@kolisachint/hoocode-agent 0.5.57 → 0.5.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/dist/core/agent-session.d.ts +2 -2
  3. package/dist/core/agent-session.d.ts.map +1 -1
  4. package/dist/core/agent-session.js +4 -3
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/extensions/runner.d.ts.map +1 -1
  7. package/dist/core/extensions/runner.js +4 -1
  8. package/dist/core/extensions/runner.js.map +1 -1
  9. package/dist/core/keybindings.d.ts +162 -98
  10. package/dist/core/keybindings.d.ts.map +1 -1
  11. package/dist/core/keybindings.js +222 -106
  12. package/dist/core/keybindings.js.map +1 -1
  13. package/dist/modes/interactive/command-executor.d.ts.map +1 -1
  14. package/dist/modes/interactive/command-executor.js +95 -62
  15. package/dist/modes/interactive/command-executor.js.map +1 -1
  16. package/dist/modes/interactive/components/ask-options.d.ts +20 -6
  17. package/dist/modes/interactive/components/ask-options.d.ts.map +1 -1
  18. package/dist/modes/interactive/components/ask-options.js +35 -13
  19. package/dist/modes/interactive/components/ask-options.js.map +1 -1
  20. package/dist/modes/interactive/components/keybinding-hints.d.ts +9 -0
  21. package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
  22. package/dist/modes/interactive/components/keybinding-hints.js +12 -0
  23. package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
  24. package/dist/modes/interactive/components/task-panel.d.ts +2 -2
  25. package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
  26. package/dist/modes/interactive/components/task-panel.js +24 -11
  27. package/dist/modes/interactive/components/task-panel.js.map +1 -1
  28. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  29. package/dist/modes/interactive/components/tree-selector.js +29 -12
  30. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  31. package/dist/modes/interactive/interactive-mode.d.ts +63 -8
  32. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  33. package/dist/modes/interactive/interactive-mode.js +189 -90
  34. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  35. package/dist/modes/interactive/model-controller.d.ts +3 -0
  36. package/dist/modes/interactive/model-controller.d.ts.map +1 -1
  37. package/dist/modes/interactive/model-controller.js +1 -1
  38. package/dist/modes/interactive/model-controller.js.map +1 -1
  39. package/docs/keybindings.md +247 -80
  40. package/docs/terminal-setup.md +8 -3
  41. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  42. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  43. package/examples/extensions/sandbox/package.json +1 -1
  44. package/examples/extensions/with-deps/package.json +1 -1
  45. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"command-executor.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/command-executor.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Extracted command handlers from InteractiveMode.\n * All methods receive dependencies via CommandContext rather than\n * reaching back into the parent class.\n */\n\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport type { EditorComponent, MarkdownTheme, TUI } from \"@kolisachint/hoocode-tui\";\nimport { type Container, Markdown, Spacer, Text, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport { spawn, spawnSync } from \"child_process\";\nimport { getDebugLogPath, getShareViewerUrl } from \"../../config.js\";\nimport { loadAgentRegistry } from \"../../core/agent-registry.js\";\nimport type { AgentSession } from \"../../core/agent-session.js\";\nimport type { AgentSessionRuntime } from \"../../core/agent-session-runtime.js\";\nimport { ChangeDirectoryError, SessionImportFileNotFoundError } from \"../../core/agent-session-runtime.js\";\nimport type { KeybindingsManager } from \"../../core/keybindings.js\";\nimport { MissingSessionCwdError } from \"../../core/session-cwd.js\";\nimport {\n\tparseSessionColorSlot,\n\tSESSION_COLOR_NAME_LIST,\n\tSESSION_COLOR_SLOTS,\n\tsessionColorName,\n} from \"../../core/session-identity.js\";\nimport type { SessionManager } from \"../../core/session-manager.js\";\nimport { getSubagentPool } from \"../../core/subagent-pool-instance.js\";\nimport type { SubagentResultFile } from \"../../core/subagent-result.js\";\nimport { getChangelogPath, parseChangelog } from \"../../utils/changelog.js\";\nimport { copyToClipboard } from \"../../utils/clipboard.js\";\nimport { BorderedLoader } from \"./components/bordered-loader.js\";\nimport { DynamicBorder } from \"./components/dynamic-border.js\";\nimport type { FooterComponent } from \"./components/footer.js\";\nimport { formatKeyText, keyDisplayText } from \"./components/keybinding-hints.js\";\nimport { renderSessionChip } from \"./components/session-chip.js\";\nimport { theme } from \"./theme/theme.js\";\n\nexport interface CommandContext {\n\t// Core dependencies\n\tsession: AgentSession;\n\tsessionManager: SessionManager;\n\truntimeHost: AgentSessionRuntime;\n\tui: TUI;\n\teditor: EditorComponent;\n\teditorContainer: Container;\n\tchatContainer: Container;\n\tstatusContainer: Container;\n\tfooter: FooterComponent;\n\tkeybindings: KeybindingsManager;\n\n\t// UI callbacks\n\tshowStatus: (message: string) => void;\n\tshowError: (message: string) => void;\n\tshowWarning: (message: string) => void;\n\tupdateEditorBorderColor: () => void;\n\trenderCurrentSessionState: () => void;\n\trebuildChatFromMessages: () => void;\n\tgetMarkdownThemeWithSettings: () => MarkdownTheme;\n\tstopLoadingAnimation: () => void;\n\n\t// Auth/model helpers\n\tfindExactModelMatch: (searchTerm: string) => Promise<Model<any> | undefined>;\n\tmaybeWarnAboutAnthropicSubscriptionAuth: (model?: Model<any>) => Promise<void>;\n\n\t// Dialog callbacks\n\tshowModelSelector: (searchTerm?: string) => void;\n\tshowExtensionConfirm: (title: string, message: string) => Promise<boolean>;\n\tpromptForMissingSessionCwd: (error: MissingSessionCwdError) => Promise<string | undefined>;\n\n\t// Fatal error handler\n\thandleFatalRuntimeError: (prefix: string, error: unknown) => Promise<never>;\n}\n\nexport class CommandExecutor {\n\t/** Where `/cd -` returns to. Set on every successful move, session-local. */\n\tprivate previousCwd?: string;\n\n\tconstructor(private readonly ctx: CommandContext) {}\n\n\t// =========================================================================\n\t// Slash command handlers\n\t// =========================================================================\n\n\tasync handleModel(searchTerm?: string): Promise<void> {\n\t\tif (!searchTerm) {\n\t\t\tthis.ctx.showModelSelector();\n\t\t\treturn;\n\t\t}\n\n\t\tconst model = await this.ctx.findExactModelMatch(searchTerm);\n\t\tif (model) {\n\t\t\ttry {\n\t\t\t\tawait this.ctx.session.setModel(model);\n\t\t\t\tthis.ctx.footer.invalidate();\n\t\t\t\tthis.ctx.updateEditorBorderColor();\n\t\t\t\tthis.ctx.showStatus(`Model: ${model.id}`);\n\t\t\t\tvoid this.ctx.maybeWarnAboutAnthropicSubscriptionAuth(model);\n\t\t\t} catch (error) {\n\t\t\t\tthis.ctx.showError(error instanceof Error ? error.message : String(error));\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tthis.ctx.showModelSelector(searchTerm);\n\t}\n\n\tasync handleClone(): Promise<void> {\n\t\tconst leafId = this.ctx.sessionManager.getLeafId();\n\t\tif (!leafId) {\n\t\t\tthis.ctx.showStatus(\"Nothing to clone yet\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tconst result = await this.ctx.runtimeHost.fork(leafId, { position: \"at\" });\n\t\t\tif (result.cancelled) {\n\t\t\t\tthis.ctx.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis.ctx.renderCurrentSessionState();\n\t\t\tthis.ctx.editor.setText(\"\");\n\t\t\tthis.ctx.showStatus(\"Cloned to new session\");\n\t\t} catch (error: unknown) {\n\t\t\tthis.ctx.showError(error instanceof Error ? error.message : String(error));\n\t\t}\n\t}\n\n\tasync handleSubagent(text: string): Promise<void> {\n\t\tconst prefix = \"/subagent \";\n\t\tconst args = text.startsWith(prefix) ? text.slice(prefix.length).trim() : \"\";\n\t\tif (!args) {\n\t\t\tthis.ctx.showStatus(\"Usage: /subagent <mode> <task>\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst firstSpace = args.indexOf(\" \");\n\t\tif (firstSpace === -1) {\n\t\t\tthis.ctx.showStatus(\"Usage: /subagent <mode> <task>\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst mode = args.slice(0, firstSpace).trim();\n\t\tconst task = args.slice(firstSpace + 1).trim();\n\t\tif (!task) {\n\t\t\tthis.ctx.showStatus(\"Usage: /subagent <mode> <task>\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst validModes = loadAgentRegistry({ cwd: this.ctx.sessionManager.getCwd() })\n\t\t\t.list()\n\t\t\t.map((a) => a.name);\n\t\tif (!validModes.includes(mode)) {\n\t\t\tthis.ctx.showStatus(`Unknown subagent_type: ${mode}. Available: ${validModes.join(\", \")}`);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.ctx.showStatus(`Spawning ${mode} subagent...`);\n\t\ttry {\n\t\t\tconst pool = getSubagentPool(this.ctx.sessionManager.getCwd(), this.ctx.session.modelRegistry.getAvailable());\n\t\t\tconst dispatchResult = await pool.dispatch(task, {\n\t\t\t\tforceAgent: mode,\n\t\t\t\tmodel: this.ctx.session.model?.id,\n\t\t\t\tprovider: this.ctx.session.model?.provider,\n\t\t\t});\n\t\t\tconst result = dispatchResult.result;\n\t\t\tconst resultData = result?.result_data as SubagentResultFile | undefined;\n\t\t\tif (result?.ok) {\n\t\t\t\tthis.ctx.showStatus(`${mode} subagent completed`);\n\t\t\t\t// Inject the subagent answer as a custom message so the user can see it in the chat\n\t\t\t\tthis.ctx.sessionManager.appendMessage({\n\t\t\t\t\trole: \"custom\",\n\t\t\t\t\tcustomType: \"subagent\",\n\t\t\t\t\tcontent: resultData?.summary || \"(no output)\",\n\t\t\t\t\tdisplay: true,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tthis.ctx.showError(`Subagent (${mode}) failed: ${result?.error ?? \"unknown error\"}`);\n\t\t\t}\n\t\t} catch (error: unknown) {\n\t\t\tthis.ctx.showError(error instanceof Error ? error.message : String(error));\n\t\t}\n\t}\n\n\tasync handleExport(text: string): Promise<void> {\n\t\tconst outputPath = this.getPathArgument(text, \"/export\");\n\n\t\ttry {\n\t\t\tif (outputPath?.endsWith(\".jsonl\")) {\n\t\t\t\tconst filePath = this.ctx.session.exportToJsonl(outputPath);\n\t\t\t\tthis.ctx.showStatus(`Session exported to: ${filePath}`);\n\t\t\t} else {\n\t\t\t\tconst filePath = await this.ctx.session.exportToHtml(outputPath);\n\t\t\t\tthis.ctx.showStatus(`Session exported to: ${filePath}`);\n\t\t\t}\n\t\t} catch (error: unknown) {\n\t\t\tthis.ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : \"Unknown error\"}`);\n\t\t}\n\t}\n\n\tprivate getPathArgument(text: string, command: \"/export\" | \"/import\"): string | undefined {\n\t\tif (text === command) {\n\t\t\treturn undefined;\n\t\t}\n\t\tif (!text.startsWith(`${command} `)) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst argsString = text.slice(command.length + 1).trimStart();\n\t\tif (!argsString) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst firstChar = argsString[0];\n\t\tif (firstChar === '\"' || firstChar === \"'\") {\n\t\t\tconst closingQuoteIndex = argsString.indexOf(firstChar, 1);\n\t\t\tif (closingQuoteIndex < 0) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\treturn argsString.slice(1, closingQuoteIndex);\n\t\t}\n\n\t\tconst firstWhitespaceIndex = argsString.search(/\\s/);\n\t\tif (firstWhitespaceIndex < 0) {\n\t\t\treturn argsString;\n\t\t}\n\t\treturn argsString.slice(0, firstWhitespaceIndex);\n\t}\n\n\tasync handleImport(text: string): Promise<void> {\n\t\tconst inputPath = this.getPathArgument(text, \"/import\");\n\t\tif (!inputPath) {\n\t\t\tthis.ctx.showError(\"Usage: /import <path.jsonl>\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst confirmed = await this.ctx.showExtensionConfirm(\n\t\t\t\"Import session\",\n\t\t\t`Replace current session with ${inputPath}?`,\n\t\t);\n\t\tif (!confirmed) {\n\t\t\tthis.ctx.showStatus(\"Import cancelled\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tthis.ctx.stopLoadingAnimation();\n\t\t\tthis.ctx.statusContainer.clear();\n\t\t\tconst result = await this.ctx.runtimeHost.importFromJsonl(inputPath);\n\t\t\tif (result.cancelled) {\n\t\t\t\tthis.ctx.showStatus(\"Import cancelled\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.ctx.renderCurrentSessionState();\n\t\t\tthis.ctx.showStatus(`Session imported from: ${inputPath}`);\n\t\t} catch (error: unknown) {\n\t\t\tif (error instanceof MissingSessionCwdError) {\n\t\t\t\tconst selectedCwd = await this.ctx.promptForMissingSessionCwd(error);\n\t\t\t\tif (!selectedCwd) {\n\t\t\t\t\tthis.ctx.showStatus(\"Import cancelled\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst result = await this.ctx.runtimeHost.importFromJsonl(inputPath, selectedCwd);\n\t\t\t\tif (result.cancelled) {\n\t\t\t\t\tthis.ctx.showStatus(\"Import cancelled\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.ctx.renderCurrentSessionState();\n\t\t\t\tthis.ctx.showStatus(`Session imported from: ${inputPath}`);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (error instanceof SessionImportFileNotFoundError) {\n\t\t\t\tthis.ctx.showError(`Failed to import session: ${error.message}`);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait this.ctx.handleFatalRuntimeError(\"Failed to import session\", error);\n\t\t}\n\t}\n\n\tasync handleShare(): Promise<void> {\n\t\t// Check if gh is available and logged in\n\t\ttry {\n\t\t\tconst authResult = spawnSync(\"gh\", [\"auth\", \"status\"], { encoding: \"utf-8\" });\n\t\t\tif (authResult.status !== 0) {\n\t\t\t\tthis.ctx.showError(\"GitHub CLI is not logged in. Run 'gh auth login' first.\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch {\n\t\t\tthis.ctx.showError(\"GitHub CLI (gh) is not installed. Install it from https://cli.github.com/\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Export to a temp file\n\t\tconst tmpFile = path.join(os.tmpdir(), \"session.html\");\n\t\ttry {\n\t\t\tawait this.ctx.session.exportToHtml(tmpFile);\n\t\t} catch (error: unknown) {\n\t\t\tthis.ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : \"Unknown error\"}`);\n\t\t\treturn;\n\t\t}\n\n\t\t// Show cancellable loader, replacing the editor\n\t\tconst loader = new BorderedLoader(this.ctx.ui, theme, \"Creating gist...\");\n\t\tthis.ctx.editorContainer.clear();\n\t\tthis.ctx.editorContainer.addChild(loader);\n\t\tthis.ctx.ui.setFocus(loader);\n\t\tthis.ctx.ui.requestRender();\n\n\t\tconst restoreEditor = () => {\n\t\t\tloader.dispose();\n\t\t\tthis.ctx.editorContainer.clear();\n\t\t\tthis.ctx.editorContainer.addChild(this.ctx.editor);\n\t\t\tthis.ctx.ui.setFocus(this.ctx.editor);\n\t\t\ttry {\n\t\t\t\tfs.unlinkSync(tmpFile);\n\t\t\t} catch {\n\t\t\t\t// Ignore cleanup errors\n\t\t\t}\n\t\t};\n\n\t\t// Create a secret gist asynchronously\n\t\tlet proc: ReturnType<typeof spawn> | null = null;\n\n\t\tloader.onAbort = () => {\n\t\t\tproc?.kill();\n\t\t\trestoreEditor();\n\t\t\tthis.ctx.showStatus(\"Share cancelled\");\n\t\t};\n\n\t\ttry {\n\t\t\tconst result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {\n\t\t\t\tproc = spawn(\"gh\", [\"gist\", \"create\", \"--public=false\", tmpFile]);\n\t\t\t\tlet stdout = \"\";\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tproc.stdout?.on(\"data\", (data) => {\n\t\t\t\t\tstdout += data.toString();\n\t\t\t\t});\n\t\t\t\tproc.stderr?.on(\"data\", (data) => {\n\t\t\t\t\tstderr += data.toString();\n\t\t\t\t});\n\t\t\t\tproc.on(\"close\", (code) => resolve({ stdout, stderr, code }));\n\t\t\t});\n\n\t\t\tif (loader.signal.aborted) return;\n\n\t\t\trestoreEditor();\n\n\t\t\tif (result.code !== 0) {\n\t\t\t\tconst errorMsg = result.stderr?.trim() || \"Unknown error\";\n\t\t\t\tthis.ctx.showError(`Failed to create gist: ${errorMsg}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Extract gist ID from the URL returned by gh\n\t\t\t// gh returns something like: https://gist.github.com/username/GIST_ID\n\t\t\tconst gistUrl = result.stdout?.trim();\n\t\t\tconst gistId = gistUrl?.split(\"/\").pop();\n\t\t\tif (!gistId) {\n\t\t\t\tthis.ctx.showError(\"Failed to parse gist ID from gh output\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Create the preview URL\n\t\t\tconst previewUrl = getShareViewerUrl(gistId);\n\t\t\tthis.ctx.showStatus(`Share URL: ${previewUrl}\\nGist: ${gistUrl}`);\n\t\t} catch (error: unknown) {\n\t\t\tif (!loader.signal.aborted) {\n\t\t\t\trestoreEditor();\n\t\t\t\tthis.ctx.showError(`Failed to create gist: ${error instanceof Error ? error.message : \"Unknown error\"}`);\n\t\t\t}\n\t\t}\n\t}\n\n\tasync handleCopy(): Promise<void> {\n\t\tconst text = this.ctx.session.getLastAssistantText();\n\t\tif (!text) {\n\t\t\tthis.ctx.showError(\"No agent messages to copy yet.\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tawait copyToClipboard(text);\n\t\t\tthis.ctx.showStatus(\"Copied last agent message to clipboard\");\n\t\t} catch (error) {\n\t\t\tthis.ctx.showError(error instanceof Error ? error.message : String(error));\n\t\t}\n\t}\n\n\t/** The session's chip as it currently renders, for echoing back after a change. */\n\tprivate currentChip(): string {\n\t\tconst chip = renderSessionChip(\n\t\t\tthis.ctx.sessionManager.getDisplayName(),\n\t\t\tthis.ctx.sessionManager.getSessionColorSlot(),\n\t\t);\n\t\treturn chip?.styled ?? this.ctx.sessionManager.getDisplayName();\n\t}\n\n\thandleName(text: string): void {\n\t\tconst name = text.replace(/^\\/name\\s*/, \"\").trim();\n\t\tif (!name) {\n\t\t\t// Every session has a name now — an auto-assigned slug until someone\n\t\t\t// picks one — so there is always something to report, and the reply\n\t\t\t// shows the chip rather than describing it.\n\t\t\tconst chosen = this.ctx.sessionManager.getSessionName();\n\t\t\tconst label = chosen ? \"Session name:\" : \"Session name (auto):\";\n\t\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.ctx.chatContainer.addChild(\n\t\t\t\tnew Text(\n\t\t\t\t\t`${theme.fg(\"dim\", label)} ${this.currentChip()} ${theme.fg(\"dim\", \"/name <name> to change\")}`,\n\t\t\t\t\t1,\n\t\t\t\t\t0,\n\t\t\t\t),\n\t\t\t);\n\t\t\tthis.ctx.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\tthis.ctx.session.setSessionName(name);\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new Text(`${theme.fg(\"dim\", \"Session name set:\")} ${this.currentChip()}`, 1, 0));\n\t\tthis.ctx.ui.requestRender();\n\t}\n\n\t/**\n\t * `/color <slot>` sets the session's chip colour directly, where a slot is a\n\t * number, a name (`green`), or a name's initial (`g`). Bare `/color` is\n\t * handled by the caller, which opens the swatch picker instead — the names\n\t * describe a hue family rather than the exact colour a theme fills the slot\n\t * with, so which one reads best here is still a thing you look at.\n\t *\n\t * The confirmation names the slot it landed on, because the aliases fold\n\t * neighbouring hues together: `/color red` answering with \"magenta\" is how\n\t * you learn the palette has no red of its own.\n\t */\n\thandleColor(text: string): boolean {\n\t\tconst arg = text.replace(/^\\/color\\s*/, \"\").trim();\n\t\tif (!arg) return false;\n\t\tconst slot = parseSessionColorSlot(arg);\n\t\tif (slot === undefined) {\n\t\t\tthis.ctx.showWarning(\n\t\t\t\t`Usage: /color <1-${SESSION_COLOR_SLOTS}> or /color <${SESSION_COLOR_NAME_LIST.join(\"|\")}> ` +\n\t\t\t\t\t\"(first letter works too), or /color on its own to pick one\",\n\t\t\t);\n\t\t\treturn true;\n\t\t}\n\t\tthis.ctx.session.setSessionColor(slot);\n\t\tconst name = sessionColorName(slot);\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(\n\t\t\tnew Text(\n\t\t\t\t`${theme.fg(\"dim\", \"Session color set:\")} ${this.currentChip()}${name ? ` ${theme.fg(\"dim\", name)}` : \"\"}`,\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.ctx.ui.requestRender();\n\t\treturn true;\n\t}\n\n\thandleSession(): void {\n\t\tconst stats = this.ctx.session.getSessionStats();\n\t\tconst sessionName = this.ctx.sessionManager.getSessionName();\n\n\t\tlet info = `${theme.bold(\"Session Info\")}\\n\\n`;\n\t\tinfo += `${theme.fg(\"dim\", sessionName ? \"Name:\" : \"Name (auto):\")} ${this.currentChip()}\\n`;\n\t\tconst branch = this.ctx.sessionManager.getSessionBranch();\n\t\tif (branch) {\n\t\t\tinfo += `${theme.fg(\"dim\", \"Branch:\")} ${branch}\\n`;\n\t\t}\n\t\tinfo += `${theme.fg(\"dim\", \"File:\")} ${stats.sessionFile ?? \"In-memory\"}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"ID:\")} ${stats.sessionId}\\n\\n`;\n\t\tinfo += `${theme.bold(\"Messages\")}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"User:\")} ${stats.userMessages}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Assistant:\")} ${stats.assistantMessages}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Tool Calls:\")} ${stats.toolCalls}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Tool Results:\")} ${stats.toolResults}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Total:\")} ${stats.totalMessages}\\n\\n`;\n\t\tinfo += `${theme.bold(\"Tokens\")}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Input:\")} ${stats.tokens.input.toLocaleString()}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Output:\")} ${stats.tokens.output.toLocaleString()}\\n`;\n\t\tif (stats.tokens.cacheRead > 0) {\n\t\t\tinfo += `${theme.fg(\"dim\", \"Cache Read:\")} ${stats.tokens.cacheRead.toLocaleString()}\\n`;\n\t\t}\n\t\tif (stats.tokens.cacheWrite > 0) {\n\t\t\tinfo += `${theme.fg(\"dim\", \"Cache Write:\")} ${stats.tokens.cacheWrite.toLocaleString()}\\n`;\n\t\t}\n\t\tinfo += `${theme.fg(\"dim\", \"Total:\")} ${stats.tokens.total.toLocaleString()}\\n`;\n\n\t\tif (stats.cost > 0) {\n\t\t\tinfo += `\\n${theme.bold(\"Cost\")}\\n`;\n\t\t\tinfo += `${theme.fg(\"dim\", \"Total:\")} ${stats.cost.toFixed(4)}`;\n\t\t}\n\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new Text(info, 1, 0));\n\t\tthis.ctx.ui.requestRender();\n\t}\n\n\thandleChangelog(): void {\n\t\tconst changelogPath = getChangelogPath();\n\t\tconst allEntries = parseChangelog(changelogPath);\n\n\t\tif (allEntries.length === 0) {\n\t\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.ctx.chatContainer.addChild(new Text(theme.fg(\"dim\", \"No changelog entries found.\"), 1, 0));\n\t\t\tthis.ctx.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\tconst changelogMarkdown = allEntries\n\t\t\t.slice()\n\t\t\t.reverse()\n\t\t\t.map((e) => e.content)\n\t\t\t.join(\"\\n\\n\");\n\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new DynamicBorder());\n\t\tthis.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg(\"accent\", \"What's New\")), 1, 0));\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 0, this.ctx.getMarkdownThemeWithSettings()));\n\t\tthis.ctx.chatContainer.addChild(new DynamicBorder());\n\t\tthis.ctx.ui.requestRender();\n\t}\n\n\thandleHotkeys(): void {\n\t\t// Navigation keybindings\n\t\tconst cursorUp = keyDisplayText(\"tui.editor.cursorUp\");\n\t\tconst cursorDown = keyDisplayText(\"tui.editor.cursorDown\");\n\t\tconst cursorLeft = keyDisplayText(\"tui.editor.cursorLeft\");\n\t\tconst cursorRight = keyDisplayText(\"tui.editor.cursorRight\");\n\t\tconst cursorWordLeft = keyDisplayText(\"tui.editor.cursorWordLeft\");\n\t\tconst cursorWordRight = keyDisplayText(\"tui.editor.cursorWordRight\");\n\t\tconst cursorLineStart = keyDisplayText(\"tui.editor.cursorLineStart\");\n\t\tconst cursorLineEnd = keyDisplayText(\"tui.editor.cursorLineEnd\");\n\t\tconst jumpForward = keyDisplayText(\"tui.editor.jumpForward\");\n\t\tconst jumpBackward = keyDisplayText(\"tui.editor.jumpBackward\");\n\t\tconst pageUp = keyDisplayText(\"tui.editor.pageUp\");\n\t\tconst pageDown = keyDisplayText(\"tui.editor.pageDown\");\n\n\t\t// Editing keybindings\n\t\tconst submit = keyDisplayText(\"tui.input.submit\");\n\t\tconst newLine = keyDisplayText(\"tui.input.newLine\");\n\t\tconst deleteWordBackward = keyDisplayText(\"tui.editor.deleteWordBackward\");\n\t\tconst deleteWordForward = keyDisplayText(\"tui.editor.deleteWordForward\");\n\t\tconst deleteToLineStart = keyDisplayText(\"tui.editor.deleteToLineStart\");\n\t\tconst deleteToLineEnd = keyDisplayText(\"tui.editor.deleteToLineEnd\");\n\t\tconst yank = keyDisplayText(\"tui.editor.yank\");\n\t\tconst yankPop = keyDisplayText(\"tui.editor.yankPop\");\n\t\tconst undo = keyDisplayText(\"tui.editor.undo\");\n\t\tconst tab = keyDisplayText(\"tui.input.tab\");\n\n\t\t// App keybindings\n\t\tconst interrupt = keyDisplayText(\"app.interrupt\");\n\t\tconst clear = keyDisplayText(\"app.clear\");\n\t\tconst exit = keyDisplayText(\"app.exit\");\n\t\tconst suspend = keyDisplayText(\"app.suspend\");\n\t\tconst cycleThinkingLevel = keyDisplayText(\"app.thinking.cycle\");\n\t\tconst cycleModelForward = keyDisplayText(\"app.model.cycleForward\");\n\t\tconst selectModel = keyDisplayText(\"app.model.select\");\n\t\tconst expandTools = keyDisplayText(\"app.tools.expand\");\n\t\tconst toggleThinking = keyDisplayText(\"app.thinking.toggle\");\n\t\tconst cycleTaskView = keyDisplayText(\"app.tasks.cycleView\");\n\t\tconst externalEditor = keyDisplayText(\"app.editor.external\");\n\t\tconst cycleModelBackward = keyDisplayText(\"app.model.cycleBackward\");\n\t\tconst followUp = keyDisplayText(\"app.message.followUp\");\n\t\tconst dequeue = keyDisplayText(\"app.message.dequeue\");\n\t\tconst pasteImage = keyDisplayText(\"app.clipboard.pasteImage\");\n\t\tconst viewForward = keyDisplayText(\"app.view.cycleForward\");\n\t\tconst viewBackward = keyDisplayText(\"app.view.cycleBackward\");\n\t\tconst voice = keyDisplayText(\"app.input.voiceTranscribe\");\n\t\tconst changeDirectory = keyDisplayText(\"app.session.changeDirectory\");\n\t\tconst openSettings = keyDisplayText(\"app.settings.open\");\n\t\tconst openHotkeys = keyDisplayText(\"app.hotkeys.open\");\n\t\tconst cycleMode = keyDisplayText(\"app.mode.cycle\");\n\t\tconst sessionTree = keyDisplayText(\"app.session.tree\");\n\t\tconst sessionResume = keyDisplayText(\"app.session.resume\");\n\t\tconst cycleSessionColor = keyDisplayText(\"app.session.color.cycleForward\");\n\t\tconst cycleSessionColorBackward = keyDisplayText(\"app.session.color.cycleBackward\");\n\n\t\tlet hotkeys = `\n**Navigation**\n| Key | Action |\n|-----|--------|\n| \\`${cursorUp}\\` / \\`${cursorDown}\\` / \\`${cursorLeft}\\` / \\`${cursorRight}\\` | Move cursor / browse history (Up when empty) |\n| \\`${cursorWordLeft}\\` / \\`${cursorWordRight}\\` | Move by word |\n| \\`${cursorLineStart}\\` | Start of line |\n| \\`${cursorLineEnd}\\` | End of line |\n| \\`${jumpForward}\\` | Jump forward to character |\n| \\`${jumpBackward}\\` | Jump backward to character |\n| \\`${pageUp}\\` / \\`${pageDown}\\` | Scroll by page |\n\n**Editing**\n| Key | Action |\n|-----|--------|\n| \\`${submit}\\` | Send message |\n| \\`${newLine}\\` | New line${process.platform === \"win32\" ? \" (Ctrl+Enter on Windows Terminal)\" : \"\"} |\n| \\`${deleteWordBackward}\\` | Delete word backwards |\n| \\`${deleteWordForward}\\` | Delete word forwards |\n| \\`${deleteToLineStart}\\` | Delete to start of line |\n| \\`${deleteToLineEnd}\\` | Delete to end of line |\n| \\`${yank}\\` | Paste the most-recently-deleted text |\n| \\`${yankPop}\\` | Cycle through the deleted text after pasting |\n| \\`${undo}\\` | Undo |\n\n**Flow** — the keys you hit without thinking\n| Key | Action |\n|-----|--------|\n| \\`${tab}\\` | Path completion / accept autocomplete |\n| \\`${interrupt}\\` | Cancel autocomplete / abort streaming |\n| \\`${clear}\\` | Clear editor (first) / exit (second) |\n| \\`${exit}\\` | Exit (when editor is empty) |\n| \\`${suspend}\\` | Suspend to background |\n| \\`${followUp}\\` | Queue follow-up message |\n| \\`${dequeue}\\` | Restore queued messages |\n| \\`${pasteImage}\\` | Paste image from clipboard |\n| \\`/\\` | Slash commands |\n| \\`!\\` | Run bash command |\n| \\`!!\\` | Run bash command (excluded from context) |\n\n**View** — what is on screen right now (\\`Ctrl\\`)\n| Key | Action |\n|-----|--------|\n| \\`${expandTools}\\` | Jump to the full view and back |\n| \\`${viewForward}\\` / \\`${viewBackward}\\` | Cycle tool output: radar → peek → full |\n| \\`${toggleThinking}\\` | Toggle thinking block visibility |\n| \\`${cycleTaskView}\\` | Cycle task panel view (tasks → subagents → teams) |\n| \\`${cycleModelForward}\\` / \\`${cycleModelBackward}\\` | Cycle models |\n| \\`${cycleThinkingLevel}\\` | Cycle thinking level |\n\n**Cockpit** — what the agent is, and where it works (\\`Alt\\`)\n| Key | Action |\n|-----|--------|\n| \\`${cycleMode}\\` | Cycle agent mode (ask → plan → build → debug) |\n| \\`${selectModel}\\` | Open model selector |\n| \\`${changeDirectory}\\` | Change working directory (\\`/cd\\`) |\n| \\`${sessionTree}\\` | Open session tree |\n| \\`${sessionResume}\\` | Resume a session from history |\n| \\`${cycleSessionColor}\\` / \\`${cycleSessionColorBackward}\\` | Cycle the session chip's color (\\`/color\\` to pick one) |\n| \\`${openSettings}\\` | Open settings |\n| \\`${openHotkeys}\\` | Show this list |\n| \\`${externalEditor}\\` | Edit message in external editor |\n| \\`${voice}\\` | Record voice and transcribe |\n\nInside a picker, every \\`Ctrl\\` key still edits the query — the picker's own\nverbs are on \\`Alt\\`, and its hint line names them.\n`;\n\n\t\t// Add extension-registered shortcuts\n\t\tconst extensionRunner = this.ctx.session.extensionRunner;\n\t\tconst shortcuts = extensionRunner.getShortcuts(this.ctx.keybindings.getEffectiveConfig());\n\t\tif (shortcuts.size > 0) {\n\t\t\thotkeys += `\n**Extensions**\n| Key | Action |\n|-----|--------|\n`;\n\t\t\tfor (const [key, shortcut] of shortcuts) {\n\t\t\t\tconst description = shortcut.description ?? shortcut.extensionPath;\n\t\t\t\tconst keyDisplay = formatKeyText(key, { capitalize: true });\n\t\t\t\thotkeys += `| \\`${keyDisplay}\\` | ${description} |\\n`;\n\t\t\t}\n\t\t}\n\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new DynamicBorder());\n\t\tthis.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg(\"accent\", \"Keyboard Shortcuts\")), 1, 0));\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 0, this.ctx.getMarkdownThemeWithSettings()));\n\t\tthis.ctx.chatContainer.addChild(new DynamicBorder());\n\t\tthis.ctx.ui.requestRender();\n\t}\n\n\tasync handleClear(): Promise<void> {\n\t\tthis.ctx.stopLoadingAnimation();\n\t\tthis.ctx.statusContainer.clear();\n\t\ttry {\n\t\t\tconst result = await this.ctx.runtimeHost.newSession();\n\t\t\tif (result.cancelled) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.ctx.renderCurrentSessionState();\n\t\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.ctx.chatContainer.addChild(new Text(`${theme.fg(\"accent\", \"✓ New session started\")}`, 1, 0));\n\t\t\tthis.ctx.ui.requestRender();\n\t\t} catch (error: unknown) {\n\t\t\tawait this.ctx.handleFatalRuntimeError(\"Failed to create session\", error);\n\t\t}\n\t}\n\n\t/**\n\t * `/cd [path]` — move the whole session to another directory.\n\t *\n\t * Written for the case it exists to serve: you are done in one repo and want\n\t * to work in the next one without losing the process, its provider auth, or\n\t * its warmed model list. Bare `/cd` goes home, `/cd -` goes back to where you\n\t * came from, `~` and relative paths resolve as a shell would.\n\t *\n\t * The move starts a fresh session rooted in the target — see\n\t * `AgentSessionRuntime.changeDirectory` for why the whole runtime is rebuilt\n\t * rather than a cwd string reassigned.\n\t */\n\tasync handleChangeDirectory(text: string): Promise<void> {\n\t\tconst prefix = \"/cd\";\n\t\tconst rawArg = text.startsWith(prefix) ? text.slice(prefix.length).trim() : text.trim();\n\t\tconst previousCwd = this.ctx.sessionManager.getCwd();\n\n\t\tlet target: string;\n\t\tif (!rawArg || rawArg === \"~\") {\n\t\t\ttarget = os.homedir();\n\t\t} else if (rawArg === \"-\") {\n\t\t\tif (!this.previousCwd) {\n\t\t\t\tthis.ctx.showWarning(\"No previous directory to return to\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttarget = this.previousCwd;\n\t\t} else {\n\t\t\tconst expanded = rawArg.startsWith(\"~/\") ? path.join(os.homedir(), rawArg.slice(2)) : rawArg;\n\t\t\ttarget = path.resolve(previousCwd, expanded);\n\t\t}\n\n\t\tif (path.resolve(target) === path.resolve(previousCwd)) {\n\t\t\tthis.ctx.showStatus(`Already in ${target}`);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.ctx.stopLoadingAnimation();\n\t\tthis.ctx.statusContainer.clear();\n\t\ttry {\n\t\t\tconst result = await this.ctx.runtimeHost.changeDirectory(target);\n\t\t\tif (result.cancelled) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.previousCwd = previousCwd;\n\t\t\tthis.ctx.renderCurrentSessionState();\n\t\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.ctx.chatContainer.addChild(\n\t\t\t\tnew Text(\n\t\t\t\t\t`${theme.fg(\"accent\", \"✓ Working directory\")} ${theme.fg(\"muted\", result.cwd)}\\n` +\n\t\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t\t`New session started here. ${keyDisplayText(\"app.session.resume\")} reopens the session you left in ${previousCwd}.`,\n\t\t\t\t\t\t),\n\t\t\t\t\t1,\n\t\t\t\t\t0,\n\t\t\t\t),\n\t\t\t);\n\t\t\tthis.ctx.ui.requestRender();\n\t\t} catch (error: unknown) {\n\t\t\tif (error instanceof ChangeDirectoryError) {\n\t\t\t\tthis.ctx.showError(error.message);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait this.ctx.handleFatalRuntimeError(`Failed to change directory to ${target}`, error);\n\t\t}\n\t}\n\n\t/** Directory completions for `/cd <prefix>`, plus the two shell shorthands. */\n\tgetChangeDirectoryCompletions(argumentPrefix: string): Array<{ value: string; label: string }> {\n\t\tconst cwd = this.ctx.sessionManager.getCwd();\n\t\tconst expanded = argumentPrefix.startsWith(\"~/\")\n\t\t\t? path.join(os.homedir(), argumentPrefix.slice(2))\n\t\t\t: argumentPrefix;\n\t\t// A prefix ending in a separator names the directory to list; otherwise the\n\t\t// last segment is a partial name to filter its parent by.\n\t\tconst endsWithSep = expanded.endsWith(\"/\") || expanded.endsWith(path.sep);\n\t\tconst base = endsWithSep ? expanded : path.dirname(expanded);\n\t\tconst partial = endsWithSep ? \"\" : path.basename(expanded);\n\t\tconst searchDir = path.isAbsolute(base) ? base : path.resolve(cwd, base || \".\");\n\n\t\tconst completions: Array<{ value: string; label: string }> = [];\n\t\tif (!argumentPrefix) {\n\t\t\tif (this.previousCwd) completions.push({ value: \"-\", label: `- (${this.previousCwd})` });\n\t\t\tcompletions.push({ value: \"~\", label: `~ (${os.homedir()})` });\n\t\t}\n\n\t\tlet entries: fs.Dirent[];\n\t\ttry {\n\t\t\tentries = fs.readdirSync(searchDir, { withFileTypes: true });\n\t\t} catch {\n\t\t\treturn completions;\n\t\t}\n\n\t\tfor (const entry of entries) {\n\t\t\tif (!entry.isDirectory()) continue;\n\t\t\tif (partial && !entry.name.startsWith(partial)) continue;\n\t\t\tif (!partial && entry.name.startsWith(\".\")) continue;\n\t\t\tconst joined =\n\t\t\t\tbase && !endsWithSep\n\t\t\t\t\t? path.join(path.dirname(argumentPrefix), entry.name)\n\t\t\t\t\t: `${argumentPrefix}${entry.name}`;\n\t\t\tcompletions.push({ value: `${joined}/`, label: `${entry.name}/` });\n\t\t}\n\t\treturn completions.slice(0, 50);\n\t}\n\n\thandleDebug(): void {\n\t\tconst width = this.ctx.ui.terminal.columns;\n\t\tconst height = this.ctx.ui.terminal.rows;\n\t\tconst allLines = this.ctx.ui.render(width);\n\n\t\tconst debugLogPath = getDebugLogPath();\n\t\tconst debugData = [\n\t\t\t`Debug output at ${new Date().toISOString()}`,\n\t\t\t`Terminal: ${width}x${height}`,\n\t\t\t`Total lines: ${allLines.length}`,\n\t\t\t\"\",\n\t\t\t\"=== All rendered lines with visible widths ===\",\n\t\t\t...allLines.map((line, idx) => {\n\t\t\t\tconst vw = visibleWidth(line);\n\t\t\t\tconst escaped = JSON.stringify(line);\n\t\t\t\treturn `[${idx}] (w=${vw}) ${escaped}`;\n\t\t\t}),\n\t\t\t\"\",\n\t\t\t\"=== Agent messages (JSONL) ===\",\n\t\t\t...this.ctx.session.messages.map((msg) => JSON.stringify(msg)),\n\t\t\t\"\",\n\t\t].join(\"\\n\");\n\n\t\tfs.mkdirSync(path.dirname(debugLogPath), { recursive: true });\n\t\tfs.writeFileSync(debugLogPath, debugData);\n\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(\n\t\t\tnew Text(`${theme.fg(\"accent\", \"✓ Debug log written\")}\\n${theme.fg(\"muted\", debugLogPath)}`, 1, 0),\n\t\t);\n\t\tthis.ctx.ui.requestRender();\n\t}\n}\n"]}
