erosolar-cli 1.7.428 → 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 +6 -1
  20. package/dist/shell/interactiveShell.d.ts.map +1 -1
  21. package/dist/shell/interactiveShell.js +129 -92
  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 -33
  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 +19 -12
  52. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  53. package/dist/ui/ShellUIAdapter.js +73 -56
  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 -926
  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 -10
  68. package/dist/ui/streamingFormatter.d.ts.map +1 -1
  69. package/dist/ui/streamingFormatter.js +9 -59
  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,244 +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
- const wrapped = this.applySingleBulletBlock(body);
659
- this.withOutput(() => {
660
- this.writeLine(); // Ensure clean start for the box
661
- this.writeLine(wrapped);
662
- this.writeLine();
663
- });
197
+ const wrapped = isThought ? this.applySingleBulletBlock(body) : body;
198
+ this.enqueueEvent('raw', `\n${wrapped}\n\n`);
664
199
  }
200
+ /**
201
+ * Show narrative (thought)
202
+ */
665
203
  showNarrative(content) {
666
- if (!content.trim()) {
204
+ if (!content.trim())
667
205
  return;
668
- }
669
206
  this.showAssistantMessage(content, { isFinal: false });
670
207
  }
208
+ /**
209
+ * Show action
210
+ */
671
211
  showAction(text, status = 'info') {
672
- if (!text.trim()) {
212
+ if (!text.trim())
673
213
  return;
674
- }
675
- this.clearSpinnerIfActive();
676
- // Claude Code style: always use ⏺ prefix for actions
677
214
  const icon = this.formatActionIcon(status);
678
- this.withOutput(() => {
679
- this.writeLine(this.wrapWithPrefix(text, `${icon} `));
680
- });
215
+ const rendered = this.wrapWithPrefix(text, `${icon} `);
216
+ this.enqueueEvent('raw', `${rendered}\n`);
681
217
  }
218
+ /**
219
+ * Show sub-action
220
+ */
682
221
  showSubAction(text, status = 'info') {
683
- if (!text.trim()) {
222
+ if (!text.trim())
684
223
  return;
685
- }
686
- this.clearSpinnerIfActive();
687
- const prefersRich = text.includes('```');
688
- let rendered = prefersRich ? this.buildRichSubActionLines(text, status) : this.buildWrappedSubActionLines(text, status);
689
- if (!rendered.length && prefersRich) {
690
- rendered = this.buildWrappedSubActionLines(text, status);
691
- }
692
- if (!rendered.length) {
224
+ const lines = this.buildWrappedSubActionLines(text, status);
225
+ if (!lines.length)
693
226
  return;
694
- }
695
- this.withOutput(() => {
696
- this.writeLine(rendered.join('\n'));
697
- this.writeLine();
698
- });
699
- }
700
- buildWrappedSubActionLines(text, status) {
701
- const lines = text.split('\n').map((line) => line.trimEnd());
702
- while (lines.length && !lines[lines.length - 1]?.trim()) {
703
- lines.pop();
704
- }
705
- if (!lines.length) {
706
- return [];
707
- }
708
- const rendered = [];
709
- for (let index = 0; index < lines.length; index += 1) {
710
- const segment = lines[index] ?? '';
711
- const isLast = index === lines.length - 1;
712
- const { prefix, continuation } = this.buildSubActionPrefixes(status, isLast);
713
- rendered.push(this.wrapWithPrefix(segment, prefix, { continuationPrefix: continuation }));
714
- }
715
- return rendered;
716
- }
717
- buildRichSubActionLines(text, status) {
718
- const normalized = text.trim();
719
- if (!normalized) {
720
- return [];
721
- }
722
- const width = Math.max(DISPLAY_CONSTANTS.MIN_ACTION_WIDTH, Math.min(this.getColumnWidth(), DISPLAY_CONSTANTS.MAX_ACTION_WIDTH));
723
- const samplePrefix = this.buildSubActionPrefixes(status, true).prefix;
724
- const contentWidth = Math.max(DISPLAY_CONSTANTS.MIN_CONTENT_WIDTH, width - this.visibleLength(samplePrefix));
725
- const blocks = formatRichContent(normalized, contentWidth);
726
- if (!blocks.length) {
727
- return [];
728
- }
729
- return blocks.map((line, index) => {
730
- const isLast = index === blocks.length - 1;
731
- const { prefix } = this.buildSubActionPrefixes(status, isLast);
732
- if (!line.trim()) {
733
- return prefix.trimEnd();
734
- }
735
- return `${prefix}${line}`;
736
- });
227
+ this.enqueueEvent('raw', `${lines.join('\n')}\n\n`);
737
228
  }
