erosolar-cli 2.1.172 → 2.1.174
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/README.md +1 -1
- package/agents/erosolar-code.rules.json +2 -2
- package/agents/general.rules.json +21 -3
- package/dist/capabilities/statusCapability.js +2 -2
- package/dist/capabilities/statusCapability.js.map +1 -1
- package/dist/contracts/agent-schemas.json +5 -5
- package/dist/core/agent.d.ts +83 -24
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +499 -248
- package/dist/core/agent.js.map +1 -1
- package/dist/core/preferences.d.ts +1 -0
- package/dist/core/preferences.d.ts.map +1 -1
- package/dist/core/preferences.js +8 -1
- package/dist/core/preferences.js.map +1 -1
- package/dist/core/reliabilityPrompt.d.ts +9 -0
- package/dist/core/reliabilityPrompt.d.ts.map +1 -0
- package/dist/core/reliabilityPrompt.js +31 -0
- package/dist/core/reliabilityPrompt.js.map +1 -0
- package/dist/core/schemaValidator.js +3 -3
- package/dist/core/schemaValidator.js.map +1 -1
- package/dist/core/toolPreconditions.d.ts +0 -11
- package/dist/core/toolPreconditions.d.ts.map +1 -1
- package/dist/core/toolPreconditions.js +33 -164
- package/dist/core/toolPreconditions.js.map +1 -1
- package/dist/core/toolRuntime.d.ts.map +1 -1
- package/dist/core/toolRuntime.js +9 -114
- package/dist/core/toolRuntime.js.map +1 -1
- package/dist/core/updateChecker.d.ts +61 -1
- package/dist/core/updateChecker.d.ts.map +1 -1
- package/dist/core/updateChecker.js +147 -3
- package/dist/core/updateChecker.js.map +1 -1
- package/dist/headless/headlessApp.d.ts.map +1 -1
- package/dist/headless/headlessApp.js +0 -39
- package/dist/headless/headlessApp.js.map +1 -1
- package/dist/plugins/tools/nodeDefaults.d.ts.map +1 -1
- package/dist/plugins/tools/nodeDefaults.js +0 -2
- package/dist/plugins/tools/nodeDefaults.js.map +1 -1
- package/dist/providers/openaiResponsesProvider.d.ts.map +1 -1
- package/dist/providers/openaiResponsesProvider.js +79 -74
- package/dist/providers/openaiResponsesProvider.js.map +1 -1
- package/dist/runtime/agentController.d.ts.map +1 -1
- package/dist/runtime/agentController.js +6 -3
- package/dist/runtime/agentController.js.map +1 -1
- package/dist/runtime/agentSession.d.ts +0 -2
- package/dist/runtime/agentSession.d.ts.map +1 -1
- package/dist/runtime/agentSession.js +2 -2
- package/dist/runtime/agentSession.js.map +1 -1
- package/dist/shell/interactiveShell.d.ts +11 -18
- package/dist/shell/interactiveShell.d.ts.map +1 -1
- package/dist/shell/interactiveShell.js +273 -291
- package/dist/shell/interactiveShell.js.map +1 -1
- package/dist/shell/shellApp.d.ts.map +1 -1
- package/dist/shell/shellApp.js +7 -1
- package/dist/shell/shellApp.js.map +1 -1
- package/dist/shell/systemPrompt.d.ts.map +1 -1
- package/dist/shell/systemPrompt.js +4 -15
- package/dist/shell/systemPrompt.js.map +1 -1
- package/dist/subagents/taskRunner.js +2 -1
- package/dist/subagents/taskRunner.js.map +1 -1
- package/dist/tools/bashTools.d.ts.map +1 -1
- package/dist/tools/bashTools.js +101 -8
- package/dist/tools/bashTools.js.map +1 -1
- package/dist/tools/diffUtils.d.ts +8 -2
- package/dist/tools/diffUtils.d.ts.map +1 -1
- package/dist/tools/diffUtils.js +72 -13
- package/dist/tools/diffUtils.js.map +1 -1
- package/dist/tools/grepTools.d.ts.map +1 -1
- package/dist/tools/grepTools.js +10 -2
- package/dist/tools/grepTools.js.map +1 -1
- package/dist/tools/planningTools.d.ts +0 -10
- package/dist/tools/planningTools.d.ts.map +1 -1
- package/dist/tools/planningTools.js +0 -16
- package/dist/tools/planningTools.js.map +1 -1
- package/dist/tools/searchTools.d.ts.map +1 -1
- package/dist/tools/searchTools.js +4 -2
- package/dist/tools/searchTools.js.map +1 -1
- package/dist/ui/PromptController.d.ts +1 -4
- package/dist/ui/PromptController.d.ts.map +1 -1
- package/dist/ui/PromptController.js +1 -7
- package/dist/ui/PromptController.js.map +1 -1
- package/dist/ui/ShellUIAdapter.d.ts +292 -28
- package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
- package/dist/ui/ShellUIAdapter.js +1513 -121
- package/dist/ui/ShellUIAdapter.js.map +1 -1
- package/dist/ui/UnifiedUIController.d.ts +81 -0
- package/dist/ui/UnifiedUIController.d.ts.map +1 -0
- package/dist/ui/UnifiedUIController.js +212 -0
- package/dist/ui/UnifiedUIController.js.map +1 -0
- package/dist/ui/UnifiedUIRenderer.d.ts +133 -30
- package/dist/ui/UnifiedUIRenderer.d.ts.map +1 -1
- package/dist/ui/UnifiedUIRenderer.js +939 -370
- package/dist/ui/UnifiedUIRenderer.js.map +1 -1
- package/dist/ui/animatedStatus.d.ts +128 -6
- package/dist/ui/animatedStatus.d.ts.map +1 -1
- package/dist/ui/animatedStatus.js +383 -50
- package/dist/ui/animatedStatus.js.map +1 -1
- package/dist/ui/animation/AnimationScheduler.d.ts +192 -0
- package/dist/ui/animation/AnimationScheduler.d.ts.map +1 -0
- package/dist/ui/animation/AnimationScheduler.js +432 -0
- package/dist/ui/animation/AnimationScheduler.js.map +1 -0
- package/dist/ui/display.d.ts +182 -26
- package/dist/ui/display.d.ts.map +1 -1
- package/dist/ui/display.js +678 -97
- package/dist/ui/display.js.map +1 -1
- package/dist/ui/inPlaceUpdater.d.ts +181 -0
- package/dist/ui/inPlaceUpdater.d.ts.map +1 -0
- package/dist/ui/inPlaceUpdater.js +515 -0
- package/dist/ui/inPlaceUpdater.js.map +1 -0
- package/dist/ui/interrupts/InterruptManager.d.ts +142 -0
- package/dist/ui/interrupts/InterruptManager.d.ts.map +1 -0
- package/dist/ui/interrupts/InterruptManager.js +439 -0
- package/dist/ui/interrupts/InterruptManager.js.map +1 -0
- package/dist/ui/layout.d.ts +0 -1
- package/dist/ui/layout.d.ts.map +1 -1
- package/dist/ui/layout.js +0 -12
- package/dist/ui/layout.js.map +1 -1
- package/dist/ui/orchestration/UIUpdateCoordinator.d.ts +61 -7
- package/dist/ui/orchestration/UIUpdateCoordinator.d.ts.map +1 -1
- package/dist/ui/orchestration/UIUpdateCoordinator.js +232 -20
- package/dist/ui/orchestration/UIUpdateCoordinator.js.map +1 -1
- package/dist/ui/shortcutsHelp.d.ts.map +1 -1
- package/dist/ui/shortcutsHelp.js +0 -1
- package/dist/ui/shortcutsHelp.js.map +1 -1
- package/dist/ui/telemetry/ResponseTracker.d.ts +22 -0
- package/dist/ui/telemetry/ResponseTracker.d.ts.map +1 -0
- package/dist/ui/telemetry/ResponseTracker.js +60 -0
- package/dist/ui/telemetry/ResponseTracker.js.map +1 -0
- package/dist/ui/telemetry/UITelemetry.d.ts +181 -0
- package/dist/ui/telemetry/UITelemetry.d.ts.map +1 -0
- package/dist/ui/telemetry/UITelemetry.js +446 -0
- package/dist/ui/telemetry/UITelemetry.js.map +1 -0
- package/dist/ui/unified/index.d.ts +30 -1
- package/dist/ui/unified/index.d.ts.map +1 -1
- package/dist/ui/unified/index.js +45 -2
- package/dist/ui/unified/index.js.map +1 -1
- package/dist/ui/unified/layout.d.ts +12 -0
- package/dist/ui/unified/layout.d.ts.map +1 -0
- package/dist/ui/unified/layout.js +96 -0
- package/dist/ui/unified/layout.js.map +1 -0
- package/package.json +2 -3
- package/dist/StringUtils.d.ts +0 -8
- package/dist/StringUtils.d.ts.map +0 -1
- package/dist/StringUtils.js +0 -11
- package/dist/StringUtils.js.map +0 -1
- package/dist/core/aiFlowSupervisor.d.ts +0 -44
- package/dist/core/aiFlowSupervisor.d.ts.map +0 -1
- package/dist/core/aiFlowSupervisor.js +0 -299
- package/dist/core/aiFlowSupervisor.js.map +0 -1
- package/dist/core/cliTestHarness.d.ts +0 -200
- package/dist/core/cliTestHarness.d.ts.map +0 -1
- package/dist/core/cliTestHarness.js +0 -549
- package/dist/core/cliTestHarness.js.map +0 -1
- package/dist/core/testUtils.d.ts +0 -121
- package/dist/core/testUtils.d.ts.map +0 -1
- package/dist/core/testUtils.js +0 -235
- package/dist/core/testUtils.js.map +0 -1
- package/dist/core/toolValidation.d.ts +0 -116
- package/dist/core/toolValidation.d.ts.map +0 -1
- package/dist/core/toolValidation.js +0 -282
- package/dist/core/toolValidation.js.map +0 -1
- package/dist/ui/planOverlay.d.ts +0 -28
- package/dist/ui/planOverlay.d.ts.map +0 -1
- package/dist/ui/planOverlay.js +0 -156
- package/dist/ui/planOverlay.js.map +0 -1
- package/dist/ui/streamingFormatter.d.ts +0 -30
- package/dist/ui/streamingFormatter.d.ts.map +0 -1
- package/dist/ui/streamingFormatter.js +0 -91
- package/dist/ui/streamingFormatter.js.map +0 -1
- package/dist/utils/errorUtils.d.ts +0 -16
- package/dist/utils/errorUtils.d.ts.map +0 -1
- package/dist/utils/errorUtils.js +0 -66
- package/dist/utils/errorUtils.js.map +0 -1
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
import * as readline from 'node:readline';
|
|
12
12
|
import { EventEmitter } from 'node:events';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
|
-
import { theme } from './theme.js';
|
|
14
|
+
import { theme, spinnerFrames } from './theme.js';
|
|
15
15
|
import { isPlainOutputMode } from './outputMode.js';
|
|
16
|
-
import {
|
|
17
|
-
import { colorizeActivity, createFrameTicker, formatElapsed, formatTinyProgressBar, formatTokenDelta, } from './animatedStatus.js';
|
|
16
|
+
import { ContextMeter, disposeAnimations } from './animatedStatus.js';
|
|
18
17
|
const ESC = {
|
|
19
18
|
HIDE_CURSOR: '\x1b[?25l',
|
|
20
19
|
SHOW_CURSOR: '\x1b[?25h',
|
|
@@ -28,6 +27,11 @@ const ESC = {
|
|
|
28
27
|
ERASE_DOWN: '\x1b[J',
|
|
29
28
|
REVERSE: '\x1b[7m',
|
|
30
29
|
RESET: '\x1b[0m',
|
|
30
|
+
// Scroll region control - CRITICAL for fixed bottom overlay
|
|
31
|
+
SET_SCROLL_REGION: (top, bottom) => `\x1b[${top};${bottom}r`,
|
|
32
|
+
RESET_SCROLL_REGION: '\x1b[r',
|
|
33
|
+
SAVE_CURSOR: '\x1b[s',
|
|
34
|
+
RESTORE_CURSOR: '\x1b[u',
|
|
31
35
|
};
|
|
32
36
|
const COLLAPSE_HINT = theme.ui.muted('…'); // subtle ellipsis, no key hint
|
|
33
37
|
const NEWLINE_PLACEHOLDER = '↵';
|
|
@@ -52,13 +56,38 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
52
56
|
hotkeysInToggleLine = new Set();
|
|
53
57
|
collapsedPaste = null;
|
|
54
58
|
mode = 'idle';
|
|
59
|
+
streamingStartTime = null;
|
|
55
60
|
statusMessage = null;
|
|
56
61
|
statusOverride = null;
|
|
57
62
|
statusStreaming = null;
|
|
63
|
+
// Animated UI components
|
|
64
|
+
streamingSpinner = null;
|
|
65
|
+
thinkingIndicator = null;
|
|
66
|
+
contextMeter;
|
|
67
|
+
spinnerFrame = 0;
|
|
68
|
+
spinnerInterval = null;
|
|
69
|
+
// Compacting status animation
|
|
70
|
+
compactingStatusMessage = '';
|
|
71
|
+
compactingStatusFrame = 0;
|
|
72
|
+
compactingStatusInterval = null;
|
|
73
|
+
compactingSpinnerFrames = ['✻', '✼', '✻', '✺'];
|
|
74
|
+
// Animated activity line (e.g., "✳ Ruminating… (esc to interrupt · 34s · ↑1.2k)")
|
|
75
|
+
activityMessage = null;
|
|
76
|
+
activityPhraseIndex = 0;
|
|
77
|
+
activityStarFrame = 0;
|
|
78
|
+
activityStarFrames = ['✳', '✴', '✵', '✶', '✷', '✸'];
|
|
79
|
+
// Token count during streaming
|
|
80
|
+
streamingTokens = 0;
|
|
81
|
+
// Fun phrases to cycle through when no specific activity is provided
|
|
82
|
+
funActivityPhrases = [
|
|
83
|
+
'Moseying', 'Ruminating', 'Pondering', 'Cogitating', 'Mulling',
|
|
84
|
+
'Contemplating', 'Deliberating', 'Noodling', 'Percolating', 'Stewing',
|
|
85
|
+
'Brewing', 'Simmering', 'Churning', 'Puzzling', 'Meandering',
|
|
86
|
+
'Wandering', 'Musing', 'Daydreaming', 'Woolgathering', 'Chewing',
|
|
87
|
+
];
|
|
58
88
|
statusMeta = {};
|
|
59
89
|
toggleState = {
|
|
60
90
|
verificationEnabled: false,
|
|
61
|
-
autoContinueEnabled: false,
|
|
62
91
|
criticalApprovalMode: 'auto',
|
|
63
92
|
};
|
|
64
93
|
// ------------ Helpers ------------
|
|
@@ -70,16 +99,14 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
70
99
|
lastPromptEvent = null;
|
|
71
100
|
promptHeight = 0;
|
|
72
101
|
lastOverlayHeight = 0;
|
|
73
|
-
lastPromptIndex = 0;
|
|
74
|
-
overlayBottomPadding = 1;
|
|
75
102
|
inlinePanel = [];
|
|
76
|
-
persistentPanel = [];
|
|
77
|
-
overlayInvalidated = false;
|
|
78
103
|
hasConversationContent = false;
|
|
79
104
|
isPromptActive = false;
|
|
105
|
+
inputRenderOffset = 0;
|
|
80
106
|
plainPasteIdleMs = 24;
|
|
81
107
|
plainPasteWindowMs = 60;
|
|
82
108
|
plainPasteTriggerChars = 24;
|
|
109
|
+
cursorVisibleColumn = 1;
|
|
83
110
|
inBracketedPaste = false;
|
|
84
111
|
pasteBuffer = '';
|
|
85
112
|
inPlainPaste = false;
|
|
@@ -91,14 +118,9 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
91
118
|
lastRenderedEventKey = null;
|
|
92
119
|
lastOutputEndedWithNewline = true;
|
|
93
120
|
hasRenderedPrompt = false;
|
|
94
|
-
hasEverRenderedOverlay = false; // Track if we've ever rendered
|
|
121
|
+
hasEverRenderedOverlay = false; // Track if we've ever rendered for inline clearing
|
|
95
122
|
lastOverlay = null;
|
|
96
123
|
allowPromptRender = true;
|
|
97
|
-
streamingStart = null;
|
|
98
|
-
activityInterval = null;
|
|
99
|
-
activityTicker = createFrameTicker('sparkle');
|
|
100
|
-
renderedContextPercent = null;
|
|
101
|
-
lastToolResult = null;
|
|
102
124
|
inputCapture = null;
|
|
103
125
|
constructor(output = process.stdout, input = process.stdin, options) {
|
|
104
126
|
super();
|
|
@@ -106,6 +128,8 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
106
128
|
this.input = input;
|
|
107
129
|
this.interactive = Boolean(this.output.isTTY && this.input.isTTY && !process.env['CI']);
|
|
108
130
|
this.plainMode = isPlainOutputMode() || !this.interactive;
|
|
131
|
+
// Initialize animated components
|
|
132
|
+
this.contextMeter = new ContextMeter();
|
|
109
133
|
this.rl = readline.createInterface({
|
|
110
134
|
input: this.input,
|
|
111
135
|
output: this.output,
|
|
@@ -137,9 +161,6 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
137
161
|
this.updateTerminalSize();
|
|
138
162
|
this.hasRenderedPrompt = false;
|
|
139
163
|
this.lastOutputEndedWithNewline = true;
|
|
140
|
-
// Don't render prompt immediately - wait for banner/content to be added first.
|
|
141
|
-
// The prompt will render after the event queue processes the welcome banner.
|
|
142
|
-
// This prevents the prompt from appearing at the bottom before the banner shows.
|
|
143
164
|
this.write(ESC.SHOW_CURSOR);
|
|
144
165
|
return;
|
|
145
166
|
}
|
|
@@ -152,12 +173,27 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
152
173
|
cleanup() {
|
|
153
174
|
this.cancelInputCapture(new Error('Renderer disposed'));
|
|
154
175
|
this.cancelPlainPasteCapture();
|
|
155
|
-
|
|
176
|
+
// Stop any running animations
|
|
177
|
+
if (this.spinnerInterval) {
|
|
178
|
+
clearInterval(this.spinnerInterval);
|
|
179
|
+
this.spinnerInterval = null;
|
|
180
|
+
}
|
|
181
|
+
if (this.streamingSpinner) {
|
|
182
|
+
this.streamingSpinner.stop();
|
|
183
|
+
this.streamingSpinner = null;
|
|
184
|
+
}
|
|
185
|
+
if (this.thinkingIndicator) {
|
|
186
|
+
this.thinkingIndicator.stop();
|
|
187
|
+
this.thinkingIndicator = null;
|
|
188
|
+
}
|
|
189
|
+
this.contextMeter.dispose();
|
|
190
|
+
disposeAnimations();
|
|
156
191
|
if (!this.interactive) {
|
|
157
192
|
this.rl.close();
|
|
158
193
|
return;
|
|
159
194
|
}
|
|
160
195
|
if (!this.plainMode) {
|
|
196
|
+
// Clear the prompt area so it doesn't remain in scrollback history
|
|
161
197
|
this.clearPromptArea();
|
|
162
198
|
this.write(ESC.DISABLE_BRACKETED_PASTE);
|
|
163
199
|
this.write(ESC.SHOW_CURSOR);
|
|
@@ -209,14 +245,25 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
209
245
|
return;
|
|
210
246
|
}
|
|
211
247
|
if (key.ctrl && key.name === 'c') {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if
|
|
215
|
-
|
|
248
|
+
// Three-stage Ctrl+C behavior:
|
|
249
|
+
// 1. Clear chat box if it has text
|
|
250
|
+
// 2. Interrupt/pause the AI if streaming
|
|
251
|
+
// 3. Quit the CLI if already idle
|
|
252
|
+
if (this.buffer.length > 0) {
|
|
253
|
+
// Stage 1: Clear the input buffer
|
|
254
|
+
this.buffer = '';
|
|
255
|
+
this.cursor = 0;
|
|
256
|
+
this.renderPrompt();
|
|
257
|
+
this.emitInputChange();
|
|
216
258
|
}
|
|
217
259
|
else if (this.mode === 'streaming') {
|
|
260
|
+
// Stage 2: Interrupt the AI run
|
|
218
261
|
this.emit('interrupt');
|
|
219
262
|
}
|
|
263
|
+
else {
|
|
264
|
+
// Stage 3: Quit the CLI (emit exit signal)
|
|
265
|
+
this.emit('exit');
|
|
266
|
+
}
|
|
220
267
|
return;
|
|
221
268
|
}
|
|
222
269
|
if (key.ctrl && key.name === 'd') {
|
|
@@ -225,12 +272,6 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
225
272
|
}
|
|
226
273
|
return;
|
|
227
274
|
}
|
|
228
|
-
if (key.ctrl && key.name === 'o') {
|
|
229
|
-
if (!this.expandLastToolResult()) {
|
|
230
|
-
this.emit('expand-tool-result');
|
|
231
|
-
}
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
275
|
if (key.ctrl && key.name === 'u') {
|
|
235
276
|
this.clearBuffer();
|
|
236
277
|
return;
|
|
@@ -241,6 +282,11 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
241
282
|
return;
|
|
242
283
|
}
|
|
243
284
|
}
|
|
285
|
+
// Ctrl+O: Expand last tool result
|
|
286
|
+
if (key.ctrl && key.name === 'o') {
|
|
287
|
+
this.emit('expand-tool-result');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
244
290
|
if (key.name === 'return' || key.name === 'enter') {
|
|
245
291
|
if (this.collapsedPaste) {
|
|
246
292
|
this.expandCollapsedPaste();
|
|
@@ -250,9 +296,12 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
250
296
|
// If a slash command suggestion is highlighted, pressing Enter submits it immediately
|
|
251
297
|
if (this.applySuggestion(true))
|
|
252
298
|
return;
|
|
253
|
-
//
|
|
299
|
+
// Fallback: if buffer starts with '/' and suggestions exist, use the selected/first one
|
|
254
300
|
if (this.buffer.startsWith('/') && this.suggestions.length > 0) {
|
|
255
|
-
|
|
301
|
+
const safeIndex = this.suggestionIndex >= 0 && this.suggestionIndex < this.suggestions.length
|
|
302
|
+
? this.suggestionIndex
|
|
303
|
+
: 0;
|
|
304
|
+
this.buffer = this.suggestions[safeIndex]?.command ?? this.buffer;
|
|
256
305
|
}
|
|
257
306
|
this.submitText(this.buffer);
|
|
258
307
|
return;
|
|
@@ -298,18 +347,6 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
298
347
|
}
|
|
299
348
|
return;
|
|
300
349
|
}
|
|
301
|
-
if (key.name === 'home') {
|
|
302
|
-
this.cursor = 0;
|
|
303
|
-
this.renderPrompt();
|
|
304
|
-
this.emitInputChange();
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
if (key.name === 'end') {
|
|
308
|
-
this.cursor = this.buffer.length;
|
|
309
|
-
this.renderPrompt();
|
|
310
|
-
this.emitInputChange();
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
350
|
if (key.name === 'up') {
|
|
314
351
|
if (this.navigateSuggestions(-1)) {
|
|
315
352
|
return;
|
|
@@ -574,6 +611,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
574
611
|
this.inputCapture = null;
|
|
575
612
|
this.buffer = '';
|
|
576
613
|
this.cursor = 0;
|
|
614
|
+
this.inputRenderOffset = 0;
|
|
577
615
|
this.resetSuggestions();
|
|
578
616
|
this.renderPrompt();
|
|
579
617
|
this.emitInputChange();
|
|
@@ -640,7 +678,11 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
640
678
|
if (!this.buffer.startsWith('/') || this.suggestions.length === 0) {
|
|
641
679
|
return false;
|
|
642
680
|
}
|
|
643
|
-
|
|
681
|
+
// Ensure suggestionIndex is valid, default to first item
|
|
682
|
+
const safeIndex = this.suggestionIndex >= 0 && this.suggestionIndex < this.suggestions.length
|
|
683
|
+
? this.suggestionIndex
|
|
684
|
+
: 0;
|
|
685
|
+
const selected = this.suggestions[safeIndex];
|
|
644
686
|
if (!selected) {
|
|
645
687
|
return false;
|
|
646
688
|
}
|
|
@@ -673,22 +715,10 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
673
715
|
this.renderPrompt();
|
|
674
716
|
return true;
|
|
675
717
|
}
|
|
676
|
-
isGarbageOutput(content) {
|
|
677
|
-
const text = content.replace(/\u001B\[[0-9;]*m/gu, '').replace(/\s+/g, '');
|
|
678
|
-
if (!text)
|
|
679
|
-
return true;
|
|
680
|
-
if (text.length <= 2 && !/[a-zA-Z0-9]/.test(text))
|
|
681
|
-
return true;
|
|
682
|
-
if (text.length < 6 && /^[\.\-_,;:~`'"!@#$%^&*+=|\\/]+$/.test(text))
|
|
683
|
-
return true;
|
|
684
|
-
return false;
|
|
685
|
-
}
|
|
686
718
|
// ------------ Event queue ------------
|
|
687
|
-
addEvent(type, content
|
|
719
|
+
addEvent(type, content) {
|
|
688
720
|
if (!content)
|
|
689
721
|
return;
|
|
690
|
-
if (this.isGarbageOutput(content))
|
|
691
|
-
return;
|
|
692
722
|
const normalized = this.normalizeEventType(type);
|
|
693
723
|
if (!normalized)
|
|
694
724
|
return;
|
|
@@ -697,6 +727,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
697
727
|
normalized === 'thought' ||
|
|
698
728
|
normalized === 'stream' ||
|
|
699
729
|
normalized === 'tool' ||
|
|
730
|
+
normalized === 'tool-result' ||
|
|
700
731
|
normalized === 'build' ||
|
|
701
732
|
normalized === 'test') {
|
|
702
733
|
this.hasConversationContent = true;
|
|
@@ -720,7 +751,6 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
720
751
|
rawType: type,
|
|
721
752
|
content,
|
|
722
753
|
timestamp: Date.now(),
|
|
723
|
-
isCompacted: options?.compact === true,
|
|
724
754
|
};
|
|
725
755
|
// Priority queue: prompt events are inserted at the front to ensure immediate display
|
|
726
756
|
// This guarantees user input is echoed before any async processing responses
|
|
@@ -824,6 +854,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
824
854
|
if (event.type !== 'prompt') {
|
|
825
855
|
this.lastRenderedEventKey = signature;
|
|
826
856
|
}
|
|
857
|
+
// Clear the prompt area before writing new content
|
|
827
858
|
if (this.promptHeight > 0 || this.lastOverlay) {
|
|
828
859
|
this.clearPromptArea();
|
|
829
860
|
}
|
|
@@ -835,10 +866,6 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
835
866
|
}
|
|
836
867
|
this.output.write(formatted);
|
|
837
868
|
this.lastOutputEndedWithNewline = formatted.endsWith('\n');
|
|
838
|
-
// Overlay must be re-anchored after new scrollback is written
|
|
839
|
-
this.overlayInvalidated = true;
|
|
840
|
-
// Don't re-render prompt after every event - wait for queue to finish
|
|
841
|
-
// This prevents premature prompt rendering that cuts off responses
|
|
842
869
|
}
|
|
843
870
|
normalizeEventType(type) {
|
|
844
871
|
switch (type) {
|
|
@@ -851,8 +878,9 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
851
878
|
return 'stream';
|
|
852
879
|
case 'tool':
|
|
853
880
|
case 'tool-call':
|
|
854
|
-
case 'tool-result':
|
|
855
881
|
return 'tool';
|
|
882
|
+
case 'tool-result':
|
|
883
|
+
return 'tool-result';
|
|
856
884
|
case 'build':
|
|
857
885
|
return 'build';
|
|
858
886
|
case 'test':
|
|
@@ -868,83 +896,388 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
868
896
|
}
|
|
869
897
|
}
|
|
870
898
|
formatContent(event) {
|
|
871
|
-
const bullet = '⏺';
|
|
899
|
+
const bullet = '⏺'; // Claude Code uses plain bullet, no color
|
|
900
|
+
// Compacted blocks already have separator and formatting
|
|
872
901
|
if (event.isCompacted) {
|
|
873
902
|
return event.content;
|
|
874
903
|
}
|
|
875
904
|
if (event.rawType === 'banner') {
|
|
905
|
+
// Banners display without bullet prefix
|
|
876
906
|
const lines = event.content.split('\n').map(line => line.trimEnd());
|
|
877
907
|
return `${lines.join('\n')}\n`;
|
|
878
908
|
}
|
|
909
|
+
// Compact, user-friendly formatting
|
|
879
910
|
switch (event.type) {
|
|
880
911
|
case 'prompt':
|
|
881
|
-
|
|
912
|
+
// User prompt - just the text (prompt box handles styling)
|
|
913
|
+
return `${theme.primary('>')} ${event.content}\n`;
|
|
882
914
|
case 'thought': {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
if (event.rawType === 'tool-call') {
|
|
887
|
-
return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
|
|
915
|
+
// Programmatic filter: reject content that looks like internal/garbage output
|
|
916
|
+
if (this.isGarbageOutput(event.content)) {
|
|
917
|
+
return '';
|
|
888
918
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
}
|
|
892
|
-
|
|
919
|
+
// Strip any existing bullet prefix (○ or ⏺) and use consistent ⏺
|
|
920
|
+
const cleanContent = event.content.replace(/^[○⏺]\s*/, '');
|
|
921
|
+
return `⏺ ${cleanContent}\n`;
|
|
922
|
+
}
|
|
923
|
+
case 'tool': {
|
|
924
|
+
// Compact tool display: ⚡ToolName → result
|
|
925
|
+
const content = event.content.replace(/^[⏺⚙○]\s*/, '');
|
|
926
|
+
return this.formatCompactToolCall(content);
|
|
927
|
+
}
|
|
928
|
+
case 'tool-result': {
|
|
929
|
+
// Inline result: └─ summary
|
|
930
|
+
return this.formatCompactToolResult(event.content);
|
|
931
|
+
}
|
|
893
932
|
case 'build':
|
|
894
|
-
return
|
|
933
|
+
return `${bullet} ${theme.warning('Build')} ${theme.ui.muted('→')} ${event.content}\n`;
|
|
895
934
|
case 'test':
|
|
896
|
-
return
|
|
935
|
+
return `${bullet} ${theme.info('Test')} ${theme.ui.muted('→')} ${event.content}\n`;
|
|
897
936
|
case 'stream':
|
|
898
937
|
return event.content;
|
|
899
938
|
case 'response':
|
|
900
939
|
default: {
|
|
901
|
-
|
|
940
|
+
// Programmatic filter: reject content that looks like internal/garbage output
|
|
941
|
+
if (this.isGarbageOutput(event.content)) {
|
|
942
|
+
return '';
|
|
943
|
+
}
|
|
944
|
+
// Clean response without excessive bullets
|
|
945
|
+
return this.formatCompactResponse(event.content);
|
|
902
946
|
}
|
|
903
947
|
}
|
|
904
948
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
return `\n${prefix} ${content}\n`;
|
|
949
|
+
/**
|
|
950
|
+
* Programmatic garbage detection - checks if content looks like internal/system output
|
|
951
|
+
* that shouldn't be shown to users. Uses structural checks, not pattern matching.
|
|
952
|
+
*/
|
|
953
|
+
isGarbageOutput(content) {
|
|
954
|
+
if (!content || content.trim().length === 0)
|
|
955
|
+
return true;
|
|
956
|
+
// Structural check: content starting with < that isn't valid markdown/code
|
|
957
|
+
if (content.startsWith('<') && !content.startsWith('<http') && !content.startsWith('<!')) {
|
|
958
|
+
return true;
|
|
916
959
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
.map((line, index) => (index === 0 ? `${prefix} ${line}` : ` ${line}`))
|
|
921
|
-
.join('\n');
|
|
922
|
-
return `\n${body}\n`;
|
|
923
|
-
}
|
|
924
|
-
wrapText(content, width) {
|
|
925
|
-
if (!content) {
|
|
926
|
-
return [''];
|
|
960
|
+
// Structural check: contains "to=functions." or "to=tools." (internal routing)
|
|
961
|
+
if (content.includes('to=functions.') || content.includes('to=tools.')) {
|
|
962
|
+
return true;
|
|
927
963
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
964
|
+
// Structural check: looks like internal instruction (quoted system text)
|
|
965
|
+
if (content.startsWith('"') && content.includes('block') && content.includes('tool')) {
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
// Structural check: very short content that's just timing info
|
|
969
|
+
if (content.length < 30 && /elapsed|seconds?|ms\b/i.test(content)) {
|
|
970
|
+
return true;
|
|
971
|
+
}
|
|
972
|
+
// Structural check: gibberish - high ratio of non-word characters
|
|
973
|
+
const alphaCount = (content.match(/[a-zA-Z]/g) || []).length;
|
|
974
|
+
const totalCount = content.replace(/\s/g, '').length;
|
|
975
|
+
if (totalCount > 20 && alphaCount / totalCount < 0.5) {
|
|
976
|
+
return true; // Less than 50% letters = likely garbage
|
|
977
|
+
}
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Format text in Claude Code style: ⏺ prefix with wrapped continuation lines
|
|
982
|
+
* Example:
|
|
983
|
+
* ⏺ The AI ran tools but gave no response. Need to fix
|
|
984
|
+
* the response handling. Let me check where the AI's
|
|
985
|
+
* text response should be displayed:
|
|
986
|
+
*/
|
|
987
|
+
formatClaudeCodeBlock(content) {
|
|
988
|
+
const bullet = '⏺';
|
|
989
|
+
const maxWidth = Math.min(this.cols - 4, 56); // Leave room for prefix and margins
|
|
990
|
+
const lines = content.split('\n');
|
|
991
|
+
const result = [];
|
|
992
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
993
|
+
const line = lines[lineIdx];
|
|
994
|
+
if (!line.trim()) {
|
|
995
|
+
result.push('');
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
// Word-wrap each line
|
|
999
|
+
const words = line.split(/(\s+)/);
|
|
1000
|
+
let currentLine = '';
|
|
933
1001
|
for (const word of words) {
|
|
934
|
-
if (
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
current = word.trimStart();
|
|
1002
|
+
if ((currentLine + word).length > maxWidth && currentLine.trim()) {
|
|
1003
|
+
// First line of this paragraph gets ⏺, rest get indent
|
|
1004
|
+
const prefix = result.length === 0 && lineIdx === 0 ? `${bullet} ` : ' ';
|
|
1005
|
+
result.push(`${prefix}${currentLine.trimEnd()}`);
|
|
1006
|
+
currentLine = word.trimStart();
|
|
940
1007
|
}
|
|
941
1008
|
else {
|
|
942
|
-
|
|
1009
|
+
currentLine += word;
|
|
943
1010
|
}
|
|
944
1011
|
}
|
|
945
|
-
|
|
1012
|
+
if (currentLine.trim()) {
|
|
1013
|
+
const prefix = result.length === 0 && lineIdx === 0 ? `${bullet} ` : ' ';
|
|
1014
|
+
result.push(`${prefix}${currentLine.trimEnd()}`);
|
|
1015
|
+
}
|
|
946
1016
|
}
|
|
947
|
-
return
|
|
1017
|
+
return result.join('\n') + '\n';
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Format a tool call in Claude Code style:
|
|
1021
|
+
* ⏺ Search(pattern: "foo", path: "src",
|
|
1022
|
+
* output_mode: "content", head_limit: 30)
|
|
1023
|
+
*/
|
|
1024
|
+
formatToolCall(content) {
|
|
1025
|
+
const bullet = '⏺';
|
|
1026
|
+
// Parse tool name and arguments
|
|
1027
|
+
const match = content.match(/^(\w+)\((.*)\)$/s);
|
|
1028
|
+
if (!match) {
|
|
1029
|
+
// Simple format without args
|
|
1030
|
+
const nameMatch = content.match(/^(\w+)/);
|
|
1031
|
+
if (nameMatch) {
|
|
1032
|
+
return `${bullet} ${theme.info(nameMatch[1])}\n`;
|
|
1033
|
+
}
|
|
1034
|
+
return `${bullet} ${content}\n`;
|
|
1035
|
+
}
|
|
1036
|
+
const toolName = match[1];
|
|
1037
|
+
const argsStr = match[2];
|
|
1038
|
+
const maxWidth = Math.min(this.cols - 4, 56);
|
|
1039
|
+
// Format: ⏺ ToolName(args...)
|
|
1040
|
+
const prefix = `${bullet} ${theme.info(toolName)}(`;
|
|
1041
|
+
const prefixLen = toolName.length + 3; // "⏺ ToolName(" visible length
|
|
1042
|
+
const indent = ' '.repeat(prefixLen + 4); // Extra indent for wrapped args
|
|
1043
|
+
// Parse and format arguments
|
|
1044
|
+
const args = this.parseToolArgs(argsStr);
|
|
1045
|
+
if (args.length === 0) {
|
|
1046
|
+
return `${prefix})\n`;
|
|
1047
|
+
}
|
|
1048
|
+
const lines = [];
|
|
1049
|
+
let currentLine = prefix;
|
|
1050
|
+
for (let i = 0; i < args.length; i++) {
|
|
1051
|
+
const arg = args[i];
|
|
1052
|
+
const argText = `${theme.ui.muted(arg.key + ':')} ${this.formatArgValue(arg.value)}`;
|
|
1053
|
+
const separator = i < args.length - 1 ? ', ' : ')';
|
|
1054
|
+
// Check if this arg fits on current line
|
|
1055
|
+
const testLine = currentLine + argText + separator;
|
|
1056
|
+
if (this.stripAnsi(testLine).length > maxWidth && currentLine !== prefix) {
|
|
1057
|
+
lines.push(currentLine.trimEnd());
|
|
1058
|
+
currentLine = indent + argText + separator;
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
currentLine += argText + separator;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (currentLine.trim()) {
|
|
1065
|
+
lines.push(currentLine.trimEnd());
|
|
1066
|
+
}
|
|
1067
|
+
return lines.join('\n') + '\n';
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Parse tool arguments from string like: key: "value", key2: value2
|
|
1071
|
+
*/
|
|
1072
|
+
parseToolArgs(argsStr) {
|
|
1073
|
+
const args = [];
|
|
1074
|
+
// Simple regex to extract key: value pairs
|
|
1075
|
+
const regex = /(\w+):\s*("(?:[^"\\]|\\.)*"|[^,\)]+)/g;
|
|
1076
|
+
let match;
|
|
1077
|
+
while ((match = regex.exec(argsStr)) !== null) {
|
|
1078
|
+
args.push({ key: match[1], value: match[2].trim() });
|
|
1079
|
+
}
|
|
1080
|
+
return args;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Format an argument value (truncate long strings)
|
|
1084
|
+
*/
|
|
1085
|
+
formatArgValue(value) {
|
|
1086
|
+
// Remove surrounding quotes if present
|
|
1087
|
+
const isQuoted = value.startsWith('"') && value.endsWith('"');
|
|
1088
|
+
const inner = isQuoted ? value.slice(1, -1) : value;
|
|
1089
|
+
// Truncate long values
|
|
1090
|
+
const maxLen = 40;
|
|
1091
|
+
const truncated = inner.length > maxLen ? inner.slice(0, maxLen - 3) + '...' : inner;
|
|
1092
|
+
return isQuoted ? `"${truncated}"` : truncated;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Format a tool result in Claude Code style:
|
|
1096
|
+
* ⎿ Found 12 lines (ctrl+o to expand)
|
|
1097
|
+
*/
|
|
1098
|
+
formatToolResult(content) {
|
|
1099
|
+
// Check if this is a summary line (e.g., "Found X lines")
|
|
1100
|
+
const summaryMatch = content.match(/^(Found \d+ (?:lines?|files?|matches?)|Read \d+ lines?|Wrote \d+ lines?|Edited|Created|Deleted)/i);
|
|
1101
|
+
if (summaryMatch) {
|
|
1102
|
+
return ` ${theme.ui.muted('⎿')} ${content} ${theme.ui.muted('(ctrl+o to expand)')}\n`;
|
|
1103
|
+
}
|
|
1104
|
+
// For other results, show truncated preview
|
|
1105
|
+
const lines = content.split('\n');
|
|
1106
|
+
if (lines.length > 3) {
|
|
1107
|
+
const preview = lines.slice(0, 2).join('\n');
|
|
1108
|
+
return ` ${theme.ui.muted('⎿')} ${preview}\n ${theme.ui.muted(`... ${lines.length - 2} more lines (ctrl+o to expand)`)}\n`;
|
|
1109
|
+
}
|
|
1110
|
+
return ` ${theme.ui.muted('⎿')} ${content}\n`;
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Format a compact tool call: ⏺ Read → file.ts
|
|
1114
|
+
*/
|
|
1115
|
+
formatCompactToolCall(content) {
|
|
1116
|
+
const bullet = '⏺';
|
|
1117
|
+
// Parse tool name and args
|
|
1118
|
+
const match = content.match(/^(\w+)\s*(?:\((.*)\))?$/s);
|
|
1119
|
+
if (!match) {
|
|
1120
|
+
return `${bullet} ${content}\n`;
|
|
1121
|
+
}
|
|
1122
|
+
const toolName = match[1];
|
|
1123
|
+
const argsStr = match[2]?.trim() || '';
|
|
1124
|
+
// If no args, just show tool name
|
|
1125
|
+
if (!argsStr) {
|
|
1126
|
+
return `${bullet} ${theme.info(toolName)}\n`;
|
|
1127
|
+
}
|
|
1128
|
+
// Format full params in Claude Code style with line wrapping
|
|
1129
|
+
// For long args, wrap them nicely with continuation indent
|
|
1130
|
+
const prefix = `${bullet} ${theme.info(toolName)}(`;
|
|
1131
|
+
const suffix = ')';
|
|
1132
|
+
const maxWidth = this.cols - 8; // Leave room for margins
|
|
1133
|
+
// Parse individual params
|
|
1134
|
+
const params = this.parseToolParams(argsStr);
|
|
1135
|
+
if (params.length === 0) {
|
|
1136
|
+
return `${prefix}${argsStr}${suffix}\n`;
|
|
1137
|
+
}
|
|
1138
|
+
// Format params with proper wrapping
|
|
1139
|
+
return this.formatToolParams(toolName, params, maxWidth);
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Parse tool params from args string
|
|
1143
|
+
*/
|
|
1144
|
+
parseToolParams(argsStr) {
|
|
1145
|
+
const params = [];
|
|
1146
|
+
// Match key: "value" or key: value patterns
|
|
1147
|
+
const regex = /(\w+):\s*("(?:[^"\\]|\\.)*"|[^,\n]+)/g;
|
|
1148
|
+
let match;
|
|
1149
|
+
while ((match = regex.exec(argsStr)) !== null) {
|
|
1150
|
+
params.push({ key: match[1], value: match[2].trim() });
|
|
1151
|
+
}
|
|
1152
|
+
return params;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Format tool params in Claude Code style with wrapping
|
|
1156
|
+
*/
|
|
1157
|
+
formatToolParams(toolName, params, maxWidth) {
|
|
1158
|
+
const bullet = '⏺';
|
|
1159
|
+
const lines = [];
|
|
1160
|
+
const indent = ' '; // 8 spaces for continuation
|
|
1161
|
+
let currentLine = `${bullet} ${theme.info(toolName)}(`;
|
|
1162
|
+
let firstParam = true;
|
|
1163
|
+
for (const param of params) {
|
|
1164
|
+
const paramStr = firstParam
|
|
1165
|
+
? `${param.key}: ${param.value}`
|
|
1166
|
+
: `, ${param.key}: ${param.value}`;
|
|
1167
|
+
// Check if adding this param would exceed width
|
|
1168
|
+
const testLine = currentLine + paramStr;
|
|
1169
|
+
const plainLength = testLine.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
1170
|
+
if (plainLength > maxWidth && !firstParam) {
|
|
1171
|
+
// Start new line
|
|
1172
|
+
lines.push(currentLine);
|
|
1173
|
+
currentLine = indent + `${param.key}: ${param.value}`;
|
|
1174
|
+
}
|
|
1175
|
+
else {
|
|
1176
|
+
currentLine += paramStr;
|
|
1177
|
+
}
|
|
1178
|
+
firstParam = false;
|
|
1179
|
+
}
|
|
1180
|
+
currentLine += ')';
|
|
1181
|
+
lines.push(currentLine);
|
|
1182
|
+
return lines.join('\n') + '\n';
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Extract a short summary from tool args
|
|
1186
|
+
*/
|
|
1187
|
+
extractToolSummary(toolName, argsStr) {
|
|
1188
|
+
const tool = toolName.toLowerCase();
|
|
1189
|
+
// Extract path/file for file operations
|
|
1190
|
+
if (['read', 'write', 'edit', 'glob', 'grep', 'search'].includes(tool)) {
|
|
1191
|
+
const pathMatch = argsStr.match(/(?:path|file_path|pattern):\s*"([^"]+)"/);
|
|
1192
|
+
if (pathMatch) {
|
|
1193
|
+
const path = pathMatch[1];
|
|
1194
|
+
// Shorten long paths
|
|
1195
|
+
const short = path.length > 30 ? '…' + path.slice(-28) : path;
|
|
1196
|
+
return theme.ui.muted(short);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
// Extract command for bash
|
|
1200
|
+
if (tool === 'bash') {
|
|
1201
|
+
const cmdMatch = argsStr.match(/command:\s*"([^"]+)"/);
|
|
1202
|
+
if (cmdMatch) {
|
|
1203
|
+
const cmd = cmdMatch[1];
|
|
1204
|
+
const short = cmd.length > 40 ? cmd.slice(0, 37) + '…' : cmd;
|
|
1205
|
+
return theme.ui.muted(short);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
return null;
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Format a compact tool result: ⎿ Found X lines (ctrl+o to expand)
|
|
1212
|
+
*/
|
|
1213
|
+
formatCompactToolResult(content) {
|
|
1214
|
+
// Parse common result patterns for summary
|
|
1215
|
+
const lineMatch = content.match(/(\d+)\s*lines?/i);
|
|
1216
|
+
const fileMatch = content.match(/(\d+)\s*(?:files?|matches?)/i);
|
|
1217
|
+
const readMatch = content.match(/read.*?(\d+)\s*lines?/i);
|
|
1218
|
+
let summary;
|
|
1219
|
+
if (readMatch) {
|
|
1220
|
+
summary = `Read ${readMatch[1]} lines`;
|
|
1221
|
+
}
|
|
1222
|
+
else if (lineMatch) {
|
|
1223
|
+
summary = `Found ${lineMatch[1]} line${lineMatch[1] === '1' ? '' : 's'}`;
|
|
1224
|
+
}
|
|
1225
|
+
else if (fileMatch) {
|
|
1226
|
+
summary = `Found ${fileMatch[1]} file${fileMatch[1] === '1' ? '' : 's'}`;
|
|
1227
|
+
}
|
|
1228
|
+
else if (content.match(/^(success|ok|done|completed|written|edited|created)/i)) {
|
|
1229
|
+
summary = '✓';
|
|
1230
|
+
}
|
|
1231
|
+
else {
|
|
1232
|
+
// Use content directly, truncated if needed
|
|
1233
|
+
summary = content.length > 40 ? content.slice(0, 37) + '…' : content;
|
|
1234
|
+
}
|
|
1235
|
+
return ` ${theme.ui.muted('⎿')} ${summary} ${theme.ui.muted('(ctrl+o to expand)')}\n`;
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Format a compact response with bullet on first line
|
|
1239
|
+
*/
|
|
1240
|
+
formatCompactResponse(content) {
|
|
1241
|
+
const bullet = '⏺';
|
|
1242
|
+
const trimmed = content.trim();
|
|
1243
|
+
if (!trimmed)
|
|
1244
|
+
return '';
|
|
1245
|
+
// Single line responses - bullet prefix
|
|
1246
|
+
if (!trimmed.includes('\n') && trimmed.length < 80) {
|
|
1247
|
+
return `${bullet} ${trimmed}\n`;
|
|
1248
|
+
}
|
|
1249
|
+
// Multi-line: bullet on first, indent continuation
|
|
1250
|
+
const lines = trimmed.split('\n');
|
|
1251
|
+
const result = [];
|
|
1252
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1253
|
+
const line = lines[i].trimEnd();
|
|
1254
|
+
if (!line) {
|
|
1255
|
+
result.push('');
|
|
1256
|
+
}
|
|
1257
|
+
else if (i === 0) {
|
|
1258
|
+
result.push(`${bullet} ${line}`);
|
|
1259
|
+
}
|
|
1260
|
+
else {
|
|
1261
|
+
result.push(` ${line}`);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return result.join('\n') + '\n';
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Format streaming elapsed time in Claude Code style: 3m 30s
|
|
1268
|
+
*/
|
|
1269
|
+
formatStreamingElapsed() {
|
|
1270
|
+
if (!this.streamingStartTime)
|
|
1271
|
+
return null;
|
|
1272
|
+
const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
|
|
1273
|
+
if (elapsed < 5)
|
|
1274
|
+
return null; // Don't show for very short durations
|
|
1275
|
+
const mins = Math.floor(elapsed / 60);
|
|
1276
|
+
const secs = elapsed % 60;
|
|
1277
|
+
if (mins > 0) {
|
|
1278
|
+
return `${mins}m ${secs}s`;
|
|
1279
|
+
}
|
|
1280
|
+
return `${secs}s`;
|
|
948
1281
|
}
|
|
949
1282
|
/**
|
|
950
1283
|
* Format a compact conversation block (Claude Code style)
|
|
@@ -981,26 +1314,81 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
981
1314
|
setMode(mode) {
|
|
982
1315
|
const wasStreaming = this.mode === 'streaming';
|
|
983
1316
|
this.mode = mode;
|
|
1317
|
+
// Track streaming start time for elapsed display
|
|
1318
|
+
if (mode === 'streaming' && !wasStreaming) {
|
|
1319
|
+
this.streamingStartTime = Date.now();
|
|
1320
|
+
this.streamingTokens = 0; // Reset token count
|
|
1321
|
+
this.startSpinnerAnimation();
|
|
1322
|
+
}
|
|
1323
|
+
else if (mode === 'idle' && wasStreaming) {
|
|
1324
|
+
this.streamingStartTime = null;
|
|
1325
|
+
this.stopSpinnerAnimation();
|
|
1326
|
+
}
|
|
984
1327
|
if (wasStreaming && mode === 'idle' && !this.lastOutputEndedWithNewline) {
|
|
985
1328
|
// Finish streaming on a fresh line so the next prompt/event doesn't collide
|
|
986
1329
|
this.write('\n');
|
|
987
1330
|
this.lastOutputEndedWithNewline = true;
|
|
988
1331
|
}
|
|
989
|
-
if (
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
}
|
|
993
|
-
this.startActivityTimer();
|
|
1332
|
+
if (!this.plainMode) {
|
|
1333
|
+
// Always render prompt to keep bottom UI persistent (rich mode only)
|
|
1334
|
+
this.renderPrompt();
|
|
994
1335
|
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Start the animated spinner for streaming status
|
|
1339
|
+
*/
|
|
1340
|
+
startSpinnerAnimation() {
|
|
1341
|
+
if (this.spinnerInterval)
|
|
1342
|
+
return; // Already running
|
|
1343
|
+
this.spinnerFrame = 0;
|
|
1344
|
+
this.activityStarFrame = 0;
|
|
1345
|
+
this.spinnerInterval = setInterval(() => {
|
|
1346
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % spinnerFrames.braille.length;
|
|
1347
|
+
this.activityStarFrame = (this.activityStarFrame + 1) % this.activityStarFrames.length;
|
|
1348
|
+
// Re-render to show updated spinner/star frame
|
|
1349
|
+
if (!this.plainMode && this.mode === 'streaming') {
|
|
1350
|
+
this.renderPrompt();
|
|
1351
|
+
}
|
|
1352
|
+
}, 80); // ~12 FPS for smooth spinner animation
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Stop the animated spinner
|
|
1356
|
+
*/
|
|
1357
|
+
stopSpinnerAnimation() {
|
|
1358
|
+
if (this.spinnerInterval) {
|
|
1359
|
+
clearInterval(this.spinnerInterval);
|
|
1360
|
+
this.spinnerInterval = null;
|
|
998
1361
|
}
|
|
1362
|
+
this.spinnerFrame = 0;
|
|
1363
|
+
this.activityStarFrame = 0;
|
|
1364
|
+
this.activityMessage = null;
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Set the activity message displayed with animated star
|
|
1368
|
+
* Example: "Ruminating…" shows as "✳ Ruminating… (esc to interrupt · 34s · ↑1.2k)"
|
|
1369
|
+
*/
|
|
1370
|
+
setActivity(message) {
|
|
1371
|
+
this.activityMessage = message;
|
|
999
1372
|
if (!this.plainMode) {
|
|
1000
|
-
// Always render prompt to keep bottom UI persistent (rich mode only)
|
|
1001
1373
|
this.renderPrompt();
|
|
1002
1374
|
}
|
|
1003
1375
|
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Update the token count displayed in the activity line
|
|
1378
|
+
*/
|
|
1379
|
+
updateStreamingTokens(tokens) {
|
|
1380
|
+
this.streamingTokens = tokens;
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Format token count as compact string (e.g., 1.2k, 24k, 128k)
|
|
1384
|
+
*/
|
|
1385
|
+
formatTokenCount(tokens) {
|
|
1386
|
+
if (tokens < 1000)
|
|
1387
|
+
return String(tokens);
|
|
1388
|
+
if (tokens < 10000)
|
|
1389
|
+
return `${(tokens / 1000).toFixed(1)}k`;
|
|
1390
|
+
return `${Math.round(tokens / 1000)}k`;
|
|
1391
|
+
}
|
|
1004
1392
|
getMode() {
|
|
1005
1393
|
return this.mode;
|
|
1006
1394
|
}
|
|
@@ -1039,15 +1427,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1039
1427
|
}
|
|
1040
1428
|
}
|
|
1041
1429
|
updateStatusMeta(meta, options = {}) {
|
|
1042
|
-
const next = { ...this.statusMeta };
|
|
1043
|
-
for (const [key, value] of Object.entries(meta)) {
|
|
1044
|
-
if (value === null || value === undefined) {
|
|
1045
|
-
delete next[key];
|
|
1046
|
-
}
|
|
1047
|
-
else {
|
|
1048
|
-
next[key] = value;
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1430
|
+
const next = { ...this.statusMeta, ...meta };
|
|
1051
1431
|
const changed = JSON.stringify(next) !== JSON.stringify(this.statusMeta);
|
|
1052
1432
|
this.statusMeta = next;
|
|
1053
1433
|
const shouldRender = options.render !== false && changed;
|
|
@@ -1057,8 +1437,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1057
1437
|
}
|
|
1058
1438
|
updateModeToggles(state) {
|
|
1059
1439
|
this.toggleState = { ...this.toggleState, ...state };
|
|
1060
|
-
if (!state.
|
|
1061
|
-
!state.verificationHotkey &&
|
|
1440
|
+
if (!state.verificationHotkey &&
|
|
1062
1441
|
!state.thinkingHotkey &&
|
|
1063
1442
|
!state.criticalApprovalHotkey) {
|
|
1064
1443
|
this.hotkeysInToggleLine.clear();
|
|
@@ -1081,51 +1460,6 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1081
1460
|
this.inlinePanel = [];
|
|
1082
1461
|
this.renderPrompt();
|
|
1083
1462
|
}
|
|
1084
|
-
setPersistentPanel(lines) {
|
|
1085
|
-
const normalized = (lines ?? [])
|
|
1086
|
-
.map(line => line.replace(/\s+$/g, ''))
|
|
1087
|
-
.filter(line => line.trim().length > 0);
|
|
1088
|
-
if (JSON.stringify(normalized) === JSON.stringify(this.persistentPanel)) {
|
|
1089
|
-
return;
|
|
1090
|
-
}
|
|
1091
|
-
this.persistentPanel = normalized;
|
|
1092
|
-
this.renderPrompt();
|
|
1093
|
-
}
|
|
1094
|
-
clearPersistentPanel() {
|
|
1095
|
-
if (!this.persistentPanel.length)
|
|
1096
|
-
return;
|
|
1097
|
-
this.persistentPanel = [];
|
|
1098
|
-
this.renderPrompt();
|
|
1099
|
-
}
|
|
1100
|
-
startActivityTimer() {
|
|
1101
|
-
if (this.activityInterval || this.plainMode || !this.interactive) {
|
|
1102
|
-
return;
|
|
1103
|
-
}
|
|
1104
|
-
this.activityInterval = setInterval(() => {
|
|
1105
|
-
if (this.allowPromptRender) {
|
|
1106
|
-
this.renderPrompt();
|
|
1107
|
-
}
|
|
1108
|
-
}, 120);
|
|
1109
|
-
}
|
|
1110
|
-
stopActivityTimer() {
|
|
1111
|
-
if (this.activityInterval) {
|
|
1112
|
-
clearInterval(this.activityInterval);
|
|
1113
|
-
this.activityInterval = null;
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
rememberToolResult(content, summary) {
|
|
1117
|
-
const normalized = content?.trim();
|
|
1118
|
-
if (!normalized)
|
|
1119
|
-
return;
|
|
1120
|
-
this.lastToolResult = { full: normalized, summary: summary?.trim() || undefined };
|
|
1121
|
-
}
|
|
1122
|
-
expandLastToolResult() {
|
|
1123
|
-
if (!this.lastToolResult) {
|
|
1124
|
-
return false;
|
|
1125
|
-
}
|
|
1126
|
-
this.addEvent('tool-result', this.lastToolResult.full);
|
|
1127
|
-
return true;
|
|
1128
|
-
}
|
|
1129
1463
|
// ------------ Prompt rendering ------------
|
|
1130
1464
|
renderPrompt() {
|
|
1131
1465
|
if (!this.interactive) {
|
|
@@ -1139,6 +1473,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1139
1473
|
this.lastOutputEndedWithNewline = true;
|
|
1140
1474
|
}
|
|
1141
1475
|
this.write(`\r${ESC.CLEAR_LINE}${line}`);
|
|
1476
|
+
this.cursorVisibleColumn = line.length + 1;
|
|
1142
1477
|
this.hasRenderedPrompt = true;
|
|
1143
1478
|
this.isPromptActive = true;
|
|
1144
1479
|
this.lastOutputEndedWithNewline = false; // prompt ends mid-line by design
|
|
@@ -1148,23 +1483,9 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1148
1483
|
if (!this.allowPromptRender) {
|
|
1149
1484
|
return;
|
|
1150
1485
|
}
|
|
1151
|
-
|
|
1152
|
-
this.renderedContextPercent =
|
|
1153
|
-
this.renderedContextPercent === null
|
|
1154
|
-
? this.statusMeta.contextPercent
|
|
1155
|
-
: this.renderedContextPercent +
|
|
1156
|
-
(this.statusMeta.contextPercent - this.renderedContextPercent) * 0.35;
|
|
1157
|
-
}
|
|
1158
|
-
else {
|
|
1159
|
-
this.renderedContextPercent = null;
|
|
1160
|
-
}
|
|
1161
|
-
// Rich mode: inline overlay anchored to current scrollback (no full-screen clear)
|
|
1486
|
+
// Rich inline mode: prompt flows naturally with content
|
|
1162
1487
|
this.updateTerminalSize();
|
|
1163
1488
|
const maxWidth = this.safeWidth();
|
|
1164
|
-
if (this.lastRenderWidth !== null && maxWidth !== this.lastRenderWidth) {
|
|
1165
|
-
// Terminal resized; force a clean anchor so the overlay doesn't jitter.
|
|
1166
|
-
this.overlayInvalidated = true;
|
|
1167
|
-
}
|
|
1168
1489
|
this.lastRenderWidth = maxWidth;
|
|
1169
1490
|
const overlay = this.buildOverlayLines();
|
|
1170
1491
|
if (!overlay.lines.length) {
|
|
@@ -1174,99 +1495,188 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1174
1495
|
if (!renderedLines.length) {
|
|
1175
1496
|
return;
|
|
1176
1497
|
}
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
//
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
this.
|
|
1498
|
+
const promptIndex = Math.max(0, Math.min(overlay.promptIndex, renderedLines.length - 1));
|
|
1499
|
+
const height = renderedLines.length;
|
|
1500
|
+
// Clear previous prompt and handle height changes
|
|
1501
|
+
if (this.hasEverRenderedOverlay && this.lastOverlayHeight > 0 && this.lastOverlay) {
|
|
1502
|
+
// Move up from prompt row to top of overlay
|
|
1503
|
+
const linesToTop = this.lastOverlay.promptIndex;
|
|
1504
|
+
if (linesToTop > 0) {
|
|
1505
|
+
this.write(`\x1b[${linesToTop}A`);
|
|
1506
|
+
}
|
|
1507
|
+
// Clear all previous lines
|
|
1508
|
+
for (let i = 0; i < this.lastOverlayHeight; i++) {
|
|
1509
|
+
this.write('\r');
|
|
1510
|
+
this.write(ESC.CLEAR_LINE);
|
|
1511
|
+
if (i < this.lastOverlayHeight - 1) {
|
|
1512
|
+
this.write('\x1b[B');
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
// If new height is greater, we need to add blank lines
|
|
1516
|
+
const extraLines = height - this.lastOverlayHeight;
|
|
1517
|
+
if (extraLines > 0) {
|
|
1518
|
+
for (let i = 0; i < extraLines; i++) {
|
|
1519
|
+
this.write('\n');
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
// Move back to top of where overlay should start
|
|
1523
|
+
const moveBackUp = Math.max(0, height - 1);
|
|
1524
|
+
if (moveBackUp > 0) {
|
|
1525
|
+
this.write(`\x1b[${moveBackUp}A`);
|
|
1526
|
+
}
|
|
1196
1527
|
}
|
|
1197
|
-
//
|
|
1198
|
-
for (let
|
|
1199
|
-
|
|
1200
|
-
const line = renderedLines[idx] ?? '';
|
|
1201
|
-
this.write(ESC.TO(row, 1));
|
|
1528
|
+
// Write prompt lines (no trailing newline on last line)
|
|
1529
|
+
for (let i = 0; i < renderedLines.length; i++) {
|
|
1530
|
+
this.write('\r');
|
|
1202
1531
|
this.write(ESC.CLEAR_LINE);
|
|
1203
|
-
|
|
1204
|
-
|
|
1532
|
+
this.write(renderedLines[i] || '');
|
|
1533
|
+
if (i < renderedLines.length - 1) {
|
|
1534
|
+
this.write('\n');
|
|
1205
1535
|
}
|
|
1206
1536
|
}
|
|
1207
|
-
// Position cursor at prompt
|
|
1208
|
-
|
|
1537
|
+
// Position cursor at prompt input line
|
|
1538
|
+
const promptCol = Math.min(Math.max(1, 3 + this.cursor), this.cols || 80);
|
|
1539
|
+
// Cursor is now at the last line. Move up to the prompt row.
|
|
1540
|
+
const linesToMoveUp = height - 1 - promptIndex;
|
|
1541
|
+
if (linesToMoveUp > 0) {
|
|
1542
|
+
this.write(`\x1b[${linesToMoveUp}A`);
|
|
1543
|
+
}
|
|
1544
|
+
this.write(`\x1b[${promptCol}G`);
|
|
1545
|
+
this.cursorVisibleColumn = promptCol;
|
|
1209
1546
|
this.hasRenderedPrompt = true;
|
|
1210
|
-
this.hasEverRenderedOverlay = true;
|
|
1547
|
+
this.hasEverRenderedOverlay = true;
|
|
1211
1548
|
this.isPromptActive = true;
|
|
1212
1549
|
this.lastOverlayHeight = height;
|
|
1213
|
-
this.lastPromptIndex = promptIndex;
|
|
1214
1550
|
this.lastOverlay = { lines: renderedLines, promptIndex };
|
|
1215
|
-
this.
|
|
1216
|
-
this.lastOutputEndedWithNewline = true;
|
|
1551
|
+
this.lastOutputEndedWithNewline = false;
|
|
1217
1552
|
this.promptHeight = height;
|
|
1218
1553
|
}
|
|
1219
1554
|
buildOverlayLines() {
|
|
1220
1555
|
const lines = [];
|
|
1221
1556
|
const maxWidth = this.safeWidth();
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1557
|
+
// Simple horizontal divider - clean and reliable
|
|
1558
|
+
const divider = theme.ui.muted('─'.repeat(Math.min(maxWidth, 56)));
|
|
1559
|
+
// Activity line (only when streaming) - shows: ✽ Moseying… (esc to interrupt · 34s)
|
|
1560
|
+
if (this.mode === 'streaming' && this.activityMessage) {
|
|
1561
|
+
// Animated sparkle
|
|
1562
|
+
const spinnerChars = ['✽', '✾', '✿', '❀', '❁', '❂', '❃', '✻'];
|
|
1563
|
+
const spinnerChar = spinnerChars[this.spinnerFrame % spinnerChars.length] ?? '✽';
|
|
1564
|
+
const elapsed = this.formatStreamingElapsed();
|
|
1565
|
+
// Use fun phrases for generic activity, otherwise show specific activity
|
|
1566
|
+
const genericActivities = ['Streaming', 'Thinking', 'Processing'];
|
|
1567
|
+
const displayActivity = genericActivities.includes(this.activityMessage)
|
|
1568
|
+
? this.funActivityPhrases[this.activityPhraseIndex % this.funActivityPhrases.length]
|
|
1569
|
+
: this.activityMessage;
|
|
1570
|
+
// Format: ✽ Moseying… (esc to interrupt · 1m 19s · ↑1.2k tokens)
|
|
1571
|
+
const parts = ['esc to interrupt'];
|
|
1572
|
+
if (elapsed)
|
|
1573
|
+
parts.push(elapsed);
|
|
1574
|
+
if (this.streamingTokens > 0) {
|
|
1575
|
+
parts.push(`↑${this.formatTokenCount(this.streamingTokens)} tokens`);
|
|
1576
|
+
}
|
|
1577
|
+
const activityLine = `${theme.info(spinnerChar)} ${displayActivity}… ${theme.ui.muted(`(${parts.join(' · ')})`)}`;
|
|
1578
|
+
lines.push(this.truncateLine(activityLine, maxWidth));
|
|
1579
|
+
}
|
|
1580
|
+
// Top divider
|
|
1581
|
+
lines.push(divider);
|
|
1582
|
+
// Input prompt line
|
|
1583
|
+
const promptIndex = lines.length;
|
|
1584
|
+
const inputLine = this.buildInputLine();
|
|
1585
|
+
// Handle multi-line input by splitting on newlines
|
|
1586
|
+
const inputLines = inputLine.split('\n');
|
|
1587
|
+
for (const line of inputLines) {
|
|
1231
1588
|
lines.push(this.truncateLine(line, maxWidth));
|
|
1232
1589
|
}
|
|
1233
|
-
|
|
1590
|
+
// Bottom divider
|
|
1591
|
+
lines.push(divider);
|
|
1592
|
+
// Inline panel (pinned scroll box for live output/menus)
|
|
1234
1593
|
if (this.inlinePanel.length > 0) {
|
|
1235
1594
|
for (const panelLine of this.inlinePanel) {
|
|
1236
|
-
lines.push(this.truncateLine(panelLine
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
if (this.persistentPanel.length > 0) {
|
|
1240
|
-
for (const panelLine of this.persistentPanel) {
|
|
1241
|
-
lines.push(this.truncateLine(panelLine, maxWidth));
|
|
1595
|
+
lines.push(this.truncateLine(` ${panelLine}`, maxWidth));
|
|
1242
1596
|
}
|
|
1597
|
+
// Separate inline content from suggestions/toggles
|
|
1598
|
+
lines.push(divider);
|
|
1243
1599
|
}
|
|
1600
|
+
// Slash command suggestions
|
|
1244
1601
|
if (this.suggestions.length > 0) {
|
|
1245
1602
|
for (let index = 0; index < this.suggestions.length; index++) {
|
|
1246
1603
|
const suggestion = this.suggestions[index];
|
|
1247
1604
|
const isActive = index === this.suggestionIndex;
|
|
1248
|
-
const marker = isActive ? theme.primary('
|
|
1605
|
+
const marker = isActive ? theme.primary('▸') : theme.ui.muted(' ');
|
|
1249
1606
|
const cmdText = isActive ? theme.primary(suggestion.command) : theme.ui.muted(suggestion.command);
|
|
1250
1607
|
const descText = isActive ? suggestion.description : theme.ui.muted(suggestion.description);
|
|
1251
|
-
lines.push(this.truncateLine(
|
|
1608
|
+
lines.push(this.truncateLine(` ${marker} ${cmdText} — ${descText}`, maxWidth));
|
|
1252
1609
|
}
|
|
1253
1610
|
}
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1611
|
+
// Model and context info
|
|
1612
|
+
const modelContextLine = this.buildModelContextLine();
|
|
1613
|
+
if (modelContextLine) {
|
|
1614
|
+
lines.push(this.truncateLine(` ${modelContextLine}`, maxWidth));
|
|
1257
1615
|
}
|
|
1258
|
-
|
|
1616
|
+
// Mode toggles
|
|
1617
|
+
const toggleLine = this.buildInlineToggleLine();
|
|
1259
1618
|
if (toggleLine) {
|
|
1260
|
-
lines.push(toggleLine);
|
|
1619
|
+
lines.push(this.truncateLine(` ${toggleLine}`, maxWidth));
|
|
1261
1620
|
}
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1621
|
+
// Help hint
|
|
1622
|
+
lines.push(this.truncateLine(` ${theme.ui.muted('? for shortcuts')}`, maxWidth));
|
|
1623
|
+
return { lines, promptIndex };
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Build model name and context usage line with mini progress bar
|
|
1627
|
+
* Format: gpt-4 · ████░░ 85% context
|
|
1628
|
+
*/
|
|
1629
|
+
buildModelContextLine() {
|
|
1630
|
+
const parts = [];
|
|
1631
|
+
// Model name (provider / model or just model)
|
|
1632
|
+
const model = this.statusMeta.provider && this.statusMeta.model
|
|
1633
|
+
? `${this.statusMeta.provider} · ${this.statusMeta.model}`
|
|
1634
|
+
: this.statusMeta.model || this.statusMeta.provider;
|
|
1635
|
+
if (model) {
|
|
1636
|
+
parts.push(theme.info(model));
|
|
1265
1637
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1638
|
+
// Context meter with mini progress bar
|
|
1639
|
+
if (this.statusMeta.contextPercent !== undefined) {
|
|
1640
|
+
const remaining = Math.max(0, 100 - this.statusMeta.contextPercent);
|
|
1641
|
+
const barWidth = 6;
|
|
1642
|
+
const filled = Math.round((remaining / 100) * barWidth);
|
|
1643
|
+
const empty = barWidth - filled;
|
|
1644
|
+
const barColor = remaining > 50 ? theme.success : remaining > 20 ? theme.warning : theme.error;
|
|
1645
|
+
const bar = barColor('█'.repeat(filled)) + theme.ui.muted('░'.repeat(empty));
|
|
1646
|
+
parts.push(`${bar} ${barColor(`${remaining}%`)} ${theme.ui.muted('ctx')}`);
|
|
1268
1647
|
}
|
|
1269
|
-
return
|
|
1648
|
+
return parts.length > 0 ? parts.join(theme.ui.muted(' · ')) : null;
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Build inline toggle controls - Claude Code style
|
|
1652
|
+
* Format: ⏵⏵ accept edits on (shift+tab to cycle)
|
|
1653
|
+
*/
|
|
1654
|
+
buildInlineToggleLine() {
|
|
1655
|
+
const parts = [];
|
|
1656
|
+
// Edit acceptance mode - Claude Code style with ⏵⏵
|
|
1657
|
+
const editIcon = '⏵⏵';
|
|
1658
|
+
const editState = this.toggleState.verificationEnabled ? 'verify edits' : 'accept edits';
|
|
1659
|
+
const editStatus = this.toggleState.verificationEnabled ? theme.warning('on') : theme.success('on');
|
|
1660
|
+
parts.push(`${theme.ui.muted(editIcon)} ${editState} ${editStatus}`);
|
|
1661
|
+
// Thinking mode (if not default)
|
|
1662
|
+
const thinkingLabel = (this.toggleState.thinkingModeLabel || 'balanced').trim().toLowerCase();
|
|
1663
|
+
if (thinkingLabel === 'extended') {
|
|
1664
|
+
parts.push(`${theme.ui.muted('thinking')} ${theme.info('extended')}`);
|
|
1665
|
+
}
|
|
1666
|
+
// Approval mode (if not auto)
|
|
1667
|
+
const approvalMode = this.toggleState.criticalApprovalMode || 'auto';
|
|
1668
|
+
if (approvalMode === 'approval') {
|
|
1669
|
+
parts.push(`${theme.ui.muted('approvals')} ${theme.warning('ask')}`);
|
|
1670
|
+
}
|
|
1671
|
+
// Cycle hint
|
|
1672
|
+
const cycleHint = theme.ui.muted('(shift+tab to cycle)');
|
|
1673
|
+
return parts.length > 0 ? `${parts.join(theme.ui.muted(' · '))} ${cycleHint}` : null;
|
|
1674
|
+
}
|
|
1675
|
+
buildChromeLines() {
|
|
1676
|
+
const maxWidth = this.safeWidth();
|
|
1677
|
+
const statusLines = this.buildStatusBlock(maxWidth);
|
|
1678
|
+
const metaLines = this.buildMetaBlock(maxWidth);
|
|
1679
|
+
return [...statusLines, ...metaLines];
|
|
1270
1680
|
}
|
|
1271
1681
|
abbreviatePath(pathValue) {
|
|
1272
1682
|
const home = homedir();
|
|
@@ -1275,57 +1685,123 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1275
1685
|
}
|
|
1276
1686
|
return pathValue;
|
|
1277
1687
|
}
|
|
1278
|
-
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
const tokenText = this.mode === 'streaming' ? formatTokenDelta(this.statusMeta.tokensUsed) : null;
|
|
1283
|
-
const parts = [
|
|
1284
|
-
`${spinner} ${base}`,
|
|
1285
|
-
elapsed ? theme.ui.muted(elapsed) : null,
|
|
1286
|
-
tokenText ? theme.ui.muted(tokenText) : null,
|
|
1287
|
-
].filter(Boolean);
|
|
1288
|
-
if (!parts.length) {
|
|
1289
|
-
return null;
|
|
1688
|
+
buildStatusBlock(maxWidth) {
|
|
1689
|
+
const statusLabel = this.composeStatusLabel();
|
|
1690
|
+
if (!statusLabel) {
|
|
1691
|
+
return [];
|
|
1290
1692
|
}
|
|
1291
|
-
|
|
1693
|
+
const segments = [];
|
|
1694
|
+
// Add animated spinner when streaming for dynamic visual feedback
|
|
1695
|
+
if (this.mode === 'streaming') {
|
|
1696
|
+
const spinnerChars = spinnerFrames.braille;
|
|
1697
|
+
const spinnerChar = spinnerChars[this.spinnerFrame % spinnerChars.length] ?? '⠋';
|
|
1698
|
+
segments.push(`${theme.info(spinnerChar)} ${this.applyTone(statusLabel.text, statusLabel.tone)}`);
|
|
1699
|
+
}
|
|
1700
|
+
else {
|
|
1701
|
+
segments.push(`${theme.ui.muted('status')} ${this.applyTone(statusLabel.text, statusLabel.tone)}`);
|
|
1702
|
+
}
|
|
1703
|
+
if (this.statusMeta.sessionTime) {
|
|
1704
|
+
segments.push(`${theme.ui.muted('runtime')} ${theme.ui.muted(this.statusMeta.sessionTime)}`);
|
|
1705
|
+
}
|
|
1706
|
+
if (this.statusMeta.contextPercent !== undefined) {
|
|
1707
|
+
// Use animated context meter for smooth color transitions
|
|
1708
|
+
this.contextMeter.update(this.statusMeta.contextPercent);
|
|
1709
|
+
segments.push(this.contextMeter.render());
|
|
1710
|
+
}
|
|
1711
|
+
return this.wrapSegments(segments, maxWidth);
|
|
1292
1712
|
}
|
|
1293
|
-
|
|
1713
|
+
buildMetaBlock(maxWidth) {
|
|
1294
1714
|
const segments = [];
|
|
1295
1715
|
if (this.statusMeta.profile) {
|
|
1296
|
-
segments.push(
|
|
1716
|
+
segments.push(this.formatMetaSegment('profile', this.statusMeta.profile, 'info'));
|
|
1297
1717
|
}
|
|
1298
1718
|
const model = this.statusMeta.provider && this.statusMeta.model
|
|
1299
1719
|
? `${this.statusMeta.provider} / ${this.statusMeta.model}`
|
|
1300
1720
|
: this.statusMeta.model || this.statusMeta.provider;
|
|
1301
1721
|
if (model) {
|
|
1302
|
-
segments.push(
|
|
1722
|
+
segments.push(this.formatMetaSegment('model', model, 'info'));
|
|
1303
1723
|
}
|
|
1304
1724
|
const workspace = this.statusMeta.workspace || this.statusMeta.directory;
|
|
1305
1725
|
if (workspace) {
|
|
1306
|
-
segments.push(
|
|
1726
|
+
segments.push(this.formatMetaSegment('dir', this.abbreviatePath(workspace), 'muted'));
|
|
1307
1727
|
}
|
|
1308
1728
|
if (this.statusMeta.writes) {
|
|
1309
|
-
segments.push(
|
|
1729
|
+
segments.push(this.formatMetaSegment('writes', this.statusMeta.writes, 'muted'));
|
|
1310
1730
|
}
|
|
1311
1731
|
if (this.statusMeta.toolSummary) {
|
|
1312
|
-
segments.push(
|
|
1313
|
-
}
|
|
1314
|
-
if (this.statusMeta.version) {
|
|
1315
|
-
segments.push(`${theme.ui.muted('v')} ${theme.ui.muted(this.statusMeta.version)}`);
|
|
1732
|
+
segments.push(this.formatMetaSegment('tools', this.statusMeta.toolSummary, 'muted'));
|
|
1316
1733
|
}
|
|
1317
1734
|
if (this.statusMeta.sessionLabel) {
|
|
1318
|
-
segments.push(
|
|
1735
|
+
segments.push(this.formatMetaSegment('session', this.statusMeta.sessionLabel, 'muted'));
|
|
1319
1736
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
if (contextBar) {
|
|
1323
|
-
segments.push(`${theme.ui.muted('ctx')} ${theme.ui.muted(contextBar)}`);
|
|
1737
|
+
if (this.statusMeta.version) {
|
|
1738
|
+
segments.push(this.formatMetaSegment('build', `v${this.statusMeta.version}`, 'muted'));
|
|
1324
1739
|
}
|
|
1325
1740
|
if (segments.length === 0) {
|
|
1741
|
+
return [];
|
|
1742
|
+
}
|
|
1743
|
+
return this.wrapSegments(segments, maxWidth);
|
|
1744
|
+
}
|
|
1745
|
+
composeStatusLabel() {
|
|
1746
|
+
const statuses = [this.statusStreaming, this.statusOverride, this.statusMessage].filter((value, index, arr) => Boolean(value) && arr.indexOf(value) === index);
|
|
1747
|
+
const text = statuses.length > 0 ? statuses.join(' / ') : 'Ready for prompts';
|
|
1748
|
+
if (!text.trim()) {
|
|
1326
1749
|
return null;
|
|
1327
1750
|
}
|
|
1328
|
-
|
|
1751
|
+
const normalized = text.toLowerCase();
|
|
1752
|
+
const tone = normalized.includes('ready') ? 'success' : 'info';
|
|
1753
|
+
return { text, tone };
|
|
1754
|
+
}
|
|
1755
|
+
formatMetaSegment(label, value, tone) {
|
|
1756
|
+
const colorizer = tone === 'success'
|
|
1757
|
+
? theme.success
|
|
1758
|
+
: tone === 'warn'
|
|
1759
|
+
? theme.warning
|
|
1760
|
+
: tone === 'error'
|
|
1761
|
+
? theme.error
|
|
1762
|
+
: tone === 'muted'
|
|
1763
|
+
? theme.ui.muted
|
|
1764
|
+
: theme.info;
|
|
1765
|
+
return `${theme.ui.muted(label)} ${colorizer(value)}`;
|
|
1766
|
+
}
|
|
1767
|
+
applyTone(text, tone) {
|
|
1768
|
+
switch (tone) {
|
|
1769
|
+
case 'success':
|
|
1770
|
+
return theme.success(text);
|
|
1771
|
+
case 'warn':
|
|
1772
|
+
return theme.warning(text);
|
|
1773
|
+
case 'error':
|
|
1774
|
+
return theme.error(text);
|
|
1775
|
+
case 'info':
|
|
1776
|
+
default:
|
|
1777
|
+
return theme.info(text);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
wrapSegments(segments, maxWidth) {
|
|
1781
|
+
const lines = [];
|
|
1782
|
+
const separator = theme.ui.muted(' | ');
|
|
1783
|
+
let current = '';
|
|
1784
|
+
for (const segment of segments) {
|
|
1785
|
+
const normalized = segment.trim();
|
|
1786
|
+
if (!normalized)
|
|
1787
|
+
continue;
|
|
1788
|
+
if (!current) {
|
|
1789
|
+
current = this.truncateLine(normalized, maxWidth);
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1792
|
+
const candidate = `${current}${separator}${normalized}`;
|
|
1793
|
+
if (this.visibleLength(candidate) <= maxWidth) {
|
|
1794
|
+
current = candidate;
|
|
1795
|
+
}
|
|
1796
|
+
else {
|
|
1797
|
+
lines.push(this.truncateLine(current, maxWidth));
|
|
1798
|
+
current = this.truncateLine(normalized, maxWidth);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
if (current) {
|
|
1802
|
+
lines.push(this.truncateLine(current, maxWidth));
|
|
1803
|
+
}
|
|
1804
|
+
return lines;
|
|
1329
1805
|
}
|
|
1330
1806
|
buildControlLines() {
|
|
1331
1807
|
const lines = [];
|
|
@@ -1339,12 +1815,34 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1339
1815
|
}
|
|
1340
1816
|
return lines;
|
|
1341
1817
|
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Build a compact toggle line like Claude Code:
|
|
1820
|
+
* "⏵⏵ accept edits on (shift+tab to cycle)"
|
|
1821
|
+
*/
|
|
1822
|
+
buildCompactToggleLine() {
|
|
1823
|
+
// Show the most relevant mode based on current state
|
|
1824
|
+
const parts = [];
|
|
1825
|
+
// Edit mode indicator
|
|
1826
|
+
const editIcon = '⏵⏵';
|
|
1827
|
+
const editState = this.toggleState.verificationEnabled ? 'approval required' : 'accept edits';
|
|
1828
|
+
parts.push(`${theme.ui.muted(editIcon)} ${editState} ${theme.success('on')}`);
|
|
1829
|
+
// Thinking mode (if active)
|
|
1830
|
+
const thinkingLabel = (this.toggleState.thinkingModeLabel || '').trim().toLowerCase();
|
|
1831
|
+
if (thinkingLabel && thinkingLabel !== 'off') {
|
|
1832
|
+
parts.push(`${theme.ui.muted('thinking')} ${theme.info(thinkingLabel)}`);
|
|
1833
|
+
}
|
|
1834
|
+
// Cycle hint
|
|
1835
|
+
const cycleHint = theme.ui.muted('(shift+tab to cycle)');
|
|
1836
|
+
if (parts.length === 0) {
|
|
1837
|
+
return null;
|
|
1838
|
+
}
|
|
1839
|
+
return ` ${parts.join(theme.ui.muted(' · '))} ${cycleHint}`;
|
|
1840
|
+
}
|
|
1342
1841
|
buildToggleLine() {
|
|
1343
1842
|
const toggles = [];
|
|
1344
1843
|
const addToggle = (label, on, hotkey, value) => {
|
|
1345
1844
|
toggles.push({ label, on, hotkey: this.formatHotkey(hotkey), value });
|
|
1346
1845
|
};
|
|
1347
|
-
addToggle('Auto', this.toggleState.autoContinueEnabled, this.toggleState.autoContinueHotkey);
|
|
1348
1846
|
addToggle('Verify', this.toggleState.verificationEnabled, this.toggleState.verificationHotkey);
|
|
1349
1847
|
const approvalMode = this.toggleState.criticalApprovalMode || 'auto';
|
|
1350
1848
|
const approvalActive = approvalMode !== 'auto';
|
|
@@ -1389,86 +1887,129 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1389
1887
|
addHotkey('interrupt', 'Ctrl+C');
|
|
1390
1888
|
addHotkey('clear input', 'Ctrl+U');
|
|
1391
1889
|
// Feature toggles (only if hotkeys are defined)
|
|
1392
|
-
addHotkey('auto-run', this.toggleState.autoContinueHotkey);
|
|
1393
1890
|
addHotkey('verify', this.toggleState.verificationHotkey);
|
|
1394
1891
|
addHotkey('thinking', this.toggleState.thinkingHotkey);
|
|
1395
1892
|
if (parts.length === 0) {
|
|
1396
1893
|
return null;
|
|
1397
1894
|
}
|
|
1398
|
-
|
|
1399
|
-
return `${body}${theme.ui.muted(' ? shortcuts')}`;
|
|
1895
|
+
return parts.join(theme.ui.muted(' '));
|
|
1400
1896
|
}
|
|
1401
|
-
|
|
1402
|
-
const prompt = theme.primary('› ');
|
|
1403
|
-
const promptWidth = this.visibleLength(prompt);
|
|
1404
|
-
const usableWidth = Math.max(8, maxWidth - promptWidth);
|
|
1897
|
+
buildInputLine() {
|
|
1405
1898
|
if (this.collapsedPaste) {
|
|
1406
1899
|
const summary = `[pasted ${this.collapsedPaste.lines} lines, ${this.collapsedPaste.chars} chars] (Ctrl+L insert, Backspace discard)`;
|
|
1407
|
-
|
|
1408
|
-
const cursorCol = Math.min(maxWidth, this.visibleLength(line) + 1);
|
|
1409
|
-
return { lines: [this.truncateLine(line, maxWidth)], cursorRow: 0, cursorCol };
|
|
1900
|
+
return this.truncateLine(`${theme.primary('> ')}${theme.ui.muted(summary)}`, this.safeWidth());
|
|
1410
1901
|
}
|
|
1902
|
+
// Claude Code uses simple '>' prompt
|
|
1903
|
+
const prompt = theme.primary('> ');
|
|
1904
|
+
const promptWidth = this.visibleLength(prompt);
|
|
1905
|
+
const maxWidth = this.safeWidth();
|
|
1906
|
+
const continuationIndent = ' '; // 2 spaces for continuation lines
|
|
1907
|
+
const continuationWidth = continuationIndent.length;
|
|
1908
|
+
// Handle multi-line input - split buffer on newlines first
|
|
1411
1909
|
const normalized = this.buffer.replace(/\r/g, '\n');
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
let
|
|
1416
|
-
|
|
1417
|
-
let cursorRow = 0;
|
|
1910
|
+
const bufferLines = normalized.split('\n');
|
|
1911
|
+
// Wrap each logical line to fit terminal width, expanding vertically
|
|
1912
|
+
const result = [];
|
|
1913
|
+
let totalChars = 0;
|
|
1914
|
+
let cursorLine = 0;
|
|
1418
1915
|
let cursorCol = 0;
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1916
|
+
let foundCursor = false;
|
|
1917
|
+
for (let lineIndex = 0; lineIndex < bufferLines.length; lineIndex++) {
|
|
1918
|
+
const line = bufferLines[lineIndex] ?? '';
|
|
1919
|
+
const isFirstLogicalLine = lineIndex === 0;
|
|
1920
|
+
const lineStartChar = totalChars;
|
|
1921
|
+
// Determine available width for this line
|
|
1922
|
+
const firstLineWidth = maxWidth - promptWidth;
|
|
1923
|
+
const contLineWidth = maxWidth - continuationWidth;
|
|
1924
|
+
// Wrap this logical line into display lines
|
|
1925
|
+
let remaining = line;
|
|
1926
|
+
let isFirstDisplayLine = true;
|
|
1927
|
+
while (remaining.length > 0 || isFirstDisplayLine) {
|
|
1928
|
+
const availableWidth = (isFirstLogicalLine && isFirstDisplayLine) ? firstLineWidth : contLineWidth;
|
|
1929
|
+
const chunk = remaining.slice(0, availableWidth);
|
|
1930
|
+
remaining = remaining.slice(availableWidth);
|
|
1931
|
+
// Build the display line
|
|
1932
|
+
let displayLine;
|
|
1933
|
+
if (isFirstLogicalLine && isFirstDisplayLine) {
|
|
1934
|
+
displayLine = `${prompt}${chunk}`;
|
|
1935
|
+
}
|
|
1936
|
+
else {
|
|
1937
|
+
displayLine = `${continuationIndent}${chunk}`;
|
|
1938
|
+
}
|
|
1939
|
+
// Track cursor position
|
|
1940
|
+
if (!foundCursor) {
|
|
1941
|
+
const chunkStart = lineStartChar + (line.length - remaining.length - chunk.length);
|
|
1942
|
+
const chunkEnd = chunkStart + chunk.length;
|
|
1943
|
+
if (this.cursor >= chunkStart && this.cursor <= chunkEnd) {
|
|
1944
|
+
cursorLine = result.length;
|
|
1945
|
+
const offsetInChunk = this.cursor - chunkStart;
|
|
1946
|
+
cursorCol = ((isFirstLogicalLine && isFirstDisplayLine) ? promptWidth : continuationWidth) + offsetInChunk;
|
|
1947
|
+
foundCursor = true;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
result.push(displayLine);
|
|
1951
|
+
isFirstDisplayLine = false;
|
|
1952
|
+
// If nothing left and this was an empty line, we already added it
|
|
1953
|
+
if (remaining.length === 0 && chunk.length === 0)
|
|
1954
|
+
break;
|
|
1440
1955
|
}
|
|
1441
|
-
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1956
|
+
totalChars += line.length + 1; // +1 for the newline separator
|
|
1957
|
+
}
|
|
1958
|
+
// Handle cursor at very end
|
|
1959
|
+
if (!foundCursor) {
|
|
1960
|
+
cursorLine = Math.max(0, result.length - 1);
|
|
1961
|
+
const lastLine = result[cursorLine] ?? '';
|
|
1962
|
+
cursorCol = this.visibleLength(lastLine);
|
|
1963
|
+
}
|
|
1964
|
+
// Add cursor highlight to the appropriate position
|
|
1965
|
+
if (result.length > 0) {
|
|
1966
|
+
const targetLine = result[cursorLine] ?? '';
|
|
1967
|
+
const visiblePart = this.stripAnsi(targetLine);
|
|
1968
|
+
const cursorPos = Math.min(cursorCol, visiblePart.length);
|
|
1969
|
+
// Rebuild the line with cursor highlight
|
|
1970
|
+
const before = visiblePart.slice(0, cursorPos);
|
|
1971
|
+
const at = visiblePart.charAt(cursorPos) || ' ';
|
|
1972
|
+
const after = visiblePart.slice(cursorPos + 1);
|
|
1973
|
+
// Preserve the prompt/indent styling
|
|
1974
|
+
const prefix = cursorLine === 0 ? prompt : continuationIndent;
|
|
1975
|
+
const textPart = cursorLine === 0 ? before.slice(promptWidth) : before.slice(continuationWidth);
|
|
1976
|
+
result[cursorLine] = `${prefix}${textPart}${ESC.REVERSE}${at}${ESC.RESET}${after}`;
|
|
1977
|
+
}
|
|
1978
|
+
// Store cursor column for terminal positioning
|
|
1979
|
+
this.cursorVisibleColumn = cursorCol + 1;
|
|
1980
|
+
return result.join('\n');
|
|
1981
|
+
}
|
|
1982
|
+
buildInputWindow(available) {
|
|
1983
|
+
if (available <= 0) {
|
|
1984
|
+
return { text: '', cursor: 0 };
|
|
1445
1985
|
}
|
|
1446
|
-
if (
|
|
1447
|
-
|
|
1448
|
-
cursorCol = rawLines[cursorRow]?.length ?? 0;
|
|
1986
|
+
if (this.collapsedPaste) {
|
|
1987
|
+
return { text: '', cursor: 0 };
|
|
1449
1988
|
}
|
|
1450
|
-
const
|
|
1451
|
-
const
|
|
1452
|
-
let
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1989
|
+
const normalized = this.buffer.replace(/\r/g, '\n');
|
|
1990
|
+
const cursorIndex = Math.min(this.cursor, normalized.length);
|
|
1991
|
+
let offset = this.inputRenderOffset;
|
|
1992
|
+
if (cursorIndex < offset) {
|
|
1993
|
+
offset = cursorIndex;
|
|
1994
|
+
}
|
|
1995
|
+
const overflow = cursorIndex - offset - available + 1;
|
|
1996
|
+
if (overflow > 0) {
|
|
1997
|
+
offset += overflow;
|
|
1998
|
+
}
|
|
1999
|
+
const maxOffset = Math.max(0, normalized.length - available);
|
|
2000
|
+
if (offset > maxOffset) {
|
|
2001
|
+
offset = maxOffset;
|
|
2002
|
+
}
|
|
2003
|
+
this.inputRenderOffset = offset;
|
|
2004
|
+
const window = normalized.slice(offset, offset + available);
|
|
2005
|
+
const display = window.split('').map(char => (char === '\n' ? NEWLINE_PLACEHOLDER : char)).join('');
|
|
2006
|
+
const cursorInWindow = Math.min(display.length, Math.max(0, cursorIndex - offset));
|
|
2007
|
+
const before = display.slice(0, cursorInWindow);
|
|
2008
|
+
const at = display.charAt(cursorInWindow) || ' ';
|
|
2009
|
+
const after = display.slice(cursorInWindow + 1);
|
|
1468
2010
|
return {
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
cursorCol: cursorColumn,
|
|
2011
|
+
text: `${before}${ESC.REVERSE}${at}${ESC.RESET}${after}`,
|
|
2012
|
+
cursor: cursorInWindow,
|
|
1472
2013
|
};
|
|
1473
2014
|
}
|
|
1474
2015
|
expandCollapsedPaste() {
|
|
@@ -1488,6 +2029,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1488
2029
|
if (options.resetBuffer) {
|
|
1489
2030
|
this.buffer = '';
|
|
1490
2031
|
this.cursor = 0;
|
|
2032
|
+
this.inputRenderOffset = 0;
|
|
1491
2033
|
this.resetSuggestions();
|
|
1492
2034
|
this.renderPrompt();
|
|
1493
2035
|
this.emitInputChange();
|
|
@@ -1566,17 +2108,6 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1566
2108
|
}
|
|
1567
2109
|
return result;
|
|
1568
2110
|
}
|
|
1569
|
-
clearOverlayRows(rows, startRow) {
|
|
1570
|
-
const totalRows = this.rows || 24;
|
|
1571
|
-
const limit = Math.max(0, Math.min(rows, totalRows));
|
|
1572
|
-
for (let idx = 0; idx < limit; idx++) {
|
|
1573
|
-
const row = startRow + idx;
|
|
1574
|
-
if (row < 1 || row > totalRows)
|
|
1575
|
-
continue;
|
|
1576
|
-
this.write(ESC.TO(row, 1));
|
|
1577
|
-
this.write(ESC.CLEAR_LINE);
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
2111
|
getBuffer() {
|
|
1581
2112
|
return this.buffer;
|
|
1582
2113
|
}
|
|
@@ -1586,6 +2117,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1586
2117
|
setBuffer(text, cursorPos) {
|
|
1587
2118
|
this.buffer = text;
|
|
1588
2119
|
this.cursor = cursorPos ?? text.length;
|
|
2120
|
+
this.inputRenderOffset = 0;
|
|
1589
2121
|
this.updateSuggestions();
|
|
1590
2122
|
this.renderPrompt();
|
|
1591
2123
|
this.emitInputChange();
|
|
@@ -1594,6 +2126,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1594
2126
|
this.cancelPlainPasteCapture();
|
|
1595
2127
|
this.buffer = '';
|
|
1596
2128
|
this.cursor = 0;
|
|
2129
|
+
this.inputRenderOffset = 0;
|
|
1597
2130
|
this.suggestions = [];
|
|
1598
2131
|
this.suggestionIndex = -1;
|
|
1599
2132
|
this.renderPrompt();
|
|
@@ -1602,6 +2135,35 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1602
2135
|
setModeStatus(status) {
|
|
1603
2136
|
this.updateStatus(status);
|
|
1604
2137
|
}
|
|
2138
|
+
/**
|
|
2139
|
+
* Show a compacting status with animated spinner (Claude Code style)
|
|
2140
|
+
* Uses ✻ character with animation to indicate context compaction in progress
|
|
2141
|
+
*/
|
|
2142
|
+
showCompactingStatus(message) {
|
|
2143
|
+
this.statusMessage = message;
|
|
2144
|
+
if (!this.spinnerInterval) {
|
|
2145
|
+
this.spinnerInterval = setInterval(() => {
|
|
2146
|
+
this.spinnerFrame++;
|
|
2147
|
+
// Cycle activity phrase every ~4 seconds (50 frames at 80ms)
|
|
2148
|
+
if (this.spinnerFrame % 50 === 0) {
|
|
2149
|
+
this.activityPhraseIndex++;
|
|
2150
|
+
}
|
|
2151
|
+
this.renderPrompt();
|
|
2152
|
+
}, 80);
|
|
2153
|
+
}
|
|
2154
|
+
this.renderPrompt();
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Hide the compacting status and stop spinner animation
|
|
2158
|
+
*/
|
|
2159
|
+
hideCompactingStatus() {
|
|
2160
|
+
if (this.spinnerInterval) {
|
|
2161
|
+
clearInterval(this.spinnerInterval);
|
|
2162
|
+
this.spinnerInterval = null;
|
|
2163
|
+
}
|
|
2164
|
+
this.statusMessage = null;
|
|
2165
|
+
this.renderPrompt();
|
|
2166
|
+
}
|
|
1605
2167
|
emitPrompt(content) {
|
|
1606
2168
|
this.pushPromptEvent(content);
|
|
1607
2169
|
}
|
|
@@ -1645,23 +2207,30 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1645
2207
|
const height = this.lastOverlay?.lines.length ?? this.promptHeight ?? 0;
|
|
1646
2208
|
if (height === 0)
|
|
1647
2209
|
return;
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
2210
|
+
// Cursor is at prompt row. Move up to top of overlay first.
|
|
2211
|
+
if (this.lastOverlay) {
|
|
2212
|
+
const linesToTop = this.lastOverlay.promptIndex;
|
|
2213
|
+
if (linesToTop > 0) {
|
|
2214
|
+
this.write(`\x1b[${linesToTop}A`);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
// Now at top, clear each line downward
|
|
2218
|
+
for (let i = 0; i < height; i++) {
|
|
2219
|
+
this.write('\r');
|
|
1656
2220
|
this.write(ESC.CLEAR_LINE);
|
|
2221
|
+
if (i < height - 1) {
|
|
2222
|
+
this.write('\x1b[B');
|
|
2223
|
+
}
|
|
1657
2224
|
}
|
|
1658
|
-
// Move
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
2225
|
+
// Move back to top (where content should continue from)
|
|
2226
|
+
if (height > 1) {
|
|
2227
|
+
this.write(`\x1b[${height - 1}A`);
|
|
2228
|
+
}
|
|
2229
|
+
this.write('\r');
|
|
1662
2230
|
this.lastOverlay = null;
|
|
1663
|
-
this.overlayInvalidated = true;
|
|
1664
2231
|
this.promptHeight = 0;
|
|
2232
|
+
this.lastOverlayHeight = 0;
|
|
2233
|
+
this.isPromptActive = false;
|
|
1665
2234
|
}
|
|
1666
2235
|
updateTerminalSize() {
|
|
1667
2236
|
if (this.output.isTTY) {
|