diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 0247cfd38..162b25913 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -7,11 +7,10 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentMessage, ThinkingLevel } from "@gsd/pi-agent-core"; +import type { AgentMessage } from "@gsd/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, Model, OAuthProviderId } from "@gsd/pi-ai"; import type { AutocompleteItem, - EditorAction, EditorComponent, EditorTheme, KeyId, @@ -40,7 +39,6 @@ import { APP_NAME, getAuthPath, getDebugLogPath, - getShareViewerUrl, getUpdateInstruction, VERSION, } from "../../config.js"; @@ -62,10 +60,8 @@ import { type SessionContext, SessionManager } from "../../core/session-manager. import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js"; import type { TruncationResult } from "../../core/tools/truncate.js"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js"; -import { copyToClipboard } from "../../utils/clipboard.js"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js"; import { ensureTool } from "../../utils/tools-manager.js"; -import { ArminComponent } from "./components/armin.js"; import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; import { BorderedLoader } from "./components/bordered-loader.js"; @@ -86,12 +82,13 @@ import { OAuthSelectorComponent } from "./components/oauth-selector.js"; import { ProviderManagerComponent } from "./components/provider-manager.js"; import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; -import { SelectSubmenu, SettingsSelectorComponent, THINKING_DESCRIPTIONS } from "./components/settings-selector.js"; +import { SettingsSelectorComponent } from "./components/settings-selector.js"; import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.js"; import { ToolExecutionComponent } from "./components/tool-execution.js"; import { TreeSelectorComponent } from "./components/tree-selector.js"; import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; +import { type SlashCommandContext, dispatchSlashCommand, getAppKeyDisplay } from "./slash-command-handlers.js"; import { getAvailableThemes, getAvailableThemesWithPaths, @@ -1980,135 +1977,57 @@ export class InteractiveMode { } } + private getSlashCommandContext(): SlashCommandContext { + return { + session: this.session, + ui: this.ui, + keybindings: this.keybindings, + chatContainer: this.chatContainer, + statusContainer: this.statusContainer, + editorContainer: this.editorContainer, + headerContainer: this.headerContainer, + pendingMessagesContainer: this.pendingMessagesContainer, + editor: this.editor, + defaultEditor: this.defaultEditor, + sessionManager: this.sessionManager, + settingsManager: this.settingsManager, + invalidateFooter: () => this.footer.invalidate(), + showStatus: (msg) => this.showStatus(msg), + showError: (msg) => this.showError(msg), + showWarning: (msg) => this.showWarning(msg), + showSelector: (create) => this.showSelector(create), + updateEditorBorderColor: () => this.updateEditorBorderColor(), + getMarkdownThemeWithSettings: () => this.getMarkdownThemeWithSettings(), + requestRender: () => this.ui.requestRender(), + updateTerminalTitle: () => this.updateTerminalTitle(), + showSettingsSelector: () => this.showSettingsSelector(), + showModelsSelector: () => this.showModelsSelector(), + handleModelCommand: (searchTerm) => this.handleModelCommand(searchTerm), + showUserMessageSelector: () => this.showUserMessageSelector(), + showTreeSelector: () => this.showTreeSelector(), + showProviderManager: () => this.showProviderManager(), + showOAuthSelector: (mode) => this.showOAuthSelector(mode), + showSessionSelector: () => this.showSessionSelector(), + handleClearCommand: () => this.handleClearCommand(), + handleReloadCommand: () => this.handleReloadCommand(), + handleDebugCommand: () => this.handleDebugCommand(), + shutdown: () => this.shutdown(), + executeCompaction: (instructions, isAuto) => this.executeCompaction(instructions, isAuto), + }; + } + private setupEditorSubmitHandler(): void { this.defaultEditor.onSubmit = async (text: string) => { text = text.trim(); if (!text) return; - // Handle commands - if (text === "/settings") { - this.showSettingsSelector(); - this.editor.setText(""); - return; - } - if (text === "/scoped-models") { - this.editor.setText(""); - await this.showModelsSelector(); - return; - } - if (text === "/model" || text.startsWith("/model ")) { - const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined; - this.editor.setText(""); - await this.handleModelCommand(searchTerm); - return; - } - if (text.startsWith("/export")) { - await this.handleExportCommand(text); - this.editor.setText(""); - return; - } - if (text === "/share") { - await this.handleShareCommand(); - this.editor.setText(""); - return; - } - if (text === "/copy") { - this.handleCopyCommand(); - this.editor.setText(""); - return; - } - if (text === "/name" || text.startsWith("/name ")) { - this.handleNameCommand(text); - this.editor.setText(""); - return; - } - if (text === "/session") { - this.handleSessionCommand(); - this.editor.setText(""); - return; - } - if (text === "/changelog") { - this.handleChangelogCommand(); - this.editor.setText(""); - return; - } - if (text === "/hotkeys") { - this.handleHotkeysCommand(); - this.editor.setText(""); - return; - } - if (text === "/fork") { - this.showUserMessageSelector(); - this.editor.setText(""); - return; - } - if (text === "/tree") { - this.showTreeSelector(); - this.editor.setText(""); - return; - } - if (text === "/provider") { - this.showProviderManager(); - this.editor.setText(""); - return; - } - if (text === "/login") { - this.showOAuthSelector("login"); - this.editor.setText(""); - return; - } - if (text === "/logout") { - this.showOAuthSelector("logout"); - this.editor.setText(""); - return; - } - if (text === "/new") { - this.editor.setText(""); - await this.handleClearCommand(); - return; - } - if (text === "/compact" || text.startsWith("/compact ")) { - const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined; - this.editor.setText(""); - await this.handleCompactCommand(customInstructions); - return; - } - if (text === "/reload") { - this.editor.setText(""); - await this.handleReloadCommand(); - return; - } - if (text === "/thinking" || text.startsWith("/thinking ")) { - const arg = text.startsWith("/thinking ") ? text.slice(10).trim() : undefined; - this.editor.setText(""); - this.handleThinkingCommand(arg); - return; - } - if (text === "/edit-mode" || text.startsWith("/edit-mode ")) { - const arg = text.startsWith("/edit-mode ") ? text.slice(11).trim() : undefined; - this.editor.setText(""); - this.handleEditModeCommand(arg); - return; - } - if (text === "/debug") { - this.handleDebugCommand(); - this.editor.setText(""); - return; - } - if (text === "/arminsayshi") { - this.handleArminSaysHi(); - this.editor.setText(""); - return; - } - if (text === "/resume") { - this.showSessionSelector(); - this.editor.setText(""); - return; - } - if (text === "/quit") { - this.editor.setText(""); - await this.shutdown(); - return; + // Handle slash commands + if (text.startsWith("/")) { + const handled = await dispatchSlashCommand(text, this.getSlashCommandContext()); + if (handled) { + this.editor.setText(""); + return; + } } // Handle bash command (! for normal, !! for excluded from context) @@ -2897,78 +2816,6 @@ export class InteractiveMode { } } - private handleThinkingCommand(arg?: string): void { - if (!this.session.supportsThinking()) { - this.showStatus("Current model does not support thinking"); - return; - } - - const availableLevels = this.session.getAvailableThinkingLevels(); - - if (arg) { - const level = arg.toLowerCase(); - if (!availableLevels.includes(level as ThinkingLevel)) { - this.showStatus(`Invalid thinking level "${arg}". Available: ${availableLevels.join(", ")}`); - return; - } - this.session.setThinkingLevel(level as ThinkingLevel); - this.footer.invalidate(); - this.updateEditorBorderColor(); - this.showStatus(`Thinking level: ${level}`); - return; - } - - this.showThinkingSelector(); - } - - private handleEditModeCommand(arg?: string): void { - const modes = ["standard", "hashline"] as const; - - if (arg) { - const mode = arg.toLowerCase(); - if (!modes.includes(mode as typeof modes[number])) { - this.showStatus(`Invalid edit mode "${arg}". Available: standard, hashline`); - return; - } - this.session.setEditMode(mode as "standard" | "hashline"); - this.showStatus(`Edit mode: ${mode}${mode === "hashline" ? " (LINE#ID anchored edits)" : " (text-match edits)"}`); - return; - } - - // Toggle - const current = this.session.editMode; - const next = current === "standard" ? "hashline" : "standard"; - this.session.setEditMode(next); - this.showStatus(`Edit mode: ${next}${next === "hashline" ? " (LINE#ID anchored edits)" : " (text-match edits)"}`); - } - - private showThinkingSelector(): void { - const availableLevels = this.session.getAvailableThinkingLevels(); - this.showSelector((done) => { - const selector = new SelectSubmenu( - "Thinking Level", - "Select reasoning depth for thinking-capable models", - availableLevels.map((level) => ({ - value: level, - label: level, - description: THINKING_DESCRIPTIONS[level], - })), - this.session.thinkingLevel, - (value) => { - this.session.setThinkingLevel(value as ThinkingLevel); - this.footer.invalidate(); - this.updateEditorBorderColor(); - done(); - this.showStatus(`Thinking level: ${value}`); - }, - () => { - done(); - }, - ); - return { component: selector, focus: selector }; - }); - } - private async cycleModel(direction: "forward" | "backward"): Promise { try { const result = await this.session.cycleModel(direction); @@ -3159,7 +3006,7 @@ export class InteractiveMode { const text = theme.fg("dim", `Follow-up: ${message}`); this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); } - const dequeueHint = this.getAppKeyDisplay("dequeue"); + const dequeueHint = getAppKeyDisplay(this.keybindings, "dequeue"); const hintText = theme.fg("dim", `↳ ${dequeueHint} to edit all queued messages`); this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); } @@ -4191,346 +4038,6 @@ export class InteractiveMode { } } - private async handleExportCommand(text: string): Promise { - const parts = text.split(/\s+/); - const outputPath = parts.length > 1 ? parts[1] : undefined; - - try { - const filePath = await this.session.exportToHtml(outputPath); - this.showStatus(`Session exported to: ${filePath}`); - } catch (error: unknown) { - this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); - } - } - - private async handleShareCommand(): Promise { - // Check if gh is available and logged in - try { - const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); - if (authResult.status !== 0) { - this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); - return; - } - } catch { - this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); - return; - } - - // Export to a temp file - const tmpFile = path.join(os.tmpdir(), "session.html"); - try { - await this.session.exportToHtml(tmpFile); - } catch (error: unknown) { - this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); - return; - } - - // Show cancellable loader, replacing the editor - const loader = new BorderedLoader(this.ui, theme, "Creating gist..."); - this.editorContainer.clear(); - this.editorContainer.addChild(loader); - this.ui.setFocus(loader); - this.ui.requestRender(); - - const restoreEditor = () => { - loader.dispose(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - try { - fs.unlinkSync(tmpFile); - } catch { - // Ignore cleanup errors - } - }; - - // Create a secret gist asynchronously - let proc: ReturnType | null = null; - - loader.onAbort = () => { - proc?.kill(); - restoreEditor(); - this.showStatus("Share cancelled"); - }; - - try { - const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { - proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); - let stdout = ""; - let stderr = ""; - proc.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - proc.stderr?.on("data", (data) => { - stderr += data.toString(); - }); - proc.on("close", (code) => resolve({ stdout, stderr, code })); - }); - - if (loader.signal.aborted) return; - - restoreEditor(); - - if (result.code !== 0) { - const errorMsg = result.stderr?.trim() || "Unknown error"; - this.showError(`Failed to create gist: ${errorMsg}`); - return; - } - - // Extract gist ID from the URL returned by gh - // gh returns something like: https://gist.github.com/username/GIST_ID - const gistUrl = result.stdout?.trim(); - const gistId = gistUrl?.split("/").pop(); - if (!gistId) { - this.showError("Failed to parse gist ID from gh output"); - return; - } - - // Create the preview URL - const previewUrl = getShareViewerUrl(gistId); - this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`); - } catch (error: unknown) { - if (!loader.signal.aborted) { - restoreEditor(); - this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); - } - } - } - - private handleCopyCommand(): void { - const text = this.session.getLastAssistantText(); - if (!text) { - this.showError("No agent messages to copy yet."); - return; - } - - try { - copyToClipboard(text); - this.showStatus("Copied last agent message to clipboard"); - } catch (error) { - this.showError(error instanceof Error ? error.message : String(error)); - } - } - - private handleNameCommand(text: string): void { - const name = text.replace(/^\/name\s*/, "").trim(); - if (!name) { - const currentName = this.sessionManager.getSessionName(); - if (currentName) { - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0)); - } else { - this.showWarning("Usage: /name "); - } - this.ui.requestRender(); - return; - } - - this.sessionManager.appendSessionInfo(name); - this.updateTerminalTitle(); - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0)); - this.ui.requestRender(); - } - - private handleSessionCommand(): void { - const stats = this.session.getSessionStats(); - const sessionName = this.sessionManager.getSessionName(); - - let info = `${theme.bold("Session Info")}\n\n`; - if (sessionName) { - info += `${theme.fg("dim", "Name:")} ${sessionName}\n`; - } - info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; - info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`; - info += `${theme.bold("Messages")}\n`; - info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`; - info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`; - info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`; - info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`; - info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`; - info += `${theme.bold("Tokens")}\n`; - info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`; - info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`; - if (stats.tokens.cacheRead > 0) { - info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`; - } - if (stats.tokens.cacheWrite > 0) { - info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`; - } - info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`; - - if (stats.cost > 0) { - info += `\n${theme.bold("Cost")}\n`; - info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`; - } - - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(info, 1, 0)); - this.ui.requestRender(); - } - - private handleChangelogCommand(): void { - const changelogPath = getChangelogPath(); - const allEntries = parseChangelog(changelogPath); - - const changelogMarkdown = - allEntries.length > 0 - ? allEntries - .reverse() - .map((e) => e.content) - .join("\n\n") - : "No changelog entries found."; - - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new DynamicBorder()); - this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, this.getMarkdownThemeWithSettings())); - this.chatContainer.addChild(new DynamicBorder()); - this.ui.requestRender(); - } - - /** - * Capitalize keybinding for display (e.g., "ctrl+c" -> "Ctrl+C"). - */ - private capitalizeKey(key: string): string { - return key - .split("/") - .map((k) => - k - .split("+") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join("+"), - ) - .join("/"); - } - - /** - * Get capitalized display string for an app keybinding action. - */ - private getAppKeyDisplay(action: AppAction): string { - return this.capitalizeKey(appKey(this.keybindings, action)); - } - - /** - * Get capitalized display string for an editor keybinding action. - */ - private getEditorKeyDisplay(action: EditorAction): string { - return this.capitalizeKey(editorKey(action)); - } - - private handleHotkeysCommand(): void { - // Navigation keybindings - const cursorWordLeft = this.getEditorKeyDisplay("cursorWordLeft"); - const cursorWordRight = this.getEditorKeyDisplay("cursorWordRight"); - const cursorLineStart = this.getEditorKeyDisplay("cursorLineStart"); - const cursorLineEnd = this.getEditorKeyDisplay("cursorLineEnd"); - const jumpForward = this.getEditorKeyDisplay("jumpForward"); - const jumpBackward = this.getEditorKeyDisplay("jumpBackward"); - const pageUp = this.getEditorKeyDisplay("pageUp"); - const pageDown = this.getEditorKeyDisplay("pageDown"); - - // Editing keybindings - const submit = this.getEditorKeyDisplay("submit"); - const newLine = this.getEditorKeyDisplay("newLine"); - const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward"); - const deleteWordForward = this.getEditorKeyDisplay("deleteWordForward"); - const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart"); - const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd"); - const yank = this.getEditorKeyDisplay("yank"); - const yankPop = this.getEditorKeyDisplay("yankPop"); - const undo = this.getEditorKeyDisplay("undo"); - const tab = this.getEditorKeyDisplay("tab"); - - // App keybindings - const interrupt = this.getAppKeyDisplay("interrupt"); - const clear = this.getAppKeyDisplay("clear"); - const exit = this.getAppKeyDisplay("exit"); - const suspend = this.getAppKeyDisplay("suspend"); - const cycleThinkingLevel = this.getAppKeyDisplay("cycleThinkingLevel"); - const cycleModelForward = this.getAppKeyDisplay("cycleModelForward"); - const selectModel = this.getAppKeyDisplay("selectModel"); - const expandTools = this.getAppKeyDisplay("expandTools"); - const toggleThinking = this.getAppKeyDisplay("toggleThinking"); - const externalEditor = this.getAppKeyDisplay("externalEditor"); - const followUp = this.getAppKeyDisplay("followUp"); - const dequeue = this.getAppKeyDisplay("dequeue"); - - let hotkeys = ` -**Navigation** -| Key | Action | -|-----|--------| -| \`Arrow keys\` | Move cursor / browse history (Up when empty) | -| \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word | -| \`${cursorLineStart}\` | Start of line | -| \`${cursorLineEnd}\` | End of line | -| \`${jumpForward}\` | Jump forward to character | -| \`${jumpBackward}\` | Jump backward to character | -| \`${pageUp}\` / \`${pageDown}\` | Scroll by page | - -**Editing** -| Key | Action | -|-----|--------| -| \`${submit}\` | Send message | -| \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} | -| \`${deleteWordBackward}\` | Delete word backwards | -| \`${deleteWordForward}\` | Delete word forwards | -| \`${deleteToLineStart}\` | Delete to start of line | -| \`${deleteToLineEnd}\` | Delete to end of line | -| \`${yank}\` | Paste the most-recently-deleted text | -| \`${yankPop}\` | Cycle through the deleted text after pasting | -| \`${undo}\` | Undo | - -**Other** -| Key | Action | -|-----|--------| -| \`${tab}\` | Path completion / accept autocomplete | -| \`${interrupt}\` | Cancel autocomplete / abort streaming | -| \`${clear}\` | Clear editor (first) / exit (second) | -| \`${exit}\` | Exit (when editor is empty) | -| \`${suspend}\` | Suspend to background | -| \`${cycleThinkingLevel}\` | Cycle thinking level | -| \`${cycleModelForward}\` | Cycle models | -| \`${selectModel}\` | Open model selector | -| \`${expandTools}\` | Toggle tool output expansion | -| \`${toggleThinking}\` | Toggle thinking block visibility | -| \`${externalEditor}\` | Edit message in external editor | -| \`${followUp}\` | Queue follow-up message | -| \`${dequeue}\` | Restore queued messages | -| \`Ctrl+V\` | Paste image from clipboard | -| \`/\` | Slash commands | -| \`!\` | Run bash command | -| \`!!\` | Run bash command (excluded from context) | -`; - - // Add extension-registered shortcuts - const extensionRunner = this.session.extensionRunner; - if (extensionRunner) { - const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig()); - if (shortcuts.size > 0) { - hotkeys += ` -**Extensions** -| Key | Action | -|-----|--------| -`; - for (const [key, shortcut] of shortcuts) { - const description = shortcut.description ?? shortcut.extensionPath; - const keyDisplay = formatKeyForDisplay(key).replace(/\b\w/g, (c) => c.toUpperCase()); - hotkeys += `| \`${keyDisplay}\` | ${description} |\n`; - } - } - } - - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new DynamicBorder()); - this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0)); - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings())); - this.chatContainer.addChild(new DynamicBorder()); - this.ui.requestRender(); - } - private async handleClearCommand(): Promise { // Stop loading animation if (this.loadingAnimation) { @@ -4589,12 +4096,6 @@ export class InteractiveMode { this.ui.requestRender(); } - private handleArminSaysHi(): void { - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new ArminComponent(this.ui)); - this.ui.requestRender(); - } - private handleDaxnuts(): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DaxnutsComponent(this.ui)); @@ -4696,18 +4197,6 @@ export class InteractiveMode { this.ui.requestRender(); } - private async handleCompactCommand(customInstructions?: string): Promise { - const entries = this.sessionManager.getEntries(); - const messageCount = entries.filter((e) => e.type === "message").length; - - if (messageCount < 2) { - this.showWarning("Nothing to compact (no messages yet)"); - return; - } - - await this.executeCompaction(customInstructions, false); - } - private async executeCompaction(customInstructions?: string, isAuto = false): Promise { // Stop loading animation if (this.loadingAnimation) { diff --git a/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts b/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts new file mode 100644 index 000000000..46a0e82b0 --- /dev/null +++ b/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts @@ -0,0 +1,653 @@ +/** + * Slash command dispatch and handler implementations extracted from InteractiveMode. + * + * The `dispatchSlashCommand` function contains the dispatch logic (routing text + * to handlers), and individual handler functions implement each command. + * + * Handlers that are also invoked from keybindings or other subsystems remain on + * InteractiveMode and are called through the `SlashCommandContext` interface. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { ThinkingLevel } from "@gsd/pi-agent-core"; +import type { + EditorAction, + EditorComponent, + MarkdownTheme, +} from "@gsd/pi-tui"; +import { + type Component, + Container, + Markdown, + Spacer, + Text, +} from "@gsd/pi-tui"; +import { spawn, spawnSync } from "child_process"; +import { + getShareViewerUrl, +} from "../../config.js"; +import type { AgentSession } from "../../core/agent-session.js"; +import type { AppAction, KeybindingsManager } from "../../core/keybindings.js"; +import type { SessionManager } from "../../core/session-manager.js"; +import type { SettingsManager } from "../../core/settings-manager.js"; +import { copyToClipboard } from "../../utils/clipboard.js"; +import { getChangelogPath, parseChangelog } from "../../utils/changelog.js"; +import { ArminComponent } from "./components/armin.js"; +import { BorderedLoader } from "./components/bordered-loader.js"; +import { DynamicBorder } from "./components/dynamic-border.js"; +import { appKey, editorKey, formatKeyForDisplay } from "./components/keybinding-hints.js"; +import { SelectSubmenu, THINKING_DESCRIPTIONS } from "./components/settings-selector.js"; +import { theme } from "./theme/theme.js"; + +import type { TUI } from "@gsd/pi-tui"; + +// --------------------------------------------------------------------------- +// Context interface — the subset of InteractiveMode needed by slash commands +// --------------------------------------------------------------------------- + +/** + * Provides slash command handlers with access to the parts of InteractiveMode + * they need without coupling them to the entire class. + */ +export interface SlashCommandContext { + // Core objects + readonly session: AgentSession; + readonly ui: TUI; + readonly keybindings: KeybindingsManager; + + // Containers + readonly chatContainer: Container; + readonly statusContainer: Container; + readonly editorContainer: Container; + readonly headerContainer: Container; + readonly pendingMessagesContainer: Container; + + // Editor + readonly editor: EditorComponent; + readonly defaultEditor: EditorComponent & { + onEscape?: () => void; + }; + + // Accessors + readonly sessionManager: SessionManager; + readonly settingsManager: SettingsManager; + + // Footer + invalidateFooter(): void; + + // UI helpers + showStatus(message: string): void; + showError(message: string): void; + showWarning(message: string): void; + showSelector(create: (done: () => void) => { component: Component; focus: Component }): void; + updateEditorBorderColor(): void; + getMarkdownThemeWithSettings(): MarkdownTheme; + requestRender(): void; + + updateTerminalTitle(): void; + + // Methods that stay on InteractiveMode (called from both dispatch and keybindings/events) + showSettingsSelector(): void; + showModelsSelector(): Promise; + handleModelCommand(searchTerm?: string): Promise; + showUserMessageSelector(): void; + showTreeSelector(): void; + showProviderManager(): void; + showOAuthSelector(mode: "login" | "logout"): Promise; + showSessionSelector(): void; + handleClearCommand(): Promise; + handleReloadCommand(): Promise; + handleDebugCommand(): void; + shutdown(): Promise; + + // For compaction + executeCompaction(customInstructions?: string, isAuto?: boolean): Promise; +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +/** + * Routes a slash command string to the appropriate handler. + * + * @returns `true` if the text was handled as a slash command (caller should + * not process it further), `false` otherwise. + */ +export async function dispatchSlashCommand( + text: string, + ctx: SlashCommandContext, +): Promise { + if (text === "/settings") { + ctx.showSettingsSelector(); + return true; + } + if (text === "/scoped-models") { + await ctx.showModelsSelector(); + return true; + } + if (text === "/model" || text.startsWith("/model ")) { + const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined; + await ctx.handleModelCommand(searchTerm); + return true; + } + if (text.startsWith("/export")) { + await handleExportCommand(text, ctx); + return true; + } + if (text === "/share") { + await handleShareCommand(ctx); + return true; + } + if (text === "/copy") { + handleCopyCommand(ctx); + return true; + } + if (text === "/name" || text.startsWith("/name ")) { + handleNameCommand(text, ctx); + return true; + } + if (text === "/session") { + handleSessionCommand(ctx); + return true; + } + if (text === "/changelog") { + handleChangelogCommand(ctx); + return true; + } + if (text === "/hotkeys") { + handleHotkeysCommand(ctx); + return true; + } + if (text === "/fork") { + ctx.showUserMessageSelector(); + return true; + } + if (text === "/tree") { + ctx.showTreeSelector(); + return true; + } + if (text === "/provider") { + ctx.showProviderManager(); + return true; + } + if (text === "/login") { + await ctx.showOAuthSelector("login"); + return true; + } + if (text === "/logout") { + await ctx.showOAuthSelector("logout"); + return true; + } + if (text === "/new") { + await ctx.handleClearCommand(); + return true; + } + if (text === "/compact" || text.startsWith("/compact ")) { + const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined; + await handleCompactCommand(customInstructions, ctx); + return true; + } + if (text === "/reload") { + await ctx.handleReloadCommand(); + return true; + } + if (text === "/thinking" || text.startsWith("/thinking ")) { + const arg = text.startsWith("/thinking ") ? text.slice(10).trim() : undefined; + handleThinkingCommand(arg, ctx); + return true; + } + if (text === "/edit-mode" || text.startsWith("/edit-mode ")) { + const arg = text.startsWith("/edit-mode ") ? text.slice(11).trim() : undefined; + handleEditModeCommand(arg, ctx); + return true; + } + if (text === "/debug") { + ctx.handleDebugCommand(); + return true; + } + if (text === "/arminsayshi") { + handleArminSaysHi(ctx); + return true; + } + if (text === "/resume") { + ctx.showSessionSelector(); + return true; + } + if (text === "/quit") { + await ctx.shutdown(); + return true; + } + + return false; +} + +// --------------------------------------------------------------------------- +// Individual command handlers +// --------------------------------------------------------------------------- + +async function handleExportCommand(text: string, ctx: SlashCommandContext): Promise { + const parts = text.split(/\s+/); + const outputPath = parts.length > 1 ? parts[1] : undefined; + + try { + const filePath = await ctx.session.exportToHtml(outputPath); + ctx.showStatus(`Session exported to: ${filePath}`); + } catch (error: unknown) { + ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + } +} + +async function handleShareCommand(ctx: SlashCommandContext): Promise { + // Check if gh is available and logged in + try { + const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); + if (authResult.status !== 0) { + ctx.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); + return; + } + } catch { + ctx.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); + return; + } + + // Export to a temp file + const tmpFile = path.join(os.tmpdir(), "session.html"); + try { + await ctx.session.exportToHtml(tmpFile); + } catch (error: unknown) { + ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return; + } + + // Show cancellable loader, replacing the editor + const loader = new BorderedLoader(ctx.ui, theme, "Creating gist..."); + ctx.editorContainer.clear(); + ctx.editorContainer.addChild(loader); + ctx.ui.setFocus(loader); + ctx.requestRender(); + + const restoreEditor = () => { + loader.dispose(); + ctx.editorContainer.clear(); + ctx.editorContainer.addChild(ctx.editor); + ctx.ui.setFocus(ctx.editor); + try { + fs.unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + }; + + // Create a secret gist asynchronously + let proc: ReturnType | null = null; + + loader.onAbort = () => { + proc?.kill(); + restoreEditor(); + ctx.showStatus("Share cancelled"); + }; + + try { + const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { + proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); + let stdout = ""; + let stderr = ""; + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + proc.on("close", (code) => resolve({ stdout, stderr, code })); + }); + + if (loader.signal.aborted) return; + + restoreEditor(); + + if (result.code !== 0) { + const errorMsg = result.stderr?.trim() || "Unknown error"; + ctx.showError(`Failed to create gist: ${errorMsg}`); + return; + } + + // Extract gist ID from the URL returned by gh + // gh returns something like: https://gist.github.com/username/GIST_ID + const gistUrl = result.stdout?.trim(); + const gistId = gistUrl?.split("/").pop(); + if (!gistId) { + ctx.showError("Failed to parse gist ID from gh output"); + return; + } + + // Create the preview URL + const previewUrl = getShareViewerUrl(gistId); + ctx.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`); + } catch (error: unknown) { + if (!loader.signal.aborted) { + restoreEditor(); + ctx.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } +} + +function handleCopyCommand(ctx: SlashCommandContext): void { + const text = ctx.session.getLastAssistantText(); + if (!text) { + ctx.showError("No agent messages to copy yet."); + return; + } + + try { + copyToClipboard(text); + ctx.showStatus("Copied last agent message to clipboard"); + } catch (error) { + ctx.showError(error instanceof Error ? error.message : String(error)); + } +} + +function handleNameCommand(text: string, ctx: SlashCommandContext): void { + const name = text.replace(/^\/name\s*/, "").trim(); + if (!name) { + const currentName = ctx.sessionManager.getSessionName(); + if (currentName) { + ctx.chatContainer.addChild(new Spacer(1)); + ctx.chatContainer.addChild(new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0)); + } else { + ctx.showWarning("Usage: /name "); + } + ctx.requestRender(); + return; + } + + ctx.sessionManager.appendSessionInfo(name); + ctx.updateTerminalTitle(); + ctx.chatContainer.addChild(new Spacer(1)); + ctx.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0)); + ctx.requestRender(); +} + +function handleSessionCommand(ctx: SlashCommandContext): void { + const stats = ctx.session.getSessionStats(); + const sessionName = ctx.sessionManager.getSessionName(); + + let info = `${theme.bold("Session Info")}\n\n`; + if (sessionName) { + info += `${theme.fg("dim", "Name:")} ${sessionName}\n`; + } + info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; + info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`; + info += `${theme.bold("Messages")}\n`; + info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`; + info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`; + info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`; + info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`; + info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`; + info += `${theme.bold("Tokens")}\n`; + info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`; + info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`; + if (stats.tokens.cacheRead > 0) { + info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`; + } + if (stats.tokens.cacheWrite > 0) { + info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`; + } + info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`; + + if (stats.cost > 0) { + info += `\n${theme.bold("Cost")}\n`; + info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`; + } + + ctx.chatContainer.addChild(new Spacer(1)); + ctx.chatContainer.addChild(new Text(info, 1, 0)); + ctx.requestRender(); +} + +function handleChangelogCommand(ctx: SlashCommandContext): void { + const changelogPath = getChangelogPath(); + const allEntries = parseChangelog(changelogPath); + + const changelogMarkdown = + allEntries.length > 0 + ? allEntries + .reverse() + .map((e) => e.content) + .join("\n\n") + : "No changelog entries found."; + + ctx.chatContainer.addChild(new Spacer(1)); + ctx.chatContainer.addChild(new DynamicBorder()); + ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); + ctx.chatContainer.addChild(new Spacer(1)); + ctx.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, ctx.getMarkdownThemeWithSettings())); + ctx.chatContainer.addChild(new DynamicBorder()); + ctx.requestRender(); +} + +// --------------------------------------------------------------------------- +// /hotkeys helpers +// --------------------------------------------------------------------------- + +export function capitalizeKey(key: string): string { + return key + .split("/") + .map((k) => + k + .split("+") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join("+"), + ) + .join("/"); +} + +export function getAppKeyDisplay(keybindings: KeybindingsManager, action: AppAction): string { + return capitalizeKey(appKey(keybindings, action)); +} + +function getEditorKeyDisplay(action: EditorAction): string { + return capitalizeKey(editorKey(action)); +} + +function handleHotkeysCommand(ctx: SlashCommandContext): void { + // Navigation keybindings + const cursorWordLeft = getEditorKeyDisplay("cursorWordLeft"); + const cursorWordRight = getEditorKeyDisplay("cursorWordRight"); + const cursorLineStart = getEditorKeyDisplay("cursorLineStart"); + const cursorLineEnd = getEditorKeyDisplay("cursorLineEnd"); + const jumpForward = getEditorKeyDisplay("jumpForward"); + const jumpBackward = getEditorKeyDisplay("jumpBackward"); + const pageUp = getEditorKeyDisplay("pageUp"); + const pageDown = getEditorKeyDisplay("pageDown"); + + // Editing keybindings + const submit = getEditorKeyDisplay("submit"); + const newLine = getEditorKeyDisplay("newLine"); + const deleteWordBackward = getEditorKeyDisplay("deleteWordBackward"); + const deleteWordForward = getEditorKeyDisplay("deleteWordForward"); + const deleteToLineStart = getEditorKeyDisplay("deleteToLineStart"); + const deleteToLineEnd = getEditorKeyDisplay("deleteToLineEnd"); + const yank = getEditorKeyDisplay("yank"); + const yankPop = getEditorKeyDisplay("yankPop"); + const undo = getEditorKeyDisplay("undo"); + const tab = getEditorKeyDisplay("tab"); + + // App keybindings + const interrupt = getAppKeyDisplay(ctx.keybindings, "interrupt"); + const clear = getAppKeyDisplay(ctx.keybindings, "clear"); + const exit = getAppKeyDisplay(ctx.keybindings, "exit"); + const suspend = getAppKeyDisplay(ctx.keybindings, "suspend"); + const cycleThinkingLevel = getAppKeyDisplay(ctx.keybindings, "cycleThinkingLevel"); + const cycleModelForward = getAppKeyDisplay(ctx.keybindings, "cycleModelForward"); + const selectModel = getAppKeyDisplay(ctx.keybindings, "selectModel"); + const expandTools = getAppKeyDisplay(ctx.keybindings, "expandTools"); + const toggleThinking = getAppKeyDisplay(ctx.keybindings, "toggleThinking"); + const externalEditor = getAppKeyDisplay(ctx.keybindings, "externalEditor"); + const followUp = getAppKeyDisplay(ctx.keybindings, "followUp"); + const dequeue = getAppKeyDisplay(ctx.keybindings, "dequeue"); + + let hotkeys = ` +**Navigation** +| Key | Action | +|-----|--------| +| \`Arrow keys\` | Move cursor / browse history (Up when empty) | +| \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word | +| \`${cursorLineStart}\` | Start of line | +| \`${cursorLineEnd}\` | End of line | +| \`${jumpForward}\` | Jump forward to character | +| \`${jumpBackward}\` | Jump backward to character | +| \`${pageUp}\` / \`${pageDown}\` | Scroll by page | + +**Editing** +| Key | Action | +|-----|--------| +| \`${submit}\` | Send message | +| \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} | +| \`${deleteWordBackward}\` | Delete word backwards | +| \`${deleteWordForward}\` | Delete word forwards | +| \`${deleteToLineStart}\` | Delete to start of line | +| \`${deleteToLineEnd}\` | Delete to end of line | +| \`${yank}\` | Paste the most-recently-deleted text | +| \`${yankPop}\` | Cycle through the deleted text after pasting | +| \`${undo}\` | Undo | + +**Other** +| Key | Action | +|-----|--------| +| \`${tab}\` | Path completion / accept autocomplete | +| \`${interrupt}\` | Cancel autocomplete / abort streaming | +| \`${clear}\` | Clear editor (first) / exit (second) | +| \`${exit}\` | Exit (when editor is empty) | +| \`${suspend}\` | Suspend to background | +| \`${cycleThinkingLevel}\` | Cycle thinking level | +| \`${cycleModelForward}\` | Cycle models | +| \`${selectModel}\` | Open model selector | +| \`${expandTools}\` | Toggle tool output expansion | +| \`${toggleThinking}\` | Toggle thinking block visibility | +| \`${externalEditor}\` | Edit message in external editor | +| \`${followUp}\` | Queue follow-up message | +| \`${dequeue}\` | Restore queued messages | +| \`Ctrl+V\` | Paste image from clipboard | +| \`/\` | Slash commands | +| \`!\` | Run bash command | +| \`!!\` | Run bash command (excluded from context) | +`; + + // Add extension-registered shortcuts + const extensionRunner = ctx.session.extensionRunner; + if (extensionRunner) { + const shortcuts = extensionRunner.getShortcuts(ctx.keybindings.getEffectiveConfig()); + if (shortcuts.size > 0) { + hotkeys += ` +**Extensions** +| Key | Action | +|-----|--------| +`; + for (const [key, shortcut] of shortcuts) { + const description = shortcut.description ?? shortcut.extensionPath; + const keyDisplay = formatKeyForDisplay(key).replace(/\b\w/g, (c) => c.toUpperCase()); + hotkeys += `| \`${keyDisplay}\` | ${description} |\n`; + } + } + } + + ctx.chatContainer.addChild(new Spacer(1)); + ctx.chatContainer.addChild(new DynamicBorder()); + ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0)); + ctx.chatContainer.addChild(new Spacer(1)); + ctx.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, ctx.getMarkdownThemeWithSettings())); + ctx.chatContainer.addChild(new DynamicBorder()); + ctx.requestRender(); +} + +async function handleCompactCommand(customInstructions: string | undefined, ctx: SlashCommandContext): Promise { + const entries = ctx.sessionManager.getEntries(); + const messageCount = entries.filter((e) => e.type === "message").length; + + if (messageCount < 2) { + ctx.showWarning("Nothing to compact (no messages yet)"); + return; + } + + await ctx.executeCompaction(customInstructions, false); +} + +function handleThinkingCommand(arg: string | undefined, ctx: SlashCommandContext): void { + if (!ctx.session.supportsThinking()) { + ctx.showStatus("Current model does not support thinking"); + return; + } + + const availableLevels = ctx.session.getAvailableThinkingLevels(); + + if (arg) { + const level = arg.toLowerCase(); + if (!availableLevels.includes(level as ThinkingLevel)) { + ctx.showStatus(`Invalid thinking level "${arg}". Available: ${availableLevels.join(", ")}`); + return; + } + ctx.session.setThinkingLevel(level as ThinkingLevel); + ctx.invalidateFooter(); + ctx.updateEditorBorderColor(); + ctx.showStatus(`Thinking level: ${level}`); + return; + } + + showThinkingSelector(ctx, availableLevels); +} + +function showThinkingSelector(ctx: SlashCommandContext, availableLevels: readonly ThinkingLevel[]): void { + ctx.showSelector((done) => { + const selector = new SelectSubmenu( + "Thinking Level", + "Select reasoning depth for thinking-capable models", + availableLevels.map((level) => ({ + value: level, + label: level, + description: THINKING_DESCRIPTIONS[level], + })), + ctx.session.thinkingLevel, + (value) => { + ctx.session.setThinkingLevel(value as ThinkingLevel); + ctx.invalidateFooter(); + ctx.updateEditorBorderColor(); + done(); + ctx.showStatus(`Thinking level: ${value}`); + }, + () => { + done(); + }, + ); + return { component: selector, focus: selector }; + }); +} + +function handleEditModeCommand(arg: string | undefined, ctx: SlashCommandContext): void { + const modes = ["standard", "hashline"] as const; + + if (arg) { + const mode = arg.toLowerCase(); + if (!modes.includes(mode as typeof modes[number])) { + ctx.showStatus(`Invalid edit mode "${arg}". Available: standard, hashline`); + return; + } + ctx.session.setEditMode(mode as "standard" | "hashline"); + ctx.showStatus(`Edit mode: ${mode}${mode === "hashline" ? " (LINE#ID anchored edits)" : " (text-match edits)"}`); + return; + } + + // Toggle + const current = ctx.session.editMode; + const next = current === "standard" ? "hashline" : "standard"; + ctx.session.setEditMode(next); + ctx.showStatus(`Edit mode: ${next}${next === "hashline" ? " (LINE#ID anchored edits)" : " (text-match edits)"}`); +} + +function handleArminSaysHi(ctx: SlashCommandContext): void { + ctx.chatContainer.addChild(new Spacer(1)); + ctx.chatContainer.addChild(new ArminComponent(ctx.ui)); + ctx.requestRender(); +}