diff --git a/src/server/sessions/builtinCommands.ts b/src/server/sessions/builtinCommands.ts new file mode 100644 index 0000000..0a740d0 --- /dev/null +++ b/src/server/sessions/builtinCommands.ts @@ -0,0 +1,29 @@ +import type { ClientCommand } from "../types.js"; + +export 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 function isBuiltinCommand(name: string): boolean { + return BUILTIN_COMMANDS.some((command) => command.name === name); +} diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 690e097..08b010b 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -1,4 +1,3 @@ -import crypto from "node:crypto"; import { AuthStorage, createAgentSessionFromServices, @@ -13,44 +12,13 @@ import { } from "@mariozechner/pi-coding-agent"; import type { ClientCommand, ClientCommandResult, ClientSession, ClientSessionStatus } from "../types.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; - -interface ActiveSession { - 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" }, - { 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" }, -]; +import { BUILTIN_COMMANDS } from "./builtinCommands.js"; +import { SessionCommandService } from "./sessionCommandService.js"; +import type { ActiveSession } from "./sessionRuntimeStore.js"; export class PiSessionService { private readonly active = new Map(); - private readonly pendingSelects = new Map(); + private readonly commandService: SessionCommandService; private readonly agentDir = getAgentDir(); private readonly authStorage = AuthStorage.create(); private readonly modelRegistry = ModelRegistry.create(this.authStorage); @@ -60,7 +28,13 @@ export class PiSessionService { return { ...result, services, diagnostics: services.diagnostics }; }; - constructor(private readonly events: SessionEventHub) {} + constructor(private readonly events: SessionEventHub) { + this.commandService = new SessionCommandService( + (sessionId) => this.getActive(sessionId), + (sessionId, text) => this.prompt(sessionId, text), + events, + ); + } async list(cwd: string): Promise { const sessions = await SessionManager.list(cwd); @@ -122,65 +96,11 @@ 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` }; + return this.commandService.run(sessionId, text); } 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" }; + return this.commandService.respond(sessionId, requestId, value); } async abort(sessionId: string): Promise { @@ -232,37 +152,6 @@ export class PiSessionService { this.active.set(session.sessionId, 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 { const stats = session.getSessionStats(); return { @@ -288,11 +177,6 @@ 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/sessions/sessionCommandService.ts b/src/server/sessions/sessionCommandService.ts new file mode 100644 index 0000000..0f854aa --- /dev/null +++ b/src/server/sessions/sessionCommandService.ts @@ -0,0 +1,128 @@ +import crypto from "node:crypto"; +import type { AgentSession, AgentSessionRuntime } from "@mariozechner/pi-coding-agent"; +import type { SessionEventHub } from "../realtime/sessionEventHub.js"; +import type { ClientCommandResult, ClientSession } from "../types.js"; +import { isBuiltinCommand } from "./builtinCommands.js"; +import type { ActiveSession, GetActiveSession } from "./sessionRuntimeStore.js"; + +interface PendingCommandSelect { + sessionId: string; + command: "fork"; +} + +export class SessionCommandService { + private readonly pendingSelects = new Map(); + + constructor( + private readonly getActive: GetActiveSession, + private readonly prompt: (sessionId: string, text: string) => Promise, + private readonly events: SessionEventHub, + ) {} + + async run(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 (!isBuiltinCommand(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: formatSessionStats(session) }; + if (name === "name") return this.nameSession(session, rest); + if (name === "compact") return this.compact(session, rest); + if (name === "clone") return this.clone(active); + if (name === "fork") return this.fork(active); + + return { type: "unsupported", message: `/${name} is not implemented in the web UI yet` }; + } + + async respond(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: clientSessionFromRuntime(active.runtime) }; + } + return { type: "unsupported", message: "Unsupported command response" }; + } + + private nameSession(session: AgentSession, name: string): ClientCommandResult { + if (!name) return { type: "unsupported", message: "Usage: /name " }; + session.setSessionName(name); + return { type: "done", message: `Session named ${name}` }; + } + + private compact(session: AgentSession, instructions: string): ClientCommandResult { + void session.compact(instructions || 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" }; + } + + private async clone(active: ActiveSession): Promise { + const leafId = active.runtime.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: clientSessionFromRuntime(active.runtime) }; + } + + private fork(active: ActiveSession): ClientCommandResult { + const messages = active.runtime.session.getUserMessagesForForking(); + if (!messages.length) return { type: "unsupported", message: "No user messages to fork from" }; + const requestId = crypto.randomUUID(); + this.pendingSelects.set(requestId, { sessionId: active.runtime.session.sessionId, command: "fork" }); + return { + type: "select", + requestId, + title: "Fork from message", + options: messages.map((message) => ({ value: message.entryId, label: truncate(message.text, 140) })), + }; + } + + 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); + } +} + +function 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: "", + }; +} + +function 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"); +} + +function truncate(text: string, maxLength: number): string { + const singleLine = text.replace(/\s+/g, " ").trim(); + return singleLine.length <= maxLength ? singleLine : `${singleLine.slice(0, maxLength - 1)}…`; +} diff --git a/src/server/sessions/sessionRuntimeStore.ts b/src/server/sessions/sessionRuntimeStore.ts new file mode 100644 index 0000000..c4281ae --- /dev/null +++ b/src/server/sessions/sessionRuntimeStore.ts @@ -0,0 +1,8 @@ +import type { AgentSessionRuntime } from "@mariozechner/pi-coding-agent"; + +export interface ActiveSession { + runtime: AgentSessionRuntime; + unsubscribe: () => void; +} + +export type GetActiveSession = (sessionId: string) => Promise;