erosolar-cli 1.7.429 → 1.7.430

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 (83) hide show
  1. package/dist/capabilities/enhancedGitCapability.js +3 -3
  2. package/dist/capabilities/enhancedGitCapability.js.map +1 -1
  3. package/dist/capabilities/learnCapability.d.ts +1 -1
  4. package/dist/capabilities/learnCapability.d.ts.map +1 -1
  5. package/dist/capabilities/learnCapability.js +1 -1
  6. package/dist/capabilities/learnCapability.js.map +1 -1
  7. package/dist/core/checkpoint.d.ts +1 -1
  8. package/dist/core/checkpoint.js +1 -1
  9. package/dist/core/costTracker.d.ts +1 -1
  10. package/dist/core/costTracker.js +1 -1
  11. package/dist/core/hooks.d.ts +1 -1
  12. package/dist/core/hooks.js +1 -1
  13. package/dist/core/memorySystem.d.ts +2 -2
  14. package/dist/core/memorySystem.js +2 -2
  15. package/dist/core/outputStyles.d.ts +2 -2
  16. package/dist/core/outputStyles.js +2 -2
  17. package/dist/core/validationRunner.d.ts +1 -1
  18. package/dist/core/validationRunner.js +1 -1
  19. package/dist/shell/interactiveShell.d.ts +5 -1
  20. package/dist/shell/interactiveShell.d.ts.map +1 -1
  21. package/dist/shell/interactiveShell.js +115 -83
  22. package/dist/shell/interactiveShell.js.map +1 -1
  23. package/dist/shell/systemPrompt.d.ts.map +1 -1
  24. package/dist/shell/systemPrompt.js +13 -34
  25. package/dist/shell/systemPrompt.js.map +1 -1
  26. package/dist/shell/terminalInputAdapter.d.ts +75 -83
  27. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  28. package/dist/shell/terminalInputAdapter.js +159 -220
  29. package/dist/shell/terminalInputAdapter.js.map +1 -1
  30. package/dist/shell/vimMode.d.ts +1 -1
  31. package/dist/shell/vimMode.js +1 -1
  32. package/dist/tools/buildTools.d.ts +1 -1
  33. package/dist/tools/buildTools.js +1 -1
  34. package/dist/tools/diffUtils.d.ts +2 -2
  35. package/dist/tools/diffUtils.js +2 -2
  36. package/dist/tools/editTools.js +4 -4
  37. package/dist/tools/editTools.js.map +1 -1
  38. package/dist/tools/localExplore.d.ts +3 -3
  39. package/dist/tools/localExplore.js +3 -3
  40. package/dist/tools/skillTools.js +2 -2
  41. package/dist/tools/skillTools.js.map +1 -1
  42. package/dist/tools/validationTools.js +1 -1
  43. package/dist/ui/DisplayEventQueue.d.ts +99 -0
  44. package/dist/ui/DisplayEventQueue.d.ts.map +1 -0
  45. package/dist/ui/DisplayEventQueue.js +167 -0
  46. package/dist/ui/DisplayEventQueue.js.map +1 -0
  47. package/dist/ui/SequentialRenderer.d.ts +69 -0
  48. package/dist/ui/SequentialRenderer.d.ts.map +1 -0
  49. package/dist/ui/SequentialRenderer.js +137 -0
  50. package/dist/ui/SequentialRenderer.js.map +1 -0
  51. package/dist/ui/ShellUIAdapter.d.ts +18 -6
  52. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  53. package/dist/ui/ShellUIAdapter.js +65 -14
  54. package/dist/ui/ShellUIAdapter.js.map +1 -1
  55. package/dist/ui/UnifiedUIRenderer.d.ts +184 -0
  56. package/dist/ui/UnifiedUIRenderer.d.ts.map +1 -0
  57. package/dist/ui/UnifiedUIRenderer.js +567 -0
  58. package/dist/ui/UnifiedUIRenderer.js.map +1 -0
  59. package/dist/ui/display.d.ts +100 -173
  60. package/dist/ui/display.d.ts.map +1 -1
  61. package/dist/ui/display.js +359 -927
  62. package/dist/ui/display.js.map +1 -1
  63. package/dist/ui/errorFormatter.d.ts +1 -1
  64. package/dist/ui/errorFormatter.js +1 -1
  65. package/dist/ui/shortcutsHelp.d.ts +6 -6
  66. package/dist/ui/shortcutsHelp.js +6 -6
  67. package/dist/ui/streamingFormatter.d.ts +2 -5
  68. package/dist/ui/streamingFormatter.d.ts.map +1 -1
  69. package/dist/ui/streamingFormatter.js +9 -33
  70. package/dist/ui/streamingFormatter.js.map +1 -1
  71. package/dist/ui/textHighlighter.d.ts +8 -8
  72. package/dist/ui/textHighlighter.js +9 -9
  73. package/dist/ui/textHighlighter.js.map +1 -1
  74. package/dist/ui/theme.d.ts +2 -2
  75. package/dist/ui/theme.js +4 -4
  76. package/dist/ui/theme.js.map +1 -1
  77. package/dist/ui/toolDisplay.d.ts +8 -8
  78. package/dist/ui/toolDisplay.js +8 -8
  79. package/package.json +1 -1
  80. package/dist/shell/terminalInput.d.ts +0 -619
  81. package/dist/shell/terminalInput.d.ts.map +0 -1
  82. package/dist/shell/terminalInput.js +0 -2699
  83. package/dist/shell/terminalInput.js.map +0 -1
@@ -1,146 +1,15 @@
1
- import { createSpinner } from 'nanospinner';
2
- import { clearScreenDown, cursorTo } from 'node:readline';
1
+ /**
2
+ * Display - Simplified UI facade that routes all output through UnifiedUIRenderer
3
+ *
4
+ * This class now serves as a compatibility layer, providing the same API
5
+ * but delegating all actual rendering to UnifiedUIRenderer.
6
+ */
3
7
  import { theme, icons } from './theme.js';
4
- import { formatRichContent, renderMessagePanel, renderMessageBody } from './richText.js';
8
+ import { renderMessagePanel, renderMessageBody } from './richText.js';
5
9
  import { getTerminalColumns } from './layout.js';
6
10
  import { highlightError } from './textHighlighter.js';
7
11
  import { renderSectionHeading } from './designSystem.js';
8
12
  import { isPlainOutputMode } from './outputMode.js';
