Add shell execution from chat

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 14:11:20 +02:00
parent 34df785b68
commit 1bc7774ced
10 changed files with 134 additions and 6 deletions
+1
View File
@@ -92,6 +92,7 @@ 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 }) }),
shell: (sessionId: string, text: string) => request<{ accepted: true }>(`/api/sessions/${sessionId}/shell`, { method: "POST", body: JSON.stringify({ text }) }),
runCommand: (sessionId: string, text: string) => request<CommandResult>(`/api/sessions/${sessionId}/commands/run`, { 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 }) }), respondToCommand: (sessionId: string, requestId: string, value: string) => request<CommandResult>(`/api/sessions/${sessionId}/commands/respond`, { method: "POST", body: JSON.stringify({ requestId, value }) }),
stop: (sessionId: string) => request<{ stopped: true }>(`/api/sessions/${sessionId}/stop`, { method: "POST" }), stop: (sessionId: string) => request<{ stopped: true }>(`/api/sessions/${sessionId}/stop`, { method: "POST" }),
+12
View File
@@ -21,6 +21,7 @@ export function appendText(messages: ChatLine[], role: ChatLine["role"], text: s
} }
function normalizeMessage(message: any): ChatLine[] { function normalizeMessage(message: any): ChatLine[] {
if (message?.role === "bashExecution") return [normalizeBashExecution(message)];
const role = normalizeRole(message?.role); const role = normalizeRole(message?.role);
const parts = normalizeContent(message?.content, message); const parts = normalizeContent(message?.content, message);
if (role === "tool") return [{ role, parts }]; if (role === "tool") return [{ role, parts }];
@@ -29,6 +30,17 @@ function normalizeMessage(message: any): ChatLine[] {
return visible.length ? [{ role, parts: visible }] : []; return visible.length ? [{ role, parts: visible }] : [];
} }
function normalizeBashExecution(message: any): ChatLine {
const lines = [`$ ${message.command ?? ""}`];
if (message.output) lines.push("", String(message.output));
if (message.exitCode != null) lines.push("", `exit ${message.exitCode}`);
if (message.cancelled) lines.push("", "cancelled");
if (message.truncated) lines.push("", "output truncated");
if (message.fullOutputPath) lines.push("", `full output: ${message.fullOutputPath}`);
if (message.excludeFromContext) lines.push("", "excluded from context");
return { role: "bash", parts: [{ type: "text", text: lines.join("\n") }] };
}
function normalizeRole(role: unknown): ChatLine["role"] { function normalizeRole(role: unknown): ChatLine["role"] {
if (role === "assistant") return "assistant"; if (role === "assistant") return "assistant";
if (role === "user") return "user"; if (role === "user") return "user";
+1 -1
View File
@@ -95,7 +95,7 @@ export class ChatView extends LitElement {
} }
private isReadablePart(message: ChatLine, part: ChatPart): boolean { private isReadablePart(message: ChatLine, part: ChatPart): boolean {
return part.type === "text" && (message.role === "user" || message.role === "assistant" || message.role === "system"); return part.type === "text" && (message.role === "user" || message.role === "assistant" || message.role === "system" || message.role === "bash");
} }
private groupSummary(messages: ChatLine[]): string { private groupSummary(messages: ChatLine[]): string {
+22 -1
View File
@@ -26,9 +26,14 @@ export class PromptEditor extends LitElement {
this.selectedIndex = 0; this.selectedIndex = 0;
} }
protected updated(changed: PropertyValues) {
if (changed.has("draft") || changed.has("sessionId")) this.resizeTextarea();
}
render() { render() {
const shellMode = this.isShellMode();
return html` return html`
<footer> <footer class=${shellMode ? "shell-mode" : ""}>
<div class="editor-wrap"> <div class="editor-wrap">
<textarea <textarea
.value=${this.draft} .value=${this.draft}
@@ -37,6 +42,7 @@ export class PromptEditor extends LitElement {
@keydown=${(event: KeyboardEvent) => this.handleKeyDown(event)} @keydown=${(event: KeyboardEvent) => this.handleKeyDown(event)}
placeholder="Message pi... Use / for commands, @ for files" placeholder="Message pi... Use / for commands, @ for files"
></textarea> ></textarea>
${shellMode ? html`<div class="mode-hint">Shell command${this.isShellExcludedFromContext() ? " · excluded from context" : ""}</div>` : null}
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => this.pick(item)}></autocomplete-menu> <autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => this.pick(item)}></autocomplete-menu>
</div> </div>
<button ?disabled=${this.disabled} @click=${this.send}>Send</button> <button ?disabled=${this.disabled} @click=${this.send}>Send</button>
@@ -49,6 +55,21 @@ export class PromptEditor extends LitElement {
this.textarea?.focus(); this.textarea?.focus();
} }
private resizeTextarea() {
const textarea = this.textarea;
if (!textarea) return;
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
}
private isShellMode(): boolean {
return this.draft.trimStart().startsWith("!");
}
private isShellExcludedFromContext(): boolean {
return this.draft.trimStart().startsWith("!!");
}
private updateDraft(value: string) { private updateDraft(value: string) {
this.draft = value; this.draft = value;
if (this.sessionId) saveDraft(this.sessionId, this.draft); if (this.sessionId) saveDraft(this.sessionId, this.draft);
+7 -2
View File
@@ -8,7 +8,7 @@ export type ChatPart =
| { type: "empty" }; | { type: "empty" };
export interface ChatLine { export interface ChatLine {
role: "user" | "assistant" | "tool" | "system"; role: "user" | "assistant" | "tool" | "system" | "bash";
parts: ChatPart[]; parts: ChatPart[];
} }
@@ -55,6 +55,7 @@ export const chatStyles = css`
.msg.user { border-color: #2f81f7; background: #0d2847; } .msg.user { border-color: #2f81f7; background: #0d2847; }
.msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; } .msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; }
.msg.system { color: #ff7b72; } .msg.system { color: #ff7b72; }
.msg.bash { border-color: #3fb950; background: #0f1b12; }
.msg.event-group { padding: 0; border-color: #30363d; background: #0d1117; color: #8b949e; } .msg.event-group { padding: 0; border-color: #30363d; background: #0d1117; color: #8b949e; }
.msg.event-group > summary { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: #8b949e; } .msg.event-group > summary { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: #8b949e; }
.msg.event-group > summary .label { margin: 0; } .msg.event-group > summary .label { margin: 0; }
@@ -62,6 +63,7 @@ export const chatStyles = css`
.group-msg { padding: 10px 0; border-top: 1px solid #21262d; color: #e6edf3; } .group-msg { padding: 10px 0; border-top: 1px solid #21262d; color: #e6edf3; }
.group-msg.tool { color: #d29922; } .group-msg.tool { color: #d29922; }
.group-msg.system { color: #ff7b72; } .group-msg.system { color: #ff7b72; }
.group-msg.bash { color: #3fb950; }
.label { display: block; margin-bottom: 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; } .label { display: block; margin-bottom: 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; }
formatted-text.part { display: block; } formatted-text.part { display: block; }
.part + .part { margin-top: 10px; } .part + .part { margin-top: 10px; }
@@ -134,8 +136,11 @@ export const commandPickerStyles = css`
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; }
footer.shell-mode { border-top-color: #3fb950; background: #0f1b12; }
.editor-wrap { position: relative; min-width: 0; } .editor-wrap { position: relative; min-width: 0; }
textarea { box-sizing: border-box; width: 100%; min-height: 54px; resize: vertical; border-radius: 8px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; padding: 8px; } textarea { box-sizing: border-box; width: 100%; min-height: 54px; max-height: 220px; resize: none; overflow-y: auto; border-radius: 8px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; padding: 8px; }
.shell-mode textarea { border-color: #3fb950; box-shadow: 0 0 0 1px #3fb95055; }
.mode-hint { position: absolute; right: 8px; bottom: 8px; max-width: calc(100% - 16px); border: 1px solid #238636; border-radius: 999px; background: #0f2a16; color: #3fb950; padding: 2px 8px; font-size: 12px; pointer-events: none; }
button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; }
button:disabled, textarea:disabled { opacity: .5; cursor: not-allowed; } button:disabled, textarea:disabled { opacity: .5; cursor: not-allowed; }
`; `;
@@ -56,7 +56,9 @@ export class SessionController {
} }
async send(text: string) { async send(text: string) {
if (text.trim().startsWith("/")) return this.runCommand(text); const trimmed = text.trim();
if (trimmed.startsWith("/")) return this.runCommand(text);
if (trimmed.startsWith("!")) return this.runShell(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)] });
@@ -67,6 +69,17 @@ export class SessionController {
} }
} }
async runShell(text: string) {
const session = this.getState().selectedSession;
if (!session) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try {
await api.shell(session.id, text);
} catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
}
}
async runCommand(text: string) { async runCommand(text: string) {
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session) return; if (!session) return;
@@ -143,6 +156,12 @@ export class SessionController {
this.setState({ messages: appendPart(messages, "assistant", { type: "toolCall", toolName: event.toolName, summary: event.summary }) }); this.setState({ messages: appendPart(messages, "assistant", { type: "toolCall", toolName: event.toolName, summary: event.summary }) });
} else if (event.type === "tool.end") { } else if (event.type === "tool.end") {
this.setState({ messages: [...messages, { role: "tool", parts: [{ type: "toolResult", toolName: event.toolName, text: event.text, isError: event.isError }] }] }); this.setState({ messages: [...messages, { role: "tool", parts: [{ type: "toolResult", toolName: event.toolName, text: event.text, isError: event.isError }] }] });
} else if (event.type === "shell.start") {
this.setState({ messages: [...messages, textMessage("bash", `$ ${event.command}${event.excludeFromContext ? "\n\nexcluded from context" : ""}`)] });
} else if (event.type === "shell.chunk") {
this.setState({ messages: appendShellChunk(messages, event.chunk) });
} else if (event.type === "shell.end") {
this.setState({ messages: finalizeShellMessage(messages, event) });
} else if (event.type === "status.update") { } else if (event.type === "status.update") {
this.applyStatus(event.status); this.applyStatus(event.status);
} else if (event.type === "activity.update") { } else if (event.type === "activity.update") {
@@ -160,3 +179,26 @@ function appendPart(messages: ChatLine[], role: ChatLine["role"], part: ChatPart
if (last?.role === role) return [...messages.slice(0, -1), { ...last, parts: [...last.parts, part] }]; if (last?.role === role) return [...messages.slice(0, -1), { ...last, parts: [...last.parts, part] }];
return [...messages, { role, parts: [part] }]; return [...messages, { role, parts: [part] }];
} }
function appendShellChunk(messages: ChatLine[], chunk: string): ChatLine[] {
const last = messages.at(-1);
const lastPart = last?.parts.at(-1);
if (last?.role !== "bash" || lastPart?.type !== "text") return [...messages, textMessage("bash", chunk)];
const separator = lastPart.text.includes("\n\n") ? "" : "\n\n";
return [...messages.slice(0, -1), { ...last, parts: [...last.parts.slice(0, -1), { ...lastPart, text: lastPart.text + separator + chunk }] }];
}
function finalizeShellMessage(messages: ChatLine[], event: Extract<SessionUiEvent, { type: "shell.end" }>): ChatLine[] {
const last = messages.at(-1);
const lastPart = last?.parts.at(-1);
if (last?.role !== "bash" || lastPart?.type !== "text") return messages;
const notes: string[] = [];
if (!lastPart.text.includes("\n\n") && !event.output) notes.push("(no output)");
if (event.isError) notes.push(event.output ?? "Bash command failed");
if (event.exitCode != null) notes.push(`exit ${event.exitCode}`);
if (event.cancelled) notes.push("cancelled");
if (event.truncated) notes.push("output truncated");
if (event.fullOutputPath) notes.push(`full output: ${event.fullOutputPath}`);
if (!notes.length) return messages;
return [...messages.slice(0, -1), { ...last, parts: [...last.parts.slice(0, -1), { ...lastPart, text: `${lastPart.text}\n\n${notes.join("\n")}` }] }];
}
+4 -1
View File
@@ -4,6 +4,9 @@ export type SessionUiEvent =
| { type: "assistant.delta"; text: string } | { type: "assistant.delta"; text: string }
| { type: "tool.start"; toolName: string; summary: string } | { type: "tool.start"; toolName: string; summary: string }
| { type: "tool.end"; toolName: string; text: string; isError: boolean } | { type: "tool.end"; toolName: string; text: string; isError: boolean }
| { type: "shell.start"; command: string; excludeFromContext?: boolean }
| { type: "shell.chunk"; chunk: string }
| { type: "shell.end"; output?: string; exitCode?: number | null; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string; isError?: boolean }
| { type: "status.update"; status: SessionStatus } | { type: "status.update"; status: SessionStatus }
| { type: "activity.update"; activity: SessionActivity } | { type: "activity.update"; activity: SessionActivity }
| { type: "command.output"; level: "info" | "success" | "error"; message: string } | { type: "command.output"; level: "info" | "success" | "error"; message: string }
@@ -119,7 +122,7 @@ export class GlobalSessionSocket {
} }
function isSessionUiEvent(event: any): event is SessionUiEvent { function isSessionUiEvent(event: any): event is SessionUiEvent {
return ["assistant.delta", "tool.start", "tool.end", "status.update", "activity.update", "command.output", "session.error"].includes(event?.type); return ["assistant.delta", "tool.start", "tool.end", "shell.start", "shell.chunk", "shell.end", "status.update", "activity.update", "command.output", "session.error"].includes(event?.type);
} }
function isGlobalSessionEvent(event: unknown): event is Extract<SessionUiEvent, { type: "status.update" | "activity.update" }> { function isGlobalSessionEvent(event: unknown): event is Extract<SessionUiEvent, { type: "status.update" | "activity.update" }> {
@@ -20,6 +20,7 @@ export async function registerSessionProxyRoutes(app: FastifyInstance, daemon =
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/status", (request, reply) => proxy(request, reply)); app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/status", (request, reply) => proxy(request, reply));
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/commands", (request, reply) => proxy(request, reply)); app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/commands", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string }; Body: { text: string } }>("/api/sessions/:sessionId/prompt", (request, reply) => proxy(request, reply)); app.post<{ Params: { sessionId: string }; Body: { text: string } }>("/api/sessions/:sessionId/prompt", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string }; Body: { text: string } }>("/api/sessions/:sessionId/shell", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string }; Body: { text: string } }>("/api/sessions/:sessionId/commands/run", (request, reply) => proxy(request, reply)); app.post<{ Params: { sessionId: string }; Body: { text: string } }>("/api/sessions/:sessionId/commands/run", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string }; Body: { requestId: string; value: string } }>("/api/sessions/:sessionId/commands/respond", (request, reply) => proxy(request, reply)); app.post<{ Params: { sessionId: string }; Body: { requestId: string; value: string } }>("/api/sessions/:sessionId/commands/respond", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/abort", (request, reply) => proxy(request, reply)); app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/abort", (request, reply) => proxy(request, reply));
+34
View File
@@ -101,6 +101,40 @@ export class PiSessionService {
}); });
} }
async shell(sessionId: string, text: string): Promise<void> {
const active = await this.getActive(sessionId);
const { session } = active.runtime;
const isExcluded = text.startsWith("!!");
const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
if (!command) throw new Error("Usage: !<shell command>");
if (session.isBashRunning) throw new Error("A bash command is already running");
this.publishActivity(session, "running bash", "active", command);
this.events.publish(session.sessionId, { type: "shell.start", command, excludeFromContext: isExcluded });
void session.executeBash(command, (chunk) => {
this.events.publish(session.sessionId, { type: "shell.chunk", chunk });
this.publishActivity(session, "running bash", "active", command);
this.publishStatus(session);
}, { excludeFromContext: isExcluded }).then((result) => {
this.events.publish(session.sessionId, {
type: "shell.end",
output: result.output,
exitCode: result.exitCode,
cancelled: result.cancelled,
truncated: result.truncated,
fullOutputPath: result.fullOutputPath,
});
this.publishActivity(session, "bash complete", result.exitCode === 0 ? "idle" : "error", command);
this.publishStatus(session);
}).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
this.events.publish(session.sessionId, { type: "shell.end", output: message, isError: true });
this.events.publish(session.sessionId, { type: "session.error", message });
this.publishActivity(session, "bash failed", "error", message);
this.publishStatus(session);
});
}
async runCommand(sessionId: string, text: string): Promise<ClientCommandResult> { async runCommand(sessionId: string, text: string): Promise<ClientCommandResult> {
return this.commandService.run(sessionId, text); return this.commandService.run(sessionId, text);
} }
+9
View File
@@ -49,6 +49,15 @@ export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSe
} }
}); });
app.post<{ Params: { sessionId: string }; Body: { text: string } }>(`${prefix}/sessions/:sessionId/shell`, async (request, reply) => {
try {
await sessions.shell(request.params.sessionId, request.body.text);
return { accepted: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { text: string } }>(`${prefix}/sessions/:sessionId/commands/run`, async (request, reply) => { app.post<{ Params: { sessionId: string }; Body: { text: string } }>(`${prefix}/sessions/:sessionId/commands/run`, async (request, reply) => {
try { try {
return await sessions.runCommand(request.params.sessionId, request.body.text); return await sessions.runCommand(request.params.sessionId, request.body.text);