@oh-my-pi/pi-coding-agent 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +2 -1
  3. package/docs/sdk.md +0 -3
  4. package/package.json +6 -5
  5. package/src/config.ts +9 -0
  6. package/src/core/agent-storage.ts +450 -0
  7. package/src/core/auth-storage.ts +111 -184
  8. package/src/core/compaction/branch-summarization.ts +5 -4
  9. package/src/core/compaction/compaction.ts +7 -6
  10. package/src/core/compaction/utils.ts +6 -11
  11. package/src/core/custom-commands/bundled/review/index.ts +22 -94
  12. package/src/core/custom-share.ts +66 -0
  13. package/src/core/history-storage.ts +174 -0
  14. package/src/core/index.ts +1 -0
  15. package/src/core/keybindings.ts +3 -0
  16. package/src/core/prompt-templates.ts +271 -1
  17. package/src/core/sdk.ts +14 -3
  18. package/src/core/settings-manager.ts +100 -34
  19. package/src/core/slash-commands.ts +4 -1
  20. package/src/core/storage-migration.ts +215 -0
  21. package/src/core/system-prompt.ts +87 -289
  22. package/src/core/title-generator.ts +3 -2
  23. package/src/core/tools/ask.ts +2 -2
  24. package/src/core/tools/bash.ts +2 -1
  25. package/src/core/tools/calculator.ts +2 -1
  26. package/src/core/tools/edit.ts +2 -1
  27. package/src/core/tools/find.ts +2 -1
  28. package/src/core/tools/gemini-image.ts +2 -1
  29. package/src/core/tools/git.ts +2 -2
  30. package/src/core/tools/grep.ts +2 -1
  31. package/src/core/tools/index.test.ts +0 -28
  32. package/src/core/tools/index.ts +0 -6
  33. package/src/core/tools/lsp/index.ts +2 -1
  34. package/src/core/tools/output.ts +2 -1
  35. package/src/core/tools/read.ts +4 -1
  36. package/src/core/tools/ssh.ts +4 -2
  37. package/src/core/tools/task/agents.ts +56 -30
  38. package/src/core/tools/task/commands.ts +9 -8
  39. package/src/core/tools/task/index.ts +7 -15
  40. package/src/core/tools/web-fetch.ts +2 -1
  41. package/src/core/tools/web-search/auth.ts +106 -16
  42. package/src/core/tools/web-search/index.ts +3 -2
  43. package/src/core/tools/web-search/providers/anthropic.ts +44 -6
  44. package/src/core/tools/write.ts +2 -1
  45. package/src/core/voice.ts +3 -1
  46. package/src/main.ts +1 -1
  47. package/src/migrations.ts +20 -20
  48. package/src/modes/interactive/components/custom-editor.ts +7 -0
  49. package/src/modes/interactive/components/history-search.ts +158 -0
  50. package/src/modes/interactive/controllers/command-controller.ts +527 -0
  51. package/src/modes/interactive/controllers/event-controller.ts +340 -0
  52. package/src/modes/interactive/controllers/extension-ui-controller.ts +600 -0
  53. package/src/modes/interactive/controllers/input-controller.ts +585 -0
  54. package/src/modes/interactive/controllers/selector-controller.ts +585 -0
  55. package/src/modes/interactive/interactive-mode.ts +370 -3115
  56. package/src/modes/interactive/theme/theme.ts +5 -5
  57. package/src/modes/interactive/types.ts +189 -0
  58. package/src/modes/interactive/utils/ui-helpers.ts +449 -0
  59. package/src/modes/interactive/utils/voice-manager.ts +96 -0
  60. package/src/prompts/{explore.md → agents/explore.md} +7 -5
  61. package/src/prompts/agents/frontmatter.md +7 -0
  62. package/src/prompts/{plan.md → agents/plan.md} +3 -3
  63. package/src/prompts/{task.md → agents/task.md} +1 -1
  64. package/src/prompts/review-request.md +44 -8
  65. package/src/prompts/system/custom-system-prompt.md +80 -0
  66. package/src/prompts/system/file-operations.md +12 -0
  67. package/src/prompts/system/system-prompt.md +232 -0
  68. package/src/prompts/system/title-system.md +2 -0
  69. package/src/prompts/tools/bash.md +1 -1
  70. package/src/prompts/tools/read.md +1 -1
  71. package/src/prompts/tools/task.md +9 -3
  72. package/src/core/tools/rulebook.ts +0 -132
  73. package/src/prompts/system-prompt.md +0 -43
  74. package/src/prompts/title-system.md +0 -8
  75. /package/src/prompts/{architect-plan.md → agents/architect-plan.md} +0 -0
  76. /package/src/prompts/{implement-with-critic.md → agents/implement-with-critic.md} +0 -0
  77. /package/src/prompts/{implement.md → agents/implement.md} +0 -0
  78. /package/src/prompts/{init.md → agents/init.md} +0 -0
  79. /package/src/prompts/{reviewer.md → agents/reviewer.md} +0 -0
  80. /package/src/prompts/{branch-summary-preamble.md → compaction/branch-summary-preamble.md} +0 -0
  81. /package/src/prompts/{branch-summary.md → compaction/branch-summary.md} +0 -0
  82. /package/src/prompts/{compaction-summary.md → compaction/compaction-summary.md} +0 -0
  83. /package/src/prompts/{compaction-turn-prefix.md → compaction/compaction-turn-prefix.md} +0 -0
  84. /package/src/prompts/{compaction-update-summary.md → compaction/compaction-update-summary.md} +0 -0
  85. /package/src/prompts/{summarization-system.md → system/summarization-system.md} +0 -0
@@ -3,81 +3,51 @@
3
3
  * Handles TUI rendering and user interaction, delegating business logic to AgentSession.
4
4
  */
5
5
 
6
- import * as fs from "node:fs";
7
- import * as os from "node:os";
8
6
  import * as path from "node:path";
9
- import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
10
- import type { AssistantMessage, ImageContent, Message, OAuthProvider } from "@oh-my-pi/pi-ai";
11
- import type { SlashCommand } from "@oh-my-pi/pi-tui";
7
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
8
+ import type { AssistantMessage, ImageContent, Message } from "@oh-my-pi/pi-ai";
9
+ import type { Component, Loader, SlashCommand } from "@oh-my-pi/pi-tui";
12
10
  import {
13
11
  CombinedAutocompleteProvider,
14
- type Component,
15
12
  Container,
16
- Input,
17
- Loader,
18
13
  Markdown,
19
14
  ProcessTerminal,
20
15
  Spacer,
21
16
  Text,
22
- TruncatedText,
23
17
  TUI,
24
- visibleWidth,
25
18
  } from "@oh-my-pi/pi-tui";
26
- import { nanoid } from "nanoid";
27
- import { getAuthPath, getDebugLogPath } from "../../config";
28
19
  import type { AgentSession, AgentSessionEvent } from "../../core/agent-session";
29
20
  import type { ExtensionUIContext } from "../../core/extensions/index";
30
- import { KeybindingsManager } from "../../core/keybindings";
31
- import { type CustomMessage, createCompactionSummaryMessage } from "../../core/messages";
32
- import { getRecentSessions, type SessionContext, SessionManager } from "../../core/session-manager";
21
+ import { HistoryStorage } from "../../core/history-storage";
22
+ import type { KeybindingsManager } from "../../core/keybindings";
23
+ import { logger } from "../../core/logger";
24
+ import type { SessionContext, SessionManager } from "../../core/session-manager";
25
+ import { getRecentSessions } from "../../core/session-manager";
26
+ import type { SettingsManager } from "../../core/settings-manager";
33
27
  import { loadSlashCommands } from "../../core/slash-commands";
34
- import { detectNotificationProtocol, isNotificationSuppressed, sendNotification } from "../../core/terminal-notify";
35
- import { generateSessionTitle, setTerminalTitle } from "../../core/title-generator";
36
- import { setPreferredImageProvider, setPreferredWebSearchProvider } from "../../core/tools/index";
37
- import type { TruncationResult } from "../../core/tools/truncate";
28
+ import { setTerminalTitle } from "../../core/title-generator";
38
29
  import { VoiceSupervisor } from "../../core/voice-supervisor";
39
- import { disableProvider, enableProvider } from "../../discovery";
40
- import { getChangelogPath, parseChangelog } from "../../utils/changelog";
41
- import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
42
- import { resizeImage } from "../../utils/image-resize";
43
30
  import { registerAsyncCleanup } from "../cleanup";
44
- import { ArminComponent } from "./components/armin";
45
- import { AssistantMessageComponent } from "./components/assistant-message";
46
- import { BashExecutionComponent } from "./components/bash-execution";
47
- import { BorderedLoader } from "./components/bordered-loader";
48
- import { BranchSummaryMessageComponent } from "./components/branch-summary-message";
49
- import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message";
31
+ import type { AssistantMessageComponent } from "./components/assistant-message";
32
+ import type { BashExecutionComponent } from "./components/bash-execution";
50
33
  import { CustomEditor } from "./components/custom-editor";
51
- import { CustomMessageComponent } from "./components/custom-message";
52
34
  import { DynamicBorder } from "./components/dynamic-border";
53
- import { ExtensionDashboard } from "./components/extensions";
54
- import { HookEditorComponent } from "./components/hook-editor";
55
- import { HookInputComponent } from "./components/hook-input";
56
- import { HookSelectorComponent } from "./components/hook-selector";
57
- import { ModelSelectorComponent } from "./components/model-selector";
58
- import { OAuthSelectorComponent } from "./components/oauth-selector";
59
- import { SessionSelectorComponent } from "./components/session-selector";
60
- import { SettingsSelectorComponent } from "./components/settings-selector";
35
+ import type { HookEditorComponent } from "./components/hook-editor";
36
+ import type { HookInputComponent } from "./components/hook-input";
37
+ import type { HookSelectorComponent } from "./components/hook-selector";
61
38
  import { StatusLineComponent } from "./components/status-line";
62
- import { ToolExecutionComponent } from "./components/tool-execution";
63
- import { TreeSelectorComponent } from "./components/tree-selector";
64
- import { TtsrNotificationComponent } from "./components/ttsr-notification";
65
- import { UserMessageComponent } from "./components/user-message";
66
- import { UserMessageSelectorComponent } from "./components/user-message-selector";
39
+ import type { ToolExecutionComponent } from "./components/tool-execution";
67
40
  import { WelcomeComponent } from "./components/welcome";
68
- import {
69
- getAvailableThemes,
70
- getAvailableThemesWithPaths,
71
- getEditorTheme,
72
- getMarkdownTheme,
73
- getSymbolTheme,
74
- getThemeByName,
75
- onThemeChange,
76
- setSymbolPreset,
77
- setTheme,
78
- type Theme,
79
- theme,
80
- } from "./theme/theme";
41
+ import { CommandController } from "./controllers/command-controller";
42
+ import { EventController } from "./controllers/event-controller";
43
+ import { ExtensionUiController } from "./controllers/extension-ui-controller";
44
+ import { InputController } from "./controllers/input-controller";
45
+ import { SelectorController } from "./controllers/selector-controller";
46
+ import type { Theme } from "./theme/theme";
47
+ import { getEditorTheme, getMarkdownTheme, onThemeChange, theme } from "./theme/theme";
48
+ import type { CompactionQueuedMessage, InteractiveModeContext } from "./types";
49
+ import { UiHelpers } from "./utils/ui-helpers";
50
+ import { VoiceManager } from "./utils/voice-manager";
81
51
 
82
52
  /** Options for creating an InteractiveMode instance (for future API use) */