9
- import { writeLock } from './writeLock.js';
10
- import { renderStatusLine } from './unified/layout.js';
11
- import { isStreamingMode } from './globalWriteLock.js';
12
- /**
13
- * Output lock to prevent race conditions during spinner/stream output.
14
- * Ensures that spinner frames don't interleave with streamed content.
15
- * Uses a simple lock mechanism suitable for Node.js single-threaded event loop.
16
- */
17
- class OutputLock {
18
- static instance = null;
19
- locked = false;
20
- pendingCallbacks = [];
21
- static getInstance() {
22
- if (!OutputLock.instance) {
23
- OutputLock.instance = new OutputLock();
24
- }
25
- return OutputLock.instance;
26
- }
27
- /**
28
- * Synchronously check if output is locked (spinner is active).
29
- * Used to prevent stream writes during spinner animation.
30
- */
31
- isLocked() {
32
- return this.locked;
33
- }
34
- /**
35
- * Lock output during spinner animation.
36
- */
37
- lock() {
38
- this.locked = true;
39
- }
40
- /**
41
- * Unlock output and process any pending callbacks.
42
- */
43
- unlock() {
44
- this.locked = false;
45
- // Process any pending writes
46
- while (this.pendingCallbacks.length > 0) {
47
- const callback = this.pendingCallbacks.shift();
48
- if (callback) {
49
- callback();
50
- }
51
- }
52
- }
53
- /**
54
- * Execute a callback safely, queueing if output is locked.
55
- */
56
- safeWrite(callback) {
57
- if (this.locked) {
58
- this.pendingCallbacks.push(callback);
59
- return;
60
- }
61
- callback();
62
- }
63
- }
64
- /**
65
- * Tracks line output to stdout for banner rewriting and cursor positioning.
66
- * Instances are cached per stream to keep banner calculations consistent.
67
- */
68
- class StdoutLineTracker {
69
- static instances = new WeakMap();
70
- static getInstance(stream = process.stdout) {
71
- const existing = StdoutLineTracker.instances.get(stream);
72
- if (existing) {
73
- return existing;
74
- }
75
- const tracker = new StdoutLineTracker(stream);
76
- StdoutLineTracker.instances.set(stream, tracker);
77
- return tracker;
78
- }
79
- linesWritten = 0;
80
- suspended = false;
81
- stream;
82
- originalWrite;
83
- constructor(stream) {
84
- this.stream = stream;
85
- this.originalWrite = stream.write.bind(stream);
86
- this.patchStream();
87
- }
88
- get totalLines() {
89
- return this.linesWritten;
90
- }
91
- /**
92
- * Temporarily suspends line tracking while executing a function.
93
- * Useful for rewriting content without incrementing line count.
94
- */
95
- withSuspended(fn) {
96
- const wasSuspended = this.suspended;
97
- this.suspended = true;
98
- try {
99
- return fn();
100
- }
101
- finally {
102
- this.suspended = wasSuspended;
103
- }
104
- }
105
- reset() {
106
- this.linesWritten = 0;
107
- }
108
- patchStream() {
109
- const tracker = this;
110
- this.stream.write = function patched(chunk, encoding, callback) {
111
- const actualEncoding = typeof encoding === 'function' ? undefined : encoding;
112
- tracker.recordChunk(chunk, actualEncoding);
113
- return tracker.originalWrite.call(this, chunk, encoding, callback);
114
- };
115
- }
116
- recordChunk(chunk, encoding) {
117
- if (this.suspended) {
118
- return;
119
- }
120
- const text = this.chunkToString(chunk, encoding);
121
- if (!text) {
122
- return;
123
- }
124
- this.countNewlines(text);
125
- }
126
- countNewlines(text) {
127
- for (const char of text) {
128
- if (char === '\n') {
129
- this.linesWritten += 1;
130
- }
131
- }
132
- }
133
- chunkToString(chunk, encoding) {
134
- if (typeof chunk === 'string') {
135
- return chunk;
136
- }
137
- if (chunk instanceof Uint8Array) {
138
- const enc = encoding ?? 'utf8';
139
- return Buffer.from(chunk).toString(enc);
140
- }
141
- return null;
142
- }
143
- }
144
13
  // Display configuration constants
