erosolar-cli 1.7.189 → 1.7.191

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.
@@ -1,4 +1,3 @@
1
- import readline from 'node:readline';
2
1
  import { stdin as input, stdout as output, exit } from 'node:process';
3
2
  import { exec } from 'node:child_process';
4
3
  import { promisify } from 'node:util';
@@ -8,8 +7,6 @@ import { getContextWindowTokens } from '../core/contextWindow.js';
8
7
  import { ensureSecretForProvider, getSecretDefinitionForProvider, getSecretValue, listSecretDefinitions, maskSecret, setSecretValue, } from '../core/secretStore.js';
9
8
  import { saveActiveProfilePreference, saveModelPreference, loadToolSettings, saveToolSettings, clearToolSettings, clearActiveProfilePreference, loadSessionPreferences, saveSessionPreferences, } from '../core/preferences.js';
10
9
  import { buildEnabledToolSet, evaluateToolPermissions, getToolToggleOptions, } from '../capabilities/toolRegistry.js';
11
- import { BracketedPasteManager } from './bracketedPasteManager.js';
12
- import { ComposableMessageBuilder } from './composableMessage.js';
13
10
  import { detectApiKeyError } from '../core/errors/apiKeyErrors.js';
14
11
  import { buildWorkspaceContext } from '../workspace.js';
15
12
  import { buildInteractiveSystemPrompt } from './systemPrompt.js';
@@ -21,12 +18,10 @@ import { buildCustomCommandPrompt, loadCustomSlashCommands, } from '../core/cust
21
18
  import { SkillRepository } from '../skills/skillRepository.js';
22
19
  import { createSkillTools } from '../tools/skillTools.js';
23
20
  import { FileChangeTracker } from './fileChangeTracker.js';
24
- import { PersistentPrompt, PinnedChatBox } from '../ui/persistentPrompt.js';
25
21
  import { formatShortcutsHelp } from '../ui/shortcutsHelp.js';
26
22
  import { MetricsTracker } from '../alpha-zero/index.js';
27
23
  import { listAvailablePlugins } from '../plugins/index.js';
28
- import { getClaudeCodeStreamHandler } from './claudeCodeStreamHandler.js';
29
- import { getUnifiedChatBox } from './unifiedChatBox.js';
24
+ import { TerminalInputAdapter } from './terminalInputAdapter.js';
30
25
  const execAsync = promisify(exec);
