erosolar-cli 1.7.429 → 1.7.431

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 (87) 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/preferences.d.ts +3 -1
  18. package/dist/core/preferences.d.ts.map +1 -1
  19. package/dist/core/preferences.js +4 -2
  20. package/dist/core/preferences.js.map +1 -1
  21. package/dist/core/validationRunner.d.ts +1 -1
  22. package/dist/core/validationRunner.js +1 -1
  23. package/dist/shell/interactiveShell.d.ts +5 -1
  24. package/dist/shell/interactiveShell.d.ts.map +1 -1
  25. package/dist/shell/interactiveShell.js +120 -88
  26. package/dist/shell/interactiveShell.js.map +1 -1
  27. package/dist/shell/systemPrompt.d.ts.map +1 -1
  28. package/dist/shell/systemPrompt.js +14 -34
  29. package/dist/shell/systemPrompt.js.map +1 -1
  30. package/dist/shell/terminalInputAdapter.d.ts +77 -85
  31. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  32. package/dist/shell/terminalInputAdapter.js +163 -223
  33. package/dist/shell/terminalInputAdapter.js.map +1 -1
  34. package/dist/shell/vimMode.d.ts +1 -1
  35. package/dist/shell/vimMode.js +1 -1
  36. package/dist/tools/buildTools.d.ts +1 -1
  37. package/dist/tools/buildTools.js +1 -1
  38. package/dist/tools/diffUtils.d.ts +2 -2
  39. package/dist/tools/diffUtils.js +2 -2
  40. package/dist/tools/editTools.js +4 -4
  41. package/dist/tools/editTools.js.map +1 -1
  42. package/dist/tools/localExplore.d.ts +3 -3
  43. package/dist/tools/localExplore.js +3 -3
  44. package/dist/tools/skillTools.js +2 -2
  45. package/dist/tools/skillTools.js.map +1 -1
  46. package/dist/tools/validationTools.js +1 -1
  47. package/dist/ui/DisplayEventQueue.d.ts +99 -0
  48. package/dist/ui/DisplayEventQueue.d.ts.map +1 -0
  49. package/dist/ui/DisplayEventQueue.js +167 -0
  50. package/dist/ui/DisplayEventQueue.js.map +1 -0
  51. package/dist/ui/SequentialRenderer.d.ts +69 -0
  52. package/dist/ui/SequentialRenderer.d.ts.map +1 -0
  53. package/dist/ui/SequentialRenderer.js +137 -0
  54. package/dist/ui/SequentialRenderer.js.map +1 -0
  55. package/dist/ui/ShellUIAdapter.d.ts +18 -6
  56. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  57. package/dist/ui/ShellUIAdapter.js +65 -14
  58. package/dist/ui/ShellUIAdapter.js.map +1 -1
  59. package/dist/ui/UnifiedUIRenderer.d.ts +188 -0
  60. package/dist/ui/UnifiedUIRenderer.d.ts.map +1 -0
  61. package/dist/ui/UnifiedUIRenderer.js +581 -0
  62. package/dist/ui/UnifiedUIRenderer.js.map +1 -0
  63. package/dist/ui/display.d.ts +100 -173
  64. package/dist/ui/display.d.ts.map +1 -1
  65. package/dist/ui/display.js +364 -926
  66. package/dist/ui/display.js.map +1 -1
  67. package/dist/ui/errorFormatter.d.ts +1 -1
  68. package/dist/ui/errorFormatter.js +1 -1
  69. package/dist/ui/shortcutsHelp.d.ts +6 -6
  70. package/dist/ui/shortcutsHelp.js +6 -6
  71. package/dist/ui/streamingFormatter.d.ts +2 -5
  72. package/dist/ui/streamingFormatter.d.ts.map +1 -1
  73. package/dist/ui/streamingFormatter.js +9 -33
  74. package/dist/ui/streamingFormatter.js.map +1 -1
  75. package/dist/ui/textHighlighter.d.ts +8 -8
  76. package/dist/ui/textHighlighter.js +9 -9
  77. package/dist/ui/textHighlighter.js.map +1 -1
  78. package/dist/ui/theme.d.ts +2 -2
  79. package/dist/ui/theme.js +4 -4
  80. package/dist/ui/theme.js.map +1 -1
  81. package/dist/ui/toolDisplay.d.ts +8 -8
  82. package/dist/ui/toolDisplay.js +8 -8
  83. package/package.json +1 -1
  84. package/dist/shell/terminalInput.d.ts +0 -619
  85. package/dist/shell/terminalInput.d.ts.map +0 -1
  86. package/dist/shell/terminalInput.js +0 -2699
  87. 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.