1
+ {"version":3,"file":"command-executor.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/command-executor.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Extracted command handlers from InteractiveMode.\n * All methods receive dependencies via CommandContext rather than\n * reaching back into the parent class.\n */\n\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport type { EditorComponent, MarkdownTheme, TUI } from \"@kolisachint/hoocode-tui\";\nimport { type Container, Markdown, Spacer, Text, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport { spawn, spawnSync } from \"child_process\";\nimport { getDebugLogPath, getShareViewerUrl } from \"../../config.js\";\nimport { loadAgentRegistry } from \"../../core/agent-registry.js\";\nimport type { AgentSession } from \"../../core/agent-session.js\";\nimport type { AgentSessionRuntime } from \"../../core/agent-session-runtime.js\";\nimport { ChangeDirectoryError, SessionImportFileNotFoundError } from \"../../core/agent-session-runtime.js\";\nimport type { KeybindingsManager } from \"../../core/keybindings.js\";\nimport { MissingSessionCwdError } from \"../../core/session-cwd.js\";\nimport {\n\tparseSessionColorSlot,\n\tSESSION_COLOR_NAME_LIST,\n\tSESSION_COLOR_SLOTS,\n\tsessionColorName,\n} from \"../../core/session-identity.js\";\nimport type { SessionManager } from \"../../core/session-manager.js\";\nimport { getSubagentPool } from \"../../core/subagent-pool-instance.js\";\nimport type { SubagentResultFile } from \"../../core/subagent-result.js\";\nimport { getChangelogPath, parseChangelog } from \"../../utils/changelog.js\";\nimport { copyToClipboard } from \"../../utils/clipboard.js\";\nimport { BorderedLoader } from \"./components/bordered-loader.js\";\nimport { DynamicBorder } from \"./components/dynamic-border.js\";\nimport type { FooterComponent } from \"./components/footer.js\";\nimport { formatKeyText, keyDisplayLabel, keyDisplayText } from \"./components/keybinding-hints.js\";\nimport { renderSessionChip } from \"./components/session-chip.js\";\nimport { theme } from \"./theme/theme.js\";\n\nexport interface CommandContext {\n\t// Core dependencies\n\tsession: AgentSession;\n\tsessionManager: SessionManager;\n\truntimeHost: AgentSessionRuntime;\n\tui: TUI;\n\teditor: EditorComponent;\n\teditorContainer: Container;\n\tchatContainer: Container;\n\tstatusContainer: Container;\n\tfooter: FooterComponent;\n\tkeybindings: KeybindingsManager;\n\n\t// UI callbacks\n\tshowStatus: (message: string) => void;\n\tshowError: (message: string) => void;\n\tshowWarning: (message: string) => void;\n\tupdateEditorBorderColor: () => void;\n\trenderCurrentSessionState: () => void;\n\trebuildChatFromMessages: () => void;\n\tgetMarkdownThemeWithSettings: () => MarkdownTheme;\n\tstopLoadingAnimation: () => void;\n\n\t// Auth/model helpers\n\tfindExactModelMatch: (searchTerm: string) => Promise<Model<any> | undefined>;\n\tmaybeWarnAboutAnthropicSubscriptionAuth: (model?: Model<any>) => Promise<void>;\n\n\t// Dialog callbacks\n\tshowModelSelector: (searchTerm?: string) => void;\n\tshowExtensionConfirm: (title: string, message: string) => Promise<boolean>;\n\tpromptForMissingSessionCwd: (error: MissingSessionCwdError) => Promise<string | undefined>;\n\n\t// Fatal error handler\n\thandleFatalRuntimeError: (prefix: string, error: unknown) => Promise<never>;\n}\n\nexport class CommandExecutor {\n\t/** Where `/cd -` returns to. Set on every successful move, session-local. */\n\tprivate previousCwd?: string;\n\n\tconstructor(private readonly ctx: CommandContext) {}\n\n\t// =========================================================================\n\t// Slash command handlers\n\t// =========================================================================\n\n\tasync handleModel(searchTerm?: string): Promise<void> {\n\t\tif (!searchTerm) {\n\t\t\tthis.ctx.showModelSelector();\n\t\t\treturn;\n\t\t}\n\n\t\tconst model = await this.ctx.findExactModelMatch(searchTerm);\n\t\tif (model) {\n\t\t\ttry {\n\t\t\t\tawait this.ctx.session.setModel(model);\n\t\t\t\tthis.ctx.footer.invalidate();\n\t\t\t\tthis.ctx.updateEditorBorderColor();\n\t\t\t\tthis.ctx.showStatus(`Model: ${model.id}`);\n\t\t\t\tvoid this.ctx.maybeWarnAboutAnthropicSubscriptionAuth(model);\n\t\t\t} catch (error) {\n\t\t\t\tthis.ctx.showError(error instanceof Error ? error.message : String(error));\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tthis.ctx.showModelSelector(searchTerm);\n\t}\n\n\tasync handleClone(): Promise<void> {\n\t\tconst leafId = this.ctx.sessionManager.getLeafId();\n\t\tif (!leafId) {\n\t\t\tthis.ctx.showStatus(\"Nothing to clone yet\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tconst result = await this.ctx.runtimeHost.fork(leafId, { position: \"at\" });\n\t\t\tif (result.cancelled) {\n\t\t\t\tthis.ctx.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis.ctx.renderCurrentSessionState();\n\t\t\tthis.ctx.editor.setText(\"\");\n\t\t\tthis.ctx.showStatus(\"Cloned to new session\");\n\t\t} catch (error: unknown) {\n\t\t\tthis.ctx.showError(error instanceof Error ? error.message : String(error));\n\t\t}\n\t}\n\n\tasync handleSubagent(text: string): Promise<void> {\n\t\tconst prefix = \"/subagent \";\n\t\tconst args = text.startsWith(prefix) ? text.slice(prefix.length).trim() : \"\";\n\t\tif (!args) {\n\t\t\tthis.ctx.showStatus(\"Usage: /subagent <mode> <task>\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst firstSpace = args.indexOf(\" \");\n\t\tif (firstSpace === -1) {\n\t\t\tthis.ctx.showStatus(\"Usage: /subagent <mode> <task>\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst mode = args.slice(0, firstSpace).trim();\n\t\tconst task = args.slice(firstSpace + 1).trim();\n\t\tif (!task) {\n\t\t\tthis.ctx.showStatus(\"Usage: /subagent <mode> <task>\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst validModes = loadAgentRegistry({ cwd: this.ctx.sessionManager.getCwd() })\n\t\t\t.list()\n\t\t\t.map((a) => a.name);\n\t\tif (!validModes.includes(mode)) {\n\t\t\tthis.ctx.showStatus(`Unknown subagent_type: ${mode}. Available: ${validModes.join(\", \")}`);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.ctx.showStatus(`Spawning ${mode} subagent...`);\n\t\ttry {\n\t\t\tconst pool = getSubagentPool(this.ctx.sessionManager.getCwd(), this.ctx.session.modelRegistry.getAvailable());\n\t\t\tconst dispatchResult = await pool.dispatch(task, {\n\t\t\t\tforceAgent: mode,\n\t\t\t\tmodel: this.ctx.session.model?.id,\n\t\t\t\tprovider: this.ctx.session.model?.provider,\n\t\t\t});\n\t\t\tconst result = dispatchResult.result;\n\t\t\tconst resultData = result?.result_data as SubagentResultFile | undefined;\n\t\t\tif (result?.ok) {\n\t\t\t\tthis.ctx.showStatus(`${mode} subagent completed`);\n\t\t\t\t// Inject the subagent answer as a custom message so the user can see it in the chat\n\t\t\t\tthis.ctx.sessionManager.appendMessage({\n\t\t\t\t\trole: \"custom\",\n\t\t\t\t\tcustomType: \"subagent\",\n\t\t\t\t\tcontent: resultData?.summary || \"(no output)\",\n\t\t\t\t\tdisplay: true,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tthis.ctx.showError(`Subagent (${mode}) failed: ${result?.error ?? \"unknown error\"}`);\n\t\t\t}\n\t\t} catch (error: unknown) {\n\t\t\tthis.ctx.showError(error instanceof Error ? error.message : String(error));\n\t\t}\n\t}\n\n\tasync handleExport(text: string): Promise<void> {\n\t\tconst outputPath = this.getPathArgument(text, \"/export\");\n\n\t\ttry {\n\t\t\tif (outputPath?.endsWith(\".jsonl\")) {\n\t\t\t\tconst filePath = this.ctx.session.exportToJsonl(outputPath);\n\t\t\t\tthis.ctx.showStatus(`Session exported to: ${filePath}`);\n\t\t\t} else {\n\t\t\t\tconst filePath = await this.ctx.session.exportToHtml(outputPath);\n\t\t\t\tthis.ctx.showStatus(`Session exported to: ${filePath}`);\n\t\t\t}\n\t\t} catch (error: unknown) {\n\t\t\tthis.ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : \"Unknown error\"}`);\n\t\t}\n\t}\n\n\tprivate getPathArgument(text: string, command: \"/export\" | \"/import\"): string | undefined {\n\t\tif (text === command) {\n\t\t\treturn undefined;\n\t\t}\n\t\tif (!text.startsWith(`${command} `)) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst argsString = text.slice(command.length + 1).trimStart();\n\t\tif (!argsString) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst firstChar = argsString[0];\n\t\tif (firstChar === '\"' || firstChar === \"'\") {\n\t\t\tconst closingQuoteIndex = argsString.indexOf(firstChar, 1);\n\t\t\tif (closingQuoteIndex < 0) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\treturn argsString.slice(1, closingQuoteIndex);\n\t\t}\n\n\t\tconst firstWhitespaceIndex = argsString.search(/\\s/);\n\t\tif (firstWhitespaceIndex < 0) {\n\t\t\treturn argsString;\n\t\t}\n\t\treturn argsString.slice(0, firstWhitespaceIndex);\n\t}\n\n\tasync handleImport(text: string): Promise<void> {\n\t\tconst inputPath = this.getPathArgument(text, \"/import\");\n\t\tif (!inputPath) {\n\t\t\tthis.ctx.showError(\"Usage: /import <path.jsonl>\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst confirmed = await this.ctx.showExtensionConfirm(\n\t\t\t\"Import session\",\n\t\t\t`Replace current session with ${inputPath}?`,\n\t\t);\n\t\tif (!confirmed) {\n\t\t\tthis.ctx.showStatus(\"Import cancelled\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tthis.ctx.stopLoadingAnimation();\n\t\t\tthis.ctx.statusContainer.clear();\n\t\t\tconst result = await this.ctx.runtimeHost.importFromJsonl(inputPath);\n\t\t\tif (result.cancelled) {\n\t\t\t\tthis.ctx.showStatus(\"Import cancelled\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.ctx.renderCurrentSessionState();\n\t\t\tthis.ctx.showStatus(`Session imported from: ${inputPath}`);\n\t\t} catch (error: unknown) {\n\t\t\tif (error instanceof MissingSessionCwdError) {\n\t\t\t\tconst selectedCwd = await this.ctx.promptForMissingSessionCwd(error);\n\t\t\t\tif (!selectedCwd) {\n\t\t\t\t\tthis.ctx.showStatus(\"Import cancelled\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst result = await this.ctx.runtimeHost.importFromJsonl(inputPath, selectedCwd);\n\t\t\t\tif (result.cancelled) {\n\t\t\t\t\tthis.ctx.showStatus(\"Import cancelled\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.ctx.renderCurrentSessionState();\n\t\t\t\tthis.ctx.showStatus(`Session imported from: ${inputPath}`);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (error instanceof SessionImportFileNotFoundError) {\n\t\t\t\tthis.ctx.showError(`Failed to import session: ${error.message}`);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait this.ctx.handleFatalRuntimeError(\"Failed to import session\", error);\n\t\t}\n\t}\n\n\tasync handleShare(): Promise<void> {\n\t\t// Check if gh is available and logged in\n\t\ttry {\n\t\t\tconst authResult = spawnSync(\"gh\", [\"auth\", \"status\"], { encoding: \"utf-8\" });\n\t\t\tif (authResult.status !== 0) {\n\t\t\t\tthis.ctx.showError(\"GitHub CLI is not logged in. Run 'gh auth login' first.\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch {\n\t\t\tthis.ctx.showError(\"GitHub CLI (gh) is not installed. Install it from https://cli.github.com/\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Export to a temp file\n\t\tconst tmpFile = path.join(os.tmpdir(), \"session.html\");\n\t\ttry {\n\t\t\tawait this.ctx.session.exportToHtml(tmpFile);\n\t\t} catch (error: unknown) {\n\t\t\tthis.ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : \"Unknown error\"}`);\n\t\t\treturn;\n\t\t}\n\n\t\t// Show cancellable loader, replacing the editor\n\t\tconst loader = new BorderedLoader(this.ctx.ui, theme, \"Creating gist...\");\n\t\tthis.ctx.editorContainer.clear();\n\t\tthis.ctx.editorContainer.addChild(loader);\n\t\tthis.ctx.ui.setFocus(loader);\n\t\tthis.ctx.ui.requestRender();\n\n\t\tconst restoreEditor = () => {\n\t\t\tloader.dispose();\n\t\t\tthis.ctx.editorContainer.clear();\n\t\t\tthis.ctx.editorContainer.addChild(this.ctx.editor);\n\t\t\tthis.ctx.ui.setFocus(this.ctx.editor);\n\t\t\ttry {\n\t\t\t\tfs.unlinkSync(tmpFile);\n\t\t\t} catch {\n\t\t\t\t// Ignore cleanup errors\n\t\t\t}\n\t\t};\n\n\t\t// Create a secret gist asynchronously\n\t\tlet proc: ReturnType<typeof spawn> | null = null;\n\n\t\tloader.onAbort = () => {\n\t\t\tproc?.kill();\n\t\t\trestoreEditor();\n\t\t\tthis.ctx.showStatus(\"Share cancelled\");\n\t\t};\n\n\t\ttry {\n\t\t\tconst result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {\n\t\t\t\tproc = spawn(\"gh\", [\"gist\", \"create\", \"--public=false\", tmpFile]);\n\t\t\t\tlet stdout = \"\";\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tproc.stdout?.on(\"data\", (data) => {\n\t\t\t\t\tstdout += data.toString();\n\t\t\t\t});\n\t\t\t\tproc.stderr?.on(\"data\", (data) => {\n\t\t\t\t\tstderr += data.toString();\n\t\t\t\t});\n\t\t\t\tproc.on(\"close\", (code) => resolve({ stdout, stderr, code }));\n\t\t\t});\n\n\t\t\tif (loader.signal.aborted) return;\n\n\t\t\trestoreEditor();\n\n\t\t\tif (result.code !== 0) {\n\t\t\t\tconst errorMsg = result.stderr?.trim() || \"Unknown error\";\n\t\t\t\tthis.ctx.showError(`Failed to create gist: ${errorMsg}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Extract gist ID from the URL returned by gh\n\t\t\t// gh returns something like: https://gist.github.com/username/GIST_ID\n\t\t\tconst gistUrl = result.stdout?.trim();\n\t\t\tconst gistId = gistUrl?.split(\"/\").pop();\n\t\t\tif (!gistId) {\n\t\t\t\tthis.ctx.showError(\"Failed to parse gist ID from gh output\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Create the preview URL\n\t\t\tconst previewUrl = getShareViewerUrl(gistId);\n\t\t\tthis.ctx.showStatus(`Share URL: ${previewUrl}\\nGist: ${gistUrl}`);\n\t\t} catch (error: unknown) {\n\t\t\tif (!loader.signal.aborted) {\n\t\t\t\trestoreEditor();\n\t\t\t\tthis.ctx.showError(`Failed to create gist: ${error instanceof Error ? error.message : \"Unknown error\"}`);\n\t\t\t}\n\t\t}\n\t}\n\n\tasync handleCopy(): Promise<void> {\n\t\tconst text = this.ctx.session.getLastAssistantText();\n\t\tif (!text) {\n\t\t\tthis.ctx.showError(\"No agent messages to copy yet.\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tawait copyToClipboard(text);\n\t\t\tthis.ctx.showStatus(\"Copied last agent message to clipboard\");\n\t\t} catch (error) {\n\t\t\tthis.ctx.showError(error instanceof Error ? error.message : String(error));\n\t\t}\n\t}\n\n\t/** The session's chip as it currently renders, for echoing back after a change. */\n\tprivate currentChip(): string {\n\t\tconst chip = renderSessionChip(\n\t\t\tthis.ctx.sessionManager.getDisplayName(),\n\t\t\tthis.ctx.sessionManager.getSessionColorSlot(),\n\t\t);\n\t\treturn chip?.styled ?? this.ctx.sessionManager.getDisplayName();\n\t}\n\n\thandleName(text: string): void {\n\t\tconst name = text.replace(/^\\/name\\s*/, \"\").trim();\n\t\tif (!name) {\n\t\t\t// Every session has a name now — an auto-assigned slug until someone\n\t\t\t// picks one — so there is always something to report, and the reply\n\t\t\t// shows the chip rather than describing it.\n\t\t\tconst chosen = this.ctx.sessionManager.getSessionName();\n\t\t\tconst label = chosen ? \"Session name:\" : \"Session name (auto):\";\n\t\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.ctx.chatContainer.addChild(\n\t\t\t\tnew Text(\n\t\t\t\t\t`${theme.fg(\"dim\", label)} ${this.currentChip()} ${theme.fg(\"dim\", \"/name <name> to change\")}`,\n\t\t\t\t\t1,\n\t\t\t\t\t0,\n\t\t\t\t),\n\t\t\t);\n\t\t\tthis.ctx.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\tthis.ctx.session.setSessionName(name);\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new Text(`${theme.fg(\"dim\", \"Session name set:\")} ${this.currentChip()}`, 1, 0));\n\t\tthis.ctx.ui.requestRender();\n\t}\n\n\t/**\n\t * `/color <slot>` sets the session's chip colour directly, where a slot is a\n\t * number, a name (`green`), or a name's initial (`g`). Bare `/color` is\n\t * handled by the caller, which opens the swatch picker instead — the names\n\t * describe a hue family rather than the exact colour a theme fills the slot\n\t * with, so which one reads best here is still a thing you look at.\n\t *\n\t * The confirmation names the slot it landed on, because the aliases fold\n\t * neighbouring hues together: `/color red` answering with \"magenta\" is how\n\t * you learn the palette has no red of its own.\n\t */\n\thandleColor(text: string): boolean {\n\t\tconst arg = text.replace(/^\\/color\\s*/, \"\").trim();\n\t\tif (!arg) return false;\n\t\tconst slot = parseSessionColorSlot(arg);\n\t\tif (slot === undefined) {\n\t\t\tthis.ctx.showWarning(\n\t\t\t\t`Usage: /color <1-${SESSION_COLOR_SLOTS}> or /color <${SESSION_COLOR_NAME_LIST.join(\"|\")}> ` +\n\t\t\t\t\t\"(first letter works too), or /color on its own to pick one\",\n\t\t\t);\n\t\t\treturn true;\n\t\t}\n\t\tthis.ctx.session.setSessionColor(slot);\n\t\tconst name = sessionColorName(slot);\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(\n\t\t\tnew Text(\n\t\t\t\t`${theme.fg(\"dim\", \"Session color set:\")} ${this.currentChip()}${name ? ` ${theme.fg(\"dim\", name)}` : \"\"}`,\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.ctx.ui.requestRender();\n\t\treturn true;\n\t}\n\n\thandleSession(): void {\n\t\tconst stats = this.ctx.session.getSessionStats();\n\t\tconst sessionName = this.ctx.sessionManager.getSessionName();\n\n\t\tlet info = `${theme.bold(\"Session Info\")}\\n\\n`;\n\t\tinfo += `${theme.fg(\"dim\", sessionName ? \"Name:\" : \"Name (auto):\")} ${this.currentChip()}\\n`;\n\t\tconst branch = this.ctx.sessionManager.getSessionBranch();\n\t\tif (branch) {\n\t\t\tinfo += `${theme.fg(\"dim\", \"Branch:\")} ${branch}\\n`;\n\t\t}\n\t\tinfo += `${theme.fg(\"dim\", \"File:\")} ${stats.sessionFile ?? \"In-memory\"}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"ID:\")} ${stats.sessionId}\\n\\n`;\n\t\tinfo += `${theme.bold(\"Messages\")}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"User:\")} ${stats.userMessages}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Assistant:\")} ${stats.assistantMessages}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Tool Calls:\")} ${stats.toolCalls}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Tool Results:\")} ${stats.toolResults}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Total:\")} ${stats.totalMessages}\\n\\n`;\n\t\tinfo += `${theme.bold(\"Tokens\")}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Input:\")} ${stats.tokens.input.toLocaleString()}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Output:\")} ${stats.tokens.output.toLocaleString()}\\n`;\n\t\tif (stats.tokens.cacheRead > 0) {\n\t\t\tinfo += `${theme.fg(\"dim\", \"Cache Read:\")} ${stats.tokens.cacheRead.toLocaleString()}\\n`;\n\t\t}\n\t\tif (stats.tokens.cacheWrite > 0) {\n\t\t\tinfo += `${theme.fg(\"dim\", \"Cache Write:\")} ${stats.tokens.cacheWrite.toLocaleString()}\\n`;\n\t\t}\n\t\tinfo += `${theme.fg(\"dim\", \"Total:\")} ${stats.tokens.total.toLocaleString()}\\n`;\n\n\t\tif (stats.cost > 0) {\n\t\t\tinfo += `\\n${theme.bold(\"Cost\")}\\n`;\n\t\t\tinfo += `${theme.fg(\"dim\", \"Total:\")} ${stats.cost.toFixed(4)}`;\n\t\t}\n\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new Text(info, 1, 0));\n\t\tthis.ctx.ui.requestRender();\n\t}\n\n\thandleChangelog(): void {\n\t\tconst changelogPath = getChangelogPath();\n\t\tconst allEntries = parseChangelog(changelogPath);\n\n\t\tif (allEntries.length === 0) {\n\t\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.ctx.chatContainer.addChild(new Text(theme.fg(\"dim\", \"No changelog entries found.\"), 1, 0));\n\t\t\tthis.ctx.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\tconst changelogMarkdown = allEntries\n\t\t\t.slice()\n\t\t\t.reverse()\n\t\t\t.map((e) => e.content)\n\t\t\t.join(\"\\n\\n\");\n\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new DynamicBorder());\n\t\tthis.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg(\"accent\", \"What's New\")), 1, 0));\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 0, this.ctx.getMarkdownThemeWithSettings()));\n\t\tthis.ctx.chatContainer.addChild(new DynamicBorder());\n\t\tthis.ctx.ui.requestRender();\n\t}\n\n\thandleHotkeys(): void {\n\t\t// Navigation keybindings\n\t\tconst cursorUp = keyDisplayText(\"tui.editor.cursorUp\");\n\t\tconst cursorDown = keyDisplayText(\"tui.editor.cursorDown\");\n\t\tconst cursorLeft = keyDisplayText(\"tui.editor.cursorLeft\");\n\t\tconst cursorRight = keyDisplayText(\"tui.editor.cursorRight\");\n\t\tconst cursorWordLeft = keyDisplayText(\"tui.editor.cursorWordLeft\");\n\t\tconst cursorWordRight = keyDisplayText(\"tui.editor.cursorWordRight\");\n\t\tconst cursorLineStart = keyDisplayText(\"tui.editor.cursorLineStart\");\n\t\tconst cursorLineEnd = keyDisplayText(\"tui.editor.cursorLineEnd\");\n\t\tconst jumpForward = keyDisplayText(\"tui.editor.jumpForward\");\n\t\tconst jumpBackward = keyDisplayText(\"tui.editor.jumpBackward\");\n\t\tconst pageUp = keyDisplayText(\"tui.editor.pageUp\");\n\t\tconst pageDown = keyDisplayText(\"tui.editor.pageDown\");\n\n\t\t// Editing keybindings\n\t\tconst submit = keyDisplayText(\"tui.input.submit\");\n\t\tconst newLine = keyDisplayText(\"tui.input.newLine\");\n\t\tconst deleteWordBackward = keyDisplayText(\"tui.editor.deleteWordBackward\");\n\t\tconst deleteWordForward = keyDisplayText(\"tui.editor.deleteWordForward\");\n\t\tconst deleteToLineStart = keyDisplayText(\"tui.editor.deleteToLineStart\");\n\t\tconst deleteToLineEnd = keyDisplayText(\"tui.editor.deleteToLineEnd\");\n\t\tconst yank = keyDisplayText(\"tui.editor.yank\");\n\t\tconst yankPop = keyDisplayText(\"tui.editor.yankPop\");\n\t\tconst undo = keyDisplayText(\"tui.editor.undo\");\n\t\tconst tab = keyDisplayText(\"tui.input.tab\");\n\n\t\t// App keybindings\n\t\tconst interrupt = keyDisplayText(\"app.interrupt\");\n\t\tconst clear = keyDisplayText(\"app.clear\");\n\t\tconst exit = keyDisplayText(\"app.exit\");\n\t\tconst suspend = keyDisplayText(\"app.suspend\");\n\t\tconst cycleThinkingLevel = keyDisplayLabel(\"app.thinking.cycleForward\");\n\t\tconst cycleThinkingLevelBack = keyDisplayLabel(\"app.thinking.cycleBackward\");\n\t\tconst cycleModelForward = keyDisplayLabel(\"app.model.cycleForward\");\n\t\tconst expandTools = keyDisplayText(\"app.tools.expand\");\n\t\tconst toggleThinking = keyDisplayText(\"app.thinking.toggle\");\n\t\tconst cycleTaskView = keyDisplayLabel(\"app.tasks.cycleForward\");\n\t\tconst cycleTaskViewBack = keyDisplayLabel(\"app.tasks.cycleBackward\");\n\t\tconst teamFocus = keyDisplayText(\"app.team.focus\");\n\t\tconst teamNudge = keyDisplayText(\"app.team.nudge\");\n\t\tconst teamAttach = keyDisplayText(\"app.team.attach\");\n\t\tconst externalEditor = keyDisplayText(\"app.editor.external\");\n\t\tconst cycleModelBackward = keyDisplayLabel(\"app.model.cycleBackward\");\n\t\tconst followUp = keyDisplayText(\"app.message.followUp\");\n\t\tconst dequeue = keyDisplayText(\"app.message.dequeue\");\n\t\tconst pasteImage = keyDisplayText(\"app.clipboard.pasteImage\");\n\t\tconst viewForward = keyDisplayLabel(\"app.view.cycleForward\");\n\t\tconst viewBackward = keyDisplayLabel(\"app.view.cycleBackward\");\n\t\tconst voice = keyDisplayText(\"app.input.voiceTranscribe\");\n\t\tconst changeDirectory = keyDisplayText(\"app.session.changeDirectory\");\n\t\tconst openSettings = keyDisplayText(\"app.settings.open\");\n\t\tconst openHotkeys = keyDisplayText(\"app.hotkeys.open\");\n\t\tconst cycleMode = keyDisplayLabel(\"app.mode.cycleForward\");\n\t\tconst cycleModeBack = keyDisplayLabel(\"app.mode.cycleBackward\");\n\t\tconst sessionResume = keyDisplayText(\"app.session.resume\");\n\t\tconst cycleSessionColor = keyDisplayLabel(\"app.session.color.cycleForward\");\n\t\tconst cycleSessionColorBackward = keyDisplayLabel(\"app.session.color.cycleBackward\");\n\n\t\tlet hotkeys = `\nGrouped by what you are doing, not by what the key is. Five groups, none bigger\nthan five — the size a person can actually hold. Two of them are free: **Flow**\nis what every terminal program already taught you, and every picker prints its\nown keys on its own hint line, so you read those instead of remembering them.\n\n**Compose** — the message in your hands\n| Key | Action |\n|-----|--------|\n| \\`${submit}\\` | Send message |\n| \\`${newLine}\\` | New line${process.platform === \"win32\" ? \" (Ctrl+Enter on Windows Terminal)\" : \"\"} |\n| \\`${tab}\\` | Path completion / accept autocomplete |\n| \\`${externalEditor}\\` | Edit the message in \\`$VISUAL\\` / \\`$EDITOR\\` |\n| \\`${voice}\\` | Speak instead of type |\n| \\`${pasteImage}\\` | Paste image from clipboard |\n| \\`${followUp}\\` | Queue a follow-up while the agent works |\n| \\`${dequeue}\\` | Bring every queued message back to the editor |\n| \\`/\\` \\`!\\` \\`!!\\` | Slash commands · run bash · run bash off the record |\n\n**Steer** — what the agent is before it runs\nThe only three keys that change what happens next, and the only three that cost\nanything. Step forward on the first key, back on the second; the footer shows\nall three.\n\n| Key | Steps | Through |\n|-----|-------|---------|\n| \\`${cycleMode}\\` / \\`${cycleModeBack}\\` | Agent mode | ask → plan → build → debug (\\`/mode\\` picks one) |\n| \\`${cycleModelForward}\\` / \\`${cycleModelBackward}\\` | Model | your enabled models (\\`/model\\` picks one) |\n| \\`${cycleThinkingLevel}\\` / \\`${cycleThinkingLevelBack}\\` | Thinking level | off → … → high |\n\n**Read** — what you see of what it did\nFree and reversible, every one: nothing here touches the work, only the window\nonto it. Press again or add \\`Shift\\` and you are back where you were.\n\n| Key | Action |\n|-----|--------|\n| \\`${viewForward}\\` / \\`${viewBackward}\\` | Step tool output: radar → peek → full |\n| \\`${cycleTaskView}\\` / \\`${cycleTaskViewBack}\\` | Step the task panel: tasks → subagents → teams |\n| \\`${expandTools}\\` | Jump to the full view and back, without moving the dial |\n| \\`${toggleThinking}\\` | Show or hide thinking blocks |\n| \\`${teamFocus}\\` | Focus the team roster — \\`${teamNudge}\\` nudges, \\`${teamAttach}\\` attaches, \\`q\\`/\\`${interrupt}\\` leaves (\\`--team\\`) |\n\n**Go** — sessions and places\nEach takes the screen and hands it back on \\`${interrupt}\\`, and each has a slash\ncommand that does the same thing.\n\n| Key | Action |\n|-----|--------|\n| \\`${sessionResume}\\` | Resume a session from history (\\`/resume\\`) |\n| \\`${changeDirectory}\\` | Change working directory (\\`/cd\\`) |\n| \\`${cycleSessionColor}\\` / \\`${cycleSessionColorBackward}\\` | Step the session chip's color (\\`/color\\` picks one) |\n| \\`${openSettings}\\` | Open settings (\\`/settings\\`) |\n| \\`${openHotkeys}\\` | Show this list (\\`/hotkeys\\`) |\n| \\`/tree\\` | Open the session tree |\n\n**Flow** — getting out, getting back\n| Key | Action |\n|-----|--------|\n| \\`${interrupt}\\` | Cancel autocomplete / abort streaming |\n| \\`${clear}\\` | Clear editor (first) / exit (second) |\n| \\`${exit}\\` | Exit when the editor is empty |\n| \\`${suspend}\\` | Suspend to background |\n\n### What the chord tells you\n\n\\`Alt\\`+letter **sets a value** and nothing takes the screen — you keep typing.\nSix of those are dials, and \\`Shift\\` always steps one back: **a**gent mode,\n**m**odel, **t**hinking, tool **o**utput, task **l**ist, session **c**olor.\n\n\\`Ctrl\\`+letter **acts on what is drawn right now** and shares its letter with the\n\\`Alt\\` key for the same subject. \\`${viewForward}\\` sets how much tool output there\never is, \\`${expandTools}\\` jumps to all of it and back; \\`${cycleThinkingLevel}\\`\nsets how much thinking there ever is, \\`${toggleThinking}\\` shows or hides what you\nhave. Two subjects, two letters, four keys.\n\nThe thinking level also answers to \\`Shift+Tab\\` — it is the one dial with no\nslash command, so that is the way to it on a terminal that does not send \\`Alt\\`.\n\nInside a picker every \\`Ctrl\\` key still edits the query, so the picker's own verbs\nare on \\`Alt\\` and its hint line names them.\n\n### The editor\nStandard readline/emacs bindings — reference, not something to memorise.\n\n| Key | Action |\n|-----|--------|\n| \\`${cursorUp}\\` / \\`${cursorDown}\\` / \\`${cursorLeft}\\` / \\`${cursorRight}\\` | Move cursor / browse history (Up when empty) |\n| \\`${cursorWordLeft}\\` / \\`${cursorWordRight}\\` | Move by word |\n| \\`${cursorLineStart}\\` / \\`${cursorLineEnd}\\` | Start / end of line |\n| \\`${jumpForward}\\` / \\`${jumpBackward}\\` | Jump forward / backward to character |\n| \\`${pageUp}\\` / \\`${pageDown}\\` | Scroll by page |\n| \\`${deleteWordBackward}\\` / \\`${deleteWordForward}\\` | Delete word backwards / forwards |\n| \\`${deleteToLineStart}\\` / \\`${deleteToLineEnd}\\` | Delete to start / end of line |\n| \\`${yank}\\` / \\`${yankPop}\\` | Paste the most-recently-deleted text / cycle older ones |\n| \\`${undo}\\` | Undo |\n`;\n\n\t\t// Add extension-registered shortcuts\n\t\tconst extensionRunner = this.ctx.session.extensionRunner;\n\t\tconst shortcuts = extensionRunner.getShortcuts(this.ctx.keybindings.getEffectiveConfig());\n\t\tif (shortcuts.size > 0) {\n\t\t\thotkeys += `\n**Extensions**\n| Key | Action |\n|-----|--------|\n`;\n\t\t\tfor (const [key, shortcut] of shortcuts) {\n\t\t\t\tconst description = shortcut.description ?? shortcut.extensionPath;\n\t\t\t\tconst keyDisplay = formatKeyText(key, { capitalize: true });\n\t\t\t\thotkeys += `| \\`${keyDisplay}\\` | ${description} |\\n`;\n\t\t\t}\n\t\t}\n\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new DynamicBorder());\n\t\tthis.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg(\"accent\", \"Keyboard Shortcuts\")), 1, 0));\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 0, this.ctx.getMarkdownThemeWithSettings()));\n\t\tthis.ctx.chatContainer.addChild(new DynamicBorder());\n\t\tthis.ctx.ui.requestRender();\n\t}\n\n\tasync handleClear(): Promise<void> {\n\t\tthis.ctx.stopLoadingAnimation();\n\t\tthis.ctx.statusContainer.clear();\n\t\ttry {\n\t\t\tconst result = await this.ctx.runtimeHost.newSession();\n\t\t\tif (result.cancelled) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.ctx.renderCurrentSessionState();\n\t\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.ctx.chatContainer.addChild(new Text(`${theme.fg(\"accent\", \"✓ New session started\")}`, 1, 0));\n\t\t\tthis.ctx.ui.requestRender();\n\t\t} catch (error: unknown) {\n\t\t\tawait this.ctx.handleFatalRuntimeError(\"Failed to create session\", error);\n\t\t}\n\t}\n\n\t/**\n\t * `/cd [path]` — move the whole session to another directory.\n\t *\n\t * Written for the case it exists to serve: you are done in one repo and want\n\t * to work in the next one without losing the process, its provider auth, or\n\t * its warmed model list. Bare `/cd` goes home, `/cd -` goes back to where you\n\t * came from, `~` and relative paths resolve as a shell would.\n\t *\n\t * The move starts a fresh session rooted in the target — see\n\t * `AgentSessionRuntime.changeDirectory` for why the whole runtime is rebuilt\n\t * rather than a cwd string reassigned.\n\t */\n\tasync handleChangeDirectory(text: string): Promise<void> {\n\t\tconst prefix = \"/cd\";\n\t\tconst rawArg = text.startsWith(prefix) ? text.slice(prefix.length).trim() : text.trim();\n\t\tconst previousCwd = this.ctx.sessionManager.getCwd();\n\n\t\tlet target: string;\n\t\tif (!rawArg || rawArg === \"~\") {\n\t\t\ttarget = os.homedir();\n\t\t} else if (rawArg === \"-\") {\n\t\t\tif (!this.previousCwd) {\n\t\t\t\tthis.ctx.showWarning(\"No previous directory to return to\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttarget = this.previousCwd;\n\t\t} else {\n\t\t\tconst expanded = rawArg.startsWith(\"~/\") ? path.join(os.homedir(), rawArg.slice(2)) : rawArg;\n\t\t\ttarget = path.resolve(previousCwd, expanded);\n\t\t}\n\n\t\tif (path.resolve(target) === path.resolve(previousCwd)) {\n\t\t\tthis.ctx.showStatus(`Already in ${target}`);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.ctx.stopLoadingAnimation();\n\t\tthis.ctx.statusContainer.clear();\n\t\ttry {\n\t\t\tconst result = await this.ctx.runtimeHost.changeDirectory(target);\n\t\t\tif (result.cancelled) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.previousCwd = previousCwd;\n\t\t\tthis.ctx.renderCurrentSessionState();\n\t\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.ctx.chatContainer.addChild(\n\t\t\t\tnew Text(\n\t\t\t\t\t`${theme.fg(\"accent\", \"✓ Working directory\")} ${theme.fg(\"muted\", result.cwd)}\\n` +\n\t\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t\t`New session started here. ${keyDisplayText(\"app.session.resume\")} reopens the session you left in ${previousCwd}.`,\n\t\t\t\t\t\t),\n\t\t\t\t\t1,\n\t\t\t\t\t0,\n\t\t\t\t),\n\t\t\t);\n\t\t\tthis.ctx.ui.requestRender();\n\t\t} catch (error: unknown) {\n\t\t\tif (error instanceof ChangeDirectoryError) {\n\t\t\t\tthis.ctx.showError(error.message);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait this.ctx.handleFatalRuntimeError(`Failed to change directory to ${target}`, error);\n\t\t}\n\t}\n\n\t/** Directory completions for `/cd <prefix>`, plus the two shell shorthands. */\n\tgetChangeDirectoryCompletions(argumentPrefix: string): Array<{ value: string; label: string }> {\n\t\tconst cwd = this.ctx.sessionManager.getCwd();\n\t\tconst expanded = argumentPrefix.startsWith(\"~/\")\n\t\t\t? path.join(os.homedir(), argumentPrefix.slice(2))\n\t\t\t: argumentPrefix;\n\t\t// A prefix ending in a separator names the directory to list; otherwise the\n\t\t// last segment is a partial name to filter its parent by.\n\t\tconst endsWithSep = expanded.endsWith(\"/\") || expanded.endsWith(path.sep);\n\t\tconst base = endsWithSep ? expanded : path.dirname(expanded);\n\t\tconst partial = endsWithSep ? \"\" : path.basename(expanded);\n\t\tconst searchDir = path.isAbsolute(base) ? base : path.resolve(cwd, base || \".\");\n\n\t\tconst completions: Array<{ value: string; label: string }> = [];\n\t\tif (!argumentPrefix) {\n\t\t\tif (this.previousCwd) completions.push({ value: \"-\", label: `- (${this.previousCwd})` });\n\t\t\tcompletions.push({ value: \"~\", label: `~ (${os.homedir()})` });\n\t\t}\n\n\t\tlet entries: fs.Dirent[];\n\t\ttry {\n\t\t\tentries = fs.readdirSync(searchDir, { withFileTypes: true });\n\t\t} catch {\n\t\t\treturn completions;\n\t\t}\n\n\t\tfor (const entry of entries) {\n\t\t\tif (!entry.isDirectory()) continue;\n\t\t\tif (partial && !entry.name.startsWith(partial)) continue;\n\t\t\tif (!partial && entry.name.startsWith(\".\")) continue;\n\t\t\tconst joined =\n\t\t\t\tbase && !endsWithSep\n\t\t\t\t\t? path.join(path.dirname(argumentPrefix), entry.name)\n\t\t\t\t\t: `${argumentPrefix}${entry.name}`;\n\t\t\tcompletions.push({ value: `${joined}/`, label: `${entry.name}/` });\n\t\t}\n\t\treturn completions.slice(0, 50);\n\t}\n\n\thandleDebug(): void {\n\t\tconst width = this.ctx.ui.terminal.columns;\n\t\tconst height = this.ctx.ui.terminal.rows;\n\t\tconst allLines = this.ctx.ui.render(width);\n\n\t\tconst debugLogPath = getDebugLogPath();\n\t\tconst debugData = [\n\t\t\t`Debug output at ${new Date().toISOString()}`,\n\t\t\t`Terminal: ${width}x${height}`,\n\t\t\t`Total lines: ${allLines.length}`,\n\t\t\t\"\",\n\t\t\t\"=== All rendered lines with visible widths ===\",\n\t\t\t...allLines.map((line, idx) => {\n\t\t\t\tconst vw = visibleWidth(line);\n\t\t\t\tconst escaped = JSON.stringify(line);\n\t\t\t\treturn `[${idx}] (w=${vw}) ${escaped}`;\n\t\t\t}),\n\t\t\t\"\",\n\t\t\t\"=== Agent messages (JSONL) ===\",\n\t\t\t...this.ctx.session.messages.map((msg) => JSON.stringify(msg)),\n\t\t\t\"\",\n\t\t].join(\"\\n\");\n\n\t\tfs.mkdirSync(path.dirname(debugLogPath), { recursive: true });\n\t\tfs.writeFileSync(debugLogPath, debugData);\n\n\t\tthis.ctx.chatContainer.addChild(new Spacer(1));\n\t\tthis.ctx.chatContainer.addChild(\n\t\t\tnew Text(`${theme.fg(\"accent\", \"✓ Debug log written\")}\\n${theme.fg(\"muted\", debugLogPath)}`, 1, 0),\n\t\t);\n\t\tthis.ctx.ui.requestRender();\n\t}\n}\n"]}
@@ -18,7 +18,7 @@ import { getChangelogPath, parseChangelog } from "../../utils/changelog.js";
18
18
  import { copyToClipboard } from "../../utils/clipboard.js";