31
26
  const DROPDOWN_COLORS = [
32
27
  theme.primary,
@@ -54,9 +49,6 @@ const BASE_SLASH_COMMANDS = getSlashCommands().map((cmd) => ({
54
49
  // Load PROVIDER_LABELS from centralized schema
55
50
  const PROVIDER_LABELS = Object.fromEntries(getProviders().map((provider) => [provider.id, provider.label]));
56
51
  // Allow enough time for paste detection to kick in before flushing buffered lines
57
- const MULTILINE_INPUT_FLUSH_DELAY_MS = 120;
58
- const BRACKETED_PASTE_ENABLE = '\u001b[?2004h';
59
- const BRACKETED_PASTE_DISABLE = '\u001b[?2004l';
60
52
  const CONTEXT_USAGE_THRESHOLD = 0.9;
61
53
  const CONTEXT_RECENT_MESSAGE_COUNT = 12;
62
54
  const CONTEXT_CLEANUP_CHARS_PER_CHUNK = 6000;
@@ -68,7 +60,6 @@ const CONTEXT_CLEANUP_SYSTEM_PROMPT = `You condense earlier IDE collaboration lo
68
60
  - Keep the response under roughly 200 words, prefer short bullet lists.
69
61
  - Never call tools or run shell commands; respond with plain Markdown text only.`;
70
62
  export class InteractiveShell {
71
- rl;
72
63
  agent = null;
73
64
  profile;
74
65
  profileLabel;
@@ -78,18 +69,14 @@ export class InteractiveShell {
78
69
  workspaceOptions;
79
70
  sessionState;
80
71
  isProcessing = false;
72
+ shuttingDown = false;
81
73
  pendingInteraction = null;
82
74
  pendingSecretRetry = null;
83
- bufferedInputLines = [];
84
- bufferedInputTimer = null;
85
- bracketedPaste;
86
- bracketedPasteEnabled = false;
87
- composableMessage = new ComposableMessageBuilder();
75
+ terminalInput;
76
+ currentInput = '';
88
77
  pendingCleanup = null;
89
78
  cleanupInProgress = false;
90
79
  slashPreviewVisible = false;
91
- keypressHandler = null;
92
- rawDataHandler = null;
93
80
  skillRepository;
94
81
  skillToolHandlers = new Map();
95
82
  thinkingMode = 'balanced';
@@ -99,7 +86,6 @@ export class InteractiveShell {
99
86
  statusTracker;
100
87
  uiAdapter;
101
88
  _fileChangeTracker = new FileChangeTracker(); // Reserved for future file tracking features
102
- persistentPrompt;
103
89
  alphaZeroMetrics; // Alpha Zero 2 performance tracking
104
90
  statusSubscription = null;
105
91
  followUpQueue = [];
@@ -117,11 +103,6 @@ export class InteractiveShell {
117
103
  customCommandMap;
118
104
  sessionRestoreConfig;
119
105
  _enabledPlugins;
120
- pinnedChatBox;
121
- readlineOutputSuppressed = false;
122
- originalStdoutWrite = null;
123
- streamHandler;
124
- unifiedChatBox;
125
106
  // Cached provider status for unified status bar display after streaming
126
107
  cachedProviderStatus = [];
127
108
  // Auto-test tracking
@@ -180,19 +161,10 @@ export class InteractiveShell {
180
161
  // Set up file change tracking callback
181
162
  this.uiAdapter.setFileChangeCallback((path, type, additions, removals) => {
182
163
  this._fileChangeTracker.recordChange(path, type, additions, removals);
183
- // Update persistent prompt status bar with file changes
184
- this.updatePersistentPromptFileChanges();
185
164
  });
186
- // Set up tool status callback to update pinned chat box during tool execution
165
+ // Set up tool status callback to update status during tool execution
187
166
  this.uiAdapter.setToolStatusCallback((status) => {
188
- if (status) {
189
- this.pinnedChatBox.setStatusMessage(status);
190
- }
191
- else {
192
- // Clear status but keep processing indicator if still processing
193
- this.pinnedChatBox.setStatusMessage(null);
194
- }
195
- this.pinnedChatBox.forceRender();
167
+ this.updateStatusMessage(status ?? null);
196
168
  });
197
169
  this.skillRepository = new SkillRepository({
198
170
  workingDir: this.workingDir,
@@ -201,62 +173,18 @@ export class InteractiveShell {
201
173
  for (const definition of createSkillTools({ repository: this.skillRepository })) {
202
174
  this.skillToolHandlers.set(definition.name, definition.handler);
203
175
  }
204
- this.rl = readline.createInterface({
205
- input,
206
- output,
207
- // Use '> ' as the standard prompt prefix
208
- prompt: formatUserPrompt(this.profileLabel || this.profile),
209
- terminal: true,
210
- historySize: 100, // Enable native readline history
211
- });
212
- // Wrap prompt() so we always re-arm stdin before showing it
213
- const originalPrompt = this.rl.prompt.bind(this.rl);
214
- this.rl.prompt = (preserveCursor) => {
215
- this.ensureReadlineReady();
216
- originalPrompt(preserveCursor);
217
- };
218
- // Initialize persistent prompt (Claude Code style)
219
- this.persistentPrompt = new PersistentPrompt(output, formatUserPrompt(this.profileLabel || this.profile));
220
- // Initialize pinned chat box for always-visible input during AI processing
221
- // Note: Pass empty string for promptText since render() adds its own '> ' prefix
222
- this.pinnedChatBox = new PinnedChatBox(output, '', // Don't pass formatUserPrompt - the render method adds '> ' prefix
223
- {
224
- onCommandQueued: (cmd) => {
225
- // Bridge to existing follow-up queue system
226
- this.followUpQueue.push({
227
- type: cmd.type === 'slash' ? 'request' : cmd.type,
228
- text: cmd.text,
229
- });
230
- this.refreshQueueIndicators();
231
- },
232
- onInputSubmit: (input) => {
233
- // Process the input through the normal flow
234
- this.processInputBlock(input).catch((err) => {
235
- display.showError(err instanceof Error ? err.message : String(err), err);
236
- });
237
- },
176
+ // Initialize unified terminal input handler
177
+ this.terminalInput = new TerminalInputAdapter(input, output, {
178
+ onSubmit: (text) => this.processInput(text),
179
+ onQueue: (text) => this.handleQueuedInput(text),
180
+ onInterrupt: () => this.handleInterrupt(),
181
+ onChange: (text) => this.handleInputChange(text),
238
182
  });
239
- // Register pinned chat box with display's output interceptor system
240
- // This ensures the pinned area is cleared before output and re-rendered after,
241
- // preventing ghost lines when the terminal scrolls
242
- this.pinnedChatBox.registerOutputInterceptor(display);
243
- // Initialize Claude Code style stream handler for natural output flow
244
- // Key insight: No cursor manipulation during streaming - output just appends
245
- this.streamHandler = getClaudeCodeStreamHandler();
246
- this.streamHandler.attachToReadline(this.rl);
247
- // Initialize UnifiedChatBox - Claude Code exact style
248
- // The chat box is always at the bottom naturally through readline
249
- // During streaming: output flows up, typed input queued invisibly
250
- // After streaming: new prompt appears at bottom naturally
251
- this.unifiedChatBox = getUnifiedChatBox();
252
- this.unifiedChatBox.attachToReadline(this.rl);
253
- this.unifiedChatBox.setPrompt(formatUserPrompt(this.profileLabel || this.profile));
254
183
  // Initialize Alpha Zero 2 metrics tracking
255
184
  this.alphaZeroMetrics = new MetricsTracker(`${this.profile}-${Date.now()}`);
256
185
  this.setupStatusTracking();
257
186
  this.refreshContextGauge();
258
- this.bracketedPasteEnabled = this.enableBracketedPasteMode();
259
- this.bracketedPaste = new BracketedPasteManager(this.bracketedPasteEnabled);
187
+ this.terminalInput.start();
260
188
  this.rebuildAgent();
261
189
  this.setupHandlers();
262
190
  this.refreshBannerSessionInfo();
@@ -303,17 +231,88 @@ export class InteractiveShell {
303
231
  this.sessionResumeNotice = null;
304
232
  }
305
233
  async start(initialPrompt) {
306
- // Claude Code style: Just use readline's prompt
307
- // The prompt naturally appears at the bottom
308
- // No complex UI rendering needed - terminal handles it naturally
309
234
  if (initialPrompt) {
310
235
  display.newLine();
311
236
  console.log(`${formatUserPrompt(this.profileLabel || this.profile)}${initialPrompt}`);
312
237
  await this.processInputBlock(initialPrompt);
313
238
  return;
314
239
  }
315
- // Show readline prompt - naturally at bottom of terminal
316
- this.rl.prompt();
240
+ // Ensure the terminal input is visible
241
+ this.terminalInput.render();
242
+ }
243
+ /**
244
+ * TerminalInputAdapter submit handler
245
+ */
246
+ processInput(text) {
247
+ this.handleInputChange('');
248
+ void this.processInputBlock(text).catch((err) => {
249
+ display.showError(err instanceof Error ? err.message : String(err), err);
250
+ });
251
+ }
252
+ /**
253
+ * TerminalInputAdapter queue handler (streaming mode)
254
+ */
255
+ handleQueuedInput(text) {
256
+ // Keep adapter queue trimmed so hints stay accurate
257
+ this.terminalInput.dequeue();
258
+ this.followUpQueue.push({ type: 'request', text });
259
+ display.showInfo(`Queued: "${text}"`);
260
+ this.refreshQueueIndicators();
261
+ this.scheduleQueueProcessing();
262
+ this.handleInputChange('');
263
+ }
264
+ /**
265
+ * TerminalInputAdapter change handler
266
+ */
267
+ handleInputChange(text) {
268
+ this.currentInput = text;
269
+ this.handleSlashCommandPreviewChange();
270
+ }
271
+ /**
272
+ * TerminalInputAdapter interrupt handler (Ctrl+C with empty buffer)
273
+ */
274
+ handleInterrupt() {
275
+ if (this.isProcessing && this.agent) {
276
+ this.agent.requestCancellation();
277
+ display.showWarning('Cancelling current operation... (Ctrl+C again to force quit)');
278
+ return;
279
+ }
280
+ this.shutdown();
281
+ }
282
+ /**
283
+ * Gracefully tear down the shell and exit
284
+ */
285
+ shutdown() {
286
+ if (this.shuttingDown) {
287
+ return;
288
+ }
289
+ this.shuttingDown = true;
290
+ // Stop any active spinner to prevent process hang
291
+ display.stopThinking(false);
292
+ this.teardownStatusTracking();
293
+ // Clear any pending cleanup to prevent hanging
294
+ this.pendingCleanup = null;
295
+ // Dispose terminal input handler
296
+ this.terminalInput.dispose();
297
+ // Dispose unified UI adapter
298
+ this.uiAdapter.dispose();
299
+ display.newLine();
300
+ const highlightedEmail = theme.info('support@ero.solar');
301
+ const infoMessage = [
302
+ 'Thank you to Anthropic for allowing me to use Claude Code to build erosolar-cli.',
303
+ '',
304
+ `Email ${highlightedEmail} with any bugs or feedback`,
305
+ 'GitHub: https://github.com/ErosolarAI/erosolar-by-bo',
306
+ 'npm: https://www.npmjs.com/package/erosolar-cli',
307
+ ].join('\n');
308
+ display.showInfo(infoMessage);
309
+ exit(0);
310
+ }
311
+ /**
312
+ * Update status bar message
313
+ */
314
+ updateStatusMessage(_message) {
315
+ this.terminalInput.render();
317
316
  }
318
317
  async handleToolSettingsInput(input) {
319
318
  const pending = this.pendingInteraction;
@@ -323,26 +322,26 @@ export class InteractiveShell {
323
322
  const trimmed = input.trim();
324
323
  if (!trimmed) {
325
324
  display.showWarning('Enter a number, "save", "defaults", or "cancel".');
326
- this.rl.prompt();
325
+ this.terminalInput.render();
327
326
  return;
328
327
  }
329
328
  const normalized = trimmed.toLowerCase();
330
329
  if (normalized === 'cancel') {
331
330
  this.pendingInteraction = null;
332
331
  display.showInfo('Tool selection cancelled.');
333
- this.rl.prompt();
332
+ this.terminalInput.render();
334
333
  return;
335
334
  }
336
335
  if (normalized === 'defaults') {
337
336
  pending.selection = buildEnabledToolSet(null);
338
337
  this.renderToolMenu(pending);
339
- this.rl.prompt();
338
+ this.terminalInput.render();
340
339
  return;
341
340
  }
342
341
  if (normalized === 'save') {
343
342
  await this.persistToolSelection(pending);
344
343
  this.pendingInteraction = null;
345
- this.rl.prompt();
344
+ this.terminalInput.render();
346
345
  return;
347
346
  }
348
347
  const choice = Number.parseInt(trimmed, 10);
@@ -360,11 +359,11 @@ export class InteractiveShell {
360
359
  }
361
360
  this.renderToolMenu(pending);
362
361
  }
363
- this.rl.prompt();
362
+ this.terminalInput.render();
364
363
  return;
365
364
  }
366
365
  display.showWarning('Enter a number, "save", "defaults", or "cancel".');
367
- this.rl.prompt();
366
+ this.terminalInput.render();
368
367
  }
369
368
  async persistToolSelection(interaction) {
370
369
  if (setsEqual(interaction.selection, interaction.initialSelection)) {
@@ -391,36 +390,36 @@ export class InteractiveShell {
391
390
  if (!this.agentMenu) {
392
391
  this.pendingInteraction = null;
393
392
  display.showWarning('Agent selection is unavailable in this CLI.');
394
- this.rl.prompt();
393
+ this.terminalInput.render();
395
394
  return;
396
395
  }
397
396
  const trimmed = input.trim();
398
397
  if (!trimmed) {
399
398
  display.showWarning('Enter a number or type "cancel".');
400
- this.rl.prompt();
399
+ this.terminalInput.render();
401
400
  return;
402
401
  }
403
402
  if (trimmed.toLowerCase() === 'cancel') {
404
403
  this.pendingInteraction = null;
405
404
  display.showInfo('Agent selection cancelled.');
406
- this.rl.prompt();
405
+ this.terminalInput.render();
407
406
  return;
408
407
  }
409
408
  const choice = Number.parseInt(trimmed, 10);
410
409
  if (!Number.isFinite(choice)) {
411
410
  display.showWarning('Please enter a valid number.');
412
- this.rl.prompt();
411
+ this.terminalInput.render();
413
412
  return;
414
413
  }
415
414
  const option = pending.options[choice - 1];
416
415
  if (!option) {
417
416
  display.showWarning('That option is not available.');
418
- this.rl.prompt();
417
+ this.terminalInput.render();
419
418
  return;
420
419
  }
421
420
  await this.persistAgentSelection(option.name);
422
421
  this.pendingInteraction = null;
423
- this.rl.prompt();
422
+ this.terminalInput.render();
424
423
  }
425
424
  async persistAgentSelection(profileName) {
426
425
  if (!this.agentMenu) {
@@ -442,342 +441,12 @@ export class InteractiveShell {
442
441
  display.showInfo(`${this.agentMenuLabel(profileName)} will load the next time you start the CLI. Restart to switch now.`);
443
442
  }
444
443
  setupHandlers() {
445
- // Set up raw data interception for bracketed paste
446
- this.setupRawPasteHandler();
447
- this.rl.on('line', (line) => {
448
- // If we're capturing raw paste data, ignore readline line events
449
- // (they've already been handled by the raw data handler)
450
- if (this.bracketedPaste.isCapturingRaw()) {
451
- this.resetBufferedInputLines();
452
- // Clear the line that readline just echoed - move up and clear
453
- output.write('\x1b[A\r\x1b[K');
454
- // Show paste progress (this will update in place)
455
- this.showMultiLinePastePreview(this.bracketedPaste.getRawBufferLineCount(), this.bracketedPaste.getRawBufferPreview());
456
- return;
457
- }
458
- // If a paste was just captured via raw handler, ignore readline's line events
459
- // (readline still emits line events after we've already captured the paste)
460
- if (this.bracketedPaste.shouldIgnoreLineEvent()) {
461
- this.resetBufferedInputLines();
462
- // Clear the echoed line that readline wrote
463
- output.write('\x1b[A\r\x1b[K');
464
- return;
465
- }
466
- const normalized = this.bracketedPaste.process(line);
467
- if (normalized.handled) {
468
- // Clear any partially buffered lines so we don't auto-submit the first line of a paste
469
- this.resetBufferedInputLines();
470
- // If still accumulating multi-line paste, show preview
471
- if (normalized.isPending) {
472
- // Clear the line that readline just echoed - move up and clear
473
- output.write('\x1b[A\r\x1b[K');
474
- this.showMultiLinePastePreview(normalized.lineCount || 0, normalized.preview);
475
- return;
476
- }
477
- // Paste complete, store or submit the full content
478
- if (typeof normalized.result === 'string') {
479
- this.clearMultiLinePastePreview();
480
- const lineCount = normalized.lineCount ?? normalized.result.split('\n').length;
481
- // All pastes (single or multi-line) are captured for confirmation before submit
482
- void this.capturePaste(normalized.result, lineCount);
483
- return;
484
- }
485
- return;
486
- }
487
- this.enqueueUserInput(line);
488
- // Clear input in persistent prompt after submission
489
- this.persistentPrompt.updateInput('', 0);
490
- });
491
- this.rl.on('close', () => {
492
- // Stop any active spinner to prevent process hang
493
- display.stopThinking(false);
494
- this.disableBracketedPasteMode();
495
- this.teardownStatusTracking();
496
- // Remove keypress listener to prevent memory leaks
497
- const inputStream = input;
498
- if (inputStream && this.keypressHandler) {
499
- inputStream.off('keypress', this.keypressHandler);
500
- this.keypressHandler = null;
501
- }
502
- // Restore original stdin emit (cleanup from paste interception)
503
- if (this.rawDataHandler) {
504
- this.rawDataHandler(); // This restores the original emit function
505
- this.rawDataHandler = null;
506
- }
507
- // Clear any pending cleanup to prevent hanging
508
- this.pendingCleanup = null;
509
- // Dispose persistent prompt
510
- this.persistentPrompt.dispose();
511
- // Dispose Claude Code stream handler
512
- this.streamHandler.dispose();
513
- // Dispose unified chat box
514
- this.unifiedChatBox.dispose();
515
- // Dispose unified UI adapter
516
- this.uiAdapter.dispose();
517
- display.newLine();
518
- const highlightedEmail = theme.info('support@ero.solar');
519
- const infoMessage = [
520
- 'Thank you to Anthropic for allowing me to use Claude Code to build erosolar-cli.',
521
- '',
522
- `Email ${highlightedEmail} with any bugs or feedback`,
523
- 'GitHub: https://github.com/ErosolarAI/erosolar-by-bo',
524
- 'npm: https://www.npmjs.com/package/erosolar-cli',
525
- ].join('\n');
526
- display.showInfo(infoMessage);
527
- exit(0);
528
- });
529
444
  // Handle terminal resize
530
445
  output.on('resize', () => {
531
- this.persistentPrompt.handleResize();
532
- this.pinnedChatBox.handleResize();
533
- });
534
- this.setupSlashCommandPreviewHandler();
535
- // Show initial persistent prompt
536
- this.persistentPrompt.show();
537
- }
538
- /**
539
- * Set up raw stdin data interception for bracketed paste mode.
540
- * This intercepts data before readline processes it, allowing us to
541
- * capture complete multi-line pastes without readline splitting them.
542
- */
543
- setupRawPasteHandler() {
544
- if (!this.bracketedPasteEnabled) {
545
- return;
546
- }
547
- const inputStream = input;
548
- if (!inputStream || !inputStream.isTTY) {
549
- return;
550
- }
551
- // Set up callback for when a complete paste is captured
552
- this.bracketedPaste.setRawPasteCallback((content) => {
553
- this.clearMultiLinePastePreview();
554
- const lines = content.split('\n');
555
- const lineCount = lines.length;
556
- // When streaming, route pastes into the pinned chat box instead of readline
557
- if (this.isProcessing) {
558
- this.pinnedChatBox.handlePaste(content);
559
- this.pinnedChatBox.updatePersistentInput();
560
- return;
561
- }
562
- // All pastes (single or multi-line) are captured for confirmation before submit
563
- void this.capturePaste(content, lineCount);
446
+ this.terminalInput.handleResize();
564
447
  });
565
- // Set up raw data interception to catch bracketed paste before readline processes it.
566
- // We need to actually PREVENT readline from seeing the paste content to avoid echo.
567
- // Strategy: Replace stdin's 'data' event emission during paste capture.
568
- const originalEmit = inputStream.emit.bind(inputStream);
569
- inputStream.emit = (event, ...args) => {
570
- if (event === 'data' && args[0]) {
571
- const data = args[0];
572
- const str = typeof data === 'string' ? data : data.toString();
573
- const result = this.bracketedPaste.processRawData(str);
574
- if (result.consumed) {
575
- // Data was consumed by paste handler - don't pass to readline
576
- // If there's passThrough data, emit that instead
577
- if (result.passThrough) {
578
- return originalEmit('data', Buffer.from(result.passThrough));
579
- }
580
- return true; // Event "handled" but not passed to other listeners
581
- }
582
- }
583
- // Pass through all other events and non-paste data normally
584
- return originalEmit(event, ...args);
585
- };
586
- // Store reference for cleanup
587
- this.rawDataHandler = () => {
588
- inputStream.emit = originalEmit;
589
- };
590
- }
591
- setupSlashCommandPreviewHandler() {
592
- const inputStream = input;
593
- if (!inputStream || typeof inputStream.on !== 'function' || !inputStream.isTTY) {
594
- return;
595
- }
596
- if (inputStream.listenerCount('keypress') === 0) {
597
- readline.emitKeypressEvents(inputStream, this.rl);
598
- }
599
- // Ensure raw mode for keypress events
600
- if (inputStream.setRawMode && !inputStream.isRaw) {
601
- inputStream.setRawMode(true);
602
- }
603
- this.keypressHandler = (_str, key) => {
604
- // Handle special keys
605
- if (key) {
606
- // Shift+Tab: Cycle paste preview options if paste blocks exist, otherwise profile switching
607
- if (key.name === 'tab' && key.shift) {
608
- // Check if there are paste blocks to cycle through
609
- const pasteBlockCount = this.composableMessage.getPasteBlockCount();
610
- if (pasteBlockCount > 0) {
611
- this.cyclePastePreview();
612
- return;
613
- }
614
- // Fall through to profile switching if no paste blocks
615
- if (this.agentMenu) {
616
- this.showProfileSwitcher();
617
- return;
618
- }
619
- }
620
- // Tab: Autocomplete slash commands
621
- if (key.name === 'tab' && !key.shift && !key.ctrl) {
622
- this.handleTabCompletion();
623
- return;
624
- }
625
- // Escape: Cancel current operation if agent is running
626
- if (key.name === 'escape') {
627
- if (this.isProcessing && this.agent) {
628
- this.agent.requestCancellation();
629
- output.write('\n');
630
- display.showWarning('Cancelling current operation... (waiting for safe stop point)');
631
- return;
632
- }
633
- // If not processing, clear any queued follow-ups
634
- if (this.followUpQueue.length > 0) {
635
- const cleared = this.followUpQueue.length;
636
- this.followUpQueue.length = 0;
637
- this.refreshQueueIndicators();
638
- output.write('\n');
639
- display.showInfo(`Cleared ${cleared} queued follow-up${cleared === 1 ? '' : 's'}.`);
640
- this.rl.prompt();
641
- return;
642
- }
643
- }
644
- // Ctrl+C: Clear input first, then cancel operation if no input
645
- if (key.ctrl && key.name === 'c') {
646
- // During processing, check pinnedChatBox input first
647
- if (this.isProcessing) {
648
- const chatBoxInput = this.pinnedChatBox.getInput();
649
- if (chatBoxInput.length > 0) {
650
- // Clear the chat box input instead of cancelling
651
- this.pinnedChatBox.clearPastedBlockState();
652
- this.pinnedChatBox.updatePersistentInput();
653
- return;
654
- }
655
- // No text in chat box, cancel the agent
656
- if (this.agent) {
657
- this.agent.requestCancellation();
658
- output.write('\n');
659
- display.showWarning('Cancelling current operation... (Ctrl+C again to force quit)');
660
- return;
661
- }
662
- }
663
- // Not processing - check readline input
664
- const currentLine = this.rl.line || '';
665
- if (currentLine.length > 0) {
666
- // Clear the input buffer instead of exiting
667
- this.rl.line = '';
668
- this.rl.cursor = 0;
669
- this.persistentPrompt.updateInput('', 0);
670
- this.pinnedChatBox.setInput('');
671
- this.pinnedChatBox.clearPastedBlockState();
672
- // Clear the displayed line and show fresh prompt
673
- output.write('\r\x1b[K'); // Clear current line
674
- output.write('^C\n'); // Show ^C indicator
675
- this.rl.prompt(); // Re-show prompt
676
- return;
677
- }
678
- // If no text in buffer, let default Ctrl+C behavior exit
679
- }
680
- // Ctrl+G: Edit pasted content blocks
681
- if (key.ctrl && key.name === 'g') {
682
- this.showPasteBlockEditor();
683
- return;
684
- }
685
- }
686
- // Claude Code approach: During processing, route input to PinnedChatBox
687
- // - User can type and see their input in the visible chat box at bottom
688
- // - Input is queued until streaming ends
689
- // - After each keystroke, we re-render the bottom area to show the input
690
- if (this.isProcessing) {
691
- // Handle paste: if _str contains newlines, it's a paste - don't auto-submit
692
- const isPaste = typeof _str === 'string' && (_str.length > 1 || _str.includes('\n'));
693
- if (key) {
694
- if (key.name === 'backspace') {
695
- this.pinnedChatBox.handleBackspace();
696
- }
697
- else if (key.name === 'delete') {
698
- this.pinnedChatBox.handleDelete();
699
- }
700
- else if (key.name === 'left') {
701
- if (key.meta || key.alt) {
702
- this.pinnedChatBox.handleWordLeft();
703
- }
704
- else {
705
- this.pinnedChatBox.handleCursorLeft();
706
- }
707
- }
708
- else if (key.name === 'right') {
709
- if (key.meta || key.alt) {
710
- this.pinnedChatBox.handleWordRight();
711
- }
712
- else {
713
- this.pinnedChatBox.handleCursorRight();
714
- }
715
- }
716
- else if (key.name === 'up') {
717
- this.pinnedChatBox.handleHistoryUp();
718
- }
719
- else if (key.name === 'down') {
720
- this.pinnedChatBox.handleHistoryDown();
721
- }
722
- else if (key.name === 'home' || (key.ctrl && key.name === 'a')) {
723
- this.pinnedChatBox.handleHome();
724
- }
725
- else if (key.name === 'end' || (key.ctrl && key.name === 'e')) {
726
- this.pinnedChatBox.handleEnd();
727
- }
728
- else if (key.ctrl && key.name === 'u') {
729
- this.pinnedChatBox.handleDeleteToStart();
730
- }
731
- else if (key.ctrl && key.name === 'k') {
732
- this.pinnedChatBox.handleDeleteToEnd();
733
- }
734
- else if (key.ctrl && key.name === 'w') {
735
- this.pinnedChatBox.handleDeleteWord();
736
- }
737
- else if (key.name === 'return' && (key.shift || key.meta || key.ctrl)) {
738
- // Shift+Enter or Option+Enter: Insert newline for multi-line input
739
- this.pinnedChatBox.handleNewline();
740
- }
741
- else if (key.name === 'return' && !isPaste) {
742
- // Queue the command - but NOT if this is part of a paste
743
- const result = this.pinnedChatBox.handleSubmit();
744
- if (result) {
745
- const preview = result.replace(/\s+/g, ' ').trim();
746
- const displayText = preview.length > 50 ? `${preview.slice(0, 47)}...` : preview;
747
- display.showInfo(`📝 Queued: "${displayText}"`);
748
- }
749
- }
750
- else if (_str && !key.ctrl && !key.meta) {
751
- // Route raw input (including multi-line paste) to the pinned chat box
752
- this.pinnedChatBox.handleInput(_str, { allowNewlines: true, isPaste });
753
- }
754
- }
755
- else if (_str) {
756
- // String without key info (paste) - pass through for summary handling
757
- this.pinnedChatBox.handleInput(_str, { allowNewlines: true, isPaste: true });
758
- }
759
- // Re-render the bottom chat box to show the updated input
760
- this.pinnedChatBox.updatePersistentInput();
761
- return;
762
- }
763
- // Readline handles all keyboard input natively (history, shortcuts, etc.)
764
- // We just sync the current state to our display components
765
- // Use setImmediate to get the updated line after readline processes the key
766
- setImmediate(() => {
767
- const currentLine = this.rl.line || '';
768
- const cursorPos = this.rl.cursor || 0;
769
- this.persistentPrompt.updateInput(currentLine, cursorPos);
770
- // Sync to pinned chat box for display only
771
- this.pinnedChatBox.setInput(currentLine, cursorPos);
772
- if (this.composableMessage.hasContent()) {
773
- this.composableMessage.setDraft(currentLine);
774
- this.updateComposeStatusSummary();
775
- }
776
- this.handleSlashCommandPreviewChange();
777
- });
778
- };
779
- // Use prependListener to handle before readline
780
- inputStream.prependListener('keypress', this.keypressHandler);
448
+ // Show initial input UI
449
+ this.terminalInput.render();
781
450
  }
782
451
  setupStatusTracking() {
783
452
  this.statusSubscription = this.statusTracker.subscribe((_state) => {
@@ -835,14 +504,10 @@ export class InteractiveShell {
835
504
  if (!shouldShow && this.slashPreviewVisible) {
836
505
  this.slashPreviewVisible = false;
837
506
  this.uiAdapter.hideSlashCommandPreview();
838
- // Show persistent prompt again after hiding overlay
839
- if (!this.isProcessing) {
840
- this.persistentPrompt.show();
841
- }
842
507
  }
843
508
  }
844
509
  shouldShowSlashCommandPreview() {
845
- const line = this.rl.line ?? '';
510
+ const line = this.currentInput ?? '';
846
511
  if (!line.trim()) {
847
512
  return false;
848
513
  }
@@ -851,642 +516,28 @@ export class InteractiveShell {
851
516
  }
852
517
  showSlashCommandPreview() {
853
518
  // Filter commands based on current input
854
- const line = this.rl.line ?? '';
519
+ const line = this.currentInput ?? '';
855
520
  const trimmed = line.trimStart();
856
521
  // Filter commands that match the current input
857
522
  const filtered = this.slashCommands.filter(cmd => cmd.command.startsWith(trimmed) || trimmed === '/');
858
- // Hide persistent prompt to avoid conflicts with overlay
859
- this.persistentPrompt.hide();
860
523
  // Show in the unified UI with dynamic overlay
861
524
  this.uiAdapter.showSlashCommandPreview(filtered);
862
525
  // Don't reprompt - this causes flickering
863
526
  }
864
- showProfileSwitcher() {
865
- if (!this.agentMenu) {
866
- return;
867
- }
868
- // Build profile options with current/next indicators
869
- const profiles = this.agentMenu.options.map((option, index) => {
870
- const badges = [];
871
- const nextProfile = this.agentMenu.persistedProfile ?? this.agentMenu.defaultProfile;
872
- if (option.name === this.profile) {
873
- badges.push('current');
874
- }
875
- if (option.name === nextProfile && option.name !== this.profile) {
876
- badges.push('next');
877
- }
878
- const badgeText = badges.length > 0 ? ` (${badges.join(', ')})` : '';
879
- return {
880
- command: `${index + 1}. ${option.label}${badgeText}`,
881
- description: `${this.providerLabel(option.defaultProvider)} • ${option.defaultModel}`,
882
- };
883
- });
884
- // Show profile switcher overlay
885
- this.uiAdapter.showProfileSwitcher(profiles, this.profileLabel);
886
- }
887
527
  /**
888
- * Ensure stdin is ready for interactive input before showing the prompt.
889
- * This recovers from cases where a nested readline/prompt or partial paste
890
- * left the stream paused, raw mode disabled, or keypress listeners detached.
528
+ * Ensure the terminal input is ready for interactive input.
891
529
  */
892
530
  ensureReadlineReady() {
893
- // Clear any stuck bracketed paste state so new input isn't dropped
894
- this.bracketedPaste.reset();
895
- // Always ensure the pinned chat box is visible when readline is ready
896
- this.pinnedChatBox.show();
897
- this.pinnedChatBox.forceRender();
898
- const inputStream = input;
899
- if (!inputStream) {
900
- return;
901
- }
902
- // Resume stdin if another consumer paused it
903
- if (typeof inputStream.isPaused === 'function' && inputStream.isPaused()) {
904
- inputStream.resume();
905
- }
906
- else if (inputStream.readableFlowing === false) {
907
- inputStream.resume();
908
- }
909
- // Restore raw mode if a nested readline turned it off
910
- if (inputStream.isTTY && typeof inputStream.isRaw === 'boolean') {
911
- const ttyStream = inputStream;
912
- if (!ttyStream.isRaw) {
913
- ttyStream.setRawMode(true);
914
- }
915
- }
916
- // Reattach keypress handler if it was removed
917
- if (this.keypressHandler) {
918
- const listeners = inputStream.listeners('keypress');
919
- const hasHandler = listeners.includes(this.keypressHandler);
920
- if (!hasHandler) {
921
- if (inputStream.listenerCount('keypress') === 0) {
922
- readline.emitKeypressEvents(inputStream, this.rl);
923
- }
924
- inputStream.on('keypress', this.keypressHandler);
925
- }
926
- }
927
- // Restore readline output if it was suppressed
928
- // Note: Stream handler manages its own state, this is for legacy compatibility
929
- if (!this.streamHandler.isStreaming()) {
930
- this.restoreReadlineOutput();
931
- }
932
- }
933
- /**
934
- * Suppress readline's character echo during streaming.
935
- * Characters typed will be captured but not echoed to the main output.
936
- * Instead, they appear only in the persistent input box at the bottom.
937
- *
938
- * @deprecated Use streamHandler.startStreaming() instead - kept for backwards compatibility
939
- * @internal Kept for legacy code paths that may still reference this method
940
- */
941
- // @ts-expect-error - Legacy method kept for backwards compatibility, unused in favor of streamHandler
942
- suppressReadlineOutput() {
943
- // Delegate to stream handler if it's not already streaming
944
- if (this.streamHandler.isStreaming()) {
945
- return;
946
- }
947
- if (this.readlineOutputSuppressed || !output.isTTY) {
948
- return;
949
- }
950
- this.originalStdoutWrite = output.write.bind(output);
951
- const self = this;
952
- // Replace stdout.write to filter readline echo
953
- // Readline writes single characters for echo - we filter those out
954
- // but allow multi-character writes (actual output) through
955
- output.write = function (chunk, encodingOrCallback, callback) {
956
- if (!self.originalStdoutWrite) {
957
- return true;
958
- }
959
- const str = typeof chunk === 'string' ? chunk : chunk.toString();
960
- // Filter out readline echo patterns:
961
- // - Single printable characters (user typing)
962
- // - Cursor movement sequences for single chars
963
- // - Backspace sequences
964
- // - Prompt redraws
965
- // But allow through:
966
- // - Multi-character content (actual AI output)
967
- // - Newlines and control sequences for formatting
968
- // If it's a single printable char, suppress it (user typing)
969
- if (str.length === 1 && str.charCodeAt(0) >= 32 && str.charCodeAt(0) < 127) {
970
- return true;
971
- }
972
- // Suppress backspace sequences (readline's delete char)
973
- if (str === '\b \b' || str === '\x1b[D \x1b[D' || str === '\b' || str === '\x7f') {
974
- return true;
975
- }
976
- // Suppress cursor movement sequences (readline cursor positioning)
977
- if (/^\x1b\[\d*[ABCD]$/.test(str)) {
978
- return true;
979
- }
980
- // Suppress readline prompt redraw patterns (starts with \r or cursor home)
981
- if (/^\r/.test(str) && str.length < 20 && /^[\r\x1b\[\dGK> ]+$/.test(str)) {
982
- return true;
983
- }
984
- // Suppress clear line + prompt patterns from readline
985
- if (/^\x1b\[\d*[GK]/.test(str) && str.length < 15) {
986
- return true;
987
- }
988
- // Suppress short sequences that look like readline control (not AI content)
989
- // AI content is typically longer or contains actual text
990
- if (str.length <= 3 && /^[\x1b\[\]\d;GKJ]+$/.test(str)) {
991
- return true;
992
- }
993
- // Allow everything else through (actual AI output)
994
- if (typeof encodingOrCallback === 'function') {
995
- return self.originalStdoutWrite(chunk, encodingOrCallback);
996
- }
997
- return self.originalStdoutWrite(chunk, encodingOrCallback, callback);
998
- };
999
- this.readlineOutputSuppressed = true;
1000
- }
1001
- /**
1002
- * Restore normal readline output after streaming completes.
1003
- *
1004
- * @deprecated Use streamHandler.endStreaming() instead
1005
- */
1006
- restoreReadlineOutput() {
1007
- if (!this.readlineOutputSuppressed || !this.originalStdoutWrite) {
1008
- return;
1009
- }
1010
- output.write = this.originalStdoutWrite;
1011
- this.originalStdoutWrite = null;
1012
- this.readlineOutputSuppressed = false;
1013
- }
1014
- enqueueUserInput(line, flushImmediately = false) {
1015
- this.bufferedInputLines.push(line);
1016
- if (flushImmediately) {
1017
- if (this.bufferedInputTimer) {
1018
- clearTimeout(this.bufferedInputTimer);
1019
- this.bufferedInputTimer = null;
1020
- }
1021
- void this.flushBufferedInput();
1022
- return;
1023
- }
1024
- if (this.bufferedInputTimer) {
1025
- clearTimeout(this.bufferedInputTimer);
1026
- }
1027
- this.bufferedInputTimer = setTimeout(() => {
1028
- void this.flushBufferedInput();
1029
- }, MULTILINE_INPUT_FLUSH_DELAY_MS);
1030
- }
1031
- /**
1032
- * Clear any buffered input lines and pending flush timers.
1033
- * Prevents partial multi-line pastes from being auto-submitted.
1034
- */
1035
- resetBufferedInputLines() {
1036
- if (this.bufferedInputTimer) {
1037
- clearTimeout(this.bufferedInputTimer);
1038
- this.bufferedInputTimer = null;
1039
- }
1040
- if (this.bufferedInputLines.length) {
1041
- this.bufferedInputLines = [];
1042
- }
1043
- }
1044
- /** Track paste preview state to properly clear/update display */
1045
- lastPastePreviewLineCount = 0;
1046
- inPasteCapture = false;
1047
- /**
1048
- * Show a preview indicator while accumulating multi-line paste.
1049
- * The line handler has already cleared readline's echoed line, so we just
1050
- * write our preview in place using carriage return.
1051
- */
1052
- showMultiLinePastePreview(lineCount, preview) {
1053
- // Only update if we're actually accumulating (avoid spam during rapid paste)
1054
- if (lineCount === 0 && !preview) {
1055
- return;
1056
- }
1057
- // Mark that we're in paste capture mode
1058
- this.inPasteCapture = true;
1059
- // Throttle updates - only update if line count changed to avoid flicker
1060
- if (lineCount === this.lastPastePreviewLineCount) {
1061
- return;
1062
- }
1063
- this.lastPastePreviewLineCount = lineCount;
1064
- // Clear current line and write preview (the line handler already moved cursor up)
1065
- output.write('\r\x1b[K');
1066
- const statusText = preview
1067
- ? `${theme.ui.muted('📋 Pasting:')} ${theme.ui.muted(preview.slice(0, 50))}${preview.length > 50 ? '...' : ''}`
1068
- : `${theme.ui.muted(`📋 Pasting ${lineCount} line${lineCount !== 1 ? 's' : ''}...`)}`;
1069
- output.write(statusText);
1070
- }
1071
- /**
1072
- * Clear the multi-line paste preview
1073
- */
1074
- clearMultiLinePastePreview() {
1075
- if (!this.inPasteCapture) {
1076
- return;
1077
- }
1078
- // Clear current line
1079
- output.write('\r\x1b[K');
1080
- // Reset tracking state
1081
- this.lastPastePreviewLineCount = 0;
1082
- this.inPasteCapture = false;
1083
- }
1084
- /**
1085
- * Capture any paste (single or multi-line) without immediately submitting it.
1086
- * - Short pastes (1-2 lines) are displayed inline like normal typed text
1087
- * - Longer pastes (3+ lines) show as collapsed block chips
1088
- * Supports multiple pastes - user can paste multiple times before submitting.
1089
- */
1090
- async capturePaste(content, lineCount) {
1091
- this.resetBufferedInputLines();
1092
- // Short pastes (1-2 lines) display inline like normal text
1093
- const isShortPaste = lineCount <= 2;
1094
- if (isShortPaste) {
1095
- // For short pastes, display inline like normal typed text
1096
- // No composableMessage storage - just treat as typed input
1097
- // For 2-line pastes, join with a visual newline indicator
1098
- const displayContent = lineCount === 1
1099
- ? content
1100
- : content.replace(/\n/g, ' ↵ '); // Visual newline indicator for 2-line pastes
1101
- // Clear any echoed content first
1102
- output.write('\r\x1b[K');
1103
- // Get current readline content and append paste
1104
- const currentLine = this.rl.line || '';
1105
- const cursorPos = this.rl.cursor || 0;
1106
- // Insert paste at cursor position
1107
- const before = currentLine.slice(0, cursorPos);
1108
- const after = currentLine.slice(cursorPos);
1109
- const newLine = before + displayContent + after;
1110
- const newCursor = cursorPos + displayContent.length;
1111
- // Update readline buffer - write directly without storing in composableMessage
1112
- // This allows short pastes to flow through as normal typed text
1113
- this.rl.write(null, { ctrl: true, name: 'u' }); // Clear line
1114
- this.rl.write(newLine); // Write new content
1115
- // Update persistent prompt display
1116
- this.persistentPrompt.updateInput(newLine, newCursor);
1117
- // NOTE: Don't clear pasteJustCaptured here - the counter-based logic in shouldIgnoreLineEvent()
1118
- // will decrement for each readline line event and auto-clear when all are processed.
1119
- // Clearing prematurely causes the remaining readline-echoed lines to pass through.
1120
- // Re-prompt to show the inline content
1121
- this.rl.prompt(true);
1122
- return;
1123
- }
1124
- // For longer pastes (3+ lines), store as a composable block
1125
- // Check size limits first
1126
- const { ComposableMessageBuilder } = await import('./composableMessage.js');
1127
- const sizeCheck = ComposableMessageBuilder.checkPasteSize(content);
1128
- if (!sizeCheck.ok) {
1129
- // Paste rejected - show error and abort
1130
- display.showError(sizeCheck.error || 'Paste rejected due to size limits');
1131
- this.rl.prompt();
1132
- return;
1133
- }
1134
- // Show warning for large (but acceptable) pastes
1135
- if (sizeCheck.warning) {
1136
- display.showWarning(`⚠️ ${sizeCheck.warning}`);
1137
- }
1138
- const pasteId = this.composableMessage.addPaste(content);
1139
- if (!pasteId) {
1140
- // Should not happen since we checked above, but handle defensively
1141
- display.showError('Failed to store paste block');
1142
- this.rl.prompt();
1143
- return;
1144
- }
1145
- // Clear remaining echoed lines from terminal
1146
- output.write('\r\x1b[K');
1147
- // Build the paste chips to show inline with prompt
1148
- // Format: [Pasted text #1 +104 lines] [Pasted text #2 +50 lines]
1149
- const pasteChips = this.composableMessage.formatPasteChips();
1150
- // Update status bar - minimal hint, no confirmation required
1151
- this.persistentPrompt.updateStatusBar({
1152
- message: 'Press Enter to send',
1153
- });
1154
- // Set the prompt to show paste chips, then position cursor after them
1155
- // The user can type additional text after the chips
1156
- this.persistentPrompt.updateInput(pasteChips + ' ', pasteChips.length + 1);
1157
- // Update readline's line buffer to include the chips as prefix
1158
- // This ensures typed text appears after the chips
1159
- if (this.rl.line !== undefined) {
1160
- this.rl.line = pasteChips + ' ';
1161
- this.rl.cursor = pasteChips.length + 1;
1162
- }
1163
- // NOTE: Don't clear pasteJustCaptured here - the counter-based logic in shouldIgnoreLineEvent()
1164
- // will decrement for each readline line event (one per pasted line) and auto-clear when done.
1165
- // Clearing prematurely causes remaining readline-echoed lines to pass through and get displayed.
1166
- this.rl.prompt(true); // preserveCursor=true to keep position after chips
1167
- }
1168
- /**
1169
- * Update the status bar to reflect any pending composed message parts
1170
- */
1171
- updateComposeStatusSummary() {
1172
- if (this.composableMessage.hasContent()) {
1173
- // Chips are shown inline - minimal status hint
1174
- this.persistentPrompt.updateStatusBar({
1175
- message: 'Press Enter to send',
1176
- });
1177
- }
1178
- else {
1179
- this.persistentPrompt.updateStatusBar({ message: undefined });
1180
- }
1181
- }
1182
- /**
1183
- * Show paste block editor (Ctrl+G)
1184
- * Allows viewing and editing captured paste blocks before sending
1185
- *
1186
- * Features:
1187
- * - View block content with line numbers
1188
- * - Edit blocks inline (replace content)
1189
- * - Remove individual blocks
1190
- * - Undo/redo paste operations
1191
- * - Clear all blocks
1192
- */
1193
- showPasteBlockEditor() {
1194
- const state = this.composableMessage.getState();
1195
- const pasteBlocks = state.parts.filter((p) => p.type === 'paste');
1196
- if (pasteBlocks.length === 0) {
1197
- display.showInfo('No pasted content blocks to edit. Paste some content first.');
1198
- this.rl.prompt();
1199
- return;
1200
- }
1201
- output.write('\n');
1202
- display.showSystemMessage('📋 Paste Block Editor');
1203
- output.write('\n');
1204
- // Display each paste block with preview
1205
- pasteBlocks.forEach((block, index) => {
1206
- const lines = block.content.split('\n');
1207
- const preview = lines.slice(0, 3).map((l) => ` ${l.slice(0, 60)}${l.length > 60 ? '...' : ''}`).join('\n');
1208
- const moreLines = lines.length > 3 ? `\n ... +${lines.length - 3} more lines` : '';
1209
- const editedFlag = block.edited ? theme.warning(' [edited]') : '';
1210
- output.write(theme.ui.muted(`[${index + 1}] `) + theme.info(`${block.lineCount} lines, ${block.content.length} chars`) + editedFlag + '\n');
1211
- output.write(theme.secondary(preview + moreLines) + '\n\n');
1212
- });
1213
- // Show undo/redo status
1214
- const canUndo = this.composableMessage.canUndo();
1215
- const canRedo = this.composableMessage.canRedo();
1216
- if (canUndo || canRedo) {
1217
- const undoStatus = canUndo ? theme.success('undo available') : theme.ui.muted('undo unavailable');
1218
- const redoStatus = canRedo ? theme.success('redo available') : theme.ui.muted('redo unavailable');
1219
- output.write(theme.ui.muted('History: ') + undoStatus + theme.ui.muted(' │ ') + redoStatus + '\n\n');
1220
- }
1221
- output.write(theme.ui.muted('Commands: ') + '\n');
1222
- output.write(theme.ui.muted(' • Enter number to view full block') + '\n');
1223
- output.write(theme.ui.muted(' • "edit N" to replace block N content') + '\n');
1224
- output.write(theme.ui.muted(' • "remove N" to remove block N') + '\n');
1225
- output.write(theme.ui.muted(' • "undo" / "redo" to undo/redo changes') + '\n');
1226
- output.write(theme.ui.muted(' • "clear" to remove all blocks') + '\n');
1227
- output.write(theme.ui.muted(' • Enter to return to prompt') + '\n');
1228
- output.write('\n');
1229
- // Set up interaction mode for paste editing
1230
- this.pendingInteraction = { type: 'paste-edit' };
1231
- this.rl.prompt();
1232
- }
1233
- /**
1234
- * Handle paste block editor input
1235
- */
1236
- async handlePasteEditInput(trimmedInput) {
1237
- const trimmed = trimmedInput.toLowerCase();
1238
- const state = this.composableMessage.getState();
1239
- const pasteBlocks = state.parts.filter((p) => p.type === 'paste');
1240
- if (!trimmed) {
1241
- // Exit paste edit mode
1242
- this.pendingInteraction = null;
1243
- display.showInfo('Returned to prompt.');
1244
- this.rl.prompt();
1245
- return;
1246
- }
1247
- // Handle undo command
1248
- if (trimmed === 'undo') {
1249
- if (this.composableMessage.undo()) {
1250
- this.updateComposeStatusSummary();
1251
- this.refreshPasteChipsDisplay();
1252
- display.showInfo('Undone.');
1253
- // Refresh the paste block view
1254
- this.showPasteBlockEditor();
1255
- }
1256
- else {
1257
- display.showWarning('Nothing to undo.');
1258
- this.rl.prompt();
1259
- }
1260
- return;
1261
- }
1262
- // Handle redo command
1263
- if (trimmed === 'redo') {
1264
- if (this.composableMessage.redo()) {
1265
- this.updateComposeStatusSummary();
1266
- this.refreshPasteChipsDisplay();
1267
- display.showInfo('Redone.');
1268
- // Refresh the paste block view
1269
- this.showPasteBlockEditor();
1270
- }
1271
- else {
1272
- display.showWarning('Nothing to redo.');
1273
- this.rl.prompt();
1274
- }
1275
- return;
1276
- }
1277
- if (trimmed === 'clear') {
1278
- this.composableMessage.clear();
1279
- this.updateComposeStatusSummary();
1280
- this.persistentPrompt.updateInput('', 0);
1281
- this.rl.line = '';
1282
- this.rl.cursor = 0;
1283
- this.pendingInteraction = null;
1284
- display.showInfo('Cleared all paste blocks.');
1285
- this.rl.prompt();
1286
- return;
1287
- }
1288
- // Handle "edit N" command - start inline editing mode
1289
- const editMatch = trimmed.match(/^edit\s+(\d+)$/);
1290
- if (editMatch) {
1291
- const blockNum = parseInt(editMatch[1], 10);
1292
- if (blockNum >= 1 && blockNum <= pasteBlocks.length) {
1293
- const block = pasteBlocks[blockNum - 1];
1294
- if (block) {
1295
- // Enter edit mode for this block
1296
- this.pendingInteraction = { type: 'paste-edit-block', blockId: block.id, blockNum };
1297
- output.write('\n');
1298
- display.showSystemMessage(`Editing Block ${blockNum}:`);
1299
- output.write(theme.ui.muted('Paste or type new content. Press Enter twice to finish, or "cancel" to abort.\n'));
1300
- output.write('\n');
1301
- // Show current content as reference
1302
- const preview = block.content.split('\n').slice(0, 5).map((l) => theme.ui.muted(` ${l.slice(0, 60)}`)).join('\n');
1303
- output.write(theme.ui.muted('Current content (first 5 lines):\n') + preview + '\n\n');
1304
- }
1305
- }
1306
- else {
1307
- display.showWarning(`Invalid block number. Use 1-${pasteBlocks.length}.`);
1308
- }
1309
- this.rl.prompt();
1310
- return;
1311
- }
1312
- // Handle "remove N" command
1313
- const removeMatch = trimmed.match(/^remove\s+(\d+)$/);
1314
- if (removeMatch) {
1315
- const blockNum = parseInt(removeMatch[1], 10);
1316
- if (blockNum >= 1 && blockNum <= pasteBlocks.length) {
1317
- const block = pasteBlocks[blockNum - 1];
1318
- if (block) {
1319
- this.composableMessage.removePart(block.id);
1320
- this.updateComposeStatusSummary();
1321
- this.refreshPasteChipsDisplay();
1322
- display.showInfo(`Removed block ${blockNum}.`);
1323
- // Check if any blocks remain
1324
- const remaining = this.composableMessage.getState().parts.filter(p => p.type === 'paste');
1325
- if (remaining.length === 0) {
1326
- this.pendingInteraction = null;
1327
- display.showInfo('No more paste blocks.');
1328
- }
1329
- }
1330
- }
1331
- else {
1332
- display.showWarning(`Invalid block number. Use 1-${pasteBlocks.length}.`);
1333
- }
1334
- this.rl.prompt();
1335
- return;
1336
- }
1337
- // Handle viewing a block by number
1338
- const blockNum = parseInt(trimmed, 10);
1339
- if (!Number.isNaN(blockNum) && blockNum >= 1 && blockNum <= pasteBlocks.length) {
1340
- const block = pasteBlocks[blockNum - 1];
1341
- if (block) {
1342
- output.write('\n');
1343
- display.showSystemMessage(`Block ${blockNum} Content:`);
1344
- output.write('\n');
1345
- // Show full content with line numbers
1346
- const lines = block.content.split('\n');
1347
- lines.forEach((line, i) => {
1348
- const lineNum = theme.ui.muted(`${String(i + 1).padStart(4)} │ `);
1349
- output.write(lineNum + line + '\n');
1350
- });
1351
- output.write('\n');
1352
- }
1353
- this.rl.prompt();
1354
- return;
1355
- }
1356
- display.showWarning('Enter a block number, "edit N", "remove N", "undo", "redo", "clear", or press Enter to return.');
1357
- this.rl.prompt();
1358
- }
1359
- /**
1360
- * Handle paste block inline editing mode
1361
- */
1362
- editBlockBuffer = [];
1363
- async handlePasteBlockEditInput(input, blockId, blockNum) {
1364
- // Check for cancel
1365
- if (input.toLowerCase() === 'cancel') {
1366
- this.editBlockBuffer = [];
1367
- this.pendingInteraction = { type: 'paste-edit' };
1368
- display.showInfo('Edit cancelled.');
1369
- this.showPasteBlockEditor();
1370
- return;
1371
- }
1372
- // Empty line - check if this is end of edit (double Enter)
1373
- if (!input.trim()) {
1374
- if (this.editBlockBuffer.length > 0) {
1375
- // Complete the edit
1376
- const newContent = this.editBlockBuffer.join('\n');
1377
- this.composableMessage.editPaste(blockId, newContent);
1378
- this.editBlockBuffer = [];
1379
- this.updateComposeStatusSummary();
1380
- this.refreshPasteChipsDisplay();
1381
- display.showInfo(`Block ${blockNum} updated (${newContent.split('\n').length} lines).`);
1382
- this.pendingInteraction = { type: 'paste-edit' };
1383
- this.showPasteBlockEditor();
1384
- return;
1385
- }
1386
- // First empty line with no buffer - just prompt again
1387
- this.rl.prompt();
1388
- return;
1389
- }
1390
- // Add line to edit buffer
1391
- this.editBlockBuffer.push(input);
1392
- this.rl.prompt();
1393
- }
1394
- /**
1395
- * Refresh paste chips display in prompt
1396
- */
1397
- refreshPasteChipsDisplay() {
1398
- const chips = this.composableMessage.formatPasteChips();
1399
- this.persistentPrompt.updateInput(chips ? chips + ' ' : '', chips ? chips.length + 1 : 0);
1400
- this.rl.line = chips ? chips + ' ' : '';
1401
- this.rl.cursor = chips ? chips.length + 1 : 0;
1402
- }
1403
- /**
1404
- * Cycle paste preview mode (Shift+Tab)
1405
- * Cycles through: collapsed → summary → expanded
1406
- */
1407
- pastePreviewMode = 'collapsed';
1408
- cyclePastePreview() {
1409
- const state = this.composableMessage.getState();
1410
- const pasteBlocks = state.parts.filter((p) => p.type === 'paste');
1411
- if (pasteBlocks.length === 0) {
1412
- return;
1413
- }
1414
- // Cycle through modes
1415
- const modes = ['collapsed', 'summary', 'expanded'];
1416
- const currentIndex = modes.indexOf(this.pastePreviewMode);
1417
- this.pastePreviewMode = modes[(currentIndex + 1) % modes.length];
1418
- output.write('\n');
1419
- switch (this.pastePreviewMode) {
1420
- case 'collapsed':
1421
- // Show just chips
1422
- display.showSystemMessage(`📋 Preview: Collapsed (${pasteBlocks.length} block${pasteBlocks.length > 1 ? 's' : ''})`);
1423
- output.write(theme.ui.muted(this.composableMessage.formatPasteChips()) + '\n');
1424
- break;
1425
- case 'summary':
1426
- // Show blocks with first line preview
1427
- display.showSystemMessage('📋 Preview: Summary');
1428
- pasteBlocks.forEach((block, i) => {
1429
- const preview = block.summary.length > 60 ? block.summary.slice(0, 57) + '...' : block.summary;
1430
- output.write(theme.ui.muted(`[${i + 1}] `) + theme.info(`${block.lineCount}L`) + theme.ui.muted(` ${preview}`) + '\n');
1431
- });
1432
- break;
1433
- case 'expanded':
1434
- // Show full content (up to 10 lines each)
1435
- display.showSystemMessage('📋 Preview: Expanded');
1436
- pasteBlocks.forEach((block, i) => {
1437
- output.write(theme.ui.muted(`[${i + 1}] `) + theme.info(`${block.lineCount} lines, ${block.content.length} chars`) + '\n');
1438
- const lines = block.content.split('\n').slice(0, 10);
1439
- lines.forEach((line, j) => {
1440
- const lineNum = theme.ui.muted(`${String(j + 1).padStart(3)} │ `);
1441
- output.write(lineNum + line.slice(0, 80) + (line.length > 80 ? '...' : '') + '\n');
1442
- });
1443
- if (block.lineCount > 10) {
1444
- output.write(theme.ui.muted(` ... +${block.lineCount - 10} more lines\n`));
1445
- }
1446
- output.write('\n');
1447
- });
1448
- break;
1449
- }
1450
- output.write(theme.ui.muted('(Shift+Tab to cycle preview modes)') + '\n\n');
1451
- this.rl.prompt(true);
1452
- }
1453
- async flushBufferedInput() {
1454
- if (!this.bufferedInputLines.length) {
1455
- this.bufferedInputTimer = null;
1456
- return;
1457
- }
1458
- const lineCount = this.bufferedInputLines.length;
1459
- const combined = this.bufferedInputLines.join('\n');
1460
- this.bufferedInputLines = [];
1461
- this.bufferedInputTimer = null;
1462
- try {
1463
- await this.processInputBlock(combined, lineCount > 1);
1464
- }
1465
- catch (error) {
1466
- // Pass full error object for enhanced formatting
1467
- display.showError(error instanceof Error ? error.message : String(error), error);
1468
- this.rl.prompt();
1469
- }
531
+ this.terminalInput.render();
1470
532
  }
1471
533
  refreshQueueIndicators() {
1472
- const queued = this.followUpQueue.length;
1473
- // Build status message based on processing state and queue
1474
- let message;
1475
- if (this.isProcessing) {
1476
- const queuePart = queued > 0 ? `${queued} queued · ` : '';
1477
- // Show helpful hints: Esc to cancel, type to queue
1478
- message = `⏳ Processing... ${queuePart}[Esc: cancel · Enter: queue follow-up]`;
1479
- }
1480
- else if (queued > 0) {
1481
- message = `${queued} follow-up${queued === 1 ? '' : 's'} queued · [Esc: clear queue]`;
1482
- }
1483
- this.persistentPrompt.updateStatusBar({ message });
1484
534
  if (this.isProcessing) {
1485
535
  this.setProcessingStatus();
1486
536
  }
1487
537
  else {
1488
538
  this.setIdleStatus();
1489
539
  }
540
+ this.terminalInput.render();
1490
541
  }
1491
542
  enqueueFollowUpAction(action) {
1492
543
  this.followUpQueue.push(action);
@@ -1495,23 +546,17 @@ export class InteractiveShell {
1495
546
  const preview = normalized.length > previewLimit ? `${normalized.slice(0, previewLimit - 3)}...` : normalized;
1496
547
  const position = this.followUpQueue.length === 1 ? 'to run next' : `#${this.followUpQueue.length} in queue`;
1497
548
  const label = action.type === 'continuous' ? 'continuous command' : 'follow-up';
1498
- const queueCount = this.followUpQueue.length;
1499
549
  // Show immediate acknowledgment with checkmark
1500
- const queueLabel = queueCount === 1 ? '1 queued' : `${queueCount} queued`;
1501
550
  if (preview) {
1502
551
  display.showInfo(`✓ ${theme.info(label)} ${position}: ${theme.ui.muted(preview)}`);
1503
552
  }
1504
553
  else {
1505
554
  display.showInfo(`✓ ${theme.info(label)} queued ${position}.`);
1506
555
  }
1507
- // Update status bar to show queue count
1508
- this.persistentPrompt.updateStatusBar({
1509
- message: `⏳ Processing... (${queueLabel})`
1510
- });
1511
556
  this.refreshQueueIndicators();
1512
557
  this.scheduleQueueProcessing();
1513
558
  // Re-show the prompt so user can continue typing more follow-ups
1514
- this.rl.prompt();
559
+ this.terminalInput.render();
1515
560
  }
1516
561
  scheduleQueueProcessing() {
1517
562
  if (!this.followUpQueue.length) {
@@ -1523,71 +568,8 @@ export class InteractiveShell {
1523
568
  });
1524
569
  }
1525
570
  /**
1526
- * Process any inputs that were queued during streaming.
1527
- * Claude Code style: User can type while streaming, input is stored in queue,
1528
- * processed after streaming ends.
1529
- *
1530
- * @deprecated Use processUnifiedChatBoxQueue() instead
1531
- */
1532
- // @ts-expect-error - Legacy method kept for backwards compatibility
1533
- processQueuedInputs() {
1534
- if (!this.streamHandler.hasQueuedInput()) {
1535
- return;
1536
- }
1537
- // Transfer queued inputs from stream handler to follow-up queue
1538
- while (this.streamHandler.hasQueuedInput()) {
1539
- const input = this.streamHandler.getNextQueuedInput();
1540
- if (!input) {
1541
- break;
1542
- }
1543
- // Handle interrupts specially
1544
- if (input.type === 'interrupt') {
1545
- // Interrupt was already processed during capture
1546
- continue;
1547
- }
1548
- // Add to follow-up queue for processing
1549
- const actionType = input.type === 'command' ? 'request' : 'request';
1550
- this.followUpQueue.push({
1551
- type: actionType,
1552
- text: input.text,
1553
- });
1554
- }
1555
- // Show queue summary if there are items
1556
- const summary = this.streamHandler.getQueueSummary();
1557
- if (summary) {
1558
- display.showInfo(`📝 ${summary}`);
1559
- }
1560
- }
1561
- /**
1562
- * Process any inputs that were queued in UnifiedChatBox during streaming.
1563
- * Claude Code style: User can type while streaming, input is stored invisibly,
1564
- * processed after streaming ends.
571
+ * Process queued follow-up actions.
1565
572
  */
1566
- processUnifiedChatBoxQueue() {
1567
- if (!this.unifiedChatBox.hasQueuedInput()) {
1568
- return;
1569
- }
1570
- // Transfer queued inputs from unified chat box to follow-up queue
1571
- while (this.unifiedChatBox.hasQueuedInput()) {
1572
- const input = this.unifiedChatBox.dequeue();
1573
- if (!input) {
1574
- break;
1575
- }
1576
- // Handle interrupts specially - they cancel current operation
1577
- if (input.type === 'interrupt') {
1578
- // Request cancellation if agent is running
1579
- if (this.agent) {
1580
- this.agent.requestCancellation();
1581
- }
1582
- continue;
1583
- }
1584
- // Add to follow-up queue for processing
1585
- this.followUpQueue.push({
1586
- type: 'request',
1587
- text: input.text,
1588
- });
1589
- }
1590
- }
1591
573
  async processQueuedActions() {
1592
574
  if (this.isDrainingQueue || this.isProcessing || !this.followUpQueue.length) {
1593
575
  return;
@@ -1621,85 +603,22 @@ export class InteractiveShell {
1621
603
  if (await this.handlePendingInteraction(trimmed)) {
1622
604
  return;
1623
605
  }
1624
- // If we have captured multi-line paste blocks, respect control commands before assembling
1625
- if (this.composableMessage.hasContent()) {
1626
- // Strip paste chip prefixes from input since actual content is in composableMessage
1627
- // Chips look like: [Pasted text #1 +X lines] [Pasted text #2 +Y lines]
1628
- const chipsPrefix = this.composableMessage.formatPasteChips();
1629
- let userText = trimmed;
1630
- if (chipsPrefix && trimmed.startsWith(chipsPrefix)) {
1631
- userText = trimmed.slice(chipsPrefix.length).trim();
1632
- }
1633
- const lower = userText.toLowerCase();
1634
- // Control commands that should NOT consume the captured paste
1635
- if (lower === '/cancel' || lower === 'cancel') {
1636
- this.composableMessage.clear();
1637
- this.updateComposeStatusSummary();
1638
- this.persistentPrompt.updateInput('', 0);
1639
- display.showInfo('Discarded captured paste.');
1640
- this.rl.prompt();
1641
- return;
1642
- }
1643
- if (lower === 'exit' || lower === 'quit') {
1644
- this.rl.close();
1645
- return;
1646
- }
1647
- if (lower === 'clear') {
1648
- display.clear();
1649
- this.rl.prompt();
1650
- return;
1651
- }
1652
- if (lower === 'help') {
1653
- this.showHelp();
1654
- this.rl.prompt();
1655
- return;
1656
- }
1657
- // Slash commands operate independently of captured paste
1658
- if (userText.startsWith('/')) {
1659
- await this.processSlashCommand(userText);
1660
- this.updateComposeStatusSummary();
1661
- return;
1662
- }
1663
- // If userText is empty OR it's additional content, assemble and send
1664
- // Empty enter sends the captured paste; non-empty content is appended first
1665
- if (userText) {
1666
- this.composableMessage.setDraft(userText);
1667
- this.composableMessage.commitDraft();
1668
- }
1669
- const assembled = this.composableMessage.assemble();
1670
- this.composableMessage.clear();
1671
- this.updateComposeStatusSummary();
1672
- this.persistentPrompt.updateInput('', 0);
1673
- if (!assembled) {
1674
- this.rl.prompt();
1675
- return;
1676
- }
1677
- // Check if assembled content is a continuous command
1678
- if (this.isContinuousCommand(assembled)) {
1679
- await this.processContinuousRequest(assembled);
1680
- this.rl.prompt();
1681
- return;
1682
- }
1683
- await this.processRequest(assembled);
1684
- this.rl.prompt();
1685
- return;
1686
- }
1687
606
  if (!trimmed) {
1688
- this.rl.prompt();
1689
607
  return;
1690
608
  }
1691
- if (trimmed.toLowerCase() === 'exit' || trimmed.toLowerCase() === 'quit') {
1692
- this.rl.close();
609
+ const lower = trimmed.toLowerCase();
610
+ if (lower === 'exit' || lower === 'quit') {
611
+ this.shutdown();
1693
612
  return;
1694
613
  }
1695
- if (trimmed.toLowerCase() === 'clear') {
614
+ if (lower === 'clear') {
1696
615
  display.clear();
1697
- this.rl.prompt();
616
+ this.terminalInput.render();
1698
617
  return;
1699
618
  }
1700
- if (trimmed.toLowerCase() === 'help') {
619
+ if (lower === 'help') {
1701
620
  this.showHelp();
1702
- this.rl.prompt();
621
+ this.terminalInput.render();
1703
622
  return;
1704
623
  }
1705
624
  if (trimmed.startsWith('/')) {
@@ -1709,12 +628,12 @@ export class InteractiveShell {
1709
628
  // Check for continuous/infinite loop commands
1710
629
  if (this.isContinuousCommand(trimmed)) {
1711
630
  await this.processContinuousRequest(trimmed);
1712
- this.rl.prompt();
631
+ this.terminalInput.render();
1713
632
  return;
1714
633
  }
1715
634
  // Direct execution for all inputs, including multi-line pastes
1716
635
  await this.processRequest(trimmed);
1717
- this.rl.prompt();
636
+ this.terminalInput.render();
1718
637
  }
1719
638
  /**
1720
639
  * Check if the command is a continuous/infinite loop command
@@ -1766,12 +685,6 @@ export class InteractiveShell {
1766
685
  case 'agent-selection':
1767
686
  await this.handleAgentSelectionInput(input);
1768
687
  return true;
1769
- case 'paste-edit':
1770
- await this.handlePasteEditInput(input);
1771
- return true;
1772
- case 'paste-edit-block':
1773
- await this.handlePasteBlockEditInput(input, this.pendingInteraction.blockId, this.pendingInteraction.blockNum);
1774
- return true;
1775
688
  default:
1776
689
  return false;
1777
690
  }
@@ -1780,7 +693,7 @@ export class InteractiveShell {
1780
693
  const [command] = input.split(/\s+/);
1781
694
  if (!command) {
1782
695
  display.showWarning('Enter a slash command.');
1783
- this.rl.prompt();
696
+ this.terminalInput.render();
1784
697
  return;
1785
698
  }
1786
699
  switch (command) {
@@ -1855,7 +768,7 @@ export class InteractiveShell {
1855
768
  }
1856
769
  break;
1857
770
  }
1858
- this.rl.prompt();
771
+ this.terminalInput.render();
1859
772
  }
1860
773
  async tryCustomSlashCommand(command, fullInput) {
1861
774
  const custom = this.customCommandMap.get(command);
@@ -1889,100 +802,6 @@ export class InteractiveShell {
1889
802
  ];
1890
803
  display.showSystemMessage(info.join('\n'));
1891
804
  }
1892
- /**
1893
- * Handle Tab key for slash command autocompletion
1894
- * Completes partial slash commands like /mo -> /model
1895
- */
1896
- handleTabCompletion() {
1897
- const currentLine = this.rl.line || '';
1898
- const cursorPos = this.rl.cursor || 0;
1899
- // Only complete if line starts with /
1900
- if (!currentLine.startsWith('/')) {
1901
- return;
1902
- }
1903
- // Get the partial command (from / to cursor)
1904
- const partial = currentLine.slice(0, cursorPos).toLowerCase();
1905
- // Available slash commands
1906
- const commands = [
1907
- '/help',
1908
- '/model',
1909
- '/secrets',
1910
- '/tools',
1911
- '/doctor',
1912
- '/clear',
1913
- '/cancel',
1914
- '/compact',
1915
- '/cost',
1916
- '/status',
1917
- '/undo',
1918
- '/redo',
1919
- ];
1920
- // Find matching commands
1921
- const matches = commands.filter(cmd => cmd.startsWith(partial));
1922
- if (matches.length === 0) {
1923
- // No matches - beep or do nothing
1924
- return;
1925
- }
1926
- if (matches.length === 1) {
1927
- // Single match - complete it
1928
- const completion = matches[0];
1929
- const suffix = currentLine.slice(cursorPos);
1930
- const newLine = completion + suffix;
1931
- const newCursor = completion.length;
1932
- // Update readline
1933
- this.rl.line = newLine;
1934
- this.rl.cursor = newCursor;
1935
- // Redraw
1936
- output.write('\r\x1b[K'); // Clear line
1937
- output.write(formatUserPrompt(this.profileLabel || this.profile));
1938
- output.write(newLine);
1939
- // Position cursor
1940
- if (suffix.length > 0) {
1941
- output.write(`\x1b[${suffix.length}D`);
1942
- }
1943
- // Sync to display components
1944
- this.persistentPrompt.updateInput(newLine, newCursor);
1945
- this.pinnedChatBox.setInput(newLine, newCursor);
1946
- }
1947
- else {
1948
- // Multiple matches - show them
1949
- output.write('\n');
1950
- output.write(theme.ui.muted('Completions: ') + matches.join(' ') + '\n');
1951
- this.rl.prompt();
1952
- // Find common prefix for partial completion
1953
- const commonPrefix = this.findCommonPrefix(matches);
1954
- if (commonPrefix.length > partial.length) {
1955
- const suffix = currentLine.slice(cursorPos);
1956
- const newLine = commonPrefix + suffix;
1957
- const newCursor = commonPrefix.length;
1958
- this.rl.line = newLine;
1959
- this.rl.cursor = newCursor;
1960
- this.persistentPrompt.updateInput(newLine, newCursor);
1961
- this.pinnedChatBox.setInput(newLine, newCursor);
1962
- }
1963
- }
1964
- }
1965
- /**
1966
- * Find the longest common prefix among strings
1967
- */
1968
- findCommonPrefix(strings) {
1969
- if (strings.length === 0)
1970
- return '';
1971
- if (strings.length === 1)
1972
- return strings[0];
1973
- let prefix = strings[0];
1974
- for (let i = 1; i < strings.length; i++) {
1975
- const str = strings[i];
1976
- let j = 0;
1977
- while (j < prefix.length && j < str.length && prefix[j] === str[j]) {
1978
- j++;
1979
- }
1980
- prefix = prefix.slice(0, j);
1981
- if (prefix.length === 0)
1982
- break;
1983
- }
1984
- return prefix;
1985
- }
1986
805
  runDoctor() {
1987
806
  const lines = [];
1988
807
  lines.push(theme.bold('Environment diagnostics'));
@@ -2876,29 +1695,29 @@ export class InteractiveShell {
2876
1695
  const trimmed = input.trim();
2877
1696
  if (!trimmed) {
2878
1697
  display.showWarning('Enter a number or type cancel.');
2879
- this.rl.prompt();
1698
+ this.terminalInput.render();
2880
1699
  return;
2881
1700
  }
2882
1701
  if (trimmed.toLowerCase() === 'cancel') {
2883
1702
  this.pendingInteraction = null;
2884
1703
  display.showInfo('Model selection cancelled.');
2885
- this.rl.prompt();
1704
+ this.terminalInput.render();
2886
1705
  return;
2887
1706
  }
2888
1707
  const choice = Number.parseInt(trimmed, 10);
2889
1708
  if (!Number.isFinite(choice)) {
2890
1709
  display.showWarning('Please enter a valid number.');
2891
- this.rl.prompt();
1710
+ this.terminalInput.render();
2892
1711
  return;
2893
1712
  }
2894
1713
  const option = pending.options[choice - 1];
2895
1714
  if (!option) {
2896
1715
  display.showWarning('That option is not available.');
2897
- this.rl.prompt();
1716
+ this.terminalInput.render();
2898
1717
  return;
2899
1718
  }
2900
1719
  this.showProviderModels(option);
2901
- this.rl.prompt();
1720
+ this.terminalInput.render();
2902
1721
  }
2903
1722
  async handleModelSelection(input) {
2904
1723
  const pending = this.pendingInteraction;
@@ -2908,35 +1727,35 @@ export class InteractiveShell {
2908
1727
  const trimmed = input.trim();
2909
1728
  if (!trimmed) {
2910
1729
  display.showWarning('Enter a number, type "back", or type "cancel".');
2911
- this.rl.prompt();
1730
+ this.terminalInput.render();
2912
1731
  return;
2913
1732
  }
2914
1733
  if (trimmed.toLowerCase() === 'back') {
2915
1734
  this.showModelMenu();
2916
- this.rl.prompt();
1735
+ this.terminalInput.render();
2917
1736
  return;
2918
1737
  }
2919
1738
  if (trimmed.toLowerCase() === 'cancel') {
2920
1739
  this.pendingInteraction = null;
2921
1740
  display.showInfo('Model selection cancelled.');
2922
- this.rl.prompt();
1741
+ this.terminalInput.render();
2923
1742
  return;
2924
1743
  }
2925
1744
  const choice = Number.parseInt(trimmed, 10);
2926
1745
  if (!Number.isFinite(choice)) {
2927
1746
  display.showWarning('Please enter a valid number.');
2928
- this.rl.prompt();
1747
+ this.terminalInput.render();
2929
1748
  return;
2930
1749
  }
2931
1750
  const preset = pending.options[choice - 1];
2932
1751
  if (!preset) {
2933
1752
  display.showWarning('That option is not available.');
2934
- this.rl.prompt();
1753
+ this.terminalInput.render();
2935
1754
  return;
2936
1755
  }
2937
1756
  this.pendingInteraction = null;
2938
1757
  await this.applyModelPreset(preset);
2939
- this.rl.prompt();
1758
+ this.terminalInput.render();
2940
1759
  }
2941
1760
  async applyModelPreset(preset) {
2942
1761
  try {
@@ -2969,30 +1788,30 @@ export class InteractiveShell {
2969
1788
  const trimmed = input.trim();
2970
1789
  if (!trimmed) {
2971
1790
  display.showWarning('Enter a number or type cancel.');
2972
- this.rl.prompt();
1791
+ this.terminalInput.render();
2973
1792
  return;
2974
1793
  }
2975
1794
  if (trimmed.toLowerCase() === 'cancel') {
2976
1795
  this.pendingInteraction = null;
2977
1796
  display.showInfo('Secret management cancelled.');
2978
- this.rl.prompt();
1797
+ this.terminalInput.render();
2979
1798
  return;
2980
1799
  }
2981
1800
  const choice = Number.parseInt(trimmed, 10);
2982
1801
  if (!Number.isFinite(choice)) {
2983
1802
  display.showWarning('Please enter a valid number.');
2984
- this.rl.prompt();
1803
+ this.terminalInput.render();
2985
1804
  return;
2986
1805
  }
2987
1806
  const secret = pending.options[choice - 1];
2988
1807
  if (!secret) {
2989
1808
  display.showWarning('That option is not available.');
2990
- this.rl.prompt();
1809
+ this.terminalInput.render();
2991
1810
  return;
2992
1811
  }
2993
1812
  display.showSystemMessage(`Enter a new value for ${secret.label} or type "cancel".`);
2994
1813
  this.pendingInteraction = { type: 'secret-input', secret };
2995
- this.rl.prompt();
1814
+ this.terminalInput.render();
2996
1815
  }
2997
1816
  async handleSecretInput(input) {
2998
1817
  const pending = this.pendingInteraction;
@@ -3002,14 +1821,14 @@ export class InteractiveShell {
3002
1821
  const trimmed = input.trim();
3003
1822
  if (!trimmed) {
3004
1823
  display.showWarning('Enter a value or type cancel.');
3005
- this.rl.prompt();
1824
+ this.terminalInput.render();
3006
1825
  return;
3007
1826
  }
3008
1827
  if (trimmed.toLowerCase() === 'cancel') {
3009
1828
  this.pendingInteraction = null;
3010
1829
  this.pendingSecretRetry = null;
3011
1830
  display.showInfo('Secret unchanged.');
3012
- this.rl.prompt();
1831
+ this.terminalInput.render();
3013
1832
  return;
3014
1833
  }
3015
1834
  try {
@@ -3033,7 +1852,7 @@ export class InteractiveShell {
3033
1852
  this.pendingInteraction = null;
3034
1853
  this.pendingSecretRetry = null;
3035
1854
  }
3036
- this.rl.prompt();
1855
+ this.terminalInput.render();
3037
1856
  }
3038
1857
  async processRequest(request) {
3039
1858
  if (this.isProcessing) {
@@ -3049,11 +1868,8 @@ export class InteractiveShell {
3049
1868
  return;
3050
1869
  }
3051
1870
  this.isProcessing = true;
1871
+ this.terminalInput.setStreaming(true);
3052
1872
  const requestStartTime = Date.now(); // Alpha Zero 2 timing
3053
- // Claude Code style: Enable scroll region for fixed chat box at bottom
3054
- this.pinnedChatBox.setProcessing(true);
3055
- // Claude Code style: Update status (optional, won't interfere with streaming)
3056
- this.persistentPrompt.updateStatusBar({ message: '⏳ Processing...' });
3057
1873
  this.uiAdapter.startProcessing('Working on your request');
3058
1874
  this.setProcessingStatus();
3059
1875
  try {
@@ -3062,13 +1878,6 @@ export class InteractiveShell {
3062
1878
  // Claude Code style: Show unified streaming header before response
3063
1879
  // This provides visual consistency with the startup Ready bar
3064
1880
  display.showStreamingHeader();
3065
- // NOTE: Do NOT call showWaitingPrompt() here - the PinnedChatBox scroll region
3066
- // handles rendering the prompt at the bottom via renderPersistentInput().
3067
- // Calling showWaitingPrompt() writes to the scroll area instead of the reserved bottom.
3068
- // Claude Code style: Start streaming mode using UnifiedChatBox
3069
- // - User input is captured and queued
3070
- // - After response ends, new prompt appears at bottom
3071
- this.unifiedChatBox.startStreaming();
3072
1881
  // DISABLED streaming - wait for full response instead
3073
1882
  // This keeps cursor at the > prompt during processing
3074
1883
  await agent.send(request, false);
@@ -3087,30 +1896,20 @@ export class InteractiveShell {
3087
1896
  }
3088
1897
  }
3089
1898
  finally {
3090
- // Claude Code style: End streaming mode using UnifiedChatBox
3091
- // This restores readline echo and returns queue info
3092
- const streamResult = this.unifiedChatBox.endStreaming();
3093
- // Process any queued inputs that came in during streaming
3094
- this.processUnifiedChatBoxQueue();
3095
- // Show queue indicator if there were queued inputs
3096
- if (streamResult.queuedCount > 0) {
3097
- this.unifiedChatBox.showQueueIndicator();
3098
- }
3099
1899
  display.stopThinking(false);
3100
1900
  this.isProcessing = false;
3101
- this.pinnedChatBox.setProcessing(false); // Disable scroll region
1901
+ this.terminalInput.setStreaming(false);
3102
1902
  this.uiAdapter.endProcessing('Ready for prompts');
3103
1903
  this.setIdleStatus();
3104
1904
  display.newLine();
3105
- // Clear the processing status
3106
- this.persistentPrompt.updateStatusBar({ message: undefined });
1905
+ this.updateStatusMessage(null);
3107
1906
  // Claude Code style: Show unified status bar before prompt
3108
1907
  // This creates consistent UI between startup and post-streaming
3109
1908
  this.showUnifiedStatusBar();
3110
1909
  // CRITICAL: Ensure readline prompt is active for user input
3111
1910
  // Claude Code style: New prompt naturally appears at bottom
3112
1911
  this.ensureReadlineReady();
3113
- this.rl.prompt();
1912
+ this.terminalInput.render();
3114
1913
  this.scheduleQueueProcessing();
3115
1914
  this.refreshQueueIndicators();
3116
1915
  }
@@ -3142,26 +1941,17 @@ export class InteractiveShell {
3142
1941
  return;
3143
1942
  }
3144
1943
  this.isProcessing = true;
1944
+ this.terminalInput.setStreaming(true);
3145
1945
  const overallStartTime = Date.now();
3146
- // Claude Code style: Enable scroll region for fixed chat box at bottom
3147
- this.pinnedChatBox.setProcessing(true);
3148
1946
  // Initialize the task completion detector
3149
1947
  const completionDetector = getTaskCompletionDetector();
3150
1948
  completionDetector.reset();
3151
- // Claude Code style: Update status
3152
- this.persistentPrompt.updateStatusBar({ message: '🔄 Continuous mode...' });
3153
1949
  display.showSystemMessage(`🔄 Starting continuous execution mode. Press Ctrl+C to stop.`);
3154
1950
  display.showSystemMessage(`📊 Using intelligent task completion detection with AI verification.`);
3155
1951
  this.uiAdapter.startProcessing('Continuous execution mode');
3156
1952
  this.setProcessingStatus();
3157
1953
  // Claude Code style: Show unified streaming header before response
3158
1954
  display.showStreamingHeader();
3159
- // NOTE: Do NOT call showWaitingPrompt() here - the PinnedChatBox scroll region
3160
- // handles rendering the prompt at the bottom via renderPersistentInput().
3161
- // Calling showWaitingPrompt() writes to the scroll area instead of the reserved bottom.
3162
- // Claude Code style: Start streaming mode using UnifiedChatBox
3163
- // - User input is captured and queued
3164
- this.unifiedChatBox.startStreaming();
3165
1955
  let iteration = 0;
3166
1956
  let lastResponse = '';
3167
1957
  let consecutiveNoProgress = 0;
@@ -3187,9 +1977,7 @@ When truly finished with ALL tasks, explicitly state "TASK_FULLY_COMPLETE".`;
3187
1977
  while (iteration < MAX_ITERATIONS) {
3188
1978
  iteration++;
3189
1979
  display.showSystemMessage(`\n📍 Iteration ${iteration}/${MAX_ITERATIONS}`);
3190
- // Update pinned chat box with iteration status instead of spinner
3191
- this.pinnedChatBox.setStatusMessage(`Working on iteration ${iteration}...`);
3192
- this.pinnedChatBox.forceRender();
1980
+ this.updateStatusMessage(`Working on iteration ${iteration}...`);
3193
1981
  try {
3194
1982
  // Send the request and capture the response (streaming disabled)
3195
1983
  const response = await agent.send(currentPrompt, false);
@@ -3323,28 +2111,18 @@ What's the next action?`;
3323
2111
  display.showSystemMessage(`\n🏁 Continuous execution completed: ${iteration} iterations, ${minutes}m ${seconds}s total`);
3324
2112
  // Reset completion detector for next task
3325
2113
  resetTaskCompletionDetector();
3326
- // Claude Code style: End streaming mode using UnifiedChatBox
3327
- const streamResult = this.unifiedChatBox.endStreaming();
3328
- // Process any queued inputs that came in during streaming
3329
- this.processUnifiedChatBoxQueue();
3330
- // Show queue indicator if there were queued inputs
3331
- if (streamResult.queuedCount > 0) {
3332
- this.unifiedChatBox.showQueueIndicator();
3333
- }
3334
2114
  this.isProcessing = false;
3335
- this.pinnedChatBox.setProcessing(false); // Disable scroll region
2115
+ this.terminalInput.setStreaming(false);
2116
+ this.updateStatusMessage(null);
3336
2117
  this.uiAdapter.endProcessing('Ready for prompts');
3337
2118
  this.setIdleStatus();
3338
2119
  display.newLine();
3339
- // Clear the processing status
3340
- this.persistentPrompt.updateStatusBar({ message: undefined });
3341
2120
  // Claude Code style: Show unified status bar before prompt
3342
2121
  // This creates consistent UI between startup and post-streaming
3343
2122
  this.showUnifiedStatusBar();
3344
2123
  // CRITICAL: Ensure readline prompt is active for user input
3345
2124
  // Claude Code style: New prompt naturally appears at bottom
3346
2125
  this.ensureReadlineReady();
3347
- this.rl.prompt();
3348
2126
  this.scheduleQueueProcessing();
3349
2127
  this.refreshQueueIndicators();
3350
2128
  }
@@ -3552,8 +2330,7 @@ What's the next action?`;
3552
2330
  this.autoTestInFlight = true;
3553
2331
  const command = 'npm test -- --runInBand';
3554
2332
  display.showSystemMessage(`🧪 Auto-testing recent changes (${trigger}) with "${command}"...`);
3555
- this.pinnedChatBox.setStatusMessage('Running tests automatically...');
3556
- this.pinnedChatBox.forceRender();
2333
+ this.updateStatusMessage('Running tests automatically...');
3557
2334
  try {
3558
2335
  const { stdout, stderr } = await execAsync(command, {
3559
2336
  cwd: this.workingDir,
@@ -3581,8 +2358,7 @@ What's the next action?`;
3581
2358
  });
3582
2359
  }
3583
2360
  finally {
3584
- this.pinnedChatBox.setStatusMessage(null);
3585
- this.pinnedChatBox.forceRender();
2361
+ this.updateStatusMessage(null);
3586
2362
  this.autoTestInFlight = false;
3587
2363
  }
3588
2364
  }
@@ -3653,7 +2429,7 @@ What's the next action?`;
3653
2429
  display.showNarrative(content.trim());
3654
2430
  }
3655
2431
  // The isProcessing flag already shows "⏳ Processing..." - no need for duplicate status
3656
- this.pinnedChatBox.forceRender();
2432
+ this.terminalInput.render();
3657
2433
  return;
3658
2434
  }
3659
2435
  const cleanup = this.handleContextTelemetry(metadata, enriched);
@@ -3672,10 +2448,6 @@ What's the next action?`;
3672
2448
  onContextRecovery: (attempt, maxAttempts, message) => {
3673
2449
  // Show recovery progress in UI
3674
2450
  display.showSystemMessage(`⚡ Context Recovery (${attempt}/${maxAttempts}): ${message}`);
3675
- // Update persistent prompt to show recovery status
3676
- this.persistentPrompt.updateStatusBar({
3677
- contextUsage: 100, // Show as full during recovery
3678
- });
3679
2451
  },
3680
2452
  onContextPruned: (removedCount, stats) => {
3681
2453
  // Clear squish overlay if active
@@ -3689,30 +2461,28 @@ What's the next action?`;
3689
2461
  // Update context usage in UI
3690
2462
  if (typeof percentage === 'number') {
3691
2463
  this.uiAdapter.updateContextUsage(percentage);
3692
- this.persistentPrompt.updateStatusBar({ contextUsage: percentage });
3693
2464
  }
3694
2465
  // Ensure prompt remains visible at bottom after context messages
3695
- this.pinnedChatBox.forceRender();
2466
+ this.terminalInput.render();
3696
2467
  },
3697
2468
  onContinueAfterRecovery: () => {
3698
2469
  // Update UI to show we're continuing after context recovery
3699
2470
  display.showSystemMessage(`🔄 Continuing after context recovery...`);
3700
- // Update pinned chat box status instead of spinner
3701
- this.pinnedChatBox.setStatusMessage('Retrying with reduced context...');
3702
- this.pinnedChatBox.forceRender();
2471
+ this.updateStatusMessage('Retrying with reduced context...');
2472
+ this.terminalInput.render();
3703
2473
  },
3704
2474
  onAutoContinue: (attempt, maxAttempts, _message) => {
3705
2475
  // Show auto-continue progress in UI
3706
2476
  display.showSystemMessage(`🔄 Auto-continue (${attempt}/${maxAttempts}): Model expressed intent, prompting to act...`);
3707
- this.pinnedChatBox.setStatusMessage('Auto-continuing...');
3708
- this.pinnedChatBox.forceRender();
2477
+ this.updateStatusMessage('Auto-continuing...');
2478
+ this.terminalInput.render();
3709
2479
  },
3710
2480
  onCancelled: () => {
3711
2481
  // Update UI to show operation was cancelled
3712
2482
  display.showWarning('Operation cancelled.');
3713
- this.pinnedChatBox.setStatusMessage(null);
3714
- this.pinnedChatBox.setProcessing(false);
3715
- this.pinnedChatBox.forceRender();
2483
+ this.updateStatusMessage(null);
2484
+ this.terminalInput.setStreaming(false);
2485
+ this.terminalInput.render();
3716
2486
  },
3717
2487
  onVerificationNeeded: () => {
3718
2488
  void this.enforceAutoTests('verification');
@@ -3746,10 +2516,9 @@ What's the next action?`;
3746
2516
  * just like on fresh startup.
3747
2517
  */
3748
2518
  resetChatBoxAfterModelSwap() {
3749
- this.pinnedChatBox.setStatusMessage(null);
3750
- this.pinnedChatBox.setProcessing(false);
3751
- this.pinnedChatBox.show();
3752
- this.pinnedChatBox.forceRender();
2519
+ this.updateStatusMessage(null);
2520
+ this.terminalInput.setStreaming(false);
2521
+ this.terminalInput.render();
3753
2522
  this.ensureReadlineReady();
3754
2523
  }
3755
2524
  buildSystemPrompt() {
@@ -3816,10 +2585,6 @@ What's the next action?`;
3816
2585
  // Always update context usage in the UI
3817
2586
  const percentUsed = Math.round(usageRatio * 100);
3818
2587
  this.uiAdapter.updateContextUsage(percentUsed);
3819
- // Update persistent prompt status bar with context usage
3820
- this.persistentPrompt.updateStatusBar({ contextUsage: percentUsed });
3821
- // Update pinned chat box with context usage
3822
- this.pinnedChatBox.setContextUsage(percentUsed);
3823
2588
  if (usageRatio < CONTEXT_USAGE_THRESHOLD) {
3824
2589
  return null;
3825
2590
  }
@@ -3867,8 +2632,6 @@ What's the next action?`;
3867
2632
  const percentUsed = Math.round((totalTokens / windowTokens) * 100);
3868
2633
  // Update context usage in unified UI
3869
2634
  this.uiAdapter.updateContextUsage(percentUsed);
3870
- // Update persistent prompt status bar with context usage
3871
- this.persistentPrompt.updateStatusBar({ contextUsage: percentUsed });
3872
2635
  display.showSystemMessage([
3873
2636
  `Context usage: ${totalTokens.toLocaleString('en-US')} of ${windowTokens.toLocaleString('en-US')} tokens`,
3874
2637
  `(${percentUsed}% full). Running automatic cleanup...`,
@@ -4105,14 +2868,6 @@ What's the next action?`;
4105
2868
  const entry = this.agentMenu.options.find((option) => option.name === name);
4106
2869
  return entry?.label ?? name;
4107
2870
  }
4108
- updatePersistentPromptFileChanges() {
4109
- const summary = this._fileChangeTracker.getSummary();
4110
- if (summary.files === 0) {
4111
- return;
4112
- }
4113
- const fileChangesText = `${summary.files} file${summary.files === 1 ? '' : 's'} +${summary.additions} -${summary.removals}`;
4114
- this.persistentPrompt.updateStatusBar({ fileChanges: fileChangesText });
4115
- }
4116
2871
  extractThoughtSummary(thought) {
4117
2872
  // Extract first non-empty line
4118
2873
  const lines = thought?.split('\n').filter(line => line.trim()) ?? [];
@@ -4402,32 +3157,6 @@ What's the next action?`;
4402
3157
  ];
4403
3158
  display.showSystemMessage(lines.join('\n'));
4404
3159
  }
4405
- enableBracketedPasteMode() {
4406
- if (!input.isTTY || !output.isTTY) {
4407
- return false;
4408
- }
4409
- try {
4410
- output.write(BRACKETED_PASTE_ENABLE);
4411
- return true;
4412
- }
4413
- catch (error) {
4414
- const message = error instanceof Error ? error.message : String(error);
4415
- display.showWarning(`Unable to enable bracketed paste: ${message}`);
4416
- return false;
4417
- }
4418
- }
4419
- disableBracketedPasteMode() {
4420
- if (!this.bracketedPasteEnabled || !output.isTTY) {
4421
- return;
4422
- }
4423
- try {
4424
- output.write(BRACKETED_PASTE_DISABLE);
4425
- }
4426
- finally {
4427
- this.bracketedPasteEnabled = false;
4428
- this.bracketedPaste.reset();
4429
- }
4430
- }
4431
3160
  /**
4432
3161
  * Set the cached provider status for unified status bar display.
4433
3162
  * Called once at startup after checking providers.
@@ -4435,10 +3164,6 @@ What's the next action?`;
4435
3164
  setProviderStatus(providers) {
4436
3165
  this.cachedProviderStatus = providers;
4437
3166
  }
4438
- // NOTE: showWaitingPrompt() was removed because the PinnedChatBox scroll region
4439
- // now handles rendering the prompt at the bottom via renderPersistentInput().
4440
- // The scroll region approach keeps the cursor in the correct location during
4441
- // streaming while allowing the user to type in the reserved bottom area.
4442
3167
  /**
4443
3168
  * Show the unified status bar (Claude Code style).
4444
3169
  * Displays provider indicators and ready hints before the prompt.