29
+ * Display class - now a thin wrapper around UnifiedUIRenderer
165
30
  *
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
171
- *
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) {
306
- return;
307
- }
308
- const normalized = this.normalizeStreamingContent(content);
309
- if (!normalized) {
101
+ if (!content)
310
102
  return;
311
- }
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');
103
+ this.notifyBeforeOutput();
104
+ if (this.enqueueEvent('raw', content)) {
105
+ this.notifyAfterOutput(content);
327
106
  return;
328
107
  }
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).
408
- */
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.
140
+ * Show thinking indicator
415
141
  */
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,76 @@ 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;
197
+ const wrapped = isThought ? this.applySingleBulletBlock(body) : body;
198
+ const output = `\n${wrapped}\n\n`;
199
+ this.notifyBeforeOutput();
200
+ if (!this.enqueueEvent('raw', output)) {
201
+ // Fallback if no renderer
202
+ this.outputStream.write(output);
657
203
  }
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
- });
204
+ this.notifyAfterOutput(output);
665
205
  }
206
+ /**
207
+ * Show narrative (thought)
208
+ */
666
209
  showNarrative(content) {
667
- if (!content.trim()) {
210
+ if (!content.trim())
668
211
  return;
669
- }
670
212
  this.showAssistantMessage(content, { isFinal: false });
671
213
  }
214
+ /**
215
+ * Show action
216
+ */
672
217
  showAction(text, status = 'info') {
673
- if (!text.trim()) {
218
+ if (!text.trim())
674
219
  return;
675
- }
676
- this.clearSpinnerIfActive();
677
- // Claude Code style: always use ⏺ prefix for actions
678
220
  const icon = this.formatActionIcon(status);
679
- this.withOutput(() => {
680
- this.writeLine(this.wrapWithPrefix(text, `${icon} `));
681
- });
221
+ const rendered = this.wrapWithPrefix(text, `${icon} `);
222
+ this.enqueueEvent('raw', `${rendered}\n`);
682
223
  }
224
+ /**
225
+ * Show sub-action
226
+ */
683
227
  showSubAction(text, status = 'info') {
684
- if (!text.trim()) {
228
+ if (!text.trim())
685
229
  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) {
230
+ const lines = this.buildWrappedSubActionLines(text, status);
231
+ if (!lines.length)
694
232
  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
- });
233
+ this.enqueueEvent('raw', `${lines.join('\n')}\n\n`);
738
234
  }