229
+ /**
230
+ * Show message
231
+ */
738
232
  showMessage(content, role = 'assistant') {
739
233
  if (role === 'system') {
740
234
  this.showSystemMessage(content);
@@ -743,16 +237,19 @@ export class Display {
743
237
  this.showAssistantMessage(content);
744
238
  }
745
239
  }
240
+ /**
241
+ * Show system message
242
+ */
746
243
  showSystemMessage(content) {
747
- this.clearSpinnerIfActive();
748
244
  const normalized = content.trim();
749
- if (!normalized) {
245
+ if (!normalized)
750
246
  return;
751
- }
752
247
  this.stream(`${normalized}\n\n`);
753
248
  }
249
+ /**
250
+ * Show error
251
+ */
754
252
  showError(message, error) {
755
- this.clearSpinnerIfActive();
756
253
  const details = this.formatErrorDetails(error);
757
254
  const parts = [`${theme.error('✗')} ${message}`];
758
255
  if (details) {
@@ -760,23 +257,26 @@ export class Display {
760
257
  }
761
258
  this.stream(`${parts.join('\n')}\n`);
762
259
  }
260
+ /**
261
+ * Show warning
262
+ */
763
263
  showWarning(message) {
764
- this.clearSpinnerIfActive();
765
264
  this.stream(`${theme.warning('!')} ${message}\n`);
766
265
  }
266
+ /**
267
+ * Show info
268
+ */
767
269
  showInfo(message) {
768
- this.clearSpinnerIfActive();
769
270
  this.stream(`${theme.info('ℹ')} ${message}\n`);
770
271
  }
771
272
  /**
772
- * Show a success message with simple styling
273
+ * Show success
773
274
  */
774
275
  showSuccess(message) {
775
- this.clearSpinnerIfActive();
776
276
  this.stream(`${theme.success('✓')} ${message}\n`);
777
277
  }
778
278
  /**
779
- * Show a stylish progress indicator for long operations
279
+ * Show progress badge
780
280
  */
781
281
  showProgressBadge(label, current, total) {
782
282
  const percentage = Math.round((current / total) * 100);
@@ -785,66 +285,133 @@ export class Display {
785
285
  const empty = barWidth - filled;
786
286
  const progressBar = `${'█'.repeat(filled)}${'░'.repeat(empty)}`;
787
287
  const badge = `[${label}] ${progressBar} ${percentage}%`;
788
- this.withOutput(() => {
789
- this.write(`\r${theme.info(badge)}`);
790
- if (current >= total) {
791
- this.writeLine();
792
- }
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,
793
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
794
358
  }
795
- showStatusLine(status, elapsedMs, _context) {
796
- this.clearSpinnerIfActive();
797
- const normalized = status?.trim();
798
- if (!normalized) {
799
- return;
800
- }
801
- const elapsed = this.formatElapsed(elapsedMs);
802
- const parts = [
803
- { text: `${theme.success('✓')} ${normalized}`, tone: 'success' },
804
- elapsed ? { text: `(${elapsed})`, tone: 'muted' } : null,
805
- ].filter(Boolean);
806
- const line = renderStatusLine(parts, this.getColumnWidth() - 2);
807
- this.withOutput(() => {
808
- this.writeLine(line);
809
- });
359
+ /**
360
+ * Update streaming status (routes to renderer)
361
+ */
362
+ updateStreamingStatus(status) {
363
+ this.renderer?.updateStatus(status);
810
364
  }
811
- showAvailableTools(_tools) {
812
- // Hidden by default to match Claude Code style
813
- // Tools are available but not listed verbosely on startup
814
- // Parameter prefixed with underscore to indicate intentionally unused
365
+ /**
366
+ * Clear streaming status
367
+ */
368
+ clearStreamingStatus() {
369
+ this.renderer?.updateStatus(null);
815
370
  }
816
371
  /**
817
- * Show a compact launch panel of slash commands with wrapped descriptions.
372
+ * Check if streaming status is visible
818
373
  */
819
- showCommandPalette(commands, options) {
820
- if (!commands || commands.length === 0) {
821
- return;
822
- }
823
- const panel = this.buildCommandPalette(commands, options);
824
- if (!panel.trim()) {
825
- return;
826
- }
827
- this.withOutput(() => {
828
- this.writeLine();
829
- this.writeLine(panel);
830
- this.writeLine();
831
- });
374
+ isStreamingStatusVisible() {
375
+ return false; // Renderer manages this
832
376
  }
833
377
  /**
834
- * Show ready message with keyboard shortcuts hint (compact)
835
- * Note: Commands are now shown in the banner, so this is a no-op
378
+ * Legacy compatibility methods (no-ops)
836
379
  */
837
- showReadyWithHints() {
838
- // 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();
839
408
  }
840
409
  formatErrorDetails(error) {
841
- if (!error) {
410
+ if (!error)
842
411
  return null;
843
- }
844
412
  if (error instanceof Error) {
845
- if (error.stack) {
413
+ if (error.stack)
846
414
  return highlightError(error.stack);
847
- }
848
415
  return highlightError(error.message);
849
416
  }
850
417
  if (typeof error === 'string') {
@@ -857,83 +424,22 @@ export class Display {
857
424
  return null;
858
425
  }
859
426
  }
860
- showPlanningStep(step, index, total) {
861
- // Validate inputs
862
- if (!step?.trim()) {
863
- return;
864
- }
865
- if (index < 1 || total < 1 || index > total) {
866
- return;
867
- }
868
- const width = Math.max(DISPLAY_CONSTANTS.MIN_THOUGHT_WIDTH, Math.min(this.getColumnWidth(), DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
869
- const heading = renderSectionHeading(`Plan ${index}/${total}`, {
870
- subtitle: step,
871
- icon: icons.arrow,
872
- tone: 'info',
873
- width,
874
- });
875
- this.withOutput(() => {
876
- this.writeLine(heading);
877
- });
878
- }
879
- clear() {
880
- this.withOutput(() => {
881
- try {
882
- cursorTo(this.outputStream, 0, 0);
883
- clearScreenDown(this.outputStream);
884
- }
885
- catch {
886
- this.write('\x1Bc');
887
- }
888
- });
889
- this.stdoutTracker.reset();
890
- // Banner is streamed content - no re-render on clear
891
- }
892
- formatModelLabel(model) {
893
- if (/gpt-5\.1-?codex/i.test(model)) {
894
- return model;
895
- }
896
- if (/sonnet-4[-.]?5/i.test(model)) {
897
- return 'Sonnet 4.5';
898
- }
899
- if (/opus-4[-.]?1/i.test(model)) {
900
- return 'Opus 4.1';
901
- }
902
- if (/haiku-4[-.]?5/i.test(model)) {
903
- return 'Haiku 4.5';
904
- }
905
- if (/gpt-5\.1/i.test(model)) {
906
- return 'GPT-5.1';
907
- }
908
- if (/gpt-5-?pro/i.test(model)) {
909
- return 'GPT-5 Pro';
910
- }
911
- if (/gpt-5-?mini/i.test(model)) {
912
- return 'GPT-5 Mini';
913
- }
914
- if (/gpt-5-?nano/i.test(model)) {
915
- return 'GPT-5 Nano';
916
- }
917
- return model;
918
- }
919
- compactPath(path, maxLen) {
920
- if (!path) {
921
- return '';
922
- }
923
- if (this.visibleLength(path) <= maxLen) {
924
- return path;
427
+ formatElapsed(elapsedMs) {
428
+ if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs) || elapsedMs < 0) {
429
+ return null;
925
430
  }
926
- const parts = path.split('/').filter(Boolean);
927
- if (parts.length <= 2) {
928
- 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`;
929
436
  }
930
- return `${parts[0]}/.../${parts[parts.length - 1]}`;
437
+ return `${seconds}s`;
931
438
  }
932
439
  buildChatBox(content, metadata) {
933
440
  const normalized = content.trim();
934
- if (!normalized) {
441
+ if (!normalized)
935
442
  return '';
936
- }
937
443
  if (isPlainOutputMode()) {
938
444
  const body = renderMessageBody(normalized, this.resolveMessageWidth());
939
445
  const telemetry = this.formatTelemetryLine(metadata);
@@ -948,19 +454,15 @@ export class Display {
948
454
  borderColor: theme.ui.border,
949
455
  });
950
456
  const telemetry = this.formatTelemetryLine(metadata);
951
- if (!telemetry) {
952
- return panel;
953
- }
954
- return `${panel}\n${telemetry}`;
457
+ return telemetry ? `${panel}\n${telemetry}` : panel;
955
458
  }
956
459
  resolveMessageWidth() {
957
460
  const columns = this.getColumnWidth();
958
461
  return Math.max(DISPLAY_CONSTANTS.MIN_MESSAGE_WIDTH, Math.min(columns - DISPLAY_CONSTANTS.MESSAGE_PADDING, DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
959
462
  }
960
463
  formatTelemetryLine(metadata) {
961
- if (!metadata) {
464
+ if (!metadata)
962
465
  return '';
963
- }
964
466
  const parts = [];
965
467
  const elapsed = this.formatElapsed(metadata.elapsedMs);
966
468
  if (elapsed) {
@@ -968,28 +470,67 @@ export class Display {
968
470
  const elapsedValue = theme.metrics?.elapsedValue ?? theme.secondary;
969
471
  parts.push(`${elapsedLabel('elapsed')} ${elapsedValue(elapsed)}`);
970
472
  }
971
- if (!parts.length) {
473
+ if (!parts.length)
972
474
  return '';
973
- }
974
475
  const separator = theme.ui.muted(' • ');
975
476
  return ` ${parts.join(separator)}`;
976
477
  }
977
- formatElapsed(elapsedMs) {
978
- if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs) || elapsedMs < 0) {
979
- 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}`);
980
491
  }
981
- const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
982
- const minutes = Math.floor(totalSeconds / 60);
983
- const seconds = totalSeconds % 60;
984
- if (minutes > 0) {
985
- return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
492
+ else {
493
+ lines.push(`${theme.info('✻')} ${thinkingStyle.label('Thinking…')}`);
986
494
  }
987
- 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');
988
530
  }
989
531
  buildCommandPalette(commands, options) {
990
- if (!commands.length) {
532
+ if (!commands.length)
991
533
  return '';
992
- }
993
534
  const width = Math.max(DISPLAY_CONSTANTS.MIN_MESSAGE_WIDTH, Math.min(this.getColumnWidth() - 2, DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
994
535
  const indent = ' ';
995
536
  const grouped = this.groupPaletteCommands(commands);
@@ -1048,39 +589,25 @@ export class Display {
1048
589
  return Math.min(maxAllowed, budget);
1049
590
  }
1050
591
  formatPaletteCategory(category) {
1051
- if (!category) {
592
+ if (!category)
1052
593
  return 'Other';
1053
- }
1054
594
  switch (category.toLowerCase()) {
1055
- case 'configuration':
1056
- return 'Configuration';
1057
- case 'workspace':
1058
- return 'Workspace';
1059
- case 'diagnostics':
1060
- return 'Diagnostics';
1061
- case 'other':
1062
- return 'Other';
1063
- default:
1064
- 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);
1065
600
  }
1066
601
  }
1067
602
  colorizePaletteText(text, tone) {
1068
603
  switch (tone) {
1069
- case 'warn':
1070
- return theme.warning(text);
1071
- case 'success':
1072
- return theme.success(text);
1073
- case 'info':
1074
- 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);
1075
607
  case 'muted':
1076
- default:
1077
- return theme.ui.muted(text);
608
+ default: return theme.ui.muted(text);
1078
609
  }
1079
610
  }
1080
- /**
1081
- * Wraps text with a prefix on the first line and optional continuation prefix.
1082
- * Handles multi-line text and word wrapping intelligently.
1083
- */
1084
611
  wrapWithPrefix(text, prefix, options) {
1085
612
  if (!text) {
1086
613
  return prefix.trimEnd();
@@ -1113,93 +640,25 @@ export class Display {
1113
640
  }
1114
641
  return lines.join('\n');
1115
642
  }
1116
- resolveStatusColor(status) {
1117
- switch (status) {
1118
- case 'success':
1119
- return theme.success;
1120
- case 'error':
1121
- return theme.error;
1122
- case 'warning':
1123
- return theme.warning;
1124
- case 'pending':
1125
- return theme.info;
1126
- default:
1127
- return theme.secondary;
1128
- }
1129
- }
1130
- formatActionIcon(status) {
1131
- const colorize = this.resolveStatusColor(status);
1132
- return colorize(`${icons.action}`);
1133
- }
1134
- /**
1135
- * Prefix a multi-line block with a single bullet, indenting subsequent lines.
1136
- * Keeps entire assistant responses as one visual event.
1137
- */
1138
- applySingleBulletBlock(text) {
1139
- const lines = text.split('\n');
1140
- const bullet = `${icons.action} `;
1141
- const prefix = theme.info(bullet);
1142
- const indent = ' '.repeat(this.visibleLength(this.stripAnsi(bullet)));
1143
- return lines
1144
- .map((line, index) => (index === 0 ? `${prefix}${line}` : `${indent}${line}`))
1145
- .join('\n');
1146
- }
1147
- buildClaudeStyleThought(content, durationMs) {
1148
- // Claude Code style: ∴ Thought for Xs or ✻ Thinking…
1149
- const thinkingStyle = theme.thinking || {
1150
- icon: theme.info,
1151
- text: theme.ui.muted,
1152
- border: theme.ui.border,
1153
- label: theme.info,
1154
- };
1155
- const width = Math.min(this.getColumnWidth() - 4, 70);
1156
- const lines = [];
1157
- // Header: "∴ Thought for Xs" for completed, "✻ Thinking…" for active
1158
- if (durationMs !== undefined) {
1159
- const elapsed = this.formatElapsedTime(Math.floor(durationMs / 1000));
1160
- lines.push(`${theme.info('∴')} Thought for ${elapsed}`);
1161
- }
1162
- else {
1163
- lines.push(`${theme.info('✻')} ${thinkingStyle.label('Thinking…')}`);
1164
- }
1165
- // Parse and format the thinking content with simple indentation
1166
- const contentLines = content.split('\n');
1167
- const hasContent = contentLines.some(line => line.trim().length > 0);
1168
- if (hasContent) {
1169
- 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();
1170
647
  }
1171
- for (const line of contentLines) {
1172
- const trimmed = line.replace(/\s+$/, '');
1173
- if (!trimmed.trim()) {
1174
- lines.push(''); // Preserve intentional blank lines inside the block
1175
- continue;
1176
- }
1177
- const wrapped = this.wrapLine(trimmed, width - 4);
1178
- for (const wrappedLine of wrapped) {
1179
- lines.push(` ${thinkingStyle.text(wrappedLine)}`);
1180
- }
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 }));
1181
656
  }
1182
- return lines.join('\n');
1183
- }
1184
- /**
1185
- * Show a thinking block with rich formatting (public method for external use)
1186
- * @param content The thinking content to display
1187
- * @param durationMs Optional duration in milliseconds to show "Thought for Xs"
1188
- */
1189
- showThinkingBlock(content, durationMs) {
1190
- this.clearSpinnerIfActive();
1191
- const block = this.buildClaudeStyleThought(content, durationMs);
1192
- this.withOutput(() => {
1193
- this.writeLine();
1194
- this.writeLine(block);
1195
- this.writeLine();
1196
- this.writeLine(); // Extra newline for better visual separation
1197
- });
657
+ return rendered;
1198
658
  }
1199
659
  buildSubActionPrefixes(status, isLast) {
1200
660
  if (isLast) {
1201
661
  const colorize = this.resolveStatusColor(status);
1202
- // Claude Code style: use ⎿ for sub-action result/detail prefix
1203
662
  return {
1204
663
  prefix: ` ${colorize(icons.subaction)} `,
1205
664
  continuation: ' ',
@@ -1211,40 +670,55 @@ export class Display {
1211
670
  continuation: ` ${branch} `,
1212
671
  };
1213
672
  }
1214
- /**
1215
- * Wraps a single line of text to fit within the specified width.
1216
- * Intelligently handles word breaking and preserves spaces.
1217
- */
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
+ }
1218
686
  wrapLine(text, width) {
1219
- // Handle edge cases
1220
- if (width <= 0) {
687
+ if (width <= 0)
1221
688
  return [text];
1222
- }
1223
- if (!text) {
689
+ if (!text)
1224
690
  return [''];
1225
- }
1226
- if (text.length <= width) {
691
+ if (text.length <= width)
1227
692
  return [text];
1228
- }
1229
693
  const words = text.split(/\s+/).filter(Boolean);
1230
- // If no words, chunk the entire text
1231
- if (!words.length) {
694
+ if (!words.length)
1232
695
  return this.chunkWord(text, width);
1233
- }
1234
696
  const lines = [];
1235
697
  let current = '';
1236
698
  for (const word of words) {
1237
- const appendResult = this.tryAppendWord(current, word, width);
1238
- if (appendResult.shouldFlush) {
1239
- 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
+ }
1240
708
  }
1241
- if (appendResult.chunks.length > 0) {
1242
- // Word was too long and was chunked
1243
- lines.push(...appendResult.chunks.slice(0, -1));
1244
- current = appendResult.chunks[appendResult.chunks.length - 1] ?? '';
709
+ else if (current.length + 1 + word.length <= width) {
710
+ current = `${current} ${word}`;
1245
711
  }
1246
712
  else {
1247
- 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
+ }
1248
722
  }
1249
723
  }
1250
724
  if (current) {
@@ -1252,64 +726,23 @@ export class Display {
1252
726
  }
1253
727
  return lines.length ? lines : [''];
1254
728
  }
1255
- /**
1256
- * Attempts to append a word to the current line.
1257
- * Returns instructions on how to handle the word.
1258
- */
1259
- tryAppendWord(current, word, width) {
1260
- if (!word) {
1261
- return { shouldFlush: false, newCurrent: current, chunks: [] };
1262
- }
1263
- // Empty current line - start new line with word
1264
- if (!current) {
1265
- if (word.length <= width) {
1266
- return { shouldFlush: false, newCurrent: word, chunks: [] };
1267
- }
1268
- // Word too long, need to chunk it
1269
- return { shouldFlush: false, newCurrent: '', chunks: this.chunkWord(word, width) };
1270
- }
1271
- // Word fits on current line with space
1272
- if (current.length + 1 + word.length <= width) {
1273
- return { shouldFlush: false, newCurrent: `${current} ${word}`, chunks: [] };
1274
- }
1275
- // Word doesn't fit - flush current and start new line
1276
- if (word.length <= width) {
1277
- return { shouldFlush: true, newCurrent: word, chunks: [] };
1278
- }
1279
- // Word doesn't fit and is too long - flush current and chunk word
1280
- return { shouldFlush: true, newCurrent: '', chunks: this.chunkWord(word, width) };
1281
- }
1282
- /**
1283
- * Splits a long word into chunks that fit within the specified width.
1284
- * Used when a single word is too long to fit on one line.
1285
- */
1286
729
  chunkWord(word, width) {
1287
- if (width <= 0 || !word) {
730
+ if (width <= 0 || !word)
1288
731
  return word ? [word] : [''];
1289
- }
1290
732
  const chunks = [];
1291
733
  for (let i = 0; i < word.length; i += width) {
1292
734
  chunks.push(word.slice(i, i + width));
1293
735
  }
1294
736
  return chunks.length > 0 ? chunks : [''];
1295
737
  }
1296
- /**
1297
- * Returns the visible length of a string, excluding ANSI escape codes.
1298
- */
1299
738
  visibleLength(value) {
1300
- if (!value) {
739
+ if (!value)
1301
740
  return 0;
1302
- }
1303
741
  return this.stripAnsi(value).length;
1304
742
  }
1305
- /**
1306
- * Removes ANSI escape codes from a string to get the visible text.
1307
- * Uses the standard ANSI escape sequence pattern.
1308
- */
1309
743
  stripAnsi(value) {
1310
- if (!value) {
744
+ if (!value)
1311
745
  return '';
1312
- }
1313
746
  return value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '');
1314
747
  }
1315
748
  }