erosolar-cli 1.7.14 → 1.7.16

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 (61) hide show
  1. package/dist/core/responseVerifier.d.ts +79 -0
  2. package/dist/core/responseVerifier.d.ts.map +1 -0
  3. package/dist/core/responseVerifier.js +443 -0
  4. package/dist/core/responseVerifier.js.map +1 -0
  5. package/dist/shell/interactiveShell.d.ts +10 -0
  6. package/dist/shell/interactiveShell.d.ts.map +1 -1
  7. package/dist/shell/interactiveShell.js +80 -0
  8. package/dist/shell/interactiveShell.js.map +1 -1
  9. package/dist/ui/ShellUIAdapter.d.ts +3 -0
  10. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  11. package/dist/ui/ShellUIAdapter.js +4 -10
  12. package/dist/ui/ShellUIAdapter.js.map +1 -1
  13. package/dist/ui/persistentPrompt.d.ts +4 -0
  14. package/dist/ui/persistentPrompt.d.ts.map +1 -1
  15. package/dist/ui/persistentPrompt.js +10 -11
  16. package/dist/ui/persistentPrompt.js.map +1 -1
  17. package/package.json +1 -1
  18. package/dist/bin/core/agent.js +0 -362
  19. package/dist/bin/core/agentProfileManifest.js +0 -187
  20. package/dist/bin/core/agentProfiles.js +0 -34
  21. package/dist/bin/core/agentRulebook.js +0 -135
  22. package/dist/bin/core/agentSchemaLoader.js +0 -233
  23. package/dist/bin/core/contextManager.js +0 -412
  24. package/dist/bin/core/contextWindow.js +0 -122
  25. package/dist/bin/core/customCommands.js +0 -80
  26. package/dist/bin/core/errors/apiKeyErrors.js +0 -114
  27. package/dist/bin/core/errors/errorTypes.js +0 -340
  28. package/dist/bin/core/errors/safetyValidator.js +0 -304
  29. package/dist/bin/core/errors.js +0 -32
  30. package/dist/bin/core/modelDiscovery.js +0 -755
  31. package/dist/bin/core/preferences.js +0 -224
  32. package/dist/bin/core/schemaValidator.js +0 -92
  33. package/dist/bin/core/secretStore.js +0 -199
  34. package/dist/bin/core/sessionStore.js +0 -187
  35. package/dist/bin/core/toolRuntime.js +0 -290
  36. package/dist/bin/core/types.js +0 -1
  37. package/dist/bin/shell/bracketedPasteManager.js +0 -350
  38. package/dist/bin/shell/fileChangeTracker.js +0 -65
  39. package/dist/bin/shell/interactiveShell.js +0 -2908
  40. package/dist/bin/shell/liveStatus.js +0 -78
  41. package/dist/bin/shell/shellApp.js +0 -290
  42. package/dist/bin/shell/systemPrompt.js +0 -60
  43. package/dist/bin/shell/updateManager.js +0 -108
  44. package/dist/bin/ui/ShellUIAdapter.js +0 -459
  45. package/dist/bin/ui/UnifiedUIController.js +0 -183
  46. package/dist/bin/ui/animation/AnimationScheduler.js +0 -430
  47. package/dist/bin/ui/codeHighlighter.js +0 -854
  48. package/dist/bin/ui/designSystem.js +0 -121
  49. package/dist/bin/ui/display.js +0 -1222
  50. package/dist/bin/ui/interrupts/InterruptManager.js +0 -437
  51. package/dist/bin/ui/layout.js +0 -139
  52. package/dist/bin/ui/orchestration/StatusOrchestrator.js +0 -403
  53. package/dist/bin/ui/outputMode.js +0 -38
  54. package/dist/bin/ui/persistentPrompt.js +0 -183
  55. package/dist/bin/ui/richText.js +0 -338
  56. package/dist/bin/ui/shortcutsHelp.js +0 -87
  57. package/dist/bin/ui/telemetry/UITelemetry.js +0 -443
  58. package/dist/bin/ui/textHighlighter.js +0 -210
  59. package/dist/bin/ui/theme.js +0 -116
  60. package/dist/bin/ui/toolDisplay.js +0 -423
  61. package/dist/bin/ui/toolDisplayAdapter.js +0 -357