235
+ /**
236
+ * Show message
237
+ */
739
238
  showMessage(content, role = 'assistant') {
740
239
  if (role === 'system') {
741
240
  this.showSystemMessage(content);
@@ -744,16 +243,19 @@ export class Display {
744
243
  this.showAssistantMessage(content);
745
244
  }
746
245
  }
246
+ /**
247
+ * Show system message
248
+ */
747
249
  showSystemMessage(content) {
748
- this.clearSpinnerIfActive();
749
250
  const normalized = content.trim();
750
- if (!normalized) {
251
+ if (!normalized)
751
252
  return;
752
- }
753
253
  this.stream(`${normalized}\n\n`);
754
254
  }
255
+ /**
256
+ * Show error
257
+ */
755
258
  showError(message, error) {
756
- this.clearSpinnerIfActive();
757
259
  const details = this.formatErrorDetails(error);
758
260
  const parts = [`${theme.error('✗')} ${message}`];
759
261
  if (details) {
@@ -761,23 +263,26 @@ export class Display {
761
263
  }
762
264
  this.stream(`${parts.join('\n')}\n`);
763
265
  }
266
+ /**
267
+ * Show warning
268
+ */
764
269
  showWarning(message) {
765
- this.clearSpinnerIfActive();
766
270
  this.stream(`${theme.warning('!')} ${message}\n`);
767
271
  }
272
+ /**
273
+ * Show info
274
+ */
768
275
  showInfo(message) {
769
- this.clearSpinnerIfActive();
770
276
  this.stream(`${theme.info('ℹ')} ${message}\n`);
771
277
  }
772
278
  /**
773
- * Show a success message with simple styling
279
+ * Show success
774
280
  */
775
281
  showSuccess(message) {
776
- this.clearSpinnerIfActive();
777
282
  this.stream(`${theme.success('✓')} ${message}\n`);
778
283
  }
779
284
  /**
780
- * Show a stylish progress indicator for long operations
285
+ * Show progress badge
781
286
  */
782
287
  showProgressBadge(label, current, total) {
783
288
  const percentage = Math.round((current / total) * 100);
@@ -786,66 +291,133 @@ export class Display {
786
291
  const empty = barWidth - filled;
787
292
  const progressBar = `${'█'.repeat(filled)}${'░'.repeat(empty)}`;
788
293
  const badge = `[${label}] ${progressBar} ${percentage}%`;
789
- this.withOutput(() => {
790
- this.write(`\r${theme.info(badge)}`);
791
- if (current >= total) {
792
- this.writeLine();
793
- }
294
+ this.stream(`\r${theme.info(badge)}`);
295
+ if (current >= total) {
296
+ this.stream('\n');
297
+ }
298
+ }
299
+ /**
300
+ * Show status line
301
+ */
302
+ showStatusLine(status, elapsedMs, _context) {
303
+ const normalized = status?.trim();
304
+ if (!normalized)
305
+ return;
306
+ const elapsed = this.formatElapsed(elapsedMs);
307
+ const parts = [];
308
+ parts.push(`${theme.success('✓')} ${normalized}`);
309
+ if (elapsed) {
310
+ parts.push(`(${elapsed})`);
311
+ }
312
+ this.stream(`${parts.join(' ')}\n`);
313
+ }
314
+ /**
315
+ * Show available tools (no-op)
316
+ */
317
+ showAvailableTools(_tools) {
318
+ // Hidden by default
319
+ }
320
+ /**
321
+ * Show command palette
322
+ */
323
+ showCommandPalette(commands, options) {
324
+ if (!commands || commands.length === 0)
325
+ return;
326
+ const panel = this.buildCommandPalette(commands, options);
327
+ if (!panel.trim())
328
+ return;
329
+ this.enqueueEvent('raw', `\n${panel}\n\n`);
330
+ }
331
+ /**
332
+ * Show ready with hints (no-op)
333
+ */
334
+ showReadyWithHints() {
335
+ // Commands hint is now in the renderer
336
+ }
337
+ /**
338
+ * Show planning step
339
+ */
340
+ showPlanningStep(step, index, total) {
341
+ if (!step?.trim() || index < 1 || total < 1 || index > total)
342
+ return;
343
+ const width = Math.max(DISPLAY_CONSTANTS.MIN_THOUGHT_WIDTH, Math.min(this.getColumnWidth(), DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
344
+ const heading = renderSectionHeading(`Plan ${index}/${total}`, {
345
+ subtitle: step,
346
+ icon: icons.arrow,
347
+ tone: 'info',
348
+ width,
794
349
  });
350
+ this.enqueueEvent('raw', `${heading}\n`);
351
+ }
352
+ /**
353
+ * Show thinking block
354
+ */
355
+ showThinkingBlock(content, durationMs) {
356
+ const block = this.buildClaudeStyleThought(content, durationMs);
357
+ this.enqueueEvent('raw', `\n${block}\n\n`);
358
+ }
359
+ /**
360
+ * Clear screen
361
+ */
362
+ clear() {
363
+ // Renderer handles this
795
364
  }
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
- });
365
+ /**
366
+ * Update streaming status (routes to renderer)
367
+ */
368
+ updateStreamingStatus(status) {
369
+ this.renderer?.updateStatus(status);
811
370
  }
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
371
+ /**
372
+ * Clear streaming status
373
+ */
374
+ clearStreamingStatus() {
375
+ this.renderer?.updateStatus(null);
816
376
  }
817
377
  /**
818
- * Show a compact launch panel of slash commands with wrapped descriptions.
378
+ * Check if streaming status is visible
819
379
  */
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
- });
380
+ isStreamingStatusVisible() {
381
+ return false; // Renderer manages this
833
382
  }
834
383
  /**
835
- * Show ready message with keyboard shortcuts hint (compact)
836
- * Note: Commands are now shown in the banner, so this is a no-op
384
+ * Legacy compatibility methods (no-ops)
837
385
  */
838
- showReadyWithHints() {
839
- // Commands hint is now included in the banner
386
+ getTotalWrittenLines() {
387
+ return 0;
388
+ }
389
+ getPinnedHeaderLines() {
390
+ return 0;
391
+ }
392
+ setupScrollRegion() {
393
+ // No-op - renderer handles layout
394
+ }
395
+ teardownScrollRegion() {
396
+ // No-op
397
+ }
398
+ isScrollRegionActive() {
399
+ return false;
400
+ }
401
+ parallelAgentStatus(content) {
402
+ if (!content)
403
+ return;
404
+ this.enqueueEvent('streaming', `${content}\n`);
405
+ }
406
+ // ==================== Private Helper Methods ====================
407
+ getColumnWidth() {
408
+ if (typeof this.outputStream.columns === 'number' &&
409
+ Number.isFinite(this.outputStream.columns) &&
410
+ this.outputStream.columns > 0) {
411
+ return this.outputStream.columns;
412
+ }
413
+ return getTerminalColumns();
840
414
  }
841
415
  formatErrorDetails(error) {
842
- if (!error) {
416
+ if (!error)
843
417
  return null;
844
- }
845
418
  if (error instanceof Error) {
846
- if (error.stack) {
419
+ if (error.stack)
847
420
  return highlightError(error.stack);
848
- }
849
421
  return highlightError(error.message);
850
422
  }
851
423
  if (typeof error === 'string') {
@@ -858,83 +430,22 @@ export class Display {
858
430
  return null;
859
431
  }
860
432
  }
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;
433
+ formatElapsed(elapsedMs) {
434
+ if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs) || elapsedMs < 0) {
435
+ return null;
926
436
  }
927
- const parts = path.split('/').filter(Boolean);
928
- if (parts.length <= 2) {
929
- return `${path.slice(0, Math.max(0, maxLen - 3))}...`;
437
+ const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
438
+ const minutes = Math.floor(totalSeconds / 60);
439
+ const seconds = totalSeconds % 60;
440
+ if (minutes > 0) {
441
+ return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
930
442
  }
931
- return `${parts[0]}/.../${parts[parts.length - 1]}`;
443
+ return `${seconds}s`;
932
444
  }
933
445
  buildChatBox(content, metadata) {
934
446
  const normalized = content.trim();
935
- if (!normalized) {
447
+ if (!normalized)
936
448
  return '';
937
- }
938
449
  if (isPlainOutputMode()) {
939
450
  const body = renderMessageBody(normalized, this.resolveMessageWidth());
940
451
  const telemetry = this.formatTelemetryLine(metadata);
@@ -949,19 +460,15 @@ export class Display {
949
460
  borderColor: theme.ui.border,
950
461
  });
951
462
  const telemetry = this.formatTelemetryLine(metadata);
952
- if (!telemetry) {
953
- return panel;
954
- }
955
- return `${panel}\n${telemetry}`;
463
+ return telemetry ? `${panel}\n${telemetry}` : panel;
956
464
  }
957
465
  resolveMessageWidth() {
958
466
  const columns = this.getColumnWidth();
959
467
  return Math.max(DISPLAY_CONSTANTS.MIN_MESSAGE_WIDTH, Math.min(columns - DISPLAY_CONSTANTS.MESSAGE_PADDING, DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
960
468
  }
961
469
  formatTelemetryLine(metadata) {
962
- if (!metadata) {
470
+ if (!metadata)
963
471
  return '';
964
- }
965
472
  const parts = [];
966
473
  const elapsed = this.formatElapsed(metadata.elapsedMs);
967
474
  if (elapsed) {
@@ -969,28 +476,67 @@ export class Display {
969
476
  const elapsedValue = theme.metrics?.elapsedValue ?? theme.secondary;
970
477
  parts.push(`${elapsedLabel('elapsed')} ${elapsedValue(elapsed)}`);
971
478
  }
972
- if (!parts.length) {
479
+ if (!parts.length)
973
480
  return '';
974
- }
975
481
  const separator = theme.ui.muted(' • ');
976
482
  return ` ${parts.join(separator)}`;
977
483
  }
978
- formatElapsed(elapsedMs) {
979
- if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs) || elapsedMs < 0) {
980
- return null;
484
+ buildClaudeStyleThought(content, durationMs) {
485
+ const thinkingStyle = theme.thinking || {
486
+ icon: theme.info,
487
+ text: theme.ui.muted,
488
+ border: theme.ui.border,
489
+ label: theme.info,
490
+ };
491
+ const width = Math.min(this.getColumnWidth() - 4, 70);
492
+ const lines = [];
493
+ // Header
494
+ if (durationMs !== undefined) {
495
+ const elapsed = this.formatElapsedTime(Math.floor(durationMs / 1000));
496
+ lines.push(`${theme.info('∴')} Thought for ${elapsed}`);
981
497
  }
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`;
498
+ else {
499
+ lines.push(`${theme.info('✻')} ${thinkingStyle.label('Thinking…')}`);
987
500
  }
988
- return `${seconds}s`;
501
+ // Content
502
+ const contentLines = content.split('\n');
503
+ const hasContent = contentLines.some(line => line.trim().length > 0);
504
+ if (hasContent) {
505
+ lines.push('');
506
+ }
507
+ for (const line of contentLines) {
508
+ const trimmed = line.replace(/\s+$/, '');
509
+ if (!trimmed.trim()) {
510
+ lines.push('');
511
+ continue;
512
+ }
513
+ const wrapped = this.wrapLine(trimmed, width - 4);
514
+ for (const wrappedLine of wrapped) {
515
+ lines.push(` ${thinkingStyle.text(wrappedLine)}`);
516
+ }
517
+ }
518
+ return lines.join('\n');
519
+ }
520
+ formatElapsedTime(seconds) {
521
+ if (seconds < 60) {
522
+ return `${seconds}s`;
523
+ }
524
+ const mins = Math.floor(seconds / 60);
525
+ const secs = seconds % 60;
526
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
527
+ }
528
+ applySingleBulletBlock(text) {
529
+ const lines = text.split('\n');
530
+ const bullet = `${icons.action} `;
531
+ const prefix = theme.info(bullet);
532
+ const indent = ' '.repeat(this.visibleLength(this.stripAnsi(bullet)));
533
+ return lines
534
+ .map((line, index) => (index === 0 ? `${prefix}${line}` : `${indent}${line}`))
535
+ .join('\n');
989
536
  }
990
537
  buildCommandPalette(commands, options) {
991
- if (!commands.length) {
538
+ if (!commands.length)
992
539
  return '';
993
- }
994
540
  const width = Math.max(DISPLAY_CONSTANTS.MIN_MESSAGE_WIDTH, Math.min(this.getColumnWidth() - 2, DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
995
541
  const indent = ' ';
996
542
  const grouped = this.groupPaletteCommands(commands);
@@ -1049,39 +595,25 @@ export class Display {
1049
595
  return Math.min(maxAllowed, budget);
1050
596
  }
1051
597
  formatPaletteCategory(category) {
1052
- if (!category) {
598
+ if (!category)
1053
599
  return 'Other';
1054
- }
1055
600
  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);
601
+ case 'configuration': return 'Configuration';
602
+ case 'workspace': return 'Workspace';
603
+ case 'diagnostics': return 'Diagnostics';
604
+ case 'other': return 'Other';
605
+ default: return category[0]?.toUpperCase() + category.slice(1);
1066
606
  }
1067
607
  }
1068
608
  colorizePaletteText(text, tone) {
1069
609
  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);
610
+ case 'warn': return theme.warning(text);
611
+ case 'success': return theme.success(text);
612
+ case 'info': return theme.info(text);
1076
613
  case 'muted':
1077
- default:
1078
- return theme.ui.muted(text);
614
+ default: return theme.ui.muted(text);
1079
615
  }
1080
616
  }
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
617
  wrapWithPrefix(text, prefix, options) {
1086
618
  if (!text) {
1087
619
  return prefix.trimEnd();
@@ -1114,93 +646,25 @@ export class Display {
1114
646
  }
1115
647
  return lines.join('\n');
1116
648
  }
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
649
+ buildWrappedSubActionLines(text, status) {
650
+ const lines = text.split('\n').map((line) => line.trimEnd());
651
+ while (lines.length && !lines[lines.length - 1]?.trim()) {
652
+ lines.pop();
1171
653
  }
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
- }
654
+ if (!lines.length)
655
+ return [];
656
+ const rendered = [];
657
+ for (let index = 0; index < lines.length; index += 1) {
658
+ const segment = lines[index] ?? '';
659
+ const isLast = index === lines.length - 1;
660
+ const { prefix, continuation } = this.buildSubActionPrefixes(status, isLast);
661
+ rendered.push(this.wrapWithPrefix(segment, prefix, { continuationPrefix: continuation }));
1182
662
  }
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
- });
663
+ return rendered;
1199
664
  }
1200
665
  buildSubActionPrefixes(status, isLast) {
1201
666
  if (isLast) {
1202
667
  const colorize = this.resolveStatusColor(status);
1203
- // Claude Code style: use ⎿ for sub-action result/detail prefix
1204
668
  return {
1205
669
  prefix: ` ${colorize(icons.subaction)} `,
1206
670
  continuation: ' ',
@@ -1212,40 +676,55 @@ export class Display {
1212
676
  continuation: ` ${branch} `,
1213
677
  };
1214
678
  }
1215
- /**
1216
- * Wraps a single line of text to fit within the specified width.
1217
- * Intelligently handles word breaking and preserves spaces.
1218
- */
679
+ resolveStatusColor(status) {
680
+ switch (status) {
681
+ case 'success': return theme.success;
682
+ case 'error': return theme.error;
683
+ case 'warning': return theme.warning;
684
+ case 'pending': return theme.info;
685
+ default: return theme.secondary;
686
+ }
687
+ }
688
+ formatActionIcon(status) {
689
+ const colorize = this.resolveStatusColor(status);
690
+ return colorize(`${icons.action}`);
691
+ }
1219
692
  wrapLine(text, width) {
1220
- // Handle edge cases
1221
- if (width <= 0) {
693
+ if (width <= 0)
1222
694
  return [text];
1223
- }
1224
- if (!text) {
695
+ if (!text)
1225
696
  return [''];
1226
- }
1227
- if (text.length <= width) {
697
+ if (text.length <= width)
1228
698
  return [text];
1229
- }
1230
699
  const words = text.split(/\s+/).filter(Boolean);
1231
- // If no words, chunk the entire text
1232
- if (!words.length) {
700
+ if (!words.length)
1233
701
  return this.chunkWord(text, width);
1234
- }
1235
702
  const lines = [];
1236
703
  let current = '';
1237
704
  for (const word of words) {
1238
- const appendResult = this.tryAppendWord(current, word, width);
1239
- if (appendResult.shouldFlush) {
1240
- lines.push(current);
705
+ if (!current) {
706
+ if (word.length <= width) {
707
+ current = word;
708
+ }
709
+ else {
710
+ const chunks = this.chunkWord(word, width);
711
+ lines.push(...chunks.slice(0, -1));
712
+ current = chunks[chunks.length - 1] ?? '';
713
+ }
1241
714
  }
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] ?? '';
715
+ else if (current.length + 1 + word.length <= width) {
716
+ current = `${current} ${word}`;
1246
717
  }
1247
718
  else {
1248
- current = appendResult.newCurrent;
719
+ lines.push(current);
720
+ if (word.length <= width) {
721
+ current = word;
722
+ }
723
+ else {
724
+ const chunks = this.chunkWord(word, width);
725
+ lines.push(...chunks.slice(0, -1));
726
+ current = chunks[chunks.length - 1] ?? '';
727
+ }
1249
728
  }
1250
729
  }
1251
730
  if (current) {
@@ -1253,64 +732,23 @@ export class Display {
1253
732
  }
1254
733
  return lines.length ? lines : [''];
1255
734
  }
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
735
  chunkWord(word, width) {
1288
- if (width <= 0 || !word) {
736
+ if (width <= 0 || !word)
1289
737
  return word ? [word] : [''];
1290
- }
1291
738
  const chunks = [];
1292
739
  for (let i = 0; i < word.length; i += width) {
1293
740
  chunks.push(word.slice(i, i + width));
1294
741
  }
1295
742
  return chunks.length > 0 ? chunks : [''];
1296
743
  }
1297
- /**
1298
- * Returns the visible length of a string, excluding ANSI escape codes.
1299
- */
1300
744
  visibleLength(value) {
1301
- if (!value) {
745
+ if (!value)
1302
746
  return 0;
1303
- }
1304
747
  return this.stripAnsi(value).length;
1305
748
  }
1306
- /**
1307
- * Removes ANSI escape codes from a string to get the visible text.
1308
- * Uses the standard ANSI escape sequence pattern.
1309
- */
1310
749
  stripAnsi(value) {
1311
- if (!value) {
750
+ if (!value)
1312
751
  return '';
1313
- }
1314
752
  return value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '');
1315
753
  }
1316
754
  }