Archived
Add prompt autocomplete and session status
This commit is contained in:
@@ -8,6 +8,7 @@ import { ProjectService } from "./projects/projectService.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { SessionEventHub } from "./realtime/sessionEventHub.js";
|
||||
import { PiSessionService } from "./sessions/piSessionService.js";
|
||||
import { listFileSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
await app.register(fastifyWebsocket);
|
||||
@@ -57,6 +58,22 @@ app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/messages",
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/status", async (request, reply) => {
|
||||
try {
|
||||
return await sessions.status(request.params.sessionId);
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/commands", async (request, reply) => {
|
||||
try {
|
||||
return await sessions.commands(request.params.sessionId);
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { text: string } }>("/api/sessions/:sessionId/prompt", async (request, reply) => {
|
||||
try {
|
||||
await sessions.prompt(request.params.sessionId, request.body.text);
|
||||
@@ -80,6 +97,15 @@ app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", {
|
||||
eventHub.add(request.params.sessionId, socket);
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other" } }>("/api/files", async (request, reply) => {
|
||||
if (!request.query.cwd) return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
try {
|
||||
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
const clientDist = join(process.cwd(), "dist", "client");
|
||||
if (existsSync(clientDist)) {
|
||||
await app.register(fastifyStatic, { root: clientDist });
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
SessionManager,
|
||||
type AgentSession,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import type { ClientSession } from "../types.js";
|
||||
import type { ClientCommand, ClientSession, ClientSessionStatus } from "../types.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
|
||||
interface ActiveSession {
|
||||
@@ -13,6 +13,30 @@ interface ActiveSession {
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
|
||||
const BUILTIN_COMMANDS: ClientCommand[] = [
|
||||
{ name: "settings", description: "Open settings menu", source: "builtin" },
|
||||
{ name: "model", description: "Select model", source: "builtin" },
|
||||
{ name: "scoped-models", description: "Enable/disable models for cycling", source: "builtin" },
|
||||
{ name: "export", description: "Export session", source: "builtin" },
|
||||
{ name: "import", description: "Import and resume a session from JSONL", source: "builtin" },
|
||||
{ name: "share", description: "Share session as a secret GitHub gist", source: "builtin" },
|
||||
{ name: "copy", description: "Copy last agent message", source: "builtin" },
|
||||
{ name: "name", description: "Set session display name", source: "builtin" },
|
||||
{ name: "session", description: "Show session info and stats", source: "builtin" },
|
||||
{ name: "changelog", description: "Show changelog entries", source: "builtin" },
|
||||
{ name: "hotkeys", description: "Show keyboard shortcuts", source: "builtin" },
|
||||
{ name: "fork", description: "Create a new fork from a previous user message", source: "builtin" },
|
||||
{ name: "clone", description: "Duplicate current session at current position", source: "builtin" },
|
||||
{ name: "tree", description: "Navigate session tree", source: "builtin" },
|
||||
{ name: "login", description: "Configure provider authentication", source: "builtin" },
|
||||
{ name: "logout", description: "Remove provider authentication", source: "builtin" },
|
||||
{ name: "new", description: "Start a new session", source: "builtin" },
|
||||
{ name: "compact", description: "Manually compact session context", source: "builtin" },
|
||||
{ name: "resume", description: "Resume a different session", source: "builtin" },
|
||||
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes", source: "builtin" },
|
||||
{ name: "quit", description: "Quit pi", source: "builtin" },
|
||||
];
|
||||
|
||||
export class PiSessionService {
|
||||
private readonly active = new Map<string, ActiveSession>();
|
||||
private readonly authStorage = AuthStorage.create();
|
||||
@@ -52,6 +76,25 @@ export class PiSessionService {
|
||||
return session.messages;
|
||||
}
|
||||
|
||||
async status(sessionId: string): Promise<ClientSessionStatus> {
|
||||
return this.statusFromSession(await this.getOrOpen(sessionId));
|
||||
}
|
||||
|
||||
async commands(sessionId: string): Promise<ClientCommand[]> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
const commands: ClientCommand[] = [...BUILTIN_COMMANDS];
|
||||
for (const command of session.extensionRunner.getRegisteredCommands()) {
|
||||
commands.push({ name: command.invocationName, description: command.description, source: "extension" });
|
||||
}
|
||||
for (const template of session.promptTemplates) {
|
||||
commands.push({ name: template.name, description: template.description, source: "prompt" });
|
||||
}
|
||||
for (const skill of session.resourceLoader.getSkills().skills) {
|
||||
commands.push({ name: `skill:${skill.name}`, description: skill.description, source: "skill" });
|
||||
}
|
||||
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string): Promise<void> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
void session.prompt(text).catch((error) => {
|
||||
@@ -91,12 +134,38 @@ export class PiSessionService {
|
||||
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
this.events.publish(session.sessionId, toClientEvent(event));
|
||||
this.events.publish(session.sessionId, { type: "status.update", status: this.statusFromSession(session) });
|
||||
});
|
||||
|
||||
const active = { session, unsubscribe };
|
||||
this.active.set(session.sessionId, active);
|
||||
this.events.publish(session.sessionId, { type: "status.update", status: this.statusFromSession(session) });
|
||||
return active;
|
||||
}
|
||||
|
||||
private statusFromSession(session: AgentSession): ClientSessionStatus {
|
||||
const stats = session.getSessionStats();
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
model: session.model
|
||||
? {
|
||||
provider: session.model.provider,
|
||||
id: session.model.id,
|
||||
name: (session.model as any).name,
|
||||
contextWindow: session.model.contextWindow,
|
||||
reasoning: (session.model as any).reasoning,
|
||||
}
|
||||
: undefined,
|
||||
thinkingLevel: session.thinkingLevel,
|
||||
isStreaming: session.isStreaming,
|
||||
isCompacting: session.isCompacting,
|
||||
isBashRunning: session.isBashRunning,
|
||||
pendingMessageCount: session.pendingMessageCount,
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
contextUsage: session.getContextUsage(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toClientEvent(event: any): unknown {
|
||||
|
||||
@@ -25,3 +25,27 @@ export interface ClientSession {
|
||||
messageCount: number;
|
||||
firstMessage: string;
|
||||
}
|
||||
|
||||
export interface ClientSessionStatus {
|
||||
sessionId: string;
|
||||
model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown };
|
||||
thinkingLevel?: string;
|
||||
isStreaming: boolean;
|
||||
isCompacting: boolean;
|
||||
isBashRunning: boolean;
|
||||
pendingMessageCount: number;
|
||||
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
|
||||
cost: number;
|
||||
contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null };
|
||||
}
|
||||
|
||||
export interface ClientCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
source: "extension" | "prompt" | "skill" | "builtin";
|
||||
}
|
||||
|
||||
export interface ClientFileSuggestion {
|
||||
path: string;
|
||||
kind: "tracked" | "untracked" | "other";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { ClientFileSuggestion } from "../types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function listFileSuggestions(cwd: string, query = "", kind?: ClientFileSuggestion["kind"]): Promise<ClientFileSuggestion[]> {
|
||||
const normalizedQuery = query.replace(/^@/, "").toLowerCase();
|
||||
const files = await listGitFiles(cwd).catch(() => listPlainFiles(cwd));
|
||||
return files
|
||||
.filter((file) => !kind || file.kind === kind)
|
||||
.filter((file) => !normalizedQuery || file.path.toLowerCase().includes(normalizedQuery))
|
||||
.sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path))
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
async function listGitFiles(cwd: string): Promise<ClientFileSuggestion[]> {
|
||||
const [tracked, untracked] = await Promise.all([
|
||||
git(cwd, ["ls-files"]),
|
||||
git(cwd, ["ls-files", "--others", "--exclude-standard"]),
|
||||
]);
|
||||
return [
|
||||
...withDirectories(lines(tracked), "tracked"),
|
||||
...withDirectories(lines(untracked), "untracked"),
|
||||
];
|
||||
}
|
||||
|
||||
async function listPlainFiles(cwd: string): Promise<ClientFileSuggestion[]> {
|
||||
const { stdout } = await execFileAsync("rg", ["--files"], { cwd, maxBuffer: 1024 * 1024 * 8 });
|
||||
return withDirectories(lines(stdout), "other");
|
||||
}
|
||||
|
||||
async function git(cwd: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 1024 * 1024 * 8 });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function lines(text: string): string[] {
|
||||
return text.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function withDirectories(paths: string[], kind: ClientFileSuggestion["kind"]): ClientFileSuggestion[] {
|
||||
const seen = new Set<string>();
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
for (const path of paths) {
|
||||
for (const directory of parentDirectories(path)) add(`${directory}/`);
|
||||
add(path);
|
||||
}
|
||||
return suggestions;
|
||||
|
||||
function add(path: string) {
|
||||
if (seen.has(path)) return;
|
||||
seen.add(path);
|
||||
suggestions.push({ path, kind });
|
||||
}
|
||||
}
|
||||
|
||||
function parentDirectories(path: string): string[] {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
const directories: string[] = [];
|
||||
for (let index = 1; index < parts.length; index++) {
|
||||
directories.push(parts.slice(0, index).join("/"));
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
Reference in New Issue
Block a user