{"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;AAEvF,OAAO,KAAK,EAOX,QAAQ,EACR,MAAM,0BAA0B,CAAC;AA2BlC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AA6B/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AA2GlE;;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;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;CACpB;AAwDD,qBAAa,eAAe;IAkR1B,OAAO,CAAC,OAAO;IAjRhB,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,8EAA8E;IAC9E,OAAO,CAAC,gBAAgB,CAAC,CAAa;IACtC,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;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAoB;IAC/C,iFAAiF;IACjF,OAAO,CAAC,aAAa,CAAoB;IACzC,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,kEAAkE;IAClE,OAAO,CAAC,iBAAiB,CAA4B;IACrD,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,cAAc,CAA4C;IAGlE,OAAO,CAAC,cAAc,CAAC,CAAiB;IAExC;;;;;;OAMG;IACH,OAAO,KAAK,kBAAkB,GAE7B;IAED,OAAO,CAAC,SAAS,CAAC,CAAqB;IACvC,+DAA+D;IAC/D,OAAO,CAAC,eAAe,CAAC,CAAyB;IACjD,sEAAsE;IACtE,OAAO,CAAC,WAAW,CAAC,CAAqB;IAGzC,OAAO,CAAC,uBAAuB,CAAS;IAGxC,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;IAIhC,OAAO,CAAC,IAAI,CAAC,CAAiB;IAC9B,+EAA+E;IAC/E,OAAO,CAAC,sBAAsB,CAAC,CAAa;IAG5C,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;;;;OAIG;IACH,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,YAAY,CAA0B;IAC9C,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,GAuD1B;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,EAuL5C;IAED,OAAO,CAAC,wBAAwB;IAyBhC,OAAO,CAAC,6BAA6B;IAQrC,OAAO,CAAC,oCAAoC;IAe5C,OAAO,CAAC,8BAA8B;IAsFtC,OAAO,CAAC,yBAAyB;IAajC,OAAO,CAAC,0BAA0B;IA6BlC,qFAAqF;IACrF,OAAO,CAAC,kBAAkB;IAIpB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAmO1B;IAED;;;;OAIG;IACH,OAAO,CAAC,sBAAsB;IAK9B;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;IAUzB;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAU3B;;;OAGG;IACG,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,CA+EzB;IAED,OAAO,CAAC,4BAA4B;IAWpC,OAAO,CAAC,wBAAwB;IAIhC,4EAA4E;IAC5E,OAAO,CAAC,mBAAmB;IAwD3B;;;OAGG;IACH,OAAO,CAAC,yBAAyB;YAYnB,4BAA4B;IA8E1C;;;;;;;;;;OAUG;IACH,OAAO,CAAC,oBAAoB;IA2B5B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,iBAAiB;YAoBX,qBAAqB;YAcrB,oBAAoB;YAcpB,uBAAuB;IAQrC;;;;;;OAMG;IACH,OAAO,CAAC,mBAAmB;IAU3B;;;;;;;;OAQG;IACH,OAAO,CAAC,yBAAyB;IASjC;;;;;;OAMG;IACH,OAAO,CAAC,oBAAoB;IAQ5B;;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;IAmEhC;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAU3B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAsB1B,OAAO,CAAC,gBAAgB;YA2GV,yBAAyB;IA0BvC;;;OAGG;IACH,gBAAgB,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI,CAEjD;IAED;;;;;OAKG;IACH,OAAO,CAAC,0BAA0B;IAwLlC,OAAO,CAAC,wBAAwB;IAmEhC,OAAO,CAAC,gBAAgB;IAMxB;;;;;;;;;;OAUG;IACH,OAAO,CAAC,mBAAmB;IAgB3B;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,uBAAuB;IAM/B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,YAAY;YAmCN,WAAW;IAqYzB,+CAA+C;IAC/C,OAAO,CAAC,kBAAkB;IAS1B;;;;;OAKG;IAEH;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,YAAY;IAWpB;;;;;OAKG;IACH,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAG3D;IAED;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,OAAO;IAQf;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,UAAU;IAKlB;;;;;;;;;OASG;IACH,OAAO,CAAC,UAAU;IAoBlB,OAAO,CAAC,gBAAgB;IA4FxB;;;;;OAKG;IACH,OAAO,CAAC,oBAAoB;IAsF5B,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;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAqBvB;;;;;;;;OAQG;IACH,OAAO,CAAC,cAAc;IAWtB,uEAAuE;IACvE,OAAO,CAAC,oBAAoB;IAS5B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,cAAc;IAWtB;;;;;;;OAOG;IACH,OAAO,CAAC,mBAAmB;IAS3B;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,sBAAsB;IAK9B,OAAO,CAAC,mBAAmB;IA6B3B;;;;OAIG;IACH,OAAO,CAAC,gBAAgB;YAuBV,cAAc;IAiC5B;;;;;;;;;OASG;IACH,OAAO,CAAC,iBAAiB;IAOzB;;;;;;;;OAQG;IACH,OAAO,CAAC,kBAAkB;IAO1B;;;;;;;;;OASG;IACH,OAAO,CAAC,mBAAmB;IAmB3B,OAAO,CAAC,6BAA6B;IAwBrC,OAAO,CAAC,kBAAkB;IAoD1B,WAAW,IAAI,IAAI,CAGlB;IAED;;;;;;;;;OASG;IACH,OAAO,CAAC,SAAS;IAYjB;;;;;;OAMG;IACH,OAAO,CAAC,iBAAiB;IAKzB,SAAS,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CASpC;IAED;;;;;;;;;;OAUG;IACH,WAAW,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAGxC;IAED;;;;;;;;OAQG;IACH,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAE9C;IAED;;;;OAIG;IACH,OAAO,CAAC,iCAAiC;IASzC,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;IA6V5B,OAAO,CAAC,uBAAuB;IAyC/B,OAAO,CAAC,gBAAgB;IAiIxB;;;;OAIG;IACH,OAAO,CAAC,wBAAwB;IAgChC,OAAO,CAAC,mBAAmB;YAmCb,mBAAmB;YA6CnB,mBAAmB;YAkEnB,oBAAoB;IAkBlC,IAAI,IAAI,IAAI,CA0BX;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 { isLongRetryDelayError } from \"@kolisachint/hoocode-ai\";\nimport type {\n\tAutocompleteItem,\n\tAutocompleteProvider,\n\tEditorComponent,\n\tKeyId,\n\tMarkdownTheme,\n\tSlashCommand,\n\tTerminal,\n} from \"@kolisachint/hoocode-tui\";\nimport {\n\tBox,\n\tCombinedAutocompleteProvider,\n\ttype Component,\n\tContainer,\n\tFlexSpacer,\n\tfuzzyFilter,\n\tgetCapabilities,\n\thyperlink,\n\tLoader,\n\ttype LoaderIndicatorOptions,\n\tMarkdown,\n\tmatchesKey,\n\tProcessTerminal,\n\tSlot,\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 { TASK_TOOL_NAME } from \"../../core/agent-frontmatter.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 { canvasSearchRoots, discoverCanvasExtensions } from \"../../core/canvas/discovery.js\";\nimport { pluginCanvasExtensions } from \"../../core/canvas/plugin-canvases.js\";\nimport { gateCanvasExtensions } from \"../../core/canvas/trust.js\";\nimport type {\n\tAutocompleteProviderFactory,\n\tEditorFactory,\n\tExtensionCommandContext,\n\tExtensionContext,\n\tExtensionRunner,\n\tExtensionUIContext,\n} from \"../../core/extensions/index.js\";\nimport { getWorkspacePlatforms, setPlatforms } from \"../../core/extensions/plugins/formats/platform-targets.js\";\nimport { describeExternalTools } from \"../../core/external-tools.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 { measurePromptSurface, measureToolSchemaTokens } from \"../../core/light.js\";\nimport type { ResourceDiagnostic } from \"../../core/resource-loader.js\";\nimport { formatMissingSessionCwdPrompt, MissingSessionCwdError } from \"../../core/session-cwd.js\";\nimport { cycleSessionColorSlot, sessionColorName } from \"../../core/session-identity.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 {\n\tcycleToolOutputView,\n\tDEFAULT_TOOL_OUTPUT_VIEW,\n\tMAX_TOOL_OUTPUT_VIEW,\n\ttype ToolOutputView,\n} from \"../../core/tool-output-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 { openUrl } from \"../../utils/open-url.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 { CHROME_DENSITIES, ChromeLayoutController, isChromeDensity, SMALL_TERMINAL_ROWS } from \"./chrome-layout.js\";\nimport { type CommandContext, CommandExecutor } from \"./command-executor.js\";\nimport { BELL, CompletionChime } from \"./completion-chime.js\";\nimport { AssistantMessageComponent, type ThinkingDisplay } 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 { setInputFrameBorder } from \"./components/input-frame.js\";\nimport { appKeyLabel, keyHint, keyText, rawKeyHint } from \"./components/keybinding-hints.js\";\nimport { NotificationPanel } from \"./components/notification-panel.js\";\nimport { renderSessionChip } from \"./components/session-chip.js\";\nimport { SessionColorSelectorComponent } from \"./components/session-color-selector.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 { ToolChainComponent } from \"./components/tool-chain.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 { installScrollView } from \"./scroll-view.js\";\nimport { checkForPackageUpdates, checkTmuxKeyboardSetup, getChangelogForDisplay } from \"./startup-checks.js\";\nimport { TeamFocusController } from \"./team-focus.js\";\nimport {\n\tapplyBlockFill,\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 { renderTip, type Tip, TipRotation } from \"./tips.js\";\nimport { TipsController } from \"./tips-controller.js\";\nimport { VoiceController } from \"./voice/voice-controller.js\";\nimport { websearchApiKeyNotice } from \"./websearch-warning.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\t/**\n\t * Terminal the TUI draws to. Defaults to the real process terminal; tests\n\t * pass a capturing one to mount the whole mode headlessly and screenshot it.\n\t */\n\tterminal?: Terminal;\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\t/** Teardown for the pinned-view key capture; re-installed with the editor. */\n\tprivate removeScrollView?: () => void;\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\t/**\n\t * The rows nobody is using, handed to the renderer so the app is the size of\n\t * the screen. It sits directly below the banner, so on a fresh session the\n\t * logo holds the first row, the leftover rows sit between it and the\n\t * conversation, and the prompt and footer stay packed against the foot of\n\t * the screen. Past a screenful the fill is 0 and the layout is exactly the\n\t * old pack-against-the-floor. See `TUI.setFlexSpacer`.\n\t */\n\tprivate readonly screenFill = new FlexSpacer();\n\t/** The transient band above the prompt; see components/notification-panel.ts. */\n\tprivate notifications: NotificationPanel;\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\t/** Dial reverses already named this session; see showDialStep. */\n\tprivate dialReverseTaught = new Set<AppKeybinding>();\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// Persisted view dial (radar / peek / full). See core/tool-output-view.ts.\n\tprivate toolOutputView: ToolOutputView = DEFAULT_TOOL_OUTPUT_VIEW;\n\t// Where `app.tools.expand` came from, so the same key goes back there. Kept\n\t// in memory only: the jump is the momentary look, the dial is the decision.\n\tprivate viewBeforeJump?: ToolOutputView;\n\n\t/**\n\t * Whether everything foldable is currently open.\n\t *\n\t * Not a state of its own any more — it is the top of the view dial. The\n\t * header, compaction and branch summaries and skill blocks follow the dial\n\t * for the same reason tool bodies do: `full` means nothing is held back.\n\t */\n\tprivate get toolOutputExpanded(): boolean {\n\t\treturn this.toolOutputView === MAX_TOOL_OUTPUT_VIEW;\n\t}\n\t// The chain currently collecting tool calls, if the agent is mid-run.\n\tprivate openChain?: ToolChainComponent;\n\t/** The newest tool block in the transcript; radar marks it. */\n\tprivate latestToolBlock?: ToolExecutionComponent;\n\t/** The run that block belongs to; radar marks its folded line too. */\n\tprivate latestChain?: ToolChainComponent;\n\t// Whether the assistant message being streamed has said anything yet. The\n\t// first words it speaks close the chain the previous message's calls built.\n\tprivate sawTextInCurrentMessage = false;\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// Tips on the notification band (see tips.ts / tips-controller.ts). Optional\n\t// for the same reason the chime is: a mode constructed in a test has no\n\t// terminal to teach anybody anything on.\n\tprivate tips?: TipsController;\n\t/** Drops the keystroke observer that feeds the tip controller's idle clock. */\n\tprivate tipActivityUnsubscribe?: () => void;\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\t/**\n\t * The two pieces of chrome the density dial can take away, each in a slot so\n\t * hiding one costs nothing and never moves the tree. The prompt has no slot\n\t * on purpose — see `chrome-layout.ts`.\n\t */\n\tprivate footerSlot!: Slot;\n\tprivate tasksSlot!: Slot;\n\tprivate chromeLayout!: ChromeLayoutController;\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\tshowRecord: (message) => self.showRecord(message),\n\t\t\t\tnotify: (message, note) => self.notify(message, note),\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(options.terminal ?? 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.footer.setToolOutputView(this.toolOutputView);\n\t\tthis.footerDataProvider.setSubagentEnabled(this.session.getActiveToolNames().includes(\"Task\"));\n\t\tthis.footerSlot = new Slot(this.footer);\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\tfooterSlot: this.footerSlot,\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.tasksSlot = new Slot(this.taskPanel);\n\t\t// A third of the screen is the band's share of it: enough for a listing to\n\t\t// arrive whole on a tall terminal, never so much that a notification is\n\t\t// what the session looks like.\n\t\tthis.notifications = new NotificationPanel(\n\t\t\t() => this.ui.requestRender(),\n\t\t\t() => Math.floor(this.ui.terminal.rows / 3),\n\t\t);\n\t\t// Unset means nobody has chosen, so a short terminal may open compact; a\n\t\t// stored stop is obeyed at every size.\n\t\tthis.chromeLayout = new ChromeLayoutController(\n\t\t\t{\n\t\t\t\tfooterSlot: this.footerSlot,\n\t\t\t\ttasksSlot: this.tasksSlot,\n\t\t\t\t// Density reaches the built-in footer only. An extension that has\n\t\t\t\t// replaced it owns its own rows, and second-guessing their height\n\t\t\t\t// from out here would be this controller overreaching.\n\t\t\t\tsetFooterDensity: (density) => this.footer.setDensity(density),\n\t\t\t\tsetTasksDensity: (density) => this.taskPanel.setDensity(density),\n\t\t\t},\n\t\t\tthis.settingsManager.getChromeDensity() ?? (this.ui.terminal.rows < SMALL_TERMINAL_ROWS ? \"compact\" : \"full\"),\n\t\t);\n\t\tthis.chromeLayout.apply();\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\tnotify: (message, note) => this.notify(message, note),\n\t\t\tshowDialStep: (backward, message) => this.showDialStep(backward, 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\tshowRecord: (message) => this.showRecord(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 the tool-output view dial\n\t\tthis.toolOutputView = this.settingsManager.getToolOutputView();\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// Tips: one small thing about HooCode, on the band, at a moment that was\n\t\t// being spent anyway. `bandIsFree` is what keeps it the lowest-priority\n\t\t// thing on screen -- a tip is dropped rather than queued behind anything\n\t\t// the user actually caused.\n\t\tthis.tips = new TipsController({\n\t\t\tisEnabled: () => this.settingsManager.getTipsEnabled(),\n\t\t\tbandIsFree: () => this.notifications.showing === undefined,\n\t\t\tshow: (tip) => this.showTip(tip),\n\t\t\trotation: new TipRotation({\n\t\t\t\tseen: () => this.settingsManager.getSeenTips(),\n\t\t\t\tmarkSeen: (id) => this.settingsManager.markTipSeen(id),\n\t\t\t\tstarNudgeCount: () => this.settingsManager.getStarNudgeCount(),\n\t\t\t\tmarkStarNudge: () => this.settingsManager.recordStarNudge(),\n\t\t\t}),\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\tconst cdCommand = slashCommands.find((command) => command.name === \"cd\");\n\t\tif (cdCommand) {\n\t\t\tcdCommand.argumentHint = \"<path>\";\n\t\t\tcdCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {\n\t\t\t\tconst completions = this.commandExecutor.getChangeDirectoryCompletions(prefix);\n\t\t\t\treturn completions.length > 0 ? completions : null;\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}\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 search 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 search tool resolves rg 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// The rows nobody is using go here, directly below the banner, so a fresh\n\t\t// session opens with the logo on the first row and the prompt on the\n\t\t// last. Below the fill nothing ever has room held back from it, which\n\t\t// is what keeps the conversation and the prompt touching at every\n\t\t// session length; past a screenful the fill is 0 and the terminal's\n\t\t// own scroll holds the floor exactly as before.\n\t\tthis.ui.addChild(this.headerContainer);\n\t\tthis.ui.addChild(this.screenFill);\n\t\tthis.ui.setFlexSpacer(this.screenFill);\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\t// A function, not a value: the banner names the working directory, and\n\t\t\t// `/cd` moves it. `refreshBuiltInHeader` re-reads this after a move.\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\t// The owl is terminal-default ink: dark on light themes,\n\t\t\t\t\t\t\t// light on dark ones, with no theme logic of its own.\n\t\t\t\t\t\t\tglyph: (text) => theme.fg(\"text\", 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\t\t\t// A dial as one line in one shape: the key steps it forward, the same\n\t\t\t// key with shift steps it back. The banner is where that rule is first\n\t\t\t// read, and it is the rule covering the most-pressed keys in the app,\n\t\t\t// so the dials are listed together rather than scattered by topic.\n\t\t\t// appKeyLabel, not keyText: the thinking dial answers to shift+tab as well,\n\t\t\t// and splicing that into the middle of `alt+t/shift+alt+t` would hide the\n\t\t\t// one shape these six lines exist to show.\n\t\t\tconst dial = (forward: AppKeybinding, backward: AppKeybinding, subject: string) =>\n\t\t\t\trawKeyHint(`${appKeyLabel(forward)}/${appKeyLabel(backward)}`, `to step ${subject}`);\n\n\t\t\t// Grouped the way the map itself is (see core/keybindings.ts): by what you\n\t\t\t// are trying to do, not by what the widget is. A flat list of thirty\n\t\t\t// hints is a wall nobody reads; five short groups with a heading each is\n\t\t\t// something a person can skim once and place a key in later.\n\t\t\tconst group = (title: string) => theme.fg(\"dim\", `\\n${title}`);\n\t\t\tconst expandedInstructions = [\n\t\t\t\tgroup(\"Compose — the message in your hands\"),\n\t\t\t\thint(\"app.editor.external\", \"for external editor\"),\n\t\t\t\thint(\"app.input.voiceTranscribe\", \"to speak instead of type\"),\n\t\t\t\thint(\"app.clipboard.pasteImage\", \"to paste image\"),\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\trawKeyHint(\"drop files\", \"to attach\"),\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\n\t\t\t\tgroup(\"Steer — what the agent is before it runs\"),\n\t\t\t\tdial(\"app.mode.cycleForward\", \"app.mode.cycleBackward\", \"agent mode (ask/plan/build/debug)\"),\n\t\t\t\tdial(\"app.model.cycleForward\", \"app.model.cycleBackward\", \"model — /model to pick one\"),\n\t\t\t\tdial(\"app.thinking.cycleForward\", \"app.thinking.cycleBackward\", \"thinking level\"),\n\n\t\t\t\tgroup(\"Read — what you see of what it did\"),\n\t\t\t\tdial(\"app.view.cycleForward\", \"app.view.cycleBackward\", \"tool output (radar/peek/full)\"),\n\t\t\t\tdial(\"app.tasks.cycleForward\", \"app.tasks.cycleBackward\", \"task panel view\"),\n\t\t\t\thint(\"app.tools.expand\", \"to jump to full output and back\"),\n\t\t\t\thint(\"app.thinking.toggle\", \"to show or hide thinking\"),\n\t\t\t\t...(this.teamFocus.connected ? [hint(\"app.team.focus\", \"to focus team roster\")] : []),\n\n\t\t\t\tgroup(\"Go — sessions and places\"),\n\t\t\t\thint(\"app.session.resume\", \"to resume a session\"),\n\t\t\t\thint(\"app.session.changeDirectory\", \"to change working directory\"),\n\t\t\t\tdial(\"app.session.color.cycleForward\", \"app.session.color.cycleBackward\", \"session colour\"),\n\t\t\t\thint(\"app.settings.open\", \"for settings\"),\n\t\t\t\thint(\"app.hotkeys.open\", \"for all shortcuts\"),\n\n\t\t\t\tgroup(\"Flow — getting out, getting back\"),\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].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\t0,\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.tasksSlot);\n\t\tthis.ui.addChild(this.notifications);\n\t\tthis.ui.addChild(this.editorContainer);\n\t\tthis.ui.addChild(this.widgetContainerBelow);\n\t\tthis.ui.addChild(this.footerSlot);\n\t\tthis.ui.setFocus(this.editor);\n\n\t\tthis.setupKeyHandlers();\n\t\tthis.setupEditorSubmitHandler();\n\t\t// Capturing the mouse for the wheel is what stopped the terminal resolving\n\t\t// clicks on links, so the app answers them itself — with the same click, no\n\t\t// modifier, which is what it cost the user in the first place.\n\t\tthis.ui.onHyperlink = (url) => openUrl(url);\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// The tip controller's idle clock is \"no keystroke for a while\", so it needs\n\t\t// to see keystrokes. A pure observer: it returns undefined, so it neither\n\t\t// consumes input nor rewrites it, and it runs before any component has\n\t\t// decided what the key means -- which is right, because a key that turns out\n\t\t// to do nothing is still the user being present.\n\t\tthis.tipActivityUnsubscribe = this.ui.addInputListener(() => {\n\t\t\tthis.tips?.onActivity();\n\t\t\treturn undefined;\n\t\t});\n\n\t\t// Initialize extensions first so resources are shown before messages\n\t\tawait this.rebindCurrentSession({ renderResources: true });\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\t// The chip's fill and ink are baked in at build time, so a theme swap\n\t\t\t// has to rebuild it or it keeps the old theme's colours.\n\t\t\tthis.updateSessionChip();\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 * Refresh everything in the chrome that says *which session this is*: the\n\t * terminal title and the chip on the input box. Both read from the same\n\t * source, so they can never disagree about a rename.\n\t */\n\tprivate refreshSessionIdentity(): void {\n\t\tthis.updateTerminalTitle();\n\t\tthis.updateSessionChip();\n\t}\n\n\t/**\n\t * Rebuild the session chip and hand it to the editor, telling the footer to\n\t * stand down from showing the name itself. Cheap enough to call on any change\n\t * that could move the name, the colour, or the theme they are drawn in.\n\t */\n\tprivate updateSessionChip(): void {\n\t\tconst chip = renderSessionChip(this.sessionManager.getDisplayName(), this.sessionManager.getSessionColorSlot());\n\t\tthis.defaultEditor.topBorderLabel = chip;\n\t\tif (this.editor !== this.defaultEditor) {\n\t\t\tthis.editor.topBorderLabel = chip;\n\t\t}\n\t\tthis.footer.setSessionChipShown(chip !== undefined);\n\t\tthis.ui.requestRender();\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\t// A notice, not a warning: with no model there is nothing the app can\n\t\t\t// do, and this message is the only place the remedy is named. Ghosting\n\t\t\t// it after a few seconds would leave a first run staring at a prompt\n\t\t\t// that silently refuses every turn.\n\t\t\tconst { title, body } = this.splitBlockMessage(modelFallbackMessage);\n\t\t\tthis.showNotice(title, body);\n\t\t}\n\n\t\tvoid this.modelController.maybeWarnAboutAnthropicSubscriptionAuth();\n\t\tthis.maybeWarnAboutMissingWebSearchKey();\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\t// Cheap and fork-free: directory entries plus each installed plugin's\n\t\t\t\t// resolved canvas dirs. Computed inside the listing branch, so a quiet\n\t\t\t\t// startup pays nothing for it.\n\t\t\t\tgetCanvases: () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst cwd = this.sessionManager.getCwd();\n\t\t\t\t\t\tconst found = discoverCanvasExtensions(\n\t\t\t\t\t\t\tcanvasSearchRoots(cwd, os.homedir()),\n\t\t\t\t\t\t\tpluginCanvasExtensions(cwd),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tconst gated = gateCanvasExtensions(found, cwd);\n\t\t\t\t\t\treturn [\n\t\t\t\t\t\t\t...gated.runnable.map((e) => ({ id: e.id, scope: e.scope as string, withheld: false })),\n\t\t\t\t\t\t\t...gated.withheld.map((w) => ({\n\t\t\t\t\t\t\t\tid: w.extension.id,\n\t\t\t\t\t\t\t\tscope: w.extension.scope as string,\n\t\t\t\t\t\t\t\twithheld: true,\n\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t];\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\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\tthis.setupAutocompleteProvider();\n\t\tthis.setupExtensionShortcuts(this.session.extensionRunner);\n\t}\n\n\t/**\n\t * Push the current settings and session onto the chrome: keybindings, footer,\n\t * editor, cursor.\n\t *\n\t * Startup, a session swap (/new, /resume, /fork, /cd) and /reload all rebuild\n\t * the same things from disk, so they must all repaint the same way. They used\n\t * to do it with two hand-kept copies of this list, and the /reload copy was\n\t * missing pieces — a `compaction.enabled` edit left the footer still\n\t * promising auto-compaction. One list, called from both paths; the theme is\n\t * the second half, in `applySessionTheme`.\n\t */\n\tprivate applyRuntimeSettings(): void {\n\t\t// Keybindings first: what follows is drawn with the hints they name.\n\t\tthis.keybindings.reload();\n\t\tthis.footer.setSession(this.session);\n\t\tthis.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);\n\t\tthis.footer.setToolOutputView(this.toolOutputView);\n\t\tthis.footerDataProvider.setCwd(this.sessionManager.getCwd());\n\t\tthis.footerDataProvider.setSubagentEnabled(this.session.getActiveToolNames().includes(TASK_TOOL_NAME));\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\t// The prompt's border and the frame every surface that replaces the prompt\n\t\t// draws are one decision, so they are set in one place.\n\t\tsetInputFrameBorder(editorBorder);\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\t/**\n\t * Load the theme the settings name, and rebuild what holds colour baked in.\n\t *\n\t * Runs after extensions are bound (and after `session.reload()`), because a\n\t * `resources_discover` handler can contribute the very theme directory the\n\t * name resolves in — resolving it earlier would fall back to dark for anyone\n\t * whose theme ships with an extension.\n\t *\n\t * The banner is rebuilt rather than invalidated: a `Text` holds its string\n\t * with the escapes already in it, so invalidating only drops the line cache\n\t * and the old theme's colours come straight back. It also names the working\n\t * directory, which `/cd` moves.\n\t */\n\tprivate applySessionTheme(): void {\n\t\tsetRegisteredThemes(this.session.resourceLoader.getThemes().themes);\n\t\tconst themeName = this.settingsManager.getTheme();\n\t\tif (themeName) {\n\t\t\tconst themeResult = setTheme(themeName, 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}\n\t\tconst activeHeader = this.chrome.customHeader ?? this.builtInHeader;\n\t\tif (isExpandable(activeHeader)) {\n\t\t\tactiveHeader.setExpanded(this.getStartupExpansionState());\n\t\t}\n\t}\n\n\t/**\n\t * The half of the chrome refresh that has to wait for extensions to bind:\n\t * they can swap the editor out from under us, and the provider count is only\n\t * final once their `registerProvider` calls have run.\n\t */\n\tprivate async finishRuntimeSettings(): Promise<void> {\n\t\tawait this.modelController.updateAvailableProviderCount();\n\t\tthis.updateEditorBorderColor();\n\t\tthis.refreshSessionIdentity();\n\t}\n\n\t/**\n\t * Rebind the mode to whatever session the runtime now holds.\n\t *\n\t * `renderResources` belongs to startup alone: it renders into a transcript it\n\t * never clears, so the listing has to be drawn here. Every other caller\n\t * clears the transcript straight afterwards and draws the listing itself\n\t * through `renderCurrentSessionState`.\n\t */\n\tprivate async rebindCurrentSession(options?: { renderResources?: boolean }): Promise<void> {\n\t\tthis.unsubscribe?.();\n\t\tthis.unsubscribe = undefined;\n\t\tthis.applyRuntimeSettings();\n\t\tawait this.bindCurrentSessionExtensions();\n\t\tthis.applySessionTheme();\n\t\tif (options?.renderResources) {\n\t\t\tthis.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });\n\t\t\tthis.showStartupNoticesIfNeeded();\n\t\t}\n\t\tthis.subscribeToAgent();\n\t\tawait this.finishRuntimeSettings();\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\t/**\n\t * Drop every view-layer reference into the transcript that is about to be\n\t * rebuilt. Shared by the session swaps and by /reload: a component that\n\t * survives the clear is detached, so anything still pointing at it (the open\n\t * chain the next tool call would join, the block the unfold key peels back)\n\t * would be writing to a frame nobody renders.\n\t */\n\tprivate resetTranscriptView(): void {\n\t\tthis.chatContainer.clear();\n\t\tthis.openChain = undefined;\n\t\tthis.latestToolBlock = undefined;\n\t\tthis.latestChain = undefined;\n\t\tthis.streamingComponent = undefined;\n\t\tthis.streamingMessage = undefined;\n\t\tthis.pendingTools.clear();\n\t}\n\n\t/**\n\t * Reset the transcript to whatever the just-swapped session holds.\n\t *\n\t * Every caller — /new, /resume, /fork, /cd, /import — reaches here after the\n\t * runtime has rebound extensions. Drawing the listing here rather than in the\n\t * rebind is what makes the surface common: one call site, so a session change\n\t * of any kind reports the same capabilities /reload does, and neither path\n\t * draws the listing twice.\n\t */\n\tprivate renderCurrentSessionState(): void {\n\t\tthis.resetTranscriptView();\n\t\t// Queues belong to the session that is going away, not to the new one.\n\t\tthis.pendingMessagesContainer.clear();\n\t\tthis.messageQueue.resetCompactionQueue();\n\t\tthis.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });\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 = this.transcriptToolBlocks().filter((block) => block.isFreezable());\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.refreshSessionIdentity();\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.updateSessionChip();\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.cycleForward\", () => this.cycleThinkingLevel(\"forward\"));\n\t\tthis.defaultEditor.onAction(\"app.thinking.cycleBackward\", () => this.cycleThinkingLevel(\"backward\"));\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.jumpToFullView());\n\t\tthis.defaultEditor.onAction(\"app.view.cycleForward\", () => this.cycleToolOutputView(\"forward\"));\n\t\tthis.defaultEditor.onAction(\"app.view.cycleBackward\", () => this.cycleToolOutputView(\"backward\"));\n\t\tthis.defaultEditor.onAction(\"app.thinking.toggle\", () => this.toggleThinkingBlockVisibility());\n\t\tthis.defaultEditor.onAction(\"app.chrome.cycleForward\", () => this.cycleChromeDensity(\"forward\"));\n\t\tthis.defaultEditor.onAction(\"app.chrome.cycleBackward\", () => this.cycleChromeDensity(\"backward\"));\n\t\tthis.defaultEditor.onAction(\"app.tasks.cycleForward\", () => {\n\t\t\tthis.showDialStep(\"app.tasks.cycleBackward\", `Task panel: ${this.taskPanel.cycleView(\"forward\")}`);\n\t\t});\n\t\tthis.defaultEditor.onAction(\"app.tasks.cycleBackward\", () => {\n\t\t\tthis.showDialStep(\"app.tasks.cycleForward\", `Task panel: ${this.taskPanel.cycleView(\"backward\")}`);\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.clipboard.copyMessage\", () => void this.commandExecutor.handleCopy());\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\t\tthis.defaultEditor.onAction(\"app.session.changeDirectory\", () => {\n\t\t\t// Prefill rather than act: a directory change needs a target, and the\n\t\t\t// editor's own path completion is already the best way to name one.\n\t\t\tthis.editor.setText(\"/cd \");\n\t\t\tthis.ui.requestRender();\n\t\t});\n\t\tthis.defaultEditor.onAction(\"app.session.color.cycleForward\", () => this.cycleSessionColor(\"forward\"));\n\t\tthis.defaultEditor.onAction(\"app.session.color.cycleBackward\", () => this.cycleSessionColor(\"backward\"));\n\t\tthis.defaultEditor.onAction(\"app.settings.open\", () => this.showSettingsSelector());\n\t\tthis.defaultEditor.onAction(\"app.hotkeys.open\", () => this.commandExecutor.handleHotkeys());\n\t\tthis.defaultEditor.onAction(\"app.mode.cycleForward\", () => void this.cycleAgentMode(\"forward\"));\n\t\tthis.defaultEditor.onAction(\"app.mode.cycleBackward\", () => void this.cycleAgentMode(\"backward\"));\n\n\t\t// Scrolling the transcript: the four prompt keys, plus the listener that\n\t\t// captures the rest once the view is pinned. See `scroll-view.ts`.\n\t\tthis.removeScrollView?.();\n\t\tthis.removeScrollView = installScrollView(this.ui, this.defaultEditor, this.keybindings, {\n\t\t\tchat: this.chatContainer,\n\t\t\tisUserMessage: (child) => child instanceof UserMessageComponent,\n\t\t});\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// The completion list is why the prompt grew, so the footer lends it the\n\t\t// rows — and takes them back the moment the list closes, however it closed.\n\t\tthis.defaultEditor.onAutocompleteVisibilityChange = (visible) => {\n\t\t\tif (this.chromeLayout.setAutocompleteOpen(visible)) this.ui.requestRender();\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\twithArgs: true,\n\t\t\t\t\trun: async (text: string) => {\n\t\t\t\t\t\tawait this.commandExecutor.handleCopy(text);\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\"/color\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: (text: string) => {\n\t\t\t\t\t\t// An argument sets the slot outright; bare `/color` opens the\n\t\t\t\t\t\t// swatches, since a slot number is not something anyone knows\n\t\t\t\t\t\t// by heart.\n\t\t\t\t\t\tif (!this.commandExecutor.handleColor(text)) {\n\t\t\t\t\t\t\tthis.showSessionColorSelector();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclearEditor();\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"/chrome\": {\n\t\t\t\t\twithArgs: true,\n\t\t\t\t\trun: (text: string) => {\n\t\t\t\t\t\tthis.handleChromeCommand(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\"/cd\": {\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.handleChangeDirectory(text);\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\t// The last chain of a turn has nothing after it to close it, so it\n\t\t\t// settles here. A turn that did not end cleanly leaves its chain in the\n\t\t\t// running rendering plus a marker rather than claiming an outcome.\n\t\t\tthis.closeOpenChain(this.turnStopReason === \"stop\" ? \"done\" : \"interrupted\");\n\t\t\tthis.showTurnCost();\n\t\t\tthis.settleDanglingPlanItems();\n\t\t\tthis.chime?.onTurnComplete({ aborted: this.turnStopReason === \"aborted\" });\n\t\t\tthis.tips?.onTurnEnd();\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.tips?.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.refreshSessionIdentity();\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.thinkingDisplayForView(),\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.sawTextInCurrentMessage = false;\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\t// The agent speaking ends the run its previous calls formed. Tool\n\t\t\t\t\t// calls later in this same message open a fresh chain.\n\t\t\t\t\tif (!this.sawTextInCurrentMessage) {\n\t\t\t\t\t\tconst spoke = this.streamingMessage.content.some(\n\t\t\t\t\t\t\t(content) => content.type === \"text\" && content.text.trim() !== \"\",\n\t\t\t\t\t\t);\n\t\t\t\t\t\tif (spoke) {\n\t\t\t\t\t\t\tthis.sawTextInCurrentMessage = true;\n\t\t\t\t\t\t\tthis.closeOpenChain(\"done\");\n\t\t\t\t\t\t}\n\t\t\t\t\t}\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\tview: this.toolOutputView,\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\tthis.attachToolBlock(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\tview: this.toolOutputView,\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\tthis.attachToolBlock(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\t/**\n\t * Report a dial step, and name the key that steps it back — once.\n\t *\n\t * The instant after someone moves a dial is the one moment they are primed to\n\t * learn its other half: they have just used one direction and can feel the\n\t * missing one. So the reverse rides along with the first step of each dial in\n\t * a session and never again. A hint that repeats forever stops being read,\n\t * and its cost falls entirely on the people who already know it.\n\t *\n\t * All six dials come through here now. Three of them used not to, because\n\t * the answer was already visible somewhere — the mode chip and the view\n\t * glyph change in place in the footer, the task panel prints its own key in\n\t * its header — and a permanent transcript line was too much to pay for\n\t * repeating it. The band is not permanent, so that trade is gone: a dial\n\t * that says nothing when you step it is a dial you press twice to be sure.\n\t */\n\tprivate showDialStep(backward: AppKeybinding, message: string): void {\n\t\tconst taught = this.dialReverseTaught.has(backward);\n\t\tthis.dialReverseTaught.add(backward);\n\t\t// Every step of one dial is one subject, so the band shows the stop you are\n\t\t// on rather than queueing the stops you went through. The id names the dial\n\t\t// once its direction is taken off: stepping forward and stepping back are\n\t\t// the same knob and must not read as two.\n\t\tconst topic = backward.replace(/\\.cycle(Forward|Backward)$/, \"\");\n\t\tthis.notify(message, taught ? undefined : `${keyText(backward)} steps back`, topic);\n\t}\n\n\t/**\n\t * A glimpse of something that just changed, on the band above the prompt.\n\t *\n\t * `topic` names what the glimpse is *of*, for anything that can be stepped:\n\t * a second reading replaces the first wherever it is, on screen included.\n\t */\n\tnotify(message: string, note?: string, topic?: string): void {\n\t\tconst { title, body } = this.splitBlockMessage(message);\n\t\tthis.notifications.notify(\"info\", title, body, note, { topic });\n\t}\n\n\t/**\n\t * A tip, on the band.\n\t *\n\t * Deliberately a `notify` like any other rather than a fourth kind of\n\t * message: a tip earns no special treatment on screen, and the one thing it\n\t * does get -- a `topic` -- exists so that a second tip can never stack behind\n\t * the first. It replaces it, or it does not appear.\n\t *\n\t * The TTL is longer than a glimpse's because a glimpse confirms something the\n\t * user just did, which they only have to recognise, while a tip is telling\n\t * them something new, which they have to read.\n\t */\n\tprivate showTip(tip: Tip): void {\n\t\tconst { title, body, note } = renderTip(tip);\n\t\tthis.notifications.notify(\"info\", title, body, note, {\n\t\t\tttlMs: Math.min(12_000, 6_000 + body.length * 1_500),\n\t\t\ttopic: \"tip\",\n\t\t});\n\t}\n\n\t/**\n\t * What a command has to say for itself — on the band, not in the transcript.\n\t *\n\t * This used to write a dimmed row into the conversation, and every command\n\t * that reported anything wrote one: `Mode set to \"build\"`, `Cloned to new\n\t * session`, `Copied last agent message`, the plugin catalogue, what `/learn`\n\t * read. All of them true for a moment and litter for the rest of the\n\t * session, sitting between the messages the transcript exists to keep.\n\t *\n\t * The band is where they belong: it is directly above the prompt, where the\n\t * user's eye already is after typing a command, and it clears itself. What\n\t * cannot be reconstructed once it fades does not come through here at all —\n\t * it goes to `showRecord`, which still writes the row.\n\t */\n\tprivate showStatus(message: string): void {\n\t\tconst { title, body } = this.splitBlockMessage(message);\n\t\tthis.notifications.notify(\"info\", title, body);\n\t}\n\n\t/**\n\t * The status rows that stay: a share URL, an export path, a saved-to path.\n\t *\n\t * The one exception to the band, and the test for it is not importance but\n\t * whether the screen can still answer the question in a minute's time. A\n\t * mode, a model, a session name are all on the footer or the prompt border\n\t * afterwards; a gist URL is nowhere but here, and a notification that fades\n\t * with a URL on it is a notification that cost the user the thing they asked\n\t * for.\n\t */\n\tprivate showRecord(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.thinkingDisplayForView(),\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\tlet lastAssistantStopReason: AssistantMessage[\"stopReason\"] | undefined;\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\t// Same boundary the live path uses: the agent speaking ends the run\n\t\t\t\t// its previous calls formed. Rebuilding history has to reproduce it,\n\t\t\t\t// or a resumed session would show one chain where it lived through\n\t\t\t\t// several.\n\t\t\t\tconst spoke = message.content.some((content) => content.type === \"text\" && content.text.trim() !== \"\");\n\t\t\t\tif (spoke) this.closeOpenChain(\"done\");\n\t\t\t\tlastAssistantStopReason = message.stopReason;\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\tview: this.toolOutputView,\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\tthis.attachToolBlock(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\t// History has no live state: a chain left open at the end of the rebuild is\n\t\t// finished, and how the last assistant message ended says whether it\n\t\t// finished cleanly. Tool calls still awaiting results are the exception —\n\t\t// this is a resumed run in flight, so its chain stays open for them.\n\t\tif (renderedPendingTools.size === 0) {\n\t\t\tthis.closeOpenChain(lastAssistantStopReason === \"stop\" ? \"done\" : \"interrupted\");\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(direction: \"forward\" | \"backward\"): void {\n\t\tconst newLevel = this.session.cycleThinkingLevel(direction);\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.showDialStep(\"app.thinking.cycleBackward\", `Thinking level: ${newLevel}`);\n\t\t}\n\t}\n\n\t/**\n\t * Put a tool block into the transcript, extending the open chain or starting\n\t * one. Every path that renders a tool call comes through here — live\n\t * streaming, execution start, and rebuilding history — so a chain cannot be\n\t * assembled correctly in one place and forgotten in another.\n\t */\n\tprivate attachToolBlock(block: ToolExecutionComponent): void {\n\t\tif (!this.openChain || !this.openChain.isOpen) {\n\t\t\tthis.openChain = new ToolChainComponent(this.toolOutputView);\n\t\t\tthis.chatContainer.addChild(this.openChain);\n\t\t}\n\t\tthis.openChain.add(block);\n\t\t// The newest call carries radar's marker stroke. This is the one place\n\t\t// every tool block is attached, so it is the one place that can know\n\t\t// which block is newest without two paths disagreeing about it. The run\n\t\t// it belongs to is marked as well: radar folds a run of more than one\n\t\t// call to a single line, and that line is what the view usually shows.\n\t\tthis.latestToolBlock?.setLatest(false);\n\t\tblock.setLatest(true);\n\t\tthis.latestToolBlock = block;\n\t\tif (this.latestChain !== this.openChain) {\n\t\t\tthis.latestChain?.setLatest(false);\n\t\t\tthis.openChain.setLatest(true);\n\t\t\tthis.latestChain = this.openChain;\n\t\t}\n\t}\n\n\t/**\n\t * Settle the open chain.\n\t *\n\t * Called when the agent speaks (the run it was doing is over, whatever comes\n\t * next is a new one) and when the turn settles. Closing on the agent's next\n\t * words rather than only at turn end is what keeps the flip cheap: the line\n\t * is still at the bottom of the screen, so the TUI rewrites it in place\n\t * instead of taking the full-redraw path that clears terminal scrollback.\n\t */\n\tprivate closeOpenChain(outcome: \"done\" | \"interrupted\"): void {\n\t\tif (!this.openChain) return;\n\t\tif (this.openChain.isEmpty) {\n\t\t\tthis.chatContainer.removeChild(this.openChain);\n\t\t} else {\n\t\t\tthis.openChain.close(outcome);\n\t\t}\n\t\tthis.openChain = undefined;\n\t\tthis.ui.requestRender();\n\t}\n\n\t/** Every tool block in the transcript, in order, across all chains. */\n\tprivate transcriptToolBlocks(): ToolExecutionComponent[] {\n\t\tconst blocks: ToolExecutionComponent[] = [];\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof ToolChainComponent) blocks.push(...child.toolBlocks);\n\t\t\telse if (child instanceof ToolExecutionComponent) blocks.push(child);\n\t\t}\n\t\treturn blocks;\n\t}\n\n\t/**\n\t * Jump to the full view, or back to where the jump started.\n\t *\n\t * The dial's top stop is the whole result with nothing trimmed, which is what\n\t * \"expand\" always meant — so expand is not a second state layered over the\n\t * dial any more, it is a jump along it. From any stop the first press lands\n\t * on `full`; the next press returns to the stop you came from (`peek` if the\n\t * jump started there, so the key is never a no-op).\n\t *\n\t * Only `app.view.cycleForward` writes the setting. This key deliberately does\n\t * not: the dial is the decision you keep, the jump is the look you take.\n\t */\n\tprivate jumpToFullView(): void {\n\t\tif (this.toolOutputView === MAX_TOOL_OUTPUT_VIEW) {\n\t\t\tconst back = this.viewBeforeJump ?? DEFAULT_TOOL_OUTPUT_VIEW;\n\t\t\tthis.viewBeforeJump = undefined;\n\t\t\tthis.applyToolOutputView(back, { persist: false });\n\t\t\treturn;\n\t\t}\n\t\tthis.viewBeforeJump = this.toolOutputView;\n\t\tthis.applyToolOutputView(MAX_TOOL_OUTPUT_VIEW, { persist: false });\n\t}\n\n\t/**\n\t * Move the view dial one stop; the footer shows where it landed.\n\t *\n\t * This is the persistent decision (\"how much do I ever want to see\"), which\n\t * is why it saves. `app.tools.expand` jumps along the same dial without\n\t * saving, which is what keeps \"the look I am taking now\" from overwriting\n\t * \"the view I live in\".\n\t */\n\tprivate cycleToolOutputView(direction: \"forward\" | \"backward\"): void {\n\t\tconst next = cycleToolOutputView(this.toolOutputView, direction);\n\t\tthis.applyToolOutputView(next);\n\t\tthis.showDialStep(\n\t\t\tdirection === \"forward\" ? \"app.view.cycleBackward\" : \"app.view.cycleForward\",\n\t\t\t`Tool output: ${next}`,\n\t\t);\n\t}\n\n\t/**\n\t * How much of a thinking trace this view shows.\n\t *\n\t * Radar drops them outright, regardless of the setting. The dial is one\n\t * question asked once — \"how much do I ever want to see\" — and a view whose\n\t * whole job is to fold a run of tool calls down to a row cannot then spend\n\t * forty lines on the reasoning that led to it. Folding to the label is not\n\t * enough either: a message that only thought and called tools has nothing\n\t * else on screen once its calls join the chain, so its label would stand\n\t * alone under the summary and one row per chain would become one row plus a\n\t * label per call.\n\t *\n\t * It is also what keeps a running chain on screen. A chain stays open across\n\t * a thinking block (thinking is not text, so it is not a chain boundary), and\n\t * a trace rendering below pushes the chain's own summary line off the top —\n\t * where every subsequent call rewrites a line above the viewport and forces\n\t * the full redraw that clears terminal scrollback.\n\t */\n\tprivate thinkingDisplayForView(): ThinkingDisplay {\n\t\tif (this.toolOutputView === \"radar\") return \"omit\";\n\t\treturn this.hideThinkingBlock ? \"label\" : \"full\";\n\t}\n\n\tprivate applyToolOutputView(view: ToolOutputView, options: { persist?: boolean } = {}): void {\n\t\tconst previousThinking = this.thinkingDisplayForView();\n\t\tconst wasExpanded = this.toolOutputExpanded;\n\t\tthis.toolOutputView = view;\n\t\tif (options.persist !== false) {\n\t\t\tthis.settingsManager.setToolOutputView(view);\n\t\t\t// Anything that moves the dial deliberately — the cycle key, the\n\t\t\t// settings pane — is a new home stop, so the jump has nowhere stale to\n\t\t\t// return to.\n\t\t\tthis.viewBeforeJump = undefined;\n\t\t}\n\t\tthis.footer.setToolOutputView(view);\n\t\tconst thinking = this.thinkingDisplayForView();\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof ToolChainComponent) child.setView(view);\n\t\t\telse if (child instanceof ToolExecutionComponent) child.setView(view);\n\t\t\telse if (child instanceof AssistantMessageComponent && thinking !== previousThinking) {\n\t\t\t\tchild.setThinkingDisplay(thinking);\n\t\t\t}\n\t\t}\n\t\tif (this.streamingComponent && thinking !== previousThinking) {\n\t\t\tthis.streamingComponent.setThinkingDisplay(thinking);\n\t\t}\n\t\t// The header and the summary blocks are not tool output, but \"full\" means\n\t\t// nothing is held back, so they open and close with the dial's top stop.\n\t\tif (this.toolOutputExpanded !== wasExpanded) this.setToolsExpanded(this.toolOutputExpanded);\n\t\tthis.ui.requestRender();\n\t}\n\n\t/**\n\t * Open or close everything that folds but is not a tool call — the header,\n\t * compaction and branch summaries, skill blocks. Tool blocks and chains are\n\t * not in this sweep: they take the view dial directly.\n\t */\n\tprivate setToolsExpanded(expanded: boolean): void {\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\t/**\n\t * Step the agent mode one along (ask → plan → build → debug → ask).\n\t *\n\t * Mode is the product's central guardrail and sits first in the footer, yet\n\t * it was reachable only by typing `/mode <name>`. The list comes from the\n\t * mode command's own argument completions rather than a copy kept here, so\n\t * a project that adds a mode gets it in the rotation for free; if the command\n\t * is missing (a `--light` run strips the extension), the key says so instead\n\t * of guessing.\n\t */\n\tprivate async cycleAgentMode(direction: \"forward\" | \"backward\"): Promise<void> {\n\t\tconst command = this.session.extensionRunner.getCommand(\"mode\");\n\t\tif (!command) {\n\t\t\tthis.showWarning(\"Modes are not available in this session\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst completions = (await command.getArgumentCompletions?.(\"\")) ?? [];\n\t\tconst modes = completions.map((item) => item.value);\n\t\tif (modes.length === 0) {\n\t\t\tthis.showWarning(\"No modes are configured\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst current = this.footerDataProvider.getActiveMode();\n\t\t// indexOf is -1 when the active mode is not in the list; stepping from\n\t\t// there lands on the first mode going forward and the last going back,\n\t\t// which is the useful answer either way.\n\t\tconst index = modes.indexOf(current);\n\t\tconst step = direction === \"forward\" ? 1 : -1;\n\t\tconst next = modes[(index + step + modes.length) % modes.length];\n\t\tawait this.session.prompt(`/mode ${next}`);\n\t\t// Read the mode back rather than announcing the one that was asked for:\n\t\t// the command owns whether it took, and a glimpse naming a mode the\n\t\t// session is not in is worse than no glimpse at all. A throw above never\n\t\t// reaches here, which is the same rule.\n\t\tconst landed = this.footerDataProvider.getActiveMode();\n\t\tthis.showDialStep(\n\t\t\tdirection === \"forward\" ? \"app.mode.cycleBackward\" : \"app.mode.cycleForward\",\n\t\t\t`Mode: ${landed || next}`,\n\t\t);\n\t}\n\n\t/**\n\t * Step the session chip to the next colour slot, no picker involved.\n\t *\n\t * The picker exists for choosing a colour; this exists for telling two\n\t * terminals apart, which is a different job — you press the key until the two\n\t * chips stop looking alike, and the chip repaints on each press because\n\t * setSessionColor writes the slot and the session_info event refreshes the\n\t * identity. The status line names the slot as well, since the point of the\n\t * names is that `/color <name>` takes them.\n\t */\n\tprivate cycleSessionColor(direction: \"forward\" | \"backward\"): void {\n\t\tconst slot = cycleSessionColorSlot(this.sessionManager.getSessionColorSlot(), direction);\n\t\tthis.session.setSessionColor(slot);\n\t\tconst name = sessionColorName(slot);\n\t\tthis.showDialStep(\"app.session.color.cycleBackward\", `Session color: ${name ?? slot}`);\n\t}\n\n\t/**\n\t * Step the chrome dial and say where it landed.\n\t *\n\t * The step is announced like every other dial's, and it has to be: at `bare`\n\t * the footer that normally shows a dial's new stop is the very thing that\n\t * just went away, so the status line is the only place left to say what\n\t * happened. Without it the screen would simply lose two rows with no\n\t * explanation — which is how a feature gets reported as a glitch.\n\t */\n\tprivate cycleChromeDensity(direction: \"forward\" | \"backward\"): void {\n\t\tconst density = this.chromeLayout.cycleDensity(direction);\n\t\tthis.settingsManager.setChromeDensity(density);\n\t\tthis.ui.requestRender();\n\t\tthis.showDialStep(\"app.chrome.cycleBackward\", `Chrome: ${density}`);\n\t}\n\n\t/**\n\t * `/chrome <stop>`, and the reason the dial is not reachable by key alone.\n\t *\n\t * macOS Terminal.app composes characters instead of sending alt, so a dial\n\t * whose only key is `alt+<letter>` is *unreachable* there. That is survivable\n\t * for a cosmetic dial and not for this one: the stop persists globally, so\n\t * someone who picked `bare` on one machine would open on that terminal with\n\t * no footer, no ledger and no way to ask for them back. Every dial that can\n\t * strand a setting has a slash command; this is that command.\n\t */\n\tprivate handleChromeCommand(text: string): void {\n\t\tconst argument = text\n\t\t\t.replace(/^\\/chrome\\s*/, \"\")\n\t\t\t.trim()\n\t\t\t.toLowerCase();\n\t\tif (argument.length === 0) {\n\t\t\tthis.showStatus(`Chrome: ${this.chromeLayout.density} — ${CHROME_DENSITIES.join(\" · \")}`);\n\t\t\treturn;\n\t\t}\n\t\tif (!isChromeDensity(argument)) {\n\t\t\tthis.showError(`Unknown chrome density \"${argument}\". Try: ${CHROME_DENSITIES.join(\", \")}`);\n\t\t\treturn;\n\t\t}\n\t\tthis.chromeLayout.setDensity(argument);\n\t\tthis.settingsManager.setChromeDensity(argument);\n\t\tthis.ui.requestRender();\n\t\tthis.showStatus(`Chrome: ${argument}`);\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.setThinkingDisplay(this.thinkingDisplayForView());\n\t\t\tthis.streamingComponent.updateContent(this.streamingMessage);\n\t\t\tthis.chatContainer.addChild(this.streamingComponent);\n\t\t}\n\n\t\t// Radar overrides the setting, so say so rather than claiming a change the\n\t\t// screen does not show.\n\t\tthis.showStatus(\n\t\t\t!this.hideThinkingBlock && this.toolOutputView === \"radar\"\n\t\t\t\t? \"Thinking blocks: visible (radar hides them)\"\n\t\t\t\t: `Thinking blocks: ${this.hideThinkingBlock ? \"hidden\" : \"visible\"}`,\n\t\t);\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);\n\t\tapplyBlockFill(box, bg);\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\t// A wait measured in days is a dead end, not a delay, and the block that\n\t\t// reports it is the only place the user finds out. Say what to do about\n\t\t// it there, rather than leaving them to reread a 429 and guess.\n\t\tif (isLongRetryDelayError(errorMessage)) {\n\t\t\tbody.push(\"Switch to another model with /model, or wait for the provider's quota to reset.\");\n\t\t}\n\t\tthis.showBlock(\"toolErrorBg\", \"error\", `Error: ${title}`, body);\n\t}\n\n\t/**\n\t * A warning, on the band, for as long as a warning is worth.\n\t *\n\t * It used to be a filled block in the transcript, which outlived what it was\n\t * warning about by the whole session: \"No previous directory to return to\"\n\t * is worth a glance and then nothing, and a permanent block for it is a\n\t * permanent hole in the conversation. Warnings queue rather than replace, so\n\t * a run of them at startup is still shown one after another.\n\t *\n\t * `showNotice` is the exception that proves this rule — see below.\n\t */\n\tshowWarning(warningMessage: string): void {\n\t\tconst { title, body } = this.splitBlockMessage(warningMessage);\n\t\tthis.notifications.notify(\"warning\", title, body);\n\t}\n\n\t/**\n\t * A warning the user pays for if they miss it.\n\t *\n\t * The one thing still drawn as a filled block in the transcript, and the\n\t * reason is in the name: these say money is being spent on the wrong\n\t * account, or a tool is running on a backend the user did not choose.\n\t * Someone who looked away for four seconds has to still be able to find it,\n\t * which is exactly what the band cannot promise.\n\t */\n\tshowNotice(title: string, body: string[]): void {\n\t\tthis.showBlock(\"warningBg\", \"warning\", title, body);\n\t}\n\n\t/**\n\t * Say once, at startup, that `websearch` is running on the keyless backend.\n\t * Shown only while the tool is actually active and no keyed provider is\n\t * configured; silenced by `warnings.websearchApiKey`.\n\t */\n\tprivate maybeWarnAboutMissingWebSearchKey(): void {\n\t\tconst notice = websearchApiKeyNotice({\n\t\t\tactiveToolNames: this.session.getActiveToolNames(),\n\t\t\twarnings: this.settingsManager.getWarnings(),\n\t\t\tsearch: this.settingsManager.getWebtoolsSearch(),\n\t\t});\n\t\tif (notice) this.showNotice(notice.title, notice.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\t// Price every tool the pane lists, including the ones that are off: what\n\t\t\t// the row needs to show is what turning it back on will cost per turn. A\n\t\t\t// tool disabled before launch has no schema in this session, so it has no\n\t\t\t// price to show either.\n\t\t\tconst schemaTokens = new Map(\n\t\t\t\tthis.session.getAllTools().map((tool) => [tool.name, measureToolSchemaTokens(tool)]),\n\t\t\t);\n\t\t\tconst toolToggles = toolToggleNames.map((name) => ({\n\t\t\t\tname,\n\t\t\t\tenabled: !disabledToolNames.has(name),\n\t\t\t\ttokens: schemaTokens.get(name),\n\t\t\t}));\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.getEnableSemanticIndex(),\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\t// Resolved fresh on every open, never downloaded: a binary that\n\t\t\t\t\t// arrived since the last open (a startup fetch, a `brew install`)\n\t\t\t\t\t// shows as present without a restart.\n\t\t\t\t\texternalTools: describeExternalTools(),\n\t\t\t\t\tflags,\n\t\t\t\t\ttoolOutputView: this.toolOutputView,\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\tlight: this.settingsManager.getLight(),\n\t\t\t\t\tpluginInstallScope: this.settingsManager.getPluginInstallScope(),\n\t\t\t\t\tenablePluginTools: this.settingsManager.getEnablePluginTools(),\n\t\t\t\t\t// Rows write the user settings file, so a key the project file also\n\t\t\t\t\t// sets is merged back over it next session. The pane says which.\n\t\t\t\t\tprojectPinnedSettings: Object.keys(this.settingsManager.getProjectSettings()),\n\t\t\t\t\t// The targets actually in force, so a session launched with --platform\n\t\t\t\t\t// shows what it is writing rather than the (possibly unset) setting.\n\t\t\t\t\tplatform: getWorkspacePlatforms() ?? [],\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\ttipsEnabled: this.settingsManager.getTipsEnabled(),\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\t// Re-measured on every change so the pane can price what a toggle did:\n\t\t\t\t\t// the session rebuilds its system prompt as tools come and go, and\n\t\t\t\t\t// this reads that live state rather than a snapshot.\n\t\t\t\t\tmeasureTokenSurface: () => measurePromptSurface(this.session),\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.setEnableSemanticIndex(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\tonToolOutputViewChange: (view) => {\n\t\t\t\t\t\tthis.applyToolOutputView(view);\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\tonLightChange: (enabled) => {\n\t\t\t\t\t\t// Picks the tool set and the system prompt at startup, so it binds\n\t\t\t\t\t\t// on the next session rather than this one.\n\t\t\t\t\t\tthis.settingsManager.setLight(enabled);\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\tonEnablePluginToolsChange: (enabled) => {\n\t\t\t\t\t\t// Master switch for the autonomous plugin system. The lifecycle\n\t\t\t\t\t\t// tools are attached when the session is built, so they arrive on\n\t\t\t\t\t\t// the next session; the reuse nudge re-reads settings.json per\n\t\t\t\t\t\t// check and follows immediately.\n\t\t\t\t\t\tthis.settingsManager.setEnablePluginTools(enabled);\n\t\t\t\t\t},\n\t\t\t\t\tonPlatformChange: (platforms) => {\n\t\t\t\t\t\t// Persist for later sessions, then update the process-wide session\n\t\t\t\t\t\t// state so plugin authoring and the /new-* scaffolds pick the new\n\t\t\t\t\t\t// layout up in this session too.\n\t\t\t\t\t\tthis.settingsManager.setPlatform(platforms);\n\t\t\t\t\t\tsetPlatforms(platforms);\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\tconst effective = this.thinkingDisplayForView();\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.setThinkingDisplay(effective);\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\tonTipsEnabledChange: (enabled) => {\n\t\t\t\t\t\t// The controller reads this fresh on every offer, so switching it\n\t\t\t\t\t\t// off silences the tip that was already scheduled, not just the\n\t\t\t\t\t\t// next one.\n\t\t\t\t\t\tthis.settingsManager.setTipsEnabled(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\tsetInputFrameBorder(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\t/**\n\t * Swatch picker for the session chip's colour. Moving through the list\n\t * repaints the live chip, so the choice is made by looking at the real thing\n\t * in the real theme rather than at a preview of it.\n\t */\n\tprivate showSessionColorSelector(): void {\n\t\tconst originalSlot = this.sessionManager.getSessionColorSlot();\n\t\tthis.showSelector((done) => {\n\t\t\tconst previewSlot = (slot: number) => {\n\t\t\t\tconst chip = renderSessionChip(this.sessionManager.getDisplayName(), slot);\n\t\t\t\tthis.defaultEditor.topBorderLabel = chip;\n\t\t\t\tif (this.editor !== this.defaultEditor) {\n\t\t\t\t\tthis.editor.topBorderLabel = chip;\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t};\n\t\t\tconst selector = new SessionColorSelectorComponent(\n\t\t\t\tthis.sessionManager.getDisplayName(),\n\t\t\t\toriginalSlot,\n\t\t\t\t(slot) => {\n\t\t\t\t\tdone();\n\t\t\t\t\tthis.session.setSessionColor(slot);\n\t\t\t\t\tthis.showStatus(`Session color: ${sessionColorName(slot) ?? slot}`);\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tdone();\n\t\t\t\t\t// Cancelling must put back the colour the session actually has,\n\t\t\t\t\t// not leave it wearing the last swatch the cursor passed over.\n\t\t\t\t\tpreviewSlot(originalSlot);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t},\n\t\t\t\tpreviewSlot,\n\t\t\t);\n\t\t\treturn { component: selector, focus: selector.getSelectList() };\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({ name: 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(\n\t\t\tnew Text(theme.fg(\"muted\", \"Reloading keybindings, extensions, skills, prompts, themes...\"), 1, 0),\n\t\t);\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\t// The same chrome refresh a session swap runs, in the same order, so the\n\t\t\t// two paths cannot report different capabilities or settings.\n\t\t\tthis.applyRuntimeSettings();\n\t\t\tthis.applySessionTheme();\n\t\t\tthis.setupAutocompleteProvider();\n\t\t\tthis.setupExtensionShortcuts(this.session.extensionRunner);\n\t\t\t// The session itself is not replaced, so the transcript is replayed\n\t\t\t// rather than rebuilt from a new one — but the view state pointing into\n\t\t\t// it is just as stale, and gets the same reset.\n\t\t\tthis.resetTranscriptView();\n\t\t\tthis.renderSessionContext(this.sessionManager.buildSessionContext());\n\t\t\tdismissReloadBox(this.editor as Component);\n\t\t\t// Below the replayed transcript, not above it: /reload is asked for to\n\t\t\t// see what just loaded, and that answer belongs where you are looking.\n\t\t\tthis.showLoadedResources({\n\t\t\t\tforce: false,\n\t\t\t\tshowDiagnosticsWhenQuiet: true,\n\t\t\t});\n\t\t\tawait this.finishRuntimeSettings();\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.tipActivityUnsubscribe?.();\n\t\tthis.tipActivityUnsubscribe = undefined;\n\t\tthis.tips?.stop();\n\t\tthis.notifications.stop();\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"]}