145
14
  const DISPLAY_CONSTANTS = {
146
15
  MIN_BANNER_WIDTH: 32,
@@ -155,60 +24,33 @@ const DISPLAY_CONSTANTS = {
155
24
  MAX_THOUGHT_WIDTH: 96,
156
25
  MIN_CONTENT_WIDTH: 10,
157
26
  MIN_WRAP_WIDTH: 12,
158
- SPINNER_INTERVAL: 80,
159
27
  };
160
- // Claude Code style spinner frames: ✻ as primary thinking indicator
161
- // Alternates between ✻ and ◐ for visual interest
162
- const SPINNER_FRAMES = ['✻', '◐', '✻', '◓', '✻', '◑', '✻', '◒'];
163
28
  /**
164
- * Display class manages all terminal UI output for the application.
165
- *
166
- * Architecture:
167
- * - Per-stream line tracking via StdoutLineTracker for consistent banner updates
168
- * - Output interceptor pattern for live update integration
169
- * - Banner state management for in-place updates
170
- * - Configurable width constraints via DISPLAY_CONSTANTS
29
+ * Display class - now a thin wrapper around UnifiedUIRenderer
171
30
  *
172
- * Claude Code Style Formatting:
173
- * - ⏺ prefix for tool calls, actions, and thinking/reasoning
174
- * - ⎿ prefix for results, details, and nested information
175
- * - ─ horizontal separators for dividing sections (edit diffs, etc.)
176
- * - > prefix for user prompts (handled in theme.ts formatUserPrompt)
177
- * - Compact epsilon spinner: ∴, ε, ✻
178
- *
179
- * Key responsibilities:
180
- * - Welcome banners and session information display
181
- * - Message formatting (assistant, system, errors, warnings)
182
- * - Spinner/thinking indicators
183
- * - Action and sub-action formatting with tree-style prefixes
184
- * - Text wrapping and layout management
185
- *
186
- * Error handling:
187
- * - Graceful degradation for non-TTY environments
188
- * - Input validation on public methods
189
- * - Safe cursor manipulation with fallback
31
+ * Provides backward-compatible API while routing all output through the renderer.
190
32
  */
191
33
  export class Display {
192
- stdoutTracker;
193
34
  outputStream;
194
35
  errorStream;
195
- activeSpinner = null;
36
+ renderer = null;
196
37
  outputInterceptors = new Set();
197
- outputLock = OutputLock.getInstance();
198
- spinnerFrames = [...SPINNER_FRAMES];
199
- captureStack = [];
38
+ // Legacy spinner support for compatibility with existing tests/callers
39
+ activeSpinner = null;
200
40
  thinkingStartTime = null;
201
- thinkingElapsedTimer = null;
202
- thinkingBaseMessage = 'Thinking...';
203
- pendingCarriageReturn = false;
204
- // Streaming status line (Claude Code style - fixed at bottom using scroll region)
205
- streamingStatusVisible = false;
206
- scrollRegionActive = false;
207
- savedCursorRow = 0;
208
41
  constructor(stream = process.stdout, errorStream) {
209
42
  this.outputStream = stream;
210
43
  this.errorStream = errorStream ?? stream;
211
- this.stdoutTracker = StdoutLineTracker.getInstance(stream);
44
+ }
45
+ setRenderer(renderer) {
46
+ this.renderer = renderer;
47
+ }
48
+ enqueueEvent(type, content) {
49
+ if (!this.renderer || !content) {
50
+ return false;
51
+ }
52
+ this.renderer.addEvent(type, content);
53
+ return true;
212
54
  }
213
55
  registerOutputInterceptor(interceptor) {
214
56
  if (!interceptor) {
@@ -219,38 +61,6 @@ export class Display {
219
61
  this.outputInterceptors.delete(interceptor);
220
62
  };
221
63
  }
222
- /**
223
- * Execute output with proper write lock coordination.
224
- * All display output goes through this method to prevent race conditions.
225
- *
226
- * Behavior:
227
- * - If write lock is already held, execute directly (we're in a protected context)
228
- * - Otherwise acquire lock during output to prevent interleaving
229
- * - Notifies interceptors before/after output for cursor positioning
230
- */
231
- withOutput(fn) {
232
- const run = () => {
233
- this.notifyBeforeOutput();
234
- this.pushCaptureBuffer();
235
- try {
236
- fn();
237
- }
238
- finally {
239
- const captured = this.popCaptureBuffer();
240
- this.notifyAfterOutput(captured || undefined);
241
- }
242
- };
243
- // If lock is already held, execute directly - we're in a protected context
244
- // This prevents queuing issues where content gets delayed
245
- if (writeLock.isLocked()) {
246
- run();
247
- return;
248
- }
249
- // Acquire lock during output to prevent interleaved writes
250
- writeLock.withLock(() => {
251
- run();
252
- }, 'display.withOutput');
253
- }
254
64
  notifyBeforeOutput() {
255
65
  for (const interceptor of this.outputInterceptors) {
256
66
  interceptor.beforeWrite?.();
@@ -262,234 +72,92 @@ export class Display {
262
72
  interceptors[index]?.afterWrite?.(content);
263
73
  }
264
74
  }
265
- pushCaptureBuffer() {
266
- this.captureStack.push('');
267
- }
268
- appendCapturedOutput(chunk) {
269
- if (!chunk || this.captureStack.length === 0) {
75
+ /**
76
+ * Clear any active spinner for backward compatibility with legacy rendering.
77
+ * Optionally emits a newline to separate subsequent output.
78
+ */
79
+ clearActiveSpinner(addNewLine) {
80
+ if (!this.activeSpinner) {
270
81
  return;
271
82
  }
272
- const idx = this.captureStack.length - 1;
273
- this.captureStack[idx] = `${this.captureStack[idx] ?? ''}${chunk}`;
274
- }
275
- popCaptureBuffer() {
276
- if (this.captureStack.length === 0) {
277
- return '';
83
+ const spinner = this.activeSpinner;
84
+ this.activeSpinner = null;
85
+ if (typeof spinner.clear === 'function') {
86
+ spinner.clear();
278
87
  }
279
- return this.captureStack.pop() ?? '';
280
- }
281
- write(value, target = this.outputStream) {
282
- // Write directly - this method is called from within locked contexts
283
- // like withOutput(), so we don't need additional locking here.
284
- // The globalWriteLock wrapper will handle coordination if needed.
285
- try {
286
- target.write(value);
287
- this.appendCapturedOutput(value);
88
+ else if (typeof spinner.stop === 'function') {
89
+ spinner.stop();
288
90
  }
289
- catch {
290
- // Ignore write failures to keep UI resilient
91
+ if (addNewLine) {
92
+ if (!this.enqueueEvent('raw', '\n')) {
93
+ this.outputStream.write('\n');
94
+ }
291
95
  }
292
96
  }
293
- writeLine(value = '', target = this.outputStream) {
294
- this.write(`${value}\n`, target);
295
- }
296
97
  /**
297
- * Write raw content directly to output stream.
298
- * For streaming output chunks - writes directly without locks during streaming mode.
299
- *
300
- * During streaming mode, content is written directly to stdout without any
301
- * lock coordination. This ensures streaming content flows smoothly without
302
- * being delayed by other UI components.
98
+ * Write raw content directly
303
99
  */
304
100
  writeRaw(content) {
305
- if (typeof content !== 'string' || !content) {
101
+ if (!content)
306
102
  return;
307
- }
308
- const normalized = this.normalizeStreamingContent(content);
309
- if (!normalized) {
103
+ this.notifyBeforeOutput();
104
+ if (this.enqueueEvent('raw', content)) {
105
+ this.notifyAfterOutput(content);
310
106
  return;
311
107
  }
312
- // During streaming, use withLock to prevent interleaving with escape codes.
313
- // This ensures the before/write/after sequence is atomic.
314
- if (isStreamingMode()) {
315
- writeLock.withLock(() => {
316
- this.notifyBeforeOutput();
317
- try {
318
- this.outputStream.write(normalized);
319
- }
320
- catch {
321
- // Ignore write failures to keep UI resilient
322
- }
323
- finally {
324
- this.notifyAfterOutput(normalized);
325
- }
326
- }, 'display.writeRaw.streaming');
327
- return;
328
- }
329
- // Outside streaming mode, use locks to coordinate with other UI
330
- writeLock.withLock(() => {
331
- this.notifyBeforeOutput();
332
- try {
333
- this.write(normalized);
334
- }
335
- finally {
336
- this.notifyAfterOutput(normalized);
337
- }
338
- }, 'display.writeRaw');
108
+ // Fallback if no renderer
109
+ this.outputStream.write(content);
110
+ this.notifyAfterOutput(content);
339
111
  }
340
112
  /**
341
- * Normalize streaming chunks so progress-style carriage returns render cleanly.
342
- * - Preserve CR-based rewrites instead of converting to newlines
343
- * - Clear lines when rewriting to avoid leftover characters
344
- * - Carry trailing CR across chunks so the next write can clear before drawing
345
- */
346
- normalizeStreamingContent(content) {
347
- // Normalize CRLF first
348
- let text = content.replace(/\r\n/g, '\n');
349
- let result = '';
350
- // If the previous chunk ended with a bare CR, clear the line before new text
351
- if (this.pendingCarriageReturn) {
352
- if (text.length === 0) {
353
- return '';
354
- }
355
- if (text[0] !== '\n') {
356
- result += '\r\x1b[K';
357
- }
358
- this.pendingCarriageReturn = false;
359
- }
360
- let trailingCarriageReturn = false;
361
- for (let i = 0; i < text.length; i++) {
362
- const char = text[i];
363
- if (char === '\r') {
364
- const next = text[i + 1];
365
- if (next === '\n') {
366
- // CRLF already normalized above; skip CR
367
- continue;
368
- }
369
- const isLastChar = i === text.length - 1;
370
- if (isLastChar) {
371
- // Trailing CR: keep it so cursor is at column 0, but delay clearing
372
- result += '\r';
373
- trailingCarriageReturn = true;
374
- }
375
- else {
376
- // In-place update: move to start and clear the line before new text
377
- result += '\r\x1b[K';
378
- }
379
- continue;
380
- }
381
- result += char;
382
- }
383
- this.pendingCarriageReturn = trailingCarriageReturn;
384
- return result;
385
- }
386
- /**
387
- * Stream chunk helper - writes directly to stdout during streaming.
388
- * This is the primary method for streaming AI responses.
113
+ * Stream chunk (for streaming responses)
389
114
  */
390
115
  stream(chunk) {
391
- this.writeRaw(chunk);
116
+ if (!chunk)
117
+ return;
118
+ this.notifyBeforeOutput();
119
+ if (this.enqueueEvent('streaming', chunk)) {
120
+ this.notifyAfterOutput(chunk);
121
+ return;
122
+ }
123
+ // Fallback
124
+ this.outputStream.write(chunk);
125
+ this.notifyAfterOutput(chunk);
392
126
  }
393
127
  /**
394
- * Backward-compatible alias for stream().
128
+ * Backward-compatible alias
395
129
  */
396
130
  writeStreamChunk(chunk) {
397
131
  this.stream(chunk);
398
132
  }
399
133
  /**
400
- * Get the output stream for direct access.
401
- * Prefer writeRaw/writeStreamChunk for interceptor support.
134
+ * Get the output stream for direct access
402
135
  */
403
136
  getOutputStream() {
404
137
  return this.outputStream;
405
138
  }
406
139
  /**
407
- * Current number of lines written to stdout (tracked for positioning).
140
+ * Show thinking indicator
408
141
  */
409
- getTotalWrittenLines() {
410
- return this.stdoutTracker.totalLines;
411
- }
412
- /**
413
- * Number of lines at the top of the terminal that belong to the pinned banner.
414
- * For integrated scroll-region layouts, banners are not pinned, so this is 0.
415
- */
416
- getPinnedHeaderLines() {
417
- return 0;
418
- }
419
- getColumnWidth() {
420
- if (typeof this.outputStream.columns === 'number' &&
421
- Number.isFinite(this.outputStream.columns) &&
422
- this.outputStream.columns > 0) {
423
- return this.outputStream.columns;
424
- }
425
- return getTerminalColumns();
426
- }
427
- // Banner is now streamed by the shell - no storage needed
428
142
  showThinking(message = 'Thinking…') {
429
- // If we already have a spinner, just update its text instead of creating a new one
430
- if (this.activeSpinner) {
431
- this.thinkingBaseMessage = message;
432
- this.activeSpinner.update({ text: message });
433
- return;
434
- }
435
- // Lock output to prevent stream writes from interleaving with spinner frames
436
- this.outputLock.lock();
437
- // Track when thinking started for elapsed time display
438
143
  this.thinkingStartTime = Date.now();
439
- this.thinkingBaseMessage = message;
440
- // Use Claude Code style spinner with ✻ as primary thinking indicator
441
- this.activeSpinner = createSpinner(message, {
442
- stream: this.outputStream,
443
- spinner: {
444
- interval: DISPLAY_CONSTANTS.SPINNER_INTERVAL,
445
- frames: this.spinnerFrames,
446
- },
447
- }).start();
448
- // Update spinner with elapsed time every second (Claude Code style)
449
- this.thinkingElapsedTimer = setInterval(() => {
450
- if (this.activeSpinner && this.thinkingStartTime) {
451
- const elapsed = Math.floor((Date.now() - this.thinkingStartTime) / 1000);
452
- if (elapsed > 0) {
453
- const elapsedText = this.formatElapsedTime(elapsed);
454
- this.activeSpinner.update({ text: `${this.thinkingBaseMessage} ${theme.ui.muted(`(${elapsedText})`)}` });
455
- }
456
- }
457
- }, 1000);
144
+ this.enqueueEvent('response', message);
458
145
  }
459
146
  /**
460
- * Format elapsed seconds as human-readable time (e.g., "5s", "1m 23s")
147
+ * Update thinking message
461
148
  */
462
- formatElapsedTime(seconds) {
463
- if (seconds < 60) {
464
- return `${seconds}s`;
465
- }
466
- const mins = Math.floor(seconds / 60);
467
- const secs = seconds % 60;
468
- return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
469
- }
470
149
  updateThinking(message) {
471
- this.thinkingBaseMessage = message;
472
- if (this.activeSpinner) {
473
- // If we have elapsed time, include it in the update
474
- if (this.thinkingStartTime) {
475
- const elapsed = Math.floor((Date.now() - this.thinkingStartTime) / 1000);
476
- if (elapsed > 0) {
477
- const elapsedText = this.formatElapsedTime(elapsed);
478
- this.activeSpinner.update({ text: `${message} ${theme.ui.muted(`(${elapsedText})`)}` });
479
- return;
480
- }
481
- }
482
- this.activeSpinner.update({ text: message });
483
- }
484
- else {
485
- this.showThinking(message);
486
- }
150
+ this.enqueueEvent('response', message);
487
151
  }
488
- stopThinking(addNewLine = true) {
489
- this.clearSpinnerIfActive(addNewLine);
152
+ /**
153
+ * Stop thinking
154
+ */
155
+ stopThinking(_addNewLine = true) {
156
+ this.clearActiveSpinner(_addNewLine);
157
+ this.thinkingStartTime = null;
490
158
  }
491
159
  /**
492
- * Get current thinking elapsed time in milliseconds, or null if not thinking.
160
+ * Get thinking elapsed time
493
161
  */
494
162
  getThinkingElapsedMs() {
495
163
  if (!this.thinkingStartTime) {
@@ -497,245 +165,70 @@ export class Display {
497
165
  }
498
166
  return Date.now() - this.thinkingStartTime;
499
167
  }
168
+ /**
169
+ * Check if spinner is active (always false with new renderer)
170
+ */
500
171
  isSpinnerActive() {
501
- return this.activeSpinner !== null;
172
+ return false;
502
173
  }
503
174
  /**
504
- * Check if output is currently locked (e.g., spinner is active).
505
- * Used by external callers to coordinate output timing.
175
+ * Check if output is locked (always false with event queue)
506
176
  */
507
177
  isOutputLocked() {
508
- return this.outputLock.isLocked();
178
+ return false;
509
179
  }
510
180
  /**
511
- * Execute a write callback safely, ensuring it doesn't interleave with spinner output.
512
- * If spinner is active, the write will be queued until spinner stops.
181
+ * Safe write (just calls callback immediately with event queue)
513
182
  */
514
183
  safeWrite(callback) {
515
- this.outputLock.safeWrite(callback);
516
- }
517
- /**
518
- * Get terminal rows, with fallback
519
- */
520
- getTerminalRows() {
521
- const stream = this.outputStream;
522
- return stream.rows ?? 24;
523
- }
524
- /**
525
- * Set up scroll region to reserve bottom line for status.
526
- * This allows content to scroll while status stays fixed.
527
- */
528
- setupScrollRegion() {
529
- if (this.scrollRegionActive || !this.isTTY()) {
530
- return;
531
- }
532
- const rows = this.getTerminalRows();
533
- // Reserve bottom 2 lines (separator + status)
534
- // Set scroll region from row 1 to row (rows - 2)
535
- this.outputStream.write(`\x1b[1;${rows - 2}r`);
536
- // Move cursor to end of scroll region
537
- this.outputStream.write(`\x1b[${rows - 2};1H`);
538
- this.scrollRegionActive = true;
539
- this.savedCursorRow = rows - 2;
540
- }
541
- /**
542
- * Tear down scroll region and restore normal terminal.
543
- */
544
- teardownScrollRegion() {
545
- if (!this.scrollRegionActive) {
546
- return;
547
- }
548
- const rows = this.getTerminalRows();
549
- // Reset scroll region to full screen
550
- this.outputStream.write('\x1b[r');
551
- // Clear the status lines at bottom
552
- this.outputStream.write(`\x1b[${rows - 1};1H\x1b[2K`);
553
- this.outputStream.write(`\x1b[${rows};1H\x1b[2K`);
554
- // Move cursor back to where content was
555
- this.outputStream.write(`\x1b[${this.savedCursorRow};1H`);
556
- this.scrollRegionActive = false;
557
- this.streamingStatusVisible = false;
558
- }
559
- /**
560
- * Check if we're in a TTY (can use escape codes)
561
- */
562
- isTTY() {
563
- const stream = this.outputStream;
564
- return stream.isTTY === true;
565
- }
566
- /**
567
- * Update the streaming status line (Claude Code style).
568
- * When scroll region is active, updates the fixed status area at bottom.
569
- * Call with null to clear the status line.
570
- * Uses shared writeLock to prevent race conditions with other output.
571
- */
572
- updateStreamingStatus(status) {
573
- // Use shared writeLock to coordinate with terminalInput and streaming output
574
- writeLock.withLock(() => {
575
- this.updateStreamingStatusInternal(status);
576
- }, 'display.updateStreamingStatus');
577
- }
578
- /**
579
- * Internal implementation of updateStreamingStatus (called with lock held)
580
- * NOTE: During streaming, we do NOT write status lines to stdout to prevent
581
- * race conditions with stream chunks. The status is tracked internally and
582
- * the terminalInput's status message display handles showing it in the
583
- * reserved input area, separate from the main content stream.
584
- */
585
- updateStreamingStatusInternal(status) {
586
- if (!status) {
587
- this.streamingStatusVisible = false;
588
- return;
589
- }
590
- // Just track the status - don't write to stdout during streaming
591
- this.streamingStatusVisible = true;
592
- }
593
- /**
594
- * Check if streaming status is currently visible.
595
- */
596
- isStreamingStatusVisible() {
597
- return this.streamingStatusVisible;
598
- }
599
- /**
600
- * Check if scroll region is active.
601
- */
602
- isScrollRegionActive() {
603
- return this.scrollRegionActive;
604
- }
605
- /**
606
- * Clear streaming status and reset state.
607
- */
608
- clearStreamingStatus() {
609
- this.streamingStatusVisible = false;
184
+ callback();
610
185
  }
611
186
  /**
612
- * Display parallel agent status (Claude Code style).
613
- * Shows a tree of running agents with their progress.
187
+ * Show assistant message
614
188
  */
615
- parallelAgentStatus(content) {
616
- if (!content)
617
- return;
618
- // During streaming, write directly to output
619
- this.withOutput(() => {
620
- // Clear current line and write agent status
621
- this.writeRaw('\r\x1b[K');
622
- this.writeLine(content);
623
- });
624
- }
625
- clearSpinnerIfActive(addNewLine = true) {
626
- if (!this.activeSpinner) {
627
- return;
628
- }
629
- // Clear the elapsed time update timer
630
- if (this.thinkingElapsedTimer) {
631
- clearInterval(this.thinkingElapsedTimer);
632
- this.thinkingElapsedTimer = null;
633
- }
634
- this.thinkingStartTime = null;
635
- const spinner = this.activeSpinner;
636
- this.activeSpinner = null;
637
- // Use stop() instead of clear() so nanospinner removes its SIGINT/SIGTERM listeners.
638
- // clear() leaves the listeners attached, which triggers MaxListenersExceededWarning over time.
639
- spinner.stop();
640
- // Unlock output to process any pending writes
641
- this.outputLock.unlock();
642
- if (addNewLine) {
643
- this.withOutput(() => {
644
- this.writeLine();
645
- });
646
- }
647
- }
648
189
  showAssistantMessage(content, metadata) {
649
- if (!content.trim()) {
190
+ this.clearActiveSpinner(false);
191
+ if (!content.trim())
650
192
  return;
651
- }
652
- this.clearSpinnerIfActive();
653
193
  const isThought = metadata?.isFinal === false;
654
194
  const body = isThought ? this.buildClaudeStyleThought(content) : this.buildChatBox(content, metadata);
655
- if (!body.trim()) {
195
+ if (!body.trim())
656
196
  return;
657
- }
658
- // Claude Code: Final responses use bullet prefix ⏺ with continuation indent
659
- const wrapped = this.applySingleBulletBlock(body);
660
- this.withOutput(() => {
661
- this.writeLine(); // Ensure clean start for the box
662
- this.writeLine(wrapped);
663
- this.writeLine();
664
- });
197
+ const wrapped = isThought ? this.applySingleBulletBlock(body) : body;
198
+ this.enqueueEvent('raw', `\n${wrapped}\n\n`);
665
199
  }
200
+ /**
201
+ * Show narrative (thought)
202
+ */
666
203
  showNarrative(content) {
667
- if (!content.trim()) {
204
+ if (!content.trim())
668
205
  return;
669
- }
670
206
  this.showAssistantMessage(content, { isFinal: false });
671
207
  }
208
+ /**
209
+ * Show action
210
+ */
672
211
  showAction(text, status = 'info') {
673
- if (!text.trim()) {
212
+ if (!text.trim())
674
213
  return;
675
- }
676
- this.clearSpinnerIfActive();
677
- // Claude Code style: always use ⏺ prefix for actions
678
214
  const icon = this.formatActionIcon(status);
679
- this.withOutput(() => {
680
- this.writeLine(this.wrapWithPrefix(text, `${icon} `));
681
- });
215
+ const rendered = this.wrapWithPrefix(text, `${icon} `);
216
+ this.enqueueEvent('raw', `${rendered}\n`);
682
217
  }
218
+ /**
219
+ * Show sub-action
220
+ */
683
221
  showSubAction(text, status = 'info') {
684
- if (!text.trim()) {
222
+ if (!text.trim())
685
223
  return;
686
- }
687
- this.clearSpinnerIfActive();
688
- const prefersRich = text.includes('```');
689
- let rendered = prefersRich ? this.buildRichSubActionLines(text, status) : this.buildWrappedSubActionLines(text, status);
690
- if (!rendered.length && prefersRich) {
691
- rendered = this.buildWrappedSubActionLines(text, status);
692
- }
693
- if (!rendered.length) {
224
+ const lines = this.buildWrappedSubActionLines(text, status);
225
+ if (!lines.length)
694
226
  return;
695
- }
696
- this.withOutput(() => {
697
- this.writeLine(rendered.join('\n'));
698
- this.writeLine();
699
- });
700
- }
701
- buildWrappedSubActionLines(text, status) {
702
- const lines = text.split('\n').map((line) => line.trimEnd());
703
- while (lines.length && !lines[lines.length - 1]?.trim()) {
704
- lines.pop();
705
- }
706
- if (!lines.length) {
707
- return [];
708
- }
709
- const rendered = [];
710
- for (let index = 0; index < lines.length; index += 1) {
711
- const segment = lines[index] ?? '';
712
- const isLast = index === lines.length - 1;
713
- const { prefix, continuation } = this.buildSubActionPrefixes(status, isLast);
714
- rendered.push(this.wrapWithPrefix(segment, prefix, { continuationPrefix: continuation }));
715
- }
716
- return rendered;
717
- }
718
- buildRichSubActionLines(text, status) {
719
- const normalized = text.trim();
720
- if (!normalized) {
721
- return [];
722
- }
723
- const width = Math.max(DISPLAY_CONSTANTS.MIN_ACTION_WIDTH, Math.min(this.getColumnWidth(), DISPLAY_CONSTANTS.MAX_ACTION_WIDTH));
724
- const samplePrefix = this.buildSubActionPrefixes(status, true).prefix;
725
- const contentWidth = Math.max(DISPLAY_CONSTANTS.MIN_CONTENT_WIDTH, width - this.visibleLength(samplePrefix));
726
- const blocks = formatRichContent(normalized, contentWidth);
727
- if (!blocks.length) {
728
- return [];
729
- }
730
- return blocks.map((line, index) => {
731
- const isLast = index === blocks.length - 1;
732
- const { prefix } = this.buildSubActionPrefixes(status, isLast);
733
- if (!line.trim()) {
734
- return prefix.trimEnd();
735
- }
736
- return `${prefix}${line}`;
737
- });
227
+ this.enqueueEvent('raw', `${lines.join('\n')}\n\n`);
738
228
  }
229
+ /**
230
+ * Show message
231
+ */
739
232
  showMessage(content, role = 'assistant') {
740
233
  if (role === 'system') {
741
234
  this.showSystemMessage(content);
@@ -744,16 +237,19 @@ export class Display {
744
237
  this.showAssistantMessage(content);
745
238
  }
746
239
  }
240
+ /**
241
+ * Show system message
242
+ */
747
243
  showSystemMessage(content) {
748
- this.clearSpinnerIfActive();
749
244
  const normalized = content.trim();
750
- if (!normalized) {
245
+ if (!normalized)
751
246
  return;
752
- }
753
247
  this.stream(`${normalized}\n\n`);
754
248
  }
249
+ /**
250
+ * Show error
251
+ */
755
252
  showError(message, error) {
756
- this.clearSpinnerIfActive();
757
253
  const details = this.formatErrorDetails(error);
758
254
  const parts = [`${theme.error('✗')} ${message}`];
759
255
  if (details) {
@@ -761,23 +257,26 @@ export class Display {
761
257
  }
762
258
  this.stream(`${parts.join('\n')}\n`);
763
259
  }
260
+ /**
261
+ * Show warning
262
+ */
764
263
  showWarning(message) {
765
- this.clearSpinnerIfActive();
766
264
  this.stream(`${theme.warning('!')} ${message}\n`);
767
265
  }
266
+ /**
267
+ * Show info
268
+ */
768
269
  showInfo(message) {
769
- this.clearSpinnerIfActive();
770
270
  this.stream(`${theme.info('ℹ')} ${message}\n`);
771
271
  }
772
272
  /**
773
- * Show a success message with simple styling
273
+ * Show success
774
274
  */
775
275
  showSuccess(message) {
776
- this.clearSpinnerIfActive();
777
276
  this.stream(`${theme.success('✓')} ${message}\n`);
778
277
  }
779
278
  /**
780
- * Show a stylish progress indicator for long operations
279
+ * Show progress badge
781
280
  */
782
281
  showProgressBadge(label, current, total) {
783
282
  const percentage = Math.round((current / total) * 100);
@@ -786,66 +285,133 @@ export class Display {
786
285
  const empty = barWidth - filled;
787
286
  const progressBar = `${'█'.repeat(filled)}${'░'.repeat(empty)}`;
788
287
  const badge = `[${label}] ${progressBar} ${percentage}%`;
789
- this.withOutput(() => {
790
- this.write(`\r${theme.info(badge)}`);
791
- if (current >= total) {
792
- this.writeLine();
793
- }
288
+ this.stream(`\r${theme.info(badge)}`);
289
+ if (current >= total) {
290
+ this.stream('\n');
291
+ }
292
+ }
293
+ /**
294
+ * Show status line
295
+ */
296
+ showStatusLine(status, elapsedMs, _context) {
297
+ const normalized = status?.trim();
298
+ if (!normalized)
299
+ return;
300
+ const elapsed = this.formatElapsed(elapsedMs);
301
+ const parts = [];
302
+ parts.push(`${theme.success('✓')} ${normalized}`);
303
+ if (elapsed) {
304
+ parts.push(`(${elapsed})`);
305
+ }
306
+ this.stream(`${parts.join(' ')}\n`);
307
+ }
308
+ /**
309
+ * Show available tools (no-op)
310
+ */
311
+ showAvailableTools(_tools) {
312
+ // Hidden by default
313
+ }
314
+ /**
315
+ * Show command palette
316
+ */
317
+ showCommandPalette(commands, options) {
318
+ if (!commands || commands.length === 0)
319
+ return;
320
+ const panel = this.buildCommandPalette(commands, options);
321
+ if (!panel.trim())
322
+ return;
323
+ this.enqueueEvent('raw', `\n${panel}\n\n`);
324
+ }
325
+ /**
326
+ * Show ready with hints (no-op)
327
+ */
328
+ showReadyWithHints() {
329
+ // Commands hint is now in the renderer
330
+ }
331
+ /**
332
+ * Show planning step
333
+ */
334
+ showPlanningStep(step, index, total) {
335
+ if (!step?.trim() || index < 1 || total < 1 || index > total)
336
+ return;
337
+ const width = Math.max(DISPLAY_CONSTANTS.MIN_THOUGHT_WIDTH, Math.min(this.getColumnWidth(), DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
338
+ const heading = renderSectionHeading(`Plan ${index}/${total}`, {
339
+ subtitle: step,
340
+ icon: icons.arrow,
341
+ tone: 'info',
342
+ width,
794
343
  });
344
+ this.enqueueEvent('raw', `${heading}\n`);
345
+ }
346
+ /**
347
+ * Show thinking block
348
+ */
349
+ showThinkingBlock(content, durationMs) {
350
+ const block = this.buildClaudeStyleThought(content, durationMs);
351
+ this.enqueueEvent('raw', `\n${block}\n\n`);
352
+ }
353
+ /**
354
+ * Clear screen
355
+ */
356
+ clear() {
357
+ // Renderer handles this
795
358
  }
796
- showStatusLine(status, elapsedMs, _context) {
797
- this.clearSpinnerIfActive();
798
- const normalized = status?.trim();
799
- if (!normalized) {
800
- return;
801
- }
802
- const elapsed = this.formatElapsed(elapsedMs);
803
- const parts = [
804
- { text: `${theme.success('✓')} ${normalized}`, tone: 'success' },
805
- elapsed ? { text: `(${elapsed})`, tone: 'muted' } : null,
806
- ].filter(Boolean);
807
- const line = renderStatusLine(parts, this.getColumnWidth() - 2);
808
- this.withOutput(() => {
809
- this.writeLine(line);
810
- });
359
+ /**
360
+ * Update streaming status (routes to renderer)
361
+ */
362
+ updateStreamingStatus(status) {
363
+ this.renderer?.updateStatus(status);
811
364
  }
812
- showAvailableTools(_tools) {
813
- // Hidden by default to match Claude Code style
814
- // Tools are available but not listed verbosely on startup
815
- // Parameter prefixed with underscore to indicate intentionally unused
365
+ /**
366
+ * Clear streaming status
367
+ */
368
+ clearStreamingStatus() {
369
+ this.renderer?.updateStatus(null);
816
370
  }
817
371
  /**
818
- * Show a compact launch panel of slash commands with wrapped descriptions.
372
+ * Check if streaming status is visible
819
373
  */
820
- showCommandPalette(commands, options) {
821
- if (!commands || commands.length === 0) {
822
- return;
823
- }
824
- const panel = this.buildCommandPalette(commands, options);
825
- if (!panel.trim()) {
826
- return;
827
- }
828
- this.withOutput(() => {
829
- this.writeLine();
830
- this.writeLine(panel);
831
- this.writeLine();
832
- });
374
+ isStreamingStatusVisible() {
375
+ return false; // Renderer manages this
833
376
  }
834
377
  /**
835
- * Show ready message with keyboard shortcuts hint (compact)
836
- * Note: Commands are now shown in the banner, so this is a no-op
378
+ * Legacy compatibility methods (no-ops)
837
379
  */
838
- showReadyWithHints() {
839
- // Commands hint is now included in the banner
380
+ getTotalWrittenLines() {
381
+ return 0;
382
+ }
383
+ getPinnedHeaderLines() {
384
+ return 0;
385
+ }
386
+ setupScrollRegion() {
387
+ // No-op - renderer handles layout
388
+ }
389
+ teardownScrollRegion() {
390
+ // No-op
391
+ }
392
+ isScrollRegionActive() {
393
+ return false;
394
+ }
395
+ parallelAgentStatus(content) {
396
+ if (!content)
397
+ return;
398
+ this.enqueueEvent('streaming', `${content}\n`);
399
+ }
400
+ // ==================== Private Helper Methods ====================
401
+ getColumnWidth() {
402
+ if (typeof this.outputStream.columns === 'number' &&
403
+ Number.isFinite(this.outputStream.columns) &&
404
+ this.outputStream.columns > 0) {
405
+ return this.outputStream.columns;
406
+ }
407
+ return getTerminalColumns();
840
408
  }
841
409
  formatErrorDetails(error) {
842
- if (!error) {
410
+ if (!error)
843
411
  return null;
844
- }
845
412
  if (error instanceof Error) {
846
- if (error.stack) {
413
+ if (error.stack)
847
414
  return highlightError(error.stack);
848
- }
849
415
  return highlightError(error.message);
850
416
  }
851
417
  if (typeof error === 'string') {
@@ -858,83 +424,22 @@ export class Display {
858
424
  return null;
859
425
  }
860
426
  }
861
- showPlanningStep(step, index, total) {
862
- // Validate inputs
863
- if (!step?.trim()) {
864
- return;
865
- }
866
- if (index < 1 || total < 1 || index > total) {
867
- return;
868
- }
869
- const width = Math.max(DISPLAY_CONSTANTS.MIN_THOUGHT_WIDTH, Math.min(this.getColumnWidth(), DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
870
- const heading = renderSectionHeading(`Plan ${index}/${total}`, {
871
- subtitle: step,
872
- icon: icons.arrow,
873
- tone: 'info',
874
- width,
875
- });
876
- this.withOutput(() => {
877
- this.writeLine(heading);
878
- });
879
- }
880
- clear() {
881
- this.withOutput(() => {
882
- try {
883
- cursorTo(this.outputStream, 0, 0);
884
- clearScreenDown(this.outputStream);
885
- }
886
- catch {
887
- this.write('\x1Bc');
888
- }
889
- });
890
- this.stdoutTracker.reset();
891
- // Banner is streamed content - no re-render on clear
892
- }
893
- formatModelLabel(model) {
894
- if (/gpt-5\.1-?codex/i.test(model)) {
895
- return model;
896
- }
897
- if (/sonnet-4[-.]?5/i.test(model)) {
898
- return 'Sonnet 4.5';
899
- }
900
- if (/opus-4[-.]?1/i.test(model)) {
901
- return 'Opus 4.1';
902
- }
903
- if (/haiku-4[-.]?5/i.test(model)) {
904
- return 'Haiku 4.5';
905
- }
906
- if (/gpt-5\.1/i.test(model)) {
907
- return 'GPT-5.1';
908
- }
909
- if (/gpt-5-?pro/i.test(model)) {
910
- return 'GPT-5 Pro';
911
- }
912
- if (/gpt-5-?mini/i.test(model)) {
913
- return 'GPT-5 Mini';
914
- }
915
- if (/gpt-5-?nano/i.test(model)) {
916
- return 'GPT-5 Nano';
917
- }
918
- return model;
919
- }
920
- compactPath(path, maxLen) {
921
- if (!path) {
922
- return '';
923
- }
924
- if (this.visibleLength(path) <= maxLen) {
925
- return path;
427
+ formatElapsed(elapsedMs) {
428
+ if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs) || elapsedMs < 0) {
429
+ return null;
926
430
  }
927
- const parts = path.split('/').filter(Boolean);
928
- if (parts.length <= 2) {
929
- return `${path.slice(0, Math.max(0, maxLen - 3))}...`;
431
+ const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
432
+ const minutes = Math.floor(totalSeconds / 60);
433
+ const seconds = totalSeconds % 60;
434
+ if (minutes > 0) {
435
+ return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
930
436
  }
931
- return `${parts[0]}/.../${parts[parts.length - 1]}`;
437
+ return `${seconds}s`;
932
438
  }
933
439
  buildChatBox(content, metadata) {
934
440
  const normalized = content.trim();
935
- if (!normalized) {
441
+ if (!normalized)
936
442
  return '';
937
- }
938
443
  if (isPlainOutputMode()) {
939
444
  const body = renderMessageBody(normalized, this.resolveMessageWidth());
940
445
  const telemetry = this.formatTelemetryLine(metadata);
@@ -949,19 +454,15 @@ export class Display {
949
454
  borderColor: theme.ui.border,
950
455
  });
951
456
  const telemetry = this.formatTelemetryLine(metadata);
952
- if (!telemetry) {
953
- return panel;
954
- }
955
- return `${panel}\n${telemetry}`;
457
+ return telemetry ? `${panel}\n${telemetry}` : panel;
956
458
  }
957
459
  resolveMessageWidth() {
958
460
  const columns = this.getColumnWidth();
959
461
  return Math.max(DISPLAY_CONSTANTS.MIN_MESSAGE_WIDTH, Math.min(columns - DISPLAY_CONSTANTS.MESSAGE_PADDING, DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
960
462
  }
961
463
  formatTelemetryLine(metadata) {
962
- if (!metadata) {
464
+ if (!metadata)
963
465
  return '';
964
- }
965
466
  const parts = [];
966
467
  const elapsed = this.formatElapsed(metadata.elapsedMs);
967
468
  if (elapsed) {
@@ -969,28 +470,67 @@ export class Display {
969
470
  const elapsedValue = theme.metrics?.elapsedValue ?? theme.secondary;
970
471
  parts.push(`${elapsedLabel('elapsed')} ${elapsedValue(elapsed)}`);
971
472
  }
972
- if (!parts.length) {
473
+ if (!parts.length)
973
474
  return '';
974
- }
975
475
  const separator = theme.ui.muted(' • ');
976
476
  return ` ${parts.join(separator)}`;
977
477
  }
978
- formatElapsed(elapsedMs) {
979
- if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs) || elapsedMs < 0) {
980
- return null;
478
+ buildClaudeStyleThought(content, durationMs) {
479
+ const thinkingStyle = theme.thinking || {
480
+ icon: theme.info,
481
+ text: theme.ui.muted,
482
+ border: theme.ui.border,
483
+ label: theme.info,
484
+ };
485
+ const width = Math.min(this.getColumnWidth() - 4, 70);
486
+ const lines = [];
487
+ // Header
488
+ if (durationMs !== undefined) {
489
+ const elapsed = this.formatElapsedTime(Math.floor(durationMs / 1000));
490
+ lines.push(`${theme.info('∴')} Thought for ${elapsed}`);
981
491
  }
982
- const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
983
- const minutes = Math.floor(totalSeconds / 60);
984
- const seconds = totalSeconds % 60;
985
- if (minutes > 0) {
986
- return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
492
+ else {
493
+ lines.push(`${theme.info('✻')} ${thinkingStyle.label('Thinking…')}`);
987
494
  }
988
- return `${seconds}s`;
495
+ // Content
496
+ const contentLines = content.split('\n');
497
+ const hasContent = contentLines.some(line => line.trim().length > 0);
498
+ if (hasContent) {
499
+ lines.push('');
500
+ }
501
+ for (const line of contentLines) {
502
+ const trimmed = line.replace(/\s+$/, '');
503
+ if (!trimmed.trim()) {
504
+ lines.push('');
505
+ continue;
506
+ }
507
+ const wrapped = this.wrapLine(trimmed, width - 4);
508
+ for (const wrappedLine of wrapped) {
509
+ lines.push(` ${thinkingStyle.text(wrappedLine)}`);
510
+ }
511
+ }
512
+ return lines.join('\n');
513
+ }
514
+ formatElapsedTime(seconds) {
515
+ if (seconds < 60) {
516
+ return `${seconds}s`;
517
+ }
518
+ const mins = Math.floor(seconds / 60);
519
+ const secs = seconds % 60;
520
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
521
+ }
522
+ applySingleBulletBlock(text) {
523
+ const lines = text.split('\n');
524
+ const bullet = `${icons.action} `;
525
+ const prefix = theme.info(bullet);
526
+ const indent = ' '.repeat(this.visibleLength(this.stripAnsi(bullet)));
527
+ return lines
528
+ .map((line, index) => (index === 0 ? `${prefix}${line}` : `${indent}${line}`))
529
+ .join('\n');
989
530
  }
990
531
  buildCommandPalette(commands, options) {
991
- if (!commands.length) {
532
+ if (!commands.length)
992
533
  return '';
993
- }
994
534
  const width = Math.max(DISPLAY_CONSTANTS.MIN_MESSAGE_WIDTH, Math.min(this.getColumnWidth() - 2, DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
995
535
  const indent = ' ';
996
536
  const grouped = this.groupPaletteCommands(commands);
@@ -1049,39 +589,25 @@ export class Display {
1049
589
  return Math.min(maxAllowed, budget);
1050
590
  }
1051
591
  formatPaletteCategory(category) {
1052
- if (!category) {
592
+ if (!category)
1053
593
  return 'Other';
1054
- }
1055
594
  switch (category.toLowerCase()) {
1056
- case 'configuration':
1057
- return 'Configuration';
1058
- case 'workspace':
1059
- return 'Workspace';
1060
- case 'diagnostics':
1061
- return 'Diagnostics';
1062
- case 'other':
1063
- return 'Other';
1064
- default:
1065
- return category[0]?.toUpperCase() + category.slice(1);
595
+ case 'configuration': return 'Configuration';
596
+ case 'workspace': return 'Workspace';
597
+ case 'diagnostics': return 'Diagnostics';
598
+ case 'other': return 'Other';
599
+ default: return category[0]?.toUpperCase() + category.slice(1);
1066
600
  }
1067
601
  }
1068
602
  colorizePaletteText(text, tone) {
1069
603
  switch (tone) {
1070
- case 'warn':
1071
- return theme.warning(text);
1072
- case 'success':
1073
- return theme.success(text);
1074
- case 'info':
1075
- return theme.info(text);
604
+ case 'warn': return theme.warning(text);
605
+ case 'success': return theme.success(text);
606
+ case 'info': return theme.info(text);
1076
607
  case 'muted':
1077
- default:
1078
- return theme.ui.muted(text);
608
+ default: return theme.ui.muted(text);
1079
609
  }
1080
610
  }
1081
- /**
1082
- * Wraps text with a prefix on the first line and optional continuation prefix.
1083
- * Handles multi-line text and word wrapping intelligently.
1084
- */
1085
611
  wrapWithPrefix(text, prefix, options) {
1086
612
  if (!text) {
1087
613
  return prefix.trimEnd();
@@ -1114,93 +640,25 @@ export class Display {
1114
640
  }
1115
641
  return lines.join('\n');
1116
642
  }
1117
- resolveStatusColor(status) {
1118
- switch (status) {
1119
- case 'success':
1120
- return theme.success;
1121
- case 'error':
1122
- return theme.error;
1123
- case 'warning':
1124
- return theme.warning;
1125
- case 'pending':
1126
- return theme.info;
1127
- default:
1128
- return theme.secondary;
1129
- }
1130
- }
1131
- formatActionIcon(status) {
1132
- const colorize = this.resolveStatusColor(status);
1133
- return colorize(`${icons.action}`);
1134
- }
1135
- /**
1136
- * Prefix a multi-line block with a single bullet, indenting subsequent lines.
1137
- * Keeps entire assistant responses as one visual event.
1138
- */
1139
- applySingleBulletBlock(text) {
1140
- const lines = text.split('\n');
1141
- const bullet = `${icons.action} `;
1142
- const prefix = theme.info(bullet);
1143
- const indent = ' '.repeat(this.visibleLength(this.stripAnsi(bullet)));
1144
- return lines
1145
- .map((line, index) => (index === 0 ? `${prefix}${line}` : `${indent}${line}`))
1146
- .join('\n');
1147
- }
1148
- buildClaudeStyleThought(content, durationMs) {
1149
- // Claude Code style: ∴ Thought for Xs or ✻ Thinking…
1150
- const thinkingStyle = theme.thinking || {
1151
- icon: theme.info,
1152
- text: theme.ui.muted,
1153
- border: theme.ui.border,
1154
- label: theme.info,
1155
- };
1156
- const width = Math.min(this.getColumnWidth() - 4, 70);
1157
- const lines = [];
1158
- // Header: "∴ Thought for Xs" for completed, "✻ Thinking…" for active
1159
- if (durationMs !== undefined) {
1160
- const elapsed = this.formatElapsedTime(Math.floor(durationMs / 1000));
1161
- lines.push(`${theme.info('∴')} Thought for ${elapsed}`);
1162
- }
1163
- else {
1164
- lines.push(`${theme.info('✻')} ${thinkingStyle.label('Thinking…')}`);
1165
- }
1166
- // Parse and format the thinking content with simple indentation
1167
- const contentLines = content.split('\n');
1168
- const hasContent = contentLines.some(line => line.trim().length > 0);
1169
- if (hasContent) {
1170
- lines.push(''); // Visual gap between header and content
643
+ buildWrappedSubActionLines(text, status) {
644
+ const lines = text.split('\n').map((line) => line.trimEnd());
645
+ while (lines.length && !lines[lines.length - 1]?.trim()) {
646
+ lines.pop();
1171
647
  }
1172
- for (const line of contentLines) {
1173
- const trimmed = line.replace(/\s+$/, '');
1174
- if (!trimmed.trim()) {
1175
- lines.push(''); // Preserve intentional blank lines inside the block
1176
- continue;
1177
- }
1178
- const wrapped = this.wrapLine(trimmed, width - 4);
1179
- for (const wrappedLine of wrapped) {
1180
- lines.push(` ${thinkingStyle.text(wrappedLine)}`);
1181
- }
648
+ if (!lines.length)
649
+ return [];
650
+ const rendered = [];
651
+ for (let index = 0; index < lines.length; index += 1) {
652
+ const segment = lines[index] ?? '';
653
+ const isLast = index === lines.length - 1;
654
+ const { prefix, continuation } = this.buildSubActionPrefixes(status, isLast);
655
+ rendered.push(this.wrapWithPrefix(segment, prefix, { continuationPrefix: continuation }));
1182
656
  }
1183
- return lines.join('\n');
1184
- }
1185
- /**
1186
- * Show a thinking block with rich formatting (public method for external use)
1187
- * @param content The thinking content to display
1188
- * @param durationMs Optional duration in milliseconds to show "Thought for Xs"
1189
- */
1190
- showThinkingBlock(content, durationMs) {
1191
- this.clearSpinnerIfActive();
1192
- const block = this.buildClaudeStyleThought(content, durationMs);
1193
- this.withOutput(() => {
1194
- this.writeLine();
1195
- this.writeLine(block);
1196
- this.writeLine();
1197
- this.writeLine(); // Extra newline for better visual separation
1198
- });
657
+ return rendered;
1199
658
  }
1200
659
  buildSubActionPrefixes(status, isLast) {
1201
660
  if (isLast) {
1202
661
  const colorize = this.resolveStatusColor(status);
1203
- // Claude Code style: use ⎿ for sub-action result/detail prefix
1204
662
  return {
1205
663
  prefix: ` ${colorize(icons.subaction)} `,
1206
664
  continuation: ' ',
@@ -1212,40 +670,55 @@ export class Display {
1212
670
  continuation: ` ${branch} `,
1213
671
  };
1214
672
  }
1215
- /**
1216
- * Wraps a single line of text to fit within the specified width.
1217
- * Intelligently handles word breaking and preserves spaces.
1218
- */
673
+ resolveStatusColor(status) {
674
+ switch (status) {
675
+ case 'success': return theme.success;
676
+ case 'error': return theme.error;
677
+ case 'warning': return theme.warning;
678
+ case 'pending': return theme.info;
679
+ default: return theme.secondary;
680
+ }
681
+ }
682
+ formatActionIcon(status) {
683
+ const colorize = this.resolveStatusColor(status);
684
+ return colorize(`${icons.action}`);
685
+ }
1219
686
  wrapLine(text, width) {
1220
- // Handle edge cases
1221
- if (width <= 0) {
687
+ if (width <= 0)
1222
688
  return [text];
1223
- }
1224
- if (!text) {
689
+ if (!text)
1225
690
  return [''];
1226
- }
1227
- if (text.length <= width) {
691
+ if (text.length <= width)
1228
692
  return [text];
1229
- }
1230
693
  const words = text.split(/\s+/).filter(Boolean);
1231
- // If no words, chunk the entire text
1232
- if (!words.length) {
694
+ if (!words.length)
1233
695
  return this.chunkWord(text, width);
1234
- }
1235
696
  const lines = [];
1236
697
  let current = '';
1237
698
  for (const word of words) {
1238
- const appendResult = this.tryAppendWord(current, word, width);
1239
- if (appendResult.shouldFlush) {
1240
- lines.push(current);
699
+ if (!current) {
700
+ if (word.length <= width) {
701
+ current = word;
702
+ }
703
+ else {
704
+ const chunks = this.chunkWord(word, width);
705
+ lines.push(...chunks.slice(0, -1));
706
+ current = chunks[chunks.length - 1] ?? '';
707
+ }
1241
708
  }
1242
- if (appendResult.chunks.length > 0) {
1243
- // Word was too long and was chunked
1244
- lines.push(...appendResult.chunks.slice(0, -1));
1245
- current = appendResult.chunks[appendResult.chunks.length - 1] ?? '';
709
+ else if (current.length + 1 + word.length <= width) {
710
+ current = `${current} ${word}`;
1246
711
  }
1247
712
  else {
1248
- current = appendResult.newCurrent;
713
+ lines.push(current);
714
+ if (word.length <= width) {
715
+ current = word;
716
+ }
717
+ else {
718
+ const chunks = this.chunkWord(word, width);
719
+ lines.push(...chunks.slice(0, -1));
720
+ current = chunks[chunks.length - 1] ?? '';
721
+ }
1249
722
  }
1250
723
  }
1251
724
  if (current) {
@@ -1253,64 +726,23 @@ export class Display {
1253
726
  }
1254
727
  return lines.length ? lines : [''];
1255
728
  }
1256
- /**
1257
- * Attempts to append a word to the current line.
1258
- * Returns instructions on how to handle the word.
1259
- */
1260
- tryAppendWord(current, word, width) {
1261
- if (!word) {
1262
- return { shouldFlush: false, newCurrent: current, chunks: [] };
1263
- }
1264
- // Empty current line - start new line with word
1265
- if (!current) {
1266
- if (word.length <= width) {
1267
- return { shouldFlush: false, newCurrent: word, chunks: [] };
1268
- }
1269
- // Word too long, need to chunk it
1270
- return { shouldFlush: false, newCurrent: '', chunks: this.chunkWord(word, width) };
1271
- }
1272
- // Word fits on current line with space
1273
- if (current.length + 1 + word.length <= width) {
1274
- return { shouldFlush: false, newCurrent: `${current} ${word}`, chunks: [] };
1275
- }
1276
- // Word doesn't fit - flush current and start new line
1277
- if (word.length <= width) {
1278
- return { shouldFlush: true, newCurrent: word, chunks: [] };
1279
- }
1280
- // Word doesn't fit and is too long - flush current and chunk word
1281
- return { shouldFlush: true, newCurrent: '', chunks: this.chunkWord(word, width) };
1282
- }
1283
- /**
1284
- * Splits a long word into chunks that fit within the specified width.
1285
- * Used when a single word is too long to fit on one line.
1286
- */
1287
729
  chunkWord(word, width) {
1288
- if (width <= 0 || !word) {
730
+ if (width <= 0 || !word)
1289
731
  return word ? [word] : [''];
1290
- }
1291
732
  const chunks = [];
1292
733
  for (let i = 0; i < word.length; i += width) {
1293
734
  chunks.push(word.slice(i, i + width));
1294
735
  }
1295
736
  return chunks.length > 0 ? chunks : [''];
1296
737
  }
1297
- /**
1298
- * Returns the visible length of a string, excluding ANSI escape codes.
1299
- */
1300
738
  visibleLength(value) {
1301
- if (!value) {
739
+ if (!value)
1302
740
  return 0;
1303
- }
1304
741
  return this.stripAnsi(value).length;
1305
742
  }
1306
- /**
1307
- * Removes ANSI escape codes from a string to get the visible text.
1308
- * Uses the standard ANSI escape sequence pattern.
1309
- */
1310
743
  stripAnsi(value) {
1311
- if (!value) {
744
+ if (!value)
1312
745
  return '';
1313
- }
1314
746
  return value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '');
1315
747
  }
1316
748
  }