83
53
  export interface InteractiveModeOptions {
@@ -93,136 +63,98 @@ export interface InteractiveModeOptions {
93
63
  initialMessages?: string[];
94
64
  }
95
65
 
96
- /** Interface for components that can be expanded/collapsed */
97
- interface Expandable {
98
- setExpanded(expanded: boolean): void;
99
- }
100
-
101
- function isExpandable(obj: unknown): obj is Expandable {
102
- return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
103
- }
66
+ export class InteractiveMode implements InteractiveModeContext {
67
+ public session: AgentSession;
68
+ public sessionManager: SessionManager;
69
+ public settingsManager: SettingsManager;
70
+ public agent: AgentSession["agent"];
71
+ public voiceSupervisor: VoiceSupervisor;
72
+ public historyStorage?: HistoryStorage;
73
+
74
+ public ui: TUI;
75
+ public chatContainer: Container;
76
+ public pendingMessagesContainer: Container;
77
+ public statusContainer: Container;
78
+ public editor: CustomEditor;
79
+ public editorContainer: Container;
80
+ public statusLine: StatusLineComponent;
81
+
82
+ public isInitialized = false;
83
+ public isBackgrounded = false;
84
+ public isBashMode = false;
85
+ public toolOutputExpanded = false;
86
+ public hideThinkingBlock = false;
87
+ public pendingImages: ImageContent[] = [];
88
+ public compactionQueuedMessages: CompactionQueuedMessage[] = [];
89
+ public pendingTools = new Map<string, ToolExecutionComponent>();
90
+ public pendingBashComponents: BashExecutionComponent[] = [];
91
+ public bashComponent: BashExecutionComponent | undefined = undefined;
92
+ public streamingComponent: AssistantMessageComponent | undefined = undefined;
93
+ public streamingMessage: AssistantMessage | undefined = undefined;
94
+ public loadingAnimation: Loader | undefined = undefined;
95
+ public autoCompactionLoader: Loader | undefined = undefined;
96
+ public retryLoader: Loader | undefined = undefined;
97
+ public autoCompactionEscapeHandler?: () => void;
98
+ public retryEscapeHandler?: () => void;
99
+ public unsubscribe?: () => void;
100
+ public onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void;
101
+ public lastSigintTime = 0;
102
+ public lastEscapeTime = 0;
103
+ public lastVoiceInterruptAt = 0;
104
+ public voiceAutoModeEnabled = false;
105
+ public voiceProgressTimer: ReturnType<typeof setTimeout> | undefined = undefined;
106
+ public voiceProgressSpoken = false;
107
+ public voiceProgressLastLength = 0;
108
+ public hookSelector: HookSelectorComponent | undefined = undefined;
109
+ public hookInput: HookInputComponent | undefined = undefined;
110
+ public hookEditor: HookEditorComponent | undefined = undefined;
111
+ public lastStatusSpacer: Spacer | undefined = undefined;
112
+ public lastStatusText: Text | undefined = undefined;
113
+ public fileSlashCommands: Set<string> = new Set();
104
114
 
105
- type CompactionQueuedMessage = {
106
- text: string;
107
- mode: "steer" | "followUp";
108
- };
109
-
110
- const VOICE_PROGRESS_DELAY_MS = 15000;
111
- const VOICE_PROGRESS_MIN_CHARS = 160;
112
- const VOICE_PROGRESS_DELTA_CHARS = 120;
113
-
114
- export class InteractiveMode {
115
- private session: AgentSession;
116
- private ui: TUI;
117
- private chatContainer: Container;
118
- private pendingMessagesContainer: Container;
119
- private statusContainer: Container;
120
- private editor: CustomEditor;
121
- private editorContainer: Container;
122
- private statusLine: StatusLineComponent;
123
- private version: string;
124
- private isInitialized = false;
125
- private onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void;
126
- private loadingAnimation: Loader | undefined = undefined;
127
-
128
- private lastSigintTime = 0;
129
- private lastEscapeTime = 0;
130
- private changelogMarkdown: string | undefined = undefined;
131
-
132
- // Status line tracking (for mutating immediately-sequential status updates)
133
- private lastStatusSpacer: Spacer | undefined = undefined;
134
- private lastStatusText: Text | undefined = undefined;
135
-
136
- // Streaming message tracking
137
- private streamingComponent: AssistantMessageComponent | undefined = undefined;
138
- private streamingMessage: AssistantMessage | undefined = undefined;
139
-
140
- // Tool execution tracking: toolCallId -> component
141
- private pendingTools = new Map<string, ToolExecutionComponent>();
142
-
143
- // Tool output expansion state
144
- private toolOutputExpanded = false;
145
-
146
- // Thinking block visibility state
147
- private hideThinkingBlock = false;
148
-
149
- // Background mode flag (no UI, no interactive prompts)
150
- private isBackgrounded = false;
151
-
152
- // Agent subscription unsubscribe function
153
- private unsubscribe?: () => void;
154
-
155
- // Signal cleanup unsubscribe function (for SIGINT/SIGTERM flush)
156
115
  private cleanupUnsubscribe?: () => void;
157
-
158
- // Track if editor is in bash mode (text starts with !)
159
- private isBashMode = false;
160
-
161
- // Track current bash execution component
162
- private bashComponent: BashExecutionComponent | undefined = undefined;
163
-
164
- // Track pending bash components (shown in pending area, moved to chat on submit)
165
- private pendingBashComponents: BashExecutionComponent[] = [];
166
-
167
- // Track pending images from clipboard paste (attached to next message)
168
- private pendingImages: ImageContent[] = [];
169
-
170
- // Slash commands loaded from files (for compaction queue handling)
171
- private fileSlashCommands = new Set<string>();
172
-
173
- // Voice mode state
174
- private voiceSupervisor: VoiceSupervisor;
175
- private voiceAutoModeEnabled = false;
176
- private voiceProgressTimer: ReturnType<typeof setTimeout> | undefined = undefined;
177
- private voiceProgressSpoken = false;
178
- private voiceProgressLastLength = 0;
179
- private lastVoiceInterruptAt = 0;
180
-
181
- // Auto-compaction state
182
- private autoCompactionLoader: Loader | undefined = undefined;
183
- private autoCompactionEscapeHandler?: () => void;
184
-
185
- // Messages queued while compaction is running
186
- private compactionQueuedMessages: CompactionQueuedMessage[] = [];
187
-
188
- // Auto-retry state
189
- private retryLoader: Loader | undefined = undefined;
190
- private retryEscapeHandler?: () => void;
191
-
192
- // Hook UI state
193
- private hookSelector: HookSelectorComponent | undefined = undefined;
194
- private hookInput: HookInputComponent | undefined = undefined;
195
- private hookEditor: HookEditorComponent | undefined = undefined;
196
-
197
- // Convenience accessors
198
- private get agent() {
199
- return this.session.agent;
200
- }
201
- private get sessionManager() {
202
- return this.session.sessionManager;
203
- }
204
- private get settingsManager() {
205
- return this.session.settingsManager;
206
- }
116
+ private readonly version: string;
117
+ private readonly changelogMarkdown: string | undefined;
118
+ private readonly lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined =
119
+ undefined;
120
+ private readonly toolUiContextSetter: (uiContext: ExtensionUIContext, hasUI: boolean) => void;
121
+
122
+ private readonly commandController: CommandController;
123
+ private readonly eventController: EventController;
124
+ private readonly extensionUiController: ExtensionUiController;
125
+ private readonly inputController: InputController;
126
+ private readonly selectorController: SelectorController;
127
+ private readonly uiHelpers: UiHelpers;
128
+ private readonly voiceManager: VoiceManager;
207
129
 
208
130
  constructor(
209
131
  session: AgentSession,
210
132
  version: string,
211
133
  changelogMarkdown: string | undefined = undefined,
212
- private setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {},
213
- private lspServers:
214
- | Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }>
215
- | undefined = undefined,
134
+ setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {},
135
+ lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined = undefined,
216
136
  ) {
217
137
  this.session = session;
138
+ this.sessionManager = session.sessionManager;
139
+ this.settingsManager = session.settingsManager;
140
+ this.agent = session.agent;
218
141
  this.version = version;
219
142
  this.changelogMarkdown = changelogMarkdown;
143
+ this.toolUiContextSetter = setToolUIContext;
144
+ this.lspServers = lspServers;
145
+
220
146
  this.ui = new TUI(new ProcessTerminal());
221
147
  this.chatContainer = new Container();
222
148
  this.pendingMessagesContainer = new Container();
223
149
  this.statusContainer = new Container();
224
150
  this.editor = new CustomEditor(getEditorTheme());
225
151
  this.editor.setUseTerminalCursor(true);
152
+ try {
153
+ this.historyStorage = HistoryStorage.open();
154
+ this.editor.setHistoryStorage(this.historyStorage);
155
+ } catch (error) {
156
+ logger.warn("History storage unavailable", { error: String(error) });
157
+ }
226
158
  this.editorContainer = new Container();
227
159
  this.editorContainer.addChild(this.editor);
228
160
  this.statusLine = new StatusLineComponent(session);
@@ -248,11 +180,14 @@ export class InteractiveMode {
248
180
  },
249
181
  });
250
182
 
183
+ this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
184
+
251
185
  // Define slash commands for autocomplete
252
186
  const slashCommands: SlashCommand[] = [
253
187
  { name: "settings", description: "Open settings menu" },
254
188
  { name: "model", description: "Select model (opens selector UI)" },
255
- { name: "export", description: "Export session to HTML file or clipboard (--copy)" },
189
+ { name: "export", description: "Export session to HTML file" },
190
+ { name: "dump", description: "Copy session transcript to clipboard" },
256
191
  { name: "share", description: "Share session as a secret GitHub gist" },
257
192
  { name: "copy", description: "Copy last agent message to clipboard" },
258
193
  { name: "session", description: "Show session info and stats" },
@@ -272,9 +207,6 @@ export class InteractiveMode {
272
207
  { name: "exit", description: "Exit the application" },
273
208
  ];
274
209
 
275
- // Load hide thinking block setting
276
- this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
277
-
278
210
  // Load and convert file commands to SlashCommand format
279
211
  const fileCommands = loadSlashCommands({ cwd: process.cwd() });
280
212
  this.fileSlashCommands = new Set(fileCommands.map((cmd) => cmd.name));
@@ -301,6 +233,14 @@ export class InteractiveMode {
301
233
  process.cwd(),
302
234
  );
303
235
  this.editor.setAutocompleteProvider(autocompleteProvider);
236
+
237
+ this.uiHelpers = new UiHelpers(this);
238
+ this.voiceManager = new VoiceManager(this);
239
+ this.extensionUiController = new ExtensionUiController(this);
240
+ this.eventController = new EventController(this);
241
+ this.commandController = new CommandController(this);
242
+ this.selectorController = new SelectorController(this);
243
+ this.inputController = new InputController(this);
304
244
  }
305
245
 
306
246
  async init(): Promise<void> {
@@ -366,8 +306,8 @@ export class InteractiveMode {
366
306
  this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
367
307
  this.ui.setFocus(this.editor);
368
308
 
369
- this.setupKeyHandlers();
370
- this.setupEditorSubmitHandler();
309
+ this.inputController.setupKeyHandlers();
310
+ this.inputController.setupEditorSubmitHandler();
371
311
 
372
312
  // Start the UI
373
313
  this.ui.start();
@@ -400,3081 +340,396 @@ export class InteractiveMode {
400
340
  this.updateEditorTopBorder();
401
341
  }
402
342
 
403
- // =========================================================================
404
- // Hook System
405
- // =========================================================================
406
-
407
- /**
408
- * Initialize the hook system with TUI-based UI context.
409
- */
410
- private async initHooksAndCustomTools(): Promise<void> {
411
- // Create and set hook & tool UI context
412
- const uiContext: ExtensionUIContext = {
413
- select: (title, options, _dialogOptions) => this.showHookSelector(title, options),
414
- confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
415
- input: (title, placeholder, _dialogOptions) => this.showHookInput(title, placeholder),
416
- notify: (message, type) => this.showHookNotify(message, type),
417
- setStatus: (key, text) => this.setHookStatus(key, text),
418
- setWidget: (key, content) => this.setHookWidget(key, content),
419
- setTitle: (title) => setTerminalTitle(title),
420
- custom: (factory, _options) => this.showHookCustom(factory),
421
- setEditorText: (text) => this.editor.setText(text),
422
- getEditorText: () => this.editor.getText(),
423
- editor: (title, prefill) => this.showHookEditor(title, prefill),
424
- get theme() {
425
- return theme;
426
- },
427
- getAllThemes: () => getAvailableThemesWithPaths().map((t) => ({ name: t.name, path: t.path })),
428
- getTheme: (name) => getThemeByName(name),
429
- setTheme: (themeArg) => {
430
- if (typeof themeArg === "string") {
431
- return setTheme(themeArg, true);
432
- }
433
- // Theme object passed directly - not supported in current implementation
434
- return { success: false, error: "Direct theme object not supported" };
435
- },
436
- setFooter: () => {},
437
- setHeader: () => {},
438
- setEditorComponent: () => {},
439
- };
440
- this.setToolUIContext(uiContext, true);
441
-
442
- const extensionRunner = this.session.extensionRunner;
443
- if (!extensionRunner) {
444
- return; // No hooks loaded
445
- }
446
-
447
- extensionRunner.initialize(
448
- // ExtensionActions - for pi.* API
449
- {
450
- sendMessage: (message, options) => {
451
- const wasStreaming = this.session.isStreaming;
452
- this.session
453
- .sendCustomMessage(message, options)
454
- .then(() => {
455
- // For non-streaming cases with display=true, update UI
456
- // (streaming cases update via message_end event)
457
- if (!this.isBackgrounded && !wasStreaming && message.display) {
458
- this.rebuildChatFromMessages();
459
- }
460
- })
461
- .catch((err) => {
462
- this.showError(
463
- `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`,
464
- );
465
- });
466
- },
467
- sendUserMessage: (content, options) => {
468
- this.session.sendUserMessage(content, options).catch((err) => {
469
- this.showError(
470
- `Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
471
- );
472
- });
473
- },
474
- appendEntry: (customType, data) => {
475
- this.sessionManager.appendCustomEntry(customType, data);
476
- },
477
- getActiveTools: () => this.session.getActiveToolNames(),
478
- getAllTools: () => this.session.getAllToolNames(),
479
- setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames),
480
- setModel: async (model) => {
481
- const key = await this.session.modelRegistry.getApiKey(model);
482
- if (!key) return false;
483
- await this.session.setModel(model);
484
- return true;
485
- },
486
- getThinkingLevel: () => this.session.thinkingLevel,
487
- setThinkingLevel: (level) => this.session.setThinkingLevel(level),
488
- },
489
- // ExtensionContextActions - for ctx.* in event handlers
490
- {
491
- getModel: () => this.session.model,
492
- isIdle: () => !this.session.isStreaming,
493
- abort: () => this.session.abort(),
494
- hasPendingMessages: () => this.session.queuedMessageCount > 0,
495
- shutdown: () => {
496
- // Signal shutdown request (will be handled by main loop)
497
- },
498
- },
499
- // ExtensionCommandContextActions - for ctx.* in command handlers
500
- {
501
- waitForIdle: () => this.session.agent.waitForIdle(),
502
- newSession: async (options) => {
503
- // Stop any loading animation
504
- if (this.loadingAnimation) {
505
- this.loadingAnimation.stop();
506
- this.loadingAnimation = undefined;
507
- }
508
- this.statusContainer.clear();
509
-
510
- // Create new session
511
- const success = await this.session.newSession({ parentSession: options?.parentSession });
512
- if (!success) {
513
- return { cancelled: true };
514
- }
515
-
516
- // Call setup callback if provided
517
- if (options?.setup) {
518
- await options.setup(this.sessionManager);
519
- }
520
-
521
- // Clear UI state
522
- this.chatContainer.clear();
523
- this.pendingMessagesContainer.clear();
524
- this.compactionQueuedMessages = [];
525
- this.streamingComponent = undefined;
526
- this.streamingMessage = undefined;
527
- this.pendingTools.clear();
528
-
529
- this.chatContainer.addChild(new Spacer(1));
530
- this.chatContainer.addChild(
531
- new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
532
- );
533
- this.ui.requestRender();
534
-
535
- return { cancelled: false };
536
- },
537
- branch: async (entryId) => {
538
- const result = await this.session.branch(entryId);
539
- if (result.cancelled) {
540
- return { cancelled: true };
541
- }
542
-
543
- // Update UI
544
- this.chatContainer.clear();
545
- this.renderInitialMessages();
546
- this.editor.setText(result.selectedText);
547
- this.showStatus("Branched to new session");
548
-
549
- return { cancelled: false };
550
- },
551
- navigateTree: async (targetId, options) => {
552
- const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
553
- if (result.cancelled) {
554
- return { cancelled: true };
555
- }
556
-
557
- // Update UI
558
- this.chatContainer.clear();
559
- this.renderInitialMessages();
560
- if (result.editorText) {
561
- this.editor.setText(result.editorText);
562
- }
563
- this.showStatus("Navigated to selected point");
564
-
565
- return { cancelled: false };
566
- },
567
- },
568
- // ExtensionUIContext
569
- uiContext,
570
- );
571
-
572
- // Subscribe to extension errors
573
- extensionRunner.onError((error) => {
574
- this.showExtensionError(error.extensionPath, error.error);
575
- });
576
-
577
- // Emit session_start event
578
- await extensionRunner.emit({
579
- type: "session_start",
343
+ async getUserInput(): Promise<{ text: string; images?: ImageContent[] }> {
344
+ return new Promise((resolve) => {
345
+ this.onInputCallback = (input) => {
346
+ this.onInputCallback = undefined;
347
+ resolve(input);
348
+ };
580
349
  });
581
350
  }
582
351
 
583
- /**
584
- * Set extension widget content.
585
- */
586
- private setHookWidget(key: string, content: unknown): void {
587
- this.statusLine.setHookStatus(key, String(content));
352
+ updateEditorBorderColor(): void {
353
+ if (this.isBashMode) {
354
+ this.editor.borderColor = theme.getBashModeBorderColor();
355
+ } else {
356
+ const level = this.session.thinkingLevel || "off";
357
+ this.editor.borderColor = theme.getThinkingBorderColor(level);
358
+ }
359
+ this.updateEditorTopBorder();
588
360
  this.ui.requestRender();
589
361
  }
590
362
 
591
- private initializeHookRunner(uiContext: ExtensionUIContext, _hasUI: boolean): void {
592
- const extensionRunner = this.session.extensionRunner;
593
- if (!extensionRunner) {
594
- return;
595
- }
363
+ updateEditorTopBorder(): void {
364
+ const width = this.ui.getWidth();
365
+ const topBorder = this.statusLine.getTopBorder(width);
366
+ this.editor.setTopBorder(topBorder);
367
+ }
596
368
 
597
- extensionRunner.initialize(
598
- // ExtensionActions - for pi.* API
599
- {
600
- sendMessage: (message, options) => {
601
- const wasStreaming = this.session.isStreaming;
602
- this.session
603
- .sendCustomMessage(message, options)
604
- .then(() => {
605
- // For non-streaming cases with display=true, update UI
606
- // (streaming cases update via message_end event)
607
- if (!this.isBackgrounded && !wasStreaming && message.display) {
608
- this.rebuildChatFromMessages();
609
- }
610
- })
611
- .catch((err: Error) => {
612
- const errorText = `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`;
613
- if (this.isBackgrounded) {
614
- console.error(errorText);
615
- return;
616
- }
617
- this.showError(errorText);
618
- });
619
- },
620
- sendUserMessage: (content, options) => {
621
- this.session.sendUserMessage(content, options).catch((err) => {
622
- this.showError(
623
- `Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
624
- );
625
- });
626
- },
627
- appendEntry: (customType, data) => {
628
- this.sessionManager.appendCustomEntry(customType, data);
629
- },
630
- getActiveTools: () => this.session.getActiveToolNames(),
631
- getAllTools: () => this.session.getAllToolNames(),
632
- setActiveTools: (toolNames: string[]) => this.session.setActiveToolsByName(toolNames),
633
- setModel: async (model) => {
634
- const key = await this.session.modelRegistry.getApiKey(model);
635
- if (!key) return false;
636
- await this.session.setModel(model);
637
- return true;
638
- },
639
- getThinkingLevel: () => this.session.thinkingLevel,
640
- setThinkingLevel: (level) => this.session.setThinkingLevel(level),
641
- },
642
- // ExtensionContextActions - for ctx.* in event handlers
643
- {
644
- getModel: () => this.session.model,
645
- isIdle: () => !this.session.isStreaming,
646
- abort: () => this.session.abort(),
647
- hasPendingMessages: () => this.session.queuedMessageCount > 0,
648
- shutdown: () => {
649
- // Signal shutdown request (will be handled by main loop)
650
- },
651
- },
652
- // ExtensionCommandContextActions - for ctx.* in command handlers
653
- {
654
- waitForIdle: () => this.session.agent.waitForIdle(),
655
- newSession: async (options) => {
656
- if (this.isBackgrounded) {
657
- return { cancelled: true };
658
- }
659
- // Stop any loading animation
660
- if (this.loadingAnimation) {
661
- this.loadingAnimation.stop();
662
- this.loadingAnimation = undefined;
663
- }
664
- this.statusContainer.clear();
665
-
666
- // Create new session
667
- const success = await this.session.newSession({ parentSession: options?.parentSession });
668
- if (!success) {
669
- return { cancelled: true };
670
- }
671
-
672
- // Call setup callback if provided
673
- if (options?.setup) {
674
- await options.setup(this.sessionManager);
675
- }
676
-
677
- // Clear UI state
678
- this.chatContainer.clear();
679
- this.pendingMessagesContainer.clear();
680
- this.compactionQueuedMessages = [];
681
- this.streamingComponent = undefined;
682
- this.streamingMessage = undefined;
683
- this.pendingTools.clear();
684
-
685
- this.chatContainer.addChild(new Spacer(1));
686
- this.chatContainer.addChild(
687
- new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
688
- );
689
- this.ui.requestRender();
690
-
691
- return { cancelled: false };
692
- },
693
- branch: async (entryId) => {
694
- if (this.isBackgrounded) {
695
- return { cancelled: true };
696
- }
697
- const result = await this.session.branch(entryId);
698
- if (result.cancelled) {
699
- return { cancelled: true };
700
- }
701
-
702
- // Update UI
703
- this.chatContainer.clear();
704
- this.renderInitialMessages();
705
- this.editor.setText(result.selectedText);
706
- this.showStatus("Branched to new session");
707
-
708
- return { cancelled: false };
709
- },
710
- navigateTree: async (targetId, options) => {
711
- if (this.isBackgrounded) {
712
- return { cancelled: true };
713
- }
714
- const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
715
- if (result.cancelled) {
716
- return { cancelled: true };
717
- }
718
-
719
- // Update UI
720
- this.chatContainer.clear();
721
- this.renderInitialMessages();
722
- if (result.editorText) {
723
- this.editor.setText(result.editorText);
724
- }
725
- this.showStatus("Navigated to selected point");
726
-
727
- return { cancelled: false };
728
- },
729
- },
730
- uiContext,
731
- );
369
+ rebuildChatFromMessages(): void {
370
+ this.chatContainer.clear();
371
+ const context = this.sessionManager.buildSessionContext();
372
+ this.renderSessionContext(context);
732
373
  }
733
374
 
734
- private createBackgroundUiContext(): ExtensionUIContext {
735
- return {
736
- select: async (_title: string, _options: string[], _dialogOptions) => undefined,
737
- confirm: async (_title: string, _message: string, _dialogOptions) => false,
738
- input: async (_title: string, _placeholder?: string, _dialogOptions?: unknown) => undefined,
739
- notify: () => {},
740
- setStatus: () => {},
741
- setWidget: () => {},
742
- setTitle: () => {},
743
- custom: async () => undefined as never,
744
- setEditorText: () => {},
745
- getEditorText: () => "",
746
- editor: async () => undefined,
747
- get theme() {
748
- return theme;
749
- },
750
- getAllThemes: () => [],
751
- getTheme: () => undefined,
752
- setTheme: () => ({ success: false, error: "Background mode" }),
753
- setFooter: () => {},
754
- setHeader: () => {},
755
- setEditorComponent: () => {},
756
- };
757
- }
758
-
759
- /**
760
- * Emit session event to all extension tools.
761
- */
762
- private async emitCustomToolSessionEvent(
763
- reason: "start" | "switch" | "branch" | "tree" | "shutdown",
764
- previousSessionFile?: string,
765
- ): Promise<void> {
766
- const event = { reason, previousSessionFile };
767
- const uiContext = this.session.extensionRunner?.getUIContext();
768
- if (!uiContext) {
769
- return;
375
+ stop(): void {
376
+ if (this.loadingAnimation) {
377
+ this.loadingAnimation.stop();
378
+ this.loadingAnimation = undefined;
770
379
  }
771
- for (const registeredTool of this.session.extensionRunner?.getAllRegisteredTools() ?? []) {
772
- if (registeredTool.definition.onSession) {
773
- try {
774
- await registeredTool.definition.onSession(event, {
775
- ui: uiContext,
776
- hasUI: !this.isBackgrounded,
777
- cwd: this.sessionManager.getCwd(),
778
- sessionManager: this.session.sessionManager,
779
- modelRegistry: this.session.modelRegistry,
780
- model: this.session.model,
781
- isIdle: () => !this.session.isStreaming,
782
- hasPendingMessages: () => this.session.queuedMessageCount > 0,
783
- hasQueuedMessages: () => this.session.queuedMessageCount > 0,
784
- abort: () => {
785
- this.session.abort();
786
- },
787
- shutdown: () => {
788
- // Signal shutdown request
789
- },
790
- });
791
- } catch (err) {
792
- this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err));
793
- }
794
- }
380
+ this.statusLine.dispose();
381
+ if (this.unsubscribe) {
382
+ this.unsubscribe();
383
+ }
384
+ if (this.cleanupUnsubscribe) {
385
+ this.cleanupUnsubscribe();
386
+ }
387
+ if (this.isInitialized) {
388
+ this.ui.stop();
389
+ this.isInitialized = false;
795
390
  }
796
391
  }
797
392
 
798
- /**
799
- * Show a tool error in the chat.
800
- */
801
- private showToolError(toolName: string, error: string): void {
802
- if (this.isBackgrounded) {
803
- console.error(`Tool "${toolName}" error: ${error}`);
804
- return;
805
- }
806
- const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0);
807
- this.chatContainer.addChild(errorText);
808
- this.ui.requestRender();
393
+ async shutdown(): Promise<void> {
394
+ this.voiceAutoModeEnabled = false;
395
+ await this.voiceSupervisor.stop();
396
+
397
+ // Flush pending session writes before shutdown
398
+ await this.sessionManager.flush();
399
+
400
+ // Emit shutdown event to hooks
401
+ await this.session.emitCustomToolSessionEvent("shutdown");
402
+
403
+ this.stop();
404
+ process.exit(0);
809
405
  }
810
406
 
811
- /**
812
- * Set hook status text in the footer.
813
- */
814
- private setHookStatus(key: string, text: string | undefined): void {
815
- if (this.isBackgrounded) {
816
- return;
817
- }
818
- this.statusLine.setHookStatus(key, text);
819
- this.ui.requestRender();
407
+ // Extension UI integration
408
+ setToolUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void {
409
+ this.toolUiContextSetter(uiContext, hasUI);
820
410
  }
821
411
 
822
- /**
823
- * Show a selector for hooks.
824
- */
825
- private showHookSelector(title: string, options: string[]): Promise<string | undefined> {
826
- return new Promise((resolve) => {
827
- this.hookSelector = new HookSelectorComponent(
828
- title,
829
- options,
830
- (option) => {
831
- this.hideHookSelector();
832
- resolve(option);
833
- },
834
- () => {
835
- this.hideHookSelector();
836
- resolve(undefined);
837
- },
838
- );
839
-
840
- this.editorContainer.clear();
841
- this.editorContainer.addChild(this.hookSelector);
842
- this.ui.setFocus(this.hookSelector);
843
- this.ui.requestRender();
844
- });
412
+ initializeHookRunner(uiContext: ExtensionUIContext, hasUI: boolean): void {
413
+ this.extensionUiController.initializeHookRunner(uiContext, hasUI);
845
414
  }
846
415
 
847
- /**
848
- * Hide the hook selector.
849
- */
850
- private hideHookSelector(): void {
851
- this.editorContainer.clear();
852
- this.editorContainer.addChild(this.editor);
853
- this.hookSelector = undefined;
854
- this.ui.setFocus(this.editor);
855
- this.ui.requestRender();
416
+ createBackgroundUiContext(): ExtensionUIContext {
417
+ return this.extensionUiController.createBackgroundUiContext();
856
418
  }
857
419
 
858
- /**
859
- * Show a confirmation dialog for hooks.
860
- */
861
- private async showHookConfirm(title: string, message: string): Promise<boolean> {
862
- const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"]);
863
- return result === "Yes";
420
+ // Event handling
421
+ async handleBackgroundEvent(event: AgentSessionEvent): Promise<void> {
422
+ await this.eventController.handleBackgroundEvent(event);
864
423
  }
865
424
 
866
- /**
867
- * Show a text input for hooks.
868
- */
869
- private showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
870
- return new Promise((resolve) => {
871
- this.hookInput = new HookInputComponent(
872
- title,
873
- placeholder,
874
- (value) => {
875
- this.hideHookInput();
876
- resolve(value);
877
- },
878
- () => {
879
- this.hideHookInput();
880
- resolve(undefined);
881
- },
882
- );
883
-
884
- this.editorContainer.clear();
885
- this.editorContainer.addChild(this.hookInput);
886
- this.ui.setFocus(this.hookInput);
887
- this.ui.requestRender();
888
- });
425
+ // UI helpers
426
+ showStatus(message: string, options?: { dim?: boolean }): void {
427
+ this.uiHelpers.showStatus(message, options);
889
428
  }
890
429
 
891
- /**
892
- * Hide the hook input.
893
- */
894
- private hideHookInput(): void {
895
- this.editorContainer.clear();
896
- this.editorContainer.addChild(this.editor);
897
- this.hookInput = undefined;
898
- this.ui.setFocus(this.editor);
899
- this.ui.requestRender();
430
+ showError(message: string): void {
431
+ this.uiHelpers.showError(message);
900
432
  }
901
433
 
902
- /**
903
- * Show a multi-line editor for hooks (with Ctrl+G support).
904
- */
905
- private showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
906
- return new Promise((resolve) => {
907
- this.hookEditor = new HookEditorComponent(
908
- this.ui,
909
- title,
910
- prefill,
911
- (value) => {
912
- this.hideHookEditor();
913
- resolve(value);
914
- },
915
- () => {
916
- this.hideHookEditor();
917
- resolve(undefined);
918
- },
919
- );
920
-
921
- this.editorContainer.clear();
922
- this.editorContainer.addChild(this.hookEditor);
923
- this.ui.setFocus(this.hookEditor);
924
- this.ui.requestRender();
925
- });
434
+ showWarning(message: string): void {
435
+ this.uiHelpers.showWarning(message);
926
436
  }
927
437
 
928
- /**
929
- * Hide the hook editor.
930
- */
931
- private hideHookEditor(): void {
932
- this.editorContainer.clear();
933
- this.editorContainer.addChild(this.editor);
934
- this.hookEditor = undefined;
935
- this.ui.setFocus(this.editor);
936
- this.ui.requestRender();
438
+ showNewVersionNotification(newVersion: string): void {
439
+ this.uiHelpers.showNewVersionNotification(newVersion);
937
440
  }
938
441
 
939
- /**
940
- * Show a notification for hooks.
941
- */
942
- private showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
943
- if (type === "error") {
944
- this.showError(message);
945
- } else if (type === "warning") {
946
- this.showWarning(message);
947
- } else {
948
- this.showStatus(message);
949
- }
442
+ clearEditor(): void {
443
+ this.uiHelpers.clearEditor();
950
444
  }
951
445
 
952
- /**
953
- * Show a custom component with keyboard focus.
954
- */
955
- private async showHookCustom<T>(
956
- factory: (
957
- tui: TUI,
958
- theme: Theme,
959
- keybindings: KeybindingsManager,
960
- done: (result: T) => void,
961
- ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
962
- ): Promise<T> {
963
- const savedText = this.editor.getText();
964
- const keybindings = KeybindingsManager.inMemory();
446
+ updatePendingMessagesDisplay(): void {
447
+ this.uiHelpers.updatePendingMessagesDisplay();
448
+ }
965
449
 
966
- return new Promise((resolve) => {
967
- let component: Component & { dispose?(): void };
968
-
969
- const close = (result: T) => {
970
- component.dispose?.();
971
- this.editorContainer.clear();
972
- this.editorContainer.addChild(this.editor);
973
- this.editor.setText(savedText);
974
- this.ui.setFocus(this.editor);
975
- this.ui.requestRender();
976
- resolve(result);
977
- };
450
+ queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
451
+ this.uiHelpers.queueCompactionMessage(text, mode);
452
+ }
978
453
 
979
- Promise.resolve(factory(this.ui, theme, keybindings, close)).then((c) => {
980
- component = c;
981
- this.editorContainer.clear();
982
- this.editorContainer.addChild(component);
983
- this.ui.setFocus(component);
984
- this.ui.requestRender();
985
- });
986
- });
454
+ flushCompactionQueue(options?: { willRetry?: boolean }): Promise<void> {
455
+ return this.uiHelpers.flushCompactionQueue(options);
987
456
  }
988
457
 
989
- /**
990
- * Show an extension error in the UI.
991
- */
992
- private showExtensionError(extensionPath: string, error: string): void {
993
- const errorText = new Text(theme.fg("error", `Extension "${extensionPath}" error: ${error}`), 1, 0);
994
- this.chatContainer.addChild(errorText);
995
- this.ui.requestRender();
458
+ flushPendingBashComponents(): void {
459
+ this.uiHelpers.flushPendingBashComponents();
996
460
  }
997
461
 
998
- /**
999
- * Handle pi.send() from hooks.
1000
- * If streaming, queue the message. Otherwise, start a new agent loop.
1001
- */
1002
- // =========================================================================
1003
- // Key Handlers
1004
- // =========================================================================
1005
-
1006
- private setupKeyHandlers(): void {
1007
- this.editor.onEscape = () => {
1008
- if (this.loadingAnimation) {
1009
- // Abort and restore queued messages to editor
1010
- const queuedMessages = this.session.clearQueue();
1011
- const queuedText = [...queuedMessages.steering, ...queuedMessages.followUp].join("\n\n");
1012
- const currentText = this.editor.getText();
1013
- const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
1014
- this.editor.setText(combinedText);
1015
- this.updatePendingMessagesDisplay();
1016
- this.agent.abort();
1017
- } else if (this.session.isBashRunning) {
1018
- this.session.abortBash();
1019
- } else if (this.isBashMode) {
1020
- this.editor.setText("");
1021
- this.isBashMode = false;
1022
- this.updateEditorBorderColor();
1023
- } else if (!this.editor.getText().trim()) {
1024
- // Double-escape with empty editor triggers /tree or /branch based on setting
1025
- const now = Date.now();
1026
- if (now - this.lastEscapeTime < 500) {
1027
- if (this.settingsManager.getDoubleEscapeAction() === "tree") {
1028
- this.showTreeSelector();
1029
- } else {
1030
- this.showUserMessageSelector();
1031
- }
1032
- this.lastEscapeTime = 0;
1033
- } else {
1034
- this.lastEscapeTime = now;
1035
- }
1036
- }
1037
- };
1038
-
1039
- this.editor.onCtrlC = () => this.handleCtrlC();
1040
- this.editor.onCtrlD = () => this.handleCtrlD();
1041
- this.editor.onCtrlZ = () => this.handleCtrlZ();
1042
- this.editor.onShiftTab = () => this.cycleThinkingLevel();
1043
- this.editor.onCtrlP = () => this.cycleRoleModel();
1044
- this.editor.onShiftCtrlP = () => this.cycleRoleModel({ temporary: true });
1045
- this.editor.onCtrlY = () => this.showModelSelector({ temporaryOnly: true });
1046
-
1047
- // Global debug handler on TUI (works regardless of focus)
1048
- this.ui.onDebug = () => this.handleDebugCommand();
1049
- this.editor.onCtrlL = () => this.showModelSelector();
1050
- this.editor.onCtrlO = () => this.toggleToolOutputExpansion();
1051
- this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility();
1052
- this.editor.onCtrlG = () => this.openExternalEditor();
1053
- this.editor.onQuestionMark = () => this.handleHotkeysCommand();
1054
- this.editor.onCtrlV = () => this.handleImagePaste();
1055
- this.editor.onAltUp = () => this.handleDequeue();
1056
-
1057
- // Wire up extension shortcuts
1058
- this.registerExtensionShortcuts();
1059
-
1060
- this.editor.onChange = (text: string) => {
1061
- const wasBashMode = this.isBashMode;
1062
- this.isBashMode = text.trimStart().startsWith("!");
1063
- if (wasBashMode !== this.isBashMode) {
1064
- this.updateEditorBorderColor();
1065
- }
1066
- };
462
+ isKnownSlashCommand(text: string): boolean {
463
+ return this.uiHelpers.isKnownSlashCommand(text);
464
+ }
1067
465
 
1068
- this.editor.onAltEnter = async (text: string) => {
1069
- text = text.trim();
1070
- if (!text) return;
466
+ addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
467
+ this.uiHelpers.addMessageToChat(message, options);
468
+ }
1071
469
 
1072
- // Queue follow-up messages while compaction is running
1073
- if (this.session.isCompacting) {
1074
- this.queueCompactionMessage(text, "followUp");
1075
- return;
1076
- }
470
+ renderSessionContext(
471
+ sessionContext: SessionContext,
472
+ options?: { updateFooter?: boolean; populateHistory?: boolean },
473
+ ): void {
474
+ this.uiHelpers.renderSessionContext(sessionContext, options);
475
+ }
1077
476
 
1078
- // Alt+Enter queues a follow-up message (waits until agent finishes)
1079
- // This handles extension commands (execute immediately), prompt template expansion, and queueing
1080
- if (this.session.isStreaming) {
1081
- this.editor.addToHistory(text);
1082
- this.editor.setText("");
1083
- await this.session.prompt(text, { streamingBehavior: "followUp" });
1084
- this.updatePendingMessagesDisplay();
1085
- this.ui.requestRender();
1086
- }
1087
- // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)
1088
- else if (this.editor.onSubmit) {
1089
- this.editor.onSubmit(text);
1090
- }
1091
- };
477
+ renderInitialMessages(): void {
478
+ this.uiHelpers.renderInitialMessages();
1092
479
  }
1093
480
 
1094
- private setupEditorSubmitHandler(): void {
1095
- this.editor.onSubmit = async (text: string) => {
1096
- text = text.trim();
481
+ getUserMessageText(message: Message): string {
482
+ return this.uiHelpers.getUserMessageText(message);
483
+ }
1097
484
 
1098
- // Empty submit while streaming with queued messages: flush queues immediately
1099
- if (!text && this.session.isStreaming && this.session.queuedMessageCount > 0) {
1100
- // Abort current stream and let queued messages be processed
1101
- await this.session.abort();
1102
- return;
1103
- }
485
+ findLastAssistantMessage(): AssistantMessage | undefined {
486
+ return this.uiHelpers.findLastAssistantMessage();
487
+ }
1104
488
 
1105
- if (!text) return;
489
+ extractAssistantText(message: AssistantMessage): string {
490
+ return this.uiHelpers.extractAssistantText(message);
491
+ }
1106
492
 
1107
- // Handle slash commands
1108
- if (text === "/settings") {
1109
- this.showSettingsSelector();
1110
- this.editor.setText("");
1111
- return;
1112
- }
1113
- if (text === "/model") {
1114
- this.showModelSelector();
1115
- this.editor.setText("");
1116
- return;
1117
- }
1118
- if (text.startsWith("/export")) {
1119
- await this.handleExportCommand(text);
1120
- this.editor.setText("");
1121
- return;
1122
- }
1123
- if (text === "/share") {
1124
- await this.handleShareCommand();
1125
- this.editor.setText("");
1126
- return;
1127
- }
1128
- if (text === "/copy") {
1129
- await this.handleCopyCommand();
1130
- this.editor.setText("");
1131
- return;
1132
- }
1133
- if (text === "/session") {
1134
- this.handleSessionCommand();
1135
- this.editor.setText("");
1136
- return;
1137
- }
1138
- if (text === "/changelog") {
1139
- this.handleChangelogCommand();
1140
- this.editor.setText("");
1141
- return;
1142
- }
1143
- if (text === "/hotkeys") {
1144
- this.handleHotkeysCommand();
1145
- this.editor.setText("");
1146
- return;
1147
- }
1148
- if (text === "/extensions" || text === "/status") {
1149
- this.showExtensionsDashboard();
1150
- this.editor.setText("");
1151
- return;
1152
- }
1153
- if (text === "/branch") {
1154
- if (this.settingsManager.getDoubleEscapeAction() === "tree") {
1155
- this.showTreeSelector();
1156
- } else {
1157
- this.showUserMessageSelector();
1158
- }
1159
- this.editor.setText("");
1160
- return;
1161
- }
1162
- if (text === "/tree") {
1163
- this.showTreeSelector();
1164
- this.editor.setText("");
1165
- return;
1166
- }
1167
- if (text === "/login") {
1168
- this.showOAuthSelector("login");
1169
- this.editor.setText("");
1170
- return;
1171
- }
1172
- if (text === "/logout") {
1173
- this.showOAuthSelector("logout");
1174
- this.editor.setText("");
1175
- return;
1176
- }
1177
- if (text === "/new") {
1178
- this.editor.setText("");
1179
- await this.handleClearCommand();
1180
- return;
1181
- }
1182
- if (text === "/compact" || text.startsWith("/compact ")) {
1183
- const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
1184
- this.editor.setText("");
1185
- await this.handleCompactCommand(customInstructions);
1186
- return;
1187
- }
1188
- if (text === "/background" || text === "/bg") {
1189
- this.editor.setText("");
1190
- this.handleBackgroundCommand();
1191
- return;
1192
- }
1193
- if (text === "/debug") {
1194
- this.handleDebugCommand();
1195
- this.editor.setText("");
1196
- return;
1197
- }
1198
- if (text === "/arminsayshi") {
1199
- this.handleArminSaysHi();
1200
- this.editor.setText("");
1201
- return;
1202
- }
1203
- if (text === "/resume") {
1204
- this.showSessionSelector();
1205
- this.editor.setText("");
1206
- return;
1207
- }
1208
- if (text === "/exit") {
1209
- this.editor.setText("");
1210
- void this.shutdown();
1211
- return;
1212
- }
493
+ // Command handling
494
+ handleExportCommand(text: string): Promise<void> {
495
+ return this.commandController.handleExportCommand(text);
496
+ }
1213
497
 
1214
- // Handle bash command (! for normal, !! for excluded from context)
1215
- if (text.startsWith("!")) {
1216
- const isExcluded = text.startsWith("!!");
1217
- const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
1218
- if (command) {
1219
- if (this.session.isBashRunning) {
1220
- this.showWarning("A bash command is already running. Press Esc to cancel it first.");
1221
- this.editor.setText(text);
1222
- return;
1223
- }
1224
- this.editor.addToHistory(text);
1225
- await this.handleBashCommand(command, isExcluded);
1226
- this.isBashMode = false;
1227
- this.updateEditorBorderColor();
1228
- return;
1229
- }
1230
- }
498
+ handleDumpCommand(): Promise<void> {
499
+ return this.commandController.handleDumpCommand();
500
+ }
1231
501
 
1232
- // Queue input during compaction
1233
- if (this.session.isCompacting) {
1234
- if (this.pendingImages.length > 0) {
1235
- this.showStatus("Compaction in progress. Retry after it completes to send images.");
1236
- return;
1237
- }
1238
- this.queueCompactionMessage(text, "steer");
1239
- return;
1240
- }
502
+ handleShareCommand(): Promise<void> {
503
+ return this.commandController.handleShareCommand();
504
+ }
1241
505
 
1242
- // If streaming, use prompt() with steer behavior
1243
- // This handles extension commands (execute immediately), prompt template expansion, and queueing
1244
- if (this.session.isStreaming) {
1245
- this.editor.addToHistory(text);
1246
- this.editor.setText("");
1247
- const images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined;
1248
- this.pendingImages = [];
1249
- await this.session.prompt(text, { streamingBehavior: "steer", images });
1250
- this.updatePendingMessagesDisplay();
1251
- this.ui.requestRender();
1252
- return;
1253
- }
506
+ handleCopyCommand(): Promise<void> {
507
+ return this.commandController.handleCopyCommand();
508
+ }
1254
509
 
1255
- // Normal message submission
1256
- // First, move any pending bash components to chat
1257
- this.flushPendingBashComponents();
1258
-
1259
- // Generate session title on first message
1260
- const hasUserMessages = this.agent.state.messages.some((m) => m.role === "user");
1261
- if (!hasUserMessages && !this.sessionManager.getSessionTitle()) {
1262
- const registry = this.session.modelRegistry;
1263
- const smolModel = this.settingsManager.getModelRole("smol");
1264
- generateSessionTitle(text, registry, smolModel, this.session.sessionId)
1265
- .then(async (title) => {
1266
- if (title) {
1267
- await this.sessionManager.setSessionTitle(title);
1268
- setTerminalTitle(`omp: ${title}`);
1269
- }
1270
- })
1271
- .catch(() => {});
1272
- }
510
+ handleSessionCommand(): void {
511
+ this.commandController.handleSessionCommand();
512
+ }
1273
513
 
1274
- if (this.onInputCallback) {
1275
- // Include any pending images from clipboard paste
1276
- const images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined;
1277
- this.pendingImages = [];
1278
- this.onInputCallback({ text, images });
1279
- }
1280
- this.editor.addToHistory(text);
1281
- };
514
+ handleChangelogCommand(): void {
515
+ this.commandController.handleChangelogCommand();
1282
516
  }
1283
517
 
1284
- private subscribeToAgent(): void {
1285
- this.unsubscribe = this.session.subscribe(async (event) => {
1286
- await this.handleEvent(event);
1287
- });
518
+ handleHotkeysCommand(): void {
519
+ this.commandController.handleHotkeysCommand();
1288
520
  }
1289
521
 
1290
- private async handleEvent(event: AgentSessionEvent): Promise<void> {
1291
- if (!this.isInitialized) {
1292
- await this.init();
1293
- }
522
+ handleClearCommand(): Promise<void> {
523
+ return this.commandController.handleClearCommand();
524
+ }
1294
525
 
1295
- this.statusLine.invalidate();
1296
- this.updateEditorTopBorder();
526
+ handleDebugCommand(): void {
527
+ this.commandController.handleDebugCommand();
528
+ }
1297
529
 
1298
- switch (event.type) {
1299
- case "agent_start":
1300
- // Restore escape handler if retry UI is still active
1301
- if (this.retryEscapeHandler) {
1302
- this.editor.onEscape = this.retryEscapeHandler;
1303
- this.retryEscapeHandler = undefined;
1304
- }
1305
- if (this.retryLoader) {
1306
- this.retryLoader.stop();
1307
- this.retryLoader = undefined;
1308
- this.statusContainer.clear();
1309
- }
1310
- if (this.loadingAnimation) {
1311
- this.loadingAnimation.stop();
1312
- }
1313
- this.statusContainer.clear();
1314
- this.loadingAnimation = new Loader(
1315
- this.ui,
1316
- (spinner) => theme.fg("accent", spinner),
1317
- (text) => theme.fg("muted", text),
1318
- `Working${theme.format.ellipsis} (esc to interrupt)`,
1319
- getSymbolTheme().spinnerFrames,
1320
- );
1321
- this.statusContainer.addChild(this.loadingAnimation);
1322
- this.startVoiceProgressTimer();
1323
- this.ui.requestRender();
1324
- break;
1325
-
1326
- case "message_start":
1327
- if (event.message.role === "hookMessage" || event.message.role === "custom") {
1328
- this.addMessageToChat(event.message);
1329
- this.ui.requestRender();
1330
- } else if (event.message.role === "user") {
1331
- this.addMessageToChat(event.message);
1332
- this.editor.setText("");
1333
- this.updatePendingMessagesDisplay();
1334
- this.ui.requestRender();
1335
- } else if (event.message.role === "fileMention") {
1336
- this.addMessageToChat(event.message);
1337
- this.ui.requestRender();
1338
- } else if (event.message.role === "assistant") {
1339
- this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock);
1340
- this.streamingMessage = event.message;
1341
- this.chatContainer.addChild(this.streamingComponent);
1342
- this.streamingComponent.updateContent(this.streamingMessage);
1343
- this.ui.requestRender();
1344
- }
1345
- break;
1346
-
1347
- case "message_update":
1348
- if (this.streamingComponent && event.message.role === "assistant") {
1349
- this.streamingMessage = event.message;
1350
- this.streamingComponent.updateContent(this.streamingMessage);
1351
-
1352
- for (const content of this.streamingMessage.content) {
1353
- if (content.type === "toolCall") {
1354
- if (!this.pendingTools.has(content.id)) {
1355
- this.chatContainer.addChild(new Text("", 0, 0));
1356
- const tool = this.session.getToolByName(content.name);
1357
- const component = new ToolExecutionComponent(
1358
- content.name,
1359
- content.arguments,
1360
- {
1361
- showImages: this.settingsManager.getShowImages(),
1362
- },
1363
- tool,
1364
- this.ui,
1365
- this.sessionManager.getCwd(),
1366
- );
1367
- component.setExpanded(this.toolOutputExpanded);
1368
- this.chatContainer.addChild(component);
1369
- this.pendingTools.set(content.id, component);
1370
- } else {
1371
- const component = this.pendingTools.get(content.id);
1372
- if (component) {
1373
- component.updateArgs(content.arguments);
1374
- }
1375
- }
1376
- }
1377
- }
1378
- this.ui.requestRender();
1379
- }
1380
- break;
1381
-
1382
- case "message_end":
1383
- if (event.message.role === "user") break;
1384
- if (this.streamingComponent && event.message.role === "assistant") {
1385
- this.streamingMessage = event.message;
1386
- // Don't show "Aborted" text for TTSR aborts - we'll show a nicer message
1387
- if (this.session.isTtsrAbortPending && this.streamingMessage.stopReason === "aborted") {
1388
- // TTSR abort - suppress the "Aborted" rendering in the component
1389
- const msgWithoutAbort = { ...this.streamingMessage, stopReason: "stop" as const };
1390
- this.streamingComponent.updateContent(msgWithoutAbort);
1391
- } else {
1392
- this.streamingComponent.updateContent(this.streamingMessage);
1393
- }
1394
-
1395
- if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") {
1396
- // Skip error handling for TTSR aborts
1397
- if (!this.session.isTtsrAbortPending) {
1398
- let errorMessage: string;
1399
- if (this.streamingMessage.stopReason === "aborted") {
1400
- const retryAttempt = this.session.retryAttempt;
1401
- errorMessage =
1402
- retryAttempt > 0
1403
- ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
1404
- : "Operation aborted";
1405
- } else {
1406
- errorMessage = this.streamingMessage.errorMessage || "Error";
1407
- }
1408
- for (const [, component] of this.pendingTools.entries()) {
1409
- component.updateResult({
1410
- content: [{ type: "text", text: errorMessage }],
1411
- isError: true,
1412
- });
1413
- }
1414
- }
1415
- this.pendingTools.clear();
1416
- } else {
1417
- // Args are now complete - trigger diff computation for edit tools
1418
- for (const [, component] of this.pendingTools.entries()) {
1419
- component.setArgsComplete();
1420
- }
1421
- }
1422
- this.streamingComponent = undefined;
1423
- this.streamingMessage = undefined;
1424
- this.statusLine.invalidate();
1425
- this.updateEditorTopBorder();
1426
- }
1427
- this.ui.requestRender();
1428
- break;
1429
-
1430
- case "tool_execution_start": {
1431
- if (!this.pendingTools.has(event.toolCallId)) {
1432
- const tool = this.session.getToolByName(event.toolName);
1433
- const component = new ToolExecutionComponent(
1434
- event.toolName,
1435
- event.args,
1436
- {
1437
- showImages: this.settingsManager.getShowImages(),
1438
- },
1439
- tool,
1440
- this.ui,
1441
- this.sessionManager.getCwd(),
1442
- );
1443
- component.setExpanded(this.toolOutputExpanded);
1444
- this.chatContainer.addChild(component);
1445
- this.pendingTools.set(event.toolCallId, component);
1446
- this.ui.requestRender();
1447
- }
1448
- break;
1449
- }
530
+ handleArminSaysHi(): void {
531
+ this.commandController.handleArminSaysHi();
532
+ }
1450
533
 
1451
- case "tool_execution_update": {
1452
- const component = this.pendingTools.get(event.toolCallId);
1453
- if (component) {
1454
- component.updateResult({ ...event.partialResult, isError: false }, true);
1455
- this.ui.requestRender();
1456
- }
1457
- break;
1458
- }
534
+ handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void> {
535
+ return this.commandController.handleBashCommand(command, excludeFromContext);
536
+ }
1459
537
 
1460
- case "tool_execution_end": {
1461
- const component = this.pendingTools.get(event.toolCallId);
1462
- if (component) {
1463
- component.updateResult({ ...event.result, isError: event.isError });
1464
- this.pendingTools.delete(event.toolCallId);
1465
- this.ui.requestRender();
1466
- }
1467
- break;
1468
- }
538
+ handleCompactCommand(customInstructions?: string): Promise<void> {
539
+ return this.commandController.handleCompactCommand(customInstructions);
540
+ }
1469
541
 
1470
- case "agent_end":
1471
- this.stopVoiceProgressTimer();
1472
- if (this.loadingAnimation) {
1473
- this.loadingAnimation.stop();
1474
- this.loadingAnimation = undefined;
1475
- this.statusContainer.clear();
1476
- }
1477
- if (this.streamingComponent) {
1478
- this.chatContainer.removeChild(this.streamingComponent);
1479
- this.streamingComponent = undefined;
1480
- this.streamingMessage = undefined;
1481
- }
1482
- this.pendingTools.clear();
1483
- if (this.settingsManager.getVoiceEnabled() && this.voiceAutoModeEnabled) {
1484
- const lastAssistant = this.findLastAssistantMessage();
1485
- if (lastAssistant && lastAssistant.stopReason !== "aborted" && lastAssistant.stopReason !== "error") {
1486
- const text = this.extractAssistantText(lastAssistant);
1487
- if (text) {
1488
- this.voiceSupervisor.notifyResult(text);
1489
- }
1490
- }
1491
- }
1492
- this.ui.requestRender();
1493
- this.sendCompletionNotification();
1494
- break;
1495
-
1496
- case "auto_compaction_start": {
1497
- // Allow input during compaction; submissions are queued
1498
- // Set up escape to abort auto-compaction
1499
- this.autoCompactionEscapeHandler = this.editor.onEscape;
1500
- this.editor.onEscape = () => {
1501
- this.session.abortCompaction();
1502
- };
1503
- // Show compacting indicator with reason
1504
- this.statusContainer.clear();
1505
- const reasonText = event.reason === "overflow" ? "Context overflow detected, " : "";
1506
- this.autoCompactionLoader = new Loader(
1507
- this.ui,
1508
- (spinner) => theme.fg("accent", spinner),
1509
- (text) => theme.fg("muted", text),
1510
- `${reasonText}Auto-compacting${theme.format.ellipsis} (esc to cancel)`,
1511
- getSymbolTheme().spinnerFrames,
1512
- );
1513
- this.statusContainer.addChild(this.autoCompactionLoader);
1514
- this.ui.requestRender();
1515
- break;
1516
- }
542
+ executeCompaction(customInstructions?: string, isAuto?: boolean): Promise<void> {
543
+ return this.commandController.executeCompaction(customInstructions, isAuto);
544
+ }
1517
545
 
1518
- case "auto_compaction_end": {
1519
- // Restore escape handler
1520
- if (this.autoCompactionEscapeHandler) {
1521
- this.editor.onEscape = this.autoCompactionEscapeHandler;
1522
- this.autoCompactionEscapeHandler = undefined;
1523
- }
1524
- // Stop loader
1525
- if (this.autoCompactionLoader) {
1526
- this.autoCompactionLoader.stop();
1527
- this.autoCompactionLoader = undefined;
1528
- this.statusContainer.clear();
1529
- }
1530
- // Handle result
1531
- if (event.aborted) {
1532
- this.showStatus("Auto-compaction cancelled");
1533
- } else if (event.result) {
1534
- // Rebuild chat to show compacted state
1535
- this.chatContainer.clear();
1536
- this.rebuildChatFromMessages();
1537
- // Add compaction component at bottom so user sees it without scrolling
1538
- this.addMessageToChat({
1539
- role: "compactionSummary",
1540
- tokensBefore: event.result.tokensBefore,
1541
- summary: event.result.summary,
1542
- timestamp: Date.now(),
1543
- });
1544
- this.statusLine.invalidate();
1545
- this.updateEditorTopBorder();
1546
- }
1547
- await this.flushCompactionQueue({ willRetry: event.willRetry });
1548
- this.ui.requestRender();
1549
- break;
1550
- }
546
+ openInBrowser(urlOrPath: string): void {
547
+ this.commandController.openInBrowser(urlOrPath);
548
+ }
1551
549
 
1552
- case "auto_retry_start": {
1553
- // Set up escape to abort retry
1554
- this.retryEscapeHandler = this.editor.onEscape;
1555
- this.editor.onEscape = () => {
1556
- this.session.abortRetry();
1557
- };
1558
- // Show retry indicator
1559
- this.statusContainer.clear();
1560
- const delaySeconds = Math.round(event.delayMs / 1000);
1561
- this.retryLoader = new Loader(
1562
- this.ui,
1563
- (spinner) => theme.fg("warning", spinner),
1564
- (text) => theme.fg("muted", text),
1565
- `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s${theme.format.ellipsis} (esc to cancel)`,
1566
- getSymbolTheme().spinnerFrames,
1567
- );
1568
- this.statusContainer.addChild(this.retryLoader);
1569
- this.ui.requestRender();
1570
- break;
1571
- }
550
+ // Selector handling
551
+ showSettingsSelector(): void {
552
+ this.selectorController.showSettingsSelector();
553
+ }
1572
554
 
1573
- case "auto_retry_end": {
1574
- // Restore escape handler
1575
- if (this.retryEscapeHandler) {
1576
- this.editor.onEscape = this.retryEscapeHandler;
1577
- this.retryEscapeHandler = undefined;
1578
- }
1579
- // Stop loader
1580
- if (this.retryLoader) {
1581
- this.retryLoader.stop();
1582
- this.retryLoader = undefined;
1583
- this.statusContainer.clear();
1584
- }
1585
- // Show error only on final failure (success shows normal response)
1586
- if (!event.success) {
1587
- this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
1588
- }
1589
- this.ui.requestRender();
1590
- break;
1591
- }
555
+ showHistorySearch(): void {
556
+ this.selectorController.showHistorySearch();
557
+ }
1592
558
 
1593
- case "ttsr_triggered": {
1594
- // Show a fancy notification when TTSR rules are triggered
1595
- const component = new TtsrNotificationComponent(event.rules);
1596
- component.setExpanded(this.toolOutputExpanded);
1597
- this.chatContainer.addChild(component);
1598
- this.ui.requestRender();
1599
- break;
1600
- }
1601
- }
559
+ showExtensionsDashboard(): void {
560
+ this.selectorController.showExtensionsDashboard();
1602
561
  }
1603
562
 
1604
- private sendCompletionNotification(): void {
1605
- if (this.isBackgrounded === false) return;
1606
- if (isNotificationSuppressed()) return;
1607
- const method = this.settingsManager.getNotificationOnComplete();
1608
- if (method === "off") return;
1609
- const protocol = method === "auto" ? detectNotificationProtocol() : method;
1610
- const title = this.sessionManager.getSessionTitle();
1611
- const message = title ? `${title}: Complete` : "Complete";
1612
- sendNotification(protocol, message);
1613
- }
1614
-
1615
- /** Extract text content from a user message */
1616
- private getUserMessageText(message: Message): string {
1617
- if (message.role !== "user") return "";
1618
- const textBlocks =
1619
- typeof message.content === "string"
1620
- ? [{ type: "text", text: message.content }]
1621
- : message.content.filter((c: { type: string }) => c.type === "text");
1622
- return textBlocks.map((c) => (c as { text: string }).text).join("");
1623
- }
1624
-
1625
- /**
1626
- * Show a status message in the chat.
1627
- *
1628
- * If multiple status messages are emitted back-to-back (without anything else being added to the chat),
1629
- * we update the previous status line instead of appending new ones to avoid log spam.
1630
- */
1631
- private showStatus(message: string, options?: { dim?: boolean }): void {
1632
- if (this.isBackgrounded) {
1633
- return;
1634
- }
1635
- const children = this.chatContainer.children;
1636
- const last = children.length > 0 ? children[children.length - 1] : undefined;
1637
- const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
1638
- const useDim = options?.dim ?? true;
1639
- const rendered = useDim ? theme.fg("dim", message) : message;
1640
-
1641
- if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {
1642
- this.lastStatusText.setText(rendered);
1643
- this.ui.requestRender();
1644
- return;
1645
- }
563
+ showModelSelector(options?: { temporaryOnly?: boolean }): void {
564
+ this.selectorController.showModelSelector(options);
565
+ }
1646
566
 
1647
- const spacer = new Spacer(1);
1648
- const text = new Text(rendered, 1, 0);
1649
- this.chatContainer.addChild(spacer);
1650
- this.chatContainer.addChild(text);
1651
- this.lastStatusSpacer = spacer;
1652
- this.lastStatusText = text;
1653
- this.ui.requestRender();
567
+ showUserMessageSelector(): void {
568
+ this.selectorController.showUserMessageSelector();
1654
569
  }
1655
570
 
1656
- private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
1657
- switch (message.role) {
1658
- case "bashExecution": {
1659
- const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext);
1660
- if (message.output) {
1661
- component.appendOutput(message.output);
1662
- }
1663
- component.setComplete(
1664
- message.exitCode,
1665
- message.cancelled,
1666
- message.truncated ? ({ truncated: true } as TruncationResult) : undefined,
1667
- message.fullOutputPath,
1668
- );
1669
- this.chatContainer.addChild(component);
1670
- break;
1671
- }
1672
- case "hookMessage":
1673
- case "custom": {
1674
- if (message.display) {
1675
- const renderer = this.session.extensionRunner?.getMessageRenderer(message.customType);
1676
- // Both HookMessage and CustomMessage have the same structure, cast for compatibility
1677
- this.chatContainer.addChild(new CustomMessageComponent(message as CustomMessage<unknown>, renderer));
1678
- }
1679
- break;
1680
- }
1681
- case "compactionSummary": {
1682
- this.chatContainer.addChild(new Spacer(1));
1683
- const component = new CompactionSummaryMessageComponent(message);
1684
- component.setExpanded(this.toolOutputExpanded);
1685
- this.chatContainer.addChild(component);
1686
- break;
1687
- }
1688
- case "branchSummary": {
1689
- this.chatContainer.addChild(new Spacer(1));
1690
- const component = new BranchSummaryMessageComponent(message);
1691
- component.setExpanded(this.toolOutputExpanded);
1692
- this.chatContainer.addChild(component);
1693
- break;
1694
- }
1695
- case "fileMention": {
1696
- // Render compact file mention display
1697
- for (const file of message.files) {
1698
- const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
1699
- "accent",
1700
- file.path,
1701
- )} ${theme.fg("dim", `(${file.lineCount} lines)`)}`;
1702
- this.chatContainer.addChild(new Text(text, 0, 0));
1703
- }
1704
- break;
1705
- }
1706
- case "user": {
1707
- const textContent = this.getUserMessageText(message);
1708
- if (textContent) {
1709
- const userComponent = new UserMessageComponent(textContent);
1710
- this.chatContainer.addChild(userComponent);
1711
- if (options?.populateHistory) {
1712
- this.editor.addToHistory(textContent);
1713
- }
1714
- }
1715
- break;
1716
- }
1717
- case "assistant": {
1718
- const assistantComponent = new AssistantMessageComponent(message, this.hideThinkingBlock);
1719
- this.chatContainer.addChild(assistantComponent);
1720
- break;
1721
- }
1722
- case "toolResult": {
1723
- // Tool results are rendered inline with tool calls, handled separately
1724
- break;
1725
- }
1726
- default: {
1727
- const _exhaustive: never = message;
1728
- }
1729
- }
571
+ showTreeSelector(): void {
572
+ this.selectorController.showTreeSelector();
1730
573
  }
1731
574
 
1732
- /**
1733
- * Render session context to chat. Used for initial load and rebuild after compaction.
1734
- * @param sessionContext Session context to render
1735
- * @param options.updateFooter Update footer state
1736
- * @param options.populateHistory Add user messages to editor history
1737
- */
1738
- private renderSessionContext(
1739
- sessionContext: SessionContext,
1740
- options: { updateFooter?: boolean; populateHistory?: boolean } = {},
1741
- ): void {
1742
- this.pendingTools.clear();
575
+ showSessionSelector(): void {
576
+ this.selectorController.showSessionSelector();
577
+ }
1743
578
 
1744
- if (options.updateFooter) {
1745
- this.statusLine.invalidate();
1746
- this.updateEditorBorderColor();
1747
- }
579
+ handleResumeSession(sessionPath: string): Promise<void> {
580
+ return this.selectorController.handleResumeSession(sessionPath);
581
+ }
1748
582
 
1749
- for (const message of sessionContext.messages) {
1750
- // Assistant messages need special handling for tool calls
1751
- if (message.role === "assistant") {
1752
- this.addMessageToChat(message);
1753
- // Render tool call components
1754
- for (const content of message.content) {
1755
- if (content.type === "toolCall") {
1756
- const tool = this.session.getToolByName(content.name);
1757
- const component = new ToolExecutionComponent(
1758
- content.name,
1759
- content.arguments,
1760
- { showImages: this.settingsManager.getShowImages() },
1761
- tool,
1762
- this.ui,
1763
- this.sessionManager.getCwd(),
1764
- );
1765
- component.setExpanded(this.toolOutputExpanded);
1766
- this.chatContainer.addChild(component);
1767
-
1768
- if (message.stopReason === "aborted" || message.stopReason === "error") {
1769
- let errorMessage: string;
1770
- if (message.stopReason === "aborted") {
1771
- const retryAttempt = this.session.retryAttempt;
1772
- errorMessage =
1773
- retryAttempt > 0
1774
- ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
1775
- : "Operation aborted";
1776
- } else {
1777
- errorMessage = message.errorMessage || "Error";
1778
- }
1779
- component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
1780
- } else {
1781
- this.pendingTools.set(content.id, component);
1782
- }
1783
- }
1784
- }
1785
- } else if (message.role === "toolResult") {
1786
- // Match tool results to pending tool components
1787
- const component = this.pendingTools.get(message.toolCallId);
1788
- if (component) {
1789
- component.updateResult(message);
1790
- this.pendingTools.delete(message.toolCallId);
1791
- }
1792
- } else {
1793
- // All other messages use standard rendering
1794
- this.addMessageToChat(message, options);
1795
- }
1796
- }
583
+ showOAuthSelector(mode: "login" | "logout"): Promise<void> {
584
+ return this.selectorController.showOAuthSelector(mode);
585
+ }
1797
586
 
1798
- this.pendingTools.clear();
1799
- this.ui.requestRender();
587
+ showHookConfirm(title: string, message: string): Promise<boolean> {
588
+ return this.extensionUiController.showHookConfirm(title, message);
1800
589
  }
1801
590
 
1802
- renderInitialMessages(): void {
1803
- // Get aligned messages and entries from session context
1804
- const context = this.sessionManager.buildSessionContext();
1805
- this.renderSessionContext(context, {
1806
- updateFooter: true,
1807
- populateHistory: true,
1808
- });
591
+ // Input handling
592
+ handleCtrlC(): void {
593
+ this.inputController.handleCtrlC();
594
+ }
1809
595
 
1810
- // Show compaction info if session was compacted
1811
- const allEntries = this.sessionManager.getEntries();
1812
- const compactionCount = allEntries.filter((e) => e.type === "compaction").length;
1813
- if (compactionCount > 0) {
1814
- const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`;
1815
- this.showStatus(`Session compacted ${times}`);
1816
- }
596
+ handleCtrlD(): void {
597
+ this.inputController.handleCtrlD();
1817
598
  }
1818
599
 
1819
- async getUserInput(): Promise<{ text: string; images?: ImageContent[] }> {
1820
- return new Promise((resolve) => {
1821
- this.onInputCallback = (input) => {
1822
- this.onInputCallback = undefined;
1823
- resolve(input);
1824
- };
1825
- });
600
+ handleCtrlZ(): void {
601
+ this.inputController.handleCtrlZ();
1826
602
  }
1827
603
 
1828
- private rebuildChatFromMessages(): void {
1829
- this.chatContainer.clear();
1830
- const context = this.sessionManager.buildSessionContext();
1831
- this.renderSessionContext(context);
604
+ handleDequeue(): void {
605
+ this.inputController.handleDequeue();
1832
606
  }
1833
607
 
1834
- // =========================================================================
1835
- // Key handlers
1836
- // =========================================================================
608
+ handleBackgroundCommand(): void {
609
+ this.inputController.handleBackgroundCommand();
610
+ }
1837
611
 
1838
- private handleCtrlC(): void {
1839
- const now = Date.now();
1840
- if (now - this.lastSigintTime < 500) {
1841
- void this.shutdown();
1842
- } else {
1843
- this.clearEditor();
1844
- this.lastSigintTime = now;
1845
- }
612
+ handleImagePaste(): Promise<boolean> {
613
+ return this.inputController.handleImagePaste();
1846
614
  }
1847
615
 
1848
- private handleCtrlD(): void {
1849
- // Only called when editor is empty (enforced by CustomEditor)
1850
- void this.shutdown();
616
+ cycleThinkingLevel(): void {
617
+ this.inputController.cycleThinkingLevel();
1851
618
  }
1852
619
 
1853
- /**
1854
- * Gracefully shutdown the agent.
1855
- * Emits shutdown event to hooks and tools, then exits.
1856
- */
1857
- private async shutdown(): Promise<void> {
1858
- this.voiceAutoModeEnabled = false;
1859
- await this.voiceSupervisor.stop();
620
+ cycleRoleModel(options?: { temporary?: boolean }): Promise<void> {
621
+ return this.inputController.cycleRoleModel(options);
622
+ }
1860
623
 
1861
- // Flush pending session writes before shutdown
1862
- await this.sessionManager.flush();
624
+ toggleToolOutputExpansion(): void {
625
+ this.inputController.toggleToolOutputExpansion();
626
+ }
1863
627
 
1864
- // Emit shutdown event to hooks
1865
- await this.session.emitCustomToolSessionEvent("shutdown");
628
+ toggleThinkingBlockVisibility(): void {
629
+ this.inputController.toggleThinkingBlockVisibility();
630
+ }
1866
631
 
1867
- this.stop();
1868
- process.exit(0);
632
+ openExternalEditor(): void {
633
+ this.inputController.openExternalEditor();
1869
634
  }
1870
635
 
1871
- private handleCtrlZ(): void {
1872
- // Set up handler to restore TUI when resumed
1873
- process.once("SIGCONT", () => {
1874
- this.ui.start();
1875
- this.ui.requestRender(true);
1876
- });
1877
-
1878
- // Stop the TUI (restore terminal to normal mode)
1879
- this.ui.stop();
1880
-
1881
- // Send SIGTSTP to process group (pid=0 means all processes in group)
1882
- process.kill(0, "SIGTSTP");
1883
- }
1884
-
1885
- /**
1886
- * Handle Alt+Up: pop the last queued message and restore it to the editor.
1887
- */
1888
- private handleDequeue(): void {
1889
- const message = this.session.popLastQueuedMessage();
1890
- if (!message) return;
1891
-
1892
- // Prepend to existing editor text (if any)
1893
- const currentText = this.editor.getText();
1894
- const newText = currentText ? `${message}\n\n${currentText}` : message;
1895
- this.editor.setText(newText);
1896
- this.updatePendingMessagesDisplay();
1897
- this.ui.requestRender();
636
+ registerExtensionShortcuts(): void {
637
+ this.inputController.registerExtensionShortcuts();
1898
638
  }
1899
639
 
1900
- private handleBackgroundCommand(): void {
1901
- if (this.isBackgrounded) {
1902
- this.showStatus("Background mode already enabled");
1903
- return;
1904
- }
1905
- if (!this.session.isStreaming && this.session.queuedMessageCount === 0) {
1906
- this.showWarning("Agent is idle; nothing to background");
1907
- return;
1908
- }
1909
-
1910
- this.isBackgrounded = true;
1911
- const backgroundUiContext = this.createBackgroundUiContext();
1912
-
1913
- // Background mode disables interactive UI so tools like ask fail fast.
1914
- this.setToolUIContext(backgroundUiContext, false);
1915
- this.initializeHookRunner(backgroundUiContext, false);
1916
-
1917
- if (this.loadingAnimation) {
1918
- this.loadingAnimation.stop();
1919
- this.loadingAnimation = undefined;
1920
- }
1921
- if (this.autoCompactionLoader) {
1922
- this.autoCompactionLoader.stop();
1923
- this.autoCompactionLoader = undefined;
1924
- }
1925
- if (this.retryLoader) {
1926
- this.retryLoader.stop();
1927
- this.retryLoader = undefined;
1928
- }
1929
- this.statusContainer.clear();
1930
- this.statusLine.dispose();
1931
-
1932
- if (this.unsubscribe) {
1933
- this.unsubscribe();
1934
- }
1935
- this.unsubscribe = this.session.subscribe(async (event) => {
1936
- await this.handleBackgroundEvent(event);
1937
- });
1938
-
1939
- // Backgrounding keeps the current process to preserve in-flight agent state.
1940
- if (this.isInitialized) {
1941
- this.ui.stop();
1942
- this.isInitialized = false;
1943
- }
1944
-
1945
- process.stdout.write("Background mode enabled. Run `bg` to continue in background.\n");
1946
-
1947
- if (process.platform === "win32" || !process.stdout.isTTY) {
1948
- process.stdout.write("Backgrounding requires POSIX job control; continuing in foreground.\n");
1949
- return;
1950
- }
1951
-
1952
- process.kill(0, "SIGTSTP");
640
+ // Voice handling
641
+ setVoiceStatus(text: string | undefined): void {
642
+ this.voiceManager.setVoiceStatus(text);
1953
643
  }
1954
644
 
1955
- private async handleBackgroundEvent(event: AgentSessionEvent): Promise<void> {
1956
- if (event.type !== "agent_end") {
1957
- return;
1958
- }
1959
- if (this.session.queuedMessageCount > 0 || this.session.isStreaming) {
1960
- return;
1961
- }
1962
- this.sendCompletionNotification();
1963
- await this.shutdown();
645
+ handleVoiceInterrupt(reason?: string): Promise<void> {
646
+ return this.voiceManager.handleVoiceInterrupt(reason);
1964
647
  }
1965
648
 
1966
- /**
1967
- * Handle Ctrl+V for image paste from clipboard.
1968
- * Returns true if an image was found and added, false otherwise.
1969
- */
1970
- private async handleImagePaste(): Promise<boolean> {
1971
- try {
1972
- const image = await readImageFromClipboard();
1973
- if (image) {
1974
- let imageData = image;
1975
- if (this.settingsManager.getImageAutoResize()) {
1976
- try {
1977
- const resized = await resizeImage({
1978
- type: "image",
1979
- data: image.data,
1980
- mimeType: image.mimeType,
1981
- });
1982
- imageData = { data: resized.data, mimeType: resized.mimeType };
1983
- } catch {
1984
- imageData = image;
1985
- }
1986
- }
1987
-
1988
- this.pendingImages.push({
1989
- type: "image",
1990
- data: imageData.data,
1991
- mimeType: imageData.mimeType,
1992
- });
1993
- // Insert styled placeholder at cursor like Claude does
1994
- const imageNum = this.pendingImages.length;
1995
- const placeholder = theme.bold(theme.underline(`[Image #${imageNum}]`));
1996
- this.editor.insertText(`${placeholder} `);
1997
- this.ui.requestRender();
1998
- return true;
1999
- }
2000
- // No image in clipboard - show hint
2001
- this.showStatus("No image in clipboard (use terminal paste for text)");
2002
- return false;
2003
- } catch {
2004
- this.showStatus("Failed to read clipboard");
2005
- return false;
2006
- }
649
+ startVoiceProgressTimer(): void {
650
+ this.voiceManager.startVoiceProgressTimer();
2007
651
  }
2008
652
 
2009
- private setVoiceStatus(text: string | undefined): void {
2010
- this.statusLine.setHookStatus("voice", text);
2011
- this.ui.requestRender();
653
+ stopVoiceProgressTimer(): void {
654
+ this.voiceManager.stopVoiceProgressTimer();
2012
655
  }
2013
656
 
2014
- private async handleVoiceInterrupt(reason?: string): Promise<void> {
2015
- const now = Date.now();
2016
- if (now - this.lastVoiceInterruptAt < 200) return;
2017
- this.lastVoiceInterruptAt = now;
2018
- if (this.session.isBashRunning) {
2019
- this.session.abortBash();
2020
- }
2021
- if (this.session.isStreaming) {
2022
- await this.session.abort();
2023
- }
2024
- if (reason) {
2025
- this.showStatus(reason);
2026
- }
657
+ maybeSpeakProgress(): Promise<void> {
658
+ return this.voiceManager.maybeSpeakProgress();
2027
659
  }
2028
660
 
2029
- private stopVoiceProgressTimer(): void {
2030
- if (this.voiceProgressTimer) {
2031
- clearTimeout(this.voiceProgressTimer);
2032
- this.voiceProgressTimer = undefined;
2033
- }
661
+ submitVoiceText(text: string): Promise<void> {
662
+ return this.voiceManager.submitVoiceText(text);
2034
663
  }
2035
664
 
2036
- private startVoiceProgressTimer(): void {
2037
- this.stopVoiceProgressTimer();
2038
- if (!this.settingsManager.getVoiceEnabled() || !this.voiceAutoModeEnabled) return;
2039
- this.voiceProgressSpoken = false;
2040
- this.voiceProgressLastLength = 0;
2041
- this.voiceProgressTimer = setTimeout(() => {
2042
- void this.maybeSpeakProgress();
2043
- }, VOICE_PROGRESS_DELAY_MS);
2044
- }
2045
-
2046
- private async maybeSpeakProgress(): Promise<void> {
2047
- if (!this.session.isStreaming || this.voiceProgressSpoken || !this.voiceAutoModeEnabled) return;
2048
- const streaming = this.streamingMessage;
2049
- if (!streaming) return;
2050
- const text = this.extractAssistantText(streaming);
2051
- if (!text || text.length < VOICE_PROGRESS_MIN_CHARS) {
2052
- if (this.session.isStreaming) {
2053
- this.voiceProgressTimer = setTimeout(() => {
2054
- void this.maybeSpeakProgress();
2055
- }, VOICE_PROGRESS_DELAY_MS);
2056
- }
2057
- return;
2058
- }
2059
-
2060
- const delta = text.length - this.voiceProgressLastLength;
2061
- if (delta < VOICE_PROGRESS_DELTA_CHARS) {
2062
- if (this.session.isStreaming) {
2063
- this.voiceProgressTimer = setTimeout(() => {
2064
- void this.maybeSpeakProgress();
2065
- }, VOICE_PROGRESS_DELAY_MS);
2066
- }
2067
- return;
2068
- }
2069
-
2070
- this.voiceProgressLastLength = text.length;
2071
- this.voiceProgressSpoken = true;
2072
- this.voiceSupervisor.notifyProgress(text);
2073
- }
2074
-
2075
- private async submitVoiceText(text: string): Promise<void> {
2076
- const cleaned = text.trim();
2077
- if (!cleaned) {
2078
- this.showWarning("No speech detected. Try again.");
2079
- return;
2080
- }
2081
- const toSend = cleaned;
2082
- this.editor.addToHistory(toSend);
2083
-
2084
- if (this.session.isStreaming) {
2085
- await this.session.abort();
2086
- await this.session.steer(toSend);
2087
- this.updatePendingMessagesDisplay();
2088
- return;
2089
- }
2090
-
2091
- if (this.onInputCallback) {
2092
- this.onInputCallback({ text: toSend });
2093
- }
2094
- }
2095
-
2096
- private findLastAssistantMessage(): AssistantMessage | undefined {
2097
- for (let i = this.session.messages.length - 1; i >= 0; i--) {
2098
- const message = this.session.messages[i];
2099
- if (message?.role === "assistant") {
2100
- return message as AssistantMessage;
2101
- }
2102
- }
2103
- return undefined;
2104
- }
2105
-
2106
- private extractAssistantText(message: AssistantMessage): string {
2107
- let text = "";
2108
- for (const content of message.content) {
2109
- if (content.type === "text") {
2110
- text += content.text;
2111
- }
2112
- }
2113
- return text.trim();
2114
- }
2115
-
2116
- private updateEditorBorderColor(): void {
2117
- if (this.isBashMode) {
2118
- this.editor.borderColor = theme.getBashModeBorderColor();
2119
- } else {
2120
- const level = this.session.thinkingLevel || "off";
2121
- this.editor.borderColor = theme.getThinkingBorderColor(level);
2122
- }
2123
- // Update footer content in editor's top border
2124
- this.updateEditorTopBorder();
2125
- this.ui.requestRender();
2126
- }
2127
-
2128
- private updateEditorTopBorder(): void {
2129
- const width = this.ui.getWidth();
2130
- const topBorder = this.statusLine.getTopBorder(width);
2131
- this.editor.setTopBorder(topBorder);
2132
- }
2133
-
2134
- private cycleThinkingLevel(): void {
2135
- const newLevel = this.session.cycleThinkingLevel();
2136
- if (newLevel === undefined) {
2137
- this.showStatus("Current model does not support thinking");
2138
- } else {
2139
- this.statusLine.invalidate();
2140
- this.updateEditorBorderColor();
2141
- }
2142
- }
2143
-
2144
- private async cycleRoleModel(options?: { temporary?: boolean }): Promise<void> {
2145
- try {
2146
- const roleOrder = ["slow", "default", "smol"];
2147
- const result = await this.session.cycleRoleModels(roleOrder, options);
2148
- if (!result) {
2149
- this.showStatus("Only one role model available");
2150
- return;
2151
- }
2152
-
2153
- this.statusLine.invalidate();
2154
- this.updateEditorBorderColor();
2155
- const roleLabel = result.role === "default" ? "default" : result.role;
2156
- const roleLabelStyled = theme.bold(theme.fg("accent", roleLabel));
2157
- const thinkingStr =
2158
- result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
2159
- const tempLabel = options?.temporary ? " (temporary)" : "";
2160
- const cycleSeparator = theme.fg("dim", " > ");
2161
- const cycleLabel = roleOrder
2162
- .map((role) => {
2163
- if (role === result.role) {
2164
- return theme.bold(theme.fg("accent", role));
2165
- }
2166
- return theme.fg("muted", role);
2167
- })
2168
- .join(cycleSeparator);
2169
- const orderLabel = ` (cycle: ${cycleLabel})`;
2170
- this.showStatus(
2171
- `Switched to ${roleLabelStyled}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}${orderLabel}`,
2172
- { dim: false },
2173
- );
2174
- } catch (error) {
2175
- this.showError(error instanceof Error ? error.message : String(error));
2176
- }
2177
- }
2178
-
2179
- private toggleToolOutputExpansion(): void {
2180
- this.toolOutputExpanded = !this.toolOutputExpanded;
2181
- for (const child of this.chatContainer.children) {
2182
- if (isExpandable(child)) {
2183
- child.setExpanded(this.toolOutputExpanded);
2184
- }
2185
- }
2186
- this.ui.requestRender();
2187
- }
2188
-
2189
- private toggleThinkingBlockVisibility(): void {
2190
- this.hideThinkingBlock = !this.hideThinkingBlock;
2191
- this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);
2192
-
2193
- // Rebuild chat from session messages
2194
- this.chatContainer.clear();
2195
- this.rebuildChatFromMessages();
2196
-
2197
- // If streaming, re-add the streaming component with updated visibility and re-render
2198
- if (this.streamingComponent && this.streamingMessage) {
2199
- this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock);
2200
- this.streamingComponent.updateContent(this.streamingMessage);
2201
- this.chatContainer.addChild(this.streamingComponent);
2202
- }
2203
-
2204
- this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
2205
- }
2206
-
2207
- private openExternalEditor(): void {
2208
- // Determine editor (respect $VISUAL, then $EDITOR)
2209
- const editorCmd = process.env.VISUAL || process.env.EDITOR;
2210
- if (!editorCmd) {
2211
- this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
2212
- return;
2213
- }
2214
-
2215
- const currentText = this.editor.getText();
2216
- const tmpFile = path.join(os.tmpdir(), `omp-editor-${nanoid()}.omp.md`);
2217
-
2218
- try {
2219
- // Write current content to temp file
2220
- fs.writeFileSync(tmpFile, currentText, "utf-8");
2221
-
2222
- // Stop TUI to release terminal
2223
- this.ui.stop();
2224
-
2225
- // Split by space to support editor arguments (e.g., "code --wait")
2226
- const [editor, ...editorArgs] = editorCmd.split(" ");
2227
-
2228
- // Spawn editor synchronously with inherited stdio for interactive editing
2229
- const result = Bun.spawnSync([editor, ...editorArgs, tmpFile], {
2230
- stdin: "inherit",
2231
- stdout: "inherit",
2232
- stderr: "inherit",
2233
- });
2234
-
2235
- // On successful exit (exitCode 0), replace editor content
2236
- if (result.exitCode === 0) {
2237
- const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
2238
- this.editor.setText(newContent);
2239
- }
2240
- // On non-zero exit, keep original text (no action needed)
2241
- } finally {
2242
- // Clean up temp file
2243
- try {
2244
- fs.unlinkSync(tmpFile);
2245
- } catch {
2246
- // Ignore cleanup errors
2247
- }
2248
-
2249
- // Restart TUI
2250
- this.ui.start();
2251
- this.ui.requestRender();
2252
- }
2253
- }
2254
-
2255
- // =========================================================================
2256
- // UI helpers
2257
- // =========================================================================
2258
-
2259
- clearEditor(): void {
2260
- if (this.isBackgrounded) {
2261
- return;
2262
- }
2263
- this.editor.setText("");
2264
- this.pendingImages = [];
2265
- this.ui.requestRender();
2266
- }
2267
-
2268
- showError(errorMessage: string): void {
2269
- if (this.isBackgrounded) {
2270
- console.error(`Error: ${errorMessage}`);
2271
- return;
2272
- }
2273
- this.chatContainer.addChild(new Spacer(1));
2274
- this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
2275
- this.ui.requestRender();
2276
- }
2277
-
2278
- showWarning(warningMessage: string): void {
2279
- if (this.isBackgrounded) {
2280
- console.error(`Warning: ${warningMessage}`);
2281
- return;
2282
- }
2283
- this.chatContainer.addChild(new Spacer(1));
2284
- this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
2285
- this.ui.requestRender();
2286
- }
2287
-
2288
- showNewVersionNotification(newVersion: string): void {
2289
- this.chatContainer.addChild(new Spacer(1));
2290
- this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
2291
- this.chatContainer.addChild(
2292
- new Text(
2293
- theme.bold(theme.fg("warning", "Update Available")) +
2294
- "\n" +
2295
- theme.fg("muted", `New version ${newVersion} is available. Run: `) +
2296
- theme.fg("accent", "omp update"),
2297
- 1,
2298
- 0,
2299
- ),
2300
- );
2301
- this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
2302
- this.ui.requestRender();
2303
- }
2304
-
2305
- private updatePendingMessagesDisplay(): void {
2306
- this.pendingMessagesContainer.clear();
2307
- const queuedMessages = this.session.getQueuedMessages();
2308
- const steeringMessages = [
2309
- ...queuedMessages.steering.map((message) => ({ message, label: "Steer" })),
2310
- ...this.compactionQueuedMessages
2311
- .filter((entry) => entry.mode === "steer")
2312
- .map((entry) => ({ message: entry.text, label: "Steer" })),
2313
- ];
2314
- const followUpMessages = [
2315
- ...queuedMessages.followUp.map((message) => ({ message, label: "Follow-up" })),
2316
- ...this.compactionQueuedMessages
2317
- .filter((entry) => entry.mode === "followUp")
2318
- .map((entry) => ({ message: entry.text, label: "Follow-up" })),
2319
- ];
2320
- const allMessages = [...steeringMessages, ...followUpMessages];
2321
- if (allMessages.length > 0) {
2322
- this.pendingMessagesContainer.addChild(new Spacer(1));
2323
- for (const entry of allMessages) {
2324
- const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
2325
- this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
2326
- }
2327
- }
2328
- }
2329
-
2330
- private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
2331
- this.compactionQueuedMessages.push({ text, mode });
2332
- this.editor.addToHistory(text);
2333
- this.editor.setText("");
2334
- this.updatePendingMessagesDisplay();
2335
- this.showStatus("Queued message for after compaction");
2336
- }
2337
-
2338
- private isKnownSlashCommand(text: string): boolean {
2339
- if (!text.startsWith("/")) return false;
2340
- const spaceIndex = text.indexOf(" ");
2341
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
2342
- if (!commandName) return false;
2343
-
2344
- if (this.session.extensionRunner?.getCommand(commandName)) {
2345
- return true;
2346
- }
2347
-
2348
- if (this.session.customCommands.some((cmd) => cmd.command.name === commandName)) {
2349
- return true;
2350
- }
2351
-
2352
- return this.fileSlashCommands.has(commandName);
2353
- }
2354
-
2355
- private async flushCompactionQueue(options?: { willRetry?: boolean }): Promise<void> {
2356
- if (this.compactionQueuedMessages.length === 0) {
2357
- return;
2358
- }
2359
-
2360
- const queuedMessages = [...this.compactionQueuedMessages];
2361
- this.compactionQueuedMessages = [];
2362
- this.updatePendingMessagesDisplay();
2363
-
2364
- const restoreQueue = (error: unknown) => {
2365
- this.session.clearQueue();
2366
- this.compactionQueuedMessages = queuedMessages;
2367
- this.updatePendingMessagesDisplay();
2368
- this.showError(
2369
- `Failed to send queued message${queuedMessages.length > 1 ? "s" : ""}: ${
2370
- error instanceof Error ? error.message : String(error)
2371
- }`,
2372
- );
2373
- };
2374
-
2375
- try {
2376
- if (options?.willRetry) {
2377
- for (const message of queuedMessages) {
2378
- if (this.isKnownSlashCommand(message.text)) {
2379
- await this.session.prompt(message.text);
2380
- } else if (message.mode === "followUp") {
2381
- await this.session.followUp(message.text);
2382
- } else {
2383
- await this.session.steer(message.text);
2384
- }
2385
- }
2386
- this.updatePendingMessagesDisplay();
2387
- return;
2388
- }
2389
-
2390
- const firstPromptIndex = queuedMessages.findIndex((message) => !this.isKnownSlashCommand(message.text));
2391
- if (firstPromptIndex === -1) {
2392
- for (const message of queuedMessages) {
2393
- await this.session.prompt(message.text);
2394
- }
2395
- return;
2396
- }
2397
-
2398
- const preCommands = queuedMessages.slice(0, firstPromptIndex);
2399
- const firstPrompt = queuedMessages[firstPromptIndex];
2400
- const rest = queuedMessages.slice(firstPromptIndex + 1);
2401
-
2402
- for (const message of preCommands) {
2403
- await this.session.prompt(message.text);
2404
- }
2405
-
2406
- const promptPromise = this.session.prompt(firstPrompt.text).catch((error) => {
2407
- restoreQueue(error);
2408
- });
2409
-
2410
- for (const message of rest) {
2411
- if (this.isKnownSlashCommand(message.text)) {
2412
- await this.session.prompt(message.text);
2413
- } else if (message.mode === "followUp") {
2414
- await this.session.followUp(message.text);
2415
- } else {
2416
- await this.session.steer(message.text);
2417
- }
2418
- }
2419
- this.updatePendingMessagesDisplay();
2420
- void promptPromise;
2421
- } catch (error) {
2422
- restoreQueue(error);
2423
- }
2424
- }
2425
-
2426
- /** Move pending bash components from pending area to chat */
2427
- private flushPendingBashComponents(): void {
2428
- for (const component of this.pendingBashComponents) {
2429
- this.pendingMessagesContainer.removeChild(component);
2430
- this.chatContainer.addChild(component);
2431
- }
2432
- this.pendingBashComponents = [];
2433
- }
2434
-
2435
- // =========================================================================
2436
- // Selectors
2437
- // =========================================================================
2438
-
2439
- /**
2440
- * Shows a selector component in place of the editor.
2441
- * @param create Factory that receives a `done` callback and returns the component and focus target
2442
- */
2443
- private showSelector(create: (done: () => void) => { component: Component; focus: Component }): void {
2444
- const done = () => {
2445
- this.editorContainer.clear();
2446
- this.editorContainer.addChild(this.editor);
2447
- this.ui.setFocus(this.editor);
2448
- };
2449
- const { component, focus } = create(done);
2450
- this.editorContainer.clear();
2451
- this.editorContainer.addChild(component);
2452
- this.ui.setFocus(focus);
2453
- this.ui.requestRender();
665
+ // Hook UI methods
666
+ initHooksAndCustomTools(): Promise<void> {
667
+ return this.extensionUiController.initHooksAndCustomTools();
2454
668
  }
2455
669
 
2456
- private showSettingsSelector(): void {
2457
- this.showSelector((done) => {
2458
- const selector = new SettingsSelectorComponent(
2459
- this.settingsManager,
2460
- {
2461
- availableThinkingLevels: this.session.getAvailableThinkingLevels(),
2462
- thinkingLevel: this.session.thinkingLevel,
2463
- availableThemes: getAvailableThemes(),
2464
- cwd: process.cwd(),
2465
- },
2466
- {
2467
- onChange: (id, value) => this.handleSettingChange(id, value),
2468
- onThemePreview: (themeName) => {
2469
- const result = setTheme(themeName, true);
2470
- if (result.success) {
2471
- this.ui.invalidate();
2472
- this.ui.requestRender();
2473
- }
2474
- },
2475
- onStatusLinePreview: (settings) => {
2476
- // Update status line with preview settings
2477
- const currentSettings = this.settingsManager.getStatusLineSettings();
2478
- this.statusLine.updateSettings({ ...currentSettings, ...settings });
2479
- this.updateEditorTopBorder();
2480
- this.ui.requestRender();
2481
- },
2482
- getStatusLinePreview: () => {
2483
- // Return the rendered status line for inline preview
2484
- const width = this.ui.getWidth();
2485
- return this.statusLine.getTopBorder(width).content;
2486
- },
2487
- onPluginsChanged: () => {
2488
- this.ui.requestRender();
2489
- },
2490
- onCancel: () => {
2491
- done();
2492
- // Restore status line to saved settings
2493
- this.statusLine.updateSettings(this.settingsManager.getStatusLineSettings());
2494
- this.updateEditorTopBorder();
2495
- this.ui.requestRender();
2496
- },
2497
- },
2498
- );
2499
- return { component: selector, focus: selector };
2500
- });
2501
- }
2502
-
2503
- /**
2504
- * Show the Extension Control Center dashboard.
2505
- * Replaces /status with a unified view of all providers and extensions.
2506
- */
2507
- private showExtensionsDashboard(): void {
2508
- this.showSelector((done) => {
2509
- const dashboard = new ExtensionDashboard(process.cwd(), this.settingsManager, this.ui.terminal.rows);
2510
- dashboard.onClose = () => {
2511
- done();
2512
- this.ui.requestRender();
2513
- };
2514
- return { component: dashboard, focus: dashboard };
2515
- });
2516
- }
2517
-
2518
- /**
2519
- * Handle setting changes from the settings selector.
2520
- * Most settings are saved directly via SettingsManager in the definitions.
2521
- * This handles side effects and session-specific settings.
2522
- */
2523
- private handleSettingChange(id: string, value: string | boolean): void {
2524
- // Discovery provider toggles
2525
- if (id.startsWith("discovery.")) {
2526
- const providerId = id.replace("discovery.", "");
2527
- if (value) {
2528
- enableProvider(providerId);
2529
- } else {
2530
- disableProvider(providerId);
2531
- }
2532
- return;
2533
- }
2534
-
2535
- switch (id) {
2536
- // Session-managed settings (not in SettingsManager)
2537
- case "autoCompact":
2538
- this.session.setAutoCompactionEnabled(value as boolean);
2539
- this.statusLine.setAutoCompactEnabled(value as boolean);
2540
- break;
2541
- case "steeringMode":
2542
- this.session.setSteeringMode(value as "all" | "one-at-a-time");
2543
- break;
2544
- case "followUpMode":
2545
- this.session.setFollowUpMode(value as "all" | "one-at-a-time");
2546
- break;
2547
- case "interruptMode":
2548
- this.session.setInterruptMode(value as "immediate" | "wait");
2549
- break;
2550
- case "thinkingLevel":
2551
- this.session.setThinkingLevel(value as ThinkingLevel);
2552
- this.statusLine.invalidate();
2553
- this.updateEditorBorderColor();
2554
- break;
2555
-
2556
- // Settings with UI side effects
2557
- case "showImages":
2558
- for (const child of this.chatContainer.children) {
2559
- if (child instanceof ToolExecutionComponent) {
2560
- child.setShowImages(value as boolean);
2561
- }
2562
- }
2563
- break;
2564
- case "hideThinking":
2565
- this.hideThinkingBlock = value as boolean;
2566
- for (const child of this.chatContainer.children) {
2567
- if (child instanceof AssistantMessageComponent) {
2568
- child.setHideThinkingBlock(value as boolean);
2569
- }
2570
- }
2571
- this.chatContainer.clear();
2572
- this.rebuildChatFromMessages();
2573
- break;
2574
- case "theme": {
2575
- const result = setTheme(value as string, true);
2576
- this.statusLine.invalidate();
2577
- this.updateEditorTopBorder();
2578
- this.ui.invalidate();
2579
- if (!result.success) {
2580
- this.showError(`Failed to load theme "${value}": ${result.error}\nFell back to dark theme.`);
2581
- }
2582
- break;
2583
- }
2584
- case "symbolPreset": {
2585
- setSymbolPreset(value as "unicode" | "nerd" | "ascii");
2586
- this.statusLine.invalidate();
2587
- this.updateEditorTopBorder();
2588
- this.ui.invalidate();
2589
- break;
2590
- }
2591
- case "voiceEnabled": {
2592
- if (!value) {
2593
- this.voiceAutoModeEnabled = false;
2594
- this.stopVoiceProgressTimer();
2595
- void this.voiceSupervisor.stop();
2596
- this.setVoiceStatus(undefined);
2597
- }
2598
- break;
2599
- }
2600
- case "statusLinePreset":
2601
- case "statusLineSeparator":
2602
- case "statusLineShowHooks":
2603
- case "statusLineSegments":
2604
- case "statusLineModelThinking":
2605
- case "statusLinePathAbbreviate":
2606
- case "statusLinePathMaxLength":
2607
- case "statusLinePathStripWorkPrefix":
2608
- case "statusLineGitShowBranch":
2609
- case "statusLineGitShowStaged":
2610
- case "statusLineGitShowUnstaged":
2611
- case "statusLineGitShowUntracked":
2612
- case "statusLineTimeFormat":
2613
- case "statusLineTimeShowSeconds": {
2614
- this.statusLine.updateSettings(this.settingsManager.getStatusLineSettings());
2615
- this.updateEditorTopBorder();
2616
- this.ui.requestRender();
2617
- break;
2618
- }
2619
-
2620
- // Provider settings - update runtime preferences
2621
- case "webSearchProvider":
2622
- setPreferredWebSearchProvider(value as "auto" | "exa" | "perplexity" | "anthropic");
2623
- break;
2624
- case "imageProvider":
2625
- setPreferredImageProvider(value as "auto" | "gemini" | "openrouter");
2626
- break;
2627
-
2628
- // All other settings are handled by the definitions (get/set on SettingsManager)
2629
- // No additional side effects needed
2630
- }
2631
- }
2632
-
2633
- private showModelSelector(options?: { temporaryOnly?: boolean }): void {
2634
- this.showSelector((done) => {
2635
- const selector = new ModelSelectorComponent(
2636
- this.ui,
2637
- this.session.model,
2638
- this.settingsManager,
2639
- this.session.modelRegistry,
2640
- this.session.scopedModels,
2641
- async (model, role) => {
2642
- try {
2643
- if (role === "temporary") {
2644
- // Temporary: update agent state but don't persist to settings
2645
- await this.session.setModelTemporary(model);
2646
- this.statusLine.invalidate();
2647
- this.updateEditorBorderColor();
2648
- this.showStatus(`Temporary model: ${model.id}`);
2649
- done();
2650
- this.ui.requestRender();
2651
- } else if (role === "default") {
2652
- // Default: update agent state and persist
2653
- await this.session.setModel(model, role);
2654
- this.statusLine.invalidate();
2655
- this.updateEditorBorderColor();
2656
- this.showStatus(`Default model: ${model.id}`);
2657
- // Don't call done() - selector stays open for role assignment
2658
- } else {
2659
- // Other roles (smol, slow): just update settings, not current model
2660
- const roleLabel = role === "smol" ? "Smol" : role;
2661
- this.showStatus(`${roleLabel} model: ${model.id}`);
2662
- // Don't call done() - selector stays open
2663
- }
2664
- } catch (error) {
2665
- this.showError(error instanceof Error ? error.message : String(error));
2666
- }
2667
- },
2668
- () => {
2669
- done();
2670
- this.ui.requestRender();
2671
- },
2672
- options,
2673
- );
2674
- return { component: selector, focus: selector };
2675
- });
2676
- }
2677
-
2678
- private showUserMessageSelector(): void {
2679
- const userMessages = this.session.getUserMessagesForBranching();
2680
-
2681
- if (userMessages.length === 0) {
2682
- this.showStatus("No messages to branch from");
2683
- return;
2684
- }
2685
-
2686
- this.showSelector((done) => {
2687
- const selector = new UserMessageSelectorComponent(
2688
- userMessages.map((m) => ({ id: m.entryId, text: m.text })),
2689
- async (entryId) => {
2690
- const result = await this.session.branch(entryId);
2691
- if (result.cancelled) {
2692
- // Hook cancelled the branch
2693
- done();
2694
- this.ui.requestRender();
2695
- return;
2696
- }
2697
-
2698
- this.chatContainer.clear();
2699
- this.renderInitialMessages();
2700
- this.editor.setText(result.selectedText);
2701
- done();
2702
- this.showStatus("Branched to new session");
2703
- },
2704
- () => {
2705
- done();
2706
- this.ui.requestRender();
2707
- },
2708
- );
2709
- return { component: selector, focus: selector.getMessageList() };
2710
- });
2711
- }
2712
-
2713
- private showTreeSelector(): void {
2714
- const tree = this.sessionManager.getTree();
2715
- const realLeafId = this.sessionManager.getLeafId();
2716
-
2717
- // Find the visible leaf for display (skip metadata entries like labels)
2718
- let visibleLeafId = realLeafId;
2719
- while (visibleLeafId) {
2720
- const entry = this.sessionManager.getEntry(visibleLeafId);
2721
- if (!entry) break;
2722
- if (entry.type !== "label" && entry.type !== "custom") break;
2723
- visibleLeafId = entry.parentId ?? null;
2724
- }
2725
-
2726
- if (tree.length === 0) {
2727
- this.showStatus("No entries in session");
2728
- return;
2729
- }
2730
-
2731
- this.showSelector((done) => {
2732
- const selector = new TreeSelectorComponent(
2733
- tree,
2734
- visibleLeafId,
2735
- this.ui.terminal.rows,
2736
- async (entryId) => {
2737
- // Selecting the visible leaf is a no-op (already there)
2738
- if (entryId === visibleLeafId) {
2739
- done();
2740
- this.showStatus("Already at this point");
2741
- return;
2742
- }
2743
-
2744
- // Ask about summarization (or skip if disabled in settings)
2745
- done(); // Close selector first
2746
-
2747
- const branchSummariesEnabled = this.settingsManager.getBranchSummaryEnabled();
2748
- const wantsSummary = branchSummariesEnabled
2749
- ? await this.showHookConfirm("Summarize branch?", "Create a summary of the branch you're leaving?")
2750
- : false;
2751
-
2752
- // Set up escape handler and loader if summarizing
2753
- let summaryLoader: Loader | undefined;
2754
- const originalOnEscape = this.editor.onEscape;
2755
-
2756
- if (wantsSummary) {
2757
- this.editor.onEscape = () => {
2758
- this.session.abortBranchSummary();
2759
- };
2760
- this.chatContainer.addChild(new Spacer(1));
2761
- summaryLoader = new Loader(
2762
- this.ui,
2763
- (spinner) => theme.fg("accent", spinner),
2764
- (text) => theme.fg("muted", text),
2765
- "Summarizing branch... (esc to cancel)",
2766
- getSymbolTheme().spinnerFrames,
2767
- );
2768
- this.statusContainer.addChild(summaryLoader);
2769
- this.ui.requestRender();
2770
- }
2771
-
2772
- try {
2773
- const result = await this.session.navigateTree(entryId, { summarize: wantsSummary });
2774
-
2775
- if (result.aborted) {
2776
- // Summarization aborted - re-show tree selector
2777
- this.showStatus("Branch summarization cancelled");
2778
- this.showTreeSelector();
2779
- return;
2780
- }
2781
- if (result.cancelled) {
2782
- this.showStatus("Navigation cancelled");
2783
- return;
2784
- }
2785
-
2786
- // Update UI
2787
- this.chatContainer.clear();
2788
- this.renderInitialMessages();
2789
- if (result.editorText) {
2790
- this.editor.setText(result.editorText);
2791
- }
2792
- this.showStatus("Navigated to selected point");
2793
- } catch (error) {
2794
- this.showError(error instanceof Error ? error.message : String(error));
2795
- } finally {
2796
- if (summaryLoader) {
2797
- summaryLoader.stop();
2798
- this.statusContainer.clear();
2799
- }
2800
- this.editor.onEscape = originalOnEscape;
2801
- }
2802
- },
2803
- () => {
2804
- done();
2805
- this.ui.requestRender();
2806
- },
2807
- (entryId, label) => {
2808
- this.sessionManager.appendLabelChange(entryId, label);
2809
- this.ui.requestRender();
2810
- },
2811
- );
2812
- return { component: selector, focus: selector };
2813
- });
2814
- }
2815
-
2816
- private showSessionSelector(): void {
2817
- this.showSelector((done) => {
2818
- const sessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir());
2819
- const selector = new SessionSelectorComponent(
2820
- sessions,
2821
- async (sessionPath) => {
2822
- done();
2823
- await this.handleResumeSession(sessionPath);
2824
- },
2825
- () => {
2826
- done();
2827
- this.ui.requestRender();
2828
- },
2829
- () => {
2830
- void this.shutdown();
2831
- },
2832
- );
2833
- return { component: selector, focus: selector.getSessionList() };
2834
- });
2835
- }
2836
-
2837
- private async handleResumeSession(sessionPath: string): Promise<void> {
2838
- // Stop loading animation
2839
- if (this.loadingAnimation) {
2840
- this.loadingAnimation.stop();
2841
- this.loadingAnimation = undefined;
2842
- }
2843
- this.statusContainer.clear();
2844
-
2845
- // Clear UI state
2846
- this.pendingMessagesContainer.clear();
2847
- this.compactionQueuedMessages = [];
2848
- this.streamingComponent = undefined;
2849
- this.streamingMessage = undefined;
2850
- this.pendingTools.clear();
2851
-
2852
- // Switch session via AgentSession (emits hook and tool session events)
2853
- await this.session.switchSession(sessionPath);
2854
-
2855
- // Clear and re-render the chat
2856
- this.chatContainer.clear();
2857
- this.renderInitialMessages();
2858
- this.showStatus("Resumed session");
2859
- }
2860
-
2861
- private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
2862
- if (mode === "logout") {
2863
- const providers = this.session.modelRegistry.authStorage.list();
2864
- const loggedInProviders = providers.filter((p) => this.session.modelRegistry.authStorage.hasOAuth(p));
2865
- if (loggedInProviders.length === 0) {
2866
- this.showStatus("No OAuth providers logged in. Use /login first.");
2867
- return;
2868
- }
2869
- }
2870
-
2871
- this.showSelector((done) => {
2872
- const selector = new OAuthSelectorComponent(
2873
- mode,
2874
- this.session.modelRegistry.authStorage,
2875
- async (providerId: string) => {
2876
- done();
2877
-
2878
- if (mode === "login") {
2879
- this.showStatus(`Logging in to ${providerId}...`);
2880
-
2881
- try {
2882
- await this.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
2883
- onAuth: (info: { url: string; instructions?: string }) => {
2884
- this.chatContainer.addChild(new Spacer(1));
2885
- this.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0));
2886
- // Use OSC 8 hyperlink escape sequence for clickable link
2887
- const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
2888
- this.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
2889
- if (info.instructions) {
2890
- this.chatContainer.addChild(new Spacer(1));
2891
- this.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
2892
- }
2893
- this.ui.requestRender();
2894
-
2895
- this.openInBrowser(info.url);
2896
- },
2897
- onPrompt: async (prompt: { message: string; placeholder?: string }) => {
2898
- this.chatContainer.addChild(new Spacer(1));
2899
- this.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
2900
- if (prompt.placeholder) {
2901
- this.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
2902
- }
2903
- this.ui.requestRender();
2904
-
2905
- return new Promise<string>((resolve) => {
2906
- const codeInput = new Input();
2907
- codeInput.onSubmit = () => {
2908
- const code = codeInput.getValue();
2909
- this.editorContainer.clear();
2910
- this.editorContainer.addChild(this.editor);
2911
- this.ui.setFocus(this.editor);
2912
- resolve(code);
2913
- };
2914
- this.editorContainer.clear();
2915
- this.editorContainer.addChild(codeInput);
2916
- this.ui.setFocus(codeInput);
2917
- this.ui.requestRender();
2918
- });
2919
- },
2920
- onProgress: (message: string) => {
2921
- this.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
2922
- this.ui.requestRender();
2923
- },
2924
- });
2925
- // Refresh models to pick up new baseUrl (e.g., github-copilot)
2926
- await this.session.modelRegistry.refresh();
2927
- this.chatContainer.addChild(new Spacer(1));
2928
- this.chatContainer.addChild(
2929
- new Text(
2930
- theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`),
2931
- 1,
2932
- 0,
2933
- ),
2934
- );
2935
- this.chatContainer.addChild(
2936
- new Text(theme.fg("dim", `Credentials saved to ${getAuthPath()}`), 1, 0),
2937
- );
2938
- this.ui.requestRender();
2939
- } catch (error: unknown) {
2940
- this.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
2941
- }
2942
- } else {
2943
- try {
2944
- await this.session.modelRegistry.authStorage.logout(providerId);
2945
- // Refresh models to reset baseUrl
2946
- await this.session.modelRegistry.refresh();
2947
- this.chatContainer.addChild(new Spacer(1));
2948
- this.chatContainer.addChild(
2949
- new Text(
2950
- theme.fg("success", `${theme.status.success} Successfully logged out of ${providerId}`),
2951
- 1,
2952
- 0,
2953
- ),
2954
- );
2955
- this.chatContainer.addChild(
2956
- new Text(theme.fg("dim", `Credentials removed from ${getAuthPath()}`), 1, 0),
2957
- );
2958
- this.ui.requestRender();
2959
- } catch (error: unknown) {
2960
- this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
2961
- }
2962
- }
2963
- },
2964
- () => {
2965
- done();
2966
- this.ui.requestRender();
2967
- },
2968
- );
2969
- return { component: selector, focus: selector };
2970
- });
2971
- }
2972
-
2973
- // =========================================================================
2974
- // Command handlers
2975
- // =========================================================================
2976
-
2977
- private openInBrowser(urlOrPath: string): void {
2978
- try {
2979
- const args =
2980
- process.platform === "darwin"
2981
- ? ["open", urlOrPath]
2982
- : process.platform === "win32"
2983
- ? ["cmd", "/c", "start", "", urlOrPath]
2984
- : ["xdg-open", urlOrPath];
2985
- Bun.spawn(args, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
2986
- } catch {
2987
- // Best-effort: browser opening is non-critical
2988
- }
2989
- }
2990
-
2991
- private async handleExportCommand(text: string): Promise<void> {
2992
- const parts = text.split(/\s+/);
2993
- const arg = parts.length > 1 ? parts[1] : undefined;
2994
-
2995
- // Check for clipboard export
2996
- if (arg === "--copy" || arg === "clipboard" || arg === "copy") {
2997
- try {
2998
- const formatted = this.session.formatSessionAsText();
2999
- if (!formatted) {
3000
- this.showError("No messages to export yet.");
3001
- return;
3002
- }
3003
- await copyToClipboard(formatted);
3004
- this.showStatus("Session copied to clipboard");
3005
- } catch (error: unknown) {
3006
- this.showError(`Failed to copy session: ${error instanceof Error ? error.message : "Unknown error"}`);
3007
- }
3008
- return;
3009
- }
3010
-
3011
- // HTML file export
3012
- try {
3013
- const filePath = await this.session.exportToHtml(arg);
3014
- this.showStatus(`Session exported to: ${filePath}`);
3015
- this.openInBrowser(filePath);
3016
- } catch (error: unknown) {
3017
- this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
3018
- }
670
+ emitCustomToolSessionEvent(
671
+ reason: "start" | "switch" | "branch" | "tree" | "shutdown",
672
+ previousSessionFile?: string,
673
+ ): Promise<void> {
674
+ return this.extensionUiController.emitCustomToolSessionEvent(reason, previousSessionFile);
3019
675
  }
3020
676
 
3021
- private async handleShareCommand(): Promise<void> {
3022
- // Check if gh is available and logged in
3023
- try {
3024
- const authResult = Bun.spawnSync(["gh", "auth", "status"]);
3025
- if (authResult.exitCode !== 0) {
3026
- this.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
3027
- return;
3028
- }
3029
- } catch {
3030
- this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
3031
- return;
3032
- }
3033
-
3034
- // Export to a temp file
3035
- const tmpFile = path.join(os.tmpdir(), "session.html");
3036
- try {
3037
- await this.session.exportToHtml(tmpFile);
3038
- } catch (error: unknown) {
3039
- this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
3040
- return;
3041
- }
3042
-
3043
- // Show cancellable loader, replacing the editor
3044
- const loader = new BorderedLoader(this.ui, theme, "Creating gist...");
3045
- this.editorContainer.clear();
3046
- this.editorContainer.addChild(loader);
3047
- this.ui.setFocus(loader);
3048
- this.ui.requestRender();
3049
-
3050
- const restoreEditor = () => {
3051
- loader.dispose();
3052
- this.editorContainer.clear();
3053
- this.editorContainer.addChild(this.editor);
3054
- this.ui.setFocus(this.editor);
3055
- try {
3056
- fs.unlinkSync(tmpFile);
3057
- } catch {
3058
- // Ignore cleanup errors
3059
- }
3060
- };
3061
-
3062
- // Create a secret gist asynchronously
3063
- let proc: ReturnType<typeof Bun.spawn> | null = null;
3064
-
3065
- loader.onAbort = () => {
3066
- proc?.kill();
3067
- restoreEditor();
3068
- this.showStatus("Share cancelled");
3069
- };
3070
-
3071
- try {
3072
- const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
3073
- proc = Bun.spawn(["gh", "gist", "create", "--public=false", tmpFile], {
3074
- stdout: "pipe",
3075
- stderr: "pipe",
3076
- });
3077
- let stdout = "";
3078
- let stderr = "";
3079
-
3080
- const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
3081
- const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
3082
- const decoder = new TextDecoder();
3083
-
3084
- (async () => {
3085
- try {
3086
- while (true) {
3087
- const { done, value } = await stdoutReader.read();
3088
- if (done) break;
3089
- stdout += decoder.decode(value);
3090
- }
3091
- } catch {}
3092
- })();
3093
-
3094
- (async () => {
3095
- try {
3096
- while (true) {
3097
- const { done, value } = await stderrReader.read();
3098
- if (done) break;
3099
- stderr += decoder.decode(value);
3100
- }
3101
- } catch {}
3102
- })();
3103
-
3104
- proc.exited.then((code) => resolve({ stdout, stderr, code }));
3105
- });
3106
-
3107
- if (loader.signal.aborted) return;
3108
-
3109
- restoreEditor();
3110
-
3111
- if (result.code !== 0) {
3112
- const errorMsg = result.stderr?.trim() || "Unknown error";
3113
- this.showError(`Failed to create gist: ${errorMsg}`);
3114
- return;
3115
- }
3116
-
3117
- // Extract gist ID from the URL returned by gh
3118
- // gh returns something like: https://gist.github.com/username/GIST_ID
3119
- const gistUrl = result.stdout?.trim();
3120
- const gistId = gistUrl?.split("/").pop();
3121
- if (!gistId) {
3122
- this.showError("Failed to parse gist ID from gh output");
3123
- return;
3124
- }
3125
-
3126
- // Create the preview URL
3127
- const previewUrl = `https://gistpreview.github.io/?${gistId}`;
3128
- this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
3129
- this.openInBrowser(previewUrl);
3130
- } catch (error: unknown) {
3131
- if (!loader.signal.aborted) {
3132
- restoreEditor();
3133
- this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
3134
- }
3135
- }
677
+ setHookWidget(key: string, content: unknown): void {
678
+ this.extensionUiController.setHookWidget(key, content);
3136
679
  }
3137
680
 
3138
- private async handleCopyCommand(): Promise<void> {
3139
- const text = this.session.getLastAssistantText();
3140
- if (!text) {
3141
- this.showError("No agent messages to copy yet.");
3142
- return;
3143
- }
3144
-
3145
- try {
3146
- await copyToClipboard(text);
3147
- this.showStatus("Copied last agent message to clipboard");
3148
- } catch (error) {
3149
- this.showError(error instanceof Error ? error.message : String(error));
3150
- }
681
+ setHookStatus(key: string, text: string | undefined): void {
682
+ this.extensionUiController.setHookStatus(key, text);
3151
683
  }
3152
684
 
3153
- private handleSessionCommand(): void {
3154
- const stats = this.session.getSessionStats();
3155
-
3156
- let info = `${theme.bold("Session Info")}\n\n`;
3157
- info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`;
3158
- info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
3159
- info += `${theme.bold("Messages")}\n`;
3160
- info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`;
3161
- info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`;
3162
- info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`;
3163
- info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`;
3164
- info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`;
3165
- info += `${theme.bold("Tokens")}\n`;
3166
- info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`;
3167
- info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`;
3168
- if (stats.tokens.cacheRead > 0) {
3169
- info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`;
3170
- }
3171
- if (stats.tokens.cacheWrite > 0) {
3172
- info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`;
3173
- }
3174
- info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;
3175
-
3176
- if (stats.cost > 0) {
3177
- info += `\n${theme.bold("Cost")}\n`;
3178
- info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`;
3179
- }
3180
-
3181
- this.chatContainer.addChild(new Spacer(1));
3182
- this.chatContainer.addChild(new Text(info, 1, 0));
3183
- this.ui.requestRender();
685
+ showHookSelector(title: string, options: string[]): Promise<string | undefined> {
686
+ return this.extensionUiController.showHookSelector(title, options);
3184
687
  }
3185
688
 
3186
- private handleChangelogCommand(): void {
3187
- const changelogPath = getChangelogPath();
3188
- const allEntries = parseChangelog(changelogPath);
3189
-
3190
- const changelogMarkdown =
3191
- allEntries.length > 0
3192
- ? allEntries
3193
- .reverse()
3194
- .map((e) => e.content)
3195
- .join("\n\n")
3196
- : "No changelog entries found.";
3197
-
3198
- this.chatContainer.addChild(new Spacer(1));
3199
- this.chatContainer.addChild(new DynamicBorder());
3200
- this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
3201
- this.chatContainer.addChild(new Spacer(1));
3202
- this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme()));
3203
- this.chatContainer.addChild(new DynamicBorder());
3204
- this.ui.requestRender();
689
+ hideHookSelector(): void {
690
+ this.extensionUiController.hideHookSelector();
3205
691
  }
3206
692
 
3207
- /**
3208
- * Register extension-defined keyboard shortcuts with the editor.
3209
- */
3210
- private registerExtensionShortcuts(): void {
3211
- const runner = this.session.extensionRunner;
3212
- if (!runner) return;
3213
-
3214
- const shortcuts = runner.getShortcuts();
3215
- for (const [keyId, shortcut] of shortcuts) {
3216
- this.editor.setCustomKeyHandler(keyId, () => {
3217
- const ctx = runner.createCommandContext();
3218
- try {
3219
- shortcut.handler(ctx);
3220
- } catch (err) {
3221
- runner.emitError({
3222
- extensionPath: shortcut.extensionPath,
3223
- event: "shortcut",
3224
- error: err instanceof Error ? err.message : String(err),
3225
- stack: err instanceof Error ? err.stack : undefined,
3226
- });
3227
- }
3228
- });
3229
- }
693
+ showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
694
+ return this.extensionUiController.showHookInput(title, placeholder);
3230
695
  }
3231
696
 
3232
- private handleHotkeysCommand(): void {
3233
- const hotkeys = `
3234
- **Navigation**
3235
- | Key | Action |
3236
- |-----|--------|
3237
- | \`Arrow keys\` | Move cursor / browse history (Up when empty) |
3238
- | \`Option+Left/Right\` | Move by word |
3239
- | \`Ctrl+A\` / \`Home\` / \`Cmd+Left\` | Start of line |
3240
- | \`Ctrl+E\` / \`End\` / \`Cmd+Right\` | End of line |
3241
-
3242
- **Editing**
3243
- | Key | Action |
3244
- |-----|--------|
3245
- | \`Enter\` | Send message |
3246
- | \`Shift+Enter\` / \`Alt+Enter\` | New line |
3247
- | \`Ctrl+W\` / \`Option+Backspace\` | Delete word backwards |
3248
- | \`Ctrl+U\` | Delete to start of line |
3249
- | \`Ctrl+K\` | Delete to end of line |
3250
-
3251
- **Other**
3252
- | Key | Action |
3253
- |-----|--------|
3254
- | \`Tab\` | Path completion / accept autocomplete |
3255
- | \`Escape\` | Cancel autocomplete / abort streaming |
3256
- | \`Ctrl+C\` | Clear editor (first) / exit (second) |
3257
- | \`Ctrl+D\` | Exit (when editor is empty) |
3258
- | \`Ctrl+Z\` | Suspend to background |
3259
- | \`Shift+Tab\` | Cycle thinking level |
3260
- | \`Ctrl+P\` | Cycle role models (slow/default/smol) |
3261
- | \`Shift+Ctrl+P\` | Cycle role models (temporary) |
3262
- | \`Ctrl+Y\` | Select model (temporary) |
3263
- | \`Ctrl+L\` | Select model (set roles) |
3264
- | \`Ctrl+O\` | Toggle tool output expansion |
3265
- | \`Ctrl+T\` | Toggle thinking block visibility |
3266
- | \`Ctrl+G\` | Edit message in external editor |
3267
- | \`/\` | Slash commands |
3268
- | \`!\` | Run bash command |
3269
- | \`!!\` | Run bash command (excluded from context) |
3270
- `;
3271
- this.chatContainer.addChild(new Spacer(1));
3272
- this.chatContainer.addChild(new DynamicBorder());
3273
- this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));
3274
- this.chatContainer.addChild(new Spacer(1));
3275
- this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, getMarkdownTheme()));
3276
- this.chatContainer.addChild(new DynamicBorder());
3277
- this.ui.requestRender();
697
+ hideHookInput(): void {
698
+ this.extensionUiController.hideHookInput();
3278
699
  }
3279
700
 
3280
- private async handleClearCommand(): Promise<void> {
3281
- // Stop loading animation
3282
- if (this.loadingAnimation) {
3283
- this.loadingAnimation.stop();
3284
- this.loadingAnimation = undefined;
3285
- }
3286
- this.statusContainer.clear();
3287
-
3288
- // New session via session (emits hook and tool session events)
3289
- await this.session.newSession();
3290
-
3291
- // Update status line (token counts, cost reset)
3292
- this.statusLine.invalidate();
3293
- this.updateEditorTopBorder();
3294
-
3295
- // Clear UI state
3296
- this.chatContainer.clear();
3297
- this.pendingMessagesContainer.clear();
3298
- this.compactionQueuedMessages = [];
3299
- this.streamingComponent = undefined;
3300
- this.streamingMessage = undefined;
3301
- this.pendingTools.clear();
3302
-
3303
- this.chatContainer.addChild(new Spacer(1));
3304
- this.chatContainer.addChild(
3305
- new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
3306
- );
3307
- this.ui.requestRender();
701
+ showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
702
+ return this.extensionUiController.showHookEditor(title, prefill);
3308
703
  }
3309
704
 
3310
- private handleDebugCommand(): void {
3311
- const width = this.ui.terminal.columns;
3312
- const allLines = this.ui.render(width);
3313
-
3314
- const debugLogPath = getDebugLogPath();
3315
- const debugData = [
3316
- `Debug output at ${new Date().toISOString()}`,
3317
- `Terminal width: ${width}`,
3318
- `Total lines: ${allLines.length}`,
3319
- "",
3320
- "=== All rendered lines with visible widths ===",
3321
- ...allLines.map((line, idx) => {
3322
- const vw = visibleWidth(line);
3323
- const escaped = JSON.stringify(line);
3324
- return `[${idx}] (w=${vw}) ${escaped}`;
3325
- }),
3326
- "",
3327
- "=== Agent messages (JSONL) ===",
3328
- ...this.session.messages.map((msg) => JSON.stringify(msg)),
3329
- "",
3330
- ].join("\n");
3331
-
3332
- fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
3333
- fs.writeFileSync(debugLogPath, debugData);
3334
-
3335
- this.chatContainer.addChild(new Spacer(1));
3336
- this.chatContainer.addChild(
3337
- new Text(
3338
- `${theme.fg("accent", `${theme.status.success} Debug log written`)}\n${theme.fg("muted", debugLogPath)}`,
3339
- 1,
3340
- 1,
3341
- ),
3342
- );
3343
- this.ui.requestRender();
705
+ hideHookEditor(): void {
706
+ this.extensionUiController.hideHookEditor();
3344
707
  }
3345
708
 
3346
- private handleArminSaysHi(): void {
3347
- this.chatContainer.addChild(new Spacer(1));
3348
- this.chatContainer.addChild(new ArminComponent(this.ui));
3349
- this.ui.requestRender();
709
+ showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
710
+ this.extensionUiController.showHookNotify(message, type);
3350
711
  }
3351
712
 
3352
- private async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
3353
- const isDeferred = this.session.isStreaming;
3354
- this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
3355
-
3356
- if (isDeferred) {
3357
- // Show in pending area when agent is streaming
3358
- this.pendingMessagesContainer.addChild(this.bashComponent);
3359
- this.pendingBashComponents.push(this.bashComponent);
3360
- } else {
3361
- // Show in chat immediately when agent is idle
3362
- this.chatContainer.addChild(this.bashComponent);
3363
- }
3364
- this.ui.requestRender();
3365
-
3366
- try {
3367
- const result = await this.session.executeBash(
3368
- command,
3369
- (chunk) => {
3370
- if (this.bashComponent) {
3371
- this.bashComponent.appendOutput(chunk);
3372
- this.ui.requestRender();
3373
- }
3374
- },
3375
- { excludeFromContext },
3376
- );
3377
-
3378
- if (this.bashComponent) {
3379
- this.bashComponent.setComplete(
3380
- result.exitCode,
3381
- result.cancelled,
3382
- result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined,
3383
- result.fullOutputPath,
3384
- );
3385
- }
3386
- } catch (error) {
3387
- if (this.bashComponent) {
3388
- this.bashComponent.setComplete(undefined, false);
3389
- }
3390
- this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
3391
- }
3392
-
3393
- this.bashComponent = undefined;
3394
- this.ui.requestRender();
713
+ showHookCustom<T>(
714
+ factory: (
715
+ tui: TUI,
716
+ theme: Theme,
717
+ keybindings: KeybindingsManager,
718
+ done: (result: T) => void,
719
+ ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
720
+ ): Promise<T> {
721
+ return this.extensionUiController.showHookCustom(factory);
3395
722
  }
3396
723
 
3397
- private async handleCompactCommand(customInstructions?: string): Promise<void> {
3398
- const entries = this.sessionManager.getEntries();
3399
- const messageCount = entries.filter((e) => e.type === "message").length;
3400
-
3401
- if (messageCount < 2) {
3402
- this.showWarning("Nothing to compact (no messages yet)");
3403
- return;
3404
- }
3405
-
3406
- await this.executeCompaction(customInstructions, false);
724
+ showExtensionError(extensionPath: string, error: string): void {
725
+ this.extensionUiController.showExtensionError(extensionPath, error);
3407
726
  }
3408
727
 
3409
- private async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
3410
- // Stop loading animation
3411
- if (this.loadingAnimation) {
3412
- this.loadingAnimation.stop();
3413
- this.loadingAnimation = undefined;
3414
- }
3415
- this.statusContainer.clear();
3416
-
3417
- // Set up escape handler during compaction
3418
- const originalOnEscape = this.editor.onEscape;
3419
- this.editor.onEscape = () => {
3420
- this.session.abortCompaction();
3421
- };
3422
-
3423
- // Show compacting status
3424
- this.chatContainer.addChild(new Spacer(1));
3425
- const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
3426
- const compactingLoader = new Loader(
3427
- this.ui,
3428
- (spinner) => theme.fg("accent", spinner),
3429
- (text) => theme.fg("muted", text),
3430
- label,
3431
- getSymbolTheme().spinnerFrames,
3432
- );
3433
- this.statusContainer.addChild(compactingLoader);
3434
- this.ui.requestRender();
3435
-
3436
- try {
3437
- const result = await this.session.compact(customInstructions);
3438
-
3439
- // Rebuild UI
3440
- this.rebuildChatFromMessages();
3441
-
3442
- // Add compaction component at bottom so user sees it without scrolling
3443
- const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString());
3444
- this.addMessageToChat(msg);
3445
-
3446
- this.statusLine.invalidate();
3447
- this.updateEditorTopBorder();
3448
- } catch (error) {
3449
- const message = error instanceof Error ? error.message : String(error);
3450
- if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
3451
- this.showError("Compaction cancelled");
3452
- } else {
3453
- this.showError(`Compaction failed: ${message}`);
3454
- }
3455
- } finally {
3456
- compactingLoader.stop();
3457
- this.statusContainer.clear();
3458
- this.editor.onEscape = originalOnEscape;
3459
- }
3460
- await this.flushCompactionQueue({ willRetry: false });
728
+ showToolError(toolName: string, error: string): void {
729
+ this.extensionUiController.showToolError(toolName, error);
3461
730
  }
3462
731
 
3463
- stop(): void {
3464
- if (this.loadingAnimation) {
3465
- this.loadingAnimation.stop();
3466
- this.loadingAnimation = undefined;
3467
- }
3468
- this.statusLine.dispose();
3469
- if (this.unsubscribe) {
3470
- this.unsubscribe();
3471
- }
3472
- if (this.cleanupUnsubscribe) {
3473
- this.cleanupUnsubscribe();
3474
- }
3475
- if (this.isInitialized) {
3476
- this.ui.stop();
3477
- this.isInitialized = false;
3478
- }
732
+ private subscribeToAgent(): void {
733
+ this.eventController.subscribeToAgent();
3479
734
  }
3480
735
  }