@@ -1,1222 +0,0 @@
1
- import { createSpinner } from 'nanospinner';
2
- import { clearScreenDown, cursorTo, moveCursor } from 'node:readline';
3
- import { theme, icons } from './theme.js';
4
- import { formatRichContent, renderMessagePanel, renderMessageBody } from './richText.js';
5
- import { getTerminalColumns, wrapPreformatted } from './layout.js';
6
- import { highlightError } from './textHighlighter.js';
7
- import { renderCallout, renderSectionHeading } from './designSystem.js';
8
- import { isPlainOutputMode } from './outputMode.js';
9
- /**
10
- * Output lock to prevent race conditions during spinner/stream output.
11
- * Ensures that spinner frames don't interleave with streamed content.
12
- * Uses a simple lock mechanism suitable for Node.js single-threaded event loop.
13
- */
14
- class OutputLock {
15
- constructor() {
16
- this.locked = false;
17
- this.pendingCallbacks = [];
18
- }
19
- static getInstance() {
20
- if (!OutputLock.instance) {
21
- OutputLock.instance = new OutputLock();
22
- }
23
- return OutputLock.instance;
24
- }
25
- /**
26
- * Synchronously check if output is locked (spinner is active).
27
- * Used to prevent stream writes during spinner animation.
28
- */
29
- isLocked() {
30
- return this.locked;
31
- }
32
- /**
33
- * Lock output during spinner animation.
34
- */
35
- lock() {
36
- this.locked = true;
37
- }
38
- /**
39
- * Unlock output and process any pending callbacks.
40
- */
41
- unlock() {
42
- this.locked = false;
43
- // Process any pending writes
44
- while (this.pendingCallbacks.length > 0) {
45
- const callback = this.pendingCallbacks.shift();
46
- if (callback) {
47
- callback();
48
- }
49
- }
50
- }
51
- /**
52
- * Execute a callback safely, queueing if output is locked.
53
- */
54
- safeWrite(callback) {
55
- if (this.locked) {
56
- this.pendingCallbacks.push(callback);
57
- return;
58
- }
59
- callback();
60
- }
61
- }
62
- OutputLock.instance = null;
63
- /**
64
- * Tracks line output to stdout for banner rewriting and cursor positioning.
65
- * Instances are cached per stream to keep banner calculations consistent.
66
- */
67
- class StdoutLineTracker {
68
- static getInstance(stream = process.stdout) {
69
- const existing = StdoutLineTracker.instances.get(stream);
70
- if (existing) {
71
- return existing;
72
- }
73
- const tracker = new StdoutLineTracker(stream);
74
- StdoutLineTracker.instances.set(stream, tracker);
75
- return tracker;
76
- }
77
- constructor(stream) {
78
- this.linesWritten = 0;
79
- this.suspended = false;
80
- this.stream = stream;
81
- this.originalWrite = stream.write.bind(stream);
82
- this.patchStream();
83
- }
84
- get totalLines() {
85
- return this.linesWritten;
86
- }
87
- /**
88
- * Temporarily suspends line tracking while executing a function.
89
- * Useful for rewriting content without incrementing line count.
90
- */
91
- withSuspended(fn) {
92
- const wasSuspended = this.suspended;
93
- this.suspended = true;
94
- try {
95
- return fn();
96
- }
97
- finally {
98
- this.suspended = wasSuspended;
99
- }
100
- }
101
- reset() {
102
- this.linesWritten = 0;
103
- }
104
- patchStream() {
105
- const tracker = this;
106
- this.stream.write = function patched(chunk, encoding, callback) {
107
- const actualEncoding = typeof encoding === 'function' ? undefined : encoding;
108
- tracker.recordChunk(chunk, actualEncoding);
109
- return tracker.originalWrite.call(this, chunk, encoding, callback);
110
- };
111
- }
112
- recordChunk(chunk, encoding) {
113
- if (this.suspended) {
114
- return;
115
- }
116
- const text = this.chunkToString(chunk, encoding);
117
- if (!text) {
118
- return;
119
- }
120
- this.countNewlines(text);
121
- }
122
- countNewlines(text) {
123
- for (const char of text) {
124
- if (char === '\n') {
125
- this.linesWritten += 1;
126
- }
127
- }
128
- }
129
- chunkToString(chunk, encoding) {
130
- if (typeof chunk === 'string') {
131
- return chunk;
132
- }
133
- if (chunk instanceof Uint8Array) {
134
- const enc = encoding ?? 'utf8';
135
- return Buffer.from(chunk).toString(enc);
136
- }
137
- return null;
138
- }
139
- }
140
- StdoutLineTracker.instances = new WeakMap();
141
- // Display configuration constants
142
- const DISPLAY_CONSTANTS = {
143
- MIN_BANNER_WIDTH: 32,
144
- MAX_BANNER_WIDTH: 120,
145
- BANNER_PADDING: 4,
146
- MIN_MESSAGE_WIDTH: 42,
147
- MAX_MESSAGE_WIDTH: 110,
148
- MESSAGE_PADDING: 4,
149
- MIN_ACTION_WIDTH: 40,
150
- MAX_ACTION_WIDTH: 90,
151
- MIN_THOUGHT_WIDTH: 48,
152
- MAX_THOUGHT_WIDTH: 96,
153
- MIN_CONTENT_WIDTH: 10,
154
- MIN_WRAP_WIDTH: 12,
155
- SPINNER_INTERVAL: 80,
156
- };
157
- const SPINNER_FRAMES = ['∴', 'ε', '∴', '✻', 'ε', '✻'];
158
- const INDEPENDENT_SECURITY_MANDATE = 'Independent security research CLI for third-party, ethics-reviewed assessments and vulnerability discovery.';
159
- /**
160
- * Display class manages all terminal UI output for the application.
161
- *
162
- * Architecture:
163
- * - Per-stream line tracking via StdoutLineTracker for consistent banner updates
164
- * - Output interceptor pattern for live update integration
165
- * - Banner state management for in-place updates
166
- * - Configurable width constraints via DISPLAY_CONSTANTS
167
- *
168
- * Claude Code Style Formatting:
169
- * - ⏺ prefix for tool calls, actions, and thinking/reasoning
170
- * - ⎿ prefix for results, details, and nested information
171
- * - ─ horizontal separators for dividing sections (edit diffs, etc.)
172
- * - > prefix for user prompts (handled in theme.ts formatUserPrompt)
173
- * - Compact epsilon spinner: ∴, ε, ✻
174
- *
175
- * Key responsibilities:
176
- * - Welcome banners and session information display
177
- * - Message formatting (assistant, system, errors, warnings)
178
- * - Spinner/thinking indicators
179
- * - Action and sub-action formatting with tree-style prefixes
180
- * - Text wrapping and layout management
181
- *
182
- * Error handling:
183
- * - Graceful degradation for non-TTY environments
184
- * - Input validation on public methods
185
- * - Safe cursor manipulation with fallback
186
- */
187
- export class Display {
188
- constructor(stream = process.stdout, errorStream) {
189
- this.activeSpinner = null;
190
- this.outputInterceptors = new Set();
191
- this.outputLock = OutputLock.getInstance();
192
- this.spinnerFrames = [...SPINNER_FRAMES];
193
- this.bannerState = null;
194
- this.outputStream = stream;
195
- this.errorStream = errorStream ?? stream;
196
- this.stdoutTracker = StdoutLineTracker.getInstance(stream);
197
- }
198
- registerOutputInterceptor(interceptor) {
199
- if (!interceptor) {
200
- return () => { };
201
- }
202
- this.outputInterceptors.add(interceptor);
203
- return () => {
204
- this.outputInterceptors.delete(interceptor);
205
- };
206
- }
207
- withOutput(fn) {
208
- this.notifyBeforeOutput();
209
- try {
210
- return fn();
211
- }
212
- finally {
213
- this.notifyAfterOutput();
214
- }
215
- }
216
- notifyBeforeOutput() {
217
- for (const interceptor of this.outputInterceptors) {
218
- interceptor.beforeWrite?.();
219
- }
220
- }
221
- notifyAfterOutput() {
222
- const interceptors = Array.from(this.outputInterceptors);
223
- for (let index = interceptors.length - 1; index >= 0; index -= 1) {
224
- interceptors[index]?.afterWrite?.();
225
- }
226
- }
227
- write(value, target = this.outputStream) {
228
- target.write(value);
229
- }
230
- writeLine(value = '', target = this.outputStream) {
231
- this.write(`${value}\n`, target);
232
- }
233
- getColumnWidth() {
234
- if (typeof this.outputStream.columns === 'number' &&
235
- Number.isFinite(this.outputStream.columns) &&
236
- this.outputStream.columns > 0) {
237
- return this.outputStream.columns;
238
- }
239
- return getTerminalColumns();
240
- }
241
- /**
242
- * Displays the welcome banner with session information.
243
- * Stores banner state for potential in-place updates.
244
- */
245
- showWelcome(profileLabel, profileName, model, provider, workingDir, version) {
246
- // Validate required inputs
247
- if (!model?.trim() || !provider?.trim() || !workingDir?.trim()) {
248
- return;
249
- }
250
- const width = this.getBannerWidth();
251
- const banner = this.buildClaudeStyleBanner(profileLabel ?? '', model, provider, workingDir, width, version);
252
- if (!banner) {
253
- return;
254
- }
255
- const startLine = this.stdoutTracker.totalLines;
256
- this.withOutput(() => {
257
- this.writeLine(banner);
258
- });
259
- const nextState = {
260
- startLine,
261
- height: this.measureBannerHeight(banner),
262
- width,
263
- workingDir,
264
- model,
265
- provider,
266
- profileLabel: profileLabel ?? '',
267
- profileName: profileName ?? '',
268
- };
269
- if (version?.trim()) {
270
- nextState.version = version.trim();
271
- }
272
- this.bannerState = nextState;
273
- }
274
- /**
275
- * Updates the session information banner with new model/provider.
276
- * Attempts in-place update if possible, otherwise re-renders.
277
- */
278
- updateSessionInfo(model, provider) {
279
- const state = this.bannerState;
280
- if (!state) {
281
- return;
282
- }
283
- // Validate inputs
284
- if (!model?.trim() || !provider?.trim()) {
285
- return;
286
- }
287
- const lines = this.buildSessionLines(state.profileLabel, state.profileName, model, provider, state.workingDir, state.width);
288
- const banner = this.buildBanner('Erosolar CLI', state.width, lines, this.buildBannerOptions(state.version));
289
- const height = this.measureBannerHeight(banner);
290
- // If height changed or rewrite failed, do full re-render
291
- if (height !== state.height || !this.tryRewriteBanner(state, banner)) {
292
- this.renderAndStoreBanner(state, model, provider);
293
- return;
294
- }
295
- // Update succeeded, update state
296
- state.model = model;
297
- state.provider = provider;
298
- }
299
- showThinking(message = 'Thinking...') {
300
- // If we already have a spinner, just update its text instead of creating a new one
301
- if (this.activeSpinner) {
302
- this.activeSpinner.update({ text: message });
303
- return;
304
- }
305
- // Lock output to prevent stream writes from interleaving with spinner frames
306
- this.outputLock.lock();
307
- // Use Claude Code style spinner with epsilon: ∴, ε, and ✻
308
- this.activeSpinner = createSpinner(message, {
309
- stream: this.outputStream,
310
- spinner: {
311
- interval: DISPLAY_CONSTANTS.SPINNER_INTERVAL,
312
- frames: this.spinnerFrames,
313
- },
314
- }).start();
315
- }
316
- updateThinking(message) {
317
- if (this.activeSpinner) {
318
- this.activeSpinner.update({ text: message });
319
- }
320
- else {
321
- this.showThinking(message);
322
- }
323
- }
324
- stopThinking(addNewLine = true) {
325
- this.clearSpinnerIfActive(addNewLine);
326
- }
327
- isSpinnerActive() {
328
- return this.activeSpinner !== null;
329
- }
330
- /**
331
- * Check if output is currently locked (e.g., spinner is active).
332
- * Used by external callers to coordinate output timing.
333
- */
334
- isOutputLocked() {
335
- return this.outputLock.isLocked();
336
- }
337
- /**
338
- * Execute a write callback safely, ensuring it doesn't interleave with spinner output.
339
- * If spinner is active, the write will be queued until spinner stops.
340
- */
341
- safeWrite(callback) {
342
- this.outputLock.safeWrite(callback);
343
- }
344
- clearSpinnerIfActive(addNewLine = true) {
345
- if (!this.activeSpinner) {
346
- return;
347
- }
348
- const spinner = this.activeSpinner;
349
- this.activeSpinner = null;
350
- // Use stop() instead of clear() so nanospinner removes its SIGINT/SIGTERM listeners.
351
- // clear() leaves the listeners attached, which triggers MaxListenersExceededWarning over time.
352
- spinner.stop();
353
- // Unlock output to process any pending writes
354
- this.outputLock.unlock();
355
- if (addNewLine) {
356
- this.withOutput(() => {
357
- this.writeLine();
358
- });
359
- }
360
- }
361
- showAssistantMessage(content, metadata) {
362
- if (!content.trim()) {
363
- return;
364
- }
365
- this.clearSpinnerIfActive();
366
- const isThought = metadata?.isFinal === false;
367
- const body = isThought ? this.buildClaudeStyleThought(content) : this.buildChatBox(content, metadata);
368
- if (!body.trim()) {
369
- return;
370
- }
371
- this.withOutput(() => {
372
- this.writeLine(); // Ensure clean start for the box
373
- this.writeLine(body);
374
- this.writeLine();
375
- });
376
- }
377
- showNarrative(content) {
378
- if (!content.trim()) {
379
- return;
380
- }
381
- this.showAssistantMessage(content, { isFinal: false });
382
- }
383
- showAction(text, status = 'info') {
384
- if (!text.trim()) {
385
- return;
386
- }
387
- this.clearSpinnerIfActive();
388
- // Claude Code style: always use ⏺ prefix for actions
389
- const icon = this.formatActionIcon(status);
390
- this.withOutput(() => {
391
- this.writeLine(this.wrapWithPrefix(text, `${icon} `));
392
- });
393
- }
394
- showSubAction(text, status = 'info') {
395
- if (!text.trim()) {
396
- return;
397
- }
398
- this.clearSpinnerIfActive();
399
- const prefersRich = text.includes('```');
400
- let rendered = prefersRich ? this.buildRichSubActionLines(text, status) : this.buildWrappedSubActionLines(text, status);
401
- if (!rendered.length && prefersRich) {
402
- rendered = this.buildWrappedSubActionLines(text, status);
403
- }
404
- if (!rendered.length) {
405
- return;
406
- }
407
- this.withOutput(() => {
408
- this.writeLine(rendered.join('\n'));
409
- this.writeLine();
410
- });
411
- }
412
- buildWrappedSubActionLines(text, status) {
413
- const lines = text.split('\n').map((line) => line.trimEnd());
414
- while (lines.length && !lines[lines.length - 1]?.trim()) {
415
- lines.pop();
416
- }
417
- if (!lines.length) {
418
- return [];
419
- }
420
- const rendered = [];
421
- for (let index = 0; index < lines.length; index += 1) {
422
- const segment = lines[index] ?? '';
423
- const isLast = index === lines.length - 1;
424
- const { prefix, continuation } = this.buildSubActionPrefixes(status, isLast);
425
- rendered.push(this.wrapWithPrefix(segment, prefix, { continuationPrefix: continuation }));
426
- }
427
- return rendered;
428
- }
429
- buildRichSubActionLines(text, status) {
430
- const normalized = text.trim();
431
- if (!normalized) {
432
- return [];
433
- }
434
- const width = Math.max(DISPLAY_CONSTANTS.MIN_ACTION_WIDTH, Math.min(this.getColumnWidth(), DISPLAY_CONSTANTS.MAX_ACTION_WIDTH));
435
- const samplePrefix = this.buildSubActionPrefixes(status, true).prefix;
436
- const contentWidth = Math.max(DISPLAY_CONSTANTS.MIN_CONTENT_WIDTH, width - this.visibleLength(samplePrefix));
437
- const blocks = formatRichContent(normalized, contentWidth);
438
- if (!blocks.length) {
439
- return [];
440
- }
441
- return blocks.map((line, index) => {
442
- const isLast = index === blocks.length - 1;
443
- const { prefix } = this.buildSubActionPrefixes(status, isLast);
444
- if (!line.trim()) {
445
- return prefix.trimEnd();
446
- }
447
- return `${prefix}${line}`;
448
- });
449
- }
450
- showMessage(content, role = 'assistant') {
451
- if (role === 'system') {
452
- this.showSystemMessage(content);
453
- }
454
- else {
455
- this.showAssistantMessage(content);
456
- }
457
- }
458
- showSystemMessage(content) {
459
- this.clearSpinnerIfActive();
460
- this.withOutput(() => {
461
- this.writeLine(content.trim());
462
- this.writeLine();
463
- });
464
- }
465
- showError(message, error) {
466
- this.clearSpinnerIfActive();
467
- const callout = renderCallout(message, {
468
- tone: 'danger',
469
- icon: icons.error,
470
- title: 'Error',
471
- width: this.getBannerWidth(),
472
- });
473
- const details = this.formatErrorDetails(error);
474
- this.withOutput(() => {
475
- this.writeLine(`\n${callout}`, this.errorStream);
476
- if (details) {
477
- this.writeLine(details, this.errorStream);
478
- }
479
- this.writeLine('', this.errorStream);
480
- });
481
- }
482
- showWarning(message) {
483
- this.clearSpinnerIfActive();
484
- const callout = renderCallout(message, {
485
- tone: 'warning',
486
- icon: icons.warning,
487
- title: 'Warning',
488
- width: this.getBannerWidth(),
489
- });
490
- this.withOutput(() => {
491
- this.writeLine(`${callout}`, this.errorStream);
492
- });
493
- }
494
- showInfo(message) {
495
- this.clearSpinnerIfActive();
496
- const callout = renderCallout(message, {
497
- tone: 'info',
498
- icon: icons.info,
499
- title: 'Info',
500
- width: this.getBannerWidth(),
501
- });
502
- this.withOutput(() => {
503
- this.writeLine(callout);
504
- });
505
- }
506
- showStatusLine(status, elapsedMs, context) {
507
- this.clearSpinnerIfActive();
508
- const normalized = status?.trim();
509
- if (!normalized) {
510
- return;
511
- }
512
- const parts = [];
513
- const contextSummary = this.formatContextSummary(context);
514
- if (contextSummary) {
515
- parts.push(contextSummary);
516
- }
517
- const elapsed = this.formatElapsed(elapsedMs);
518
- const statusText = elapsed
519
- ? `${theme.success(normalized)} ${theme.ui.muted(`(${elapsed})`)}`
520
- : theme.success(normalized);
521
- parts.push(statusText);
522
- const separator = theme.ui.muted(` ${icons.bullet} `);
523
- this.withOutput(() => {
524
- this.writeLine(parts.join(separator));
525
- this.writeLine();
526
- });
527
- }
528
- showAvailableTools(_tools) {
529
- // Hidden by default to match Claude Code style
530
- // Tools are available but not listed verbosely on startup
531
- // Parameter prefixed with underscore to indicate intentionally unused
532
- }
533
- /**
534
- * Show available providers and their status on launch
535
- */
536
- showProvidersStatus(providers) {
537
- const available = providers.filter(p => p.available);
538
- const unavailable = providers.filter(p => !p.available);
539
- if (available.length === 0 && unavailable.length === 0) {
540
- return;
541
- }
542
- this.withOutput(() => {
543
- // Show available providers
544
- if (available.length > 0) {
545
- const providerList = available
546
- .map(p => {
547
- const name = theme.success(p.provider);
548
- const model = theme.ui.muted(`(${p.latestModel})`);
549
- return `${name} ${model}`;
550
- })
551
- .join(theme.ui.muted(' | '));
552
- this.writeLine(theme.ui.muted(`${icons.success} Providers: `) + providerList);
553
- }
554
- // Show hints for unconfigured providers (condensed)
555
- if (unavailable.length > 0 && available.length < 3) {
556
- const hints = unavailable
557
- .slice(0, 3) // Show max 3 hints
558
- .map(p => theme.ui.muted(`${p.provider}: set ${p.error?.replace(' not set', '')}`))
559
- .join(theme.ui.muted(', '));
560
- this.writeLine(theme.ui.muted(`${icons.info} Add: `) + hints);
561
- }
562
- this.writeLine();
563
- });
564
- }
565
- /**
566
- * Show model commands help
567
- */
568
- showModelCommands() {
569
- this.withOutput(() => {
570
- this.writeLine(theme.ui.muted(`${icons.info} Model commands:`));
571
- this.writeLine(theme.ui.muted(' /model <name> - Switch to a specific model'));
572
- this.writeLine(theme.ui.muted(' /model - Show current model'));
573
- this.writeLine(theme.ui.muted(' /providers - List all available providers'));
574
- this.writeLine(theme.ui.muted(' /discover - Discover models from provider APIs'));
575
- this.writeLine();
576
- });
577
- }
578
- formatErrorDetails(error) {
579
- if (!error) {
580
- return null;
581
- }
582
- if (error instanceof Error) {
583
- if (error.stack) {
584
- return highlightError(error.stack);
585
- }
586
- return highlightError(error.message);
587
- }
588
- if (typeof error === 'string') {
589
- return highlightError(error);
590
- }
591
- try {
592
- return highlightError(JSON.stringify(error, null, 2));
593
- }
594
- catch {
595
- return null;
596
- }
597
- }
598
- showPlanningStep(step, index, total) {
599
- // Validate inputs
600
- if (!step?.trim()) {
601
- return;
602
- }
603
- if (index < 1 || total < 1 || index > total) {
604
- return;
605
- }
606
- const width = Math.max(DISPLAY_CONSTANTS.MIN_THOUGHT_WIDTH, Math.min(this.getColumnWidth(), DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
607
- const heading = renderSectionHeading(`Plan ${index}/${total}`, {
608
- subtitle: step,
609
- icon: icons.arrow,
610
- tone: 'info',
611
- width,
612
- });
613
- this.withOutput(() => {
614
- this.writeLine(heading);
615
- });
616
- }
617
- clear() {
618
- this.withOutput(() => {
619
- try {
620
- cursorTo(this.outputStream, 0, 0);
621
- clearScreenDown(this.outputStream);
622
- }
623
- catch {
624
- this.write('\x1Bc');
625
- }
626
- });
627
- this.stdoutTracker.reset();
628
- if (this.bannerState) {
629
- this.renderAndStoreBanner(this.bannerState, this.bannerState.model, this.bannerState.provider);
630
- }
631
- }
632
- newLine() {
633
- this.withOutput(() => {
634
- this.writeLine();
635
- });
636
- }
637
- getBannerWidth() {
638
- const availableColumns = this.getColumnWidth();
639
- const effectiveWidth = Math.max(DISPLAY_CONSTANTS.MIN_BANNER_WIDTH, availableColumns - DISPLAY_CONSTANTS.BANNER_PADDING);
640
- return Math.min(Math.max(effectiveWidth, DISPLAY_CONSTANTS.MIN_BANNER_WIDTH), DISPLAY_CONSTANTS.MAX_BANNER_WIDTH);
641
- }
642
- buildSessionLines(profileLabel, profileName, model, provider, workingDir, width) {
643
- const normalizedLabel = profileLabel ? profileLabel.trim() : '';
644
- const normalizedProfile = profileName ? profileName.trim() : '';
645
- const agentLabel = normalizedLabel || normalizedProfile || 'Active agent';
646
- const modelSummary = [this.formatModelLabel(model), provider].join(' • ');
647
- const lines = [
648
- ...this.formatInfoBlock('Agent', agentLabel, width, this.getInfoFieldStyle('agent')),
649
- ...this.formatInfoBlock('Mandate', INDEPENDENT_SECURITY_MANDATE, width, this.getInfoFieldStyle('agent')),
650
- ];
651
- if (normalizedProfile) {
652
- lines.push(...this.formatInfoBlock('Profile', normalizedProfile, width, this.getInfoFieldStyle('profile')));
653
- }
654
- lines.push(...this.formatInfoBlock('Model', modelSummary, width, this.getInfoFieldStyle('model')), ...this.formatInfoBlock('Workspace', workingDir, width, this.getInfoFieldStyle('workspace')));
655
- return lines;
656
- }
657
- measureBannerHeight(banner) {
658
- if (!banner) {
659
- return 0;
660
- }
661
- const lines = banner.split('\n').length;
662
- return lines;
663
- }
664
- /**
665
- * Attempts to rewrite the banner in place using terminal cursor manipulation.
666
- * Returns true if successful, false if rewrite is not possible.
667
- */
668
- tryRewriteBanner(state, banner) {
669
- // Validate TTY availability
670
- if (!this.outputStream.isTTY) {
671
- return false;
672
- }
673
- // Validate banner state
674
- if (!banner || state.height <= 0) {
675
- return false;
676
- }
677
- const linesWritten = this.stdoutTracker.totalLines;
678
- const linesAfterBanner = linesWritten - (state.startLine + state.height);
679
- // Cannot rewrite if banner position is invalid
680
- if (linesAfterBanner < 0) {
681
- return false;
682
- }
683
- const totalOffset = linesAfterBanner + state.height;
684
- const maxRows = this.outputStream.rows;
685
- // Cannot rewrite if offset exceeds terminal height
686
- if (typeof maxRows === 'number' && maxRows > 0 && totalOffset > maxRows) {
687
- return false;
688
- }
689
- try {
690
- this.withOutput(() => {
691
- // Move cursor up to banner start
692
- moveCursor(this.outputStream, 0, -totalOffset);
693
- cursorTo(this.outputStream, 0);
694
- // Write new banner without tracking
695
- this.stdoutTracker.withSuspended(() => {
696
- this.outputStream.write(`${banner}\n`);
697
- });
698
- // Restore cursor position
699
- if (linesAfterBanner > 0) {
700
- moveCursor(this.outputStream, 0, linesAfterBanner);
701
- }
702
- cursorTo(this.outputStream, 0);
703
- });
704
- return true;
705
- }
706
- catch (error) {
707
- // Cursor manipulation failed (e.g., terminal doesn't support it)
708
- if (error instanceof Error) {
709
- // Could log error in debug mode if needed
710
- }
711
- return false;
712
- }
713
- }
714
- renderAndStoreBanner(state, model, provider) {
715
- const width = this.getBannerWidth();
716
- const lines = this.buildSessionLines(state.profileLabel, state.profileName, model, provider, state.workingDir, width);
717
- const banner = this.buildBanner('Erosolar CLI', width, lines, this.buildBannerOptions(state.version));
718
- const startLine = this.stdoutTracker.totalLines;
719
- this.withOutput(() => {
720
- this.writeLine(banner);
721
- });
722
- state.startLine = startLine;
723
- state.height = this.measureBannerHeight(banner);
724
- state.width = width;
725
- state.model = model;
726
- state.provider = provider;
727
- }
728
- formatModelLabel(model) {
729
- if (/gpt-5\.1-?codex/i.test(model)) {
730
- return model;
731
- }
732
- if (/sonnet-4[-.]?5/i.test(model)) {
733
- return 'Sonnet 4.5';
734
- }
735
- if (/opus-4[-.]?1/i.test(model)) {
736
- return 'Opus 4.1';
737
- }
738
- if (/haiku-4[-.]?5/i.test(model)) {
739
- return 'Haiku 4.5';
740
- }
741
- if (/gpt-5\.1/i.test(model)) {
742
- return 'GPT-5.1';
743
- }
744
- if (/gpt-5-?pro/i.test(model)) {
745
- return 'GPT-5 Pro';
746
- }
747
- if (/gpt-5-?mini/i.test(model)) {
748
- return 'GPT-5 Mini';
749
- }
750
- if (/gpt-5-?nano/i.test(model)) {
751
- return 'GPT-5 Nano';
752
- }
753
- return model;
754
- }
755
- buildChatBox(content, metadata) {
756
- const normalized = content.trim();
757
- if (!normalized) {
758
- return '';
759
- }
760
- if (isPlainOutputMode()) {
761
- const body = renderMessageBody(normalized, this.resolveMessageWidth());
762
- const telemetry = this.formatTelemetryLine(metadata);
763
- return telemetry ? `${body}\n${telemetry}` : body;
764
- }
765
- const width = this.resolveMessageWidth();
766
- const panel = renderMessagePanel(normalized, {
767
- width,
768
- title: 'Assistant',
769
- icon: icons.assistant,
770
- accentColor: theme.assistant ?? theme.primary,
771
- borderColor: theme.ui.border,
772
- });
773
- const telemetry = this.formatTelemetryLine(metadata);
774
- if (!telemetry) {
775
- return panel;
776
- }
777
- return `${panel}\n${telemetry}`;
778
- }
779
- resolveMessageWidth() {
780
- const columns = this.getColumnWidth();
781
- return Math.max(DISPLAY_CONSTANTS.MIN_MESSAGE_WIDTH, Math.min(columns - DISPLAY_CONSTANTS.MESSAGE_PADDING, DISPLAY_CONSTANTS.MAX_MESSAGE_WIDTH));
782
- }
783
- /**
784
- * Legacy method for appending thought blocks with tree-like formatting.
785
- * Kept for backwards compatibility but not actively used.
786
- * @deprecated Use buildClaudeStyleThought instead
787
- */
788
- // @ts-expect-error - Legacy method kept for backwards compatibility
789
- _appendThoughtBlock(block, format, output) {
790
- const rawLines = block.split('\n');
791
- const indices = rawLines
792
- .map((line, index) => (line.trim().length ? index : -1))
793
- .filter((index) => index >= 0);
794
- if (!indices.length) {
795
- return;
796
- }
797
- const lastIndex = indices[indices.length - 1];
798
- let usedFirst = false;
799
- for (let index = 0; index < rawLines.length; index += 1) {
800
- const rawLine = rawLines[index] ?? '';
801
- if (!rawLine.trim()) {
802
- continue;
803
- }
804
- const segments = this.wrapThoughtLine(rawLine, format.available);
805
- if (!segments.length) {
806
- continue;
807
- }
808
- const isLastLine = index === lastIndex;
809
- segments.forEach((segment, segmentIndex) => {
810
- const prefix = this.resolveThoughtPrefix({
811
- usedFirst,
812
- segmentIndex,
813
- isLastLine,
814
- format,
815
- });
816
- output.push(`${prefix}${segment}`);
817
- });
818
- usedFirst = true;
819
- }
820
- }
821
- resolveThoughtPrefix(options) {
822
- if (!options.usedFirst) {
823
- return options.segmentIndex === 0 ? options.format.bullet : options.format.spacer;
824
- }
825
- if (options.segmentIndex === 0) {
826
- return options.isLastLine ? options.format.last : options.format.branch;
827
- }
828
- return options.format.spacer;
829
- }
830
- /**
831
- * Legacy method for generating thought formatting configuration.
832
- * Kept for backwards compatibility but not actively used.
833
- * @deprecated Use buildClaudeStyleThought instead
834
- */
835
- // @ts-expect-error - Legacy method kept for backwards compatibility
836
- _getThoughtFormat() {
837
- const totalWidth = Math.max(DISPLAY_CONSTANTS.MIN_THOUGHT_WIDTH, Math.min(this.getColumnWidth(), DISPLAY_CONSTANTS.MAX_THOUGHT_WIDTH));
838
- const prefixWidth = Math.max(3, this.visibleLength(`${icons.bullet} `));
839
- const available = Math.max(DISPLAY_CONSTANTS.MIN_WRAP_WIDTH, totalWidth - prefixWidth);
840
- return {
841
- totalWidth,
842
- prefixWidth,
843
- available,
844
- bullet: theme.secondary(this.padPrefix(`${icons.bullet} `, prefixWidth)),
845
- branch: theme.ui.muted(this.padPrefix('│ ', prefixWidth)),
846
- last: theme.ui.muted(this.padPrefix('└ ', prefixWidth)),
847
- spacer: ' '.repeat(prefixWidth),
848
- };
849
- }
850
- wrapThoughtLine(line, width) {
851
- const preserveIndentation = /^\s/.test(line);
852
- const normalized = preserveIndentation ? line.replace(/\s+$/, '') : line.trim();
853
- if (!normalized) {
854
- return [];
855
- }
856
- if (preserveIndentation) {
857
- return wrapPreformatted(normalized, width);
858
- }
859
- return this.wrapLine(normalized, width);
860
- }
861
- formatTelemetryLine(metadata) {
862
- if (!metadata) {
863
- return '';
864
- }
865
- const parts = [];
866
- const elapsed = this.formatElapsed(metadata.elapsedMs);
867
- if (elapsed) {
868
- const elapsedLabel = theme.metrics?.elapsedLabel ?? theme.accent;
869
- const elapsedValue = theme.metrics?.elapsedValue ?? theme.secondary;
870
- parts.push(`${elapsedLabel('elapsed')} ${elapsedValue(elapsed)}`);
871
- }
872
- if (!parts.length) {
873
- return '';
874
- }
875
- const separator = theme.ui.muted(' • ');
876
- return ` ${parts.join(separator)}`;
877
- }
878
- formatContextSummary(context) {
879
- if (!context) {
880
- return null;
881
- }
882
- const hasPercentage = typeof context.percentage === 'number' && Number.isFinite(context.percentage);
883
- const hasTokens = typeof context.tokens === 'number' && Number.isFinite(context.tokens);
884
- if (!hasPercentage && !hasTokens) {
885
- return null;
886
- }
887
- if (hasPercentage) {
888
- const normalizedPercent = Math.max(0, Math.min(100, Math.round(context.percentage)));
889
- const tokenDetails = hasTokens ? ` (${context.tokens} tokens)` : '';
890
- return `${theme.info('Context')} ${theme.ui.muted(`${normalizedPercent}% used${tokenDetails}`)}`;
891
- }
892
- return theme.ui.muted(`${context.tokens} tokens`);
893
- }
894
- formatElapsed(elapsedMs) {
895
- if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs) || elapsedMs < 0) {
896
- return null;
897
- }
898
- const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
899
- const minutes = Math.floor(totalSeconds / 60);
900
- const seconds = totalSeconds % 60;
901
- if (minutes > 0) {
902
- return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
903
- }
904
- return `${seconds}s`;
905
- }
906
- buildClaudeStyleBanner(profileLabel, model, _provider, workingDir, width, version) {
907
- const gradient = theme.gradient.cool;
908
- const dim = theme.ui.muted;
909
- // Build clean content without box characters
910
- const lines = [];
911
- // Empty line for spacing
912
- lines.push('');
913
- // Title with version
914
- const title = version ? `Erosolar CLI v${version}` : 'Erosolar CLI';
915
- lines.push(gradient(title));
916
- // Model name
917
- lines.push(dim(model));
918
- // Profile label
919
- lines.push(dim(profileLabel));
920
- // Workspace
921
- const shortPath = this.abbreviatePath(workingDir, width - 8);
922
- lines.push(dim(shortPath));
923
- // Empty line for spacing
924
- lines.push('');
925
- return lines.join('\n');
926
- }
927
- abbreviatePath(path, maxLen) {
928
- if (path.length <= maxLen)
929
- return path;
930
- const parts = path.split('/');
931
- if (parts.length <= 2)
932
- return path;
933
- return parts[0] + '/.../' + parts[parts.length - 1];
934
- }
935
- buildBanner(title, width, lines, options) {
936
- if (isPlainOutputMode()) {
937
- const badge = options?.badge ? ` (${options.badge})` : '';
938
- const header = `${title}${badge}`;
939
- return [header, ...lines].join('\n');
940
- }
941
- const badge = options?.badge ? ` ${options.badge}` : '';
942
- const gradient = theme.gradient.cool;
943
- const header = gradient(`${title}${badge}`);
944
- const body = lines.map((line) => this.buildBannerLine(line, width)).join('\n');
945
- return `\n${header}\n${body}\n`;
946
- }
947
- buildBannerOptions(version) {
948
- if (!version?.trim()) {
949
- return undefined;
950
- }
951
- return { badge: `${version.trim()} • support@ero.solar` };
952
- }
953
- buildBannerLine(text, width) {
954
- const padded = this.padLine(text, width);
955
- if (isPlainOutputMode()) {
956
- return padded;
957
- }
958
- const tinted = theme.ui.background(theme.ui.text(padded));
959
- return tinted;
960
- }
961
- padLine(text, width) {
962
- const visible = this.visibleLength(text);
963
- if (visible >= width) {
964
- return this.truncateVisible(text, width);
965
- }
966
- const padding = Math.max(0, width - visible);
967
- return `${text}${' '.repeat(padding)}`;
968
- }
969
- /**
970
- * Formats an info block with label and value, wrapping if needed.
971
- * First line gets the label prefix, subsequent lines are indented.
972
- */
973
- formatInfoBlock(label, value, width, options) {
974
- // Validate inputs
975
- if (!label?.trim() || !value?.trim()) {
976
- return [];
977
- }
978
- if (width <= 0) {
979
- return [value];
980
- }
981
- const prefix = `${label.toUpperCase()}: `;
982
- const prefixLength = prefix.length;
983
- const available = Math.max(DISPLAY_CONSTANTS.MIN_CONTENT_WIDTH, width - prefixLength);
984
- const wrapped = this.wrapLine(value, available);
985
- return wrapped.map((line, index) => {
986
- const indent = index === 0 ? prefix : ' '.repeat(prefixLength);
987
- const raw = `${indent}${line}`;
988
- const padded = this.padLine(raw, width);
989
- if (!options) {
990
- return padded;
991
- }
992
- const labelColor = index === 0 ? options.labelColor : undefined;
993
- return this.applyInfoLineStyles(padded, prefixLength, line.length, labelColor, options.valueColor);
994
- });
995
- }
996
- applyInfoLineStyles(line, prefixLength, valueLength, labelColor, valueColor) {
997
- const prefix = line.slice(0, prefixLength);
998
- const remainder = line.slice(prefixLength);
999
- const tintedPrefix = labelColor ? labelColor(prefix) : prefix;
1000
- const safeValueLength = Math.max(0, Math.min(valueLength, remainder.length));
1001
- if (!valueColor || safeValueLength <= 0) {
1002
- return `${tintedPrefix}${remainder}`;
1003
- }
1004
- const valueSegment = remainder.slice(0, safeValueLength);
1005
- const trailing = remainder.slice(safeValueLength);
1006
- const tintedValue = valueColor(valueSegment);
1007
- return `${tintedPrefix}${tintedValue}${trailing}`;
1008
- }
1009
- getInfoFieldStyle(field) {
1010
- const labelColor = theme.fields?.label ?? ((text) => text);
1011
- const valueColor = theme.fields?.[field] ?? ((text) => text);
1012
- return {
1013
- labelColor,
1014
- valueColor,
1015
- };
1016
- }
1017
- /**
1018
- * Wraps text with a prefix on the first line and optional continuation prefix.
1019
- * Handles multi-line text and word wrapping intelligently.
1020
- */
1021
- wrapWithPrefix(text, prefix, options) {
1022
- if (!text) {
1023
- return prefix.trimEnd();
1024
- }
1025
- const width = Math.max(DISPLAY_CONSTANTS.MIN_ACTION_WIDTH, Math.min(this.getColumnWidth(), DISPLAY_CONSTANTS.MAX_ACTION_WIDTH));
1026
- const prefixWidth = this.visibleLength(prefix);
1027
- const available = Math.max(DISPLAY_CONSTANTS.MIN_CONTENT_WIDTH, width - prefixWidth);
1028
- const indent = typeof options?.continuationPrefix === 'string'
1029
- ? options.continuationPrefix
1030
- : ' '.repeat(Math.max(0, prefixWidth));
1031
- const segments = text.split('\n');
1032
- const lines = [];
1033
- let usedPrefix = false;
1034
- for (const segment of segments) {
1035
- if (!segment.trim()) {
1036
- if (usedPrefix) {
1037
- lines.push(indent);
1038
- }
1039
- else {
1040
- lines.push(prefix.trimEnd());
1041
- usedPrefix = true;
1042
- }
1043
- continue;
1044
- }
1045
- const wrapped = this.wrapLine(segment.trim(), available);
1046
- for (const line of wrapped) {
1047
- lines.push(!usedPrefix ? `${prefix}${line}` : `${indent}${line}`);
1048
- usedPrefix = true;
1049
- }
1050
- }
1051
- return lines.join('\n');
1052
- }
1053
- resolveStatusColor(status) {
1054
- switch (status) {
1055
- case 'success':
1056
- return theme.success;
1057
- case 'error':
1058
- return theme.error;
1059
- case 'warning':
1060
- return theme.warning;
1061
- case 'pending':
1062
- return theme.info;
1063
- default:
1064
- return theme.secondary;
1065
- }
1066
- }
1067
- formatActionIcon(status) {
1068
- const colorize = this.resolveStatusColor(status);
1069
- return colorize(`${icons.action}`);
1070
- }
1071
- buildClaudeStyleThought(content) {
1072
- // Claude Code style: compact ⏺ prefix for thoughts/reasoning
1073
- const prefix = theme.ui.muted('⏺') + ' ';
1074
- return this.wrapWithPrefix(content, prefix);
1075
- }
1076
- // @ts-ignore - Legacy method kept for compatibility
1077
- // Keep legacy method to avoid breaking changes
1078
- buildSubActionPrefixes(status, isLast) {
1079
- if (isLast) {
1080
- const colorize = this.resolveStatusColor(status);
1081
- // Claude Code style: use ⎿ for sub-action result/detail prefix
1082
- return {
1083
- prefix: ` ${colorize(icons.subaction)} `,
1084
- continuation: ' ',
1085
- };
1086
- }
1087
- const branch = theme.ui.muted('│');
1088
- return {
1089
- prefix: ` ${branch} `,
1090
- continuation: ` ${branch} `,
1091
- };
1092
- }
1093
- /**
1094
- * Wraps a single line of text to fit within the specified width.
1095
- * Intelligently handles word breaking and preserves spaces.
1096
- */
1097
- wrapLine(text, width) {
1098
- // Handle edge cases
1099
- if (width <= 0) {
1100
- return [text];
1101
- }
1102
- if (!text) {
1103
- return [''];
1104
- }
1105
- if (text.length <= width) {
1106
- return [text];
1107
- }
1108
- const words = text.split(/\s+/).filter(Boolean);
1109
- // If no words, chunk the entire text
1110
- if (!words.length) {
1111
- return this.chunkWord(text, width);
1112
- }
1113
- const lines = [];
1114
- let current = '';
1115
- for (const word of words) {
1116
- const appendResult = this.tryAppendWord(current, word, width);
1117
- if (appendResult.shouldFlush) {
1118
- lines.push(current);
1119
- }
1120
- if (appendResult.chunks.length > 0) {
1121
- // Word was too long and was chunked
1122
- lines.push(...appendResult.chunks.slice(0, -1));
1123
- current = appendResult.chunks[appendResult.chunks.length - 1] ?? '';
1124
- }
1125
- else {
1126
- current = appendResult.newCurrent;
1127
- }
1128
- }
1129
- if (current) {
1130
- lines.push(current);
1131
- }
1132
- return lines.length ? lines : [''];
1133
- }
1134
- /**
1135
- * Attempts to append a word to the current line.
1136
- * Returns instructions on how to handle the word.
1137
- */
1138
- tryAppendWord(current, word, width) {
1139
- if (!word) {
1140
- return { shouldFlush: false, newCurrent: current, chunks: [] };
1141
- }
1142
- // Empty current line - start new line with word
1143
- if (!current) {
1144
- if (word.length <= width) {
1145
- return { shouldFlush: false, newCurrent: word, chunks: [] };
1146
- }
1147
- // Word too long, need to chunk it
1148
- return { shouldFlush: false, newCurrent: '', chunks: this.chunkWord(word, width) };
1149
- }
1150
- // Word fits on current line with space
1151
- if (current.length + 1 + word.length <= width) {
1152
- return { shouldFlush: false, newCurrent: `${current} ${word}`, chunks: [] };
1153
- }
1154
- // Word doesn't fit - flush current and start new line
1155
- if (word.length <= width) {
1156
- return { shouldFlush: true, newCurrent: word, chunks: [] };
1157
- }
1158
- // Word doesn't fit and is too long - flush current and chunk word
1159
- return { shouldFlush: true, newCurrent: '', chunks: this.chunkWord(word, width) };
1160
- }
1161
- /**
1162
- * Splits a long word into chunks that fit within the specified width.
1163
- * Used when a single word is too long to fit on one line.
1164
- */
1165
- chunkWord(word, width) {
1166
- if (width <= 0 || !word) {
1167
- return word ? [word] : [''];
1168
- }
1169
- const chunks = [];
1170
- for (let i = 0; i < word.length; i += width) {
1171
- chunks.push(word.slice(i, i + width));
1172
- }
1173
- return chunks.length > 0 ? chunks : [''];
1174
- }
1175
- /**
1176
- * Pads a prefix string to the specified width with spaces.
1177
- */
1178
- padPrefix(value, width) {
1179
- if (!value || value.length >= width || width <= 0) {
1180
- return value;
1181
- }
1182
- return value.padEnd(width, ' ');
1183
- }
1184
- /**
1185
- * Truncates a string to fit within the specified width,
1186
- * accounting for ANSI color codes and adding ellipsis.
1187
- */
1188
- truncateVisible(value, width) {
1189
- if (width <= 0) {
1190
- return '';
1191
- }
1192
- if (!value) {
1193
- return '';
1194
- }
1195
- const plain = this.stripAnsi(value);
1196
- if (plain.length <= width) {
1197
- return value;
1198
- }
1199
- const slice = plain.slice(0, Math.max(1, width - 1));
1200
- return `${slice}…`;
1201
- }
1202
- /**
1203
- * Returns the visible length of a string, excluding ANSI escape codes.
1204
- */
1205
- visibleLength(value) {
1206
- if (!value) {
1207
- return 0;
1208
- }
1209
- return this.stripAnsi(value).length;
1210
- }
1211
- /**
1212
- * Removes ANSI escape codes from a string to get the visible text.
1213
- * Uses the standard ANSI escape sequence pattern.
1214
- */
1215
- stripAnsi(value) {
1216
- if (!value) {
1217
- return '';
1218
- }
1219
- return value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '');
1220
- }
1221
- }
1222
- export const display = new Display();