19
19
  import { BorderedLoader } from "./components/bordered-loader.js";
20
20
  import { DynamicBorder } from "./components/dynamic-border.js";
21
- import { formatKeyText, keyDisplayText } from "./components/keybinding-hints.js";
21
+ import { formatKeyText, keyDisplayLabel, keyDisplayText } from "./components/keybinding-hints.js";
22
22
  import { renderSessionChip } from "./components/session-chip.js";
23
23
  import { theme } from "./theme/theme.js";
24
24
  export class CommandExecutor {
@@ -451,94 +451,127 @@ export class CommandExecutor {
451
451
  const clear = keyDisplayText("app.clear");
452
452
  const exit = keyDisplayText("app.exit");
453
453
  const suspend = keyDisplayText("app.suspend");
454
- const cycleThinkingLevel = keyDisplayText("app.thinking.cycle");
455
- const cycleModelForward = keyDisplayText("app.model.cycleForward");
456
- const selectModel = keyDisplayText("app.model.select");
454
+ const cycleThinkingLevel = keyDisplayLabel("app.thinking.cycleForward");
455
+ const cycleThinkingLevelBack = keyDisplayLabel("app.thinking.cycleBackward");
456
+ const cycleModelForward = keyDisplayLabel("app.model.cycleForward");
457
457
  const expandTools = keyDisplayText("app.tools.expand");
458
458
  const toggleThinking = keyDisplayText("app.thinking.toggle");
459
- const cycleTaskView = keyDisplayText("app.tasks.cycleView");
459
+ const cycleTaskView = keyDisplayLabel("app.tasks.cycleForward");
460
+ const cycleTaskViewBack = keyDisplayLabel("app.tasks.cycleBackward");
461
+ const teamFocus = keyDisplayText("app.team.focus");
462
+ const teamNudge = keyDisplayText("app.team.nudge");
463
+ const teamAttach = keyDisplayText("app.team.attach");
460
464
  const externalEditor = keyDisplayText("app.editor.external");
461
- const cycleModelBackward = keyDisplayText("app.model.cycleBackward");
465
+ const cycleModelBackward = keyDisplayLabel("app.model.cycleBackward");
462
466
  const followUp = keyDisplayText("app.message.followUp");
463
467
  const dequeue = keyDisplayText("app.message.dequeue");
464
468
  const pasteImage = keyDisplayText("app.clipboard.pasteImage");
465
- const viewForward = keyDisplayText("app.view.cycleForward");
466
- const viewBackward = keyDisplayText("app.view.cycleBackward");
469
+ const viewForward = keyDisplayLabel("app.view.cycleForward");
470
+ const viewBackward = keyDisplayLabel("app.view.cycleBackward");
467
471
  const voice = keyDisplayText("app.input.voiceTranscribe");
468
472
  const changeDirectory = keyDisplayText("app.session.changeDirectory");
469
473
  const openSettings = keyDisplayText("app.settings.open");
470
474
  const openHotkeys = keyDisplayText("app.hotkeys.open");
471
- const cycleMode = keyDisplayText("app.mode.cycle");
472
- const sessionTree = keyDisplayText("app.session.tree");
475
+ const cycleMode = keyDisplayLabel("app.mode.cycleForward");
476
+ const cycleModeBack = keyDisplayLabel("app.mode.cycleBackward");
473
477
  const sessionResume = keyDisplayText("app.session.resume");
474
- const cycleSessionColor = keyDisplayText("app.session.color.cycleForward");
475
- const cycleSessionColorBackward = keyDisplayText("app.session.color.cycleBackward");
478
+ const cycleSessionColor = keyDisplayLabel("app.session.color.cycleForward");
479
+ const cycleSessionColorBackward = keyDisplayLabel("app.session.color.cycleBackward");
476
480
  let hotkeys = `
477
- **Navigation**
478
- | Key | Action |
479
- |-----|--------|
480
- | \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history (Up when empty) |
481
- | \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
482
- | \`${cursorLineStart}\` | Start of line |
483
- | \`${cursorLineEnd}\` | End of line |
484
- | \`${jumpForward}\` | Jump forward to character |
485
- | \`${jumpBackward}\` | Jump backward to character |
486
- | \`${pageUp}\` / \`${pageDown}\` | Scroll by page |
481
+ Grouped by what you are doing, not by what the key is. Five groups, none bigger
482
+ than five the size a person can actually hold. Two of them are free: **Flow**
483
+ is what every terminal program already taught you, and every picker prints its
484
+ own keys on its own hint line, so you read those instead of remembering them.
487
485
 
488
- **Editing**
486
+ **Compose** — the message in your hands
489
487
  | Key | Action |
490
488
  |-----|--------|
491
489
  | \`${submit}\` | Send message |
492
490
  | \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} |
493
- | \`${deleteWordBackward}\` | Delete word backwards |
494
- | \`${deleteWordForward}\` | Delete word forwards |
495
- | \`${deleteToLineStart}\` | Delete to start of line |
496
- | \`${deleteToLineEnd}\` | Delete to end of line |
497
- | \`${yank}\` | Paste the most-recently-deleted text |
498
- | \`${yankPop}\` | Cycle through the deleted text after pasting |
499
- | \`${undo}\` | Undo |
500
-
501
- **Flow** — the keys you hit without thinking
502
- | Key | Action |
503
- |-----|--------|
504
491
  | \`${tab}\` | Path completion / accept autocomplete |
505
- | \`${interrupt}\` | Cancel autocomplete / abort streaming |
506
- | \`${clear}\` | Clear editor (first) / exit (second) |
507
- | \`${exit}\` | Exit (when editor is empty) |
508
- | \`${suspend}\` | Suspend to background |
509
- | \`${followUp}\` | Queue follow-up message |
510
- | \`${dequeue}\` | Restore queued messages |
492
+ | \`${externalEditor}\` | Edit the message in \`$VISUAL\` / \`$EDITOR\` |
493
+ | \`${voice}\` | Speak instead of type |
511
494
  | \`${pasteImage}\` | Paste image from clipboard |
512
- | \`/\` | Slash commands |
513
- | \`!\` | Run bash command |
514
- | \`!!\` | Run bash command (excluded from context) |
495
+ | \`${followUp}\` | Queue a follow-up while the agent works |
496
+ | \`${dequeue}\` | Bring every queued message back to the editor |
497
+ | \`/\` \`!\` \`!!\` | Slash commands · run bash · run bash off the record |
498
+
499
+ **Steer** — what the agent is before it runs
500
+ The only three keys that change what happens next, and the only three that cost
501
+ anything. Step forward on the first key, back on the second; the footer shows
502
+ all three.
503
+
504
+ | Key | Steps | Through |
505
+ |-----|-------|---------|
506
+ | \`${cycleMode}\` / \`${cycleModeBack}\` | Agent mode | ask → plan → build → debug (\`/mode\` picks one) |
507
+ | \`${cycleModelForward}\` / \`${cycleModelBackward}\` | Model | your enabled models (\`/model\` picks one) |
508
+ | \`${cycleThinkingLevel}\` / \`${cycleThinkingLevelBack}\` | Thinking level | off → … → high |
509
+
510
+ **Read** — what you see of what it did
511
+ Free and reversible, every one: nothing here touches the work, only the window
512
+ onto it. Press again or add \`Shift\` and you are back where you were.
515
513
 
516
- **View** — what is on screen right now (\`Ctrl\`)
517
514
  | Key | Action |
518
515
  |-----|--------|
519
- | \`${expandTools}\` | Jump to the full view and back |
520
- | \`${viewForward}\` / \`${viewBackward}\` | Cycle tool output: radarpeekfull |
521
- | \`${toggleThinking}\` | Toggle thinking block visibility |
522
- | \`${cycleTaskView}\` | Cycle task panel view (tasks → subagents → teams) |
523
- | \`${cycleModelForward}\` / \`${cycleModelBackward}\` | Cycle models |
524
- | \`${cycleThinkingLevel}\` | Cycle thinking level |
516
+ | \`${viewForward}\` / \`${viewBackward}\` | Step tool output: radar peek full |
517
+ | \`${cycleTaskView}\` / \`${cycleTaskViewBack}\` | Step the task panel: taskssubagentsteams |
518
+ | \`${expandTools}\` | Jump to the full view and back, without moving the dial |
519
+ | \`${toggleThinking}\` | Show or hide thinking blocks |
520
+ | \`${teamFocus}\` | Focus the team roster — \`${teamNudge}\` nudges, \`${teamAttach}\` attaches, \`q\`/\`${interrupt}\` leaves (\`--team\`) |
521
+
522
+ **Go** — sessions and places
523
+ Each takes the screen and hands it back on \`${interrupt}\`, and each has a slash
524
+ command that does the same thing.
525
525
 
526
- **Cockpit** — what the agent is, and where it works (\`Alt\`)
527
526
  | Key | Action |
528
527
  |-----|--------|
529
- | \`${cycleMode}\` | Cycle agent mode (ask plan → build → debug) |
530
- | \`${selectModel}\` | Open model selector |
528
+ | \`${sessionResume}\` | Resume a session from history (\`/resume\`) |
531
529
  | \`${changeDirectory}\` | Change working directory (\`/cd\`) |
532
- | \`${sessionTree}\` | Open session tree |
533
- | \`${sessionResume}\` | Resume a session from history |
534
- | \`${cycleSessionColor}\` / \`${cycleSessionColorBackward}\` | Cycle the session chip's color (\`/color\` to pick one) |
535
- | \`${openSettings}\` | Open settings |
536
- | \`${openHotkeys}\` | Show this list |
537
- | \`${externalEditor}\` | Edit message in external editor |
538
- | \`${voice}\` | Record voice and transcribe |
530
+ | \`${cycleSessionColor}\` / \`${cycleSessionColorBackward}\` | Step the session chip's color (\`/color\` picks one) |
531
+ | \`${openSettings}\` | Open settings (\`/settings\`) |
532
+ | \`${openHotkeys}\` | Show this list (\`/hotkeys\`) |
533
+ | \`/tree\` | Open the session tree |
534
+
535
+ **Flow** getting out, getting back
536
+ | Key | Action |
537
+ |-----|--------|
538
+ | \`${interrupt}\` | Cancel autocomplete / abort streaming |
539
+ | \`${clear}\` | Clear editor (first) / exit (second) |
540
+ | \`${exit}\` | Exit when the editor is empty |
541
+ | \`${suspend}\` | Suspend to background |
542
+
543
+ ### What the chord tells you
544
+
545
+ \`Alt\`+letter **sets a value** and nothing takes the screen — you keep typing.
546
+ Six of those are dials, and \`Shift\` always steps one back: **a**gent mode,
547
+ **m**odel, **t**hinking, tool **o**utput, task **l**ist, session **c**olor.
548
+
549
+ \`Ctrl\`+letter **acts on what is drawn right now** and shares its letter with the
550
+ \`Alt\` key for the same subject. \`${viewForward}\` sets how much tool output there
551
+ ever is, \`${expandTools}\` jumps to all of it and back; \`${cycleThinkingLevel}\`
552
+ sets how much thinking there ever is, \`${toggleThinking}\` shows or hides what you
553
+ have. Two subjects, two letters, four keys.
554
+
555
+ The thinking level also answers to \`Shift+Tab\` — it is the one dial with no
556
+ slash command, so that is the way to it on a terminal that does not send \`Alt\`.
539
557
 
540
- Inside a picker, every \`Ctrl\` key still edits the query the picker's own
541
- verbs are on \`Alt\`, and its hint line names them.
558
+ Inside a picker every \`Ctrl\` key still edits the query, so the picker's own verbs
559
+ are on \`Alt\` and its hint line names them.
560
+
561
+ ### The editor
562
+ Standard readline/emacs bindings — reference, not something to memorise.
563
+
564
+ | Key | Action |
565
+ |-----|--------|
566
+ | \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history (Up when empty) |
567
+ | \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
568
+ | \`${cursorLineStart}\` / \`${cursorLineEnd}\` | Start / end of line |
569
+ | \`${jumpForward}\` / \`${jumpBackward}\` | Jump forward / backward to character |
570
+ | \`${pageUp}\` / \`${pageDown}\` | Scroll by page |
571
+ | \`${deleteWordBackward}\` / \`${deleteWordForward}\` | Delete word backwards / forwards |
572
+ | \`${deleteToLineStart}\` / \`${deleteToLineEnd}\` | Delete to start / end of line |
573
+ | \`${yank}\` / \`${yankPop}\` | Paste the most-recently-deleted text / cycle older ones |
574
+ | \`${undo}\` | Undo |
542
575
  `;
543
576
  // Add extension-registered shortcuts
544
577
  const extensionRunner = this.ctx.session.extensionRunner;