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.
- package/dist/core/responseVerifier.d.ts +79 -0
- package/dist/core/responseVerifier.d.ts.map +1 -0
- package/dist/core/responseVerifier.js +443 -0
- package/dist/core/responseVerifier.js.map +1 -0
- package/dist/shell/interactiveShell.d.ts +10 -0
- package/dist/shell/interactiveShell.d.ts.map +1 -1
- package/dist/shell/interactiveShell.js +80 -0
- package/dist/shell/interactiveShell.js.map +1 -1
- package/dist/ui/ShellUIAdapter.d.ts +3 -0
- package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
- package/dist/ui/ShellUIAdapter.js +4 -10
- package/dist/ui/ShellUIAdapter.js.map +1 -1
- package/dist/ui/persistentPrompt.d.ts +4 -0
- package/dist/ui/persistentPrompt.d.ts.map +1 -1
- package/dist/ui/persistentPrompt.js +10 -11
- package/dist/ui/persistentPrompt.js.map +1 -1
- package/package.json +1 -1
- package/dist/bin/core/agent.js +0 -362
- package/dist/bin/core/agentProfileManifest.js +0 -187
- package/dist/bin/core/agentProfiles.js +0 -34
- package/dist/bin/core/agentRulebook.js +0 -135
- package/dist/bin/core/agentSchemaLoader.js +0 -233
- package/dist/bin/core/contextManager.js +0 -412
- package/dist/bin/core/contextWindow.js +0 -122
- package/dist/bin/core/customCommands.js +0 -80
- package/dist/bin/core/errors/apiKeyErrors.js +0 -114
- package/dist/bin/core/errors/errorTypes.js +0 -340
- package/dist/bin/core/errors/safetyValidator.js +0 -304
- package/dist/bin/core/errors.js +0 -32
- package/dist/bin/core/modelDiscovery.js +0 -755
- package/dist/bin/core/preferences.js +0 -224
- package/dist/bin/core/schemaValidator.js +0 -92
- package/dist/bin/core/secretStore.js +0 -199
- package/dist/bin/core/sessionStore.js +0 -187
- package/dist/bin/core/toolRuntime.js +0 -290
- package/dist/bin/core/types.js +0 -1
- package/dist/bin/shell/bracketedPasteManager.js +0 -350
- package/dist/bin/shell/fileChangeTracker.js +0 -65
- package/dist/bin/shell/interactiveShell.js +0 -2908
- package/dist/bin/shell/liveStatus.js +0 -78
- package/dist/bin/shell/shellApp.js +0 -290
- package/dist/bin/shell/systemPrompt.js +0 -60
- package/dist/bin/shell/updateManager.js +0 -108
- package/dist/bin/ui/ShellUIAdapter.js +0 -459
- package/dist/bin/ui/UnifiedUIController.js +0 -183
- package/dist/bin/ui/animation/AnimationScheduler.js +0 -430
- package/dist/bin/ui/codeHighlighter.js +0 -854
- package/dist/bin/ui/designSystem.js +0 -121
- package/dist/bin/ui/display.js +0 -1222
- package/dist/bin/ui/interrupts/InterruptManager.js +0 -437
- package/dist/bin/ui/layout.js +0 -139
- package/dist/bin/ui/orchestration/StatusOrchestrator.js +0 -403
- package/dist/bin/ui/outputMode.js +0 -38
- package/dist/bin/ui/persistentPrompt.js +0 -183
- package/dist/bin/ui/richText.js +0 -338
- package/dist/bin/ui/shortcutsHelp.js +0 -87
- package/dist/bin/ui/telemetry/UITelemetry.js +0 -443
- package/dist/bin/ui/textHighlighter.js +0 -210
- package/dist/bin/ui/theme.js +0 -116
- package/dist/bin/ui/toolDisplay.js +0 -423
- package/dist/bin/ui/toolDisplayAdapter.js +0 -357
package/dist/bin/ui/display.js
DELETED
|
@@ -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();
|