@ebowwa/coder 0.7.64 → 0.7.65

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 (101) hide show
  1. package/dist/index.js +36168 -32
  2. package/dist/interfaces/ui/terminal/cli/index.js +34253 -158
  3. package/dist/interfaces/ui/terminal/native/README.md +53 -0
  4. package/dist/interfaces/ui/terminal/native/claude_code_native.darwin-x64.node +0 -0
  5. package/dist/interfaces/ui/terminal/native/claude_code_native.dylib +0 -0
  6. package/dist/interfaces/ui/terminal/native/index.d.ts +0 -0
  7. package/dist/interfaces/ui/terminal/native/index.darwin-arm64.node +0 -0
  8. package/dist/interfaces/ui/terminal/native/index.js +43 -0
  9. package/dist/interfaces/ui/terminal/native/index.node +0 -0
  10. package/dist/interfaces/ui/terminal/native/package.json +34 -0
  11. package/dist/native/README.md +53 -0
  12. package/dist/native/claude_code_native.darwin-x64.node +0 -0
  13. package/dist/native/claude_code_native.dylib +0 -0
  14. package/dist/native/index.d.ts +0 -480
  15. package/dist/native/index.darwin-arm64.node +0 -0
  16. package/dist/native/index.js +43 -1625
  17. package/dist/native/index.node +0 -0
  18. package/dist/native/package.json +34 -0
  19. package/native/index.darwin-arm64.node +0 -0
  20. package/native/index.js +33 -19
  21. package/package.json +3 -2
  22. package/packages/src/core/agent-loop/__tests__/compaction.test.ts +17 -14
  23. package/packages/src/core/agent-loop/compaction.ts +6 -2
  24. package/packages/src/core/agent-loop/index.ts +2 -0
  25. package/packages/src/core/agent-loop/loop-state.ts +1 -1
  26. package/packages/src/core/agent-loop/turn-executor.ts +4 -0
  27. package/packages/src/core/agent-loop/types.ts +4 -0
  28. package/packages/src/core/api-client-impl.ts +283 -173
  29. package/packages/src/core/cognitive-security/hooks.ts +2 -1
  30. package/packages/src/core/config/todo +7 -0
  31. package/packages/src/core/context/__tests__/integration.test.ts +334 -0
  32. package/packages/src/core/context/compaction.ts +170 -0
  33. package/packages/src/core/context/constants.ts +58 -0
  34. package/packages/src/core/context/extraction.ts +85 -0
  35. package/packages/src/core/context/index.ts +66 -0
  36. package/packages/src/core/context/summarization.ts +251 -0
  37. package/packages/src/core/context/token-estimation.ts +98 -0
  38. package/packages/src/core/context/types.ts +59 -0
  39. package/packages/src/core/models.ts +81 -4
  40. package/packages/src/core/normalizers/todo +5 -1
  41. package/packages/src/core/providers/README.md +230 -0
  42. package/packages/src/core/providers/__tests__/providers.test.ts +135 -0
  43. package/packages/src/core/providers/index.ts +419 -0
  44. package/packages/src/core/providers/types.ts +132 -0
  45. package/packages/src/core/retry.ts +10 -0
  46. package/packages/src/ecosystem/tools/index.ts +174 -0
  47. package/packages/src/index.ts +23 -2
  48. package/packages/src/interfaces/ui/index.ts +17 -20
  49. package/packages/src/interfaces/ui/spinner.ts +2 -2
  50. package/packages/src/interfaces/ui/terminal/bridge/index.ts +370 -0
  51. package/packages/src/interfaces/ui/terminal/bridge/ipc.ts +829 -0
  52. package/packages/src/interfaces/ui/terminal/bridge/screen-export.ts +968 -0
  53. package/packages/src/interfaces/ui/terminal/bridge/types.ts +226 -0
  54. package/packages/src/interfaces/ui/terminal/bridge/useBridge.ts +210 -0
  55. package/packages/src/interfaces/ui/terminal/cli/bootstrap.ts +132 -0
  56. package/packages/src/interfaces/ui/terminal/cli/index.ts +200 -13
  57. package/packages/src/interfaces/ui/terminal/cli/interactive/index.ts +110 -0
  58. package/packages/src/interfaces/ui/terminal/cli/interactive/input-handler.ts +393 -0
  59. package/packages/src/interfaces/ui/terminal/cli/interactive/interactive-runner.ts +820 -0
  60. package/packages/src/interfaces/ui/terminal/cli/interactive/message-store.ts +299 -0
  61. package/packages/src/interfaces/ui/terminal/cli/interactive/types.ts +274 -0
  62. package/packages/src/interfaces/ui/terminal/shared/index.ts +13 -0
  63. package/packages/src/interfaces/ui/terminal/shared/query.ts +9 -3
  64. package/packages/src/interfaces/ui/terminal/shared/setup.ts +5 -1
  65. package/packages/src/interfaces/ui/terminal/shared/spinner-frames.ts +73 -0
  66. package/packages/src/interfaces/ui/terminal/shared/status-line.ts +10 -2
  67. package/packages/src/native/index.ts +404 -27
  68. package/packages/src/native/tui_v2_types.ts +39 -0
  69. package/packages/src/teammates/coordination.test.ts +279 -0
  70. package/packages/src/teammates/coordination.ts +646 -0
  71. package/packages/src/teammates/index.ts +95 -25
  72. package/packages/src/teammates/integration.test.ts +272 -0
  73. package/packages/src/teammates/runner.test.ts +235 -0
  74. package/packages/src/teammates/runner.ts +750 -0
  75. package/packages/src/teammates/schemas.ts +673 -0
  76. package/packages/src/types/index.ts +1 -0
  77. package/packages/src/core/context-compaction.ts +0 -578
  78. package/packages/src/interfaces/ui/Screenshot 2026-03-02 at 9.23.10/342/200/257PM.png +0 -0
  79. package/packages/src/interfaces/ui/Screenshot 2026-03-03 at 10.55.11/342/200/257AM.png +0 -0
  80. package/packages/src/interfaces/ui/terminal/tui/HelpPanel.tsx +0 -262
  81. package/packages/src/interfaces/ui/terminal/tui/InputContext.tsx +0 -232
  82. package/packages/src/interfaces/ui/terminal/tui/InputField.tsx +0 -62
  83. package/packages/src/interfaces/ui/terminal/tui/InteractiveTUI.tsx +0 -537
  84. package/packages/src/interfaces/ui/terminal/tui/MessageArea.tsx +0 -107
  85. package/packages/src/interfaces/ui/terminal/tui/MessageStore.tsx +0 -240
  86. package/packages/src/interfaces/ui/terminal/tui/StatusBar.tsx +0 -54
  87. package/packages/src/interfaces/ui/terminal/tui/commands.ts +0 -438
  88. package/packages/src/interfaces/ui/terminal/tui/components/InteractiveElements.tsx +0 -584
  89. package/packages/src/interfaces/ui/terminal/tui/components/MultilineInput.tsx +0 -614
  90. package/packages/src/interfaces/ui/terminal/tui/components/PaneManager.tsx +0 -333
  91. package/packages/src/interfaces/ui/terminal/tui/components/Sidebar.tsx +0 -604
  92. package/packages/src/interfaces/ui/terminal/tui/components/index.ts +0 -118
  93. package/packages/src/interfaces/ui/terminal/tui/console.ts +0 -49
  94. package/packages/src/interfaces/ui/terminal/tui/index.ts +0 -90
  95. package/packages/src/interfaces/ui/terminal/tui/run.tsx +0 -42
  96. package/packages/src/interfaces/ui/terminal/tui/spinner.ts +0 -69
  97. package/packages/src/interfaces/ui/terminal/tui/tui-app.tsx +0 -390
  98. package/packages/src/interfaces/ui/terminal/tui/tui-footer.ts +0 -422
  99. package/packages/src/interfaces/ui/terminal/tui/types.ts +0 -186
  100. package/packages/src/interfaces/ui/terminal/tui/useInputHandler.ts +0 -104
  101. package/packages/src/interfaces/ui/terminal/tui/useNativeInput.ts +0 -239
