Archived
Add web command execution
This commit is contained in:
@@ -50,6 +50,17 @@ export interface FileSuggestion {
|
|||||||
kind: "tracked" | "untracked" | "other";
|
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<T>(url: string, init?: RequestInit): Promise<T> {
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
@@ -73,6 +84,8 @@ export const api = {
|
|||||||
commands: (sessionId: string) => request<SlashCommand[]>(`/api/sessions/${sessionId}/commands`),
|
commands: (sessionId: string) => request<SlashCommand[]>(`/api/sessions/${sessionId}/commands`),
|
||||||
files: (cwd: string, query: string, kind?: FileSuggestion["kind"]) => request<FileSuggestion[]>(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind ? `&kind=${encodeURIComponent(kind)}` : ""}`),
|
files: (cwd: string, query: string, kind?: FileSuggestion["kind"]) => request<FileSuggestion[]>(`/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 }) }),
|
prompt: (sessionId: string, text: string) => request<{ accepted: true }>(`/api/sessions/${sessionId}/prompt`, { method: "POST", body: JSON.stringify({ text }) }),
|
||||||
|
runCommand: (sessionId: string, text: string) => request<CommandResult>(`/api/sessions/${sessionId}/commands/run`, { method: "POST", body: JSON.stringify({ text }) }),
|
||||||
|
respondToCommand: (sessionId: string, requestId: string, value: string) => request<CommandResult>(`/api/sessions/${sessionId}/commands/respond`, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
||||||
close: (sessionId: string) => request<{ closed: true }>(`/api/sessions/${sessionId}/close`, { method: "POST" }),
|
close: (sessionId: string) => request<{ closed: true }>(`/api/sessions/${sessionId}/close`, { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
import type { ChatLine } from "./components/shared";
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
@@ -10,6 +10,7 @@ export interface AppState {
|
|||||||
selectedWorkspace?: Workspace;
|
selectedWorkspace?: Workspace;
|
||||||
selectedSession?: SessionInfo;
|
selectedSession?: SessionInfo;
|
||||||
status?: SessionStatus;
|
status?: SessionStatus;
|
||||||
|
commandDialog?: Extract<CommandResult, { type: "select" }>;
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
<div class="backdrop" @mousedown=${() => this.onCancel?.()}>
|
||||||
|
<section @mousedown=${(event: MouseEvent) => event.stopPropagation()}>
|
||||||
|
<header>
|
||||||
|
<strong>${this.title}</strong>
|
||||||
|
<button @click=${() => this.onCancel?.()}>×</button>
|
||||||
|
</header>
|
||||||
|
<div class="options" @keydown=${(event: KeyboardEvent) => this.handleKeyDown(event)} tabindex="0">
|
||||||
|
${this.options.map((option, index) => html`
|
||||||
|
<button class=${index === this.selectedIndex ? "selected" : ""} @click=${() => this.onPick?.(option.value)}>
|
||||||
|
<span>${option.label}</span>
|
||||||
|
${option.description ? html`<small>${option.description}</small>` : null}
|
||||||
|
</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUpdated() {
|
||||||
|
this.renderRoot.querySelector<HTMLElement>(".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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import "./SessionList";
|
|||||||
import "./ChatView";
|
import "./ChatView";
|
||||||
import "./PromptEditor";
|
import "./PromptEditor";
|
||||||
import "./StatusBar";
|
import "./StatusBar";
|
||||||
|
import "./CommandPicker";
|
||||||
import { appStyles } from "./shared";
|
import { appStyles } from "./shared";
|
||||||
|
|
||||||
@customElement("pi-web-poc")
|
@customElement("pi-web-poc")
|
||||||
@@ -92,6 +93,7 @@ export class PiWebApp extends LitElement {
|
|||||||
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
|
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
|
||||||
<chat-view .messages=${state.messages}></chat-view>
|
<chat-view .messages=${state.messages}></chat-view>
|
||||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .onSend=${(text: string) => this.sessions.send(text)} .onCloseSession=${() => this.sessions.closeSession()}></prompt-editor>
|
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .onSend=${(text: string) => this.sessions.send(text)} .onCloseSession=${() => this.sessions.closeSession()}></prompt-editor>
|
||||||
|
${state.commandDialog ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog!.requestId, value)} .onCancel=${() => this.sessions.cancelCommand()}></command-picker>` : null}
|
||||||
` : html`<div class="empty">Select or start a session.</div>`}
|
` : html`<div class="empty">Select or start a session.</div>`}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -106,6 +106,19 @@ export const autocompleteStyles = css`
|
|||||||
small { grid-column: 1 / -1; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
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`
|
export const promptEditorStyles = css`
|
||||||
:host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; }
|
: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; }
|
footer { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; padding: 12px; border-top: 1px solid #30363d; }
|
||||||
|
|||||||
@@ -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 { appendText, normalizeMessages, textMessage } from "../chatMessages";
|
||||||
import { SessionSocket, type SessionUiEvent } from "../sessionSocket";
|
import { SessionSocket, type SessionUiEvent } from "../sessionSocket";
|
||||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
import type { GetState, SetState, UpdateUrl } from "./types";
|
||||||
@@ -41,6 +41,7 @@ export class SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async send(text: string) {
|
async send(text: string) {
|
||||||
|
if (text.trim().startsWith("/")) return this.runCommand(text);
|
||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
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() {
|
async closeSession() {
|
||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session) return;
|
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) {
|
private applyEvent(event: SessionUiEvent) {
|
||||||
const messages = this.getState().messages;
|
const messages = this.getState().messages;
|
||||||
if (event.type === "assistant.delta") {
|
if (event.type === "assistant.delta") {
|
||||||
|
|||||||
@@ -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) => {
|
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/abort", async (request) => {
|
||||||
await sessions.abort(request.params.sessionId);
|
await sessions.abort(request.params.sessionId);
|
||||||
return { aborted: true };
|
return { aborted: true };
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import {
|
import {
|
||||||
AuthStorage,
|
AuthStorage,
|
||||||
createAgentSession,
|
createAgentSessionFromServices,
|
||||||
|
createAgentSessionRuntime,
|
||||||
|
createAgentSessionServices,
|
||||||
|
getAgentDir,
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type AgentSession,
|
type AgentSession,
|
||||||
|
type AgentSessionRuntime,
|
||||||
|
type CreateAgentSessionRuntimeFactory,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} 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";
|
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
|
|
||||||
interface ActiveSession {
|
interface ActiveSession {
|
||||||
session: AgentSession;
|
runtime: AgentSessionRuntime;
|
||||||
unsubscribe: () => void;
|
unsubscribe: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingCommandSelect {
|
||||||
|
sessionId: string;
|
||||||
|
command: "fork";
|
||||||
|
}
|
||||||
|
|
||||||
const BUILTIN_COMMANDS: ClientCommand[] = [
|
const BUILTIN_COMMANDS: ClientCommand[] = [
|
||||||
{ name: "settings", description: "Open settings menu", source: "builtin" },
|
{ name: "settings", description: "Open settings menu", source: "builtin" },
|
||||||
{ name: "model", description: "Select model", source: "builtin" },
|
{ name: "model", description: "Select model", source: "builtin" },
|
||||||
@@ -39,8 +50,15 @@ const BUILTIN_COMMANDS: ClientCommand[] = [
|
|||||||
|
|
||||||
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 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);
|
||||||
|
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) {}
|
constructor(private readonly events: SessionEventHub) {}
|
||||||
|
|
||||||
@@ -59,7 +77,8 @@ export class PiSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(cwd: string): Promise<ClientSession> {
|
async start(cwd: string): Promise<ClientSession> {
|
||||||
const { session } = await this.create(SessionManager.create(cwd), cwd);
|
const active = await this.create(SessionManager.create(cwd), cwd);
|
||||||
|
const { session } = active.runtime;
|
||||||
return {
|
return {
|
||||||
id: session.sessionId,
|
id: session.sessionId,
|
||||||
path: session.sessionFile ?? "",
|
path: session.sessionFile ?? "",
|
||||||
@@ -102,45 +121,146 @@ export class PiSessionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async runCommand(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 (!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> {
|
||||||
|
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<void> {
|
async abort(sessionId: string): Promise<void> {
|
||||||
const active = this.active.get(sessionId);
|
const active = this.active.get(sessionId);
|
||||||
if (active) await active.session.abort();
|
if (active) await active.runtime.session.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
close(sessionId: string): void {
|
close(sessionId: string): void {
|
||||||
const active = this.active.get(sessionId);
|
const active = this.active.get(sessionId);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
active.unsubscribe();
|
active.unsubscribe();
|
||||||
active.session.dispose();
|
void active.runtime.dispose();
|
||||||
this.active.delete(sessionId);
|
this.active.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrOpen(sessionId: string): Promise<AgentSession> {
|
private async getOrOpen(sessionId: string): Promise<AgentSession> {
|
||||||
|
return (await this.getActive(sessionId)).runtime.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getActive(sessionId: string): Promise<ActiveSession> {
|
||||||
const active = this.active.get(sessionId);
|
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));
|
const match = (await SessionManager.listAll()).find((s) => s.id === sessionId || s.id.startsWith(sessionId));
|
||||||
if (!match) throw new Error("Session not found");
|
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<ActiveSession> {
|
private async create(sessionManager: SessionManager, cwd: string): Promise<ActiveSession> {
|
||||||
const { session } = await createAgentSession({
|
const runtime = await createAgentSessionRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
|
||||||
cwd,
|
const active: ActiveSession = { runtime, unsubscribe: () => {} };
|
||||||
sessionManager,
|
this.bindRuntime(active);
|
||||||
authStorage: this.authStorage,
|
runtime.setRebindSession(async () => this.bindRuntime(active));
|
||||||
modelRegistry: this.modelRegistry,
|
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, toClientEvent(event));
|
||||||
this.events.publish(session.sessionId, { type: "status.update", status: this.statusFromSession(session) });
|
this.events.publish(session.sessionId, { type: "status.update", status: this.statusFromSession(session) });
|
||||||
});
|
});
|
||||||
|
|
||||||
const active = { session, unsubscribe };
|
|
||||||
this.active.set(session.sessionId, active);
|
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 {
|
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 {
|
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 };
|
||||||
|
|||||||
@@ -49,3 +49,14 @@ export interface ClientFileSuggestion {
|
|||||||
path: string;
|
path: string;
|
||||||
kind: "tracked" | "untracked" | "other";
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user