@kolisachint/hoocode-agent 0.5.21 → 0.5.22
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.
- package/CHANGELOG.md +26 -0
- package/dist/core/agent-log.d.ts +44 -0
- package/dist/core/agent-log.d.ts.map +1 -0
- package/dist/core/agent-log.js +74 -0
- package/dist/core/agent-log.js.map +1 -0
- package/dist/core/lifeguard.d.ts.map +1 -1
- package/dist/core/lifeguard.js +2 -1
- package/dist/core/lifeguard.js.map +1 -1
- package/dist/core/subagent-pool.d.ts.map +1 -1
- package/dist/core/subagent-pool.js +6 -4
- package/dist/core/subagent-pool.js.map +1 -1
- package/dist/core/tools/subagent.d.ts.map +1 -1
- package/dist/core/tools/subagent.js +3 -2
- package/dist/core/tools/subagent.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +7 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/docs/routing.md +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"interactive-mode.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/interactive-mode.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAQH,OAAO,KAAK,EAAoB,YAAY,EAAW,MAAM,yBAAyB,CAAC;AA+BvF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAsB/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAyFlE;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACtC,gEAAgE;IAChE,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,4DAA4D;IAC5D,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,qEAAqE;IACrE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,YAAY,EAAE,CAAC;IAC/B,4DAA4D;IAC5D,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,6DAA6D;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAC;CAClB;AAwDD,qBAAa,eAAe;IA8N1B,OAAO,CAAC,OAAO;IA7NhB,OAAO,CAAC,WAAW,CAAsB;IACzC,OAAO,CAAC,EAAE,CAAM;IAChB,OAAO,CAAC,aAAa,CAAY;IACjC,OAAO,CAAC,wBAAwB,CAAY;IAC5C,OAAO,CAAC,eAAe,CAAY;IACnC,OAAO,CAAC,aAAa,CAAe;IACpC,OAAO,CAAC,MAAM,CAAkB;IAChC,OAAO,CAAC,sBAAsB,CAA4B;IAC1D,OAAO,CAAC,KAAK,CAAkB;IAC/B,OAAO,CAAC,oBAAoB,CAAmC;IAC/D,OAAO,CAAC,4BAA4B,CAAqC;IACzE,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,eAAe,CAAY;IACnC,OAAO,CAAC,MAAM,CAAkB;IAChC,OAAO,CAAC,kBAAkB,CAAqB;IAE/C,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,eAAe,CAAC,CAAyB;IACjD,OAAO,CAAC,gBAAgB,CAAiC;IACzD,OAAO,CAAC,cAAc,CAAiC;IACvD,OAAO,CAAC,cAAc,CAAQ;IAC9B,OAAO,CAAC,uBAAuB,CAAiD;IAChF,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAgB;IACtD,OAAO,CAAC,QAAQ,CAAC,0BAA0B,CAAiB;IAC5D,OAAO,CAAC,mBAAmB,CAAmC;IAE9D,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,iBAAiB,CAAiC;IAC1D,OAAO,CAAC,mBAAmB,CAAS;IAGpC,OAAO,CAAC,gBAAgB,CAAiC;IACzD,OAAO,CAAC,cAAc,CAA+B;IAGrD,OAAO,CAAC,kBAAkB,CAAoD;IAC9E,OAAO,CAAC,gBAAgB,CAA2C;IACnE;;0EAEsE;IACtE,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAMrC;IAGH,OAAO,CAAC,YAAY,CAA6C;IAGjE,OAAO,CAAC,kBAAkB,CAAS;IAEnC,OAAO,CAAC,iBAAiB,CAAiD;IAG1E,OAAO,CAAC,iBAAiB,CAAS;IAGlC,OAAO,CAAC,aAAa,CAA6B;IAGlD,OAAO,CAAC,WAAW,CAAC,CAAa;IAEjC,OAAO,CAAC,oBAAoB,CAAC,CAAa;IAE1C,OAAO,CAAC,0BAA0B,CAAC,CAAa;IAChD,OAAO,CAAC,qBAAqB,CAAyB;IAGtD,OAAO,CAAC,UAAU,CAAS;IAG3B,OAAO,CAAC,oBAAoB,CAAiC;IAC7D,OAAO,CAAC,2BAA2B,CAAC,CAAa;IAGjD,OAAO,CAAC,WAAW,CAAiC;IACpD,OAAO,CAAC,cAAc,CAAyC;IAC/D,OAAO,CAAC,kBAAkB,CAAC,CAAa;IAGxC,OAAO,CAAC,KAAK,CAAC,CAAkB;IAGhC,OAAO,CAAC,iBAAiB,CAAS;IAMlC,OAAO,CAAC,cAAc,CAAyD;IAM/E,OAAO,CAAC,cAAc,CAA2D;IAGjF,OAAO,CAAC,iBAAiB,CAAS;IAGlC,OAAO,CAAC,OAAO,CAAmB;IAClC,OAAO,CAAC,mCAAmC,CAAyB;IAGpE,OAAO,CAAC,MAAM,CAAmB;IACjC,OAAO,CAAC,oBAAoB,CAAa;IACzC,OAAO,CAAC,oBAAoB,CAAa;IAGzC,OAAO,CAAC,SAAS,CAAqB;IAGtC,OAAO,CAAC,SAAS,CAAsB;IAGvC,OAAO,CAAC,aAAa,CAA0B;IAG/C,OAAO,CAAC,YAAY,CAAyB;IAG7C,OAAO,CAAC,eAAe,CAAkB;IAGzC,OAAO,CAAC,eAAe,CAAkB;IAGzC,OAAO,CAAC,eAAe,CAAY;IAGnC,OAAO,CAAC,aAAa,CAAoC;IAGzD,OAAO,KAAK,OAAO,GAElB;IACD,OAAO,CAAC,gBAAgB,CAAC,CAAkB;IAC3C;;;;OAIG;IACH,OAAO,KAAK,eAAe,GAqD1B;IACD,OAAO,CAAC,oBAAoB;IAM5B,OAAO,KAAK,KAAK,GAEhB;IACD,OAAO,KAAK,cAAc,GAEzB;IACD,OAAO,KAAK,eAAe,GAE1B;IAED,YACC,WAAW,EAAE,mBAAmB,EACxB,OAAO,GAAE,sBAA2B,EA0I5C;IAED,OAAO,CAAC,wBAAwB;IAyBhC,OAAO,CAAC,6BAA6B;IAQrC,OAAO,CAAC,oCAAoC;IAe5C,OAAO,CAAC,8BAA8B;IA6EtC,OAAO,CAAC,yBAAyB;IAajC,OAAO,CAAC,0BAA0B;IA8BlC,qFAAqF;IACrF,OAAO,CAAC,kBAAkB;IAIpB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAmK1B;IAED;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAU3B;;;OAGG;IACG,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,CAyEzB;IAED,OAAO,CAAC,4BAA4B;IAWpC,OAAO,CAAC,wBAAwB;IAIhC,4EAA4E;IAC5E,OAAO,CAAC,mBAAmB;IAiC3B;;;OAGG;IACH,OAAO,CAAC,yBAAyB;YAYnB,4BAA4B;IAmF1C,OAAO,CAAC,oBAAoB;YAoBd,oBAAoB;YAWpB,uBAAuB;IAQrC,OAAO,CAAC,yBAAyB;IAUjC;;;;;;OAMG;IACH,OAAO,CAAC,oBAAoB;IAa5B;;OAEG;IACH,OAAO,CAAC,2BAA2B;IAInC;;OAEG;IACH,OAAO,CAAC,uBAAuB;IAoD/B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAK1B,OAAO,CAAC,uBAAuB;IAI/B,OAAO,CAAC,mBAAmB;IAU3B,OAAO,CAAC,iBAAiB;IAQzB,OAAO,CAAC,iBAAiB;IAezB,OAAO,CAAC,mBAAmB;IAM3B,OAAO,CAAC,sBAAsB;IAa9B,OAAO,CAAC,gBAAgB;IAqBxB,OAAO,CAAC,iCAAiC;IAWzC,OAAO,CAAC,oCAAoC;IAO5C;;OAEG;IACH,OAAO,CAAC,wBAAwB;YA2ElB,0BAA0B;IAKxC;;;OAGG;IACH,OAAO,CAAC,wBAAwB;IAkEhC;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAU3B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAsB1B,OAAO,CAAC,gBAAgB;YAwEV,yBAAyB;IA0BvC;;;OAGG;IACH,gBAAgB,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI,CAEjD;IAED;;;;;OAKG;IACH,OAAO,CAAC,0BAA0B;IA6JlC,OAAO,CAAC,wBAAwB;IAmEhC,OAAO,CAAC,gBAAgB;IAMxB;;;;;;;;;;OAUG;IACH,OAAO,CAAC,mBAAmB;IAW3B;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,uBAAuB;IAM/B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,YAAY;YAmCN,WAAW;IAyXzB,+CAA+C;IAC/C,OAAO,CAAC,kBAAkB;IAS1B;;;;;OAKG;IAEH,OAAO,CAAC,UAAU;IAoBlB,OAAO,CAAC,gBAAgB;IA4FxB;;;;;OAKG;IACH,OAAO,CAAC,oBAAoB;IAuE5B,qBAAqB,IAAI,IAAI,CAe5B;IAEK,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,CAOpC;IAED,OAAO,CAAC,uBAAuB;IAU/B,OAAO,CAAC,WAAW;IAUnB,OAAO,CAAC,WAAW;IAKnB;;;;OAIG;IACH,OAAO,CAAC,cAAc,CAAS;YAEjB,QAAQ;IAkBtB,OAAO,CAAC,qBAAqB;YAYf,sBAAsB;IAKpC,OAAO,CAAC,sBAAsB;IAgC9B,OAAO,CAAC,wBAAwB;IAOhC,OAAO,CAAC,WAAW;YAqCL,cAAc;IAgC5B,OAAO,CAAC,aAAa;IASrB,OAAO,CAAC,uBAAuB;IAU/B,OAAO,CAAC,wBAAwB;IAWhC,OAAO,CAAC,kBAAkB;IAW1B,OAAO,CAAC,yBAAyB;IAIjC,OAAO,CAAC,gBAAgB;IAcxB,OAAO,CAAC,6BAA6B;IAkBrC,OAAO,CAAC,kBAAkB;IAoD1B,WAAW,IAAI,IAAI,CAGlB;IAED;;;;;;;;;OASG;IACH,OAAO,CAAC,SAAS;IAWjB;;;;;;OAMG;IACH,OAAO,CAAC,iBAAiB;IAKzB,SAAS,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAGpC;IAED,WAAW,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAGxC;IAED;;;OAGG;IACH,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAE9C;IAED,0BAA0B,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAoBnD;IAED,6BAA6B,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,CAgBtD;IAMD;;;OAGG;IACH,OAAO,CAAC,YAAY;IAapB,OAAO,CAAC,oBAAoB;IA6S5B,OAAO,CAAC,uBAAuB;IAyC/B,OAAO,CAAC,gBAAgB;IAiIxB,OAAO,CAAC,mBAAmB;YAmCb,mBAAmB;YA6CnB,mBAAmB;YAmFnB,oBAAoB;IAkBlC,IAAI,IAAI,IAAI,CAoBX;CACD","sourcesContent":["/**\n * Interactive mode for the coding agent.\n * Handles TUI rendering and user interaction, delegating business logic to AgentSession.\n */\n\nimport * as crypto from \"node:crypto\";\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport type { AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport { createCompactionSummaryMessage } from \"@kolisachint/hoocode-agent-core\";\nimport type { AssistantMessage, ImageContent, Message } from \"@kolisachint/hoocode-ai\";\nimport type {\n\tAutocompleteItem,\n\tAutocompleteProvider,\n\tEditorComponent,\n\tKeyId,\n\tMarkdownTheme,\n\tSlashCommand,\n} from \"@kolisachint/hoocode-tui\";\nimport {\n\tBox,\n\tCombinedAutocompleteProvider,\n\ttype Component,\n\tContainer,\n\tfuzzyFilter,\n\tgetCapabilities,\n\thyperlink,\n\tLoader,\n\ttype LoaderIndicatorOptions,\n\tMarkdown,\n\tmatchesKey,\n\tProcessTerminal,\n\tSpacer,\n\tsetKeybindings,\n\tText,\n\tTUI,\n} from \"@kolisachint/hoocode-tui\";\nimport { spawnSync } from \"child_process\";\nimport { APP_NAME, APP_TITLE, VERSION } from \"../../config.js\";\nimport { loadAgentRegistry } from \"../../core/agent-registry.js\";\nimport { type AgentSession, type AgentSessionEvent, parseSkillBlock } from \"../../core/agent-session.js\";\nimport type { AgentSessionRuntime } from \"../../core/agent-session-runtime.js\";\nimport { type AssistantUsageTotals, sumAssistantUsage } from \"../../core/agent-session-stats.js\";\nimport type {\n\tAutocompleteProviderFactory,\n\tEditorFactory,\n\tExtensionCommandContext,\n\tExtensionContext,\n\tExtensionRunner,\n\tExtensionUIContext,\n} from \"../../core/extensions/index.js\";\nimport { FooterDataProvider } from \"../../core/footer-data-provider.js\";\nimport { formatDurationSecs } from \"../../core/format-duration.js\";\nimport { formatTokens } from \"../../core/format-tokens.js\";\nimport { type AppKeybinding, KeybindingsManager } from \"../../core/keybindings.js\";\nimport type { ResourceDiagnostic } from \"../../core/resource-loader.js\";\nimport { formatMissingSessionCwdPrompt, MissingSessionCwdError } from \"../../core/session-cwd.js\";\nimport { type SessionContext, SessionManager } from \"../../core/session-manager.js\";\nimport type { SettingsManager } from \"../../core/settings-manager.js\";\nimport { BUILTIN_SLASH_COMMANDS } from \"../../core/slash-commands.js\";\nimport type { SourceInfo } from \"../../core/source-info.js\";\nimport { startupProgress } from \"../../core/startup-progress.js\";\nimport { taskStore } from \"../../core/task-store.js\";\nimport type { TeamViewConnection } from \"../../core/team-view.js\";\nimport { settleDanglingMainTasks } from \"../../core/tools/todo.js\";\nimport type { TruncationResult } from \"../../core/tools/truncate.js\";\nimport { buildCompactWordmark } from \"../../core/wordmark.js\";\nimport { extensionForImageMimeType, readClipboardImage } from \"../../utils/clipboard-image.js\";\nimport { parseGitUrl } from \"../../utils/git.js\";\nimport { killTrackedDetachedChildren } from \"../../utils/shell.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { checkForNewHooCodeVersion } from \"../../utils/version-check.js\";\nimport { BashExecutionController } from \"./bash-execution-controller.js\";\nimport { type CommandContext, CommandExecutor } from \"./command-executor.js\";\nimport { BELL, CompletionChime } from \"./completion-chime.js\";\nimport { AssistantMessageComponent } from \"./components/assistant-message.js\";\nimport { BashExecutionComponent } from \"./components/bash-execution.js\";\nimport { BranchSummaryMessageComponent } from \"./components/branch-summary-message.js\";\nimport { CompactionSummaryMessageComponent } from \"./components/compaction-summary-message.js\";\nimport { CountdownTimer } from \"./components/countdown-timer.js\";\nimport { CustomEditor } from \"./components/custom-editor.js\";\nimport { CustomMessageComponent } from \"./components/custom-message.js\";\nimport { DynamicBorder } from \"./components/dynamic-border.js\";\nimport { FooterComponent } from \"./components/footer.js\";\nimport { keyHint, keyText, rawKeyHint } from \"./components/keybinding-hints.js\";\nimport { SessionSelectorComponent } from \"./components/session-selector.js\";\nimport { SettingsSelectorComponent } from \"./components/settings-selector.js\";\nimport { SkillInvocationMessageComponent } from \"./components/skill-invocation-message.js\";\nimport { TaskPanelComponent } from \"./components/task-panel.js\";\nimport { ToolExecutionComponent } from \"./components/tool-execution.js\";\nimport { TreeSelectorComponent } from \"./components/tree-selector.js\";\nimport { UserMessageComponent } from \"./components/user-message.js\";\nimport { UserMessageSelectorComponent } from \"./components/user-message-selector.js\";\nimport { ExtensionChrome } from \"./extension-chrome.js\";\nimport { ExtensionDialogs } from \"./extension-dialogs.js\";\nimport { LoginController } from \"./login-controller.js\";\nimport { MessageQueueController } from \"./message-queue-controller.js\";\nimport { ModelController } from \"./model-controller.js\";\nimport {\n\tExpandableText,\n\tformatDisplayPath,\n\tisExpandable,\n\tshowLoadedResources as renderLoadedResources,\n} from \"./resource-display.js\";\nimport { checkForPackageUpdates, checkTmuxKeyboardSetup, getChangelogForDisplay } from \"./startup-checks.js\";\nimport { TeamFocusController } from \"./team-focus.js\";\nimport {\n\tgetAvailableThemes,\n\tgetAvailableThemesWithPaths,\n\tgetEditorTheme,\n\tgetMarkdownTheme,\n\tgetThemeByName,\n\tinitTheme,\n\tonThemeChange,\n\tsetRegisteredThemes,\n\tsetTheme,\n\tsetThemeInstance,\n\tstopThemeWatcher,\n\tTheme,\n\ttheme,\n} from \"./theme/theme.js\";\nimport { VoiceController } from \"./voice/voice-controller.js\";\n\n/** Interface for components that can be expanded/collapsed */\n\nconst DEAD_TERMINAL_ERROR_CODES = new Set([\"EIO\", \"EPIPE\", \"ENOTCONN\"]);\n\n/**\n * Resolve the effective voice trailing-silence window (ms): env\n * `VOICETOOLS_SILENCE_MS` wins (like `VOICETOOLS_BIN`), otherwise the settings\n * value. Both paths are clamped to 300-10000; a malformed env value falls back to\n * the settings value.\n */\nfunction resolveVoiceSilenceMs(settingsManager: SettingsManager): number {\n\tconst envRaw = process.env.VOICETOOLS_SILENCE_MS?.trim();\n\tif (envRaw) {\n\t\tconst envValue = Number(envRaw);\n\t\tif (Number.isFinite(envValue) && envValue > 0) {\n\t\t\treturn Math.min(10000, Math.max(300, Math.floor(envValue)));\n\t\t}\n\t}\n\treturn settingsManager.getVoiceSilenceMs();\n}\n\nfunction isDeadTerminalError(error: unknown): boolean {\n\tif (!error || typeof error !== \"object\" || !(\"code\" in error)) {\n\t\treturn false;\n\t}\n\tconst code = (error as NodeJS.ErrnoException).code;\n\treturn code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);\n}\n\n/**\n * Options for InteractiveMode initialization.\n */\nexport interface InteractiveModeOptions {\n\t/** Providers that were migrated to auth.json (shows warning) */\n\tmigratedProviders?: string[];\n\t/** Warning message if session model couldn't be restored */\n\tmodelFallbackMessage?: string;\n\t/** Initial message to send on startup (can include @file content) */\n\tinitialMessage?: string;\n\t/** Images to attach to the initial message */\n\tinitialImages?: ImageContent[];\n\t/** Additional messages to send after the initial message */\n\tinitialMessages?: string[];\n\t/** Force verbose startup (overrides quietStartup setting) */\n\tverbose?: boolean;\n}\n\n// How often the streaming assistant message re-parses its markdown. Deltas can\n// arrive far faster than this; every application re-lexes the growing tail\n// block, so applying per-delta made streaming cost O(message²).\nconst STREAM_RENDER_THROTTLE_MS = 100;\n// Task-store mutations (subagent tool progress, usage ticks) arrive in bursts;\n// each render they trigger reassembles the whole component tree, so cap them.\nconst TASK_RENDER_THROTTLE_MS = 50;\n// Finished tool blocks beyond this many (oldest first) are frozen to release\n// their retained output/image payloads (see ToolExecutionComponent.freeze).\n// Generous so nothing near the viewport is ever frozen; the cap bounds the\n// view layer's memory across a long session heavy on tool output.\nconst LIVE_TOOL_WINDOW = 50;\n\n/**\n * Leading+trailing throttle: the first call runs immediately, calls landing\n * inside the window coalesce into one trailing run with the latest state.\n */\nfunction throttled(ms: number, fn: () => void): () => void {\n\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\tlet pending = false;\n\tconst run = () => {\n\t\tfn();\n\t\ttimer = setTimeout(() => {\n\t\t\ttimer = undefined;\n\t\t\tif (pending) {\n\t\t\t\tpending = false;\n\t\t\t\trun();\n\t\t\t}\n\t\t}, ms);\n\t\ttimer.unref?.();\n\t};\n\treturn () => {\n\t\tif (timer) {\n\t\t\tpending = true;\n\t\t\treturn;\n\t\t}\n\t\trun();\n\t};\n}\n\n/**\n * Style a status/notify message for the chat.\n *\n * Plain messages are dimmed, as they always were. A message that already\n * carries escape codes is passed through untouched: `theme.fg` closes with\n * `\\x1b[39m` (reset foreground, not \"restore\"), so wrapping a pre-styled string\n * in dim would drop the dim at the first inner span — and the TUI's wrapper\n * carries that reset state across newlines, so every following line would lose\n * it too. Passing through is what lets a listing colour its own columns.\n */\nfunction styleStatusMessage(message: string): string {\n\treturn message.includes(\"\\x1b[\") ? message : theme.fg(\"dim\", message);\n}\n\nexport class InteractiveMode {\n\tprivate runtimeHost: AgentSessionRuntime;\n\tprivate ui: TUI;\n\tprivate chatContainer: Container;\n\tprivate pendingMessagesContainer: Container;\n\tprivate statusContainer: Container;\n\tprivate defaultEditor: CustomEditor;\n\tprivate editor: EditorComponent;\n\tprivate editorComponentFactory: EditorFactory | undefined;\n\tprivate voice: VoiceController;\n\tprivate autocompleteProvider: AutocompleteProvider | undefined;\n\tprivate autocompleteProviderWrappers: AutocompleteProviderFactory[] = [];\n\tprivate fdPath: string | undefined;\n\tprivate editorContainer: Container;\n\tprivate footer: FooterComponent;\n\tprivate footerDataProvider: FooterDataProvider;\n\t// Stored so the same manager can be injected into custom editors, selectors, and extension UI.\n\tprivate keybindings: KeybindingsManager;\n\tprivate version: string;\n\tprivate isInitialized = false;\n\tprivate onInputCallback?: (text: string) => void;\n\tprivate loadingAnimation: Loader | undefined = undefined;\n\tprivate workingMessage: string | undefined = undefined;\n\tprivate workingVisible = true;\n\tprivate workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined;\n\tprivate readonly defaultWorkingMessage = \"Working...\";\n\tprivate readonly defaultHiddenThinkingLabel = \"Thinking...\";\n\tprivate hiddenThinkingLabel = this.defaultHiddenThinkingLabel;\n\n\tprivate lastSigintTime = 0;\n\tprivate lastEscapeTime = 0;\n\tprivate changelogMarkdown: string | undefined = undefined;\n\tprivate startupNoticesShown = false;\n\n\t// Status line tracking (for mutating immediately-sequential status updates)\n\tprivate lastStatusSpacer: Spacer | undefined = undefined;\n\tprivate lastStatusText: Text | undefined = undefined;\n\n\t// Streaming message tracking\n\tprivate streamingComponent: AssistantMessageComponent | undefined = undefined;\n\tprivate streamingMessage: AssistantMessage | undefined = undefined;\n\t/** Throttled re-parse/re-render of the in-flight assistant message. The\n\t * guard makes a trailing run after message_end/agent_end a no-op; a direct\n\t * updateContent on message_end flushes the final state regardless. */\n\tprivate readonly scheduleStreamingRender = throttled(STREAM_RENDER_THROTTLE_MS, () => {\n\t\tif (!this.streamingComponent || !this.streamingMessage) return;\n\t\t// streaming=true: large blocks render segmented so only the tail chunk\n\t\t// re-parses; message_end's direct updateContent renders the canonical form.\n\t\tthis.streamingComponent.updateContent(this.streamingMessage, true);\n\t\tthis.ui.requestRender();\n\t});\n\n\t// Tool execution tracking: toolCallId -> component\n\tprivate pendingTools = new Map<string, ToolExecutionComponent>();\n\n\t// Tool output expansion state\n\tprivate toolOutputExpanded = false;\n\t// Persisted tool-output display level (collapsed / peek / standard).\n\tprivate toolOutputDisplay: \"collapsed\" | \"peek\" | \"standard\" = \"standard\";\n\n\t// Thinking block visibility state\n\tprivate hideThinkingBlock = false;\n\n\t// Skill commands: command name -> skill file path\n\tprivate skillCommands = new Map<string, string>();\n\n\t// Agent subscription unsubscribe function\n\tprivate unsubscribe?: () => void;\n\t// Task store subscription unsubscribe function (task panel)\n\tprivate taskStoreUnsubscribe?: () => void;\n\t// Startup-progress subscription unsubscribe function (footer download/index bars)\n\tprivate startupProgressUnsubscribe?: () => void;\n\tprivate signalCleanupHandlers: Array<() => void> = [];\n\n\t// Track if editor is in bash mode (text starts with !)\n\tprivate isBashMode = false;\n\n\t// Auto-compaction state\n\tprivate autoCompactionLoader: Loader | undefined = undefined;\n\tprivate autoCompactionEscapeHandler?: () => void;\n\n\t// Auto-retry state\n\tprivate retryLoader: Loader | undefined = undefined;\n\tprivate retryCountdown: CountdownTimer | undefined = undefined;\n\tprivate retryEscapeHandler?: () => void;\n\n\t// Completion chime state\n\tprivate chime?: CompletionChime;\n\t// True between auto_retry_start and the next agent_start: the turn is not done,\n\t// it is about to re-run, so the deferred completion check must not fire the chime.\n\tprivate chimePendingRetry = false;\n\t// Stop reason of the request's latest assistant message, reset when the user\n\t// starts a new one. Read at settle time by two consumers that ask the same\n\t// question — did this request end cleanly? An \"aborted\" turn means the user is\n\t// present, so no chime; anything other than a clean \"stop\" means dangling plan\n\t// items must not be claimed as completed.\n\tprivate turnStopReason: AssistantMessage[\"stopReason\"] | undefined = undefined;\n\n\t// Cumulative usage + wall clock captured at the first agent_start of a request,\n\t// diffed at agent_end to print that request's own cost into the transcript.\n\t// Anchored on the FIRST start so a retried request reports one span, not one\n\t// per attempt (same reason the chime anchors its duration there).\n\tprivate turnCostAnchor: { totals: AssistantUsageTotals; at: number } | undefined;\n\n\t// Shutdown state\n\tprivate shutdownRequested = false;\n\n\t// Extension UI state\n\tprivate dialogs: ExtensionDialogs;\n\tprivate extensionTerminalInputUnsubscribers = new Set<() => void>();\n\n\t// Extension widgets (components rendered above/below the editor)\n\tprivate chrome!: ExtensionChrome;\n\tprivate widgetContainerAbove!: Container;\n\tprivate widgetContainerBelow!: Container;\n\n\t// Task panel shown just above the editor (active subagent tasks)\n\tprivate taskPanel: TaskPanelComponent;\n\n\t// hooteams team client (--team): steering + attach share its single SSE stream\n\tprivate teamFocus: TeamFocusController;\n\n\t// Bash command execution (the `!cmd` prompt mode)\n\tprivate bashExecution: BashExecutionController;\n\n\t// Message queueing (compaction queue + pending-messages display)\n\tprivate messageQueue: MessageQueueController;\n\n\t// Model selection (/model, /models, cycle keys)\n\tprivate modelController: ModelController;\n\n\t// Auth / login flows (/login, /logout)\n\tprivate loginController: LoginController;\n\n\t// Header container that holds the built-in or custom header\n\tprivate headerContainer: Container;\n\n\t// Built-in header (logo + keybinding hints + changelog)\n\tprivate builtInHeader: Component | undefined = undefined;\n\n\t// Convenience accessors\n\tprivate get session(): AgentSession {\n\t\treturn this.runtimeHost.session;\n\t}\n\tprivate _commandExecutor?: CommandExecutor;\n\t/**\n\t * Lazily-built command executor. The context uses getters for mutable\n\t * dependencies (e.g. the active session) so handlers always operate on the\n\t * current state even after a session switch.\n\t */\n\tprivate get commandExecutor(): CommandExecutor {\n\t\tif (!this._commandExecutor) {\n\t\t\tconst self = this;\n\t\t\tconst context: CommandContext = {\n\t\t\t\tget session() {\n\t\t\t\t\treturn self.session;\n\t\t\t\t},\n\t\t\t\tget sessionManager() {\n\t\t\t\t\treturn self.sessionManager;\n\t\t\t\t},\n\t\t\t\tget runtimeHost() {\n\t\t\t\t\treturn self.runtimeHost;\n\t\t\t\t},\n\t\t\t\tget ui() {\n\t\t\t\t\treturn self.ui;\n\t\t\t\t},\n\t\t\t\tget editor() {\n\t\t\t\t\treturn self.editor;\n\t\t\t\t},\n\t\t\t\tget editorContainer() {\n\t\t\t\t\treturn self.editorContainer;\n\t\t\t\t},\n\t\t\t\tget chatContainer() {\n\t\t\t\t\treturn self.chatContainer;\n\t\t\t\t},\n\t\t\t\tget statusContainer() {\n\t\t\t\t\treturn self.statusContainer;\n\t\t\t\t},\n\t\t\t\tget footer() {\n\t\t\t\t\treturn self.footer;\n\t\t\t\t},\n\t\t\t\tget keybindings() {\n\t\t\t\t\treturn self.keybindings;\n\t\t\t\t},\n\t\t\t\tshowStatus: (message) => self.showStatus(message),\n\t\t\t\tshowError: (message) => self.showError(message),\n\t\t\t\tshowWarning: (message) => self.showWarning(message),\n\t\t\t\tupdateEditorBorderColor: () => self.updateEditorBorderColor(),\n\t\t\t\trenderCurrentSessionState: () => self.renderCurrentSessionState(),\n\t\t\t\trebuildChatFromMessages: () => self.rebuildChatFromMessages(),\n\t\t\t\tgetMarkdownThemeWithSettings: () => self.getMarkdownThemeWithSettings(),\n\t\t\t\tstopLoadingAnimation: () => self.stopLoadingAnimation(),\n\t\t\t\tfindExactModelMatch: (searchTerm) => self.modelController.findExactModelMatch(searchTerm),\n\t\t\t\tmaybeWarnAboutAnthropicSubscriptionAuth: (model) =>\n\t\t\t\t\tself.modelController.maybeWarnAboutAnthropicSubscriptionAuth(model),\n\t\t\t\tshowModelSelector: (searchTerm) => self.modelController.showModelSelector(searchTerm),\n\t\t\t\tshowExtensionConfirm: (title, message) => self.dialogs.confirm(title, message),\n\t\t\t\tpromptForMissingSessionCwd: (error) => self.promptForMissingSessionCwd(error),\n\t\t\t\thandleFatalRuntimeError: (prefix, error) => self.handleFatalRuntimeError(prefix, error),\n\t\t\t};\n\t\t\tthis._commandExecutor = new CommandExecutor(context);\n\t\t}\n\t\treturn this._commandExecutor;\n\t}\n\tprivate stopLoadingAnimation(): void {\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t}\n\t}\n\tprivate get agent() {\n\t\treturn this.session.agent;\n\t}\n\tprivate get sessionManager() {\n\t\treturn this.session.sessionManager;\n\t}\n\tprivate get settingsManager() {\n\t\treturn this.session.settingsManager;\n\t}\n\n\tconstructor(\n\t\truntimeHost: AgentSessionRuntime,\n\t\tprivate options: InteractiveModeOptions = {},\n\t) {\n\t\tthis.runtimeHost = runtimeHost;\n\t\tthis.runtimeHost.setBeforeSessionInvalidate(() => {\n\t\t\tthis.resetExtensionUI();\n\t\t});\n\t\tthis.runtimeHost.setRebindSession(async () => {\n\t\t\tawait this.rebindCurrentSession();\n\t\t});\n\t\tthis.version = VERSION;\n\t\tthis.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());\n\t\tthis.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());\n\t\tthis.headerContainer = new Container();\n\t\tthis.chatContainer = new Container();\n\t\tthis.pendingMessagesContainer = new Container();\n\t\tthis.statusContainer = new Container();\n\t\tthis.widgetContainerAbove = new Container();\n\t\tthis.widgetContainerBelow = new Container();\n\t\tthis.keybindings = KeybindingsManager.create();\n\t\tsetKeybindings(this.keybindings);\n\t\tconst editorPaddingX = this.settingsManager.getEditorPaddingX();\n\t\tconst autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();\n\t\tthis.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, {\n\t\t\tborder: this.settingsManager.getEditorBorder(),\n\t\t\tpaddingX: editorPaddingX,\n\t\t\tautocompleteMaxVisible,\n\t\t});\n\t\tthis.editor = this.defaultEditor;\n\t\tthis.editor.promptPrefix = \"❯\";\n\t\tthis.editorContainer = new Container();\n\t\tthis.editorContainer.addChild(this.editor as Component);\n\t\tthis.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());\n\t\tthis.footer = new FooterComponent(this.session, this.footerDataProvider);\n\t\tthis.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);\n\t\tthis.footerDataProvider.setSubagentEnabled(this.session.getActiveToolNames().includes(\"Task\"));\n\t\tthis.chrome = new ExtensionChrome({\n\t\t\tui: this.ui,\n\t\t\twidgetContainerAbove: this.widgetContainerAbove,\n\t\t\twidgetContainerBelow: this.widgetContainerBelow,\n\t\t\theaderContainer: this.headerContainer,\n\t\t\tfooter: this.footer,\n\t\t\tfooterDataProvider: this.footerDataProvider,\n\t\t\tgetBuiltInHeader: () => this.builtInHeader,\n\t\t\tisToolOutputExpanded: () => this.toolOutputExpanded,\n\t\t});\n\t\tthis.taskPanel = new TaskPanelComponent(this.ui);\n\t\tthis.dialogs = new ExtensionDialogs({\n\t\t\tui: this.ui,\n\t\t\teditorContainer: this.editorContainer,\n\t\t\tkeybindings: this.keybindings,\n\t\t\tgetEditor: () => this.editor,\n\t\t});\n\t\tthis.teamFocus = new TeamFocusController({\n\t\t\tui: this.ui,\n\t\t\ttaskPanel: this.taskPanel,\n\t\t\teditorContainer: this.editorContainer,\n\t\t\tgetEditor: () => this.editor as Component,\n\t\t\tshowStatus: (message) => this.showStatus(message),\n\t\t\tshowWarning: (message) => this.showWarning(message),\n\t\t\tshowAskOptions: (questions, opts) => this.dialogs.showAskOptions(questions, opts),\n\t\t\tisAskOptionsOpen: () => this.dialogs.isAskOptionsOpen(),\n\t\t});\n\t\tthis.voice = new VoiceController({\n\t\t\tui: this.ui,\n\t\t\tstatusContainer: this.statusContainer,\n\t\t\tsendEditorInput: (data) => this.editor.handleInput(data),\n\t\t\tshowError: (message) => this.showError(message),\n\t\t\tsilenceMs: resolveVoiceSilenceMs(this.settingsManager),\n\t\t});\n\t\tconst self = this;\n\t\tthis.bashExecution = new BashExecutionController({\n\t\t\tget session() {\n\t\t\t\treturn self.session;\n\t\t\t},\n\t\t\tui: this.ui,\n\t\t\tpendingMessagesContainer: this.pendingMessagesContainer,\n\t\t\tchatContainer: this.chatContainer,\n\t\t\tshowError: (message) => this.showError(message),\n\t\t});\n\t\tthis.messageQueue = new MessageQueueController({\n\t\t\tget session() {\n\t\t\t\treturn self.session;\n\t\t\t},\n\t\t\tgetEditor: () => this.editor,\n\t\t\tpendingMessagesContainer: this.pendingMessagesContainer,\n\t\t\tshowStatus: (message) => this.showStatus(message),\n\t\t\tshowError: (message) => this.showError(message),\n\t\t});\n\t\tthis.modelController = new ModelController({\n\t\t\tui: this.ui,\n\t\t\tget session() {\n\t\t\t\treturn self.session;\n\t\t\t},\n\t\t\tshowSelector: (create) => this.showSelector(create),\n\t\t\tshowStatus: (message) => this.showStatus(message),\n\t\t\tshowError: (message) => this.showError(message),\n\t\t\tshowWarning: (message) => this.showWarning(message),\n\t\t\tshowNotice: (title, body) => this.showNotice(title, body),\n\t\t\tupdateEditorBorderColor: () => this.updateEditorBorderColor(),\n\t\t\tinvalidateFooter: () => this.footer.invalidate(),\n\t\t\tsetAvailableProviderCount: (count) => this.footerDataProvider.setAvailableProviderCount(count),\n\t\t});\n\t\tthis.loginController = new LoginController({\n\t\t\tui: this.ui,\n\t\t\tget session() {\n\t\t\t\treturn self.session;\n\t\t\t},\n\t\t\tgetEditor: () => this.editor as Component,\n\t\t\teditorContainer: this.editorContainer,\n\t\t\tshowSelector: (create) => this.showSelector(create),\n\t\t\tshowStatus: (message) => this.showStatus(message),\n\t\t\tshowError: (message) => this.showError(message),\n\t\t\tupdateAvailableProviderCount: () => this.modelController.updateAvailableProviderCount(),\n\t\t\tupdateEditorBorderColor: () => this.updateEditorBorderColor(),\n\t\t\tinvalidateFooter: () => this.footer.invalidate(),\n\t\t\tmaybeWarnAboutAnthropicSubscriptionAuth: (model) =>\n\t\t\t\tthis.modelController.maybeWarnAboutAnthropicSubscriptionAuth(model),\n\t\t});\n\t\tthis.taskPanel.onNudge = (role) => this.teamFocus.showNudgeInput(role);\n\t\tthis.taskPanel.onAttach = (role) => this.teamFocus.showAttach(role);\n\t\tthis.taskPanel.onExitFocus = () => this.teamFocus.exitFocus();\n\n\t\t// Load hide thinking block setting\n\t\tthis.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();\n\n\t\t// Load tool-output display level\n\t\tthis.toolOutputDisplay = this.settingsManager.getToolOutputDisplay();\n\n\t\t// Completion chime: rings the terminal bell when a long turn finishes or the\n\t\t// agent blocks awaiting input. Enable is read fresh so a live toggle applies.\n\t\tthis.chime = new CompletionChime({\n\t\t\tisEnabled: () => this.settingsManager.getChimeOnTurnComplete(),\n\t\t\tring: () => this.ui.terminal.write(BELL),\n\t\t});\n\n\t\t// Register themes from resource loader and initialize\n\t\tsetRegisteredThemes(this.session.resourceLoader.getThemes().themes);\n\t\tinitTheme(this.settingsManager.getTheme(), true);\n\t}\n\n\tprivate getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {\n\t\tif (!sourceInfo) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst scopePrefix = sourceInfo.scope === \"user\" ? \"u\" : sourceInfo.scope === \"project\" ? \"p\" : \"t\";\n\t\tconst source = sourceInfo.source.trim();\n\n\t\tif (source === \"auto\" || source === \"local\" || source === \"cli\") {\n\t\t\treturn scopePrefix;\n\t\t}\n\n\t\tif (source.startsWith(\"npm:\")) {\n\t\t\treturn `${scopePrefix}:${source}`;\n\t\t}\n\n\t\tconst gitSource = parseGitUrl(source);\n\t\tif (gitSource) {\n\t\t\tconst ref = gitSource.ref ? `@${gitSource.ref}` : \"\";\n\t\t\treturn `${scopePrefix}:git:${gitSource.host}/${gitSource.path}${ref}`;\n\t\t}\n\n\t\treturn scopePrefix;\n\t}\n\n\tprivate prefixAutocompleteDescription(description: string | undefined, sourceInfo?: SourceInfo): string | undefined {\n\t\tconst sourceTag = this.getAutocompleteSourceTag(sourceInfo);\n\t\tif (!sourceTag) {\n\t\t\treturn description;\n\t\t}\n\t\treturn description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`;\n\t}\n\n\tprivate getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner): ResourceDiagnostic[] {\n\t\tconst builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));\n\t\treturn extensionRunner\n\t\t\t.getRegisteredCommands()\n\t\t\t.filter((command) => builtinNames.has(command.name))\n\t\t\t.map((command) => ({\n\t\t\t\ttype: \"warning\" as const,\n\t\t\t\tmessage:\n\t\t\t\t\tcommand.invocationName === command.name\n\t\t\t\t\t\t? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`\n\t\t\t\t\t\t: `Extension command '/${command.name}' conflicts with built-in interactive command. Available as '/${command.invocationName}'.`,\n\t\t\t\tpath: command.sourceInfo.path,\n\t\t\t}));\n\t}\n\n\tprivate createBaseAutocompleteProvider(): AutocompleteProvider {\n\t\t// Define commands for autocomplete\n\t\tconst slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({\n\t\t\tname: command.name,\n\t\t\tdescription: command.description,\n\t\t}));\n\n\t\tconst modelCommand = slashCommands.find((command) => command.name === \"model\");\n\t\tif (modelCommand) {\n\t\t\tmodelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {\n\t\t\t\t// Get available models (scoped or from registry)\n\t\t\t\tconst models =\n\t\t\t\t\tthis.session.scopedModels.length > 0\n\t\t\t\t\t\t? this.session.scopedModels.map((s) => s.model)\n\t\t\t\t\t\t: this.session.modelRegistry.getAvailable();\n\n\t\t\t\tif (models.length === 0) return null;\n\n\t\t\t\t// Create items with provider/id format\n\t\t\t\tconst items = models.map((m) => ({\n\t\t\t\t\tid: m.id,\n\t\t\t\t\tprovider: m.provider,\n\t\t\t\t\tlabel: `${m.provider}/${m.id}`,\n\t\t\t\t}));\n\n\t\t\t\t// Fuzzy filter by model ID + provider (allows \"opus anthropic\" to match)\n\t\t\t\tconst filtered = fuzzyFilter(items, prefix, (item) => `${item.id} ${item.provider}`);\n\n\t\t\t\tif (filtered.length === 0) return null;\n\n\t\t\t\treturn filtered.map((item) => ({\n\t\t\t\t\tvalue: item.label,\n\t\t\t\t\tlabel: item.id,\n\t\t\t\t\tdescription: item.provider,\n\t\t\t\t}));\n\t\t\t};\n\t\t}\n\n\t\t// Convert prompt templates to SlashCommand format for autocomplete\n\t\tconst templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({\n\t\t\tname: cmd.name,\n\t\t\tdescription: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),\n\t\t\t...(cmd.argumentHint && { argumentHint: cmd.argumentHint }),\n\t\t}));\n\n\t\t// Convert extension commands to SlashCommand format\n\t\tconst builtinCommandNames = new Set(slashCommands.map((c) => c.name));\n\t\tconst extensionCommands: SlashCommand[] = this.session.extensionRunner\n\t\t\t.getRegisteredCommands()\n\t\t\t.filter((cmd) => !builtinCommandNames.has(cmd.name))\n\t\t\t.map((cmd) => ({\n\t\t\t\tname: cmd.invocationName,\n\t\t\t\tdescription: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),\n\t\t\t\tgetArgumentCompletions: cmd.getArgumentCompletions,\n\t\t\t}));\n\n\t\t// Build skill commands from session.skills (if enabled)\n\t\tthis.skillCommands.clear();\n\t\tconst skillCommandList: SlashCommand[] = [];\n\t\tif (this.settingsManager.getEnableSkillCommands()) {\n\t\t\tfor (const skill of this.session.resourceLoader.getSkills().skills) {\n\t\t\t\tconst commandName = `skill:${skill.name}`;\n\t\t\t\tthis.skillCommands.set(commandName, skill.filePath);\n\t\t\t\tskillCommandList.push({\n\t\t\t\t\tname: commandName,\n\t\t\t\t\tdescription: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\treturn new CombinedAutocompleteProvider(\n\t\t\t[...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],\n\t\t\tthis.sessionManager.getCwd(),\n\t\t\tthis.fdPath,\n\t\t);\n\t}\n\n\tprivate setupAutocompleteProvider(): void {\n\t\tlet provider = this.createBaseAutocompleteProvider();\n\t\tfor (const wrapProvider of this.autocompleteProviderWrappers) {\n\t\t\tprovider = wrapProvider(provider);\n\t\t}\n\n\t\tthis.autocompleteProvider = provider;\n\t\tthis.defaultEditor.setAutocompleteProvider(provider);\n\t\tif (this.editor !== this.defaultEditor) {\n\t\t\tthis.editor.setAutocompleteProvider?.(provider);\n\t\t}\n\t}\n\n\tprivate showStartupNoticesIfNeeded(): void {\n\t\tif (this.startupNoticesShown) {\n\t\t\treturn;\n\t\t}\n\t\tthis.startupNoticesShown = true;\n\n\t\tif (!this.changelogMarkdown || !this.changelogMarkdown.trim()) {\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.chatContainer.children.length > 0) {\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t}\n\t\tthis.chatContainer.addChild(new DynamicBorder());\n\t\tif (this.settingsManager.getCollapseChangelog()) {\n\t\t\tconst versionMatch = this.changelogMarkdown.match(/##\\s+\\[?(\\d+\\.\\d+\\.\\d+)\\]?/);\n\t\t\tconst latestVersion = versionMatch ? versionMatch[1] : this.version;\n\t\t\tconst condensedText = `Updated to v${latestVersion}. Use ${theme.bold(\"/changelog\")} to view full changelog.`;\n\t\t\tthis.chatContainer.addChild(new Text(condensedText, 1, 0));\n\t\t} else {\n\t\t\tthis.chatContainer.addChild(new Text(theme.bold(theme.fg(\"accent\", \"What's New\")), 1, 0));\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(\n\t\t\t\tnew Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()),\n\t\t\t);\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t}\n\t\tthis.chatContainer.addChild(new DynamicBorder());\n\t}\n\n\t/** Feed a first-run tool-binary download into the footer's startup-progress line. */\n\tprivate reportToolDownload(key: string, label: string, receivedBytes: number, totalBytes: number | null): void {\n\t\tstartupProgress.set({ key, kind: \"download\", label, receivedBytes, totalBytes });\n\t}\n\n\tasync init(): Promise<void> {\n\t\tif (this.isInitialized) return;\n\n\t\tthis.registerSignalHandlers();\n\n\t\t// Load changelog (only show new entries, skip for resumed sessions)\n\t\tthis.changelogMarkdown = getChangelogForDisplay({\n\t\t\thasMessages: this.session.state.messages.length > 0,\n\t\t\tsettingsManager: this.settingsManager,\n\t\t});\n\n\t\t// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir).\n\t\t// Both help — fd for autocomplete, rg for the grep tool — but neither is required\n\t\t// to start: resolving them can hit a slow or failing network in restricted\n\t\t// environments, and awaiting here froze first paint. Resolve in the background\n\t\t// (never awaited, so a first-run download never blocks first paint) and wire fd\n\t\t// in when ready; the grep/find tools resolve rg/fd on demand with a native\n\t\t// fallback regardless. embsearch is intentionally NOT preloaded here: when the\n\t\t// semantic index is enabled, EmbsearchService (main.ts) owns its download and\n\t\t// build, reporting through the same startupProgress channel — preloading it too\n\t\t// would fetch the binary twice and race.\n\t\t//\n\t\t// First-run download progress streams into the footer's startupProgress line\n\t\t// via ensureTool's byte-count callback; the line clears when the download\n\t\t// settles (path resolved) or is dropped if it fails.\n\t\tvoid Promise.all([\n\t\t\tensureTool(\"fd\", true, (received, total) => this.reportToolDownload(\"fd\", \"fd\", received, total)),\n\t\t\tensureTool(\"rg\", true, (received, total) => this.reportToolDownload(\"rg\", \"ripgrep\", received, total)),\n\t\t])\n\t\t\t.then(([fdPath]) => {\n\t\t\t\tthis.fdPath = fdPath;\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\t// Non-fatal: autocomplete simply stays disabled until/unless fd resolves.\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\t// Downloads settled (resolved or failed): drop any lingering bars.\n\t\t\t\tstartupProgress.remove(\"fd\");\n\t\t\t\tstartupProgress.remove(\"rg\");\n\t\t\t});\n\n\t\t// Add header container as first child\n\t\tthis.ui.addChild(this.headerContainer);\n\n\t\t// Add header with keybindings from config (unless silenced)\n\t\tif (this.options.verbose || !this.settingsManager.getQuietStartup()) {\n\t\t\tconst termW = this.ui.terminal.columns;\n\t\t\tconst logo =\n\t\t\t\ttermW >= 40\n\t\t\t\t\t? buildCompactWordmark({\n\t\t\t\t\t\t\tappName: APP_NAME,\n\t\t\t\t\t\t\tversion: this.version,\n\t\t\t\t\t\t\tcwd: formatDisplayPath(this.sessionManager.getCwd()),\n\t\t\t\t\t\t\taccent: (text) => theme.fg(\"accent\", text),\n\t\t\t\t\t\t\tdim: (text) => theme.fg(\"dim\", text),\n\t\t\t\t\t\t\tmuted: (text) => theme.fg(\"muted\", text),\n\t\t\t\t\t\t\tcursor: (text) => theme.blink(theme.fg(\"accent\", text)),\n\t\t\t\t\t\t\tnote: () => theme.fg(\"dim\", ` ${keyText(\"app.tools.expand\")} more`),\n\t\t\t\t\t\t})\n\t\t\t\t\t: theme.bold(theme.fg(\"accent\", APP_NAME)) + theme.fg(\"dim\", ` v${this.version}`);\n\n\t\t\t// Build startup instructions using keybinding hint helpers\n\t\t\tconst hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description);\n\n\t\t\tconst expandedInstructions = [\n\t\t\t\thint(\"app.interrupt\", \"to interrupt\"),\n\t\t\t\thint(\"app.clear\", \"to clear\"),\n\t\t\t\trawKeyHint(`${keyText(\"app.clear\")} twice`, \"to exit\"),\n\t\t\t\thint(\"app.exit\", \"to exit (empty)\"),\n\t\t\t\thint(\"app.suspend\", \"to suspend\"),\n\t\t\t\tkeyHint(\"tui.editor.deleteToLineEnd\", \"to delete to end\"),\n\t\t\t\thint(\"app.thinking.cycle\", \"to cycle thinking level\"),\n\t\t\t\trawKeyHint(`${keyText(\"app.model.cycleForward\")}/${keyText(\"app.model.cycleBackward\")}`, \"to cycle models\"),\n\t\t\t\thint(\"app.model.select\", \"to select model\"),\n\t\t\t\thint(\"app.tools.expand\", \"to expand tools\"),\n\t\t\t\thint(\"app.thinking.toggle\", \"to expand thinking\"),\n\t\t\t\thint(\"app.tasks.cycleView\", \"to cycle task panel view\"),\n\t\t\t\t...(this.teamFocus.connected ? [hint(\"app.team.focus\", \"to focus team roster\")] : []),\n\t\t\t\thint(\"app.editor.external\", \"for external editor\"),\n\t\t\t\trawKeyHint(\"/\", \"for commands\"),\n\t\t\t\trawKeyHint(\"!\", \"to run bash\"),\n\t\t\t\trawKeyHint(\"!!\", \"to run bash (no context)\"),\n\t\t\t\thint(\"app.message.followUp\", \"to queue follow-up\"),\n\t\t\t\thint(\"app.message.dequeue\", \"to edit all queued messages\"),\n\t\t\t\thint(\"app.clipboard.pasteImage\", \"to paste image\"),\n\t\t\t\trawKeyHint(\"drop files\", \"to attach\"),\n\t\t\t].join(\"\\n\");\n\t\t\tconst onboarding = theme.fg(\n\t\t\t\t\"dim\",\n\t\t\t\t`${APP_NAME} can explain its own features and look up its docs. Ask it how to use or extend ${APP_NAME}.`,\n\t\t\t);\n\t\t\tthis.builtInHeader = new ExpandableText(\n\t\t\t\t() => logo,\n\t\t\t\t() => `${logo}\\n${expandedInstructions}\\n\\n${onboarding}`,\n\t\t\t\tthis.getStartupExpansionState(),\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t);\n\n\t\t\t// Setup UI layout\n\t\t\tthis.headerContainer.addChild(this.builtInHeader);\n\t\t} else {\n\t\t\t// Minimal header when silenced\n\t\t\tthis.builtInHeader = new Text(\"\", 0, 0);\n\t\t\tthis.headerContainer.addChild(this.builtInHeader);\n\t\t}\n\n\t\tthis.ui.addChild(this.chatContainer);\n\t\tthis.ui.addChild(this.pendingMessagesContainer);\n\t\tthis.ui.addChild(this.statusContainer);\n\t\tthis.chrome.renderWidgets(); // Initialize with default spacer\n\t\tthis.ui.addChild(this.widgetContainerAbove);\n\t\tthis.ui.addChild(this.taskPanel);\n\t\tthis.ui.addChild(this.editorContainer);\n\t\tthis.ui.addChild(this.widgetContainerBelow);\n\t\tthis.ui.addChild(this.footer);\n\t\tthis.ui.setFocus(this.editor);\n\n\t\tthis.setupKeyHandlers();\n\t\tthis.setupEditorSubmitHandler();\n\n\t\t// Start the UI before initializing extensions so session_start handlers can use interactive dialogs\n\t\tthis.ui.start();\n\t\tthis.isInitialized = true;\n\n\t\t// Initialize extensions first so resources are shown before messages\n\t\tawait this.rebindCurrentSession();\n\n\t\t// Render initial messages AFTER showing loaded resources\n\t\tthis.renderInitialMessages();\n\n\t\t// Set up theme file watcher\n\t\tonThemeChange(() => {\n\t\t\tthis.ui.invalidate();\n\t\t\tthis.updateEditorBorderColor();\n\t\t\tthis.ui.requestRender();\n\t\t});\n\n\t\t// Set up git branch watcher (uses provider instead of footer)\n\t\tthis.footerDataProvider.onBranchChange(() => {\n\t\t\tthis.ui.requestRender();\n\t\t});\n\n\t\t// Re-render the UI when the task list changes (task panel shows active\n\t\t// tasks). Throttled: subagent progress mutates the store in bursts, and\n\t\t// each resulting render reassembles the entire component tree.\n\t\tthis.taskStoreUnsubscribe = taskStore.subscribe(\n\t\t\tthrottled(TASK_RENDER_THROTTLE_MS, () => {\n\t\t\t\tthis.ui.requestRender();\n\t\t\t}),\n\t\t);\n\n\t\t// Re-render the footer as startup progress updates (tool downloads stream\n\t\t// byte counts in bursts, the index build ticks per batch). Throttled like the\n\t\t// task panel so a fast download doesn't drive a render per chunk.\n\t\tthis.startupProgressUnsubscribe = startupProgress.subscribe(\n\t\t\tthrottled(TASK_RENDER_THROTTLE_MS, () => {\n\t\t\t\tthis.ui.requestRender();\n\t\t\t}),\n\t\t);\n\n\t\t// Initialize available provider count for footer display\n\t\tawait this.modelController.updateAvailableProviderCount();\n\t}\n\n\t/**\n\t * Update terminal title with session name and cwd.\n\t */\n\tprivate updateTerminalTitle(): void {\n\t\tconst cwdBasename = path.basename(this.sessionManager.getCwd());\n\t\tconst sessionName = this.sessionManager.getSessionName();\n\t\tif (sessionName) {\n\t\t\tthis.ui.terminal.setTitle(`${APP_TITLE} - ${sessionName} - ${cwdBasename}`);\n\t\t} else {\n\t\t\tthis.ui.terminal.setTitle(`${APP_TITLE} - ${cwdBasename}`);\n\t\t}\n\t}\n\n\t/**\n\t * Run the interactive mode. This is the main entry point.\n\t * Initializes the UI, shows warnings, processes initial messages, and starts the interactive loop.\n\t */\n\tasync run(): Promise<void> {\n\t\tawait this.init();\n\n\t\t// Start version check asynchronously\n\t\tcheckForNewHooCodeVersion(this.version).then((newVersion) => {\n\t\t\tif (newVersion) {\n\t\t\t\tthis.showNewVersionNotification(newVersion);\n\t\t\t}\n\t\t});\n\n\t\t// Start package update check asynchronously\n\t\tcheckForPackageUpdates(this.sessionManager.getCwd(), this.settingsManager).then((updates) => {\n\t\t\tif (updates.length > 0) {\n\t\t\t\tthis.showPackageUpdateNotification(updates);\n\t\t\t}\n\t\t});\n\n\t\t// Check tmux keyboard setup asynchronously\n\t\tcheckTmuxKeyboardSetup().then((warning) => {\n\t\t\tif (warning) {\n\t\t\t\tthis.showWarning(warning);\n\t\t\t}\n\t\t});\n\n\t\t// Show startup warnings\n\t\tconst { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options;\n\n\t\tif (migratedProviders && migratedProviders.length > 0) {\n\t\t\tthis.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(\", \")}`);\n\t\t}\n\n\t\tconst modelsJsonError = this.session.modelRegistry.getError();\n\t\tif (modelsJsonError) {\n\t\t\tthis.showError(`models.json error: ${modelsJsonError}`);\n\t\t}\n\n\t\tif (modelFallbackMessage) {\n\t\t\tthis.showWarning(modelFallbackMessage);\n\t\t}\n\n\t\tvoid this.modelController.maybeWarnAboutAnthropicSubscriptionAuth();\n\n\t\t// Process initial messages\n\t\tif (initialMessage) {\n\t\t\ttry {\n\t\t\t\tawait this.session.prompt(initialMessage, { images: initialImages });\n\t\t\t} catch (error: unknown) {\n\t\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\t\tthis.showError(errorMessage);\n\t\t\t}\n\t\t}\n\n\t\tif (initialMessages) {\n\t\t\tfor (const message of initialMessages) {\n\t\t\t\ttry {\n\t\t\t\t\tawait this.session.prompt(message);\n\t\t\t\t} catch (error: unknown) {\n\t\t\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\t\t\tthis.showError(errorMessage);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Main interactive loop\n\t\twhile (true) {\n\t\t\tconst userInput = await this.getUserInput();\n\t\t\ttry {\n\t\t\t\tawait this.session.prompt(userInput);\n\t\t\t} catch (error: unknown) {\n\t\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\t\tthis.showError(errorMessage);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate getMarkdownThemeWithSettings(): MarkdownTheme {\n\t\treturn {\n\t\t\t...getMarkdownTheme(),\n\t\t\tcodeBlockIndent: this.settingsManager.getCodeBlockIndent(),\n\t\t};\n\t}\n\n\t// =========================================================================\n\t// Extension System\n\t// =========================================================================\n\n\tprivate getStartupExpansionState(): boolean {\n\t\treturn this.options.verbose || this.toolOutputExpanded;\n\t}\n\n\t/** Render the startup/reload resource listing (see resource-display.ts). */\n\tprivate showLoadedResources(options?: {\n\t\textensions?: Array<{ path: string; sourceInfo?: SourceInfo }>;\n\t\tforce?: boolean;\n\t\tshowDiagnosticsWhenQuiet?: boolean;\n\t}): void {\n\t\trenderLoadedResources(\n\t\t\t{\n\t\t\t\tchatContainer: this.chatContainer,\n\t\t\t\tgetCwd: () => this.sessionManager.getCwd(),\n\t\t\t\tgetResourceLoader: () => this.session.resourceLoader,\n\t\t\t\tgetPromptTemplates: () => this.session.promptTemplates,\n\t\t\t\tgetExtensionRunner: () => this.session.extensionRunner,\n\t\t\t\tgetActiveMode: () => this.footerDataProvider.getActiveMode(),\n\t\t\t\tgetSubagentEnabled: () => this.footerDataProvider.getSubagentEnabled(),\n\t\t\t\tgetAgentCount: () => this.getDispatchableAgentCount(),\n\t\t\t\tgetAgents: () => {\n\t\t\t\t\tif (!this.footerDataProvider.getSubagentEnabled()) return [];\n\t\t\t\t\ttry {\n\t\t\t\t\t\treturn loadAgentRegistry({ cwd: this.sessionManager.getCwd() }).list();\n\t\t\t\t\t} catch {\n\t\t\t\t\t\treturn [];\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tgetColumns: () => this.ui.terminal.columns,\n\t\t\t\tquietStartup: () => this.settingsManager.getQuietStartup(),\n\t\t\t\tverbose: this.options.verbose ?? false,\n\t\t\t\tisExpanded: () => this.getStartupExpansionState(),\n\t\t\t\tgetBuiltInCommandConflictDiagnostics: (runner) => this.getBuiltInCommandConflictDiagnostics(runner),\n\t\t\t},\n\t\t\toptions,\n\t\t);\n\t}\n\n\t/**\n\t * Agents the model can actually dispatch. Zero when the Task tool is off, so\n\t * the summary never advertises a capability the session cannot use.\n\t */\n\tprivate getDispatchableAgentCount(): number {\n\t\tif (!this.footerDataProvider.getSubagentEnabled()) return 0;\n\t\ttry {\n\t\t\treturn loadAgentRegistry({ cwd: this.sessionManager.getCwd() }).list().length;\n\t\t} catch {\n\t\t\treturn 0;\n\t\t}\n\t}\n\n\t/**\n\t * Initialize the extension system with TUI-based UI context.\n\t */\n\tprivate async bindCurrentSessionExtensions(): Promise<void> {\n\t\tconst uiContext = this.createExtensionUIContext();\n\t\tawait this.session.bindExtensions({\n\t\t\tuiContext,\n\t\t\tcommandContextActions: {\n\t\t\t\twaitForIdle: () => this.session.agent.waitForIdle(),\n\t\t\t\tnewSession: async (options) => {\n\t\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t\t\tthis.loadingAnimation = undefined;\n\t\t\t\t\t}\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst result = await this.runtimeHost.newSession(options);\n\t\t\t\t\t\tif (!result.cancelled) {\n\t\t\t\t\t\t\tthis.renderCurrentSessionState();\n\t\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn result;\n\t\t\t\t\t} catch (error: unknown) {\n\t\t\t\t\t\treturn this.handleFatalRuntimeError(\"Failed to create session\", error);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tfork: async (entryId, options) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst result = await this.runtimeHost.fork(entryId, options);\n\t\t\t\t\t\tif (!result.cancelled) {\n\t\t\t\t\t\t\tthis.renderCurrentSessionState();\n\t\t\t\t\t\t\tthis.editor.setText(result.selectedText ?? \"\");\n\t\t\t\t\t\t\tthis.showStatus(\"Forked to new session\");\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn { cancelled: result.cancelled };\n\t\t\t\t\t} catch (error: unknown) {\n\t\t\t\t\t\treturn this.handleFatalRuntimeError(\"Failed to fork session\", error);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tnavigateTree: async (targetId, options) => {\n\t\t\t\t\tconst result = await this.session.navigateTree(targetId, {\n\t\t\t\t\t\tsummarize: options?.summarize,\n\t\t\t\t\t\tcustomInstructions: options?.customInstructions,\n\t\t\t\t\t\treplaceInstructions: options?.replaceInstructions,\n\t\t\t\t\t\tlabel: options?.label,\n\t\t\t\t\t});\n\t\t\t\t\tif (result.cancelled) {\n\t\t\t\t\t\treturn { cancelled: true };\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.chatContainer.clear();\n\t\t\t\t\tthis.renderInitialMessages();\n\t\t\t\t\tif (result.editorText && !this.editor.getText().trim()) {\n\t\t\t\t\t\tthis.editor.setText(result.editorText);\n\t\t\t\t\t}\n\t\t\t\t\tthis.showStatus(\"Navigated to selected point\");\n\t\t\t\t\tvoid this.messageQueue.flushCompactionQueue({ willRetry: false });\n\t\t\t\t\treturn { cancelled: false };\n\t\t\t\t},\n\t\t\t\tswitchSession: async (sessionPath, options) => {\n\t\t\t\t\treturn this.handleResumeSession(sessionPath, options);\n\t\t\t\t},\n\t\t\t\treload: async () => {\n\t\t\t\t\tawait this.handleReloadCommand();\n\t\t\t\t},\n\t\t\t},\n\t\t\tshutdownHandler: () => {\n\t\t\t\tthis.shutdownRequested = true;\n\t\t\t\tif (!this.session.isStreaming) {\n\t\t\t\t\tvoid this.shutdown();\n\t\t\t\t}\n\t\t\t},\n\t\t\tonError: (error) => {\n\t\t\t\tthis.showExtensionError(error.extensionPath, error.error, error.stack);\n\t\t\t},\n\t\t});\n\n\t\tsetRegisteredThemes(this.session.resourceLoader.getThemes().themes);\n\t\tthis.setupAutocompleteProvider();\n\n\t\tconst extensionRunner = this.session.extensionRunner;\n\t\tthis.setupExtensionShortcuts(extensionRunner);\n\t\tthis.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });\n\t\tthis.showStartupNoticesIfNeeded();\n\t}\n\n\tprivate applyRuntimeSettings(): void {\n\t\tthis.footer.setSession(this.session);\n\t\tthis.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);\n\t\tthis.footerDataProvider.setCwd(this.sessionManager.getCwd());\n\t\tthis.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();\n\t\tthis.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());\n\t\tthis.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());\n\t\tconst editorBorder = this.settingsManager.getEditorBorder();\n\t\tconst editorPaddingX = this.settingsManager.getEditorPaddingX();\n\t\tconst autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();\n\t\tthis.defaultEditor.setBorder(editorBorder);\n\t\tthis.defaultEditor.setPaddingX(editorPaddingX);\n\t\tthis.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);\n\t\tif (this.editor !== this.defaultEditor) {\n\t\t\tthis.editor.setBorder?.(editorBorder);\n\t\t\tthis.editor.setPaddingX?.(editorPaddingX);\n\t\t\tthis.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);\n\t\t}\n\t}\n\n\tprivate async rebindCurrentSession(): Promise<void> {\n\t\tthis.unsubscribe?.();\n\t\tthis.unsubscribe = undefined;\n\t\tthis.applyRuntimeSettings();\n\t\tawait this.bindCurrentSessionExtensions();\n\t\tthis.subscribeToAgent();\n\t\tawait this.modelController.updateAvailableProviderCount();\n\t\tthis.updateEditorBorderColor();\n\t\tthis.updateTerminalTitle();\n\t}\n\n\tprivate async handleFatalRuntimeError(prefix: string, error: unknown): Promise<never> {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tthis.showError(`${prefix}: ${message}`);\n\t\tstopThemeWatcher();\n\t\tthis.stop();\n\t\tprocess.exit(1);\n\t}\n\n\tprivate renderCurrentSessionState(): void {\n\t\tthis.chatContainer.clear();\n\t\tthis.pendingMessagesContainer.clear();\n\t\tthis.messageQueue.resetCompactionQueue();\n\t\tthis.streamingComponent = undefined;\n\t\tthis.streamingMessage = undefined;\n\t\tthis.pendingTools.clear();\n\t\tthis.renderInitialMessages();\n\t}\n\n\t/**\n\t * Bound the view layer's memory: freeze finished tool blocks once more than\n\t * LIVE_TOOL_WINDOW of them are live, releasing their retained tool output and\n\t * base64 image copies. Runs on tool completion (infrequent) and only ever\n\t * freezes blocks far above the viewport. The session data stays intact, so a\n\t * later full rebuild (theme toggle / reload) restores full fidelity.\n\t */\n\tprivate trimTranscriptMemory(): void {\n\t\tconst freezable: ToolExecutionComponent[] = [];\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof ToolExecutionComponent && child.isFreezable()) {\n\t\t\t\tfreezable.push(child);\n\t\t\t}\n\t\t}\n\t\tconst excess = freezable.length - LIVE_TOOL_WINDOW;\n\t\tfor (let i = 0; i < excess; i++) {\n\t\t\tfreezable[i].freeze();\n\t\t}\n\t}\n\n\t/**\n\t * Get a registered tool definition by name (for custom rendering).\n\t */\n\tprivate getRegisteredToolDefinition(toolName: string) {\n\t\treturn this.session.getToolDefinition(toolName);\n\t}\n\n\t/**\n\t * Set up keyboard shortcuts registered by extensions.\n\t */\n\tprivate setupExtensionShortcuts(extensionRunner: ExtensionRunner): void {\n\t\tconst shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig());\n\t\tif (shortcuts.size === 0) return;\n\n\t\t// Create a context for shortcut handlers\n\t\tconst createContext = (): ExtensionContext => ({\n\t\t\tui: this.createExtensionUIContext(),\n\t\t\thasUI: true,\n\t\t\tcwd: this.sessionManager.getCwd(),\n\t\t\tsessionManager: this.sessionManager,\n\t\t\tmodelRegistry: this.session.modelRegistry,\n\t\t\tmodel: this.session.model,\n\t\t\tisIdle: () => !this.session.isStreaming,\n\t\t\tsignal: this.session.agent.signal,\n\t\t\tabort: () => this.session.abort(),\n\t\t\thasPendingMessages: () => this.session.pendingMessageCount > 0,\n\t\t\tshutdown: () => {\n\t\t\t\tthis.shutdownRequested = true;\n\t\t\t},\n\t\t\tgetContextUsage: () => this.session.getContextUsage(),\n\t\t\tcompact: (options) => {\n\t\t\t\tvoid (async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst result = await this.session.compact(options?.customInstructions);\n\t\t\t\t\t\toptions?.onComplete?.(result);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tconst err = error instanceof Error ? error : new Error(String(error));\n\t\t\t\t\t\toptions?.onError?.(err);\n\t\t\t\t\t}\n\t\t\t\t})();\n\t\t\t},\n\t\t\tgetSystemPrompt: () => this.session.systemPrompt,\n\t\t\tactivatePlugin: (pluginDir) => this.session.activatePlugin(pluginDir),\n\t\t\trequestReloadWhenIdle: () => this.session.requestReloadWhenIdle(),\n\t\t});\n\n\t\t// Set up the extension shortcut handler on the default editor\n\t\tthis.defaultEditor.onExtensionShortcut = (data: string) => {\n\t\t\tfor (const [shortcutStr, shortcut] of shortcuts) {\n\t\t\t\t// Cast to KeyId - extension shortcuts use the same format\n\t\t\t\tif (matchesKey(data, shortcutStr as KeyId)) {\n\t\t\t\t\t// Run handler async, don't block input\n\t\t\t\t\tPromise.resolve(shortcut.handler(createContext())).catch((err) => {\n\t\t\t\t\t\tthis.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\t\t});\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false;\n\t\t};\n\t}\n\n\t/**\n\t * Set extension status text in the footer.\n\t */\n\tprivate setExtensionStatus(key: string, text: string | undefined): void {\n\t\tthis.footerDataProvider.setExtensionStatus(key, text);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate getWorkingLoaderMessage(): string {\n\t\treturn this.workingMessage ?? this.defaultWorkingMessage;\n\t}\n\n\tprivate createWorkingLoader(): Loader {\n\t\treturn new Loader(\n\t\t\tthis.ui,\n\t\t\t(spinner) => theme.fg(\"accent\", spinner),\n\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\tthis.getWorkingLoaderMessage(),\n\t\t\tthis.workingIndicatorOptions,\n\t\t);\n\t}\n\n\tprivate stopWorkingLoader(): void {\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t}\n\t\tthis.statusContainer.clear();\n\t}\n\n\tprivate setWorkingVisible(visible: boolean): void {\n\t\tthis.workingVisible = visible;\n\t\tif (!visible) {\n\t\t\tthis.stopWorkingLoader();\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\t\tif (this.session.isStreaming && !this.loadingAnimation) {\n\t\t\tthis.statusContainer.clear();\n\t\t\tthis.loadingAnimation = this.createWorkingLoader();\n\t\t\tthis.statusContainer.addChild(this.loadingAnimation);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate setWorkingIndicator(options?: LoaderIndicatorOptions): void {\n\t\tthis.workingIndicatorOptions = options;\n\t\tthis.loadingAnimation?.setIndicator(options);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate setHiddenThinkingLabel(label?: string): void {\n\t\tthis.hiddenThinkingLabel = label ?? this.defaultHiddenThinkingLabel;\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof AssistantMessageComponent) {\n\t\t\t\tchild.setHiddenThinkingLabel(this.hiddenThinkingLabel);\n\t\t\t}\n\t\t}\n\t\tif (this.streamingComponent) {\n\t\t\tthis.streamingComponent.setHiddenThinkingLabel(this.hiddenThinkingLabel);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate resetExtensionUI(): void {\n\t\tthis.dialogs.reset();\n\t\tthis.ui.hideOverlay();\n\t\tthis.clearExtensionTerminalInputListeners();\n\t\tthis.chrome.reset();\n\t\tthis.footerDataProvider.clearExtensionStatuses();\n\t\tthis.footer.invalidate();\n\t\tthis.autocompleteProviderWrappers = [];\n\t\tthis.setCustomEditorComponent(undefined);\n\t\tthis.setupAutocompleteProvider();\n\t\tthis.defaultEditor.onExtensionShortcut = undefined;\n\t\tthis.updateTerminalTitle();\n\t\tthis.workingMessage = undefined;\n\t\tthis.workingVisible = true;\n\t\tthis.setWorkingIndicator();\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText(\"app.interrupt\")} to interrupt)`);\n\t\t}\n\t\tthis.setHiddenThinkingLabel();\n\t}\n\n\tprivate addExtensionTerminalInputListener(\n\t\thandler: (data: string) => { consume?: boolean; data?: string } | undefined,\n\t): () => void {\n\t\tconst unsubscribe = this.ui.addInputListener(handler);\n\t\tthis.extensionTerminalInputUnsubscribers.add(unsubscribe);\n\t\treturn () => {\n\t\t\tunsubscribe();\n\t\t\tthis.extensionTerminalInputUnsubscribers.delete(unsubscribe);\n\t\t};\n\t}\n\n\tprivate clearExtensionTerminalInputListeners(): void {\n\t\tfor (const unsubscribe of this.extensionTerminalInputUnsubscribers) {\n\t\t\tunsubscribe();\n\t\t}\n\t\tthis.extensionTerminalInputUnsubscribers.clear();\n\t}\n\n\t/**\n\t * Create the ExtensionUIContext for extensions.\n\t */\n\tprivate createExtensionUIContext(): ExtensionUIContext {\n\t\t// Captured for the `columns` getter: `this` inside a getter on the object\n\t\t// literal below would be the literal, not the mode.\n\t\tconst tui = this.ui;\n\t\treturn {\n\t\t\tselect: (title, options, opts) => this.dialogs.showSelector(title, options, opts),\n\t\t\tconfirm: (title, message, opts) => this.dialogs.confirm(title, message, opts),\n\t\t\tinput: (title, placeholder, opts) => this.dialogs.showInput(title, placeholder, opts),\n\t\t\taskOptions: (questions, opts) => {\n\t\t\t\t// The agent is blocking on the user (the ask_options pane): ring now,\n\t\t\t\t// bypassing the duration threshold — this is the \"it needs you\" cue.\n\t\t\t\tthis.chime?.onBlockedForInput();\n\t\t\t\treturn this.dialogs.showAskOptions(questions, opts);\n\t\t\t},\n\t\t\tnotify: (message, type) => this.showExtensionNotify(message, type),\n\t\t\tget columns() {\n\t\t\t\t// Live, not captured: the listing must reflow after a resize.\n\t\t\t\treturn tui.terminal.columns;\n\t\t\t},\n\t\t\tonTerminalInput: (handler) => this.addExtensionTerminalInputListener(handler),\n\t\t\tsetStatus: (key, text) => this.setExtensionStatus(key, text),\n\t\t\tsetMode: (mode) => {\n\t\t\t\tthis.footerDataProvider.setActiveMode(mode);\n\t\t\t\tthis.footer.invalidate();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\tsetWorkingMessage: (message) => {\n\t\t\t\tthis.workingMessage = message;\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.setMessage(message ?? this.defaultWorkingMessage);\n\t\t\t\t}\n\t\t\t},\n\t\t\tsetWorkingVisible: (visible) => this.setWorkingVisible(visible),\n\t\t\tsetWorkingIndicator: (options) => this.setWorkingIndicator(options),\n\t\t\tsetHiddenThinkingLabel: (label) => this.setHiddenThinkingLabel(label),\n\t\t\tsetWidget: (key, content, options) => this.chrome.setWidget(key, content, options),\n\t\t\tsetFooter: (factory) => this.chrome.setFooter(factory),\n\t\t\tsetHeader: (factory) => this.chrome.setHeader(factory),\n\t\t\tsetTitle: (title) => this.ui.terminal.setTitle(title),\n\t\t\tcustom: (factory, options) => this.dialogs.showCustom(factory, options),\n\t\t\tpasteToEditor: (text) => this.editor.handleInput(`\\x1b[200~${text}\\x1b[201~`),\n\t\t\tsetEditorText: (text) => this.editor.setText(text),\n\t\t\tgetEditorText: () => this.editor.getExpandedText?.() ?? this.editor.getText(),\n\t\t\teditor: (title, prefill) => this.dialogs.showEditor(title, prefill),\n\t\t\taddAutocompleteProvider: (factory) => {\n\t\t\t\tthis.autocompleteProviderWrappers.push(factory);\n\t\t\t\tthis.setupAutocompleteProvider();\n\t\t\t},\n\t\t\tsetEditorComponent: (factory) => this.setCustomEditorComponent(factory),\n\t\t\tgetEditorComponent: () => this.editorComponentFactory,\n\t\t\tget theme() {\n\t\t\t\treturn theme;\n\t\t\t},\n\t\t\tgetAllThemes: () => getAvailableThemesWithPaths(),\n\t\t\tgetTheme: (name) => getThemeByName(name),\n\t\t\tsetTheme: (themeOrName) => {\n\t\t\t\tif (themeOrName instanceof Theme) {\n\t\t\t\t\tsetThemeInstance(themeOrName);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\treturn { success: true };\n\t\t\t\t}\n\t\t\t\tconst result = setTheme(themeOrName, true);\n\t\t\t\tif (result.success) {\n\t\t\t\t\tif (this.settingsManager.getTheme() !== themeOrName) {\n\t\t\t\t\t\tthis.settingsManager.setTheme(themeOrName);\n\t\t\t\t\t}\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t},\n\t\t\tgetToolsExpanded: () => this.toolOutputExpanded,\n\t\t\tsetToolsExpanded: (expanded) => this.setToolsExpanded(expanded),\n\t\t};\n\t}\n\n\tprivate async promptForMissingSessionCwd(error: MissingSessionCwdError): Promise<string | undefined> {\n\t\tconst confirmed = await this.dialogs.confirm(\"Session cwd not found\", formatMissingSessionCwdPrompt(error.issue));\n\t\treturn confirmed ? error.issue.fallbackCwd : undefined;\n\t}\n\n\t/**\n\t * Set a custom editor component from an extension.\n\t * Pass undefined to restore the default editor.\n\t */\n\tprivate setCustomEditorComponent(factory: EditorFactory | undefined): void {\n\t\tthis.editorComponentFactory = factory;\n\n\t\t// Save text from current editor before switching\n\t\tconst currentText = this.editor.getText();\n\n\t\tthis.editorContainer.clear();\n\n\t\tif (factory) {\n\t\t\t// Create the custom editor with tui, theme, and keybindings\n\t\t\tconst newEditor = factory(this.ui, getEditorTheme(), this.keybindings);\n\n\t\t\t// Wire up callbacks from the default editor\n\t\t\tnewEditor.onSubmit = this.defaultEditor.onSubmit;\n\t\t\tnewEditor.onChange = this.defaultEditor.onChange;\n\n\t\t\t// Copy text from previous editor\n\t\t\tnewEditor.setText(currentText);\n\n\t\t\t// Copy appearance settings if supported\n\t\t\tif (newEditor.borderColor !== undefined) {\n\t\t\t\tnewEditor.borderColor = this.defaultEditor.borderColor;\n\t\t\t}\n\t\t\tif (newEditor.setPaddingX !== undefined) {\n\t\t\t\tnewEditor.setPaddingX(this.defaultEditor.getPaddingX());\n\t\t\t}\n\n\t\t\t// Set autocomplete if supported\n\t\t\tif (newEditor.setAutocompleteProvider && this.autocompleteProvider) {\n\t\t\t\tnewEditor.setAutocompleteProvider(this.autocompleteProvider);\n\t\t\t}\n\n\t\t\t// If extending CustomEditor, copy app-level handlers\n\t\t\t// Use duck typing since instanceof fails across jiti module boundaries\n\t\t\tconst customEditor = newEditor as unknown as Record<string, unknown>;\n\t\t\tif (\"actionHandlers\" in customEditor && customEditor.actionHandlers instanceof Map) {\n\t\t\t\tif (!customEditor.onEscape) {\n\t\t\t\t\tcustomEditor.onEscape = () => this.defaultEditor.onEscape?.();\n\t\t\t\t}\n\t\t\t\tif (!customEditor.onCtrlD) {\n\t\t\t\t\tcustomEditor.onCtrlD = () => this.defaultEditor.onCtrlD?.();\n\t\t\t\t}\n\t\t\t\tif (!customEditor.onPasteImage) {\n\t\t\t\t\tcustomEditor.onPasteImage = () => this.defaultEditor.onPasteImage?.();\n\t\t\t\t}\n\t\t\t\tif (!customEditor.onExtensionShortcut) {\n\t\t\t\t\tcustomEditor.onExtensionShortcut = (data: string) => this.defaultEditor.onExtensionShortcut?.(data);\n\t\t\t\t}\n\t\t\t\t// Copy action handlers (clear, suspend, model switching, etc.)\n\t\t\t\tfor (const [action, handler] of this.defaultEditor.actionHandlers) {\n\t\t\t\t\t(customEditor.actionHandlers as Map<string, () => void>).set(action, handler);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthis.editor = newEditor;\n\t\t} else {\n\t\t\t// Restore default editor with text from custom editor\n\t\t\tthis.defaultEditor.setText(currentText);\n\t\t\tthis.editor = this.defaultEditor;\n\t\t}\n\n\t\tthis.editorContainer.addChild(this.editor as Component);\n\t\tthis.ui.setFocus(this.editor as Component);\n\t\tthis.ui.requestRender();\n\t}\n\n\t/**\n\t * Show a notification for extensions.\n\t */\n\tprivate showExtensionNotify(message: string, type?: \"info\" | \"warning\" | \"error\"): void {\n\t\tif (type === \"error\") {\n\t\t\tthis.showError(message);\n\t\t} else if (type === \"warning\") {\n\t\t\tthis.showWarning(message);\n\t\t} else {\n\t\t\tthis.showStatus(message);\n\t\t}\n\t}\n\n\t/**\n\t * Show an extension error in the UI.\n\t */\n\tprivate showExtensionError(extensionPath: string, error: string, stack?: string): void {\n\t\tconst errorMsg = `Extension \"${extensionPath}\" error: ${error}`;\n\t\tconst errorText = new Text(theme.fg(\"error\", errorMsg), 1, 0);\n\t\tthis.chatContainer.addChild(errorText);\n\t\tif (stack) {\n\t\t\t// Show stack trace in dim color, indented\n\t\t\tconst stackLines = stack\n\t\t\t\t.split(\"\\n\")\n\t\t\t\t.slice(1) // Skip first line (duplicates error message)\n\t\t\t\t.map((line) => theme.fg(\"dim\", ` ${line.trim()}`))\n\t\t\t\t.join(\"\\n\");\n\t\t\tif (stackLines) {\n\t\t\t\tthis.chatContainer.addChild(new Text(stackLines, 1, 0));\n\t\t\t}\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\t// =========================================================================\n\t// Key Handlers\n\t// =========================================================================\n\n\tprivate setupKeyHandlers(): void {\n\t\t// Set up handlers on defaultEditor - they use this.editor for text access\n\t\t// so they work correctly regardless of which editor is active\n\t\tthis.defaultEditor.onEscape = () => {\n\t\t\tif (this.session.isStreaming) {\n\t\t\t\tthis.messageQueue.restoreQueuedMessagesToEditor({ abort: true });\n\t\t\t} else if (this.session.isBashRunning) {\n\t\t\t\tthis.session.abortBash();\n\t\t\t} else if (this.isBashMode) {\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.isBashMode = false;\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t} else if (!this.editor.getText().trim()) {\n\t\t\t\t// Double-escape with empty editor triggers /tree, /fork, or nothing based on setting\n\t\t\t\tconst action = this.settingsManager.getDoubleEscapeAction();\n\t\t\t\tif (action !== \"none\") {\n\t\t\t\t\tconst now = Date.now();\n\t\t\t\t\tif (now - this.lastEscapeTime < 500) {\n\t\t\t\t\t\tif (action === \"tree\") {\n\t\t\t\t\t\t\tthis.showTreeSelector();\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.lastEscapeTime = 0;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.lastEscapeTime = now;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\t// Register app action handlers\n\t\tthis.defaultEditor.onAction(\"app.clear\", () => this.handleCtrlC());\n\t\tthis.defaultEditor.onCtrlD = () => this.handleCtrlD();\n\t\tthis.defaultEditor.onAction(\"app.suspend\", () => this.handleCtrlZ());\n\t\tthis.defaultEditor.onAction(\"app.thinking.cycle\", () => this.cycleThinkingLevel());\n\t\tthis.defaultEditor.onAction(\"app.model.cycleForward\", () => this.modelController.cycleModel(\"forward\"));\n\t\tthis.defaultEditor.onAction(\"app.model.cycleBackward\", () => this.modelController.cycleModel(\"backward\"));\n\n\t\t// Global debug handler on TUI (works regardless of focus)\n\t\tthis.ui.onDebug = () => this.commandExecutor.handleDebug();\n\t\tthis.defaultEditor.onAction(\"app.model.select\", () => this.modelController.showModelSelector());\n\t\tthis.defaultEditor.onAction(\"app.tools.expand\", () => this.toggleToolOutputExpansion());\n\t\tthis.defaultEditor.onAction(\"app.thinking.toggle\", () => this.toggleThinkingBlockVisibility());\n\t\tthis.defaultEditor.onAction(\"app.tasks.cycleView\", () => {\n\t\t\tthis.taskPanel.cycleView();\n\t\t});\n\t\tthis.defaultEditor.onAction(\"app.team.focus\", () => this.teamFocus.enterFocus());\n\t\tthis.defaultEditor.onAction(\"app.editor.external\", () => this.openExternalEditor());\n\t\tthis.defaultEditor.onAction(\"app.input.voiceTranscribe\", () => this.voice.toggle());\n\t\tthis.defaultEditor.onAction(\"app.message.followUp\", () => this.handleFollowUp());\n\t\tthis.defaultEditor.onAction(\"app.message.dequeue\", () => this.handleDequeue());\n\t\tthis.defaultEditor.onAction(\"app.session.new\", () => this.commandExecutor.handleClear());\n\t\tthis.defaultEditor.onAction(\"app.session.tree\", () => this.showTreeSelector());\n\t\tthis.defaultEditor.onAction(\"app.session.fork\", () => this.showUserMessageSelector());\n\t\tthis.defaultEditor.onAction(\"app.session.resume\", () => this.showSessionSelector());\n\n\t\tthis.defaultEditor.onChange = (text: string) => {\n\t\t\tconst wasBashMode = this.isBashMode;\n\t\t\tthis.isBashMode = text.trimStart().startsWith(\"!\");\n\t\t\tif (wasBashMode !== this.isBashMode) {\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\tthis.updateEditorPromptPrefix();\n\t\t\t}\n\t\t};\n\n\t\t// Handle clipboard image paste (triggered on Ctrl+V)\n\t\tthis.defaultEditor.onPasteImage = () => {\n\t\t\tthis.handleClipboardImagePaste();\n\t\t};\n\t}\n\n\tprivate async handleClipboardImagePaste(): Promise<void> {\n\t\ttry {\n\t\t\tconst image = await readClipboardImage();\n\t\t\tif (!image) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Write to temp file\n\t\t\tconst tmpDir = os.tmpdir();\n\t\t\tconst ext = extensionForImageMimeType(image.mimeType) ?? \"png\";\n\t\t\tconst fileName = `${APP_NAME}-clipboard-${crypto.randomUUID()}.${ext}`;\n\t\t\tconst filePath = path.join(tmpDir, fileName);\n\t\t\tfs.writeFileSync(filePath, Buffer.from(image.bytes));\n\n\t\t\t// Insert file path directly\n\t\t\tthis.editor.insertTextAtCursor?.(filePath);\n\t\t\tthis.ui.requestRender();\n\t\t} catch {\n\t\t\t// Silently ignore clipboard errors (may not have permission, etc.)\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// hooteams team focus (--team): focus role rows, nudge, attach\n\t// =========================================================================\n\n\t/**\n\t * Wire a hooteams connection into the TUI. Called by main.ts when `--team`\n\t * is set, before run(). See TeamFocusController for the feature itself.\n\t */\n\tattachTeamClient(client: TeamViewConnection): void {\n\t\tthis.teamFocus.attachClient(client);\n\t}\n\n\t/**\n\t * Built-in slash commands dispatched by the editor submit handler.\n\t * `withArgs` commands also match \"/name <args>\" and receive the full text.\n\t * Each handler keeps its own editor-clearing order (before vs after the\n\t * await) — some commands must show their UI before the prompt is wiped.\n\t */\n\tprivate createBuiltInSlashCommands(): Map<string, { withArgs?: boolean; run(text: string): Promise<void> | void }> {\n\t\tconst clearEditor = () => this.editor.setText(\"\");\n\t\treturn new Map(\n\t\t\tObject.entries({\n\t\t\t\t\"/settings\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.showSettingsSelector();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/scoped-models\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.modelController.showModelsSelector();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/model\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: async (text: string) => {\n\t\t\t\t\t\tconst searchTerm = text.startsWith(\"/model \") ? text.slice(7).trim() : undefined;\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.commandExecutor.handleModel(searchTerm);\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/export\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: async (text: string) => {\n\t\t\t\t\t\tawait this.commandExecutor.handleExport(text);\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/import\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: async (text: string) => {\n\t\t\t\t\t\tawait this.commandExecutor.handleImport(text);\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/share\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tawait this.commandExecutor.handleShare();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/copy\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tawait this.commandExecutor.handleCopy();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/name\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: (text: string) => {\n\t\t\t\t\t\tthis.commandExecutor.handleName(text);\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/session\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.commandExecutor.handleSession();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/changelog\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.commandExecutor.handleChangelog();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/hotkeys\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.commandExecutor.handleHotkeys();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/fork\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/clone\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.commandExecutor.handleClone();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/tree\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.showTreeSelector();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/login\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.loginController.showOAuthSelector(\"login\");\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/logout\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.loginController.showOAuthSelector(\"logout\");\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/new\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.commandExecutor.handleClear();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/compact\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: async (text: string) => {\n\t\t\t\t\t\tconst prefix = \"/compact \";\n\t\t\t\t\t\tconst customInstructions = text.startsWith(prefix)\n\t\t\t\t\t\t\t? text.slice(prefix.length).trim() || undefined\n\t\t\t\t\t\t\t: undefined;\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.handleCompactCommand(customInstructions);\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/reload\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.handleReloadCommand();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/debug\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.commandExecutor.handleDebug();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/resume\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.showSessionSelector();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/quit\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.shutdown();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/subagent\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: async (text: string) => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.commandExecutor.handleSubagent(text);\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}),\n\t\t);\n\t}\n\n\tprivate setupEditorSubmitHandler(): void {\n\t\tconst slashCommands = this.createBuiltInSlashCommands();\n\t\tthis.defaultEditor.onSubmit = async (text: string) => {\n\t\t\ttext = text.trim();\n\t\t\tif (!text) return;\n\n\t\t\t// Handle built-in slash commands\n\t\t\tconst spaceIdx = text.indexOf(\" \");\n\t\t\tconst commandName = spaceIdx === -1 ? text : text.slice(0, spaceIdx);\n\t\t\tconst command = slashCommands.get(commandName);\n\t\t\tif (command && (spaceIdx === -1 || command.withArgs)) {\n\t\t\t\tawait command.run(text);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Handle bash command (! for normal, !! for excluded from context)\n\t\t\tif (text.startsWith(\"!\")) {\n\t\t\t\tconst isExcluded = text.startsWith(\"!!\");\n\t\t\t\tconst command = isExcluded ? text.slice(2).trim() : text.slice(1).trim();\n\t\t\t\tif (command) {\n\t\t\t\t\tif (this.session.isBashRunning) {\n\t\t\t\t\t\tthis.showWarning(\"A bash command is already running. Press Esc to cancel it first.\");\n\t\t\t\t\t\tthis.editor.setText(text);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tthis.editor.addToHistory?.(text);\n\t\t\t\t\tawait this.bashExecution.handleBashCommand(command, isExcluded);\n\t\t\t\t\tthis.isBashMode = false;\n\t\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Queue input during compaction (extension commands execute immediately)\n\t\t\tif (this.session.isCompacting) {\n\t\t\t\tif (this.messageQueue.isExtensionCommand(text)) {\n\t\t\t\t\tthis.editor.addToHistory?.(text);\n\t\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\t\tawait this.session.prompt(text);\n\t\t\t\t} else {\n\t\t\t\t\tthis.messageQueue.queueCompactionMessage(text, \"steer\");\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// If streaming, use prompt() with steer behavior\n\t\t\t// This handles extension commands (execute immediately), prompt template expansion, and queueing\n\t\t\tif (this.session.isStreaming) {\n\t\t\t\tthis.editor.addToHistory?.(text);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tawait this.session.prompt(text, { streamingBehavior: \"steer\" });\n\t\t\t\tthis.messageQueue.updatePendingMessagesDisplay();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Normal message submission\n\t\t\t// First, move any pending bash components to chat\n\t\t\tthis.bashExecution.flushPendingBashComponents();\n\n\t\t\tif (this.onInputCallback) {\n\t\t\t\tthis.onInputCallback(text);\n\t\t\t}\n\t\t\tthis.editor.addToHistory?.(text);\n\t\t};\n\t}\n\n\tprivate subscribeToAgent(): void {\n\t\tthis.unsubscribe = this.session.subscribe(async (event) => {\n\t\t\tawait this.handleEvent(event);\n\t\t});\n\t}\n\n\t/**\n\t * Deferred end-of-request settle. Called from the agent_end handler, it waits\n\t * one tick so the streaming flag settles and any retry has had a chance to arm,\n\t * then acts only when the session is genuinely idle (not streaming, not\n\t * compacting) and no retry is pending — so a retried or auto-continued request\n\t * neither rings a premature \"it's done\" nor prints a partial cost line.\n\t *\n\t * The completion chime, the transcript cost line, and settling dangling plan\n\t * items all hang off this single check because they answer the same question:\n\t * has the request actually ended?\n\t */\n\tprivate settleRequestOnIdle(): void {\n\t\tsetTimeout(() => {\n\t\t\tif (this.chimePendingRetry || this.session.isStreaming || this.session.isCompacting) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.showTurnCost();\n\t\t\tthis.settleDanglingPlanItems();\n\t\t\tthis.chime?.onTurnComplete({ aborted: this.turnStopReason === \"aborted\" });\n\t\t}, 0);\n\t}\n\n\t/**\n\t * Flip main-plan rows the model left at in_progress to a settled status now the\n\t * request is over, instead of letting them claim live work until the next user\n\t * message wipes the pane.\n\t *\n\t * The model marks its own TodoWrite items and routinely drops the final call\n\t * that completes the last one, so the row outlives the work. A clean \"stop\" is\n\t * taken as the model believing it was finished — it chose to stop talking, and\n\t * its closing message is the report — so those items settle to done. An abort,\n\t * error, or length cutoff says the opposite, so they settle to cancelled: an\n\t * honest \"never finished\" rather than a fabricated completion.\n\t *\n\t * Skipped while messages are queued: a follow-up/steer arriving during the run\n\t * means the request continues, and the plan with it.\n\t */\n\tprivate settleDanglingPlanItems(): void {\n\t\tif (this.session.pendingMessageCount > 0) return;\n\t\tconst settled = settleDanglingMainTasks(this.turnStopReason === \"stop\" ? \"done\" : \"cancelled\");\n\t\tif (settled > 0) this.ui.requestRender();\n\t}\n\n\t/**\n\t * Append this request's own token/time/cost to the transcript.\n\t *\n\t * This is the honest home for the number. The task panel is a live instrument —\n\t * present tense, wiped on the next user message — and the footer carries\n\t * cumulative session vitals, so after the fact neither can answer \"what did that\n\t * request cost\". Fired at agent_end rather than turn_end because one request is\n\t * commonly tens of turns; per-turn would wedge a number between every tool block.\n\t *\n\t * Delegated runs are reported separately rather than folded into the totals:\n\t * subagents bill against their own sessions, so their tokens never appear in this\n\t * session's entries and adding them to ↑/↓ would misreport what the parent spent.\n\t */\n\tprivate showTurnCost(): void {\n\t\tconst anchor = this.turnCostAnchor;\n\t\tthis.turnCostAnchor = undefined;\n\t\tif (!anchor) return;\n\n\t\tconst now = sumAssistantUsage(this.session.sessionManager.getEntries());\n\t\tconst input = now.input - anchor.totals.input;\n\t\tconst output = now.output - anchor.totals.output;\n\t\tconst cost = now.cost - anchor.totals.cost;\n\t\t// Nothing was accounted (e.g. aborted before the first response landed): stay\n\t\t// silent rather than print a row of zeroes.\n\t\tif (input <= 0 && output <= 0) return;\n\n\t\tconst segs: string[] = [\n\t\t\ttheme.fg(\"dim\", \"↑\") +\n\t\t\t\ttheme.fg(\"muted\", formatTokens(input)) +\n\t\t\t\ttheme.fg(\"dim\", \" ↓\") +\n\t\t\t\ttheme.fg(\"muted\", formatTokens(output)),\n\t\t\ttheme.fg(\"muted\", formatDurationSecs((Date.now() - anchor.at) / 1000)),\n\t\t];\n\t\tif (cost > 0) segs.push(theme.fg(\"muted\", `$${cost.toFixed(3)}`));\n\n\t\tconst runs = taskStore.list().filter((task) => task.source === \"subagent\");\n\t\tif (runs.length > 0) {\n\t\t\tconst delegatedTokens = runs.reduce((sum, r) => sum + (r.usage ? r.usage.input + r.usage.output : 0), 0);\n\t\t\tconst label = runs.length === 1 ? (runs[0]?.subagentMode ?? \"subagent\") : \"subagents\";\n\t\t\tconst text = `◇${runs.length} ${label}${delegatedTokens > 0 ? ` ${formatTokens(delegatedTokens)}` : \"\"}`;\n\t\t\tsegs.push(theme.fg(\"dim\", text));\n\t\t}\n\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(segs.join(theme.fg(\"dim\", \" · \")), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate async handleEvent(event: AgentSessionEvent): Promise<void> {\n\t\tif (!this.isInitialized) {\n\t\t\tawait this.init();\n\t\t}\n\n\t\tthis.footer.invalidate();\n\n\t\tswitch (event.type) {\n\t\t\tcase \"agent_start\":\n\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t// A (possibly retried) turn is (re)starting: anchor its duration on the\n\t\t\t\t// first start and clear the pending-retry gate now that it has resumed.\n\t\t\t\tthis.chime?.onTurnStart();\n\t\t\t\tthis.chimePendingRetry = false;\n\t\t\t\tthis.turnCostAnchor ??= {\n\t\t\t\t\ttotals: sumAssistantUsage(this.session.sessionManager.getEntries()),\n\t\t\t\t\tat: Date.now(),\n\t\t\t\t};\n\t\t\t\tif (this.settingsManager.getShowTerminalProgress()) {\n\t\t\t\t\tthis.ui.terminal.setProgress(true);\n\t\t\t\t}\n\t\t\t\t// Restore main escape handler if retry handler is still active\n\t\t\t\t// (retry success event fires later, but we need main handler now)\n\t\t\t\tif (this.retryEscapeHandler) {\n\t\t\t\t\tthis.defaultEditor.onEscape = this.retryEscapeHandler;\n\t\t\t\t\tthis.retryEscapeHandler = undefined;\n\t\t\t\t}\n\t\t\t\tif (this.retryCountdown) {\n\t\t\t\t\tthis.retryCountdown.dispose();\n\t\t\t\t\tthis.retryCountdown = undefined;\n\t\t\t\t}\n\t\t\t\tif (this.retryLoader) {\n\t\t\t\t\tthis.retryLoader.stop();\n\t\t\t\t\tthis.retryLoader = undefined;\n\t\t\t\t}\n\t\t\t\tthis.stopWorkingLoader();\n\t\t\t\tif (this.workingVisible) {\n\t\t\t\t\tthis.loadingAnimation = this.createWorkingLoader();\n\t\t\t\t\tthis.statusContainer.addChild(this.loadingAnimation);\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"queue_update\":\n\t\t\t\tthis.messageQueue.updatePendingMessagesDisplay();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"session_info_changed\":\n\t\t\t\tthis.updateTerminalTitle();\n\t\t\t\tthis.footer.invalidate();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"thinking_level_changed\":\n\t\t\t\tthis.footer.invalidate();\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_start\":\n\t\t\t\tif (event.message.role === \"custom\") {\n\t\t\t\t\tthis.addMessageToChat(event.message);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t} else if (event.message.role === \"user\") {\n\t\t\t\t\t// A new user message starts a new turn: drop finished tasks from the\n\t\t\t\t\t// previous turn and restart numbering from #1 once the pane is empty.\n\t\t\t\t\t// Finished tasks stay visible (with their final status, tokens, and time)\n\t\t\t\t\t// until this point — not the moment they finish — so their outcome remains\n\t\t\t\t\t// glanceable for the whole turn. Active tasks are kept: a follow-up/steer\n\t\t\t\t\t// message can arrive while a subagent is still running.\n\t\t\t\t\ttaskStore.reset();\n\t\t\t\t\t// Startup progress is transient startup status: once the user starts a\n\t\t\t\t\t// turn, drop any lingering bars/notices (e.g. an unavailable-index line)\n\t\t\t\t\t// so they don't sit in the footer for the rest of the session.\n\t\t\t\t\tstartupProgress.clear();\n\t\t\t\t\tthis.turnStopReason = undefined;\n\t\t\t\t\tthis.addMessageToChat(event.message);\n\t\t\t\t\tthis.messageQueue.updatePendingMessagesDisplay();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t} else if (event.message.role === \"assistant\") {\n\t\t\t\t\tthis.streamingComponent = new AssistantMessageComponent(\n\t\t\t\t\t\tundefined,\n\t\t\t\t\t\tthis.hideThinkingBlock,\n\t\t\t\t\t\tthis.getMarkdownThemeWithSettings(),\n\t\t\t\t\t\tthis.hiddenThinkingLabel,\n\t\t\t\t\t);\n\t\t\t\t\tthis.streamingMessage = event.message;\n\t\t\t\t\tthis.chatContainer.addChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent.updateContent(this.streamingMessage);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_update\":\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tthis.streamingMessage = event.message;\n\t\t\t\t\tthis.scheduleStreamingRender();\n\n\t\t\t\t\tfor (const content of this.streamingMessage.content) {\n\t\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\t\tif (!this.pendingTools.has(content.id)) {\n\t\t\t\t\t\t\t\tconst component = new ToolExecutionComponent(\n\t\t\t\t\t\t\t\t\tcontent.name,\n\t\t\t\t\t\t\t\t\tcontent.id,\n\t\t\t\t\t\t\t\t\tcontent.arguments,\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tshowImages: this.settingsManager.getShowImages(),\n\t\t\t\t\t\t\t\t\t\timageWidthCells: this.settingsManager.getImageWidthCells(),\n\t\t\t\t\t\t\t\t\t\tdisplayLevel: this.toolOutputDisplay,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tthis.getRegisteredToolDefinition(content.name),\n\t\t\t\t\t\t\t\t\tthis.ui,\n\t\t\t\t\t\t\t\t\tthis.sessionManager.getCwd(),\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tconst component = this.pendingTools.get(content.id);\n\t\t\t\t\t\t\t\tif (component) {\n\t\t\t\t\t\t\t\t\tcomponent.updateArgs(content.arguments);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_end\":\n\t\t\t\tif (event.message.role === \"user\") break;\n\t\t\t\tif (event.message.role === \"assistant\") {\n\t\t\t\t\t// Remember how the turn's latest assistant message ended, so the deferred\n\t\t\t\t\t// settle can tell a clean finish from an abort/error.\n\t\t\t\t\tthis.turnStopReason = event.message.stopReason;\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tthis.streamingMessage = event.message;\n\t\t\t\t\tlet errorMessage: string | undefined;\n\t\t\t\t\tif (this.streamingMessage.stopReason === \"aborted\") {\n\t\t\t\t\t\tconst retryAttempt = this.session.retryAttempt;\n\t\t\t\t\t\terrorMessage =\n\t\t\t\t\t\t\tretryAttempt > 0\n\t\t\t\t\t\t\t\t? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? \"s\" : \"\"}`\n\t\t\t\t\t\t\t\t: \"Operation aborted\";\n\t\t\t\t\t\tthis.streamingMessage.errorMessage = errorMessage;\n\t\t\t\t\t}\n\t\t\t\t\tthis.streamingComponent.updateContent(this.streamingMessage);\n\n\t\t\t\t\tif (this.streamingMessage.stopReason === \"aborted\" || this.streamingMessage.stopReason === \"error\") {\n\t\t\t\t\t\tif (!errorMessage) {\n\t\t\t\t\t\t\terrorMessage = this.streamingMessage.errorMessage || \"Error\";\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfor (const [, component] of this.pendingTools.entries()) {\n\t\t\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: errorMessage }],\n\t\t\t\t\t\t\t\tisError: true,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Args are now complete - trigger diff computation for edit tools\n\t\t\t\t\t\tfor (const [, component] of this.pendingTools.entries()) {\n\t\t\t\t\t\t\tcomponent.setArgsComplete();\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tthis.streamingComponent = undefined;\n\t\t\t\t\tthis.streamingMessage = undefined;\n\t\t\t\t\tthis.footer.invalidate();\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"tool_execution_start\": {\n\t\t\t\tlet component = this.pendingTools.get(event.toolCallId);\n\t\t\t\tif (!component) {\n\t\t\t\t\tcomponent = new ToolExecutionComponent(\n\t\t\t\t\t\tevent.toolName,\n\t\t\t\t\t\tevent.toolCallId,\n\t\t\t\t\t\tevent.args,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tshowImages: this.settingsManager.getShowImages(),\n\t\t\t\t\t\t\timageWidthCells: this.settingsManager.getImageWidthCells(),\n\t\t\t\t\t\t\tdisplayLevel: this.toolOutputDisplay,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tthis.getRegisteredToolDefinition(event.toolName),\n\t\t\t\t\t\tthis.ui,\n\t\t\t\t\t\tthis.sessionManager.getCwd(),\n\t\t\t\t\t);\n\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\tthis.pendingTools.set(event.toolCallId, component);\n\t\t\t\t}\n\t\t\t\tcomponent.markExecutionStarted();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_update\": {\n\t\t\t\tconst component = this.pendingTools.get(event.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({ ...event.partialResult, isError: false }, true);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\tconst component = this.pendingTools.get(event.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({ ...event.result, isError: event.isError });\n\t\t\t\t\tthis.pendingTools.delete(event.toolCallId);\n\t\t\t\t\tthis.trimTranscriptMemory();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\":\n\t\t\t\tif (this.settingsManager.getShowTerminalProgress()) {\n\t\t\t\t\tthis.ui.terminal.setProgress(false);\n\t\t\t\t}\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t\tthis.loadingAnimation = undefined;\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\tthis.chatContainer.removeChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent = undefined;\n\t\t\t\t\tthis.streamingMessage = undefined;\n\t\t\t\t}\n\t\t\t\tthis.pendingTools.clear();\n\n\t\t\t\tawait this.checkShutdownRequested();\n\n\t\t\t\t// agent_end fires before the streaming flag clears and before the retry\n\t\t\t\t// decision is made (both happen after this listener returns), so defer a\n\t\t\t\t// tick and re-check: only settle once the session is genuinely idle and no\n\t\t\t\t// retry is pending — otherwise a retried or auto-continued turn would ring\n\t\t\t\t// a premature \"it's done\" and print a partial cost line.\n\t\t\t\tthis.settleRequestOnIdle();\n\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"compaction_start\": {\n\t\t\t\tif (this.settingsManager.getShowTerminalProgress()) {\n\t\t\t\t\tthis.ui.terminal.setProgress(true);\n\t\t\t\t}\n\t\t\t\t// Keep editor active; submissions are queued during compaction.\n\t\t\t\tthis.autoCompactionEscapeHandler = this.defaultEditor.onEscape;\n\t\t\t\tthis.defaultEditor.onEscape = () => {\n\t\t\t\t\tthis.session.abortCompaction();\n\t\t\t\t};\n\t\t\t\tthis.statusContainer.clear();\n\t\t\t\tconst cancelHint = `(${keyText(\"app.interrupt\")} to cancel)`;\n\t\t\t\tconst label =\n\t\t\t\t\tevent.reason === \"manual\"\n\t\t\t\t\t\t? `Compacting context... ${cancelHint}`\n\t\t\t\t\t\t: `${event.reason === \"overflow\" ? \"Context overflow detected, \" : \"\"}Auto-compacting... ${cancelHint}`;\n\t\t\t\tthis.autoCompactionLoader = new Loader(\n\t\t\t\t\tthis.ui,\n\t\t\t\t\t(spinner) => theme.fg(\"accent\", spinner),\n\t\t\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t\t\tlabel,\n\t\t\t\t);\n\t\t\t\tthis.statusContainer.addChild(this.autoCompactionLoader);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"compaction_end\": {\n\t\t\t\tif (this.settingsManager.getShowTerminalProgress()) {\n\t\t\t\t\tthis.ui.terminal.setProgress(false);\n\t\t\t\t}\n\t\t\t\tif (this.autoCompactionEscapeHandler) {\n\t\t\t\t\tthis.defaultEditor.onEscape = this.autoCompactionEscapeHandler;\n\t\t\t\t\tthis.autoCompactionEscapeHandler = undefined;\n\t\t\t\t}\n\t\t\t\tif (this.autoCompactionLoader) {\n\t\t\t\t\tthis.autoCompactionLoader.stop();\n\t\t\t\t\tthis.autoCompactionLoader = undefined;\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t}\n\t\t\t\tif (event.aborted) {\n\t\t\t\t\tif (event.reason === \"manual\") {\n\t\t\t\t\t\tthis.showError(\"Compaction cancelled\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.showStatus(\"Auto-compaction cancelled\");\n\t\t\t\t\t}\n\t\t\t\t} else if (event.result) {\n\t\t\t\t\tthis.chatContainer.clear();\n\t\t\t\t\tthis.rebuildChatFromMessages();\n\t\t\t\t\tthis.addMessageToChat(\n\t\t\t\t\t\tcreateCompactionSummaryMessage(\n\t\t\t\t\t\t\tevent.result.summary,\n\t\t\t\t\t\t\tevent.result.tokensBefore,\n\t\t\t\t\t\t\tnew Date().toISOString(),\n\t\t\t\t\t\t\tevent.result.tokensAfter,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tthis.footer.invalidate();\n\t\t\t\t} else if (event.errorMessage) {\n\t\t\t\t\tif (event.reason === \"manual\") {\n\t\t\t\t\t\tthis.showError(event.errorMessage);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"error\", event.errorMessage), 1, 0));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tvoid this.messageQueue.flushCompactionQueue({ willRetry: event.willRetry });\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"auto_retry_start\": {\n\t\t\t\t// The turn is not done — it will re-run after a backoff (during which the\n\t\t\t\t// session reads as idle). Gate the deferred completion chime until then.\n\t\t\t\tthis.chimePendingRetry = true;\n\t\t\t\t// Set up escape to abort retry\n\t\t\t\tthis.retryEscapeHandler = this.defaultEditor.onEscape;\n\t\t\t\tthis.defaultEditor.onEscape = () => {\n\t\t\t\t\tthis.session.abortRetry();\n\t\t\t\t};\n\t\t\t\t// Show retry indicator\n\t\t\t\tthis.statusContainer.clear();\n\t\t\t\tthis.retryCountdown?.dispose();\n\t\t\t\tconst retryMessage = (seconds: number) =>\n\t\t\t\t\t`Retrying (${event.attempt}/${event.maxAttempts}) in ${seconds}s... (${keyText(\"app.interrupt\")} to cancel)`;\n\t\t\t\tthis.retryLoader = new Loader(\n\t\t\t\t\tthis.ui,\n\t\t\t\t\t(spinner) => theme.fg(\"warning\", spinner),\n\t\t\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t\t\tretryMessage(Math.ceil(event.delayMs / 1000)),\n\t\t\t\t);\n\t\t\t\tthis.retryCountdown = new CountdownTimer(\n\t\t\t\t\tevent.delayMs,\n\t\t\t\t\tthis.ui,\n\t\t\t\t\t(seconds) => {\n\t\t\t\t\t\tthis.retryLoader?.setMessage(retryMessage(seconds));\n\t\t\t\t\t},\n\t\t\t\t\t() => {\n\t\t\t\t\t\tthis.retryCountdown = undefined;\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t\tthis.statusContainer.addChild(this.retryLoader);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"auto_retry_end\": {\n\t\t\t\t// Restore escape handler\n\t\t\t\tif (this.retryEscapeHandler) {\n\t\t\t\t\tthis.defaultEditor.onEscape = this.retryEscapeHandler;\n\t\t\t\t\tthis.retryEscapeHandler = undefined;\n\t\t\t\t}\n\t\t\t\tif (this.retryCountdown) {\n\t\t\t\t\tthis.retryCountdown.dispose();\n\t\t\t\t\tthis.retryCountdown = undefined;\n\t\t\t\t}\n\t\t\t\t// Stop loader\n\t\t\t\tif (this.retryLoader) {\n\t\t\t\t\tthis.retryLoader.stop();\n\t\t\t\t\tthis.retryLoader = undefined;\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t}\n\t\t\t\t// Show error only on final failure (success shows normal response)\n\t\t\t\tif (!event.success) {\n\t\t\t\t\tthis.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || \"Unknown error\"}`);\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Extract text content from a user message */\n\tprivate getUserMessageText(message: Message): string {\n\t\tif (message.role !== \"user\") return \"\";\n\t\tconst textBlocks =\n\t\t\ttypeof message.content === \"string\"\n\t\t\t\t? [{ type: \"text\", text: message.content }]\n\t\t\t\t: message.content.filter((c: { type: string }) => c.type === \"text\");\n\t\treturn textBlocks.map((c) => (c as { text: string }).text).join(\"\");\n\t}\n\n\t/**\n\t * Show a status message in the chat.\n\t *\n\t * If multiple status messages are emitted back-to-back (without anything else being added to the chat),\n\t * we update the previous status line instead of appending new ones to avoid log spam.\n\t */\n\n\tprivate showStatus(message: string): void {\n\t\tconst children = this.chatContainer.children;\n\t\tconst last = children.length > 0 ? children[children.length - 1] : undefined;\n\t\tconst secondLast = children.length > 1 ? children[children.length - 2] : undefined;\n\n\t\tif (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {\n\t\t\tthis.lastStatusText.setText(styleStatusMessage(message));\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\tconst spacer = new Spacer(1);\n\t\tconst text = new Text(styleStatusMessage(message), 1, 0);\n\t\tthis.chatContainer.addChild(spacer);\n\t\tthis.chatContainer.addChild(text);\n\t\tthis.lastStatusSpacer = spacer;\n\t\tthis.lastStatusText = text;\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {\n\t\tswitch (message.role) {\n\t\t\tcase \"bashExecution\": {\n\t\t\t\tconst component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext);\n\t\t\t\tif (message.output) {\n\t\t\t\t\tcomponent.appendOutput(message.output);\n\t\t\t\t}\n\t\t\t\tcomponent.setComplete(\n\t\t\t\t\tmessage.exitCode,\n\t\t\t\t\tmessage.cancelled,\n\t\t\t\t\tmessage.truncated ? ({ truncated: true } as TruncationResult) : undefined,\n\t\t\t\t\tmessage.fullOutputPath,\n\t\t\t\t);\n\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"custom\": {\n\t\t\t\tif (message.display) {\n\t\t\t\t\tconst renderer = this.session.extensionRunner.getMessageRenderer(message.customType);\n\t\t\t\t\tconst component = new CustomMessageComponent(message, renderer, this.getMarkdownThemeWithSettings());\n\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"compactionSummary\": {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst component = new CompactionSummaryMessageComponent(message, this.getMarkdownThemeWithSettings());\n\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"branchSummary\": {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst component = new BranchSummaryMessageComponent(message, this.getMarkdownThemeWithSettings());\n\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"user\": {\n\t\t\t\tconst textContent = this.getUserMessageText(message);\n\t\t\t\tif (textContent) {\n\t\t\t\t\tif (this.chatContainer.children.length > 0) {\n\t\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\t}\n\t\t\t\t\tconst skillBlock = parseSkillBlock(textContent);\n\t\t\t\t\tif (skillBlock) {\n\t\t\t\t\t\t// Render skill block (collapsible)\n\t\t\t\t\t\tconst component = new SkillInvocationMessageComponent(\n\t\t\t\t\t\t\tskillBlock,\n\t\t\t\t\t\t\tthis.getMarkdownThemeWithSettings(),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\t// Render user message separately if present\n\t\t\t\t\t\tif (skillBlock.userMessage) {\n\t\t\t\t\t\t\tconst userComponent = new UserMessageComponent(\n\t\t\t\t\t\t\t\tskillBlock.userMessage,\n\t\t\t\t\t\t\t\tthis.getMarkdownThemeWithSettings(),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings());\n\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t}\n\t\t\t\t\tif (options?.populateHistory) {\n\t\t\t\t\t\tthis.editor.addToHistory?.(textContent);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"assistant\": {\n\t\t\t\tconst assistantComponent = new AssistantMessageComponent(\n\t\t\t\t\tmessage,\n\t\t\t\t\tthis.hideThinkingBlock,\n\t\t\t\t\tthis.getMarkdownThemeWithSettings(),\n\t\t\t\t\tthis.hiddenThinkingLabel,\n\t\t\t\t);\n\t\t\t\tthis.chatContainer.addChild(assistantComponent);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"toolResult\": {\n\t\t\t\t// Tool results are rendered inline with tool calls, handled separately\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\tconst _exhaustive: never = message;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Render session context to chat. Used for initial load and rebuild after compaction.\n\t * @param sessionContext Session context to render\n\t * @param options.updateFooter Update footer state\n\t * @param options.populateHistory Add user messages to editor history\n\t */\n\tprivate renderSessionContext(\n\t\tsessionContext: SessionContext,\n\t\toptions: { updateFooter?: boolean; populateHistory?: boolean } = {},\n\t): void {\n\t\tthis.pendingTools.clear();\n\t\tconst renderedPendingTools = new Map<string, ToolExecutionComponent>();\n\n\t\tif (options.updateFooter) {\n\t\t\tthis.footer.invalidate();\n\t\t\tthis.updateEditorBorderColor();\n\t\t}\n\n\t\tfor (const message of sessionContext.messages) {\n\t\t\t// Assistant messages need special handling for tool calls\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\tthis.addMessageToChat(message);\n\t\t\t\t// Render tool call components\n\t\t\t\tfor (const content of message.content) {\n\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\tconst component = new ToolExecutionComponent(\n\t\t\t\t\t\t\tcontent.name,\n\t\t\t\t\t\t\tcontent.id,\n\t\t\t\t\t\t\tcontent.arguments,\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tshowImages: this.settingsManager.getShowImages(),\n\t\t\t\t\t\t\t\timageWidthCells: this.settingsManager.getImageWidthCells(),\n\t\t\t\t\t\t\t\tdisplayLevel: this.toolOutputDisplay,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tthis.getRegisteredToolDefinition(content.name),\n\t\t\t\t\t\t\tthis.ui,\n\t\t\t\t\t\t\tthis.sessionManager.getCwd(),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\n\t\t\t\t\t\tif (message.stopReason === \"aborted\" || message.stopReason === \"error\") {\n\t\t\t\t\t\t\tlet errorMessage: string;\n\t\t\t\t\t\t\tif (message.stopReason === \"aborted\") {\n\t\t\t\t\t\t\t\tconst retryAttempt = this.session.retryAttempt;\n\t\t\t\t\t\t\t\terrorMessage =\n\t\t\t\t\t\t\t\t\tretryAttempt > 0\n\t\t\t\t\t\t\t\t\t\t? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? \"s\" : \"\"}`\n\t\t\t\t\t\t\t\t\t\t: \"Operation aborted\";\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\terrorMessage = message.errorMessage || \"Error\";\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcomponent.updateResult({ content: [{ type: \"text\", text: errorMessage }], isError: true });\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\trenderedPendingTools.set(content.id, component);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"toolResult\") {\n\t\t\t\t// Match tool results to pending tool components\n\t\t\t\tconst component = renderedPendingTools.get(message.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult(message);\n\t\t\t\t\trenderedPendingTools.delete(message.toolCallId);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// All other messages use standard rendering\n\t\t\t\tthis.addMessageToChat(message, options);\n\t\t\t}\n\t\t}\n\n\t\tfor (const [toolCallId, component] of renderedPendingTools) {\n\t\t\tthis.pendingTools.set(toolCallId, component);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\trenderInitialMessages(): void {\n\t\t// Get aligned messages and entries from session context\n\t\tconst context = this.sessionManager.buildSessionContext();\n\t\tthis.renderSessionContext(context, {\n\t\t\tupdateFooter: true,\n\t\t\tpopulateHistory: true,\n\t\t});\n\n\t\t// Show compaction info if session was compacted\n\t\tconst allEntries = this.sessionManager.getEntries();\n\t\tconst compactionCount = allEntries.filter((e) => e.type === \"compaction\").length;\n\t\tif (compactionCount > 0) {\n\t\t\tconst times = compactionCount === 1 ? \"1 time\" : `${compactionCount} times`;\n\t\t\tthis.showStatus(`Session compacted ${times}`);\n\t\t}\n\t}\n\n\tasync getUserInput(): Promise<string> {\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.onInputCallback = (text: string) => {\n\t\t\t\tthis.onInputCallback = undefined;\n\t\t\t\tresolve(text);\n\t\t\t};\n\t\t});\n\t}\n\n\tprivate rebuildChatFromMessages(): void {\n\t\tthis.chatContainer.clear();\n\t\tconst context = this.sessionManager.buildSessionContext();\n\t\tthis.renderSessionContext(context);\n\t}\n\n\t// =========================================================================\n\t// Key handlers\n\t// =========================================================================\n\n\tprivate handleCtrlC(): void {\n\t\tconst now = Date.now();\n\t\tif (now - this.lastSigintTime < 500) {\n\t\t\tvoid this.shutdown();\n\t\t} else {\n\t\t\tthis.clearEditor();\n\t\t\tthis.lastSigintTime = now;\n\t\t}\n\t}\n\n\tprivate handleCtrlD(): void {\n\t\t// Only called when editor is empty (enforced by CustomEditor)\n\t\tvoid this.shutdown();\n\t}\n\n\t/**\n\t * Gracefully shutdown the agent.\n\t * Stops the TUI before emitting shutdown events so extension UI cleanup cannot\n\t * repaint the final frame while the process is exiting.\n\t */\n\tprivate isShuttingDown = false;\n\n\tprivate async shutdown(): Promise<void> {\n\t\tif (this.isShuttingDown) return;\n\t\tthis.isShuttingDown = true;\n\t\tthis.unregisterSignalHandlers();\n\n\t\t// Drain any in-flight Kitty key release events before stopping.\n\t\t// This prevents escape sequences from leaking to the parent shell over slow SSH.\n\t\tawait this.ui.terminal.drainInput(1000);\n\n\t\tthis.taskStoreUnsubscribe?.();\n\t\tthis.startupProgressUnsubscribe?.();\n\t\tthis.teamFocus.closeAttach();\n\t\tthis.taskPanel.dispose();\n\t\tthis.stop();\n\t\tawait this.runtimeHost.dispose();\n\t\tprocess.exit(0);\n\t}\n\n\tprivate emergencyTerminalExit(): never {\n\t\tthis.isShuttingDown = true;\n\t\tthis.unregisterSignalHandlers();\n\t\tkillTrackedDetachedChildren();\n\t\t// The terminal is gone. Do not run normal shutdown because TUI and\n\t\t// extension cleanup can write restore sequences and re-trigger EIO.\n\t\tprocess.exit(129);\n\t}\n\n\t/**\n\t * Check if shutdown was requested and perform shutdown if so.\n\t */\n\tprivate async checkShutdownRequested(): Promise<void> {\n\t\tif (!this.shutdownRequested) return;\n\t\tawait this.shutdown();\n\t}\n\n\tprivate registerSignalHandlers(): void {\n\t\tthis.unregisterSignalHandlers();\n\n\t\tconst signals: NodeJS.Signals[] = [\"SIGTERM\"];\n\t\tif (process.platform !== \"win32\") {\n\t\t\tsignals.push(\"SIGHUP\");\n\t\t}\n\n\t\tfor (const signal of signals) {\n\t\t\tconst handler = () => {\n\t\t\t\tif (signal === \"SIGHUP\") {\n\t\t\t\t\tthis.emergencyTerminalExit();\n\t\t\t\t}\n\t\t\t\tkillTrackedDetachedChildren();\n\t\t\t\tvoid this.shutdown();\n\t\t\t};\n\t\t\tprocess.prependListener(signal, handler);\n\t\t\tthis.signalCleanupHandlers.push(() => process.off(signal, handler));\n\t\t}\n\n\t\tconst terminalErrorHandler = (error: Error) => {\n\t\t\tif (isDeadTerminalError(error)) {\n\t\t\t\tthis.emergencyTerminalExit();\n\t\t\t}\n\t\t\tthrow error;\n\t\t};\n\t\tprocess.stdout.on(\"error\", terminalErrorHandler);\n\t\tprocess.stderr.on(\"error\", terminalErrorHandler);\n\t\tthis.signalCleanupHandlers.push(() => process.stdout.off(\"error\", terminalErrorHandler));\n\t\tthis.signalCleanupHandlers.push(() => process.stderr.off(\"error\", terminalErrorHandler));\n\t}\n\n\tprivate unregisterSignalHandlers(): void {\n\t\tfor (const cleanup of this.signalCleanupHandlers) {\n\t\t\tcleanup();\n\t\t}\n\t\tthis.signalCleanupHandlers = [];\n\t}\n\n\tprivate handleCtrlZ(): void {\n\t\tif (process.platform === \"win32\") {\n\t\t\tthis.showStatus(\"Suspend to background is not supported on Windows\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Keep the event loop alive while suspended. Without this, stopping the TUI\n\t\t// can leave Node with no ref'ed handles, causing the process to exit on fg\n\t\t// before the SIGCONT handler gets a chance to restore the terminal.\n\t\tconst suspendKeepAlive = setInterval(() => {}, 2 ** 30);\n\n\t\t// Ignore SIGINT while suspended so Ctrl+C in the terminal does not\n\t\t// kill the backgrounded process. The handler is removed on resume.\n\t\tconst ignoreSigint = () => {};\n\t\tprocess.on(\"SIGINT\", ignoreSigint);\n\n\t\t// Set up handler to restore TUI when resumed\n\t\tprocess.once(\"SIGCONT\", () => {\n\t\t\tclearInterval(suspendKeepAlive);\n\t\t\tprocess.removeListener(\"SIGINT\", ignoreSigint);\n\t\t\tthis.ui.start();\n\t\t\tthis.ui.requestRender(true);\n\t\t});\n\n\t\ttry {\n\t\t\t// Stop the TUI (restore terminal to normal mode)\n\t\t\tthis.ui.stop();\n\n\t\t\t// Send SIGTSTP to process group (pid=0 means all processes in group)\n\t\t\tprocess.kill(0, \"SIGTSTP\");\n\t\t} catch (error) {\n\t\t\tclearInterval(suspendKeepAlive);\n\t\t\tprocess.removeListener(\"SIGINT\", ignoreSigint);\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tprivate async handleFollowUp(): Promise<void> {\n\t\tconst text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim();\n\t\tif (!text) return;\n\n\t\t// Queue input during compaction (extension commands execute immediately)\n\t\tif (this.session.isCompacting) {\n\t\t\tif (this.messageQueue.isExtensionCommand(text)) {\n\t\t\t\tthis.editor.addToHistory?.(text);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tawait this.session.prompt(text);\n\t\t\t} else {\n\t\t\t\tthis.messageQueue.queueCompactionMessage(text, \"followUp\");\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\t// Alt+Enter queues a follow-up message (waits until agent finishes)\n\t\t// This handles extension commands (execute immediately), prompt template expansion, and queueing\n\t\tif (this.session.isStreaming) {\n\t\t\tthis.editor.addToHistory?.(text);\n\t\t\tthis.editor.setText(\"\");\n\t\t\tawait this.session.prompt(text, { streamingBehavior: \"followUp\" });\n\t\t\tthis.messageQueue.updatePendingMessagesDisplay();\n\t\t\tthis.ui.requestRender();\n\t\t}\n\t\t// If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)\n\t\telse if (this.editor.onSubmit) {\n\t\t\tthis.editor.setText(\"\");\n\t\t\tthis.editor.onSubmit(text);\n\t\t}\n\t}\n\n\tprivate handleDequeue(): void {\n\t\tconst restored = this.messageQueue.restoreQueuedMessagesToEditor();\n\t\tif (restored === 0) {\n\t\t\tthis.showStatus(\"No queued messages to restore\");\n\t\t} else {\n\t\t\tthis.showStatus(`Restored ${restored} queued message${restored > 1 ? \"s\" : \"\"} to editor`);\n\t\t}\n\t}\n\n\tprivate updateEditorBorderColor(): void {\n\t\tif (this.isBashMode) {\n\t\t\tthis.editor.borderColor = theme.getBashModeBorderColor();\n\t\t} else {\n\t\t\tconst level = this.session.thinkingLevel || \"off\";\n\t\t\tthis.editor.borderColor = theme.getThinkingBorderColor(level);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate updateEditorPromptPrefix(): void {\n\t\tif (this.isBashMode) {\n\t\t\tthis.editor.promptPrefix = \"!\";\n\t\t\tthis.editor.promptColor = theme.getBashModeBorderColor();\n\t\t} else {\n\t\t\tthis.editor.promptPrefix = \"❯\";\n\t\t\tthis.editor.promptColor = (s: string) => s;\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate cycleThinkingLevel(): void {\n\t\tconst newLevel = this.session.cycleThinkingLevel();\n\t\tif (newLevel === undefined) {\n\t\t\tthis.showStatus(\"Current model does not support thinking\");\n\t\t} else {\n\t\t\tthis.footer.invalidate();\n\t\t\tthis.updateEditorBorderColor();\n\t\t\tthis.showStatus(`Thinking level: ${newLevel}`);\n\t\t}\n\t}\n\n\tprivate toggleToolOutputExpansion(): void {\n\t\tthis.setToolsExpanded(!this.toolOutputExpanded);\n\t}\n\n\tprivate setToolsExpanded(expanded: boolean): void {\n\t\tthis.toolOutputExpanded = expanded;\n\t\tconst activeHeader = this.chrome.customHeader ?? this.builtInHeader;\n\t\tif (isExpandable(activeHeader)) {\n\t\t\tactiveHeader.setExpanded(expanded);\n\t\t}\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (isExpandable(child)) {\n\t\t\t\tchild.setExpanded(expanded);\n\t\t\t}\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate toggleThinkingBlockVisibility(): void {\n\t\tthis.hideThinkingBlock = !this.hideThinkingBlock;\n\t\tthis.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);\n\n\t\t// Rebuild chat from session messages\n\t\tthis.chatContainer.clear();\n\t\tthis.rebuildChatFromMessages();\n\n\t\t// If streaming, re-add the streaming component with updated visibility and re-render\n\t\tif (this.streamingComponent && this.streamingMessage) {\n\t\t\tthis.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock);\n\t\t\tthis.streamingComponent.updateContent(this.streamingMessage);\n\t\t\tthis.chatContainer.addChild(this.streamingComponent);\n\t\t}\n\n\t\tthis.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? \"hidden\" : \"visible\"}`);\n\t}\n\n\tprivate openExternalEditor(): void {\n\t\t// Determine editor (respect $VISUAL, then $EDITOR)\n\t\tconst editorCmd = process.env.VISUAL || process.env.EDITOR;\n\t\tif (!editorCmd) {\n\t\t\tthis.showWarning(\"No editor configured. Set $VISUAL or $EDITOR environment variable.\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst currentText = this.editor.getExpandedText?.() ?? this.editor.getText();\n\t\tconst tmpFile = path.join(os.tmpdir(), `${APP_NAME}-editor-${Date.now()}.${APP_NAME}.md`);\n\n\t\ttry {\n\t\t\t// Write current content to temp file\n\t\t\tfs.writeFileSync(tmpFile, currentText, \"utf-8\");\n\n\t\t\t// Stop TUI to release terminal\n\t\t\tthis.ui.stop();\n\n\t\t\t// Split by space to support editor arguments (e.g., \"code --wait\")\n\t\t\tconst [editor, ...editorArgs] = editorCmd.split(\" \");\n\n\t\t\t// Spawn editor synchronously with inherited stdio for interactive editing\n\t\t\tconst result = spawnSync(editor, [...editorArgs, tmpFile], {\n\t\t\t\tstdio: \"inherit\",\n\t\t\t\tshell: process.platform === \"win32\",\n\t\t\t});\n\n\t\t\t// On successful exit (status 0), replace editor content\n\t\t\tif (result.status === 0) {\n\t\t\t\tconst newContent = fs.readFileSync(tmpFile, \"utf-8\").replace(/\\n$/, \"\");\n\t\t\t\tthis.editor.setText(newContent);\n\t\t\t}\n\t\t\t// On non-zero exit, keep original text (no action needed)\n\t\t} finally {\n\t\t\t// Clean up temp file\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\n\t\t\t// Restart TUI\n\t\t\tthis.ui.start();\n\t\t\t// Force full re-render since external editor uses alternate screen\n\t\t\tthis.ui.requestRender(true);\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// UI helpers\n\t// =========================================================================\n\n\tclearEditor(): void {\n\t\tthis.editor.setText(\"\");\n\t\tthis.ui.requestRender();\n\t}\n\n\t/**\n\t * Paint a message as a filled block, the shape errors and warnings share.\n\t *\n\t * These two used to render differently for no reason anyone could name: an\n\t * error got a blank line above it and a column of padding, a warning got\n\t * neither, so a warning collided with whatever was printed before it and hung\n\t * off the left margin. Both are the same kind of interruption, so both get the\n\t * same frame, and the fill is what separates them from ordinary chat output —\n\t * a single coloured line is easy to scroll straight past.\n\t */\n\tprivate showBlock(bg: \"warningBg\" | \"toolErrorBg\", fg: \"warning\" | \"error\", title: string, body: string[]): void {\n\t\tconst box = new Box(1, 1, (t) => theme.bg(bg, t));\n\t\tbox.addChild(new Text(theme.bold(theme.fg(fg, title)), 0, 0));\n\t\tfor (const line of body) {\n\t\t\tbox.addChild(new Text(theme.fg(\"muted\", line), 0, 0));\n\t\t}\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(box);\n\t\tthis.ui.requestRender();\n\t}\n\n\t/**\n\t * Split a message into its headline and the rest.\n\t *\n\t * Multi-line notifications — `/learn` reporting where it searched, a settings\n\t * dump — read as a heading over detail, and rendering them as one undifferen-\n\t * tiated coloured block loses that. A single-line message is all headline.\n\t */\n\tprivate splitBlockMessage(message: string): { title: string; body: string[] } {\n\t\tconst [first = \"\", ...rest] = message.split(\"\\n\");\n\t\treturn { title: first, body: rest };\n\t}\n\n\tshowError(errorMessage: string): void {\n\t\tconst { title, body } = this.splitBlockMessage(errorMessage);\n\t\tthis.showBlock(\"toolErrorBg\", \"error\", `Error: ${title}`, body);\n\t}\n\n\tshowWarning(warningMessage: string): void {\n\t\tconst { title, body } = this.splitBlockMessage(warningMessage);\n\t\tthis.showBlock(\"warningBg\", \"warning\", title, body);\n\t}\n\n\t/**\n\t * A warning the user pays for if they miss it — same frame as `showWarning`,\n\t * with an explicit title over its body.\n\t */\n\tshowNotice(title: string, body: string[]): void {\n\t\tthis.showBlock(\"warningBg\", \"warning\", title, body);\n\t}\n\n\tshowNewVersionNotification(newVersion: string): void {\n\t\tconst action = theme.fg(\"accent\", `${APP_NAME} update`);\n\t\tconst updateInstruction = theme.fg(\"muted\", `New version ${newVersion} is available. Run `) + action;\n\t\tconst changelogUrl = \"https://github.com/kolisachint/hoocode/blob/main/packages/coding-agent/CHANGELOG.md\";\n\t\tconst changelogLink = getCapabilities().hyperlinks\n\t\t\t? hyperlink(theme.fg(\"accent\", \"open changelog\"), changelogUrl)\n\t\t\t: theme.fg(\"accent\", changelogUrl);\n\t\tconst changelogLine = theme.fg(\"muted\", \"Changelog: \") + changelogLink;\n\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.chatContainer.addChild(\n\t\t\tnew Text(\n\t\t\t\t`${theme.bold(theme.fg(\"warning\", \"Update Available\"))}\\n${updateInstruction}\\n${changelogLine}`,\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowPackageUpdateNotification(packages: string[]): void {\n\t\tconst action = theme.fg(\"accent\", `${APP_NAME} update`);\n\t\tconst updateInstruction = theme.fg(\"muted\", \"Package updates are available. Run \") + action;\n\t\tconst packageLines = packages.map((pkg) => `- ${pkg}`).join(\"\\n\");\n\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.chatContainer.addChild(\n\t\t\tnew Text(\n\t\t\t\t`${theme.bold(theme.fg(\"warning\", \"Package Updates Available\"))}\\n${updateInstruction}\\n${theme.fg(\"muted\", \"Packages:\")}\\n${packageLines}`,\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.ui.requestRender();\n\t}\n\n\t// =========================================================================\n\t// Selectors\n\t// =========================================================================\n\n\t/**\n\t * Shows a selector component in place of the editor.\n\t * @param create Factory that receives a `done` callback and returns the component and focus target\n\t */\n\tprivate showSelector(create: (done: () => void) => { component: Component; focus: Component }): void {\n\t\tconst done = () => {\n\t\t\tthis.editorContainer.clear();\n\t\t\tthis.editorContainer.addChild(this.editor);\n\t\t\tthis.ui.setFocus(this.editor);\n\t\t};\n\t\tconst { component, focus } = create(done);\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(component);\n\t\tthis.ui.setFocus(focus);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate showSettingsSelector(): void {\n\t\tthis.showSelector((done) => {\n\t\t\tconst disabledToolNames = new Set(this.settingsManager.getDisabledTools());\n\t\t\tconst builtinToolNames = this.session\n\t\t\t\t.getAllTools()\n\t\t\t\t.filter((t) => t.sourceInfo?.source === \"builtin\")\n\t\t\t\t.map((t) => t.name);\n\t\t\t// Union so tools disabled at startup (absent from the live registry) still\n\t\t\t// appear and can be re-enabled for the next session.\n\t\t\tconst toolToggleNames = [...new Set([...builtinToolNames, ...disabledToolNames])].sort();\n\t\t\tconst toolToggles = toolToggleNames.map((name) => ({ name, enabled: !disabledToolNames.has(name) }));\n\n\t\t\tconst toolGroups = [\n\t\t\t\t{\n\t\t\t\t\tid: \"web\",\n\t\t\t\t\tlabel: \"Web tools\",\n\t\t\t\t\tdescription: \"webfetch + websearch (network access).\",\n\t\t\t\t\tenabled: this.settingsManager.getEnableWebTools(),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tid: \"embsearch\",\n\t\t\t\t\tlabel: \"Semantic search\",\n\t\t\t\t\tdescription: \"Semantic index layer fused into the always-on search tool.\",\n\t\t\t\t\tenabled: this.settingsManager.getEnableEmbsearchTools(),\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst flagDefs = this.session.extensionRunner.getFlags();\n\t\t\tconst flagValues = this.session.extensionRunner.getFlagValues();\n\t\t\tconst flags = [...flagDefs.values()].map((flag) => ({\n\t\t\t\tname: flag.name,\n\t\t\t\tdescription: flag.description,\n\t\t\t\ttype: flag.type,\n\t\t\t\tvalue: flagValues.get(flag.name) ?? flag.default ?? (flag.type === \"boolean\" ? false : \"\"),\n\t\t\t}));\n\n\t\t\tconst selector = new SettingsSelectorComponent(\n\t\t\t\t{\n\t\t\t\t\tautoCompact: this.session.autoCompactionEnabled,\n\t\t\t\t\ttools: toolToggles,\n\t\t\t\t\ttoolGroups,\n\t\t\t\t\tflags,\n\t\t\t\t\ttoolOutputDisplay: this.toolOutputDisplay,\n\t\t\t\t\ttoolOutputMaxBytes: this.settingsManager.getToolOutputMaxBytes(),\n\t\t\t\t\ttoolOutputMaxLines: this.settingsManager.getToolOutputMaxLines(),\n\t\t\t\t\tcontextGc: this.settingsManager.getContextGcEnabled(),\n\t\t\t\t\tshowImages: this.settingsManager.getShowImages(),\n\t\t\t\t\timageWidthCells: this.settingsManager.getImageWidthCells(),\n\t\t\t\t\tautoResizeImages: this.settingsManager.getImageAutoResize(),\n\t\t\t\t\tblockImages: this.settingsManager.getBlockImages(),\n\t\t\t\t\tenableSkillCommands: this.settingsManager.getEnableSkillCommands(),\n\t\t\t\t\tpluginInstallScope: this.settingsManager.getPluginInstallScope(),\n\t\t\t\t\tsteeringMode: this.session.steeringMode,\n\t\t\t\t\tfollowUpMode: this.session.followUpMode,\n\t\t\t\t\ttransport: this.settingsManager.getTransport(),\n\t\t\t\t\tthinkingLevel: this.session.thinkingLevel,\n\t\t\t\t\tavailableThinkingLevels: this.session.getAvailableThinkingLevels(),\n\t\t\t\t\tcurrentTheme: this.settingsManager.getTheme() || \"dark\",\n\t\t\t\t\tavailableThemes: getAvailableThemes(),\n\t\t\t\t\thideThinkingBlock: this.hideThinkingBlock,\n\t\t\t\t\tcollapseChangelog: this.settingsManager.getCollapseChangelog(),\n\t\t\t\t\tenableInstallTelemetry: this.settingsManager.getEnableInstallTelemetry(),\n\t\t\t\t\tdoubleEscapeAction: this.settingsManager.getDoubleEscapeAction(),\n\t\t\t\t\ttreeFilterMode: this.settingsManager.getTreeFilterMode(),\n\t\t\t\t\tshowHardwareCursor: this.settingsManager.getShowHardwareCursor(),\n\t\t\t\t\teditorBorder: this.settingsManager.getEditorBorder(),\n\t\t\t\t\teditorPaddingX: this.settingsManager.getEditorPaddingX(),\n\t\t\t\t\tautocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),\n\t\t\t\t\tquietStartup: this.settingsManager.getQuietStartup(),\n\t\t\t\t\tclearOnShrink: this.settingsManager.getClearOnShrink(),\n\t\t\t\t\tshowTerminalProgress: this.settingsManager.getShowTerminalProgress(),\n\t\t\t\t\twarnings: this.settingsManager.getWarnings(),\n\t\t\t\t\t// Show the effective value: env VOICETOOLS_SILENCE_MS wins for voice;\n\t\t\t\t\t// getWebtoolsTimeoutSecs already folds in HOOCODE_WEBTOOLS_TIMEOUT.\n\t\t\t\t\tvoiceSilenceMs: resolveVoiceSilenceMs(this.settingsManager),\n\t\t\t\t\twebtoolsTimeoutSecs: this.settingsManager.getWebtoolsTimeoutSecs(),\n\t\t\t\t\t// The effective window, so a project settings.json narrowing it for\n\t\t\t\t\t// this repo shows up here rather than the user-scope value alone.\n\t\t\t\t\tlearn: (() => {\n\t\t\t\t\t\tconst learn = this.settingsManager.getLearnSettings();\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tlearnMaxSessions: learn.maxSessions,\n\t\t\t\t\t\t\tlearnMaxAgeDays: learn.maxAgeDays,\n\t\t\t\t\t\t\tlearnMinRepeats: learn.minRepeats,\n\t\t\t\t\t\t\tlearnMinRequestRepeats: learn.minRequestRepeats,\n\t\t\t\t\t\t\tlearnMaxProposals: learn.maxProposals,\n\t\t\t\t\t\t};\n\t\t\t\t\t})(),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tonAutoCompactChange: (enabled) => {\n\t\t\t\t\t\tthis.session.setAutoCompactionEnabled(enabled);\n\t\t\t\t\t\tthis.footer.setAutoCompactEnabled(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonToolEnabledChange: (name, enabled) => {\n\t\t\t\t\t\t// Persist for future sessions (feeds the startup denylist).\n\t\t\t\t\t\tconst disabled = new Set(this.settingsManager.getDisabledTools());\n\t\t\t\t\t\tif (enabled) {\n\t\t\t\t\t\t\tdisabled.delete(name);\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tdisabled.add(name);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.settingsManager.setDisabledTools([...disabled]);\n\n\t\t\t\t\t\t// Apply live for the current session. Re-enabling only works for\n\t\t\t\t\t\t// tools still in the registry (i.e. not removed at startup); tools\n\t\t\t\t\t\t// disabled before launch take effect on the next session.\n\t\t\t\t\t\tconst active = new Set(this.session.getActiveToolNames());\n\t\t\t\t\t\tif (enabled) {\n\t\t\t\t\t\t\tif (this.session.getToolDefinition(name)) {\n\t\t\t\t\t\t\t\tactive.add(name);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tactive.delete(name);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.session.setActiveToolsByName([...active]);\n\t\t\t\t\t\tthis.footerDataProvider.setSubagentEnabled(this.session.getActiveToolNames().includes(\"Task\"));\n\t\t\t\t\t},\n\t\t\t\t\tonToolGroupChange: (id, enabled) => {\n\t\t\t\t\t\t// Master switches for tool availability. These gate tool creation at\n\t\t\t\t\t\t// session build, so they persist and take effect on the next session.\n\t\t\t\t\t\tswitch (id) {\n\t\t\t\t\t\t\tcase \"web\":\n\t\t\t\t\t\t\t\tthis.settingsManager.setEnableWebTools(enabled);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"embsearch\":\n\t\t\t\t\t\t\t\tthis.settingsManager.setEnableEmbsearchTools(enabled);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonToolOutputDisplayChange: (level) => {\n\t\t\t\t\t\tthis.toolOutputDisplay = level;\n\t\t\t\t\t\tthis.settingsManager.setToolOutputDisplay(level);\n\t\t\t\t\t\tfor (const child of this.chatContainer.children) {\n\t\t\t\t\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\t\t\t\t\tchild.setDisplayLevel(level);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t},\n\t\t\t\t\tonToolOutputMaxBytesChange: (bytes) => {\n\t\t\t\t\t\tthis.settingsManager.setToolOutputMaxBytes(bytes);\n\t\t\t\t\t},\n\t\t\t\t\tonToolOutputMaxLinesChange: (lines) => {\n\t\t\t\t\t\tthis.settingsManager.setToolOutputMaxLines(lines);\n\t\t\t\t\t},\n\t\t\t\t\tonContextGcChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setContextGcEnabled(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonFlagChange: (name, value) => {\n\t\t\t\t\t\t// Persist for future launches; apply live best-effort (extensions\n\t\t\t\t\t\t// that read a flag only at load time pick it up on next launch).\n\t\t\t\t\t\tthis.settingsManager.setFlagOverride(name, value);\n\t\t\t\t\t\tthis.session.extensionRunner.setFlagValue(name, value);\n\t\t\t\t\t},\n\t\t\t\t\tonShowImagesChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setShowImages(enabled);\n\t\t\t\t\t\tfor (const child of this.chatContainer.children) {\n\t\t\t\t\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\t\t\t\t\tchild.setShowImages(enabled);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonImageWidthCellsChange: (width) => {\n\t\t\t\t\t\tthis.settingsManager.setImageWidthCells(width);\n\t\t\t\t\t\tfor (const child of this.chatContainer.children) {\n\t\t\t\t\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\t\t\t\t\tchild.setImageWidthCells(width);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonAutoResizeImagesChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setImageAutoResize(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonBlockImagesChange: (blocked) => {\n\t\t\t\t\t\tthis.settingsManager.setBlockImages(blocked);\n\t\t\t\t\t},\n\t\t\t\t\tonEnableSkillCommandsChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setEnableSkillCommands(enabled);\n\t\t\t\t\t\tthis.setupAutocompleteProvider();\n\t\t\t\t\t},\n\t\t\t\t\tonPluginInstallScopeChange: (scope) => {\n\t\t\t\t\t\t// Read per install by InstallPlugin, so it applies immediately with\n\t\t\t\t\t\t// nothing to reload.\n\t\t\t\t\t\tthis.settingsManager.setPluginInstallScope(scope);\n\t\t\t\t\t},\n\t\t\t\t\tonSteeringModeChange: (mode) => {\n\t\t\t\t\t\tthis.session.setSteeringMode(mode);\n\t\t\t\t\t},\n\t\t\t\t\tonFollowUpModeChange: (mode) => {\n\t\t\t\t\t\tthis.session.setFollowUpMode(mode);\n\t\t\t\t\t},\n\t\t\t\t\tonTransportChange: (transport) => {\n\t\t\t\t\t\tthis.settingsManager.setTransport(transport);\n\t\t\t\t\t\tthis.session.agent.transport = transport;\n\t\t\t\t\t},\n\t\t\t\t\tonThinkingLevelChange: (level) => {\n\t\t\t\t\t\tthis.session.setThinkingLevel(level);\n\t\t\t\t\t\tthis.footer.invalidate();\n\t\t\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\t\t},\n\t\t\t\t\tonThemeChange: (themeName) => {\n\t\t\t\t\t\tconst result = setTheme(themeName, true);\n\t\t\t\t\t\tthis.settingsManager.setTheme(themeName);\n\t\t\t\t\t\tthis.ui.invalidate();\n\t\t\t\t\t\tif (!result.success) {\n\t\t\t\t\t\t\tthis.showError(`Failed to load theme \"${themeName}\": ${result.error}\\nFell back to dark theme.`);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonThemePreview: (themeName) => {\n\t\t\t\t\t\tconst result = setTheme(themeName, true);\n\t\t\t\t\t\tif (result.success) {\n\t\t\t\t\t\t\tthis.ui.invalidate();\n\t\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonHideThinkingBlockChange: (hidden) => {\n\t\t\t\t\t\tthis.hideThinkingBlock = hidden;\n\t\t\t\t\t\tthis.settingsManager.setHideThinkingBlock(hidden);\n\t\t\t\t\t\tfor (const child of this.chatContainer.children) {\n\t\t\t\t\t\t\tif (child instanceof AssistantMessageComponent) {\n\t\t\t\t\t\t\t\tchild.setHideThinkingBlock(hidden);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.chatContainer.clear();\n\t\t\t\t\t\tthis.rebuildChatFromMessages();\n\t\t\t\t\t},\n\t\t\t\t\tonCollapseChangelogChange: (collapsed) => {\n\t\t\t\t\t\tthis.settingsManager.setCollapseChangelog(collapsed);\n\t\t\t\t\t},\n\t\t\t\t\tonEnableInstallTelemetryChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setEnableInstallTelemetry(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonQuietStartupChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setQuietStartup(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonDoubleEscapeActionChange: (action) => {\n\t\t\t\t\t\tthis.settingsManager.setDoubleEscapeAction(action);\n\t\t\t\t\t},\n\t\t\t\t\tonTreeFilterModeChange: (mode) => {\n\t\t\t\t\t\tthis.settingsManager.setTreeFilterMode(mode);\n\t\t\t\t\t},\n\t\t\t\t\tonShowHardwareCursorChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setShowHardwareCursor(enabled);\n\t\t\t\t\t\tthis.ui.setShowHardwareCursor(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonEditorBorderChange: (border) => {\n\t\t\t\t\t\tthis.settingsManager.setEditorBorder(border);\n\t\t\t\t\t\tthis.defaultEditor.setBorder(border);\n\t\t\t\t\t\tif (this.editor !== this.defaultEditor && this.editor.setBorder !== undefined) {\n\t\t\t\t\t\t\tthis.editor.setBorder(border);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonEditorPaddingXChange: (padding) => {\n\t\t\t\t\t\tthis.settingsManager.setEditorPaddingX(padding);\n\t\t\t\t\t\tthis.defaultEditor.setPaddingX(padding);\n\t\t\t\t\t\tif (this.editor !== this.defaultEditor && this.editor.setPaddingX !== undefined) {\n\t\t\t\t\t\t\tthis.editor.setPaddingX(padding);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonAutocompleteMaxVisibleChange: (maxVisible) => {\n\t\t\t\t\t\tthis.settingsManager.setAutocompleteMaxVisible(maxVisible);\n\t\t\t\t\t\tthis.defaultEditor.setAutocompleteMaxVisible(maxVisible);\n\t\t\t\t\t\tif (this.editor !== this.defaultEditor && this.editor.setAutocompleteMaxVisible !== undefined) {\n\t\t\t\t\t\t\tthis.editor.setAutocompleteMaxVisible(maxVisible);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonClearOnShrinkChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setClearOnShrink(enabled);\n\t\t\t\t\t\tthis.ui.setClearOnShrink(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonShowTerminalProgressChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setShowTerminalProgress(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonWarningsChange: (warnings) => {\n\t\t\t\t\t\tthis.settingsManager.setWarnings(warnings);\n\t\t\t\t\t},\n\t\t\t\t\tonVoiceSilenceMsChange: (ms) => {\n\t\t\t\t\t\tthis.settingsManager.setVoiceSilenceMs(ms);\n\t\t\t\t\t\t// Env override still wins; re-resolve so the live value stays consistent.\n\t\t\t\t\t\tthis.voice.setSilenceMs(resolveVoiceSilenceMs(this.settingsManager));\n\t\t\t\t\t},\n\t\t\t\t\tonWebtoolsTimeoutSecsChange: (secs) => {\n\t\t\t\t\t\t// Persisted; webfetch/websearch pick it up when tools rebuild next session.\n\t\t\t\t\t\tthis.settingsManager.setWebtoolsTimeoutSecs(secs);\n\t\t\t\t\t},\n\t\t\t\t\tonLearnSettingChange: (key, value) => {\n\t\t\t\t\t\t// /learn reads settings fresh on every invocation, so the next run\n\t\t\t\t\t\t// picks this up with no restart and nothing to re-wire live.\n\t\t\t\t\t\tthis.settingsManager.setLearnSetting(key, value);\n\t\t\t\t\t},\n\t\t\t\t\tonCancel: () => {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector.getSettingsList() };\n\t\t});\n\t}\n\n\tprivate showUserMessageSelector(): void {\n\t\tconst userMessages = this.session.getUserMessagesForForking();\n\n\t\tif (userMessages.length === 0) {\n\t\t\tthis.showStatus(\"No messages to fork from\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst initialSelectedId = userMessages[userMessages.length - 1]?.entryId;\n\n\t\tthis.showSelector((done) => {\n\t\t\tconst selector = new UserMessageSelectorComponent(\n\t\t\t\tuserMessages.map((m) => ({ id: m.entryId, text: m.text })),\n\t\t\t\tasync (entryId) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst result = await this.runtimeHost.fork(entryId);\n\t\t\t\t\t\tif (result.cancelled) {\n\t\t\t\t\t\t\tdone();\n\t\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tthis.renderCurrentSessionState();\n\t\t\t\t\t\tthis.editor.setText(result.selectedText ?? \"\");\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.showStatus(\"Forked to new session\");\n\t\t\t\t\t} catch (error: unknown) {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.showError(error instanceof Error ? error.message : String(error));\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tdone();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t},\n\t\t\t\tinitialSelectedId,\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector.getMessageList() };\n\t\t});\n\t}\n\n\tprivate showTreeSelector(initialSelectedId?: string): void {\n\t\tconst tree = this.sessionManager.getTree();\n\t\tconst realLeafId = this.sessionManager.getLeafId();\n\t\tconst initialFilterMode = this.settingsManager.getTreeFilterMode();\n\n\t\tif (tree.length === 0) {\n\t\t\tthis.showStatus(\"No entries in session\");\n\t\t\treturn;\n\t\t}\n\n\t\tthis.showSelector((done) => {\n\t\t\tconst selector = new TreeSelectorComponent(\n\t\t\t\ttree,\n\t\t\t\trealLeafId,\n\t\t\t\tthis.ui.terminal.rows,\n\t\t\t\tasync (entryId) => {\n\t\t\t\t\t// Selecting the current leaf is a no-op (already there)\n\t\t\t\t\tif (entryId === realLeafId) {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.showStatus(\"Already at this point\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Ask about summarization\n\t\t\t\t\tdone(); // Close selector first\n\n\t\t\t\t\t// Loop until user makes a complete choice or cancels to tree\n\t\t\t\t\tlet wantsSummary = false;\n\t\t\t\t\tlet customInstructions: string | undefined;\n\n\t\t\t\t\t// Check if we should skip the prompt (user preference to always default to no summary)\n\t\t\t\t\tif (!this.settingsManager.getBranchSummarySkipPrompt()) {\n\t\t\t\t\t\twhile (true) {\n\t\t\t\t\t\t\tconst summaryChoice = await this.dialogs.showSelector(\"Summarize branch?\", [\n\t\t\t\t\t\t\t\t\"No summary\",\n\t\t\t\t\t\t\t\t\"Summarize\",\n\t\t\t\t\t\t\t\t\"Summarize with custom prompt\",\n\t\t\t\t\t\t\t]);\n\n\t\t\t\t\t\t\tif (summaryChoice === undefined) {\n\t\t\t\t\t\t\t\t// User pressed escape - re-show tree selector with same selection\n\t\t\t\t\t\t\t\tthis.showTreeSelector(entryId);\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\twantsSummary = summaryChoice !== \"No summary\";\n\n\t\t\t\t\t\t\tif (summaryChoice === \"Summarize with custom prompt\") {\n\t\t\t\t\t\t\t\tcustomInstructions = await this.dialogs.showEditor(\"Custom summarization instructions\");\n\t\t\t\t\t\t\t\tif (customInstructions === undefined) {\n\t\t\t\t\t\t\t\t\t// User cancelled - loop back to summary selector\n\t\t\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t// User made a complete choice\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Set up escape handler and loader if summarizing\n\t\t\t\t\tlet summaryLoader: Loader | undefined;\n\t\t\t\t\tconst originalOnEscape = this.defaultEditor.onEscape;\n\n\t\t\t\t\tif (wantsSummary) {\n\t\t\t\t\t\tthis.defaultEditor.onEscape = () => {\n\t\t\t\t\t\t\tthis.session.abortBranchSummary();\n\t\t\t\t\t\t};\n\t\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\t\tsummaryLoader = new Loader(\n\t\t\t\t\t\t\tthis.ui,\n\t\t\t\t\t\t\t(spinner) => theme.fg(\"accent\", spinner),\n\t\t\t\t\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t\t\t\t\t`Summarizing branch... (${keyText(\"app.interrupt\")} to cancel)`,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tthis.statusContainer.addChild(summaryLoader);\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t}\n\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst result = await this.session.navigateTree(entryId, {\n\t\t\t\t\t\t\tsummarize: wantsSummary,\n\t\t\t\t\t\t\tcustomInstructions,\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tif (result.aborted) {\n\t\t\t\t\t\t\t// Summarization aborted - re-show tree selector with same selection\n\t\t\t\t\t\t\tthis.showStatus(\"Branch summarization cancelled\");\n\t\t\t\t\t\t\tthis.showTreeSelector(entryId);\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (result.cancelled) {\n\t\t\t\t\t\t\tthis.showStatus(\"Navigation cancelled\");\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Update UI\n\t\t\t\t\t\tthis.chatContainer.clear();\n\t\t\t\t\t\tthis.renderInitialMessages();\n\t\t\t\t\t\tif (result.editorText && !this.editor.getText().trim()) {\n\t\t\t\t\t\t\tthis.editor.setText(result.editorText);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.showStatus(\"Navigated to selected point\");\n\t\t\t\t\t\tvoid this.messageQueue.flushCompactionQueue({ willRetry: false });\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tthis.showError(error instanceof Error ? error.message : String(error));\n\t\t\t\t\t} finally {\n\t\t\t\t\t\tif (summaryLoader) {\n\t\t\t\t\t\t\tsummaryLoader.stop();\n\t\t\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.defaultEditor.onEscape = originalOnEscape;\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tdone();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t},\n\t\t\t\t(entryId, label) => {\n\t\t\t\t\tthis.sessionManager.appendLabelChange(entryId, label);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t},\n\t\t\t\tinitialSelectedId,\n\t\t\t\tinitialFilterMode,\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n\n\tprivate showSessionSelector(): void {\n\t\tthis.showSelector((done) => {\n\t\t\tconst selector = new SessionSelectorComponent(\n\t\t\t\t(onProgress) =>\n\t\t\t\t\tSessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir(), onProgress),\n\t\t\t\tSessionManager.listAll,\n\t\t\t\tasync (sessionPath) => {\n\t\t\t\t\tdone();\n\t\t\t\t\tawait this.handleResumeSession(sessionPath);\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tdone();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tvoid this.shutdown();\n\t\t\t\t},\n\t\t\t\t() => this.ui.requestRender(),\n\t\t\t\t{\n\t\t\t\t\trenameSession: async (sessionFilePath: string, nextName: string | undefined) => {\n\t\t\t\t\t\tconst next = (nextName ?? \"\").trim();\n\t\t\t\t\t\tif (!next) return;\n\t\t\t\t\t\tconst mgr = SessionManager.open(sessionFilePath);\n\t\t\t\t\t\tmgr.appendSessionInfo(next);\n\t\t\t\t\t},\n\t\t\t\t\tshowRenameHint: true,\n\t\t\t\t\tkeybindings: this.keybindings,\n\t\t\t\t},\n\n\t\t\t\tthis.sessionManager.getSessionFile(),\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n\n\tprivate async handleResumeSession(\n\t\tsessionPath: string,\n\t\toptions?: Parameters<ExtensionCommandContext[\"switchSession\"]>[1],\n\t): Promise<{ cancelled: boolean }> {\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t}\n\t\tthis.statusContainer.clear();\n\t\ttry {\n\t\t\tconst result = await this.runtimeHost.switchSession(sessionPath, {\n\t\t\t\twithSession: options?.withSession,\n\t\t\t});\n\t\t\tif (result.cancelled) {\n\t\t\t\treturn result;\n\t\t\t}\n\t\t\tthis.renderCurrentSessionState();\n\t\t\tthis.showStatus(\"Resumed session\");\n\t\t\treturn result;\n\t\t} catch (error: unknown) {\n\t\t\tif (error instanceof MissingSessionCwdError) {\n\t\t\t\tconst selectedCwd = await this.promptForMissingSessionCwd(error);\n\t\t\t\tif (!selectedCwd) {\n\t\t\t\t\tthis.showStatus(\"Resume cancelled\");\n\t\t\t\t\treturn { cancelled: true };\n\t\t\t\t}\n\t\t\t\tconst result = await this.runtimeHost.switchSession(sessionPath, {\n\t\t\t\t\tcwdOverride: selectedCwd,\n\t\t\t\t\twithSession: options?.withSession,\n\t\t\t\t});\n\t\t\t\tif (result.cancelled) {\n\t\t\t\t\treturn result;\n\t\t\t\t}\n\t\t\t\tthis.renderCurrentSessionState();\n\t\t\t\tthis.showStatus(\"Resumed session in current cwd\");\n\t\t\t\treturn result;\n\t\t\t}\n\t\t\treturn this.handleFatalRuntimeError(\"Failed to resume session\", error);\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Command handlers\n\t// =========================================================================\n\n\tprivate async handleReloadCommand(): Promise<void> {\n\t\tif (this.session.isStreaming) {\n\t\t\tthis.showWarning(\"Wait for the current response to finish before reloading.\");\n\t\t\treturn;\n\t\t}\n\t\tif (this.session.isCompacting) {\n\t\t\tthis.showWarning(\"Wait for compaction to finish before reloading.\");\n\t\t\treturn;\n\t\t}\n\n\t\tthis.resetExtensionUI();\n\n\t\tconst reloadBox = new Container();\n\t\tconst borderColor = (s: string) => theme.fg(\"border\", s);\n\t\treloadBox.addChild(new DynamicBorder(borderColor));\n\t\treloadBox.addChild(new Spacer(1));\n\t\treloadBox.addChild(\n\t\t\tnew Text(theme.fg(\"muted\", \"Reloading keybindings, extensions, skills, prompts, themes...\"), 1, 0),\n\t\t);\n\t\treloadBox.addChild(new Spacer(1));\n\t\treloadBox.addChild(new DynamicBorder(borderColor));\n\n\t\tconst previousEditor = this.editor;\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(reloadBox);\n\t\tthis.ui.setFocus(reloadBox);\n\t\tthis.ui.requestRender(true);\n\t\tawait new Promise((resolve) => process.nextTick(resolve));\n\n\t\tconst dismissReloadBox = (editor: Component) => {\n\t\t\tthis.editorContainer.clear();\n\t\t\tthis.editorContainer.addChild(editor);\n\t\t\tthis.ui.setFocus(editor);\n\t\t\tthis.ui.requestRender();\n\t\t};\n\n\t\ttry {\n\t\t\tawait this.session.reload();\n\t\t\tthis.keybindings.reload();\n\t\t\tconst activeHeader = this.chrome.customHeader ?? this.builtInHeader;\n\t\t\tif (isExpandable(activeHeader)) {\n\t\t\t\tactiveHeader.setExpanded(this.toolOutputExpanded);\n\t\t\t}\n\t\t\tsetRegisteredThemes(this.session.resourceLoader.getThemes().themes);\n\t\t\tthis.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();\n\t\t\tconst themeName = this.settingsManager.getTheme();\n\t\t\tconst themeResult = themeName ? setTheme(themeName, true) : { success: true };\n\t\t\tif (!themeResult.success) {\n\t\t\t\tthis.showError(`Failed to load theme \"${themeName}\": ${themeResult.error}\\nFell back to dark theme.`);\n\t\t\t}\n\t\t\tconst editorBorder = this.settingsManager.getEditorBorder();\n\t\t\tconst editorPaddingX = this.settingsManager.getEditorPaddingX();\n\t\t\tconst autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();\n\t\t\tthis.defaultEditor.setBorder(editorBorder);\n\t\t\tthis.defaultEditor.setPaddingX(editorPaddingX);\n\t\t\tthis.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);\n\t\t\tif (this.editor !== this.defaultEditor) {\n\t\t\t\tthis.editor.setBorder?.(editorBorder);\n\t\t\t\tthis.editor.setPaddingX?.(editorPaddingX);\n\t\t\t\tthis.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);\n\t\t\t}\n\t\t\tthis.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());\n\t\t\tthis.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());\n\t\t\tthis.setupAutocompleteProvider();\n\t\t\tconst runner = this.session.extensionRunner;\n\t\t\tthis.setupExtensionShortcuts(runner);\n\t\t\tthis.rebuildChatFromMessages();\n\t\t\tdismissReloadBox(this.editor as Component);\n\t\t\tthis.showLoadedResources({\n\t\t\t\tforce: false,\n\t\t\t\tshowDiagnosticsWhenQuiet: true,\n\t\t\t});\n\t\t\tconst modelsJsonError = this.session.modelRegistry.getError();\n\t\t\tif (modelsJsonError) {\n\t\t\t\tthis.showError(`models.json error: ${modelsJsonError}`);\n\t\t\t}\n\t\t\tthis.showStatus(\"Reloaded keybindings, extensions, skills, prompts, themes\");\n\t\t} catch (error) {\n\t\t\tdismissReloadBox(previousEditor as Component);\n\t\t\tthis.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);\n\t\t}\n\t}\n\n\tprivate async handleCompactCommand(customInstructions?: string): Promise<void> {\n\t\t// The session is the single source of truth for whether compaction is\n\t\t// possible (e.g. \"Already compacted\", \"Nothing to compact (session too\n\t\t// small)\"). Its specific error is surfaced via the compaction_end event,\n\t\t// so we don't pre-check here and risk a divergent message.\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t}\n\t\tthis.statusContainer.clear();\n\n\t\ttry {\n\t\t\tawait this.session.compact(customInstructions);\n\t\t} catch {\n\t\t\t// Ignore, will be emitted as an event\n\t\t}\n\t}\n\n\tstop(): void {\n\t\tthis.unregisterSignalHandlers();\n\t\tthis.voice.dispose();\n\t\tif (this.settingsManager.getShowTerminalProgress()) {\n\t\t\tthis.ui.terminal.setProgress(false);\n\t\t}\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t}\n\t\tthis.clearExtensionTerminalInputListeners();\n\t\tthis.footer.dispose();\n\t\tthis.footerDataProvider.dispose();\n\t\tif (this.unsubscribe) {\n\t\t\tthis.unsubscribe();\n\t\t}\n\t\tif (this.isInitialized) {\n\t\t\tthis.ui.stop();\n\t\t\tthis.isInitialized = false;\n\t\t}\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"interactive-mode.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/interactive-mode.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAQH,OAAO,KAAK,EAAoB,YAAY,EAAW,MAAM,yBAAyB,CAAC;AAgCvF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAsB/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAyFlE;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACtC,gEAAgE;IAChE,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,4DAA4D;IAC5D,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,qEAAqE;IACrE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,YAAY,EAAE,CAAC;IAC/B,4DAA4D;IAC5D,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,6DAA6D;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAC;CAClB;AAwDD,qBAAa,eAAe;IA8N1B,OAAO,CAAC,OAAO;IA7NhB,OAAO,CAAC,WAAW,CAAsB;IACzC,OAAO,CAAC,EAAE,CAAM;IAChB,OAAO,CAAC,aAAa,CAAY;IACjC,OAAO,CAAC,wBAAwB,CAAY;IAC5C,OAAO,CAAC,eAAe,CAAY;IACnC,OAAO,CAAC,aAAa,CAAe;IACpC,OAAO,CAAC,MAAM,CAAkB;IAChC,OAAO,CAAC,sBAAsB,CAA4B;IAC1D,OAAO,CAAC,KAAK,CAAkB;IAC/B,OAAO,CAAC,oBAAoB,CAAmC;IAC/D,OAAO,CAAC,4BAA4B,CAAqC;IACzE,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,eAAe,CAAY;IACnC,OAAO,CAAC,MAAM,CAAkB;IAChC,OAAO,CAAC,kBAAkB,CAAqB;IAE/C,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,eAAe,CAAC,CAAyB;IACjD,OAAO,CAAC,gBAAgB,CAAiC;IACzD,OAAO,CAAC,cAAc,CAAiC;IACvD,OAAO,CAAC,cAAc,CAAQ;IAC9B,OAAO,CAAC,uBAAuB,CAAiD;IAChF,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAgB;IACtD,OAAO,CAAC,QAAQ,CAAC,0BAA0B,CAAiB;IAC5D,OAAO,CAAC,mBAAmB,CAAmC;IAE9D,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,iBAAiB,CAAiC;IAC1D,OAAO,CAAC,mBAAmB,CAAS;IAGpC,OAAO,CAAC,gBAAgB,CAAiC;IACzD,OAAO,CAAC,cAAc,CAA+B;IAGrD,OAAO,CAAC,kBAAkB,CAAoD;IAC9E,OAAO,CAAC,gBAAgB,CAA2C;IACnE;;0EAEsE;IACtE,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAMrC;IAGH,OAAO,CAAC,YAAY,CAA6C;IAGjE,OAAO,CAAC,kBAAkB,CAAS;IAEnC,OAAO,CAAC,iBAAiB,CAAiD;IAG1E,OAAO,CAAC,iBAAiB,CAAS;IAGlC,OAAO,CAAC,aAAa,CAA6B;IAGlD,OAAO,CAAC,WAAW,CAAC,CAAa;IAEjC,OAAO,CAAC,oBAAoB,CAAC,CAAa;IAE1C,OAAO,CAAC,0BAA0B,CAAC,CAAa;IAChD,OAAO,CAAC,qBAAqB,CAAyB;IAGtD,OAAO,CAAC,UAAU,CAAS;IAG3B,OAAO,CAAC,oBAAoB,CAAiC;IAC7D,OAAO,CAAC,2BAA2B,CAAC,CAAa;IAGjD,OAAO,CAAC,WAAW,CAAiC;IACpD,OAAO,CAAC,cAAc,CAAyC;IAC/D,OAAO,CAAC,kBAAkB,CAAC,CAAa;IAGxC,OAAO,CAAC,KAAK,CAAC,CAAkB;IAGhC,OAAO,CAAC,iBAAiB,CAAS;IAMlC,OAAO,CAAC,cAAc,CAAyD;IAM/E,OAAO,CAAC,cAAc,CAA2D;IAGjF,OAAO,CAAC,iBAAiB,CAAS;IAGlC,OAAO,CAAC,OAAO,CAAmB;IAClC,OAAO,CAAC,mCAAmC,CAAyB;IAGpE,OAAO,CAAC,MAAM,CAAmB;IACjC,OAAO,CAAC,oBAAoB,CAAa;IACzC,OAAO,CAAC,oBAAoB,CAAa;IAGzC,OAAO,CAAC,SAAS,CAAqB;IAGtC,OAAO,CAAC,SAAS,CAAsB;IAGvC,OAAO,CAAC,aAAa,CAA0B;IAG/C,OAAO,CAAC,YAAY,CAAyB;IAG7C,OAAO,CAAC,eAAe,CAAkB;IAGzC,OAAO,CAAC,eAAe,CAAkB;IAGzC,OAAO,CAAC,eAAe,CAAY;IAGnC,OAAO,CAAC,aAAa,CAAoC;IAGzD,OAAO,KAAK,OAAO,GAElB;IACD,OAAO,CAAC,gBAAgB,CAAC,CAAkB;IAC3C;;;;OAIG;IACH,OAAO,KAAK,eAAe,GAqD1B;IACD,OAAO,CAAC,oBAAoB;IAM5B,OAAO,KAAK,KAAK,GAEhB;IACD,OAAO,KAAK,cAAc,GAEzB;IACD,OAAO,KAAK,eAAe,GAE1B;IAED,YACC,WAAW,EAAE,mBAAmB,EACxB,OAAO,GAAE,sBAA2B,EA0I5C;IAED,OAAO,CAAC,wBAAwB;IAyBhC,OAAO,CAAC,6BAA6B;IAQrC,OAAO,CAAC,oCAAoC;IAe5C,OAAO,CAAC,8BAA8B;IA6EtC,OAAO,CAAC,yBAAyB;IAajC,OAAO,CAAC,0BAA0B;IA8BlC,qFAAqF;IACrF,OAAO,CAAC,kBAAkB;IAIpB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAuK1B;IAED;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAU3B;;;OAGG;IACG,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,CAyEzB;IAED,OAAO,CAAC,4BAA4B;IAWpC,OAAO,CAAC,wBAAwB;IAIhC,4EAA4E;IAC5E,OAAO,CAAC,mBAAmB;IAiC3B;;;OAGG;IACH,OAAO,CAAC,yBAAyB;YAYnB,4BAA4B;IAmF1C,OAAO,CAAC,oBAAoB;YAoBd,oBAAoB;YAWpB,uBAAuB;IAQrC,OAAO,CAAC,yBAAyB;IAUjC;;;;;;OAMG;IACH,OAAO,CAAC,oBAAoB;IAa5B;;OAEG;IACH,OAAO,CAAC,2BAA2B;IAInC;;OAEG;IACH,OAAO,CAAC,uBAAuB;IAoD/B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAK1B,OAAO,CAAC,uBAAuB;IAI/B,OAAO,CAAC,mBAAmB;IAU3B,OAAO,CAAC,iBAAiB;IAQzB,OAAO,CAAC,iBAAiB;IAezB,OAAO,CAAC,mBAAmB;IAM3B,OAAO,CAAC,sBAAsB;IAa9B,OAAO,CAAC,gBAAgB;IAqBxB,OAAO,CAAC,iCAAiC;IAWzC,OAAO,CAAC,oCAAoC;IAO5C;;OAEG;IACH,OAAO,CAAC,wBAAwB;YA2ElB,0BAA0B;IAKxC;;;OAGG;IACH,OAAO,CAAC,wBAAwB;IAkEhC;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAU3B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAsB1B,OAAO,CAAC,gBAAgB;YAwEV,yBAAyB;IA0BvC;;;OAGG;IACH,gBAAgB,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI,CAEjD;IAED;;;;;OAKG;IACH,OAAO,CAAC,0BAA0B;IA6JlC,OAAO,CAAC,wBAAwB;IAmEhC,OAAO,CAAC,gBAAgB;IAMxB;;;;;;;;;;OAUG;IACH,OAAO,CAAC,mBAAmB;IAW3B;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,uBAAuB;IAM/B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,YAAY;YAmCN,WAAW;IAyXzB,+CAA+C;IAC/C,OAAO,CAAC,kBAAkB;IAS1B;;;;;OAKG;IAEH,OAAO,CAAC,UAAU;IAoBlB,OAAO,CAAC,gBAAgB;IA4FxB;;;;;OAKG;IACH,OAAO,CAAC,oBAAoB;IAuE5B,qBAAqB,IAAI,IAAI,CAe5B;IAEK,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,CAOpC;IAED,OAAO,CAAC,uBAAuB;IAU/B,OAAO,CAAC,WAAW;IAUnB,OAAO,CAAC,WAAW;IAKnB;;;;OAIG;IACH,OAAO,CAAC,cAAc,CAAS;YAEjB,QAAQ;IAkBtB,OAAO,CAAC,qBAAqB;YAYf,sBAAsB;IAKpC,OAAO,CAAC,sBAAsB;IAgC9B,OAAO,CAAC,wBAAwB;IAOhC,OAAO,CAAC,WAAW;YAqCL,cAAc;IAgC5B,OAAO,CAAC,aAAa;IASrB,OAAO,CAAC,uBAAuB;IAU/B,OAAO,CAAC,wBAAwB;IAWhC,OAAO,CAAC,kBAAkB;IAW1B,OAAO,CAAC,yBAAyB;IAIjC,OAAO,CAAC,gBAAgB;IAcxB,OAAO,CAAC,6BAA6B;IAkBrC,OAAO,CAAC,kBAAkB;IAoD1B,WAAW,IAAI,IAAI,CAGlB;IAED;;;;;;;;;OASG;IACH,OAAO,CAAC,SAAS;IAWjB;;;;;;OAMG;IACH,OAAO,CAAC,iBAAiB;IAKzB,SAAS,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAGpC;IAED,WAAW,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAGxC;IAED;;;OAGG;IACH,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAE9C;IAED,0BAA0B,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAoBnD;IAED,6BAA6B,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,CAgBtD;IAMD;;;OAGG;IACH,OAAO,CAAC,YAAY;IAapB,OAAO,CAAC,oBAAoB;IA6S5B,OAAO,CAAC,uBAAuB;IAyC/B,OAAO,CAAC,gBAAgB;IAiIxB,OAAO,CAAC,mBAAmB;YAmCb,mBAAmB;YA6CnB,mBAAmB;YAmFnB,oBAAoB;IAkBlC,IAAI,IAAI,IAAI,CAsBX;CACD","sourcesContent":["/**\n * Interactive mode for the coding agent.\n * Handles TUI rendering and user interaction, delegating business logic to AgentSession.\n */\n\nimport * as crypto from \"node:crypto\";\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport type { AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport { createCompactionSummaryMessage } from \"@kolisachint/hoocode-agent-core\";\nimport type { AssistantMessage, ImageContent, Message } from \"@kolisachint/hoocode-ai\";\nimport type {\n\tAutocompleteItem,\n\tAutocompleteProvider,\n\tEditorComponent,\n\tKeyId,\n\tMarkdownTheme,\n\tSlashCommand,\n} from \"@kolisachint/hoocode-tui\";\nimport {\n\tBox,\n\tCombinedAutocompleteProvider,\n\ttype Component,\n\tContainer,\n\tfuzzyFilter,\n\tgetCapabilities,\n\thyperlink,\n\tLoader,\n\ttype LoaderIndicatorOptions,\n\tMarkdown,\n\tmatchesKey,\n\tProcessTerminal,\n\tSpacer,\n\tsetKeybindings,\n\tText,\n\tTUI,\n} from \"@kolisachint/hoocode-tui\";\nimport { spawnSync } from \"child_process\";\nimport { APP_NAME, APP_TITLE, VERSION } from \"../../config.js\";\nimport { setTerminalOwnedByTui } from \"../../core/agent-log.js\";\nimport { loadAgentRegistry } from \"../../core/agent-registry.js\";\nimport { type AgentSession, type AgentSessionEvent, parseSkillBlock } from \"../../core/agent-session.js\";\nimport type { AgentSessionRuntime } from \"../../core/agent-session-runtime.js\";\nimport { type AssistantUsageTotals, sumAssistantUsage } from \"../../core/agent-session-stats.js\";\nimport type {\n\tAutocompleteProviderFactory,\n\tEditorFactory,\n\tExtensionCommandContext,\n\tExtensionContext,\n\tExtensionRunner,\n\tExtensionUIContext,\n} from \"../../core/extensions/index.js\";\nimport { FooterDataProvider } from \"../../core/footer-data-provider.js\";\nimport { formatDurationSecs } from \"../../core/format-duration.js\";\nimport { formatTokens } from \"../../core/format-tokens.js\";\nimport { type AppKeybinding, KeybindingsManager } from \"../../core/keybindings.js\";\nimport type { ResourceDiagnostic } from \"../../core/resource-loader.js\";\nimport { formatMissingSessionCwdPrompt, MissingSessionCwdError } from \"../../core/session-cwd.js\";\nimport { type SessionContext, SessionManager } from \"../../core/session-manager.js\";\nimport type { SettingsManager } from \"../../core/settings-manager.js\";\nimport { BUILTIN_SLASH_COMMANDS } from \"../../core/slash-commands.js\";\nimport type { SourceInfo } from \"../../core/source-info.js\";\nimport { startupProgress } from \"../../core/startup-progress.js\";\nimport { taskStore } from \"../../core/task-store.js\";\nimport type { TeamViewConnection } from \"../../core/team-view.js\";\nimport { settleDanglingMainTasks } from \"../../core/tools/todo.js\";\nimport type { TruncationResult } from \"../../core/tools/truncate.js\";\nimport { buildCompactWordmark } from \"../../core/wordmark.js\";\nimport { extensionForImageMimeType, readClipboardImage } from \"../../utils/clipboard-image.js\";\nimport { parseGitUrl } from \"../../utils/git.js\";\nimport { killTrackedDetachedChildren } from \"../../utils/shell.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { checkForNewHooCodeVersion } from \"../../utils/version-check.js\";\nimport { BashExecutionController } from \"./bash-execution-controller.js\";\nimport { type CommandContext, CommandExecutor } from \"./command-executor.js\";\nimport { BELL, CompletionChime } from \"./completion-chime.js\";\nimport { AssistantMessageComponent } from \"./components/assistant-message.js\";\nimport { BashExecutionComponent } from \"./components/bash-execution.js\";\nimport { BranchSummaryMessageComponent } from \"./components/branch-summary-message.js\";\nimport { CompactionSummaryMessageComponent } from \"./components/compaction-summary-message.js\";\nimport { CountdownTimer } from \"./components/countdown-timer.js\";\nimport { CustomEditor } from \"./components/custom-editor.js\";\nimport { CustomMessageComponent } from \"./components/custom-message.js\";\nimport { DynamicBorder } from \"./components/dynamic-border.js\";\nimport { FooterComponent } from \"./components/footer.js\";\nimport { keyHint, keyText, rawKeyHint } from \"./components/keybinding-hints.js\";\nimport { SessionSelectorComponent } from \"./components/session-selector.js\";\nimport { SettingsSelectorComponent } from \"./components/settings-selector.js\";\nimport { SkillInvocationMessageComponent } from \"./components/skill-invocation-message.js\";\nimport { TaskPanelComponent } from \"./components/task-panel.js\";\nimport { ToolExecutionComponent } from \"./components/tool-execution.js\";\nimport { TreeSelectorComponent } from \"./components/tree-selector.js\";\nimport { UserMessageComponent } from \"./components/user-message.js\";\nimport { UserMessageSelectorComponent } from \"./components/user-message-selector.js\";\nimport { ExtensionChrome } from \"./extension-chrome.js\";\nimport { ExtensionDialogs } from \"./extension-dialogs.js\";\nimport { LoginController } from \"./login-controller.js\";\nimport { MessageQueueController } from \"./message-queue-controller.js\";\nimport { ModelController } from \"./model-controller.js\";\nimport {\n\tExpandableText,\n\tformatDisplayPath,\n\tisExpandable,\n\tshowLoadedResources as renderLoadedResources,\n} from \"./resource-display.js\";\nimport { checkForPackageUpdates, checkTmuxKeyboardSetup, getChangelogForDisplay } from \"./startup-checks.js\";\nimport { TeamFocusController } from \"./team-focus.js\";\nimport {\n\tgetAvailableThemes,\n\tgetAvailableThemesWithPaths,\n\tgetEditorTheme,\n\tgetMarkdownTheme,\n\tgetThemeByName,\n\tinitTheme,\n\tonThemeChange,\n\tsetRegisteredThemes,\n\tsetTheme,\n\tsetThemeInstance,\n\tstopThemeWatcher,\n\tTheme,\n\ttheme,\n} from \"./theme/theme.js\";\nimport { VoiceController } from \"./voice/voice-controller.js\";\n\n/** Interface for components that can be expanded/collapsed */\n\nconst DEAD_TERMINAL_ERROR_CODES = new Set([\"EIO\", \"EPIPE\", \"ENOTCONN\"]);\n\n/**\n * Resolve the effective voice trailing-silence window (ms): env\n * `VOICETOOLS_SILENCE_MS` wins (like `VOICETOOLS_BIN`), otherwise the settings\n * value. Both paths are clamped to 300-10000; a malformed env value falls back to\n * the settings value.\n */\nfunction resolveVoiceSilenceMs(settingsManager: SettingsManager): number {\n\tconst envRaw = process.env.VOICETOOLS_SILENCE_MS?.trim();\n\tif (envRaw) {\n\t\tconst envValue = Number(envRaw);\n\t\tif (Number.isFinite(envValue) && envValue > 0) {\n\t\t\treturn Math.min(10000, Math.max(300, Math.floor(envValue)));\n\t\t}\n\t}\n\treturn settingsManager.getVoiceSilenceMs();\n}\n\nfunction isDeadTerminalError(error: unknown): boolean {\n\tif (!error || typeof error !== \"object\" || !(\"code\" in error)) {\n\t\treturn false;\n\t}\n\tconst code = (error as NodeJS.ErrnoException).code;\n\treturn code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);\n}\n\n/**\n * Options for InteractiveMode initialization.\n */\nexport interface InteractiveModeOptions {\n\t/** Providers that were migrated to auth.json (shows warning) */\n\tmigratedProviders?: string[];\n\t/** Warning message if session model couldn't be restored */\n\tmodelFallbackMessage?: string;\n\t/** Initial message to send on startup (can include @file content) */\n\tinitialMessage?: string;\n\t/** Images to attach to the initial message */\n\tinitialImages?: ImageContent[];\n\t/** Additional messages to send after the initial message */\n\tinitialMessages?: string[];\n\t/** Force verbose startup (overrides quietStartup setting) */\n\tverbose?: boolean;\n}\n\n// How often the streaming assistant message re-parses its markdown. Deltas can\n// arrive far faster than this; every application re-lexes the growing tail\n// block, so applying per-delta made streaming cost O(message²).\nconst STREAM_RENDER_THROTTLE_MS = 100;\n// Task-store mutations (subagent tool progress, usage ticks) arrive in bursts;\n// each render they trigger reassembles the whole component tree, so cap them.\nconst TASK_RENDER_THROTTLE_MS = 50;\n// Finished tool blocks beyond this many (oldest first) are frozen to release\n// their retained output/image payloads (see ToolExecutionComponent.freeze).\n// Generous so nothing near the viewport is ever frozen; the cap bounds the\n// view layer's memory across a long session heavy on tool output.\nconst LIVE_TOOL_WINDOW = 50;\n\n/**\n * Leading+trailing throttle: the first call runs immediately, calls landing\n * inside the window coalesce into one trailing run with the latest state.\n */\nfunction throttled(ms: number, fn: () => void): () => void {\n\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\tlet pending = false;\n\tconst run = () => {\n\t\tfn();\n\t\ttimer = setTimeout(() => {\n\t\t\ttimer = undefined;\n\t\t\tif (pending) {\n\t\t\t\tpending = false;\n\t\t\t\trun();\n\t\t\t}\n\t\t}, ms);\n\t\ttimer.unref?.();\n\t};\n\treturn () => {\n\t\tif (timer) {\n\t\t\tpending = true;\n\t\t\treturn;\n\t\t}\n\t\trun();\n\t};\n}\n\n/**\n * Style a status/notify message for the chat.\n *\n * Plain messages are dimmed, as they always were. A message that already\n * carries escape codes is passed through untouched: `theme.fg` closes with\n * `\\x1b[39m` (reset foreground, not \"restore\"), so wrapping a pre-styled string\n * in dim would drop the dim at the first inner span — and the TUI's wrapper\n * carries that reset state across newlines, so every following line would lose\n * it too. Passing through is what lets a listing colour its own columns.\n */\nfunction styleStatusMessage(message: string): string {\n\treturn message.includes(\"\\x1b[\") ? message : theme.fg(\"dim\", message);\n}\n\nexport class InteractiveMode {\n\tprivate runtimeHost: AgentSessionRuntime;\n\tprivate ui: TUI;\n\tprivate chatContainer: Container;\n\tprivate pendingMessagesContainer: Container;\n\tprivate statusContainer: Container;\n\tprivate defaultEditor: CustomEditor;\n\tprivate editor: EditorComponent;\n\tprivate editorComponentFactory: EditorFactory | undefined;\n\tprivate voice: VoiceController;\n\tprivate autocompleteProvider: AutocompleteProvider | undefined;\n\tprivate autocompleteProviderWrappers: AutocompleteProviderFactory[] = [];\n\tprivate fdPath: string | undefined;\n\tprivate editorContainer: Container;\n\tprivate footer: FooterComponent;\n\tprivate footerDataProvider: FooterDataProvider;\n\t// Stored so the same manager can be injected into custom editors, selectors, and extension UI.\n\tprivate keybindings: KeybindingsManager;\n\tprivate version: string;\n\tprivate isInitialized = false;\n\tprivate onInputCallback?: (text: string) => void;\n\tprivate loadingAnimation: Loader | undefined = undefined;\n\tprivate workingMessage: string | undefined = undefined;\n\tprivate workingVisible = true;\n\tprivate workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined;\n\tprivate readonly defaultWorkingMessage = \"Working...\";\n\tprivate readonly defaultHiddenThinkingLabel = \"Thinking...\";\n\tprivate hiddenThinkingLabel = this.defaultHiddenThinkingLabel;\n\n\tprivate lastSigintTime = 0;\n\tprivate lastEscapeTime = 0;\n\tprivate changelogMarkdown: string | undefined = undefined;\n\tprivate startupNoticesShown = false;\n\n\t// Status line tracking (for mutating immediately-sequential status updates)\n\tprivate lastStatusSpacer: Spacer | undefined = undefined;\n\tprivate lastStatusText: Text | undefined = undefined;\n\n\t// Streaming message tracking\n\tprivate streamingComponent: AssistantMessageComponent | undefined = undefined;\n\tprivate streamingMessage: AssistantMessage | undefined = undefined;\n\t/** Throttled re-parse/re-render of the in-flight assistant message. The\n\t * guard makes a trailing run after message_end/agent_end a no-op; a direct\n\t * updateContent on message_end flushes the final state regardless. */\n\tprivate readonly scheduleStreamingRender = throttled(STREAM_RENDER_THROTTLE_MS, () => {\n\t\tif (!this.streamingComponent || !this.streamingMessage) return;\n\t\t// streaming=true: large blocks render segmented so only the tail chunk\n\t\t// re-parses; message_end's direct updateContent renders the canonical form.\n\t\tthis.streamingComponent.updateContent(this.streamingMessage, true);\n\t\tthis.ui.requestRender();\n\t});\n\n\t// Tool execution tracking: toolCallId -> component\n\tprivate pendingTools = new Map<string, ToolExecutionComponent>();\n\n\t// Tool output expansion state\n\tprivate toolOutputExpanded = false;\n\t// Persisted tool-output display level (collapsed / peek / standard).\n\tprivate toolOutputDisplay: \"collapsed\" | \"peek\" | \"standard\" = \"standard\";\n\n\t// Thinking block visibility state\n\tprivate hideThinkingBlock = false;\n\n\t// Skill commands: command name -> skill file path\n\tprivate skillCommands = new Map<string, string>();\n\n\t// Agent subscription unsubscribe function\n\tprivate unsubscribe?: () => void;\n\t// Task store subscription unsubscribe function (task panel)\n\tprivate taskStoreUnsubscribe?: () => void;\n\t// Startup-progress subscription unsubscribe function (footer download/index bars)\n\tprivate startupProgressUnsubscribe?: () => void;\n\tprivate signalCleanupHandlers: Array<() => void> = [];\n\n\t// Track if editor is in bash mode (text starts with !)\n\tprivate isBashMode = false;\n\n\t// Auto-compaction state\n\tprivate autoCompactionLoader: Loader | undefined = undefined;\n\tprivate autoCompactionEscapeHandler?: () => void;\n\n\t// Auto-retry state\n\tprivate retryLoader: Loader | undefined = undefined;\n\tprivate retryCountdown: CountdownTimer | undefined = undefined;\n\tprivate retryEscapeHandler?: () => void;\n\n\t// Completion chime state\n\tprivate chime?: CompletionChime;\n\t// True between auto_retry_start and the next agent_start: the turn is not done,\n\t// it is about to re-run, so the deferred completion check must not fire the chime.\n\tprivate chimePendingRetry = false;\n\t// Stop reason of the request's latest assistant message, reset when the user\n\t// starts a new one. Read at settle time by two consumers that ask the same\n\t// question — did this request end cleanly? An \"aborted\" turn means the user is\n\t// present, so no chime; anything other than a clean \"stop\" means dangling plan\n\t// items must not be claimed as completed.\n\tprivate turnStopReason: AssistantMessage[\"stopReason\"] | undefined = undefined;\n\n\t// Cumulative usage + wall clock captured at the first agent_start of a request,\n\t// diffed at agent_end to print that request's own cost into the transcript.\n\t// Anchored on the FIRST start so a retried request reports one span, not one\n\t// per attempt (same reason the chime anchors its duration there).\n\tprivate turnCostAnchor: { totals: AssistantUsageTotals; at: number } | undefined;\n\n\t// Shutdown state\n\tprivate shutdownRequested = false;\n\n\t// Extension UI state\n\tprivate dialogs: ExtensionDialogs;\n\tprivate extensionTerminalInputUnsubscribers = new Set<() => void>();\n\n\t// Extension widgets (components rendered above/below the editor)\n\tprivate chrome!: ExtensionChrome;\n\tprivate widgetContainerAbove!: Container;\n\tprivate widgetContainerBelow!: Container;\n\n\t// Task panel shown just above the editor (active subagent tasks)\n\tprivate taskPanel: TaskPanelComponent;\n\n\t// hooteams team client (--team): steering + attach share its single SSE stream\n\tprivate teamFocus: TeamFocusController;\n\n\t// Bash command execution (the `!cmd` prompt mode)\n\tprivate bashExecution: BashExecutionController;\n\n\t// Message queueing (compaction queue + pending-messages display)\n\tprivate messageQueue: MessageQueueController;\n\n\t// Model selection (/model, /models, cycle keys)\n\tprivate modelController: ModelController;\n\n\t// Auth / login flows (/login, /logout)\n\tprivate loginController: LoginController;\n\n\t// Header container that holds the built-in or custom header\n\tprivate headerContainer: Container;\n\n\t// Built-in header (logo + keybinding hints + changelog)\n\tprivate builtInHeader: Component | undefined = undefined;\n\n\t// Convenience accessors\n\tprivate get session(): AgentSession {\n\t\treturn this.runtimeHost.session;\n\t}\n\tprivate _commandExecutor?: CommandExecutor;\n\t/**\n\t * Lazily-built command executor. The context uses getters for mutable\n\t * dependencies (e.g. the active session) so handlers always operate on the\n\t * current state even after a session switch.\n\t */\n\tprivate get commandExecutor(): CommandExecutor {\n\t\tif (!this._commandExecutor) {\n\t\t\tconst self = this;\n\t\t\tconst context: CommandContext = {\n\t\t\t\tget session() {\n\t\t\t\t\treturn self.session;\n\t\t\t\t},\n\t\t\t\tget sessionManager() {\n\t\t\t\t\treturn self.sessionManager;\n\t\t\t\t},\n\t\t\t\tget runtimeHost() {\n\t\t\t\t\treturn self.runtimeHost;\n\t\t\t\t},\n\t\t\t\tget ui() {\n\t\t\t\t\treturn self.ui;\n\t\t\t\t},\n\t\t\t\tget editor() {\n\t\t\t\t\treturn self.editor;\n\t\t\t\t},\n\t\t\t\tget editorContainer() {\n\t\t\t\t\treturn self.editorContainer;\n\t\t\t\t},\n\t\t\t\tget chatContainer() {\n\t\t\t\t\treturn self.chatContainer;\n\t\t\t\t},\n\t\t\t\tget statusContainer() {\n\t\t\t\t\treturn self.statusContainer;\n\t\t\t\t},\n\t\t\t\tget footer() {\n\t\t\t\t\treturn self.footer;\n\t\t\t\t},\n\t\t\t\tget keybindings() {\n\t\t\t\t\treturn self.keybindings;\n\t\t\t\t},\n\t\t\t\tshowStatus: (message) => self.showStatus(message),\n\t\t\t\tshowError: (message) => self.showError(message),\n\t\t\t\tshowWarning: (message) => self.showWarning(message),\n\t\t\t\tupdateEditorBorderColor: () => self.updateEditorBorderColor(),\n\t\t\t\trenderCurrentSessionState: () => self.renderCurrentSessionState(),\n\t\t\t\trebuildChatFromMessages: () => self.rebuildChatFromMessages(),\n\t\t\t\tgetMarkdownThemeWithSettings: () => self.getMarkdownThemeWithSettings(),\n\t\t\t\tstopLoadingAnimation: () => self.stopLoadingAnimation(),\n\t\t\t\tfindExactModelMatch: (searchTerm) => self.modelController.findExactModelMatch(searchTerm),\n\t\t\t\tmaybeWarnAboutAnthropicSubscriptionAuth: (model) =>\n\t\t\t\t\tself.modelController.maybeWarnAboutAnthropicSubscriptionAuth(model),\n\t\t\t\tshowModelSelector: (searchTerm) => self.modelController.showModelSelector(searchTerm),\n\t\t\t\tshowExtensionConfirm: (title, message) => self.dialogs.confirm(title, message),\n\t\t\t\tpromptForMissingSessionCwd: (error) => self.promptForMissingSessionCwd(error),\n\t\t\t\thandleFatalRuntimeError: (prefix, error) => self.handleFatalRuntimeError(prefix, error),\n\t\t\t};\n\t\t\tthis._commandExecutor = new CommandExecutor(context);\n\t\t}\n\t\treturn this._commandExecutor;\n\t}\n\tprivate stopLoadingAnimation(): void {\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t}\n\t}\n\tprivate get agent() {\n\t\treturn this.session.agent;\n\t}\n\tprivate get sessionManager() {\n\t\treturn this.session.sessionManager;\n\t}\n\tprivate get settingsManager() {\n\t\treturn this.session.settingsManager;\n\t}\n\n\tconstructor(\n\t\truntimeHost: AgentSessionRuntime,\n\t\tprivate options: InteractiveModeOptions = {},\n\t) {\n\t\tthis.runtimeHost = runtimeHost;\n\t\tthis.runtimeHost.setBeforeSessionInvalidate(() => {\n\t\t\tthis.resetExtensionUI();\n\t\t});\n\t\tthis.runtimeHost.setRebindSession(async () => {\n\t\t\tawait this.rebindCurrentSession();\n\t\t});\n\t\tthis.version = VERSION;\n\t\tthis.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());\n\t\tthis.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());\n\t\tthis.headerContainer = new Container();\n\t\tthis.chatContainer = new Container();\n\t\tthis.pendingMessagesContainer = new Container();\n\t\tthis.statusContainer = new Container();\n\t\tthis.widgetContainerAbove = new Container();\n\t\tthis.widgetContainerBelow = new Container();\n\t\tthis.keybindings = KeybindingsManager.create();\n\t\tsetKeybindings(this.keybindings);\n\t\tconst editorPaddingX = this.settingsManager.getEditorPaddingX();\n\t\tconst autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();\n\t\tthis.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, {\n\t\t\tborder: this.settingsManager.getEditorBorder(),\n\t\t\tpaddingX: editorPaddingX,\n\t\t\tautocompleteMaxVisible,\n\t\t});\n\t\tthis.editor = this.defaultEditor;\n\t\tthis.editor.promptPrefix = \"❯\";\n\t\tthis.editorContainer = new Container();\n\t\tthis.editorContainer.addChild(this.editor as Component);\n\t\tthis.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());\n\t\tthis.footer = new FooterComponent(this.session, this.footerDataProvider);\n\t\tthis.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);\n\t\tthis.footerDataProvider.setSubagentEnabled(this.session.getActiveToolNames().includes(\"Task\"));\n\t\tthis.chrome = new ExtensionChrome({\n\t\t\tui: this.ui,\n\t\t\twidgetContainerAbove: this.widgetContainerAbove,\n\t\t\twidgetContainerBelow: this.widgetContainerBelow,\n\t\t\theaderContainer: this.headerContainer,\n\t\t\tfooter: this.footer,\n\t\t\tfooterDataProvider: this.footerDataProvider,\n\t\t\tgetBuiltInHeader: () => this.builtInHeader,\n\t\t\tisToolOutputExpanded: () => this.toolOutputExpanded,\n\t\t});\n\t\tthis.taskPanel = new TaskPanelComponent(this.ui);\n\t\tthis.dialogs = new ExtensionDialogs({\n\t\t\tui: this.ui,\n\t\t\teditorContainer: this.editorContainer,\n\t\t\tkeybindings: this.keybindings,\n\t\t\tgetEditor: () => this.editor,\n\t\t});\n\t\tthis.teamFocus = new TeamFocusController({\n\t\t\tui: this.ui,\n\t\t\ttaskPanel: this.taskPanel,\n\t\t\teditorContainer: this.editorContainer,\n\t\t\tgetEditor: () => this.editor as Component,\n\t\t\tshowStatus: (message) => this.showStatus(message),\n\t\t\tshowWarning: (message) => this.showWarning(message),\n\t\t\tshowAskOptions: (questions, opts) => this.dialogs.showAskOptions(questions, opts),\n\t\t\tisAskOptionsOpen: () => this.dialogs.isAskOptionsOpen(),\n\t\t});\n\t\tthis.voice = new VoiceController({\n\t\t\tui: this.ui,\n\t\t\tstatusContainer: this.statusContainer,\n\t\t\tsendEditorInput: (data) => this.editor.handleInput(data),\n\t\t\tshowError: (message) => this.showError(message),\n\t\t\tsilenceMs: resolveVoiceSilenceMs(this.settingsManager),\n\t\t});\n\t\tconst self = this;\n\t\tthis.bashExecution = new BashExecutionController({\n\t\t\tget session() {\n\t\t\t\treturn self.session;\n\t\t\t},\n\t\t\tui: this.ui,\n\t\t\tpendingMessagesContainer: this.pendingMessagesContainer,\n\t\t\tchatContainer: this.chatContainer,\n\t\t\tshowError: (message) => this.showError(message),\n\t\t});\n\t\tthis.messageQueue = new MessageQueueController({\n\t\t\tget session() {\n\t\t\t\treturn self.session;\n\t\t\t},\n\t\t\tgetEditor: () => this.editor,\n\t\t\tpendingMessagesContainer: this.pendingMessagesContainer,\n\t\t\tshowStatus: (message) => this.showStatus(message),\n\t\t\tshowError: (message) => this.showError(message),\n\t\t});\n\t\tthis.modelController = new ModelController({\n\t\t\tui: this.ui,\n\t\t\tget session() {\n\t\t\t\treturn self.session;\n\t\t\t},\n\t\t\tshowSelector: (create) => this.showSelector(create),\n\t\t\tshowStatus: (message) => this.showStatus(message),\n\t\t\tshowError: (message) => this.showError(message),\n\t\t\tshowWarning: (message) => this.showWarning(message),\n\t\t\tshowNotice: (title, body) => this.showNotice(title, body),\n\t\t\tupdateEditorBorderColor: () => this.updateEditorBorderColor(),\n\t\t\tinvalidateFooter: () => this.footer.invalidate(),\n\t\t\tsetAvailableProviderCount: (count) => this.footerDataProvider.setAvailableProviderCount(count),\n\t\t});\n\t\tthis.loginController = new LoginController({\n\t\t\tui: this.ui,\n\t\t\tget session() {\n\t\t\t\treturn self.session;\n\t\t\t},\n\t\t\tgetEditor: () => this.editor as Component,\n\t\t\teditorContainer: this.editorContainer,\n\t\t\tshowSelector: (create) => this.showSelector(create),\n\t\t\tshowStatus: (message) => this.showStatus(message),\n\t\t\tshowError: (message) => this.showError(message),\n\t\t\tupdateAvailableProviderCount: () => this.modelController.updateAvailableProviderCount(),\n\t\t\tupdateEditorBorderColor: () => this.updateEditorBorderColor(),\n\t\t\tinvalidateFooter: () => this.footer.invalidate(),\n\t\t\tmaybeWarnAboutAnthropicSubscriptionAuth: (model) =>\n\t\t\t\tthis.modelController.maybeWarnAboutAnthropicSubscriptionAuth(model),\n\t\t});\n\t\tthis.taskPanel.onNudge = (role) => this.teamFocus.showNudgeInput(role);\n\t\tthis.taskPanel.onAttach = (role) => this.teamFocus.showAttach(role);\n\t\tthis.taskPanel.onExitFocus = () => this.teamFocus.exitFocus();\n\n\t\t// Load hide thinking block setting\n\t\tthis.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();\n\n\t\t// Load tool-output display level\n\t\tthis.toolOutputDisplay = this.settingsManager.getToolOutputDisplay();\n\n\t\t// Completion chime: rings the terminal bell when a long turn finishes or the\n\t\t// agent blocks awaiting input. Enable is read fresh so a live toggle applies.\n\t\tthis.chime = new CompletionChime({\n\t\t\tisEnabled: () => this.settingsManager.getChimeOnTurnComplete(),\n\t\t\tring: () => this.ui.terminal.write(BELL),\n\t\t});\n\n\t\t// Register themes from resource loader and initialize\n\t\tsetRegisteredThemes(this.session.resourceLoader.getThemes().themes);\n\t\tinitTheme(this.settingsManager.getTheme(), true);\n\t}\n\n\tprivate getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {\n\t\tif (!sourceInfo) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst scopePrefix = sourceInfo.scope === \"user\" ? \"u\" : sourceInfo.scope === \"project\" ? \"p\" : \"t\";\n\t\tconst source = sourceInfo.source.trim();\n\n\t\tif (source === \"auto\" || source === \"local\" || source === \"cli\") {\n\t\t\treturn scopePrefix;\n\t\t}\n\n\t\tif (source.startsWith(\"npm:\")) {\n\t\t\treturn `${scopePrefix}:${source}`;\n\t\t}\n\n\t\tconst gitSource = parseGitUrl(source);\n\t\tif (gitSource) {\n\t\t\tconst ref = gitSource.ref ? `@${gitSource.ref}` : \"\";\n\t\t\treturn `${scopePrefix}:git:${gitSource.host}/${gitSource.path}${ref}`;\n\t\t}\n\n\t\treturn scopePrefix;\n\t}\n\n\tprivate prefixAutocompleteDescription(description: string | undefined, sourceInfo?: SourceInfo): string | undefined {\n\t\tconst sourceTag = this.getAutocompleteSourceTag(sourceInfo);\n\t\tif (!sourceTag) {\n\t\t\treturn description;\n\t\t}\n\t\treturn description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`;\n\t}\n\n\tprivate getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner): ResourceDiagnostic[] {\n\t\tconst builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));\n\t\treturn extensionRunner\n\t\t\t.getRegisteredCommands()\n\t\t\t.filter((command) => builtinNames.has(command.name))\n\t\t\t.map((command) => ({\n\t\t\t\ttype: \"warning\" as const,\n\t\t\t\tmessage:\n\t\t\t\t\tcommand.invocationName === command.name\n\t\t\t\t\t\t? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`\n\t\t\t\t\t\t: `Extension command '/${command.name}' conflicts with built-in interactive command. Available as '/${command.invocationName}'.`,\n\t\t\t\tpath: command.sourceInfo.path,\n\t\t\t}));\n\t}\n\n\tprivate createBaseAutocompleteProvider(): AutocompleteProvider {\n\t\t// Define commands for autocomplete\n\t\tconst slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({\n\t\t\tname: command.name,\n\t\t\tdescription: command.description,\n\t\t}));\n\n\t\tconst modelCommand = slashCommands.find((command) => command.name === \"model\");\n\t\tif (modelCommand) {\n\t\t\tmodelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {\n\t\t\t\t// Get available models (scoped or from registry)\n\t\t\t\tconst models =\n\t\t\t\t\tthis.session.scopedModels.length > 0\n\t\t\t\t\t\t? this.session.scopedModels.map((s) => s.model)\n\t\t\t\t\t\t: this.session.modelRegistry.getAvailable();\n\n\t\t\t\tif (models.length === 0) return null;\n\n\t\t\t\t// Create items with provider/id format\n\t\t\t\tconst items = models.map((m) => ({\n\t\t\t\t\tid: m.id,\n\t\t\t\t\tprovider: m.provider,\n\t\t\t\t\tlabel: `${m.provider}/${m.id}`,\n\t\t\t\t}));\n\n\t\t\t\t// Fuzzy filter by model ID + provider (allows \"opus anthropic\" to match)\n\t\t\t\tconst filtered = fuzzyFilter(items, prefix, (item) => `${item.id} ${item.provider}`);\n\n\t\t\t\tif (filtered.length === 0) return null;\n\n\t\t\t\treturn filtered.map((item) => ({\n\t\t\t\t\tvalue: item.label,\n\t\t\t\t\tlabel: item.id,\n\t\t\t\t\tdescription: item.provider,\n\t\t\t\t}));\n\t\t\t};\n\t\t}\n\n\t\t// Convert prompt templates to SlashCommand format for autocomplete\n\t\tconst templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({\n\t\t\tname: cmd.name,\n\t\t\tdescription: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),\n\t\t\t...(cmd.argumentHint && { argumentHint: cmd.argumentHint }),\n\t\t}));\n\n\t\t// Convert extension commands to SlashCommand format\n\t\tconst builtinCommandNames = new Set(slashCommands.map((c) => c.name));\n\t\tconst extensionCommands: SlashCommand[] = this.session.extensionRunner\n\t\t\t.getRegisteredCommands()\n\t\t\t.filter((cmd) => !builtinCommandNames.has(cmd.name))\n\t\t\t.map((cmd) => ({\n\t\t\t\tname: cmd.invocationName,\n\t\t\t\tdescription: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),\n\t\t\t\tgetArgumentCompletions: cmd.getArgumentCompletions,\n\t\t\t}));\n\n\t\t// Build skill commands from session.skills (if enabled)\n\t\tthis.skillCommands.clear();\n\t\tconst skillCommandList: SlashCommand[] = [];\n\t\tif (this.settingsManager.getEnableSkillCommands()) {\n\t\t\tfor (const skill of this.session.resourceLoader.getSkills().skills) {\n\t\t\t\tconst commandName = `skill:${skill.name}`;\n\t\t\t\tthis.skillCommands.set(commandName, skill.filePath);\n\t\t\t\tskillCommandList.push({\n\t\t\t\t\tname: commandName,\n\t\t\t\t\tdescription: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\treturn new CombinedAutocompleteProvider(\n\t\t\t[...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],\n\t\t\tthis.sessionManager.getCwd(),\n\t\t\tthis.fdPath,\n\t\t);\n\t}\n\n\tprivate setupAutocompleteProvider(): void {\n\t\tlet provider = this.createBaseAutocompleteProvider();\n\t\tfor (const wrapProvider of this.autocompleteProviderWrappers) {\n\t\t\tprovider = wrapProvider(provider);\n\t\t}\n\n\t\tthis.autocompleteProvider = provider;\n\t\tthis.defaultEditor.setAutocompleteProvider(provider);\n\t\tif (this.editor !== this.defaultEditor) {\n\t\t\tthis.editor.setAutocompleteProvider?.(provider);\n\t\t}\n\t}\n\n\tprivate showStartupNoticesIfNeeded(): void {\n\t\tif (this.startupNoticesShown) {\n\t\t\treturn;\n\t\t}\n\t\tthis.startupNoticesShown = true;\n\n\t\tif (!this.changelogMarkdown || !this.changelogMarkdown.trim()) {\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.chatContainer.children.length > 0) {\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t}\n\t\tthis.chatContainer.addChild(new DynamicBorder());\n\t\tif (this.settingsManager.getCollapseChangelog()) {\n\t\t\tconst versionMatch = this.changelogMarkdown.match(/##\\s+\\[?(\\d+\\.\\d+\\.\\d+)\\]?/);\n\t\t\tconst latestVersion = versionMatch ? versionMatch[1] : this.version;\n\t\t\tconst condensedText = `Updated to v${latestVersion}. Use ${theme.bold(\"/changelog\")} to view full changelog.`;\n\t\t\tthis.chatContainer.addChild(new Text(condensedText, 1, 0));\n\t\t} else {\n\t\t\tthis.chatContainer.addChild(new Text(theme.bold(theme.fg(\"accent\", \"What's New\")), 1, 0));\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(\n\t\t\t\tnew Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()),\n\t\t\t);\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t}\n\t\tthis.chatContainer.addChild(new DynamicBorder());\n\t}\n\n\t/** Feed a first-run tool-binary download into the footer's startup-progress line. */\n\tprivate reportToolDownload(key: string, label: string, receivedBytes: number, totalBytes: number | null): void {\n\t\tstartupProgress.set({ key, kind: \"download\", label, receivedBytes, totalBytes });\n\t}\n\n\tasync init(): Promise<void> {\n\t\tif (this.isInitialized) return;\n\n\t\tthis.registerSignalHandlers();\n\n\t\t// Load changelog (only show new entries, skip for resumed sessions)\n\t\tthis.changelogMarkdown = getChangelogForDisplay({\n\t\t\thasMessages: this.session.state.messages.length > 0,\n\t\t\tsettingsManager: this.settingsManager,\n\t\t});\n\n\t\t// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir).\n\t\t// Both help — fd for autocomplete, rg for the grep tool — but neither is required\n\t\t// to start: resolving them can hit a slow or failing network in restricted\n\t\t// environments, and awaiting here froze first paint. Resolve in the background\n\t\t// (never awaited, so a first-run download never blocks first paint) and wire fd\n\t\t// in when ready; the grep/find tools resolve rg/fd on demand with a native\n\t\t// fallback regardless. embsearch is intentionally NOT preloaded here: when the\n\t\t// semantic index is enabled, EmbsearchService (main.ts) owns its download and\n\t\t// build, reporting through the same startupProgress channel — preloading it too\n\t\t// would fetch the binary twice and race.\n\t\t//\n\t\t// First-run download progress streams into the footer's startupProgress line\n\t\t// via ensureTool's byte-count callback; the line clears when the download\n\t\t// settles (path resolved) or is dropped if it fails.\n\t\tvoid Promise.all([\n\t\t\tensureTool(\"fd\", true, (received, total) => this.reportToolDownload(\"fd\", \"fd\", received, total)),\n\t\t\tensureTool(\"rg\", true, (received, total) => this.reportToolDownload(\"rg\", \"ripgrep\", received, total)),\n\t\t])\n\t\t\t.then(([fdPath]) => {\n\t\t\t\tthis.fdPath = fdPath;\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\t// Non-fatal: autocomplete simply stays disabled until/unless fd resolves.\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\t// Downloads settled (resolved or failed): drop any lingering bars.\n\t\t\t\tstartupProgress.remove(\"fd\");\n\t\t\t\tstartupProgress.remove(\"rg\");\n\t\t\t});\n\n\t\t// Add header container as first child\n\t\tthis.ui.addChild(this.headerContainer);\n\n\t\t// Add header with keybindings from config (unless silenced)\n\t\tif (this.options.verbose || !this.settingsManager.getQuietStartup()) {\n\t\t\tconst termW = this.ui.terminal.columns;\n\t\t\tconst logo =\n\t\t\t\ttermW >= 40\n\t\t\t\t\t? buildCompactWordmark({\n\t\t\t\t\t\t\tappName: APP_NAME,\n\t\t\t\t\t\t\tversion: this.version,\n\t\t\t\t\t\t\tcwd: formatDisplayPath(this.sessionManager.getCwd()),\n\t\t\t\t\t\t\taccent: (text) => theme.fg(\"accent\", text),\n\t\t\t\t\t\t\tdim: (text) => theme.fg(\"dim\", text),\n\t\t\t\t\t\t\tmuted: (text) => theme.fg(\"muted\", text),\n\t\t\t\t\t\t\tcursor: (text) => theme.blink(theme.fg(\"accent\", text)),\n\t\t\t\t\t\t\tnote: () => theme.fg(\"dim\", ` ${keyText(\"app.tools.expand\")} more`),\n\t\t\t\t\t\t})\n\t\t\t\t\t: theme.bold(theme.fg(\"accent\", APP_NAME)) + theme.fg(\"dim\", ` v${this.version}`);\n\n\t\t\t// Build startup instructions using keybinding hint helpers\n\t\t\tconst hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description);\n\n\t\t\tconst expandedInstructions = [\n\t\t\t\thint(\"app.interrupt\", \"to interrupt\"),\n\t\t\t\thint(\"app.clear\", \"to clear\"),\n\t\t\t\trawKeyHint(`${keyText(\"app.clear\")} twice`, \"to exit\"),\n\t\t\t\thint(\"app.exit\", \"to exit (empty)\"),\n\t\t\t\thint(\"app.suspend\", \"to suspend\"),\n\t\t\t\tkeyHint(\"tui.editor.deleteToLineEnd\", \"to delete to end\"),\n\t\t\t\thint(\"app.thinking.cycle\", \"to cycle thinking level\"),\n\t\t\t\trawKeyHint(`${keyText(\"app.model.cycleForward\")}/${keyText(\"app.model.cycleBackward\")}`, \"to cycle models\"),\n\t\t\t\thint(\"app.model.select\", \"to select model\"),\n\t\t\t\thint(\"app.tools.expand\", \"to expand tools\"),\n\t\t\t\thint(\"app.thinking.toggle\", \"to expand thinking\"),\n\t\t\t\thint(\"app.tasks.cycleView\", \"to cycle task panel view\"),\n\t\t\t\t...(this.teamFocus.connected ? [hint(\"app.team.focus\", \"to focus team roster\")] : []),\n\t\t\t\thint(\"app.editor.external\", \"for external editor\"),\n\t\t\t\trawKeyHint(\"/\", \"for commands\"),\n\t\t\t\trawKeyHint(\"!\", \"to run bash\"),\n\t\t\t\trawKeyHint(\"!!\", \"to run bash (no context)\"),\n\t\t\t\thint(\"app.message.followUp\", \"to queue follow-up\"),\n\t\t\t\thint(\"app.message.dequeue\", \"to edit all queued messages\"),\n\t\t\t\thint(\"app.clipboard.pasteImage\", \"to paste image\"),\n\t\t\t\trawKeyHint(\"drop files\", \"to attach\"),\n\t\t\t].join(\"\\n\");\n\t\t\tconst onboarding = theme.fg(\n\t\t\t\t\"dim\",\n\t\t\t\t`${APP_NAME} can explain its own features and look up its docs. Ask it how to use or extend ${APP_NAME}.`,\n\t\t\t);\n\t\t\tthis.builtInHeader = new ExpandableText(\n\t\t\t\t() => logo,\n\t\t\t\t() => `${logo}\\n${expandedInstructions}\\n\\n${onboarding}`,\n\t\t\t\tthis.getStartupExpansionState(),\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t);\n\n\t\t\t// Setup UI layout\n\t\t\tthis.headerContainer.addChild(this.builtInHeader);\n\t\t} else {\n\t\t\t// Minimal header when silenced\n\t\t\tthis.builtInHeader = new Text(\"\", 0, 0);\n\t\t\tthis.headerContainer.addChild(this.builtInHeader);\n\t\t}\n\n\t\tthis.ui.addChild(this.chatContainer);\n\t\tthis.ui.addChild(this.pendingMessagesContainer);\n\t\tthis.ui.addChild(this.statusContainer);\n\t\tthis.chrome.renderWidgets(); // Initialize with default spacer\n\t\tthis.ui.addChild(this.widgetContainerAbove);\n\t\tthis.ui.addChild(this.taskPanel);\n\t\tthis.ui.addChild(this.editorContainer);\n\t\tthis.ui.addChild(this.widgetContainerBelow);\n\t\tthis.ui.addChild(this.footer);\n\t\tthis.ui.setFocus(this.editor);\n\n\t\tthis.setupKeyHandlers();\n\t\tthis.setupEditorSubmitHandler();\n\n\t\t// Start the UI before initializing extensions so session_start handlers can use interactive dialogs\n\t\tthis.ui.start();\n\t\t// From here the TUI owns the terminal, so the agent's operational log lines\n\t\t// (dispatch, warm fallback, lifeguard) must stop writing to it — a write the\n\t\t// renderer did not make desyncs its cursor bookkeeping and duplicates rows.\n\t\tsetTerminalOwnedByTui(true);\n\t\tthis.isInitialized = true;\n\n\t\t// Initialize extensions first so resources are shown before messages\n\t\tawait this.rebindCurrentSession();\n\n\t\t// Render initial messages AFTER showing loaded resources\n\t\tthis.renderInitialMessages();\n\n\t\t// Set up theme file watcher\n\t\tonThemeChange(() => {\n\t\t\tthis.ui.invalidate();\n\t\t\tthis.updateEditorBorderColor();\n\t\t\tthis.ui.requestRender();\n\t\t});\n\n\t\t// Set up git branch watcher (uses provider instead of footer)\n\t\tthis.footerDataProvider.onBranchChange(() => {\n\t\t\tthis.ui.requestRender();\n\t\t});\n\n\t\t// Re-render the UI when the task list changes (task panel shows active\n\t\t// tasks). Throttled: subagent progress mutates the store in bursts, and\n\t\t// each resulting render reassembles the entire component tree.\n\t\tthis.taskStoreUnsubscribe = taskStore.subscribe(\n\t\t\tthrottled(TASK_RENDER_THROTTLE_MS, () => {\n\t\t\t\tthis.ui.requestRender();\n\t\t\t}),\n\t\t);\n\n\t\t// Re-render the footer as startup progress updates (tool downloads stream\n\t\t// byte counts in bursts, the index build ticks per batch). Throttled like the\n\t\t// task panel so a fast download doesn't drive a render per chunk.\n\t\tthis.startupProgressUnsubscribe = startupProgress.subscribe(\n\t\t\tthrottled(TASK_RENDER_THROTTLE_MS, () => {\n\t\t\t\tthis.ui.requestRender();\n\t\t\t}),\n\t\t);\n\n\t\t// Initialize available provider count for footer display\n\t\tawait this.modelController.updateAvailableProviderCount();\n\t}\n\n\t/**\n\t * Update terminal title with session name and cwd.\n\t */\n\tprivate updateTerminalTitle(): void {\n\t\tconst cwdBasename = path.basename(this.sessionManager.getCwd());\n\t\tconst sessionName = this.sessionManager.getSessionName();\n\t\tif (sessionName) {\n\t\t\tthis.ui.terminal.setTitle(`${APP_TITLE} - ${sessionName} - ${cwdBasename}`);\n\t\t} else {\n\t\t\tthis.ui.terminal.setTitle(`${APP_TITLE} - ${cwdBasename}`);\n\t\t}\n\t}\n\n\t/**\n\t * Run the interactive mode. This is the main entry point.\n\t * Initializes the UI, shows warnings, processes initial messages, and starts the interactive loop.\n\t */\n\tasync run(): Promise<void> {\n\t\tawait this.init();\n\n\t\t// Start version check asynchronously\n\t\tcheckForNewHooCodeVersion(this.version).then((newVersion) => {\n\t\t\tif (newVersion) {\n\t\t\t\tthis.showNewVersionNotification(newVersion);\n\t\t\t}\n\t\t});\n\n\t\t// Start package update check asynchronously\n\t\tcheckForPackageUpdates(this.sessionManager.getCwd(), this.settingsManager).then((updates) => {\n\t\t\tif (updates.length > 0) {\n\t\t\t\tthis.showPackageUpdateNotification(updates);\n\t\t\t}\n\t\t});\n\n\t\t// Check tmux keyboard setup asynchronously\n\t\tcheckTmuxKeyboardSetup().then((warning) => {\n\t\t\tif (warning) {\n\t\t\t\tthis.showWarning(warning);\n\t\t\t}\n\t\t});\n\n\t\t// Show startup warnings\n\t\tconst { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options;\n\n\t\tif (migratedProviders && migratedProviders.length > 0) {\n\t\t\tthis.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(\", \")}`);\n\t\t}\n\n\t\tconst modelsJsonError = this.session.modelRegistry.getError();\n\t\tif (modelsJsonError) {\n\t\t\tthis.showError(`models.json error: ${modelsJsonError}`);\n\t\t}\n\n\t\tif (modelFallbackMessage) {\n\t\t\tthis.showWarning(modelFallbackMessage);\n\t\t}\n\n\t\tvoid this.modelController.maybeWarnAboutAnthropicSubscriptionAuth();\n\n\t\t// Process initial messages\n\t\tif (initialMessage) {\n\t\t\ttry {\n\t\t\t\tawait this.session.prompt(initialMessage, { images: initialImages });\n\t\t\t} catch (error: unknown) {\n\t\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\t\tthis.showError(errorMessage);\n\t\t\t}\n\t\t}\n\n\t\tif (initialMessages) {\n\t\t\tfor (const message of initialMessages) {\n\t\t\t\ttry {\n\t\t\t\t\tawait this.session.prompt(message);\n\t\t\t\t} catch (error: unknown) {\n\t\t\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\t\t\tthis.showError(errorMessage);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Main interactive loop\n\t\twhile (true) {\n\t\t\tconst userInput = await this.getUserInput();\n\t\t\ttry {\n\t\t\t\tawait this.session.prompt(userInput);\n\t\t\t} catch (error: unknown) {\n\t\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\t\tthis.showError(errorMessage);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate getMarkdownThemeWithSettings(): MarkdownTheme {\n\t\treturn {\n\t\t\t...getMarkdownTheme(),\n\t\t\tcodeBlockIndent: this.settingsManager.getCodeBlockIndent(),\n\t\t};\n\t}\n\n\t// =========================================================================\n\t// Extension System\n\t// =========================================================================\n\n\tprivate getStartupExpansionState(): boolean {\n\t\treturn this.options.verbose || this.toolOutputExpanded;\n\t}\n\n\t/** Render the startup/reload resource listing (see resource-display.ts). */\n\tprivate showLoadedResources(options?: {\n\t\textensions?: Array<{ path: string; sourceInfo?: SourceInfo }>;\n\t\tforce?: boolean;\n\t\tshowDiagnosticsWhenQuiet?: boolean;\n\t}): void {\n\t\trenderLoadedResources(\n\t\t\t{\n\t\t\t\tchatContainer: this.chatContainer,\n\t\t\t\tgetCwd: () => this.sessionManager.getCwd(),\n\t\t\t\tgetResourceLoader: () => this.session.resourceLoader,\n\t\t\t\tgetPromptTemplates: () => this.session.promptTemplates,\n\t\t\t\tgetExtensionRunner: () => this.session.extensionRunner,\n\t\t\t\tgetActiveMode: () => this.footerDataProvider.getActiveMode(),\n\t\t\t\tgetSubagentEnabled: () => this.footerDataProvider.getSubagentEnabled(),\n\t\t\t\tgetAgentCount: () => this.getDispatchableAgentCount(),\n\t\t\t\tgetAgents: () => {\n\t\t\t\t\tif (!this.footerDataProvider.getSubagentEnabled()) return [];\n\t\t\t\t\ttry {\n\t\t\t\t\t\treturn loadAgentRegistry({ cwd: this.sessionManager.getCwd() }).list();\n\t\t\t\t\t} catch {\n\t\t\t\t\t\treturn [];\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tgetColumns: () => this.ui.terminal.columns,\n\t\t\t\tquietStartup: () => this.settingsManager.getQuietStartup(),\n\t\t\t\tverbose: this.options.verbose ?? false,\n\t\t\t\tisExpanded: () => this.getStartupExpansionState(),\n\t\t\t\tgetBuiltInCommandConflictDiagnostics: (runner) => this.getBuiltInCommandConflictDiagnostics(runner),\n\t\t\t},\n\t\t\toptions,\n\t\t);\n\t}\n\n\t/**\n\t * Agents the model can actually dispatch. Zero when the Task tool is off, so\n\t * the summary never advertises a capability the session cannot use.\n\t */\n\tprivate getDispatchableAgentCount(): number {\n\t\tif (!this.footerDataProvider.getSubagentEnabled()) return 0;\n\t\ttry {\n\t\t\treturn loadAgentRegistry({ cwd: this.sessionManager.getCwd() }).list().length;\n\t\t} catch {\n\t\t\treturn 0;\n\t\t}\n\t}\n\n\t/**\n\t * Initialize the extension system with TUI-based UI context.\n\t */\n\tprivate async bindCurrentSessionExtensions(): Promise<void> {\n\t\tconst uiContext = this.createExtensionUIContext();\n\t\tawait this.session.bindExtensions({\n\t\t\tuiContext,\n\t\t\tcommandContextActions: {\n\t\t\t\twaitForIdle: () => this.session.agent.waitForIdle(),\n\t\t\t\tnewSession: async (options) => {\n\t\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t\t\tthis.loadingAnimation = undefined;\n\t\t\t\t\t}\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst result = await this.runtimeHost.newSession(options);\n\t\t\t\t\t\tif (!result.cancelled) {\n\t\t\t\t\t\t\tthis.renderCurrentSessionState();\n\t\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn result;\n\t\t\t\t\t} catch (error: unknown) {\n\t\t\t\t\t\treturn this.handleFatalRuntimeError(\"Failed to create session\", error);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tfork: async (entryId, options) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst result = await this.runtimeHost.fork(entryId, options);\n\t\t\t\t\t\tif (!result.cancelled) {\n\t\t\t\t\t\t\tthis.renderCurrentSessionState();\n\t\t\t\t\t\t\tthis.editor.setText(result.selectedText ?? \"\");\n\t\t\t\t\t\t\tthis.showStatus(\"Forked to new session\");\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn { cancelled: result.cancelled };\n\t\t\t\t\t} catch (error: unknown) {\n\t\t\t\t\t\treturn this.handleFatalRuntimeError(\"Failed to fork session\", error);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tnavigateTree: async (targetId, options) => {\n\t\t\t\t\tconst result = await this.session.navigateTree(targetId, {\n\t\t\t\t\t\tsummarize: options?.summarize,\n\t\t\t\t\t\tcustomInstructions: options?.customInstructions,\n\t\t\t\t\t\treplaceInstructions: options?.replaceInstructions,\n\t\t\t\t\t\tlabel: options?.label,\n\t\t\t\t\t});\n\t\t\t\t\tif (result.cancelled) {\n\t\t\t\t\t\treturn { cancelled: true };\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.chatContainer.clear();\n\t\t\t\t\tthis.renderInitialMessages();\n\t\t\t\t\tif (result.editorText && !this.editor.getText().trim()) {\n\t\t\t\t\t\tthis.editor.setText(result.editorText);\n\t\t\t\t\t}\n\t\t\t\t\tthis.showStatus(\"Navigated to selected point\");\n\t\t\t\t\tvoid this.messageQueue.flushCompactionQueue({ willRetry: false });\n\t\t\t\t\treturn { cancelled: false };\n\t\t\t\t},\n\t\t\t\tswitchSession: async (sessionPath, options) => {\n\t\t\t\t\treturn this.handleResumeSession(sessionPath, options);\n\t\t\t\t},\n\t\t\t\treload: async () => {\n\t\t\t\t\tawait this.handleReloadCommand();\n\t\t\t\t},\n\t\t\t},\n\t\t\tshutdownHandler: () => {\n\t\t\t\tthis.shutdownRequested = true;\n\t\t\t\tif (!this.session.isStreaming) {\n\t\t\t\t\tvoid this.shutdown();\n\t\t\t\t}\n\t\t\t},\n\t\t\tonError: (error) => {\n\t\t\t\tthis.showExtensionError(error.extensionPath, error.error, error.stack);\n\t\t\t},\n\t\t});\n\n\t\tsetRegisteredThemes(this.session.resourceLoader.getThemes().themes);\n\t\tthis.setupAutocompleteProvider();\n\n\t\tconst extensionRunner = this.session.extensionRunner;\n\t\tthis.setupExtensionShortcuts(extensionRunner);\n\t\tthis.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });\n\t\tthis.showStartupNoticesIfNeeded();\n\t}\n\n\tprivate applyRuntimeSettings(): void {\n\t\tthis.footer.setSession(this.session);\n\t\tthis.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);\n\t\tthis.footerDataProvider.setCwd(this.sessionManager.getCwd());\n\t\tthis.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();\n\t\tthis.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());\n\t\tthis.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());\n\t\tconst editorBorder = this.settingsManager.getEditorBorder();\n\t\tconst editorPaddingX = this.settingsManager.getEditorPaddingX();\n\t\tconst autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();\n\t\tthis.defaultEditor.setBorder(editorBorder);\n\t\tthis.defaultEditor.setPaddingX(editorPaddingX);\n\t\tthis.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);\n\t\tif (this.editor !== this.defaultEditor) {\n\t\t\tthis.editor.setBorder?.(editorBorder);\n\t\t\tthis.editor.setPaddingX?.(editorPaddingX);\n\t\t\tthis.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);\n\t\t}\n\t}\n\n\tprivate async rebindCurrentSession(): Promise<void> {\n\t\tthis.unsubscribe?.();\n\t\tthis.unsubscribe = undefined;\n\t\tthis.applyRuntimeSettings();\n\t\tawait this.bindCurrentSessionExtensions();\n\t\tthis.subscribeToAgent();\n\t\tawait this.modelController.updateAvailableProviderCount();\n\t\tthis.updateEditorBorderColor();\n\t\tthis.updateTerminalTitle();\n\t}\n\n\tprivate async handleFatalRuntimeError(prefix: string, error: unknown): Promise<never> {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tthis.showError(`${prefix}: ${message}`);\n\t\tstopThemeWatcher();\n\t\tthis.stop();\n\t\tprocess.exit(1);\n\t}\n\n\tprivate renderCurrentSessionState(): void {\n\t\tthis.chatContainer.clear();\n\t\tthis.pendingMessagesContainer.clear();\n\t\tthis.messageQueue.resetCompactionQueue();\n\t\tthis.streamingComponent = undefined;\n\t\tthis.streamingMessage = undefined;\n\t\tthis.pendingTools.clear();\n\t\tthis.renderInitialMessages();\n\t}\n\n\t/**\n\t * Bound the view layer's memory: freeze finished tool blocks once more than\n\t * LIVE_TOOL_WINDOW of them are live, releasing their retained tool output and\n\t * base64 image copies. Runs on tool completion (infrequent) and only ever\n\t * freezes blocks far above the viewport. The session data stays intact, so a\n\t * later full rebuild (theme toggle / reload) restores full fidelity.\n\t */\n\tprivate trimTranscriptMemory(): void {\n\t\tconst freezable: ToolExecutionComponent[] = [];\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof ToolExecutionComponent && child.isFreezable()) {\n\t\t\t\tfreezable.push(child);\n\t\t\t}\n\t\t}\n\t\tconst excess = freezable.length - LIVE_TOOL_WINDOW;\n\t\tfor (let i = 0; i < excess; i++) {\n\t\t\tfreezable[i].freeze();\n\t\t}\n\t}\n\n\t/**\n\t * Get a registered tool definition by name (for custom rendering).\n\t */\n\tprivate getRegisteredToolDefinition(toolName: string) {\n\t\treturn this.session.getToolDefinition(toolName);\n\t}\n\n\t/**\n\t * Set up keyboard shortcuts registered by extensions.\n\t */\n\tprivate setupExtensionShortcuts(extensionRunner: ExtensionRunner): void {\n\t\tconst shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig());\n\t\tif (shortcuts.size === 0) return;\n\n\t\t// Create a context for shortcut handlers\n\t\tconst createContext = (): ExtensionContext => ({\n\t\t\tui: this.createExtensionUIContext(),\n\t\t\thasUI: true,\n\t\t\tcwd: this.sessionManager.getCwd(),\n\t\t\tsessionManager: this.sessionManager,\n\t\t\tmodelRegistry: this.session.modelRegistry,\n\t\t\tmodel: this.session.model,\n\t\t\tisIdle: () => !this.session.isStreaming,\n\t\t\tsignal: this.session.agent.signal,\n\t\t\tabort: () => this.session.abort(),\n\t\t\thasPendingMessages: () => this.session.pendingMessageCount > 0,\n\t\t\tshutdown: () => {\n\t\t\t\tthis.shutdownRequested = true;\n\t\t\t},\n\t\t\tgetContextUsage: () => this.session.getContextUsage(),\n\t\t\tcompact: (options) => {\n\t\t\t\tvoid (async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst result = await this.session.compact(options?.customInstructions);\n\t\t\t\t\t\toptions?.onComplete?.(result);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tconst err = error instanceof Error ? error : new Error(String(error));\n\t\t\t\t\t\toptions?.onError?.(err);\n\t\t\t\t\t}\n\t\t\t\t})();\n\t\t\t},\n\t\t\tgetSystemPrompt: () => this.session.systemPrompt,\n\t\t\tactivatePlugin: (pluginDir) => this.session.activatePlugin(pluginDir),\n\t\t\trequestReloadWhenIdle: () => this.session.requestReloadWhenIdle(),\n\t\t});\n\n\t\t// Set up the extension shortcut handler on the default editor\n\t\tthis.defaultEditor.onExtensionShortcut = (data: string) => {\n\t\t\tfor (const [shortcutStr, shortcut] of shortcuts) {\n\t\t\t\t// Cast to KeyId - extension shortcuts use the same format\n\t\t\t\tif (matchesKey(data, shortcutStr as KeyId)) {\n\t\t\t\t\t// Run handler async, don't block input\n\t\t\t\t\tPromise.resolve(shortcut.handler(createContext())).catch((err) => {\n\t\t\t\t\t\tthis.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\t\t});\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false;\n\t\t};\n\t}\n\n\t/**\n\t * Set extension status text in the footer.\n\t */\n\tprivate setExtensionStatus(key: string, text: string | undefined): void {\n\t\tthis.footerDataProvider.setExtensionStatus(key, text);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate getWorkingLoaderMessage(): string {\n\t\treturn this.workingMessage ?? this.defaultWorkingMessage;\n\t}\n\n\tprivate createWorkingLoader(): Loader {\n\t\treturn new Loader(\n\t\t\tthis.ui,\n\t\t\t(spinner) => theme.fg(\"accent\", spinner),\n\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\tthis.getWorkingLoaderMessage(),\n\t\t\tthis.workingIndicatorOptions,\n\t\t);\n\t}\n\n\tprivate stopWorkingLoader(): void {\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t}\n\t\tthis.statusContainer.clear();\n\t}\n\n\tprivate setWorkingVisible(visible: boolean): void {\n\t\tthis.workingVisible = visible;\n\t\tif (!visible) {\n\t\t\tthis.stopWorkingLoader();\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\t\tif (this.session.isStreaming && !this.loadingAnimation) {\n\t\t\tthis.statusContainer.clear();\n\t\t\tthis.loadingAnimation = this.createWorkingLoader();\n\t\t\tthis.statusContainer.addChild(this.loadingAnimation);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate setWorkingIndicator(options?: LoaderIndicatorOptions): void {\n\t\tthis.workingIndicatorOptions = options;\n\t\tthis.loadingAnimation?.setIndicator(options);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate setHiddenThinkingLabel(label?: string): void {\n\t\tthis.hiddenThinkingLabel = label ?? this.defaultHiddenThinkingLabel;\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof AssistantMessageComponent) {\n\t\t\t\tchild.setHiddenThinkingLabel(this.hiddenThinkingLabel);\n\t\t\t}\n\t\t}\n\t\tif (this.streamingComponent) {\n\t\t\tthis.streamingComponent.setHiddenThinkingLabel(this.hiddenThinkingLabel);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate resetExtensionUI(): void {\n\t\tthis.dialogs.reset();\n\t\tthis.ui.hideOverlay();\n\t\tthis.clearExtensionTerminalInputListeners();\n\t\tthis.chrome.reset();\n\t\tthis.footerDataProvider.clearExtensionStatuses();\n\t\tthis.footer.invalidate();\n\t\tthis.autocompleteProviderWrappers = [];\n\t\tthis.setCustomEditorComponent(undefined);\n\t\tthis.setupAutocompleteProvider();\n\t\tthis.defaultEditor.onExtensionShortcut = undefined;\n\t\tthis.updateTerminalTitle();\n\t\tthis.workingMessage = undefined;\n\t\tthis.workingVisible = true;\n\t\tthis.setWorkingIndicator();\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText(\"app.interrupt\")} to interrupt)`);\n\t\t}\n\t\tthis.setHiddenThinkingLabel();\n\t}\n\n\tprivate addExtensionTerminalInputListener(\n\t\thandler: (data: string) => { consume?: boolean; data?: string } | undefined,\n\t): () => void {\n\t\tconst unsubscribe = this.ui.addInputListener(handler);\n\t\tthis.extensionTerminalInputUnsubscribers.add(unsubscribe);\n\t\treturn () => {\n\t\t\tunsubscribe();\n\t\t\tthis.extensionTerminalInputUnsubscribers.delete(unsubscribe);\n\t\t};\n\t}\n\n\tprivate clearExtensionTerminalInputListeners(): void {\n\t\tfor (const unsubscribe of this.extensionTerminalInputUnsubscribers) {\n\t\t\tunsubscribe();\n\t\t}\n\t\tthis.extensionTerminalInputUnsubscribers.clear();\n\t}\n\n\t/**\n\t * Create the ExtensionUIContext for extensions.\n\t */\n\tprivate createExtensionUIContext(): ExtensionUIContext {\n\t\t// Captured for the `columns` getter: `this` inside a getter on the object\n\t\t// literal below would be the literal, not the mode.\n\t\tconst tui = this.ui;\n\t\treturn {\n\t\t\tselect: (title, options, opts) => this.dialogs.showSelector(title, options, opts),\n\t\t\tconfirm: (title, message, opts) => this.dialogs.confirm(title, message, opts),\n\t\t\tinput: (title, placeholder, opts) => this.dialogs.showInput(title, placeholder, opts),\n\t\t\taskOptions: (questions, opts) => {\n\t\t\t\t// The agent is blocking on the user (the ask_options pane): ring now,\n\t\t\t\t// bypassing the duration threshold — this is the \"it needs you\" cue.\n\t\t\t\tthis.chime?.onBlockedForInput();\n\t\t\t\treturn this.dialogs.showAskOptions(questions, opts);\n\t\t\t},\n\t\t\tnotify: (message, type) => this.showExtensionNotify(message, type),\n\t\t\tget columns() {\n\t\t\t\t// Live, not captured: the listing must reflow after a resize.\n\t\t\t\treturn tui.terminal.columns;\n\t\t\t},\n\t\t\tonTerminalInput: (handler) => this.addExtensionTerminalInputListener(handler),\n\t\t\tsetStatus: (key, text) => this.setExtensionStatus(key, text),\n\t\t\tsetMode: (mode) => {\n\t\t\t\tthis.footerDataProvider.setActiveMode(mode);\n\t\t\t\tthis.footer.invalidate();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\tsetWorkingMessage: (message) => {\n\t\t\t\tthis.workingMessage = message;\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.setMessage(message ?? this.defaultWorkingMessage);\n\t\t\t\t}\n\t\t\t},\n\t\t\tsetWorkingVisible: (visible) => this.setWorkingVisible(visible),\n\t\t\tsetWorkingIndicator: (options) => this.setWorkingIndicator(options),\n\t\t\tsetHiddenThinkingLabel: (label) => this.setHiddenThinkingLabel(label),\n\t\t\tsetWidget: (key, content, options) => this.chrome.setWidget(key, content, options),\n\t\t\tsetFooter: (factory) => this.chrome.setFooter(factory),\n\t\t\tsetHeader: (factory) => this.chrome.setHeader(factory),\n\t\t\tsetTitle: (title) => this.ui.terminal.setTitle(title),\n\t\t\tcustom: (factory, options) => this.dialogs.showCustom(factory, options),\n\t\t\tpasteToEditor: (text) => this.editor.handleInput(`\\x1b[200~${text}\\x1b[201~`),\n\t\t\tsetEditorText: (text) => this.editor.setText(text),\n\t\t\tgetEditorText: () => this.editor.getExpandedText?.() ?? this.editor.getText(),\n\t\t\teditor: (title, prefill) => this.dialogs.showEditor(title, prefill),\n\t\t\taddAutocompleteProvider: (factory) => {\n\t\t\t\tthis.autocompleteProviderWrappers.push(factory);\n\t\t\t\tthis.setupAutocompleteProvider();\n\t\t\t},\n\t\t\tsetEditorComponent: (factory) => this.setCustomEditorComponent(factory),\n\t\t\tgetEditorComponent: () => this.editorComponentFactory,\n\t\t\tget theme() {\n\t\t\t\treturn theme;\n\t\t\t},\n\t\t\tgetAllThemes: () => getAvailableThemesWithPaths(),\n\t\t\tgetTheme: (name) => getThemeByName(name),\n\t\t\tsetTheme: (themeOrName) => {\n\t\t\t\tif (themeOrName instanceof Theme) {\n\t\t\t\t\tsetThemeInstance(themeOrName);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\treturn { success: true };\n\t\t\t\t}\n\t\t\t\tconst result = setTheme(themeOrName, true);\n\t\t\t\tif (result.success) {\n\t\t\t\t\tif (this.settingsManager.getTheme() !== themeOrName) {\n\t\t\t\t\t\tthis.settingsManager.setTheme(themeOrName);\n\t\t\t\t\t}\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t},\n\t\t\tgetToolsExpanded: () => this.toolOutputExpanded,\n\t\t\tsetToolsExpanded: (expanded) => this.setToolsExpanded(expanded),\n\t\t};\n\t}\n\n\tprivate async promptForMissingSessionCwd(error: MissingSessionCwdError): Promise<string | undefined> {\n\t\tconst confirmed = await this.dialogs.confirm(\"Session cwd not found\", formatMissingSessionCwdPrompt(error.issue));\n\t\treturn confirmed ? error.issue.fallbackCwd : undefined;\n\t}\n\n\t/**\n\t * Set a custom editor component from an extension.\n\t * Pass undefined to restore the default editor.\n\t */\n\tprivate setCustomEditorComponent(factory: EditorFactory | undefined): void {\n\t\tthis.editorComponentFactory = factory;\n\n\t\t// Save text from current editor before switching\n\t\tconst currentText = this.editor.getText();\n\n\t\tthis.editorContainer.clear();\n\n\t\tif (factory) {\n\t\t\t// Create the custom editor with tui, theme, and keybindings\n\t\t\tconst newEditor = factory(this.ui, getEditorTheme(), this.keybindings);\n\n\t\t\t// Wire up callbacks from the default editor\n\t\t\tnewEditor.onSubmit = this.defaultEditor.onSubmit;\n\t\t\tnewEditor.onChange = this.defaultEditor.onChange;\n\n\t\t\t// Copy text from previous editor\n\t\t\tnewEditor.setText(currentText);\n\n\t\t\t// Copy appearance settings if supported\n\t\t\tif (newEditor.borderColor !== undefined) {\n\t\t\t\tnewEditor.borderColor = this.defaultEditor.borderColor;\n\t\t\t}\n\t\t\tif (newEditor.setPaddingX !== undefined) {\n\t\t\t\tnewEditor.setPaddingX(this.defaultEditor.getPaddingX());\n\t\t\t}\n\n\t\t\t// Set autocomplete if supported\n\t\t\tif (newEditor.setAutocompleteProvider && this.autocompleteProvider) {\n\t\t\t\tnewEditor.setAutocompleteProvider(this.autocompleteProvider);\n\t\t\t}\n\n\t\t\t// If extending CustomEditor, copy app-level handlers\n\t\t\t// Use duck typing since instanceof fails across jiti module boundaries\n\t\t\tconst customEditor = newEditor as unknown as Record<string, unknown>;\n\t\t\tif (\"actionHandlers\" in customEditor && customEditor.actionHandlers instanceof Map) {\n\t\t\t\tif (!customEditor.onEscape) {\n\t\t\t\t\tcustomEditor.onEscape = () => this.defaultEditor.onEscape?.();\n\t\t\t\t}\n\t\t\t\tif (!customEditor.onCtrlD) {\n\t\t\t\t\tcustomEditor.onCtrlD = () => this.defaultEditor.onCtrlD?.();\n\t\t\t\t}\n\t\t\t\tif (!customEditor.onPasteImage) {\n\t\t\t\t\tcustomEditor.onPasteImage = () => this.defaultEditor.onPasteImage?.();\n\t\t\t\t}\n\t\t\t\tif (!customEditor.onExtensionShortcut) {\n\t\t\t\t\tcustomEditor.onExtensionShortcut = (data: string) => this.defaultEditor.onExtensionShortcut?.(data);\n\t\t\t\t}\n\t\t\t\t// Copy action handlers (clear, suspend, model switching, etc.)\n\t\t\t\tfor (const [action, handler] of this.defaultEditor.actionHandlers) {\n\t\t\t\t\t(customEditor.actionHandlers as Map<string, () => void>).set(action, handler);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthis.editor = newEditor;\n\t\t} else {\n\t\t\t// Restore default editor with text from custom editor\n\t\t\tthis.defaultEditor.setText(currentText);\n\t\t\tthis.editor = this.defaultEditor;\n\t\t}\n\n\t\tthis.editorContainer.addChild(this.editor as Component);\n\t\tthis.ui.setFocus(this.editor as Component);\n\t\tthis.ui.requestRender();\n\t}\n\n\t/**\n\t * Show a notification for extensions.\n\t */\n\tprivate showExtensionNotify(message: string, type?: \"info\" | \"warning\" | \"error\"): void {\n\t\tif (type === \"error\") {\n\t\t\tthis.showError(message);\n\t\t} else if (type === \"warning\") {\n\t\t\tthis.showWarning(message);\n\t\t} else {\n\t\t\tthis.showStatus(message);\n\t\t}\n\t}\n\n\t/**\n\t * Show an extension error in the UI.\n\t */\n\tprivate showExtensionError(extensionPath: string, error: string, stack?: string): void {\n\t\tconst errorMsg = `Extension \"${extensionPath}\" error: ${error}`;\n\t\tconst errorText = new Text(theme.fg(\"error\", errorMsg), 1, 0);\n\t\tthis.chatContainer.addChild(errorText);\n\t\tif (stack) {\n\t\t\t// Show stack trace in dim color, indented\n\t\t\tconst stackLines = stack\n\t\t\t\t.split(\"\\n\")\n\t\t\t\t.slice(1) // Skip first line (duplicates error message)\n\t\t\t\t.map((line) => theme.fg(\"dim\", ` ${line.trim()}`))\n\t\t\t\t.join(\"\\n\");\n\t\t\tif (stackLines) {\n\t\t\t\tthis.chatContainer.addChild(new Text(stackLines, 1, 0));\n\t\t\t}\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\t// =========================================================================\n\t// Key Handlers\n\t// =========================================================================\n\n\tprivate setupKeyHandlers(): void {\n\t\t// Set up handlers on defaultEditor - they use this.editor for text access\n\t\t// so they work correctly regardless of which editor is active\n\t\tthis.defaultEditor.onEscape = () => {\n\t\t\tif (this.session.isStreaming) {\n\t\t\t\tthis.messageQueue.restoreQueuedMessagesToEditor({ abort: true });\n\t\t\t} else if (this.session.isBashRunning) {\n\t\t\t\tthis.session.abortBash();\n\t\t\t} else if (this.isBashMode) {\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.isBashMode = false;\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t} else if (!this.editor.getText().trim()) {\n\t\t\t\t// Double-escape with empty editor triggers /tree, /fork, or nothing based on setting\n\t\t\t\tconst action = this.settingsManager.getDoubleEscapeAction();\n\t\t\t\tif (action !== \"none\") {\n\t\t\t\t\tconst now = Date.now();\n\t\t\t\t\tif (now - this.lastEscapeTime < 500) {\n\t\t\t\t\t\tif (action === \"tree\") {\n\t\t\t\t\t\t\tthis.showTreeSelector();\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.lastEscapeTime = 0;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.lastEscapeTime = now;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\t// Register app action handlers\n\t\tthis.defaultEditor.onAction(\"app.clear\", () => this.handleCtrlC());\n\t\tthis.defaultEditor.onCtrlD = () => this.handleCtrlD();\n\t\tthis.defaultEditor.onAction(\"app.suspend\", () => this.handleCtrlZ());\n\t\tthis.defaultEditor.onAction(\"app.thinking.cycle\", () => this.cycleThinkingLevel());\n\t\tthis.defaultEditor.onAction(\"app.model.cycleForward\", () => this.modelController.cycleModel(\"forward\"));\n\t\tthis.defaultEditor.onAction(\"app.model.cycleBackward\", () => this.modelController.cycleModel(\"backward\"));\n\n\t\t// Global debug handler on TUI (works regardless of focus)\n\t\tthis.ui.onDebug = () => this.commandExecutor.handleDebug();\n\t\tthis.defaultEditor.onAction(\"app.model.select\", () => this.modelController.showModelSelector());\n\t\tthis.defaultEditor.onAction(\"app.tools.expand\", () => this.toggleToolOutputExpansion());\n\t\tthis.defaultEditor.onAction(\"app.thinking.toggle\", () => this.toggleThinkingBlockVisibility());\n\t\tthis.defaultEditor.onAction(\"app.tasks.cycleView\", () => {\n\t\t\tthis.taskPanel.cycleView();\n\t\t});\n\t\tthis.defaultEditor.onAction(\"app.team.focus\", () => this.teamFocus.enterFocus());\n\t\tthis.defaultEditor.onAction(\"app.editor.external\", () => this.openExternalEditor());\n\t\tthis.defaultEditor.onAction(\"app.input.voiceTranscribe\", () => this.voice.toggle());\n\t\tthis.defaultEditor.onAction(\"app.message.followUp\", () => this.handleFollowUp());\n\t\tthis.defaultEditor.onAction(\"app.message.dequeue\", () => this.handleDequeue());\n\t\tthis.defaultEditor.onAction(\"app.session.new\", () => this.commandExecutor.handleClear());\n\t\tthis.defaultEditor.onAction(\"app.session.tree\", () => this.showTreeSelector());\n\t\tthis.defaultEditor.onAction(\"app.session.fork\", () => this.showUserMessageSelector());\n\t\tthis.defaultEditor.onAction(\"app.session.resume\", () => this.showSessionSelector());\n\n\t\tthis.defaultEditor.onChange = (text: string) => {\n\t\t\tconst wasBashMode = this.isBashMode;\n\t\t\tthis.isBashMode = text.trimStart().startsWith(\"!\");\n\t\t\tif (wasBashMode !== this.isBashMode) {\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\tthis.updateEditorPromptPrefix();\n\t\t\t}\n\t\t};\n\n\t\t// Handle clipboard image paste (triggered on Ctrl+V)\n\t\tthis.defaultEditor.onPasteImage = () => {\n\t\t\tthis.handleClipboardImagePaste();\n\t\t};\n\t}\n\n\tprivate async handleClipboardImagePaste(): Promise<void> {\n\t\ttry {\n\t\t\tconst image = await readClipboardImage();\n\t\t\tif (!image) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Write to temp file\n\t\t\tconst tmpDir = os.tmpdir();\n\t\t\tconst ext = extensionForImageMimeType(image.mimeType) ?? \"png\";\n\t\t\tconst fileName = `${APP_NAME}-clipboard-${crypto.randomUUID()}.${ext}`;\n\t\t\tconst filePath = path.join(tmpDir, fileName);\n\t\t\tfs.writeFileSync(filePath, Buffer.from(image.bytes));\n\n\t\t\t// Insert file path directly\n\t\t\tthis.editor.insertTextAtCursor?.(filePath);\n\t\t\tthis.ui.requestRender();\n\t\t} catch {\n\t\t\t// Silently ignore clipboard errors (may not have permission, etc.)\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// hooteams team focus (--team): focus role rows, nudge, attach\n\t// =========================================================================\n\n\t/**\n\t * Wire a hooteams connection into the TUI. Called by main.ts when `--team`\n\t * is set, before run(). See TeamFocusController for the feature itself.\n\t */\n\tattachTeamClient(client: TeamViewConnection): void {\n\t\tthis.teamFocus.attachClient(client);\n\t}\n\n\t/**\n\t * Built-in slash commands dispatched by the editor submit handler.\n\t * `withArgs` commands also match \"/name <args>\" and receive the full text.\n\t * Each handler keeps its own editor-clearing order (before vs after the\n\t * await) — some commands must show their UI before the prompt is wiped.\n\t */\n\tprivate createBuiltInSlashCommands(): Map<string, { withArgs?: boolean; run(text: string): Promise<void> | void }> {\n\t\tconst clearEditor = () => this.editor.setText(\"\");\n\t\treturn new Map(\n\t\t\tObject.entries({\n\t\t\t\t\"/settings\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.showSettingsSelector();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/scoped-models\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.modelController.showModelsSelector();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/model\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: async (text: string) => {\n\t\t\t\t\t\tconst searchTerm = text.startsWith(\"/model \") ? text.slice(7).trim() : undefined;\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.commandExecutor.handleModel(searchTerm);\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/export\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: async (text: string) => {\n\t\t\t\t\t\tawait this.commandExecutor.handleExport(text);\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/import\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: async (text: string) => {\n\t\t\t\t\t\tawait this.commandExecutor.handleImport(text);\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/share\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tawait this.commandExecutor.handleShare();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/copy\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tawait this.commandExecutor.handleCopy();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/name\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: (text: string) => {\n\t\t\t\t\t\tthis.commandExecutor.handleName(text);\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/session\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.commandExecutor.handleSession();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/changelog\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.commandExecutor.handleChangelog();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/hotkeys\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.commandExecutor.handleHotkeys();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/fork\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/clone\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.commandExecutor.handleClone();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/tree\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.showTreeSelector();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/login\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.loginController.showOAuthSelector(\"login\");\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/logout\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.loginController.showOAuthSelector(\"logout\");\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/new\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.commandExecutor.handleClear();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/compact\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: async (text: string) => {\n\t\t\t\t\t\tconst prefix = \"/compact \";\n\t\t\t\t\t\tconst customInstructions = text.startsWith(prefix)\n\t\t\t\t\t\t\t? text.slice(prefix.length).trim() || undefined\n\t\t\t\t\t\t\t: undefined;\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.handleCompactCommand(customInstructions);\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/reload\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.handleReloadCommand();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/debug\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.commandExecutor.handleDebug();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/resume\": {\n\t\t\t\t\trun: () => {\n\t\t\t\t\t\tthis.showSessionSelector();\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/quit\": {\n\t\t\t\t\trun: async () => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.shutdown();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/subagent\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: async (text: string) => {\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t\tawait this.commandExecutor.handleSubagent(text);\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}),\n\t\t);\n\t}\n\n\tprivate setupEditorSubmitHandler(): void {\n\t\tconst slashCommands = this.createBuiltInSlashCommands();\n\t\tthis.defaultEditor.onSubmit = async (text: string) => {\n\t\t\ttext = text.trim();\n\t\t\tif (!text) return;\n\n\t\t\t// Handle built-in slash commands\n\t\t\tconst spaceIdx = text.indexOf(\" \");\n\t\t\tconst commandName = spaceIdx === -1 ? text : text.slice(0, spaceIdx);\n\t\t\tconst command = slashCommands.get(commandName);\n\t\t\tif (command && (spaceIdx === -1 || command.withArgs)) {\n\t\t\t\tawait command.run(text);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Handle bash command (! for normal, !! for excluded from context)\n\t\t\tif (text.startsWith(\"!\")) {\n\t\t\t\tconst isExcluded = text.startsWith(\"!!\");\n\t\t\t\tconst command = isExcluded ? text.slice(2).trim() : text.slice(1).trim();\n\t\t\t\tif (command) {\n\t\t\t\t\tif (this.session.isBashRunning) {\n\t\t\t\t\t\tthis.showWarning(\"A bash command is already running. Press Esc to cancel it first.\");\n\t\t\t\t\t\tthis.editor.setText(text);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tthis.editor.addToHistory?.(text);\n\t\t\t\t\tawait this.bashExecution.handleBashCommand(command, isExcluded);\n\t\t\t\t\tthis.isBashMode = false;\n\t\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Queue input during compaction (extension commands execute immediately)\n\t\t\tif (this.session.isCompacting) {\n\t\t\t\tif (this.messageQueue.isExtensionCommand(text)) {\n\t\t\t\t\tthis.editor.addToHistory?.(text);\n\t\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\t\tawait this.session.prompt(text);\n\t\t\t\t} else {\n\t\t\t\t\tthis.messageQueue.queueCompactionMessage(text, \"steer\");\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// If streaming, use prompt() with steer behavior\n\t\t\t// This handles extension commands (execute immediately), prompt template expansion, and queueing\n\t\t\tif (this.session.isStreaming) {\n\t\t\t\tthis.editor.addToHistory?.(text);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tawait this.session.prompt(text, { streamingBehavior: \"steer\" });\n\t\t\t\tthis.messageQueue.updatePendingMessagesDisplay();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Normal message submission\n\t\t\t// First, move any pending bash components to chat\n\t\t\tthis.bashExecution.flushPendingBashComponents();\n\n\t\t\tif (this.onInputCallback) {\n\t\t\t\tthis.onInputCallback(text);\n\t\t\t}\n\t\t\tthis.editor.addToHistory?.(text);\n\t\t};\n\t}\n\n\tprivate subscribeToAgent(): void {\n\t\tthis.unsubscribe = this.session.subscribe(async (event) => {\n\t\t\tawait this.handleEvent(event);\n\t\t});\n\t}\n\n\t/**\n\t * Deferred end-of-request settle. Called from the agent_end handler, it waits\n\t * one tick so the streaming flag settles and any retry has had a chance to arm,\n\t * then acts only when the session is genuinely idle (not streaming, not\n\t * compacting) and no retry is pending — so a retried or auto-continued request\n\t * neither rings a premature \"it's done\" nor prints a partial cost line.\n\t *\n\t * The completion chime, the transcript cost line, and settling dangling plan\n\t * items all hang off this single check because they answer the same question:\n\t * has the request actually ended?\n\t */\n\tprivate settleRequestOnIdle(): void {\n\t\tsetTimeout(() => {\n\t\t\tif (this.chimePendingRetry || this.session.isStreaming || this.session.isCompacting) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.showTurnCost();\n\t\t\tthis.settleDanglingPlanItems();\n\t\t\tthis.chime?.onTurnComplete({ aborted: this.turnStopReason === \"aborted\" });\n\t\t}, 0);\n\t}\n\n\t/**\n\t * Flip main-plan rows the model left at in_progress to a settled status now the\n\t * request is over, instead of letting them claim live work until the next user\n\t * message wipes the pane.\n\t *\n\t * The model marks its own TodoWrite items and routinely drops the final call\n\t * that completes the last one, so the row outlives the work. A clean \"stop\" is\n\t * taken as the model believing it was finished — it chose to stop talking, and\n\t * its closing message is the report — so those items settle to done. An abort,\n\t * error, or length cutoff says the opposite, so they settle to cancelled: an\n\t * honest \"never finished\" rather than a fabricated completion.\n\t *\n\t * Skipped while messages are queued: a follow-up/steer arriving during the run\n\t * means the request continues, and the plan with it.\n\t */\n\tprivate settleDanglingPlanItems(): void {\n\t\tif (this.session.pendingMessageCount > 0) return;\n\t\tconst settled = settleDanglingMainTasks(this.turnStopReason === \"stop\" ? \"done\" : \"cancelled\");\n\t\tif (settled > 0) this.ui.requestRender();\n\t}\n\n\t/**\n\t * Append this request's own token/time/cost to the transcript.\n\t *\n\t * This is the honest home for the number. The task panel is a live instrument —\n\t * present tense, wiped on the next user message — and the footer carries\n\t * cumulative session vitals, so after the fact neither can answer \"what did that\n\t * request cost\". Fired at agent_end rather than turn_end because one request is\n\t * commonly tens of turns; per-turn would wedge a number between every tool block.\n\t *\n\t * Delegated runs are reported separately rather than folded into the totals:\n\t * subagents bill against their own sessions, so their tokens never appear in this\n\t * session's entries and adding them to ↑/↓ would misreport what the parent spent.\n\t */\n\tprivate showTurnCost(): void {\n\t\tconst anchor = this.turnCostAnchor;\n\t\tthis.turnCostAnchor = undefined;\n\t\tif (!anchor) return;\n\n\t\tconst now = sumAssistantUsage(this.session.sessionManager.getEntries());\n\t\tconst input = now.input - anchor.totals.input;\n\t\tconst output = now.output - anchor.totals.output;\n\t\tconst cost = now.cost - anchor.totals.cost;\n\t\t// Nothing was accounted (e.g. aborted before the first response landed): stay\n\t\t// silent rather than print a row of zeroes.\n\t\tif (input <= 0 && output <= 0) return;\n\n\t\tconst segs: string[] = [\n\t\t\ttheme.fg(\"dim\", \"↑\") +\n\t\t\t\ttheme.fg(\"muted\", formatTokens(input)) +\n\t\t\t\ttheme.fg(\"dim\", \" ↓\") +\n\t\t\t\ttheme.fg(\"muted\", formatTokens(output)),\n\t\t\ttheme.fg(\"muted\", formatDurationSecs((Date.now() - anchor.at) / 1000)),\n\t\t];\n\t\tif (cost > 0) segs.push(theme.fg(\"muted\", `$${cost.toFixed(3)}`));\n\n\t\tconst runs = taskStore.list().filter((task) => task.source === \"subagent\");\n\t\tif (runs.length > 0) {\n\t\t\tconst delegatedTokens = runs.reduce((sum, r) => sum + (r.usage ? r.usage.input + r.usage.output : 0), 0);\n\t\t\tconst label = runs.length === 1 ? (runs[0]?.subagentMode ?? \"subagent\") : \"subagents\";\n\t\t\tconst text = `◇${runs.length} ${label}${delegatedTokens > 0 ? ` ${formatTokens(delegatedTokens)}` : \"\"}`;\n\t\t\tsegs.push(theme.fg(\"dim\", text));\n\t\t}\n\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(segs.join(theme.fg(\"dim\", \" · \")), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate async handleEvent(event: AgentSessionEvent): Promise<void> {\n\t\tif (!this.isInitialized) {\n\t\t\tawait this.init();\n\t\t}\n\n\t\tthis.footer.invalidate();\n\n\t\tswitch (event.type) {\n\t\t\tcase \"agent_start\":\n\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t// A (possibly retried) turn is (re)starting: anchor its duration on the\n\t\t\t\t// first start and clear the pending-retry gate now that it has resumed.\n\t\t\t\tthis.chime?.onTurnStart();\n\t\t\t\tthis.chimePendingRetry = false;\n\t\t\t\tthis.turnCostAnchor ??= {\n\t\t\t\t\ttotals: sumAssistantUsage(this.session.sessionManager.getEntries()),\n\t\t\t\t\tat: Date.now(),\n\t\t\t\t};\n\t\t\t\tif (this.settingsManager.getShowTerminalProgress()) {\n\t\t\t\t\tthis.ui.terminal.setProgress(true);\n\t\t\t\t}\n\t\t\t\t// Restore main escape handler if retry handler is still active\n\t\t\t\t// (retry success event fires later, but we need main handler now)\n\t\t\t\tif (this.retryEscapeHandler) {\n\t\t\t\t\tthis.defaultEditor.onEscape = this.retryEscapeHandler;\n\t\t\t\t\tthis.retryEscapeHandler = undefined;\n\t\t\t\t}\n\t\t\t\tif (this.retryCountdown) {\n\t\t\t\t\tthis.retryCountdown.dispose();\n\t\t\t\t\tthis.retryCountdown = undefined;\n\t\t\t\t}\n\t\t\t\tif (this.retryLoader) {\n\t\t\t\t\tthis.retryLoader.stop();\n\t\t\t\t\tthis.retryLoader = undefined;\n\t\t\t\t}\n\t\t\t\tthis.stopWorkingLoader();\n\t\t\t\tif (this.workingVisible) {\n\t\t\t\t\tthis.loadingAnimation = this.createWorkingLoader();\n\t\t\t\t\tthis.statusContainer.addChild(this.loadingAnimation);\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"queue_update\":\n\t\t\t\tthis.messageQueue.updatePendingMessagesDisplay();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"session_info_changed\":\n\t\t\t\tthis.updateTerminalTitle();\n\t\t\t\tthis.footer.invalidate();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"thinking_level_changed\":\n\t\t\t\tthis.footer.invalidate();\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_start\":\n\t\t\t\tif (event.message.role === \"custom\") {\n\t\t\t\t\tthis.addMessageToChat(event.message);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t} else if (event.message.role === \"user\") {\n\t\t\t\t\t// A new user message starts a new turn: drop finished tasks from the\n\t\t\t\t\t// previous turn and restart numbering from #1 once the pane is empty.\n\t\t\t\t\t// Finished tasks stay visible (with their final status, tokens, and time)\n\t\t\t\t\t// until this point — not the moment they finish — so their outcome remains\n\t\t\t\t\t// glanceable for the whole turn. Active tasks are kept: a follow-up/steer\n\t\t\t\t\t// message can arrive while a subagent is still running.\n\t\t\t\t\ttaskStore.reset();\n\t\t\t\t\t// Startup progress is transient startup status: once the user starts a\n\t\t\t\t\t// turn, drop any lingering bars/notices (e.g. an unavailable-index line)\n\t\t\t\t\t// so they don't sit in the footer for the rest of the session.\n\t\t\t\t\tstartupProgress.clear();\n\t\t\t\t\tthis.turnStopReason = undefined;\n\t\t\t\t\tthis.addMessageToChat(event.message);\n\t\t\t\t\tthis.messageQueue.updatePendingMessagesDisplay();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t} else if (event.message.role === \"assistant\") {\n\t\t\t\t\tthis.streamingComponent = new AssistantMessageComponent(\n\t\t\t\t\t\tundefined,\n\t\t\t\t\t\tthis.hideThinkingBlock,\n\t\t\t\t\t\tthis.getMarkdownThemeWithSettings(),\n\t\t\t\t\t\tthis.hiddenThinkingLabel,\n\t\t\t\t\t);\n\t\t\t\t\tthis.streamingMessage = event.message;\n\t\t\t\t\tthis.chatContainer.addChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent.updateContent(this.streamingMessage);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_update\":\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tthis.streamingMessage = event.message;\n\t\t\t\t\tthis.scheduleStreamingRender();\n\n\t\t\t\t\tfor (const content of this.streamingMessage.content) {\n\t\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\t\tif (!this.pendingTools.has(content.id)) {\n\t\t\t\t\t\t\t\tconst component = new ToolExecutionComponent(\n\t\t\t\t\t\t\t\t\tcontent.name,\n\t\t\t\t\t\t\t\t\tcontent.id,\n\t\t\t\t\t\t\t\t\tcontent.arguments,\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tshowImages: this.settingsManager.getShowImages(),\n\t\t\t\t\t\t\t\t\t\timageWidthCells: this.settingsManager.getImageWidthCells(),\n\t\t\t\t\t\t\t\t\t\tdisplayLevel: this.toolOutputDisplay,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tthis.getRegisteredToolDefinition(content.name),\n\t\t\t\t\t\t\t\t\tthis.ui,\n\t\t\t\t\t\t\t\t\tthis.sessionManager.getCwd(),\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tconst component = this.pendingTools.get(content.id);\n\t\t\t\t\t\t\t\tif (component) {\n\t\t\t\t\t\t\t\t\tcomponent.updateArgs(content.arguments);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_end\":\n\t\t\t\tif (event.message.role === \"user\") break;\n\t\t\t\tif (event.message.role === \"assistant\") {\n\t\t\t\t\t// Remember how the turn's latest assistant message ended, so the deferred\n\t\t\t\t\t// settle can tell a clean finish from an abort/error.\n\t\t\t\t\tthis.turnStopReason = event.message.stopReason;\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tthis.streamingMessage = event.message;\n\t\t\t\t\tlet errorMessage: string | undefined;\n\t\t\t\t\tif (this.streamingMessage.stopReason === \"aborted\") {\n\t\t\t\t\t\tconst retryAttempt = this.session.retryAttempt;\n\t\t\t\t\t\terrorMessage =\n\t\t\t\t\t\t\tretryAttempt > 0\n\t\t\t\t\t\t\t\t? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? \"s\" : \"\"}`\n\t\t\t\t\t\t\t\t: \"Operation aborted\";\n\t\t\t\t\t\tthis.streamingMessage.errorMessage = errorMessage;\n\t\t\t\t\t}\n\t\t\t\t\tthis.streamingComponent.updateContent(this.streamingMessage);\n\n\t\t\t\t\tif (this.streamingMessage.stopReason === \"aborted\" || this.streamingMessage.stopReason === \"error\") {\n\t\t\t\t\t\tif (!errorMessage) {\n\t\t\t\t\t\t\terrorMessage = this.streamingMessage.errorMessage || \"Error\";\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfor (const [, component] of this.pendingTools.entries()) {\n\t\t\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: errorMessage }],\n\t\t\t\t\t\t\t\tisError: true,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Args are now complete - trigger diff computation for edit tools\n\t\t\t\t\t\tfor (const [, component] of this.pendingTools.entries()) {\n\t\t\t\t\t\t\tcomponent.setArgsComplete();\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tthis.streamingComponent = undefined;\n\t\t\t\t\tthis.streamingMessage = undefined;\n\t\t\t\t\tthis.footer.invalidate();\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"tool_execution_start\": {\n\t\t\t\tlet component = this.pendingTools.get(event.toolCallId);\n\t\t\t\tif (!component) {\n\t\t\t\t\tcomponent = new ToolExecutionComponent(\n\t\t\t\t\t\tevent.toolName,\n\t\t\t\t\t\tevent.toolCallId,\n\t\t\t\t\t\tevent.args,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tshowImages: this.settingsManager.getShowImages(),\n\t\t\t\t\t\t\timageWidthCells: this.settingsManager.getImageWidthCells(),\n\t\t\t\t\t\t\tdisplayLevel: this.toolOutputDisplay,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tthis.getRegisteredToolDefinition(event.toolName),\n\t\t\t\t\t\tthis.ui,\n\t\t\t\t\t\tthis.sessionManager.getCwd(),\n\t\t\t\t\t);\n\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\tthis.pendingTools.set(event.toolCallId, component);\n\t\t\t\t}\n\t\t\t\tcomponent.markExecutionStarted();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_update\": {\n\t\t\t\tconst component = this.pendingTools.get(event.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({ ...event.partialResult, isError: false }, true);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\tconst component = this.pendingTools.get(event.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({ ...event.result, isError: event.isError });\n\t\t\t\t\tthis.pendingTools.delete(event.toolCallId);\n\t\t\t\t\tthis.trimTranscriptMemory();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\":\n\t\t\t\tif (this.settingsManager.getShowTerminalProgress()) {\n\t\t\t\t\tthis.ui.terminal.setProgress(false);\n\t\t\t\t}\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t\tthis.loadingAnimation = undefined;\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\tthis.chatContainer.removeChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent = undefined;\n\t\t\t\t\tthis.streamingMessage = undefined;\n\t\t\t\t}\n\t\t\t\tthis.pendingTools.clear();\n\n\t\t\t\tawait this.checkShutdownRequested();\n\n\t\t\t\t// agent_end fires before the streaming flag clears and before the retry\n\t\t\t\t// decision is made (both happen after this listener returns), so defer a\n\t\t\t\t// tick and re-check: only settle once the session is genuinely idle and no\n\t\t\t\t// retry is pending — otherwise a retried or auto-continued turn would ring\n\t\t\t\t// a premature \"it's done\" and print a partial cost line.\n\t\t\t\tthis.settleRequestOnIdle();\n\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"compaction_start\": {\n\t\t\t\tif (this.settingsManager.getShowTerminalProgress()) {\n\t\t\t\t\tthis.ui.terminal.setProgress(true);\n\t\t\t\t}\n\t\t\t\t// Keep editor active; submissions are queued during compaction.\n\t\t\t\tthis.autoCompactionEscapeHandler = this.defaultEditor.onEscape;\n\t\t\t\tthis.defaultEditor.onEscape = () => {\n\t\t\t\t\tthis.session.abortCompaction();\n\t\t\t\t};\n\t\t\t\tthis.statusContainer.clear();\n\t\t\t\tconst cancelHint = `(${keyText(\"app.interrupt\")} to cancel)`;\n\t\t\t\tconst label =\n\t\t\t\t\tevent.reason === \"manual\"\n\t\t\t\t\t\t? `Compacting context... ${cancelHint}`\n\t\t\t\t\t\t: `${event.reason === \"overflow\" ? \"Context overflow detected, \" : \"\"}Auto-compacting... ${cancelHint}`;\n\t\t\t\tthis.autoCompactionLoader = new Loader(\n\t\t\t\t\tthis.ui,\n\t\t\t\t\t(spinner) => theme.fg(\"accent\", spinner),\n\t\t\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t\t\tlabel,\n\t\t\t\t);\n\t\t\t\tthis.statusContainer.addChild(this.autoCompactionLoader);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"compaction_end\": {\n\t\t\t\tif (this.settingsManager.getShowTerminalProgress()) {\n\t\t\t\t\tthis.ui.terminal.setProgress(false);\n\t\t\t\t}\n\t\t\t\tif (this.autoCompactionEscapeHandler) {\n\t\t\t\t\tthis.defaultEditor.onEscape = this.autoCompactionEscapeHandler;\n\t\t\t\t\tthis.autoCompactionEscapeHandler = undefined;\n\t\t\t\t}\n\t\t\t\tif (this.autoCompactionLoader) {\n\t\t\t\t\tthis.autoCompactionLoader.stop();\n\t\t\t\t\tthis.autoCompactionLoader = undefined;\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t}\n\t\t\t\tif (event.aborted) {\n\t\t\t\t\tif (event.reason === \"manual\") {\n\t\t\t\t\t\tthis.showError(\"Compaction cancelled\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.showStatus(\"Auto-compaction cancelled\");\n\t\t\t\t\t}\n\t\t\t\t} else if (event.result) {\n\t\t\t\t\tthis.chatContainer.clear();\n\t\t\t\t\tthis.rebuildChatFromMessages();\n\t\t\t\t\tthis.addMessageToChat(\n\t\t\t\t\t\tcreateCompactionSummaryMessage(\n\t\t\t\t\t\t\tevent.result.summary,\n\t\t\t\t\t\t\tevent.result.tokensBefore,\n\t\t\t\t\t\t\tnew Date().toISOString(),\n\t\t\t\t\t\t\tevent.result.tokensAfter,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tthis.footer.invalidate();\n\t\t\t\t} else if (event.errorMessage) {\n\t\t\t\t\tif (event.reason === \"manual\") {\n\t\t\t\t\t\tthis.showError(event.errorMessage);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"error\", event.errorMessage), 1, 0));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tvoid this.messageQueue.flushCompactionQueue({ willRetry: event.willRetry });\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"auto_retry_start\": {\n\t\t\t\t// The turn is not done — it will re-run after a backoff (during which the\n\t\t\t\t// session reads as idle). Gate the deferred completion chime until then.\n\t\t\t\tthis.chimePendingRetry = true;\n\t\t\t\t// Set up escape to abort retry\n\t\t\t\tthis.retryEscapeHandler = this.defaultEditor.onEscape;\n\t\t\t\tthis.defaultEditor.onEscape = () => {\n\t\t\t\t\tthis.session.abortRetry();\n\t\t\t\t};\n\t\t\t\t// Show retry indicator\n\t\t\t\tthis.statusContainer.clear();\n\t\t\t\tthis.retryCountdown?.dispose();\n\t\t\t\tconst retryMessage = (seconds: number) =>\n\t\t\t\t\t`Retrying (${event.attempt}/${event.maxAttempts}) in ${seconds}s... (${keyText(\"app.interrupt\")} to cancel)`;\n\t\t\t\tthis.retryLoader = new Loader(\n\t\t\t\t\tthis.ui,\n\t\t\t\t\t(spinner) => theme.fg(\"warning\", spinner),\n\t\t\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t\t\tretryMessage(Math.ceil(event.delayMs / 1000)),\n\t\t\t\t);\n\t\t\t\tthis.retryCountdown = new CountdownTimer(\n\t\t\t\t\tevent.delayMs,\n\t\t\t\t\tthis.ui,\n\t\t\t\t\t(seconds) => {\n\t\t\t\t\t\tthis.retryLoader?.setMessage(retryMessage(seconds));\n\t\t\t\t\t},\n\t\t\t\t\t() => {\n\t\t\t\t\t\tthis.retryCountdown = undefined;\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t\tthis.statusContainer.addChild(this.retryLoader);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"auto_retry_end\": {\n\t\t\t\t// Restore escape handler\n\t\t\t\tif (this.retryEscapeHandler) {\n\t\t\t\t\tthis.defaultEditor.onEscape = this.retryEscapeHandler;\n\t\t\t\t\tthis.retryEscapeHandler = undefined;\n\t\t\t\t}\n\t\t\t\tif (this.retryCountdown) {\n\t\t\t\t\tthis.retryCountdown.dispose();\n\t\t\t\t\tthis.retryCountdown = undefined;\n\t\t\t\t}\n\t\t\t\t// Stop loader\n\t\t\t\tif (this.retryLoader) {\n\t\t\t\t\tthis.retryLoader.stop();\n\t\t\t\t\tthis.retryLoader = undefined;\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t}\n\t\t\t\t// Show error only on final failure (success shows normal response)\n\t\t\t\tif (!event.success) {\n\t\t\t\t\tthis.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || \"Unknown error\"}`);\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Extract text content from a user message */\n\tprivate getUserMessageText(message: Message): string {\n\t\tif (message.role !== \"user\") return \"\";\n\t\tconst textBlocks =\n\t\t\ttypeof message.content === \"string\"\n\t\t\t\t? [{ type: \"text\", text: message.content }]\n\t\t\t\t: message.content.filter((c: { type: string }) => c.type === \"text\");\n\t\treturn textBlocks.map((c) => (c as { text: string }).text).join(\"\");\n\t}\n\n\t/**\n\t * Show a status message in the chat.\n\t *\n\t * If multiple status messages are emitted back-to-back (without anything else being added to the chat),\n\t * we update the previous status line instead of appending new ones to avoid log spam.\n\t */\n\n\tprivate showStatus(message: string): void {\n\t\tconst children = this.chatContainer.children;\n\t\tconst last = children.length > 0 ? children[children.length - 1] : undefined;\n\t\tconst secondLast = children.length > 1 ? children[children.length - 2] : undefined;\n\n\t\tif (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {\n\t\t\tthis.lastStatusText.setText(styleStatusMessage(message));\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\tconst spacer = new Spacer(1);\n\t\tconst text = new Text(styleStatusMessage(message), 1, 0);\n\t\tthis.chatContainer.addChild(spacer);\n\t\tthis.chatContainer.addChild(text);\n\t\tthis.lastStatusSpacer = spacer;\n\t\tthis.lastStatusText = text;\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {\n\t\tswitch (message.role) {\n\t\t\tcase \"bashExecution\": {\n\t\t\t\tconst component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext);\n\t\t\t\tif (message.output) {\n\t\t\t\t\tcomponent.appendOutput(message.output);\n\t\t\t\t}\n\t\t\t\tcomponent.setComplete(\n\t\t\t\t\tmessage.exitCode,\n\t\t\t\t\tmessage.cancelled,\n\t\t\t\t\tmessage.truncated ? ({ truncated: true } as TruncationResult) : undefined,\n\t\t\t\t\tmessage.fullOutputPath,\n\t\t\t\t);\n\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"custom\": {\n\t\t\t\tif (message.display) {\n\t\t\t\t\tconst renderer = this.session.extensionRunner.getMessageRenderer(message.customType);\n\t\t\t\t\tconst component = new CustomMessageComponent(message, renderer, this.getMarkdownThemeWithSettings());\n\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"compactionSummary\": {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst component = new CompactionSummaryMessageComponent(message, this.getMarkdownThemeWithSettings());\n\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"branchSummary\": {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst component = new BranchSummaryMessageComponent(message, this.getMarkdownThemeWithSettings());\n\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"user\": {\n\t\t\t\tconst textContent = this.getUserMessageText(message);\n\t\t\t\tif (textContent) {\n\t\t\t\t\tif (this.chatContainer.children.length > 0) {\n\t\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\t}\n\t\t\t\t\tconst skillBlock = parseSkillBlock(textContent);\n\t\t\t\t\tif (skillBlock) {\n\t\t\t\t\t\t// Render skill block (collapsible)\n\t\t\t\t\t\tconst component = new SkillInvocationMessageComponent(\n\t\t\t\t\t\t\tskillBlock,\n\t\t\t\t\t\t\tthis.getMarkdownThemeWithSettings(),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\t// Render user message separately if present\n\t\t\t\t\t\tif (skillBlock.userMessage) {\n\t\t\t\t\t\t\tconst userComponent = new UserMessageComponent(\n\t\t\t\t\t\t\t\tskillBlock.userMessage,\n\t\t\t\t\t\t\t\tthis.getMarkdownThemeWithSettings(),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings());\n\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t}\n\t\t\t\t\tif (options?.populateHistory) {\n\t\t\t\t\t\tthis.editor.addToHistory?.(textContent);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"assistant\": {\n\t\t\t\tconst assistantComponent = new AssistantMessageComponent(\n\t\t\t\t\tmessage,\n\t\t\t\t\tthis.hideThinkingBlock,\n\t\t\t\t\tthis.getMarkdownThemeWithSettings(),\n\t\t\t\t\tthis.hiddenThinkingLabel,\n\t\t\t\t);\n\t\t\t\tthis.chatContainer.addChild(assistantComponent);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"toolResult\": {\n\t\t\t\t// Tool results are rendered inline with tool calls, handled separately\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\tconst _exhaustive: never = message;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Render session context to chat. Used for initial load and rebuild after compaction.\n\t * @param sessionContext Session context to render\n\t * @param options.updateFooter Update footer state\n\t * @param options.populateHistory Add user messages to editor history\n\t */\n\tprivate renderSessionContext(\n\t\tsessionContext: SessionContext,\n\t\toptions: { updateFooter?: boolean; populateHistory?: boolean } = {},\n\t): void {\n\t\tthis.pendingTools.clear();\n\t\tconst renderedPendingTools = new Map<string, ToolExecutionComponent>();\n\n\t\tif (options.updateFooter) {\n\t\t\tthis.footer.invalidate();\n\t\t\tthis.updateEditorBorderColor();\n\t\t}\n\n\t\tfor (const message of sessionContext.messages) {\n\t\t\t// Assistant messages need special handling for tool calls\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\tthis.addMessageToChat(message);\n\t\t\t\t// Render tool call components\n\t\t\t\tfor (const content of message.content) {\n\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\tconst component = new ToolExecutionComponent(\n\t\t\t\t\t\t\tcontent.name,\n\t\t\t\t\t\t\tcontent.id,\n\t\t\t\t\t\t\tcontent.arguments,\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tshowImages: this.settingsManager.getShowImages(),\n\t\t\t\t\t\t\t\timageWidthCells: this.settingsManager.getImageWidthCells(),\n\t\t\t\t\t\t\t\tdisplayLevel: this.toolOutputDisplay,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tthis.getRegisteredToolDefinition(content.name),\n\t\t\t\t\t\t\tthis.ui,\n\t\t\t\t\t\t\tthis.sessionManager.getCwd(),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\n\t\t\t\t\t\tif (message.stopReason === \"aborted\" || message.stopReason === \"error\") {\n\t\t\t\t\t\t\tlet errorMessage: string;\n\t\t\t\t\t\t\tif (message.stopReason === \"aborted\") {\n\t\t\t\t\t\t\t\tconst retryAttempt = this.session.retryAttempt;\n\t\t\t\t\t\t\t\terrorMessage =\n\t\t\t\t\t\t\t\t\tretryAttempt > 0\n\t\t\t\t\t\t\t\t\t\t? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? \"s\" : \"\"}`\n\t\t\t\t\t\t\t\t\t\t: \"Operation aborted\";\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\terrorMessage = message.errorMessage || \"Error\";\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcomponent.updateResult({ content: [{ type: \"text\", text: errorMessage }], isError: true });\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\trenderedPendingTools.set(content.id, component);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"toolResult\") {\n\t\t\t\t// Match tool results to pending tool components\n\t\t\t\tconst component = renderedPendingTools.get(message.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult(message);\n\t\t\t\t\trenderedPendingTools.delete(message.toolCallId);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// All other messages use standard rendering\n\t\t\t\tthis.addMessageToChat(message, options);\n\t\t\t}\n\t\t}\n\n\t\tfor (const [toolCallId, component] of renderedPendingTools) {\n\t\t\tthis.pendingTools.set(toolCallId, component);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\trenderInitialMessages(): void {\n\t\t// Get aligned messages and entries from session context\n\t\tconst context = this.sessionManager.buildSessionContext();\n\t\tthis.renderSessionContext(context, {\n\t\t\tupdateFooter: true,\n\t\t\tpopulateHistory: true,\n\t\t});\n\n\t\t// Show compaction info if session was compacted\n\t\tconst allEntries = this.sessionManager.getEntries();\n\t\tconst compactionCount = allEntries.filter((e) => e.type === \"compaction\").length;\n\t\tif (compactionCount > 0) {\n\t\t\tconst times = compactionCount === 1 ? \"1 time\" : `${compactionCount} times`;\n\t\t\tthis.showStatus(`Session compacted ${times}`);\n\t\t}\n\t}\n\n\tasync getUserInput(): Promise<string> {\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.onInputCallback = (text: string) => {\n\t\t\t\tthis.onInputCallback = undefined;\n\t\t\t\tresolve(text);\n\t\t\t};\n\t\t});\n\t}\n\n\tprivate rebuildChatFromMessages(): void {\n\t\tthis.chatContainer.clear();\n\t\tconst context = this.sessionManager.buildSessionContext();\n\t\tthis.renderSessionContext(context);\n\t}\n\n\t// =========================================================================\n\t// Key handlers\n\t// =========================================================================\n\n\tprivate handleCtrlC(): void {\n\t\tconst now = Date.now();\n\t\tif (now - this.lastSigintTime < 500) {\n\t\t\tvoid this.shutdown();\n\t\t} else {\n\t\t\tthis.clearEditor();\n\t\t\tthis.lastSigintTime = now;\n\t\t}\n\t}\n\n\tprivate handleCtrlD(): void {\n\t\t// Only called when editor is empty (enforced by CustomEditor)\n\t\tvoid this.shutdown();\n\t}\n\n\t/**\n\t * Gracefully shutdown the agent.\n\t * Stops the TUI before emitting shutdown events so extension UI cleanup cannot\n\t * repaint the final frame while the process is exiting.\n\t */\n\tprivate isShuttingDown = false;\n\n\tprivate async shutdown(): Promise<void> {\n\t\tif (this.isShuttingDown) return;\n\t\tthis.isShuttingDown = true;\n\t\tthis.unregisterSignalHandlers();\n\n\t\t// Drain any in-flight Kitty key release events before stopping.\n\t\t// This prevents escape sequences from leaking to the parent shell over slow SSH.\n\t\tawait this.ui.terminal.drainInput(1000);\n\n\t\tthis.taskStoreUnsubscribe?.();\n\t\tthis.startupProgressUnsubscribe?.();\n\t\tthis.teamFocus.closeAttach();\n\t\tthis.taskPanel.dispose();\n\t\tthis.stop();\n\t\tawait this.runtimeHost.dispose();\n\t\tprocess.exit(0);\n\t}\n\n\tprivate emergencyTerminalExit(): never {\n\t\tthis.isShuttingDown = true;\n\t\tthis.unregisterSignalHandlers();\n\t\tkillTrackedDetachedChildren();\n\t\t// The terminal is gone. Do not run normal shutdown because TUI and\n\t\t// extension cleanup can write restore sequences and re-trigger EIO.\n\t\tprocess.exit(129);\n\t}\n\n\t/**\n\t * Check if shutdown was requested and perform shutdown if so.\n\t */\n\tprivate async checkShutdownRequested(): Promise<void> {\n\t\tif (!this.shutdownRequested) return;\n\t\tawait this.shutdown();\n\t}\n\n\tprivate registerSignalHandlers(): void {\n\t\tthis.unregisterSignalHandlers();\n\n\t\tconst signals: NodeJS.Signals[] = [\"SIGTERM\"];\n\t\tif (process.platform !== \"win32\") {\n\t\t\tsignals.push(\"SIGHUP\");\n\t\t}\n\n\t\tfor (const signal of signals) {\n\t\t\tconst handler = () => {\n\t\t\t\tif (signal === \"SIGHUP\") {\n\t\t\t\t\tthis.emergencyTerminalExit();\n\t\t\t\t}\n\t\t\t\tkillTrackedDetachedChildren();\n\t\t\t\tvoid this.shutdown();\n\t\t\t};\n\t\t\tprocess.prependListener(signal, handler);\n\t\t\tthis.signalCleanupHandlers.push(() => process.off(signal, handler));\n\t\t}\n\n\t\tconst terminalErrorHandler = (error: Error) => {\n\t\t\tif (isDeadTerminalError(error)) {\n\t\t\t\tthis.emergencyTerminalExit();\n\t\t\t}\n\t\t\tthrow error;\n\t\t};\n\t\tprocess.stdout.on(\"error\", terminalErrorHandler);\n\t\tprocess.stderr.on(\"error\", terminalErrorHandler);\n\t\tthis.signalCleanupHandlers.push(() => process.stdout.off(\"error\", terminalErrorHandler));\n\t\tthis.signalCleanupHandlers.push(() => process.stderr.off(\"error\", terminalErrorHandler));\n\t}\n\n\tprivate unregisterSignalHandlers(): void {\n\t\tfor (const cleanup of this.signalCleanupHandlers) {\n\t\t\tcleanup();\n\t\t}\n\t\tthis.signalCleanupHandlers = [];\n\t}\n\n\tprivate handleCtrlZ(): void {\n\t\tif (process.platform === \"win32\") {\n\t\t\tthis.showStatus(\"Suspend to background is not supported on Windows\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Keep the event loop alive while suspended. Without this, stopping the TUI\n\t\t// can leave Node with no ref'ed handles, causing the process to exit on fg\n\t\t// before the SIGCONT handler gets a chance to restore the terminal.\n\t\tconst suspendKeepAlive = setInterval(() => {}, 2 ** 30);\n\n\t\t// Ignore SIGINT while suspended so Ctrl+C in the terminal does not\n\t\t// kill the backgrounded process. The handler is removed on resume.\n\t\tconst ignoreSigint = () => {};\n\t\tprocess.on(\"SIGINT\", ignoreSigint);\n\n\t\t// Set up handler to restore TUI when resumed\n\t\tprocess.once(\"SIGCONT\", () => {\n\t\t\tclearInterval(suspendKeepAlive);\n\t\t\tprocess.removeListener(\"SIGINT\", ignoreSigint);\n\t\t\tthis.ui.start();\n\t\t\tthis.ui.requestRender(true);\n\t\t});\n\n\t\ttry {\n\t\t\t// Stop the TUI (restore terminal to normal mode)\n\t\t\tthis.ui.stop();\n\n\t\t\t// Send SIGTSTP to process group (pid=0 means all processes in group)\n\t\t\tprocess.kill(0, \"SIGTSTP\");\n\t\t} catch (error) {\n\t\t\tclearInterval(suspendKeepAlive);\n\t\t\tprocess.removeListener(\"SIGINT\", ignoreSigint);\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tprivate async handleFollowUp(): Promise<void> {\n\t\tconst text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim();\n\t\tif (!text) return;\n\n\t\t// Queue input during compaction (extension commands execute immediately)\n\t\tif (this.session.isCompacting) {\n\t\t\tif (this.messageQueue.isExtensionCommand(text)) {\n\t\t\t\tthis.editor.addToHistory?.(text);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tawait this.session.prompt(text);\n\t\t\t} else {\n\t\t\t\tthis.messageQueue.queueCompactionMessage(text, \"followUp\");\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\t// Alt+Enter queues a follow-up message (waits until agent finishes)\n\t\t// This handles extension commands (execute immediately), prompt template expansion, and queueing\n\t\tif (this.session.isStreaming) {\n\t\t\tthis.editor.addToHistory?.(text);\n\t\t\tthis.editor.setText(\"\");\n\t\t\tawait this.session.prompt(text, { streamingBehavior: \"followUp\" });\n\t\t\tthis.messageQueue.updatePendingMessagesDisplay();\n\t\t\tthis.ui.requestRender();\n\t\t}\n\t\t// If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)\n\t\telse if (this.editor.onSubmit) {\n\t\t\tthis.editor.setText(\"\");\n\t\t\tthis.editor.onSubmit(text);\n\t\t}\n\t}\n\n\tprivate handleDequeue(): void {\n\t\tconst restored = this.messageQueue.restoreQueuedMessagesToEditor();\n\t\tif (restored === 0) {\n\t\t\tthis.showStatus(\"No queued messages to restore\");\n\t\t} else {\n\t\t\tthis.showStatus(`Restored ${restored} queued message${restored > 1 ? \"s\" : \"\"} to editor`);\n\t\t}\n\t}\n\n\tprivate updateEditorBorderColor(): void {\n\t\tif (this.isBashMode) {\n\t\t\tthis.editor.borderColor = theme.getBashModeBorderColor();\n\t\t} else {\n\t\t\tconst level = this.session.thinkingLevel || \"off\";\n\t\t\tthis.editor.borderColor = theme.getThinkingBorderColor(level);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate updateEditorPromptPrefix(): void {\n\t\tif (this.isBashMode) {\n\t\t\tthis.editor.promptPrefix = \"!\";\n\t\t\tthis.editor.promptColor = theme.getBashModeBorderColor();\n\t\t} else {\n\t\t\tthis.editor.promptPrefix = \"❯\";\n\t\t\tthis.editor.promptColor = (s: string) => s;\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate cycleThinkingLevel(): void {\n\t\tconst newLevel = this.session.cycleThinkingLevel();\n\t\tif (newLevel === undefined) {\n\t\t\tthis.showStatus(\"Current model does not support thinking\");\n\t\t} else {\n\t\t\tthis.footer.invalidate();\n\t\t\tthis.updateEditorBorderColor();\n\t\t\tthis.showStatus(`Thinking level: ${newLevel}`);\n\t\t}\n\t}\n\n\tprivate toggleToolOutputExpansion(): void {\n\t\tthis.setToolsExpanded(!this.toolOutputExpanded);\n\t}\n\n\tprivate setToolsExpanded(expanded: boolean): void {\n\t\tthis.toolOutputExpanded = expanded;\n\t\tconst activeHeader = this.chrome.customHeader ?? this.builtInHeader;\n\t\tif (isExpandable(activeHeader)) {\n\t\t\tactiveHeader.setExpanded(expanded);\n\t\t}\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (isExpandable(child)) {\n\t\t\t\tchild.setExpanded(expanded);\n\t\t\t}\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate toggleThinkingBlockVisibility(): void {\n\t\tthis.hideThinkingBlock = !this.hideThinkingBlock;\n\t\tthis.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);\n\n\t\t// Rebuild chat from session messages\n\t\tthis.chatContainer.clear();\n\t\tthis.rebuildChatFromMessages();\n\n\t\t// If streaming, re-add the streaming component with updated visibility and re-render\n\t\tif (this.streamingComponent && this.streamingMessage) {\n\t\t\tthis.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock);\n\t\t\tthis.streamingComponent.updateContent(this.streamingMessage);\n\t\t\tthis.chatContainer.addChild(this.streamingComponent);\n\t\t}\n\n\t\tthis.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? \"hidden\" : \"visible\"}`);\n\t}\n\n\tprivate openExternalEditor(): void {\n\t\t// Determine editor (respect $VISUAL, then $EDITOR)\n\t\tconst editorCmd = process.env.VISUAL || process.env.EDITOR;\n\t\tif (!editorCmd) {\n\t\t\tthis.showWarning(\"No editor configured. Set $VISUAL or $EDITOR environment variable.\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst currentText = this.editor.getExpandedText?.() ?? this.editor.getText();\n\t\tconst tmpFile = path.join(os.tmpdir(), `${APP_NAME}-editor-${Date.now()}.${APP_NAME}.md`);\n\n\t\ttry {\n\t\t\t// Write current content to temp file\n\t\t\tfs.writeFileSync(tmpFile, currentText, \"utf-8\");\n\n\t\t\t// Stop TUI to release terminal\n\t\t\tthis.ui.stop();\n\n\t\t\t// Split by space to support editor arguments (e.g., \"code --wait\")\n\t\t\tconst [editor, ...editorArgs] = editorCmd.split(\" \");\n\n\t\t\t// Spawn editor synchronously with inherited stdio for interactive editing\n\t\t\tconst result = spawnSync(editor, [...editorArgs, tmpFile], {\n\t\t\t\tstdio: \"inherit\",\n\t\t\t\tshell: process.platform === \"win32\",\n\t\t\t});\n\n\t\t\t// On successful exit (status 0), replace editor content\n\t\t\tif (result.status === 0) {\n\t\t\t\tconst newContent = fs.readFileSync(tmpFile, \"utf-8\").replace(/\\n$/, \"\");\n\t\t\t\tthis.editor.setText(newContent);\n\t\t\t}\n\t\t\t// On non-zero exit, keep original text (no action needed)\n\t\t} finally {\n\t\t\t// Clean up temp file\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\n\t\t\t// Restart TUI\n\t\t\tthis.ui.start();\n\t\t\t// Force full re-render since external editor uses alternate screen\n\t\t\tthis.ui.requestRender(true);\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// UI helpers\n\t// =========================================================================\n\n\tclearEditor(): void {\n\t\tthis.editor.setText(\"\");\n\t\tthis.ui.requestRender();\n\t}\n\n\t/**\n\t * Paint a message as a filled block, the shape errors and warnings share.\n\t *\n\t * These two used to render differently for no reason anyone could name: an\n\t * error got a blank line above it and a column of padding, a warning got\n\t * neither, so a warning collided with whatever was printed before it and hung\n\t * off the left margin. Both are the same kind of interruption, so both get the\n\t * same frame, and the fill is what separates them from ordinary chat output —\n\t * a single coloured line is easy to scroll straight past.\n\t */\n\tprivate showBlock(bg: \"warningBg\" | \"toolErrorBg\", fg: \"warning\" | \"error\", title: string, body: string[]): void {\n\t\tconst box = new Box(1, 1, (t) => theme.bg(bg, t));\n\t\tbox.addChild(new Text(theme.bold(theme.fg(fg, title)), 0, 0));\n\t\tfor (const line of body) {\n\t\t\tbox.addChild(new Text(theme.fg(\"muted\", line), 0, 0));\n\t\t}\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(box);\n\t\tthis.ui.requestRender();\n\t}\n\n\t/**\n\t * Split a message into its headline and the rest.\n\t *\n\t * Multi-line notifications — `/learn` reporting where it searched, a settings\n\t * dump — read as a heading over detail, and rendering them as one undifferen-\n\t * tiated coloured block loses that. A single-line message is all headline.\n\t */\n\tprivate splitBlockMessage(message: string): { title: string; body: string[] } {\n\t\tconst [first = \"\", ...rest] = message.split(\"\\n\");\n\t\treturn { title: first, body: rest };\n\t}\n\n\tshowError(errorMessage: string): void {\n\t\tconst { title, body } = this.splitBlockMessage(errorMessage);\n\t\tthis.showBlock(\"toolErrorBg\", \"error\", `Error: ${title}`, body);\n\t}\n\n\tshowWarning(warningMessage: string): void {\n\t\tconst { title, body } = this.splitBlockMessage(warningMessage);\n\t\tthis.showBlock(\"warningBg\", \"warning\", title, body);\n\t}\n\n\t/**\n\t * A warning the user pays for if they miss it — same frame as `showWarning`,\n\t * with an explicit title over its body.\n\t */\n\tshowNotice(title: string, body: string[]): void {\n\t\tthis.showBlock(\"warningBg\", \"warning\", title, body);\n\t}\n\n\tshowNewVersionNotification(newVersion: string): void {\n\t\tconst action = theme.fg(\"accent\", `${APP_NAME} update`);\n\t\tconst updateInstruction = theme.fg(\"muted\", `New version ${newVersion} is available. Run `) + action;\n\t\tconst changelogUrl = \"https://github.com/kolisachint/hoocode/blob/main/packages/coding-agent/CHANGELOG.md\";\n\t\tconst changelogLink = getCapabilities().hyperlinks\n\t\t\t? hyperlink(theme.fg(\"accent\", \"open changelog\"), changelogUrl)\n\t\t\t: theme.fg(\"accent\", changelogUrl);\n\t\tconst changelogLine = theme.fg(\"muted\", \"Changelog: \") + changelogLink;\n\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.chatContainer.addChild(\n\t\t\tnew Text(\n\t\t\t\t`${theme.bold(theme.fg(\"warning\", \"Update Available\"))}\\n${updateInstruction}\\n${changelogLine}`,\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowPackageUpdateNotification(packages: string[]): void {\n\t\tconst action = theme.fg(\"accent\", `${APP_NAME} update`);\n\t\tconst updateInstruction = theme.fg(\"muted\", \"Package updates are available. Run \") + action;\n\t\tconst packageLines = packages.map((pkg) => `- ${pkg}`).join(\"\\n\");\n\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.chatContainer.addChild(\n\t\t\tnew Text(\n\t\t\t\t`${theme.bold(theme.fg(\"warning\", \"Package Updates Available\"))}\\n${updateInstruction}\\n${theme.fg(\"muted\", \"Packages:\")}\\n${packageLines}`,\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.ui.requestRender();\n\t}\n\n\t// =========================================================================\n\t// Selectors\n\t// =========================================================================\n\n\t/**\n\t * Shows a selector component in place of the editor.\n\t * @param create Factory that receives a `done` callback and returns the component and focus target\n\t */\n\tprivate showSelector(create: (done: () => void) => { component: Component; focus: Component }): void {\n\t\tconst done = () => {\n\t\t\tthis.editorContainer.clear();\n\t\t\tthis.editorContainer.addChild(this.editor);\n\t\t\tthis.ui.setFocus(this.editor);\n\t\t};\n\t\tconst { component, focus } = create(done);\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(component);\n\t\tthis.ui.setFocus(focus);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate showSettingsSelector(): void {\n\t\tthis.showSelector((done) => {\n\t\t\tconst disabledToolNames = new Set(this.settingsManager.getDisabledTools());\n\t\t\tconst builtinToolNames = this.session\n\t\t\t\t.getAllTools()\n\t\t\t\t.filter((t) => t.sourceInfo?.source === \"builtin\")\n\t\t\t\t.map((t) => t.name);\n\t\t\t// Union so tools disabled at startup (absent from the live registry) still\n\t\t\t// appear and can be re-enabled for the next session.\n\t\t\tconst toolToggleNames = [...new Set([...builtinToolNames, ...disabledToolNames])].sort();\n\t\t\tconst toolToggles = toolToggleNames.map((name) => ({ name, enabled: !disabledToolNames.has(name) }));\n\n\t\t\tconst toolGroups = [\n\t\t\t\t{\n\t\t\t\t\tid: \"web\",\n\t\t\t\t\tlabel: \"Web tools\",\n\t\t\t\t\tdescription: \"webfetch + websearch (network access).\",\n\t\t\t\t\tenabled: this.settingsManager.getEnableWebTools(),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tid: \"embsearch\",\n\t\t\t\t\tlabel: \"Semantic search\",\n\t\t\t\t\tdescription: \"Semantic index layer fused into the always-on search tool.\",\n\t\t\t\t\tenabled: this.settingsManager.getEnableEmbsearchTools(),\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst flagDefs = this.session.extensionRunner.getFlags();\n\t\t\tconst flagValues = this.session.extensionRunner.getFlagValues();\n\t\t\tconst flags = [...flagDefs.values()].map((flag) => ({\n\t\t\t\tname: flag.name,\n\t\t\t\tdescription: flag.description,\n\t\t\t\ttype: flag.type,\n\t\t\t\tvalue: flagValues.get(flag.name) ?? flag.default ?? (flag.type === \"boolean\" ? false : \"\"),\n\t\t\t}));\n\n\t\t\tconst selector = new SettingsSelectorComponent(\n\t\t\t\t{\n\t\t\t\t\tautoCompact: this.session.autoCompactionEnabled,\n\t\t\t\t\ttools: toolToggles,\n\t\t\t\t\ttoolGroups,\n\t\t\t\t\tflags,\n\t\t\t\t\ttoolOutputDisplay: this.toolOutputDisplay,\n\t\t\t\t\ttoolOutputMaxBytes: this.settingsManager.getToolOutputMaxBytes(),\n\t\t\t\t\ttoolOutputMaxLines: this.settingsManager.getToolOutputMaxLines(),\n\t\t\t\t\tcontextGc: this.settingsManager.getContextGcEnabled(),\n\t\t\t\t\tshowImages: this.settingsManager.getShowImages(),\n\t\t\t\t\timageWidthCells: this.settingsManager.getImageWidthCells(),\n\t\t\t\t\tautoResizeImages: this.settingsManager.getImageAutoResize(),\n\t\t\t\t\tblockImages: this.settingsManager.getBlockImages(),\n\t\t\t\t\tenableSkillCommands: this.settingsManager.getEnableSkillCommands(),\n\t\t\t\t\tpluginInstallScope: this.settingsManager.getPluginInstallScope(),\n\t\t\t\t\tsteeringMode: this.session.steeringMode,\n\t\t\t\t\tfollowUpMode: this.session.followUpMode,\n\t\t\t\t\ttransport: this.settingsManager.getTransport(),\n\t\t\t\t\tthinkingLevel: this.session.thinkingLevel,\n\t\t\t\t\tavailableThinkingLevels: this.session.getAvailableThinkingLevels(),\n\t\t\t\t\tcurrentTheme: this.settingsManager.getTheme() || \"dark\",\n\t\t\t\t\tavailableThemes: getAvailableThemes(),\n\t\t\t\t\thideThinkingBlock: this.hideThinkingBlock,\n\t\t\t\t\tcollapseChangelog: this.settingsManager.getCollapseChangelog(),\n\t\t\t\t\tenableInstallTelemetry: this.settingsManager.getEnableInstallTelemetry(),\n\t\t\t\t\tdoubleEscapeAction: this.settingsManager.getDoubleEscapeAction(),\n\t\t\t\t\ttreeFilterMode: this.settingsManager.getTreeFilterMode(),\n\t\t\t\t\tshowHardwareCursor: this.settingsManager.getShowHardwareCursor(),\n\t\t\t\t\teditorBorder: this.settingsManager.getEditorBorder(),\n\t\t\t\t\teditorPaddingX: this.settingsManager.getEditorPaddingX(),\n\t\t\t\t\tautocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),\n\t\t\t\t\tquietStartup: this.settingsManager.getQuietStartup(),\n\t\t\t\t\tclearOnShrink: this.settingsManager.getClearOnShrink(),\n\t\t\t\t\tshowTerminalProgress: this.settingsManager.getShowTerminalProgress(),\n\t\t\t\t\twarnings: this.settingsManager.getWarnings(),\n\t\t\t\t\t// Show the effective value: env VOICETOOLS_SILENCE_MS wins for voice;\n\t\t\t\t\t// getWebtoolsTimeoutSecs already folds in HOOCODE_WEBTOOLS_TIMEOUT.\n\t\t\t\t\tvoiceSilenceMs: resolveVoiceSilenceMs(this.settingsManager),\n\t\t\t\t\twebtoolsTimeoutSecs: this.settingsManager.getWebtoolsTimeoutSecs(),\n\t\t\t\t\t// The effective window, so a project settings.json narrowing it for\n\t\t\t\t\t// this repo shows up here rather than the user-scope value alone.\n\t\t\t\t\tlearn: (() => {\n\t\t\t\t\t\tconst learn = this.settingsManager.getLearnSettings();\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tlearnMaxSessions: learn.maxSessions,\n\t\t\t\t\t\t\tlearnMaxAgeDays: learn.maxAgeDays,\n\t\t\t\t\t\t\tlearnMinRepeats: learn.minRepeats,\n\t\t\t\t\t\t\tlearnMinRequestRepeats: learn.minRequestRepeats,\n\t\t\t\t\t\t\tlearnMaxProposals: learn.maxProposals,\n\t\t\t\t\t\t};\n\t\t\t\t\t})(),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tonAutoCompactChange: (enabled) => {\n\t\t\t\t\t\tthis.session.setAutoCompactionEnabled(enabled);\n\t\t\t\t\t\tthis.footer.setAutoCompactEnabled(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonToolEnabledChange: (name, enabled) => {\n\t\t\t\t\t\t// Persist for future sessions (feeds the startup denylist).\n\t\t\t\t\t\tconst disabled = new Set(this.settingsManager.getDisabledTools());\n\t\t\t\t\t\tif (enabled) {\n\t\t\t\t\t\t\tdisabled.delete(name);\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tdisabled.add(name);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.settingsManager.setDisabledTools([...disabled]);\n\n\t\t\t\t\t\t// Apply live for the current session. Re-enabling only works for\n\t\t\t\t\t\t// tools still in the registry (i.e. not removed at startup); tools\n\t\t\t\t\t\t// disabled before launch take effect on the next session.\n\t\t\t\t\t\tconst active = new Set(this.session.getActiveToolNames());\n\t\t\t\t\t\tif (enabled) {\n\t\t\t\t\t\t\tif (this.session.getToolDefinition(name)) {\n\t\t\t\t\t\t\t\tactive.add(name);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tactive.delete(name);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.session.setActiveToolsByName([...active]);\n\t\t\t\t\t\tthis.footerDataProvider.setSubagentEnabled(this.session.getActiveToolNames().includes(\"Task\"));\n\t\t\t\t\t},\n\t\t\t\t\tonToolGroupChange: (id, enabled) => {\n\t\t\t\t\t\t// Master switches for tool availability. These gate tool creation at\n\t\t\t\t\t\t// session build, so they persist and take effect on the next session.\n\t\t\t\t\t\tswitch (id) {\n\t\t\t\t\t\t\tcase \"web\":\n\t\t\t\t\t\t\t\tthis.settingsManager.setEnableWebTools(enabled);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"embsearch\":\n\t\t\t\t\t\t\t\tthis.settingsManager.setEnableEmbsearchTools(enabled);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonToolOutputDisplayChange: (level) => {\n\t\t\t\t\t\tthis.toolOutputDisplay = level;\n\t\t\t\t\t\tthis.settingsManager.setToolOutputDisplay(level);\n\t\t\t\t\t\tfor (const child of this.chatContainer.children) {\n\t\t\t\t\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\t\t\t\t\tchild.setDisplayLevel(level);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t},\n\t\t\t\t\tonToolOutputMaxBytesChange: (bytes) => {\n\t\t\t\t\t\tthis.settingsManager.setToolOutputMaxBytes(bytes);\n\t\t\t\t\t},\n\t\t\t\t\tonToolOutputMaxLinesChange: (lines) => {\n\t\t\t\t\t\tthis.settingsManager.setToolOutputMaxLines(lines);\n\t\t\t\t\t},\n\t\t\t\t\tonContextGcChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setContextGcEnabled(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonFlagChange: (name, value) => {\n\t\t\t\t\t\t// Persist for future launches; apply live best-effort (extensions\n\t\t\t\t\t\t// that read a flag only at load time pick it up on next launch).\n\t\t\t\t\t\tthis.settingsManager.setFlagOverride(name, value);\n\t\t\t\t\t\tthis.session.extensionRunner.setFlagValue(name, value);\n\t\t\t\t\t},\n\t\t\t\t\tonShowImagesChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setShowImages(enabled);\n\t\t\t\t\t\tfor (const child of this.chatContainer.children) {\n\t\t\t\t\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\t\t\t\t\tchild.setShowImages(enabled);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonImageWidthCellsChange: (width) => {\n\t\t\t\t\t\tthis.settingsManager.setImageWidthCells(width);\n\t\t\t\t\t\tfor (const child of this.chatContainer.children) {\n\t\t\t\t\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\t\t\t\t\tchild.setImageWidthCells(width);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonAutoResizeImagesChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setImageAutoResize(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonBlockImagesChange: (blocked) => {\n\t\t\t\t\t\tthis.settingsManager.setBlockImages(blocked);\n\t\t\t\t\t},\n\t\t\t\t\tonEnableSkillCommandsChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setEnableSkillCommands(enabled);\n\t\t\t\t\t\tthis.setupAutocompleteProvider();\n\t\t\t\t\t},\n\t\t\t\t\tonPluginInstallScopeChange: (scope) => {\n\t\t\t\t\t\t// Read per install by InstallPlugin, so it applies immediately with\n\t\t\t\t\t\t// nothing to reload.\n\t\t\t\t\t\tthis.settingsManager.setPluginInstallScope(scope);\n\t\t\t\t\t},\n\t\t\t\t\tonSteeringModeChange: (mode) => {\n\t\t\t\t\t\tthis.session.setSteeringMode(mode);\n\t\t\t\t\t},\n\t\t\t\t\tonFollowUpModeChange: (mode) => {\n\t\t\t\t\t\tthis.session.setFollowUpMode(mode);\n\t\t\t\t\t},\n\t\t\t\t\tonTransportChange: (transport) => {\n\t\t\t\t\t\tthis.settingsManager.setTransport(transport);\n\t\t\t\t\t\tthis.session.agent.transport = transport;\n\t\t\t\t\t},\n\t\t\t\t\tonThinkingLevelChange: (level) => {\n\t\t\t\t\t\tthis.session.setThinkingLevel(level);\n\t\t\t\t\t\tthis.footer.invalidate();\n\t\t\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\t\t},\n\t\t\t\t\tonThemeChange: (themeName) => {\n\t\t\t\t\t\tconst result = setTheme(themeName, true);\n\t\t\t\t\t\tthis.settingsManager.setTheme(themeName);\n\t\t\t\t\t\tthis.ui.invalidate();\n\t\t\t\t\t\tif (!result.success) {\n\t\t\t\t\t\t\tthis.showError(`Failed to load theme \"${themeName}\": ${result.error}\\nFell back to dark theme.`);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonThemePreview: (themeName) => {\n\t\t\t\t\t\tconst result = setTheme(themeName, true);\n\t\t\t\t\t\tif (result.success) {\n\t\t\t\t\t\t\tthis.ui.invalidate();\n\t\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonHideThinkingBlockChange: (hidden) => {\n\t\t\t\t\t\tthis.hideThinkingBlock = hidden;\n\t\t\t\t\t\tthis.settingsManager.setHideThinkingBlock(hidden);\n\t\t\t\t\t\tfor (const child of this.chatContainer.children) {\n\t\t\t\t\t\t\tif (child instanceof AssistantMessageComponent) {\n\t\t\t\t\t\t\t\tchild.setHideThinkingBlock(hidden);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.chatContainer.clear();\n\t\t\t\t\t\tthis.rebuildChatFromMessages();\n\t\t\t\t\t},\n\t\t\t\t\tonCollapseChangelogChange: (collapsed) => {\n\t\t\t\t\t\tthis.settingsManager.setCollapseChangelog(collapsed);\n\t\t\t\t\t},\n\t\t\t\t\tonEnableInstallTelemetryChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setEnableInstallTelemetry(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonQuietStartupChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setQuietStartup(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonDoubleEscapeActionChange: (action) => {\n\t\t\t\t\t\tthis.settingsManager.setDoubleEscapeAction(action);\n\t\t\t\t\t},\n\t\t\t\t\tonTreeFilterModeChange: (mode) => {\n\t\t\t\t\t\tthis.settingsManager.setTreeFilterMode(mode);\n\t\t\t\t\t},\n\t\t\t\t\tonShowHardwareCursorChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setShowHardwareCursor(enabled);\n\t\t\t\t\t\tthis.ui.setShowHardwareCursor(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonEditorBorderChange: (border) => {\n\t\t\t\t\t\tthis.settingsManager.setEditorBorder(border);\n\t\t\t\t\t\tthis.defaultEditor.setBorder(border);\n\t\t\t\t\t\tif (this.editor !== this.defaultEditor && this.editor.setBorder !== undefined) {\n\t\t\t\t\t\t\tthis.editor.setBorder(border);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonEditorPaddingXChange: (padding) => {\n\t\t\t\t\t\tthis.settingsManager.setEditorPaddingX(padding);\n\t\t\t\t\t\tthis.defaultEditor.setPaddingX(padding);\n\t\t\t\t\t\tif (this.editor !== this.defaultEditor && this.editor.setPaddingX !== undefined) {\n\t\t\t\t\t\t\tthis.editor.setPaddingX(padding);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonAutocompleteMaxVisibleChange: (maxVisible) => {\n\t\t\t\t\t\tthis.settingsManager.setAutocompleteMaxVisible(maxVisible);\n\t\t\t\t\t\tthis.defaultEditor.setAutocompleteMaxVisible(maxVisible);\n\t\t\t\t\t\tif (this.editor !== this.defaultEditor && this.editor.setAutocompleteMaxVisible !== undefined) {\n\t\t\t\t\t\t\tthis.editor.setAutocompleteMaxVisible(maxVisible);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonClearOnShrinkChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setClearOnShrink(enabled);\n\t\t\t\t\t\tthis.ui.setClearOnShrink(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonShowTerminalProgressChange: (enabled) => {\n\t\t\t\t\t\tthis.settingsManager.setShowTerminalProgress(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonWarningsChange: (warnings) => {\n\t\t\t\t\t\tthis.settingsManager.setWarnings(warnings);\n\t\t\t\t\t},\n\t\t\t\t\tonVoiceSilenceMsChange: (ms) => {\n\t\t\t\t\t\tthis.settingsManager.setVoiceSilenceMs(ms);\n\t\t\t\t\t\t// Env override still wins; re-resolve so the live value stays consistent.\n\t\t\t\t\t\tthis.voice.setSilenceMs(resolveVoiceSilenceMs(this.settingsManager));\n\t\t\t\t\t},\n\t\t\t\t\tonWebtoolsTimeoutSecsChange: (secs) => {\n\t\t\t\t\t\t// Persisted; webfetch/websearch pick it up when tools rebuild next session.\n\t\t\t\t\t\tthis.settingsManager.setWebtoolsTimeoutSecs(secs);\n\t\t\t\t\t},\n\t\t\t\t\tonLearnSettingChange: (key, value) => {\n\t\t\t\t\t\t// /learn reads settings fresh on every invocation, so the next run\n\t\t\t\t\t\t// picks this up with no restart and nothing to re-wire live.\n\t\t\t\t\t\tthis.settingsManager.setLearnSetting(key, value);\n\t\t\t\t\t},\n\t\t\t\t\tonCancel: () => {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector.getSettingsList() };\n\t\t});\n\t}\n\n\tprivate showUserMessageSelector(): void {\n\t\tconst userMessages = this.session.getUserMessagesForForking();\n\n\t\tif (userMessages.length === 0) {\n\t\t\tthis.showStatus(\"No messages to fork from\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst initialSelectedId = userMessages[userMessages.length - 1]?.entryId;\n\n\t\tthis.showSelector((done) => {\n\t\t\tconst selector = new UserMessageSelectorComponent(\n\t\t\t\tuserMessages.map((m) => ({ id: m.entryId, text: m.text })),\n\t\t\t\tasync (entryId) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst result = await this.runtimeHost.fork(entryId);\n\t\t\t\t\t\tif (result.cancelled) {\n\t\t\t\t\t\t\tdone();\n\t\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tthis.renderCurrentSessionState();\n\t\t\t\t\t\tthis.editor.setText(result.selectedText ?? \"\");\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.showStatus(\"Forked to new session\");\n\t\t\t\t\t} catch (error: unknown) {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.showError(error instanceof Error ? error.message : String(error));\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tdone();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t},\n\t\t\t\tinitialSelectedId,\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector.getMessageList() };\n\t\t});\n\t}\n\n\tprivate showTreeSelector(initialSelectedId?: string): void {\n\t\tconst tree = this.sessionManager.getTree();\n\t\tconst realLeafId = this.sessionManager.getLeafId();\n\t\tconst initialFilterMode = this.settingsManager.getTreeFilterMode();\n\n\t\tif (tree.length === 0) {\n\t\t\tthis.showStatus(\"No entries in session\");\n\t\t\treturn;\n\t\t}\n\n\t\tthis.showSelector((done) => {\n\t\t\tconst selector = new TreeSelectorComponent(\n\t\t\t\ttree,\n\t\t\t\trealLeafId,\n\t\t\t\tthis.ui.terminal.rows,\n\t\t\t\tasync (entryId) => {\n\t\t\t\t\t// Selecting the current leaf is a no-op (already there)\n\t\t\t\t\tif (entryId === realLeafId) {\n\t\t\t\t\t\tdone();\n\t\t\t\t\t\tthis.showStatus(\"Already at this point\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Ask about summarization\n\t\t\t\t\tdone(); // Close selector first\n\n\t\t\t\t\t// Loop until user makes a complete choice or cancels to tree\n\t\t\t\t\tlet wantsSummary = false;\n\t\t\t\t\tlet customInstructions: string | undefined;\n\n\t\t\t\t\t// Check if we should skip the prompt (user preference to always default to no summary)\n\t\t\t\t\tif (!this.settingsManager.getBranchSummarySkipPrompt()) {\n\t\t\t\t\t\twhile (true) {\n\t\t\t\t\t\t\tconst summaryChoice = await this.dialogs.showSelector(\"Summarize branch?\", [\n\t\t\t\t\t\t\t\t\"No summary\",\n\t\t\t\t\t\t\t\t\"Summarize\",\n\t\t\t\t\t\t\t\t\"Summarize with custom prompt\",\n\t\t\t\t\t\t\t]);\n\n\t\t\t\t\t\t\tif (summaryChoice === undefined) {\n\t\t\t\t\t\t\t\t// User pressed escape - re-show tree selector with same selection\n\t\t\t\t\t\t\t\tthis.showTreeSelector(entryId);\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\twantsSummary = summaryChoice !== \"No summary\";\n\n\t\t\t\t\t\t\tif (summaryChoice === \"Summarize with custom prompt\") {\n\t\t\t\t\t\t\t\tcustomInstructions = await this.dialogs.showEditor(\"Custom summarization instructions\");\n\t\t\t\t\t\t\t\tif (customInstructions === undefined) {\n\t\t\t\t\t\t\t\t\t// User cancelled - loop back to summary selector\n\t\t\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t// User made a complete choice\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Set up escape handler and loader if summarizing\n\t\t\t\t\tlet summaryLoader: Loader | undefined;\n\t\t\t\t\tconst originalOnEscape = this.defaultEditor.onEscape;\n\n\t\t\t\t\tif (wantsSummary) {\n\t\t\t\t\t\tthis.defaultEditor.onEscape = () => {\n\t\t\t\t\t\t\tthis.session.abortBranchSummary();\n\t\t\t\t\t\t};\n\t\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\t\tsummaryLoader = new Loader(\n\t\t\t\t\t\t\tthis.ui,\n\t\t\t\t\t\t\t(spinner) => theme.fg(\"accent\", spinner),\n\t\t\t\t\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t\t\t\t\t`Summarizing branch... (${keyText(\"app.interrupt\")} to cancel)`,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tthis.statusContainer.addChild(summaryLoader);\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t}\n\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst result = await this.session.navigateTree(entryId, {\n\t\t\t\t\t\t\tsummarize: wantsSummary,\n\t\t\t\t\t\t\tcustomInstructions,\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tif (result.aborted) {\n\t\t\t\t\t\t\t// Summarization aborted - re-show tree selector with same selection\n\t\t\t\t\t\t\tthis.showStatus(\"Branch summarization cancelled\");\n\t\t\t\t\t\t\tthis.showTreeSelector(entryId);\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (result.cancelled) {\n\t\t\t\t\t\t\tthis.showStatus(\"Navigation cancelled\");\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Update UI\n\t\t\t\t\t\tthis.chatContainer.clear();\n\t\t\t\t\t\tthis.renderInitialMessages();\n\t\t\t\t\t\tif (result.editorText && !this.editor.getText().trim()) {\n\t\t\t\t\t\t\tthis.editor.setText(result.editorText);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.showStatus(\"Navigated to selected point\");\n\t\t\t\t\t\tvoid this.messageQueue.flushCompactionQueue({ willRetry: false });\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tthis.showError(error instanceof Error ? error.message : String(error));\n\t\t\t\t\t} finally {\n\t\t\t\t\t\tif (summaryLoader) {\n\t\t\t\t\t\t\tsummaryLoader.stop();\n\t\t\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.defaultEditor.onEscape = originalOnEscape;\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tdone();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t},\n\t\t\t\t(entryId, label) => {\n\t\t\t\t\tthis.sessionManager.appendLabelChange(entryId, label);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t},\n\t\t\t\tinitialSelectedId,\n\t\t\t\tinitialFilterMode,\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n\n\tprivate showSessionSelector(): void {\n\t\tthis.showSelector((done) => {\n\t\t\tconst selector = new SessionSelectorComponent(\n\t\t\t\t(onProgress) =>\n\t\t\t\t\tSessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir(), onProgress),\n\t\t\t\tSessionManager.listAll,\n\t\t\t\tasync (sessionPath) => {\n\t\t\t\t\tdone();\n\t\t\t\t\tawait this.handleResumeSession(sessionPath);\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tdone();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tvoid this.shutdown();\n\t\t\t\t},\n\t\t\t\t() => this.ui.requestRender(),\n\t\t\t\t{\n\t\t\t\t\trenameSession: async (sessionFilePath: string, nextName: string | undefined) => {\n\t\t\t\t\t\tconst next = (nextName ?? \"\").trim();\n\t\t\t\t\t\tif (!next) return;\n\t\t\t\t\t\tconst mgr = SessionManager.open(sessionFilePath);\n\t\t\t\t\t\tmgr.appendSessionInfo(next);\n\t\t\t\t\t},\n\t\t\t\t\tshowRenameHint: true,\n\t\t\t\t\tkeybindings: this.keybindings,\n\t\t\t\t},\n\n\t\t\t\tthis.sessionManager.getSessionFile(),\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector };\n\t\t});\n\t}\n\n\tprivate async handleResumeSession(\n\t\tsessionPath: string,\n\t\toptions?: Parameters<ExtensionCommandContext[\"switchSession\"]>[1],\n\t): Promise<{ cancelled: boolean }> {\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t}\n\t\tthis.statusContainer.clear();\n\t\ttry {\n\t\t\tconst result = await this.runtimeHost.switchSession(sessionPath, {\n\t\t\t\twithSession: options?.withSession,\n\t\t\t});\n\t\t\tif (result.cancelled) {\n\t\t\t\treturn result;\n\t\t\t}\n\t\t\tthis.renderCurrentSessionState();\n\t\t\tthis.showStatus(\"Resumed session\");\n\t\t\treturn result;\n\t\t} catch (error: unknown) {\n\t\t\tif (error instanceof MissingSessionCwdError) {\n\t\t\t\tconst selectedCwd = await this.promptForMissingSessionCwd(error);\n\t\t\t\tif (!selectedCwd) {\n\t\t\t\t\tthis.showStatus(\"Resume cancelled\");\n\t\t\t\t\treturn { cancelled: true };\n\t\t\t\t}\n\t\t\t\tconst result = await this.runtimeHost.switchSession(sessionPath, {\n\t\t\t\t\tcwdOverride: selectedCwd,\n\t\t\t\t\twithSession: options?.withSession,\n\t\t\t\t});\n\t\t\t\tif (result.cancelled) {\n\t\t\t\t\treturn result;\n\t\t\t\t}\n\t\t\t\tthis.renderCurrentSessionState();\n\t\t\t\tthis.showStatus(\"Resumed session in current cwd\");\n\t\t\t\treturn result;\n\t\t\t}\n\t\t\treturn this.handleFatalRuntimeError(\"Failed to resume session\", error);\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Command handlers\n\t// =========================================================================\n\n\tprivate async handleReloadCommand(): Promise<void> {\n\t\tif (this.session.isStreaming) {\n\t\t\tthis.showWarning(\"Wait for the current response to finish before reloading.\");\n\t\t\treturn;\n\t\t}\n\t\tif (this.session.isCompacting) {\n\t\t\tthis.showWarning(\"Wait for compaction to finish before reloading.\");\n\t\t\treturn;\n\t\t}\n\n\t\tthis.resetExtensionUI();\n\n\t\tconst reloadBox = new Container();\n\t\tconst borderColor = (s: string) => theme.fg(\"border\", s);\n\t\treloadBox.addChild(new DynamicBorder(borderColor));\n\t\treloadBox.addChild(new Spacer(1));\n\t\treloadBox.addChild(\n\t\t\tnew Text(theme.fg(\"muted\", \"Reloading keybindings, extensions, skills, prompts, themes...\"), 1, 0),\n\t\t);\n\t\treloadBox.addChild(new Spacer(1));\n\t\treloadBox.addChild(new DynamicBorder(borderColor));\n\n\t\tconst previousEditor = this.editor;\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(reloadBox);\n\t\tthis.ui.setFocus(reloadBox);\n\t\tthis.ui.requestRender(true);\n\t\tawait new Promise((resolve) => process.nextTick(resolve));\n\n\t\tconst dismissReloadBox = (editor: Component) => {\n\t\t\tthis.editorContainer.clear();\n\t\t\tthis.editorContainer.addChild(editor);\n\t\t\tthis.ui.setFocus(editor);\n\t\t\tthis.ui.requestRender();\n\t\t};\n\n\t\ttry {\n\t\t\tawait this.session.reload();\n\t\t\tthis.keybindings.reload();\n\t\t\tconst activeHeader = this.chrome.customHeader ?? this.builtInHeader;\n\t\t\tif (isExpandable(activeHeader)) {\n\t\t\t\tactiveHeader.setExpanded(this.toolOutputExpanded);\n\t\t\t}\n\t\t\tsetRegisteredThemes(this.session.resourceLoader.getThemes().themes);\n\t\t\tthis.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();\n\t\t\tconst themeName = this.settingsManager.getTheme();\n\t\t\tconst themeResult = themeName ? setTheme(themeName, true) : { success: true };\n\t\t\tif (!themeResult.success) {\n\t\t\t\tthis.showError(`Failed to load theme \"${themeName}\": ${themeResult.error}\\nFell back to dark theme.`);\n\t\t\t}\n\t\t\tconst editorBorder = this.settingsManager.getEditorBorder();\n\t\t\tconst editorPaddingX = this.settingsManager.getEditorPaddingX();\n\t\t\tconst autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();\n\t\t\tthis.defaultEditor.setBorder(editorBorder);\n\t\t\tthis.defaultEditor.setPaddingX(editorPaddingX);\n\t\t\tthis.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);\n\t\t\tif (this.editor !== this.defaultEditor) {\n\t\t\t\tthis.editor.setBorder?.(editorBorder);\n\t\t\t\tthis.editor.setPaddingX?.(editorPaddingX);\n\t\t\t\tthis.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);\n\t\t\t}\n\t\t\tthis.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());\n\t\t\tthis.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());\n\t\t\tthis.setupAutocompleteProvider();\n\t\t\tconst runner = this.session.extensionRunner;\n\t\t\tthis.setupExtensionShortcuts(runner);\n\t\t\tthis.rebuildChatFromMessages();\n\t\t\tdismissReloadBox(this.editor as Component);\n\t\t\tthis.showLoadedResources({\n\t\t\t\tforce: false,\n\t\t\t\tshowDiagnosticsWhenQuiet: true,\n\t\t\t});\n\t\t\tconst modelsJsonError = this.session.modelRegistry.getError();\n\t\t\tif (modelsJsonError) {\n\t\t\t\tthis.showError(`models.json error: ${modelsJsonError}`);\n\t\t\t}\n\t\t\tthis.showStatus(\"Reloaded keybindings, extensions, skills, prompts, themes\");\n\t\t} catch (error) {\n\t\t\tdismissReloadBox(previousEditor as Component);\n\t\t\tthis.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);\n\t\t}\n\t}\n\n\tprivate async handleCompactCommand(customInstructions?: string): Promise<void> {\n\t\t// The session is the single source of truth for whether compaction is\n\t\t// possible (e.g. \"Already compacted\", \"Nothing to compact (session too\n\t\t// small)\"). Its specific error is surfaced via the compaction_end event,\n\t\t// so we don't pre-check here and risk a divergent message.\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t}\n\t\tthis.statusContainer.clear();\n\n\t\ttry {\n\t\t\tawait this.session.compact(customInstructions);\n\t\t} catch {\n\t\t\t// Ignore, will be emitted as an event\n\t\t}\n\t}\n\n\tstop(): void {\n\t\tthis.unregisterSignalHandlers();\n\t\tthis.voice.dispose();\n\t\tif (this.settingsManager.getShowTerminalProgress()) {\n\t\t\tthis.ui.terminal.setProgress(false);\n\t\t}\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = undefined;\n\t\t}\n\t\tthis.clearExtensionTerminalInputListeners();\n\t\tthis.footer.dispose();\n\t\tthis.footerDataProvider.dispose();\n\t\tif (this.unsubscribe) {\n\t\t\tthis.unsubscribe();\n\t\t}\n\t\tif (this.isInitialized) {\n\t\t\tthis.ui.stop();\n\t\t\t// The terminal is ours again: later log lines can print normally.\n\t\t\tsetTerminalOwnedByTui(false);\n\t\t\tthis.isInitialized = false;\n\t\t}\n\t}\n}\n"]}
|