@@ -0,0 +1,820 @@
1
+ /**
2
+ * Interactive Runner - Main Interactive CLI Loop
3
+ *
4
+ * Non-React implementation of the interactive CLI mode.
5
+ * Extracts the core patterns from v1 TUI without the Ink dependency.
6
+ *
7
+ * Features:
8
+ * - Native terminal input handling
9
+ * - Message state management via MessageStore
10
+ * - Agent loop integration for AI responses
11
+ * - Command handling
12
+ * - History navigation
13
+ * - Loading states with spinner
14
+ */
15
+
16
+ import process from "node:process";
17
+ import type { Message as ApiMessage } from "../../../../../types/index.js";
18
+ import { agentLoop } from "../../../../../core/agent-loop.js";
19
+ import { getGitStatus } from "../../../../../core/git-status.js";
20
+ import { createStreamHighlighter } from "../../../../../core/stream-highlighter.js";
21
+ import { NativeRenderer } from "../../../../../native/index.js";
22
+ import type { InputEvent, NativeRendererType } from "../../../../../native/index.js";
23
+ import { spinnerFrames } from "../../shared/spinner-frames.js";
24
+ import { MessageStoreImpl } from "./message-store.js";
25
+ import { InputManagerImpl, KeyEvents, inputEventToNativeKeyEvent } from "./input-handler.js";
26
+ import type {
27
+ InteractiveRunnerProps,
28
+ InteractiveState,
29
+ NativeKeyEvent,
30
+ } from "./types.js";
31
+ import { InputPriority } from "./types.js";
32
+
33
+ // ============================================
34
+ // STATE MANAGEMENT
35
+ // ============================================
36
+
37
+ /**
38
+ * Create initial interactive state
39
+ */
40
+ function createInitialState(): InteractiveState {
41
+ return {
42
+ isLoading: false,
43
+ inputValue: "",
44
+ cursorPos: 0,
45
+ scrollOffset: 0,
46
+ totalCost: 0,
47
+ spinnerFrame: spinnerFrames[0] ?? "⠋",
48
+ streamingText: "",
49
+ inputHistory: [],
50
+ historyIndex: -1,
51
+ sessionSelectMode: false,
52
+ selectableSessions: [],
53
+ helpMode: false,
54
+ helpSection: 0,
55
+ };
56
+ }
57
+
58
+ // ============================================
59
+ // INTERACTIVE RUNNER CLASS
60
+ // ============================================
61
+
62
+ /**
63
+ * Main interactive runner class
64
+ *
65
+ * Usage:
66
+ * ```ts
67
+ * const runner = new InteractiveRunner(props);
68
+ * await runner.start();
69
+ * ```
70
+ */
71
+ export class InteractiveRunner {
72
+ private props: InteractiveRunnerProps;
73
+ private messageStore: MessageStoreImpl;
74
+ private inputManager: InputManagerImpl;
75
+ private state: InteractiveState;
76
+ private renderer: NativeRendererType | null = null;
77
+ private spinnerInterval: Timer | null = null;
78
+ private frameIndex = 0;
79
+ private isProcessing = false;
80
+ private savedInput = "";
81
+ private shouldExit = false;
82
+
83
+ constructor(props: InteractiveRunnerProps) {
84
+ this.props = props;
85
+ this.messageStore = new MessageStoreImpl();
86
+ this.inputManager = new InputManagerImpl();
87
+ this.state = createInitialState();
88
+
89
+ // Initialize messages from props
90
+ if (props.initialMessages.length > 0) {
91
+ this.messageStore.addApiMessages(props.initialMessages);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Start the interactive loop
97
+ */
98
+ async start(): Promise<void> {
99
+ // Check if stdin is a TTY
100
+ const isInteractive = process.stdin.isTTY;
101
+ const forceInteractive = process.env.CLAUDE_FORCE_INTERACTIVE === "true";
102
+
103
+ // Debug output
104
+ if (process.env.CODER_DEBUG === "1") {
105
+ console.error("[InteractiveRunner] isInteractive:", isInteractive);
106
+ console.error("[InteractiveRunner] forceInteractive:", forceInteractive);
107
+ }
108
+
109
+ if (!isInteractive && !forceInteractive) {
110
+ console.error("Error: Interactive mode requires a TTY. Use -q for single query mode.");
111
+ return;
112
+ }
113
+
114
+ // Try to use native TUI renderer, fall back to simple mode if not available
115
+ if (isInteractive) {
116
+ try {
117
+ if (process.env.CODER_DEBUG === "1") {
118
+ console.error("[InteractiveRunner] Creating NativeRenderer...");
119
+ }
120
+ this.renderer = new NativeRenderer();
121
+ if (process.env.CODER_DEBUG === "1") {
122
+ console.error("[InteractiveRunner] NativeRenderer created, initializing...");
123
+ }
124
+ this.renderer.init();
125
+ if (process.env.CODER_DEBUG === "1") {
126
+ console.error("[InteractiveRunner] NativeRenderer initialized successfully");
127
+ }
128
+ } catch (err) {
129
+ // Native renderer not available, fall back to simple mode
130
+ if (process.env.CODER_DEBUG === "1") {
131
+ console.error("[InteractiveRunner] NativeRenderer failed:", err);
132
+ }
133
+ await this._runSimpleMode();
134
+ return;
135
+ }
136
+ } else {
137
+ // Non-interactive terminal, use simple mode
138
+ await this._runSimpleMode();
139
+ return;
140
+ }
141
+
142
+ // Show startup message
143
+ this._showStartupMessage();
144
+
145
+ // Register input handlers
146
+ this._registerInputHandlers();
147
+
148
+ // Start spinner animation (when loading)
149
+ // Note: Spinner is controlled by state.isLoading
150
+
151
+ // Main event loop
152
+ while (!this.shouldExit) {
153
+ // Poll for input (non-blocking, 16ms timeout for ~60fps)
154
+ const event = this.renderer.pollInput(16);
155
+
156
+ // Dispatch to input manager if it's a key event
157
+ // Note: NAPI-RS converts snake_case to camelCase, so use eventType (not event_type)
158
+ if (event.eventType === "key") {
159
+ // Convert InputEvent (from native module) to NativeKeyEvent (for input handlers)
160
+ const nativeEvent = inputEventToNativeKeyEvent(event);
161
+ if (nativeEvent) {
162
+ this.inputManager.dispatch(nativeEvent);
163
+ }
164
+ }
165
+
166
+ // Render current state
167
+ this._render();
168
+
169
+ // Small yield to prevent CPU spinning
170
+ await new Promise((resolve) => setTimeout(resolve, 0));
171
+ }
172
+
173
+ // Cleanup
174
+ this._cleanup();
175
+ }
176
+
177
+ /**
178
+ * Stop the interactive loop
179
+ */
180
+ stop(): void {
181
+ this.shouldExit = true;
182
+ }
183
+
184
+ // ============================================
185
+ // PRIVATE METHODS
186
+ // ============================================
187
+
188
+ /**
189
+ * Show startup message
190
+ */
191
+ private _showStartupMessage(): void {
192
+ console.log(`\x1b[90mSession: ${this.props.sessionId}\x1b[0m`);
193
+ console.log(`\x1b[90mModel: ${this.props.model}\x1b[0m`);
194
+ if (this.props.teammateRunner) {
195
+ console.log(`\x1b[90mTeammate Mode: Active\x1b[0m`);
196
+ }
197
+ console.log(`\x1b[90mType your message, ? for help, or /help for commands.\x1b[0m\n`);
198
+ }
199
+
200
+ /**
201
+ * Register input handlers
202
+ */
203
+ private _registerInputHandlers(): void {
204
+ // System handler (Ctrl+C, etc.) - highest priority
205
+ this.inputManager.register({
206
+ id: "system",
207
+ priority: InputPriority.SYSTEM,
208
+ handler: (event) => {
209
+ if (KeyEvents.isCtrlC(event)) {
210
+ this._handleExit();
211
+ return true;
212
+ }
213
+ if (KeyEvents.isCtrlD(event)) {
214
+ this._handleExit();
215
+ return true;
216
+ }
217
+ return false;
218
+ },
219
+ });
220
+
221
+ // Main input handler
222
+ this.inputManager.register({
223
+ id: "main-input",
224
+ priority: InputPriority.INPUT,
225
+ handler: (event) => this._handleMainInput(event),
226
+ });
227
+
228
+ // Focus on main input
229
+ this.inputManager.focus("main-input");
230
+ }
231
+
232
+ /**
233
+ * Handle main input events
234
+ */
235
+ private _handleMainInput(event: NativeKeyEvent): boolean {
236
+ // Block input when loading
237
+ if (this.state.isLoading) {
238
+ // Allow Ctrl+C even when loading
239
+ if (KeyEvents.isCtrlC(event)) {
240
+ this._handleExit();
241
+ return true;
242
+ }
243
+ return false;
244
+ }
245
+
246
+ // Handle special modes
247
+ if (this.state.helpMode) {
248
+ return this._handleHelpModeInput(event);
249
+ }
250
+
251
+ if (this.state.sessionSelectMode) {
252
+ return this._handleSessionSelectInput(event);
253
+ }
254
+
255
+ // Regular input handling
256
+ const { inputValue, cursorPos } = this.state;
257
+
258
+ // Enter - submit
259
+ if (KeyEvents.isEnter(event)) {
260
+ if (inputValue.trim()) {
261
+ this._submitInput();
262
+ }
263
+ return true;
264
+ }
265
+
266
+ // Escape - clear input or exit help
267
+ if (KeyEvents.isEscape(event)) {
268
+ this.state = { ...this.state, inputValue: "", cursorPos: 0 };
269
+ return true;
270
+ }
271
+
272
+ // History navigation
273
+ if (KeyEvents.isUp(event)) {
274
+ return this._handleHistoryUp();
275
+ }
276
+
277
+ if (KeyEvents.isDown(event)) {
278
+ return this._handleHistoryDown();
279
+ }
280
+
281
+ // Cursor movement
282
+ if (KeyEvents.isLeft(event)) {
283
+ this.state = { ...this.state, cursorPos: Math.max(0, cursorPos - 1) };
284
+ return true;
285
+ }
286
+
287
+ if (KeyEvents.isRight(event)) {
288
+ this.state = { ...this.state, cursorPos: Math.min(inputValue.length, cursorPos + 1) };
289
+ return true;
290
+ }
291
+
292
+ if (KeyEvents.isHome(event) || KeyEvents.isCtrlA(event)) {
293
+ this.state = { ...this.state, cursorPos: 0 };
294
+ return true;
295
+ }
296
+
297
+ if (KeyEvents.isEnd(event) || KeyEvents.isCtrlE(event)) {
298
+ this.state = { ...this.state, cursorPos: inputValue.length };
299
+ return true;
300
+ }
301
+
302
+ // Backspace
303
+ if (KeyEvents.isBackspace(event)) {
304
+ if (cursorPos > 0) {
305
+ const newVal = inputValue.slice(0, cursorPos - 1) + inputValue.slice(cursorPos);
306
+ this.state = { ...this.state, inputValue: newVal, cursorPos: cursorPos - 1 };
307
+ }
308
+ return true;
309
+ }
310
+
311
+ // Delete
312
+ if (KeyEvents.isDelete(event)) {
313
+ if (cursorPos < inputValue.length) {
314
+ const newVal = inputValue.slice(0, cursorPos) + inputValue.slice(cursorPos + 1);
315
+ this.state = { ...this.state, inputValue: newVal };
316
+ }
317
+ return true;
318
+ }
319
+
320
+ // Printable character
321
+ if (KeyEvents.isPrintable(event)) {
322
+ const char = KeyEvents.getChar(event);
323
+ const newVal = inputValue.slice(0, cursorPos) + char + inputValue.slice(cursorPos);
324
+ this.state = {
325
+ ...this.state,
326
+ inputValue: newVal,
327
+ cursorPos: cursorPos + 1,
328
+ historyIndex: -1,
329
+ };
330
+ return true;
331
+ }
332
+
333
+ return false;
334
+ }
335
+
336
+ /**
337
+ * Handle history up
338
+ */
339
+ private _handleHistoryUp(): boolean {
340
+ const { inputHistory, historyIndex } = this.state;
341
+ if (inputHistory.length === 0) return false;
342
+
343
+ if (historyIndex === -1) {
344
+ this.savedInput = this.state.inputValue;
345
+ }
346
+
347
+ const newIndex = Math.min(historyIndex + 1, inputHistory.length - 1);
348
+ const newInput = inputHistory[newIndex] ?? "";
349
+ this.state = {
350
+ ...this.state,
351
+ historyIndex: newIndex,
352
+ inputValue: newInput,
353
+ cursorPos: newInput.length,
354
+ };
355
+ return true;
356
+ }
357
+
358
+ /**
359
+ * Handle history down
360
+ */
361
+ private _handleHistoryDown(): boolean {
362
+ const { inputHistory, historyIndex } = this.state;
363
+ if (historyIndex === -1) return false;
364
+
365
+ if (historyIndex > 0) {
366
+ const newIndex = historyIndex - 1;
367
+ const newInput = inputHistory[newIndex] ?? "";
368
+ this.state = {
369
+ ...this.state,
370
+ historyIndex: newIndex,
371
+ inputValue: newInput,
372
+ cursorPos: newInput.length,
373
+ };
374
+ } else {
375
+ this.state = {
376
+ ...this.state,
377
+ historyIndex: -1,
378
+ inputValue: this.savedInput,
379
+ cursorPos: this.savedInput.length,
380
+ };
381
+ }
382
+ return true;
383
+ }
384
+
385
+ /**
386
+ * Handle help mode input
387
+ */
388
+ private _handleHelpModeInput(event: NativeKeyEvent): boolean {
389
+ const HELP_SECTIONS_COUNT = 5;
390
+
391
+ if (KeyEvents.isEscape(event) || event.code === "q") {
392
+ this.state = { ...this.state, helpMode: false };
393
+ return true;
394
+ }
395
+
396
+ if (event.code === "tab" || KeyEvents.isRight(event)) {
397
+ this.state = {
398
+ ...this.state,
399
+ helpSection: (this.state.helpSection + 1) % HELP_SECTIONS_COUNT,
400
+ };
401
+ return true;
402
+ }
403
+
404
+ if (KeyEvents.isLeft(event)) {
405
+ this.state = {
406
+ ...this.state,
407
+ helpSection: (this.state.helpSection - 1 + HELP_SECTIONS_COUNT) % HELP_SECTIONS_COUNT,
408
+ };
409
+ return true;
410
+ }
411
+
412
+ return false;
413
+ }
414
+
415
+ /**
416
+ * Handle session select input
417
+ */
418
+ private _handleSessionSelectInput(event: NativeKeyEvent): boolean {
419
+ const num = parseInt(event.code ?? "", 10);
420
+ if (!isNaN(num) && num >= 1 && num <= this.state.selectableSessions.length) {
421
+ const selected = this.state.selectableSessions[num - 1];
422
+ if (selected) {
423
+ this.state = {
424
+ ...this.state,
425
+ sessionSelectMode: false,
426
+ selectableSessions: [],
427
+ };
428
+ // Handle session resume
429
+ this._handleCommand(`/resume ${selected.id}`);
430
+ }
431
+ return true;
432
+ }
433
+
434
+ if (KeyEvents.isEnter(event) || KeyEvents.isEscape(event)) {
435
+ this.state = {
436
+ ...this.state,
437
+ sessionSelectMode: false,
438
+ selectableSessions: [],
439
+ };
440
+ this.messageStore.addSystem("Session selection cancelled.");
441
+ return true;
442
+ }
443
+
444
+ return false;
445
+ }
446
+
447
+ /**
448
+ * Submit the current input
449
+ */
450
+ private async _submitInput(): Promise<void> {
451
+ if (this.isProcessing) return;
452
+
453
+ const input = this.state.inputValue.trim();
454
+ if (!input) return;
455
+
456
+ // Clear input immediately
457
+ this.state = { ...this.state, inputValue: "", cursorPos: 0 };
458
+
459
+ // Add to history (skip commands and duplicates)
460
+ if (!input.startsWith("/") && input !== this.state.inputHistory[0]) {
461
+ this.state = {
462
+ ...this.state,
463
+ inputHistory: [input, ...this.state.inputHistory].slice(0, 100),
464
+ historyIndex: -1,
465
+ };
466
+ }
467
+
468
+ // Handle commands
469
+ if (input.startsWith("/")) {
470
+ this._handleCommand(input);
471
+ return;
472
+ }
473
+
474
+ // Process as message
475
+ await this._processMessage(input);
476
+ }
477
+
478
+ /**
479
+ * Handle a command
480
+ */
481
+ private _handleCommand(cmd: string): void {
482
+ // Import command handling logic
483
+ // For now, handle basic commands inline
484
+ const command = cmd.toLowerCase().trim();
485
+
486
+ if (command === "/exit" || command === "/quit") {
487
+ this._handleExit();
488
+ return;
489
+ }
490
+
491
+ if (command === "/clear") {
492
+ this.messageStore.clear();
493
+ this.messageStore.addSystem("Messages cleared.");
494
+ return;
495
+ }
496
+
497
+ if (command === "/help" || command === "?") {
498
+ this.state = { ...this.state, helpMode: true, helpSection: 0 };
499
+ return;
500
+ }
501
+
502
+ if (command === "/cost") {
503
+ this.messageStore.addSystem(`Total cost: $${this.state.totalCost.toFixed(4)}`);
504
+ return;
505
+ }
506
+
507
+ if (command === "/status") {
508
+ this.messageStore.addSystem(
509
+ `Session: ${this.props.sessionId}\n` +
510
+ `Model: ${this.props.model}\n` +
511
+ `Messages: ${this.messageStore.messages.length}\n` +
512
+ `Tokens: ${this.messageStore.tokenCount}`
513
+ );
514
+ return;
515
+ }
516
+
517
+ // Unknown command
518
+ this.messageStore.addSystem(`Unknown command: ${cmd}. Type /help for available commands.`);
519
+ }
520
+
521
+ /**
522
+ * Process a message through the agent loop
523
+ */
524
+ private async _processMessage(input: string): Promise<void> {
525
+ this.isProcessing = true;
526
+ this.state = { ...this.state, isLoading: true, streamingText: "" };
527
+
528
+ // Start spinner animation
529
+ this._startSpinner();
530
+
531
+ // Add user message
532
+ this.messageStore.addMessage({ role: "user", content: input });
533
+
534
+ try {
535
+ // Execute hooks
536
+ const hookResult = await this.props.hookManager.execute("UserPromptSubmit", {
537
+ prompt: input,
538
+ session_id: this.props.sessionId,
539
+ });
540
+
541
+ if (hookResult.decision === "deny" || hookResult.decision === "block") {
542
+ this.messageStore.addSystem(`Input blocked: ${hookResult.reason || "Security policy"}`);
543
+ return;
544
+ }
545
+
546
+ const processedInput = (hookResult.modified_input?.prompt as string) ?? input;
547
+
548
+ // Build messages for API
549
+ const newUserMsg: ApiMessage = {
550
+ role: "user",
551
+ content: [{ type: "text", text: processedInput }],
552
+ };
553
+ const messagesForApi = [...this.messageStore.apiMessages, newUserMsg];
554
+
555
+ // Get git status
556
+ const gitStatus = await getGitStatus(this.props.workingDirectory);
557
+
558
+ // Create stream highlighter
559
+ const highlighter = createStreamHighlighter();
560
+ let streamingText = "";
561
+
562
+ // Run agent loop
563
+ const result = await agentLoop(messagesForApi, {
564
+ apiKey: this.props.apiKey,
565
+ model: this.props.model,
566
+ maxTokens: this.props.maxTokens,
567
+ systemPrompt: this.props.systemPrompt,
568
+ tools: this.props.tools,
569
+ permissionMode: this.props.permissionMode,
570
+ workingDirectory: this.props.workingDirectory,
571
+ gitStatus,
572
+ hookManager: this.props.hookManager,
573
+ sessionId: this.props.sessionId,
574
+ onText: (text) => {
575
+ streamingText += text;
576
+ this.state = { ...this.state, streamingText };
577
+ },
578
+ onToolUse: (toolUse) => {
579
+ this.messageStore.addSystem(`[Using: ${toolUse.name}]`, "tool_call", toolUse.name);
580
+ },
581
+ onToolResult: (toolResult) => {
582
+ if (toolResult.result.is_error) {
583
+ this.messageStore.addSystem(`[Tool Error]`, "tool_result", undefined, true);
584
+ }
585
+ },
586
+ onMetrics: async (metrics) => {
587
+ const apiTokens = metrics.usage.input_tokens + metrics.usage.output_tokens;
588
+ if (apiTokens > 0) {
589
+ this.messageStore.setTokenCount(apiTokens);
590
+ }
591
+ await this.props.sessionStore.saveMetrics(metrics);
592
+ },
593
+ });
594
+
595
+ // Add API messages (skipping user message already added)
596
+ this.messageStore.addApiMessages(result.messages.slice(this.messageStore.apiMessages.length));
597
+ this.state = { ...this.state, totalCost: this.state.totalCost + result.totalCost };
598
+
599
+ // Save to session
600
+ const lastUserMsg = result.messages[result.messages.length - 2];
601
+ const lastAssistantMsg = result.messages[result.messages.length - 1];
602
+ if (lastUserMsg) await this.props.sessionStore.saveMessage(lastUserMsg);
603
+ if (lastAssistantMsg) await this.props.sessionStore.saveMessage(lastAssistantMsg);
604
+
605
+ } catch (error) {
606
+ const errorMessage = error instanceof Error ? error.message : String(error);
607
+ this.messageStore.addSystem(`Error: ${errorMessage}`, "error");
608
+ } finally {
609
+ this._stopSpinner();
610
+ this.state = { ...this.state, isLoading: false, streamingText: "" };
611
+ this.isProcessing = false;
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Start spinner animation
617
+ */
618
+ private _startSpinner(): void {
619
+ this._stopSpinner();
620
+ this.spinnerInterval = setInterval(() => {
621
+ this.frameIndex = (this.frameIndex + 1) % spinnerFrames.length;
622
+ const frame = spinnerFrames[this.frameIndex];
623
+ if (frame) {
624
+ this.state = { ...this.state, spinnerFrame: frame };
625
+ }
626
+ }, 80);
627
+ }
628
+
629
+ /**
630
+ * Stop spinner animation
631
+ */
632
+ private _stopSpinner(): void {
633
+ if (this.spinnerInterval) {
634
+ clearInterval(this.spinnerInterval);
635
+ this.spinnerInterval = null;
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Handle exit
641
+ */
642
+ private async _handleExit(): Promise<void> {
643
+ this.shouldExit = true;
644
+ await this.props.onExit?.();
645
+ }
646
+
647
+ /**
648
+ * Render current state
649
+ */
650
+ private _render(): void {
651
+ if (!this.renderer) return;
652
+
653
+ // Build render state
654
+ const renderState = this._buildRenderState();
655
+ this.renderer.render(renderState);
656
+ }
657
+
658
+ /**
659
+ * Build render state for the renderer
660
+ * Returns a RenderState object matching the NativeRenderer.render() interface
661
+ */
662
+ private _buildRenderState() {
663
+ const { messages } = this.messageStore;
664
+ const { isLoading, inputValue, cursorPos, streamingText, helpMode, helpSection, sessionSelectMode, selectableSessions } = this.state;
665
+
666
+ // Convert MessageStore messages to RenderMessage format
667
+ const renderMessages = messages.map(msg => ({
668
+ role: msg.role,
669
+ content: msg.content,
670
+ }));
671
+
672
+ // Build status text
673
+ const statusText = `Session: ${this.props.sessionId} | Model: ${this.props.model}`;
674
+
675
+ // Build help text if in help mode
676
+ const helpText = helpMode ? this._getHelpText(helpSection) : "";
677
+
678
+ // Build search results if in session select mode
679
+ const searchResults = sessionSelectMode ? selectableSessions.map(s => ({
680
+ filePath: s.id,
681
+ lineNumber: 0,
682
+ content: `${s.messageCount} messages`,
683
+ })) : [];
684
+
685
+ // NativeRenderer expects camelCase field names (NAPI-RS converts snake_case to camelCase)
686
+ return {
687
+ messages: renderMessages,
688
+ inputValue: inputValue,
689
+ cursorPos: cursorPos,
690
+ statusText: statusText,
691
+ isLoading: isLoading,
692
+ streamingText: streamingText,
693
+ model: this.props.model,
694
+ showHelp: helpMode,
695
+ helpText: helpText,
696
+ searchMode: sessionSelectMode,
697
+ searchQuery: "",
698
+ searchResults: searchResults,
699
+ searchSelected: 0,
700
+ };
701
+ }
702
+
703
+ /**
704
+ * Get help text for a given section
705
+ */
706
+ private _getHelpText(section: number): string {
707
+ const sections = [
708
+ `Commands:
709
+ /help, ? Show this help
710
+ /exit, /quit Exit the CLI
711
+ /clear Clear messages
712
+ /cost Show total cost
713
+ /status Show session status`,
714
+ `Keyboard Shortcuts:
715
+ Enter Send message
716
+ Escape Clear input
717
+ Up/Down History navigation
718
+ Ctrl+C Exit`,
719
+ `Model: ${this.props.model}
720
+ Max tokens: ${this.props.maxTokens}
721
+ Permission mode: ${this.props.permissionMode}`,
722
+ ];
723
+ return sections[section] || sections[0] || "";
724
+ }
725
+
726
+ /**
727
+ * Simple input mode fallback (without native TUI)
728
+ * Uses basic readline for input
729
+ */
730
+ private async _runSimpleMode(): Promise<void> {
731
+ const readline = await import("node:readline");
732
+ const rl = readline.createInterface({
733
+ input: process.stdin,
734
+ output: process.stdout,
735
+ });
736
+
737
+ console.log("\n\x1b[90mRunning in simple mode (no TUI)\x1b[0m");
738
+ console.log("\x1b[90mType /exit to quit\x1b[0m\n");
739
+
740
+ const prompt = (): void => {
741
+ rl.question("> ", async (input) => {
742
+ const trimmed = input.trim();
743
+
744
+ if (trimmed === "/exit" || trimmed === "/quit") {
745
+ rl.close();
746
+ console.log("\n\x1b[90mGoodbye!\x1b[0m");
747
+ return;
748
+ }
749
+
750
+ if (trimmed === "/clear") {
751
+ console.clear();
752
+ prompt();
753
+ return;
754
+ }
755
+
756
+ if (trimmed === "/help" || trimmed === "?") {
757
+ console.log(`
758
+ Commands:
759
+ /help, ? Show this help
760
+ /exit Exit the CLI
761
+ /clear Clear screen
762
+ /cost Show total cost
763
+ /status Show session status
764
+ `);
765
+ prompt();
766
+ return;
767
+ }
768
+
769
+ if (trimmed === "/cost") {
770
+ console.log(`Total cost: $${this.state.totalCost.toFixed(4)}`);
771
+ prompt();
772
+ return;
773
+ }
774
+
775
+ if (trimmed === "/status") {
776
+ console.log(`
777
+ Session: ${this.props.sessionId}
778
+ Model: ${this.props.model}
779
+ Messages: ${this.messageStore.messages.length}
780
+ Tokens: ${this.messageStore.tokenCount}
781
+ `);
782
+ prompt();
783
+ return;
784
+ }
785
+
786
+ if (trimmed) {
787
+ await this._processMessage(trimmed);
788
+ }
789
+
790
+ prompt();
791
+ });
792
+ };
793
+
794
+ prompt();
795
+
796
+ return new Promise((resolve) => {
797
+ rl.on("close", () => {
798
+ resolve();
799
+ });
800
+ });
801
+ }
802
+
803
+ /**
804
+ * Cleanup resources
805
+ */
806
+ private _cleanup(): void {
807
+ this._stopSpinner();
808
+ if (this.renderer) {
809
+ this.renderer.cleanup();
810
+ this.renderer = null;
811
+ }
812
+ console.log("\n\x1b[90mGoodbye!\x1b[0m");
813
+ }
814
+ }
815
+
816
+ // ============================================
817
+ // EXPORTS
818
+ // ============================================
819
+
820
+ export type { InteractiveRunnerProps, InteractiveState } from "./types.js";