Extract session command service

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 12:24:40 +02:00
parent 8c825c7384
commit 9be9d410e1
4 changed files with 178 additions and 129 deletions
+29
View File
@@ -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);
}
+13 -129
View File
@@ -1,4 +1,3 @@
import crypto from "node:crypto";
import { import {
AuthStorage, AuthStorage,
createAgentSessionFromServices, createAgentSessionFromServices,
@@ -13,44 +12,13 @@ import {
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import type { ClientCommand, ClientCommandResult, ClientSession, ClientSessionStatus } from "../types.js"; import type { ClientCommand, ClientCommandResult, ClientSession, ClientSessionStatus } from "../types.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
interface ActiveSession { import { SessionCommandService } from "./sessionCommandService.js";
runtime: AgentSessionRuntime; import type { ActiveSession } from "./sessionRuntimeStore.js";
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" },
];
export class PiSessionService { export class PiSessionService {
private readonly active = new Map<string, ActiveSession>(); private readonly active = new Map<string, ActiveSession>();
private readonly pendingSelects = new Map<string, PendingCommandSelect>(); private readonly commandService: SessionCommandService;
private readonly agentDir = getAgentDir(); private readonly agentDir = getAgentDir();
private readonly authStorage = AuthStorage.create(); private readonly authStorage = AuthStorage.create();
private readonly modelRegistry = ModelRegistry.create(this.authStorage); private readonly modelRegistry = ModelRegistry.create(this.authStorage);
@@ -60,7 +28,13 @@ export class PiSessionService {
return { ...result, services, diagnostics: services.diagnostics }; 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<ClientSession[]> { async list(cwd: string): Promise<ClientSession[]> {
const sessions = await SessionManager.list(cwd); const sessions = await SessionManager.list(cwd);
@@ -122,65 +96,11 @@ export class PiSessionService {
} }
async runCommand(sessionId: string, text: string): Promise<ClientCommandResult> { async runCommand(sessionId: string, text: string): Promise<ClientCommandResult> {
const active = await this.getActive(sessionId); return this.commandService.run(sessionId, text);
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 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<ClientCommandResult> { async respondToCommand(sessionId: string, requestId: string, value: string): Promise<ClientCommandResult> {
const pending = this.pendingSelects.get(requestId); return this.commandService.respond(sessionId, requestId, value);
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<void> { async abort(sessionId: string): Promise<void> {
@@ -232,37 +152,6 @@ export class PiSessionService {
this.active.set(session.sessionId, active); 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 { private statusFromSession(session: AgentSession): ClientSessionStatus {
const stats = session.getSessionStats(); const stats = session.getSessionStats();
return { 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 { function toClientEvent(event: any): unknown {
if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") { if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
return { type: "assistant.delta", text: event.assistantMessageEvent.delta }; return { type: "assistant.delta", text: event.assistantMessageEvent.delta };
@@ -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<string, PendingCommandSelect>();
constructor(
private readonly getActive: GetActiveSession,
private readonly prompt: (sessionId: string, text: string) => Promise<void>,
private readonly events: SessionEventHub,
) {}
async run(sessionId: string, text: string): Promise<ClientCommandResult> {
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<ClientCommandResult> {
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 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<ClientCommandResult> {
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)}`;
}
@@ -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<ActiveSession>;