diff --git a/src/client/src/api.ts b/src/client/src/api.ts index f1fa18b..72f6e7d 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -50,6 +50,17 @@ export interface FileSuggestion { kind: "tracked" | "untracked" | "other"; } +export interface CommandOption { + value: string; + label: string; + description?: string; +} + +export type CommandResult = + | { type: "done"; message?: string; session?: SessionInfo } + | { type: "select"; requestId: string; title: string; options: CommandOption[] } + | { type: "unsupported"; message: string }; + async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, @@ -73,6 +84,8 @@ export const api = { commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`), files: (cwd: string, query: string, kind?: FileSuggestion["kind"]) => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind ? `&kind=${encodeURIComponent(kind)}` : ""}`), prompt: (sessionId: string, text: string) => request<{ accepted: true }>(`/api/sessions/${sessionId}/prompt`, { method: "POST", body: JSON.stringify({ text }) }), + runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, { method: "POST", body: JSON.stringify({ text }) }), + respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, { method: "POST", body: JSON.stringify({ requestId, value }) }), close: (sessionId: string) => request<{ closed: true }>(`/api/sessions/${sessionId}/close`, { method: "POST" }), }; diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index bf73abb..7b8e9fc 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -1,4 +1,4 @@ -import type { Project, SessionInfo, SessionStatus, Workspace } from "./api"; +import type { CommandResult, Project, SessionInfo, SessionStatus, Workspace } from "./api"; import type { ChatLine } from "./components/shared"; export interface AppState { @@ -10,6 +10,7 @@ export interface AppState { selectedWorkspace?: Workspace; selectedSession?: SessionInfo; status?: SessionStatus; + commandDialog?: Extract; error: string; } diff --git a/src/client/src/components/CommandPicker.ts b/src/client/src/components/CommandPicker.ts new file mode 100644 index 0000000..679ecc9 --- /dev/null +++ b/src/client/src/components/CommandPicker.ts @@ -0,0 +1,57 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { CommandOption } from "../api"; +import { commandPickerStyles } from "./shared"; + +@customElement("command-picker") +export class CommandPicker extends LitElement { + @property() title = "Select"; + @property({ attribute: false }) options: CommandOption[] = []; + @property({ attribute: false }) onPick?: (value: string) => void; + @property({ attribute: false }) onCancel?: () => void; + @state() private selectedIndex = 0; + + render() { + return html` +
this.onCancel?.()}> +
event.stopPropagation()}> +
+ ${this.title} + +
+
this.handleKeyDown(event)} tabindex="0"> + ${this.options.map((option, index) => html` + + `)} +
+
+
+ `; + } + + firstUpdated() { + this.renderRoot.querySelector(".options")?.focus(); + } + + private handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + this.onCancel?.(); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + this.selectedIndex = (this.selectedIndex + 1) % this.options.length; + } else if (event.key === "ArrowUp") { + event.preventDefault(); + this.selectedIndex = (this.selectedIndex - 1 + this.options.length) % this.options.length; + } else if (event.key === "Enter") { + event.preventDefault(); + const option = this.options[this.selectedIndex]; + if (option) this.onPick?.(option.value); + } + } + + static styles = commandPickerStyles; +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 1740763..0aebb00 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -12,6 +12,7 @@ import "./SessionList"; import "./ChatView"; import "./PromptEditor"; import "./StatusBar"; +import "./CommandPicker"; import { appStyles } from "./shared"; @customElement("pi-web-poc") @@ -92,6 +93,7 @@ export class PiWebApp extends LitElement { this.sessions.send(text)} .onCloseSession=${() => this.sessions.closeSession()}> + ${state.commandDialog ? html` this.sessions.respondToCommand(state.commandDialog!.requestId, value)} .onCancel=${() => this.sessions.cancelCommand()}>` : null} ` : html`
Select or start a session.
`} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index d40064d..fc91e67 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -106,6 +106,19 @@ export const autocompleteStyles = css` small { grid-column: 1 / -1; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } `; +export const commandPickerStyles = css` + :host { position: fixed; inset: 0; z-index: 10; color: #e6edf3; font: 14px system-ui, sans-serif; } + .backdrop { display: grid; place-items: center; width: 100%; height: 100%; background: #0008; } + section { width: min(720px, calc(100vw - 40px)); max-height: min(640px, calc(100vh - 40px)); display: flex; flex-direction: column; border: 1px solid #30363d; border-radius: 12px; background: #0d1117; box-shadow: 0 20px 60px #000b; overflow: hidden; } + header { display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid #30363d; } + .options { min-height: 0; overflow: auto; outline: none; } + button { border: 0; background: transparent; color: #e6edf3; cursor: pointer; } + header button { font-size: 20px; color: #8b949e; } + .options button { display: block; width: 100%; padding: 10px 12px; border-bottom: 1px solid #21262d; text-align: left; } + .options button.selected, .options button:hover { background: #0d2847; } + small { display: block; margin-top: 4px; color: #8b949e; } +`; + export const promptEditorStyles = css` :host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; } footer { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; padding: 12px; border-top: 1px solid #30363d; } diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 9f89878..d9f6f26 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -1,4 +1,4 @@ -import { api, type SessionInfo } from "../api"; +import { api, type CommandResult, type SessionInfo } from "../api"; import { appendText, normalizeMessages, textMessage } from "../chatMessages"; import { SessionSocket, type SessionUiEvent } from "../sessionSocket"; import type { GetState, SetState, UpdateUrl } from "./types"; @@ -41,6 +41,7 @@ export class SessionController { } async send(text: string) { + if (text.trim().startsWith("/")) return this.runCommand(text); const session = this.getState().selectedSession; if (!session) return; this.setState({ messages: [...this.getState().messages, textMessage("user", text)] }); @@ -51,6 +52,31 @@ export class SessionController { } } + async runCommand(text: string) { + const session = this.getState().selectedSession; + if (!session) return; + try { + this.applyCommandResult(await api.runCommand(session.id, text)); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + async respondToCommand(requestId: string, value: string) { + const session = this.getState().selectedSession; + if (!session) return; + this.setState({ commandDialog: undefined }); + try { + this.applyCommandResult(await api.respondToCommand(session.id, requestId, value)); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + cancelCommand() { + this.setState({ commandDialog: undefined }); + } + async closeSession() { const session = this.getState().selectedSession; if (!session) return; @@ -64,6 +90,20 @@ export class SessionController { } } + private applyCommandResult(result: CommandResult) { + if (result.type === "select") { + this.setState({ commandDialog: result }); + return; + } + const message = result.type === "unsupported" ? result.message : result.message; + if (message) this.setState({ messages: [...this.getState().messages, textMessage(result.type === "unsupported" ? "system" : "tool", message)] }); + if (result.type === "done" && result.session) { + const sessions = [result.session, ...this.getState().sessions.filter((session) => session.id !== result.session?.id)]; + this.setState({ sessions }); + void this.selectSession(result.session); + } + } + private applyEvent(event: SessionUiEvent) { const messages = this.getState().messages; if (event.type === "assistant.delta") { diff --git a/src/server/index.ts b/src/server/index.ts index 4837d02..11c5b05 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -83,6 +83,22 @@ app.post<{ Params: { sessionId: string }; Body: { text: string } }>("/api/sessio } }); +app.post<{ Params: { sessionId: string }; Body: { text: string } }>("/api/sessions/:sessionId/commands/run", async (request, reply) => { + try { + return await sessions.runCommand(request.params.sessionId, request.body.text); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } +}); + +app.post<{ Params: { sessionId: string }; Body: { requestId: string; value: string } }>("/api/sessions/:sessionId/commands/respond", async (request, reply) => { + try { + return await sessions.respondToCommand(request.params.sessionId, request.body.requestId, request.body.value); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } +}); + app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/abort", async (request) => { await sessions.abort(request.params.sessionId); return { aborted: true }; diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 5395389..690e097 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -1,18 +1,29 @@ +import crypto from "node:crypto"; import { AuthStorage, - createAgentSession, + createAgentSessionFromServices, + createAgentSessionRuntime, + createAgentSessionServices, + getAgentDir, ModelRegistry, SessionManager, type AgentSession, + type AgentSessionRuntime, + type CreateAgentSessionRuntimeFactory, } from "@mariozechner/pi-coding-agent"; -import type { ClientCommand, ClientSession, ClientSessionStatus } from "../types.js"; +import type { ClientCommand, ClientCommandResult, ClientSession, ClientSessionStatus } from "../types.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; interface ActiveSession { - session: AgentSession; + runtime: AgentSessionRuntime; unsubscribe: () => void; } +interface PendingCommandSelect { + sessionId: string; + command: "fork"; +} + const BUILTIN_COMMANDS: ClientCommand[] = [ { name: "settings", description: "Open settings menu", source: "builtin" }, { name: "model", description: "Select model", source: "builtin" }, @@ -39,8 +50,15 @@ const BUILTIN_COMMANDS: ClientCommand[] = [ export class PiSessionService { private readonly active = new Map(); + private readonly pendingSelects = new Map(); + private readonly agentDir = getAgentDir(); private readonly authStorage = AuthStorage.create(); private readonly modelRegistry = ModelRegistry.create(this.authStorage); + private readonly createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => { + const services = await createAgentSessionServices({ cwd, agentDir, authStorage: this.authStorage, modelRegistry: this.modelRegistry }); + const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent }); + return { ...result, services, diagnostics: services.diagnostics }; + }; constructor(private readonly events: SessionEventHub) {} @@ -59,7 +77,8 @@ export class PiSessionService { } async start(cwd: string): Promise { - const { session } = await this.create(SessionManager.create(cwd), cwd); + const active = await this.create(SessionManager.create(cwd), cwd); + const { session } = active.runtime; return { id: session.sessionId, path: session.sessionFile ?? "", @@ -102,45 +121,146 @@ export class PiSessionService { }); } + async runCommand(sessionId: string, text: string): Promise { + const active = await this.getActive(sessionId); + const session = active.runtime.session; + const [name = "", ...args] = text.trim().replace(/^\//, "").split(/\s+/); + const rest = args.join(" ").trim(); + + if (!BUILTIN_COMMANDS.some((command) => command.name === name)) { + if (this.isRuntimeCommand(session, name)) { + await this.prompt(sessionId, text); + return { type: "done", message: `Accepted ${text}` }; + } + return { type: "unsupported", message: `Unknown command: /${name}` }; + } + + if (name === "session") return { type: "done", message: this.formatSessionStats(session) }; + if (name === "name") { + if (!rest) return { type: "unsupported", message: "Usage: /name " }; + session.setSessionName(rest); + return { type: "done", message: `Session named ${rest}` }; + } + if (name === "compact") { + void session.compact(rest || undefined).catch((error) => { + this.events.publish(session.sessionId, { type: "session.error", message: error instanceof Error ? error.message : String(error) }); + }); + return { type: "done", message: "Compaction started" }; + } + if (name === "clone") { + const leafId = session.sessionManager.getLeafId(); + if (!leafId) return { type: "unsupported", message: "Cannot clone: no current session entry" }; + const result = await active.runtime.fork(leafId, { position: "at" }); + if (result.cancelled) return { type: "done", message: "Clone cancelled" }; + return { type: "done", message: "Session cloned", session: this.clientSessionFromRuntime(active.runtime) }; + } + if (name === "fork") { + const messages = session.getUserMessagesForForking(); + if (!messages.length) return { type: "unsupported", message: "No user messages to fork from" }; + const requestId = crypto.randomUUID(); + this.pendingSelects.set(requestId, { sessionId: session.sessionId, command: "fork" }); + return { + type: "select", + requestId, + title: "Fork from message", + options: messages.map((message) => ({ value: message.entryId, label: truncate(message.text, 140) })), + }; + } + + return { type: "unsupported", message: `/${name} is not implemented in the web UI yet` }; + } + + async respondToCommand(sessionId: string, requestId: string, value: string): Promise { + const pending = this.pendingSelects.get(requestId); + if (!pending || pending.sessionId !== sessionId) return { type: "unsupported", message: "Command request expired" }; + this.pendingSelects.delete(requestId); + const active = await this.getActive(sessionId); + if (pending.command === "fork") { + const result = await active.runtime.fork(value); + if (result.cancelled) return { type: "done", message: "Fork cancelled" }; + return { type: "done", message: "Session forked", session: this.clientSessionFromRuntime(active.runtime) }; + } + return { type: "unsupported", message: "Unsupported command response" }; + } + async abort(sessionId: string): Promise { const active = this.active.get(sessionId); - if (active) await active.session.abort(); + if (active) await active.runtime.session.abort(); } close(sessionId: string): void { const active = this.active.get(sessionId); if (!active) return; active.unsubscribe(); - active.session.dispose(); + void active.runtime.dispose(); this.active.delete(sessionId); } private async getOrOpen(sessionId: string): Promise { + return (await this.getActive(sessionId)).runtime.session; + } + + private async getActive(sessionId: string): Promise { const active = this.active.get(sessionId); - if (active) return active.session; + if (active) return active; const match = (await SessionManager.listAll()).find((s) => s.id === sessionId || s.id.startsWith(sessionId)); if (!match) throw new Error("Session not found"); - return (await this.create(SessionManager.open(match.path), match.cwd)).session; + return this.create(SessionManager.open(match.path), match.cwd); } private async create(sessionManager: SessionManager, cwd: string): Promise { - const { session } = await createAgentSession({ - cwd, - sessionManager, - authStorage: this.authStorage, - modelRegistry: this.modelRegistry, - }); + const runtime = await createAgentSessionRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager }); + const active: ActiveSession = { runtime, unsubscribe: () => {} }; + this.bindRuntime(active); + runtime.setRebindSession(async () => this.bindRuntime(active)); + this.active.set(runtime.session.sessionId, active); + this.events.publish(runtime.session.sessionId, { type: "status.update", status: this.statusFromSession(runtime.session) }); + return active; + } - const unsubscribe = session.subscribe((event) => { + private bindRuntime(active: ActiveSession): void { + active.unsubscribe(); + for (const [sessionId, candidate] of this.active.entries()) { + if (candidate === active) this.active.delete(sessionId); + } + const { session } = active.runtime; + active.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 isRuntimeCommand(session: AgentSession, name: string): boolean { + return session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === name) + || session.promptTemplates.some((template) => template.name === name) + || session.resourceLoader.getSkills().skills.some((skill) => `skill:${skill.name}` === name); + } + + private clientSessionFromRuntime(runtime: AgentSessionRuntime): ClientSession { + const session = runtime.session; + return { + id: session.sessionId, + path: session.sessionFile ?? "", + cwd: runtime.cwd, + name: session.sessionName, + created: new Date().toISOString(), + modified: new Date().toISOString(), + messageCount: session.messages.length, + firstMessage: "", + }; + } + + private formatSessionStats(session: AgentSession): string { + const stats = session.getSessionStats(); + return [ + `Session: ${stats.sessionId}`, + `Messages: ${stats.totalMessages} (${stats.userMessages} user, ${stats.assistantMessages} assistant)`, + `Tool calls: ${stats.toolCalls}`, + `Tokens: ↑${stats.tokens.input} ↓${stats.tokens.output} total ${stats.tokens.total}`, + `Cost: $${stats.cost.toFixed(4)}`, + ].join("\n"); } private statusFromSession(session: AgentSession): ClientSessionStatus { @@ -168,6 +288,11 @@ export class PiSessionService { } } +function truncate(text: string, maxLength: number): string { + const singleLine = text.replace(/\s+/g, " ").trim(); + return singleLine.length <= maxLength ? singleLine : `${singleLine.slice(0, maxLength - 1)}…`; +} + function toClientEvent(event: any): unknown { if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") { return { type: "assistant.delta", text: event.assistantMessageEvent.delta }; diff --git a/src/server/types.ts b/src/server/types.ts index 1cc1f85..be0d201 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -49,3 +49,14 @@ export interface ClientFileSuggestion { path: string; kind: "tracked" | "untracked" | "other"; } + +export interface ClientCommandOption { + value: string; + label: string; + description?: string; +} + +export type ClientCommandResult = + | { type: "done"; message?: string; session?: ClientSession } + | { type: "select"; requestId: string; title: string; options: ClientCommandOption[] } + | { type: "unsupported"; message: string };