erosolar-cli 2.1.168 → 2.1.170
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 -12
- package/dist/shell/interactiveShell.d.ts.map +1 -1
- package/dist/shell/interactiveShell.js +269 -193
- package/dist/shell/interactiveShell.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/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 +2 -3
- package/dist/ui/PromptController.d.ts.map +1 -1
- package/dist/ui/PromptController.js +2 -3
- package/dist/ui/PromptController.js.map +1 -1
- package/dist/ui/ShellUIAdapter.d.ts +71 -18
- package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
- package/dist/ui/ShellUIAdapter.js +237 -139
- package/dist/ui/ShellUIAdapter.js.map +1 -1
- package/dist/ui/UnifiedUIController.d.ts +0 -1
- package/dist/ui/UnifiedUIController.d.ts.map +1 -1
- package/dist/ui/UnifiedUIController.js +0 -1
- package/dist/ui/UnifiedUIController.js.map +1 -1
- package/dist/ui/UnifiedUIRenderer.d.ts +122 -7
- package/dist/ui/UnifiedUIRenderer.d.ts.map +1 -1
- package/dist/ui/UnifiedUIRenderer.js +823 -130
- package/dist/ui/UnifiedUIRenderer.js.map +1 -1
- package/dist/ui/animatedStatus.d.ts +129 -0
- package/dist/ui/animatedStatus.d.ts.map +1 -0
- package/dist/ui/animatedStatus.js +384 -0
- package/dist/ui/animatedStatus.js.map +1 -0
- package/dist/ui/display.d.ts +13 -48
- package/dist/ui/display.d.ts.map +1 -1
- package/dist/ui/display.js +22 -105
- package/dist/ui/display.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/unified/index.d.ts +1 -1
- package/dist/ui/unified/index.d.ts.map +1 -1
- package/dist/ui/unified/index.js +0 -2
- package/dist/ui/unified/index.js.map +1 -1
- package/package.json +1 -2
- 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/compactRenderer.d.ts +0 -139
- package/dist/ui/compactRenderer.d.ts.map +0 -1
- package/dist/ui/compactRenderer.js +0 -398
- package/dist/ui/compactRenderer.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,9 +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 {
|
|
16
|
+
import { ContextMeter, disposeAnimations } from './animatedStatus.js';
|
|
17
17
|
const ESC = {
|
|
18
18
|
HIDE_CURSOR: '\x1b[?25l',
|
|
19
19
|
SHOW_CURSOR: '\x1b[?25h',
|
|
@@ -27,6 +27,11 @@ const ESC = {
|
|
|
27
27
|
ERASE_DOWN: '\x1b[J',
|
|
28
28
|
REVERSE: '\x1b[7m',
|
|
29
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',
|
|
30
35
|
};
|
|
31
36
|
const COLLAPSE_HINT = theme.ui.muted('…'); // subtle ellipsis, no key hint
|
|
32
37
|
const NEWLINE_PLACEHOLDER = '↵';
|
|
@@ -51,13 +56,38 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
51
56
|
hotkeysInToggleLine = new Set();
|
|
52
57
|
collapsedPaste = null;
|
|
53
58
|
mode = 'idle';
|
|
59
|
+
streamingStartTime = null;
|
|
54
60
|
statusMessage = null;
|
|
55
61
|
statusOverride = null;
|
|
56
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
|
+
];
|
|
57
88
|
statusMeta = {};
|
|
58
89
|
toggleState = {
|
|
59
90
|
verificationEnabled: false,
|
|
60
|
-
autoContinueEnabled: false,
|
|
61
91
|
criticalApprovalMode: 'auto',
|
|
62
92
|
};
|
|
63
93
|
// ------------ Helpers ------------
|
|
@@ -69,10 +99,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
69
99
|
lastPromptEvent = null;
|
|
70
100
|
promptHeight = 0;
|
|
71
101
|
lastOverlayHeight = 0;
|
|
72
|
-
lastPromptIndex = 0;
|
|
73
|
-
overlayBottomPadding = 1;
|
|
74
102
|
inlinePanel = [];
|
|
75
|
-
overlayInvalidated = false;
|
|
76
103
|
hasConversationContent = false;
|
|
77
104
|
isPromptActive = false;
|
|
78
105
|
inputRenderOffset = 0;
|
|
@@ -91,7 +118,7 @@ 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
124
|
inputCapture = null;
|
|
@@ -101,6 +128,8 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
101
128
|
this.input = input;
|
|
102
129
|
this.interactive = Boolean(this.output.isTTY && this.input.isTTY && !process.env['CI']);
|
|
103
130
|
this.plainMode = isPlainOutputMode() || !this.interactive;
|
|
131
|
+
// Initialize animated components
|
|
132
|
+
this.contextMeter = new ContextMeter();
|
|
104
133
|
this.rl = readline.createInterface({
|
|
105
134
|
input: this.input,
|
|
106
135
|
output: this.output,
|
|
@@ -132,9 +161,6 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
132
161
|
this.updateTerminalSize();
|
|
133
162
|
this.hasRenderedPrompt = false;
|
|
134
163
|
this.lastOutputEndedWithNewline = true;
|
|
135
|
-
// Don't render prompt immediately - wait for banner/content to be added first.
|
|
136
|
-
// The prompt will render after the event queue processes the welcome banner.
|
|
137
|
-
// This prevents the prompt from appearing at the bottom before the banner shows.
|
|
138
164
|
this.write(ESC.SHOW_CURSOR);
|
|
139
165
|
return;
|
|
140
166
|
}
|
|
@@ -147,11 +173,27 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
147
173
|
cleanup() {
|
|
148
174
|
this.cancelInputCapture(new Error('Renderer disposed'));
|
|
149
175
|
this.cancelPlainPasteCapture();
|
|
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();
|
|
150
191
|
if (!this.interactive) {
|
|
151
192
|
this.rl.close();
|
|
152
193
|
return;
|
|
153
194
|
}
|
|
154
195
|
if (!this.plainMode) {
|
|
196
|
+
// Clear the prompt area so it doesn't remain in scrollback history
|
|
155
197
|
this.clearPromptArea();
|
|
156
198
|
this.write(ESC.DISABLE_BRACKETED_PASTE);
|
|
157
199
|
this.write(ESC.SHOW_CURSOR);
|
|
@@ -203,15 +245,25 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
203
245
|
return;
|
|
204
246
|
}
|
|
205
247
|
if (key.ctrl && key.name === 'c') {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
210
254
|
this.buffer = '';
|
|
211
255
|
this.cursor = 0;
|
|
212
256
|
this.renderPrompt();
|
|
213
257
|
this.emitInputChange();
|
|
214
258
|
}
|
|
259
|
+
else if (this.mode === 'streaming') {
|
|
260
|
+
// Stage 2: Interrupt the AI run
|
|
261
|
+
this.emit('interrupt');
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
// Stage 3: Quit the CLI (emit exit signal)
|
|
265
|
+
this.emit('exit');
|
|
266
|
+
}
|
|
215
267
|
return;
|
|
216
268
|
}
|
|
217
269
|
if (key.ctrl && key.name === 'd') {
|
|
@@ -230,6 +282,11 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
230
282
|
return;
|
|
231
283
|
}
|
|
232
284
|
}
|
|
285
|
+
// Ctrl+O: Expand last tool result
|
|
286
|
+
if (key.ctrl && key.name === 'o') {
|
|
287
|
+
this.emit('expand-tool-result');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
233
290
|
if (key.name === 'return' || key.name === 'enter') {
|
|
234
291
|
if (this.collapsedPaste) {
|
|
235
292
|
this.expandCollapsedPaste();
|
|
@@ -239,9 +296,12 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
239
296
|
// If a slash command suggestion is highlighted, pressing Enter submits it immediately
|
|
240
297
|
if (this.applySuggestion(true))
|
|
241
298
|
return;
|
|
242
|
-
//
|
|
299
|
+
// Fallback: if buffer starts with '/' and suggestions exist, use the selected/first one
|
|
243
300
|
if (this.buffer.startsWith('/') && this.suggestions.length > 0) {
|
|
244
|
-
|
|
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;
|
|
245
305
|
}
|
|
246
306
|
this.submitText(this.buffer);
|
|
247
307
|
return;
|
|
@@ -618,7 +678,11 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
618
678
|
if (!this.buffer.startsWith('/') || this.suggestions.length === 0) {
|
|
619
679
|
return false;
|
|
620
680
|
}
|
|
621
|
-
|
|
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];
|
|
622
686
|
if (!selected) {
|
|
623
687
|
return false;
|
|
624
688
|
}
|
|
@@ -663,6 +727,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
663
727
|
normalized === 'thought' ||
|
|
664
728
|
normalized === 'stream' ||
|
|
665
729
|
normalized === 'tool' ||
|
|
730
|
+
normalized === 'tool-result' ||
|
|
666
731
|
normalized === 'build' ||
|
|
667
732
|
normalized === 'test') {
|
|
668
733
|
this.hasConversationContent = true;
|
|
@@ -789,6 +854,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
789
854
|
if (event.type !== 'prompt') {
|
|
790
855
|
this.lastRenderedEventKey = signature;
|
|
791
856
|
}
|
|
857
|
+
// Clear the prompt area before writing new content
|
|
792
858
|
if (this.promptHeight > 0 || this.lastOverlay) {
|
|
793
859
|
this.clearPromptArea();
|
|
794
860
|
}
|
|
@@ -800,10 +866,6 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
800
866
|
}
|
|
801
867
|
this.output.write(formatted);
|
|
802
868
|
this.lastOutputEndedWithNewline = formatted.endsWith('\n');
|
|
803
|
-
// Overlay must be re-anchored after new scrollback is written
|
|
804
|
-
this.overlayInvalidated = true;
|
|
805
|
-
// Don't re-render prompt after every event - wait for queue to finish
|
|
806
|
-
// This prevents premature prompt rendering that cuts off responses
|
|
807
869
|
}
|
|
808
870
|
normalizeEventType(type) {
|
|
809
871
|
switch (type) {
|
|
@@ -816,8 +878,9 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
816
878
|
return 'stream';
|
|
817
879
|
case 'tool':
|
|
818
880
|
case 'tool-call':
|
|
819
|
-
case 'tool-result':
|
|
820
881
|
return 'tool';
|
|
882
|
+
case 'tool-result':
|
|
883
|
+
return 'tool-result';
|
|
821
884
|
case 'build':
|
|
822
885
|
return 'build';
|
|
823
886
|
case 'test':
|
|
@@ -843,35 +906,378 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
843
906
|
const lines = event.content.split('\n').map(line => line.trimEnd());
|
|
844
907
|
return `${lines.join('\n')}\n`;
|
|
845
908
|
}
|
|
909
|
+
// Compact, user-friendly formatting
|
|
846
910
|
switch (event.type) {
|
|
847
911
|
case 'prompt':
|
|
848
|
-
|
|
912
|
+
// User prompt - just the text (prompt box handles styling)
|
|
913
|
+
return `${theme.primary('>')} ${event.content}\n`;
|
|
849
914
|
case 'thought': {
|
|
850
|
-
//
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
915
|
+
// Programmatic filter: reject content that looks like internal/garbage output
|
|
916
|
+
if (this.isGarbageOutput(event.content)) {
|
|
917
|
+
return '';
|
|
918
|
+
}
|
|
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);
|
|
856
931
|
}
|
|
857
|
-
case 'tool':
|
|
858
|
-
// Tool calls don't have bullet in Claude Code - just the name
|
|
859
|
-
return `\n${event.content}\n`;
|
|
860
932
|
case 'build':
|
|
861
|
-
return
|
|
933
|
+
return `${bullet} ${theme.warning('Build')} ${theme.ui.muted('→')} ${event.content}\n`;
|
|
862
934
|
case 'test':
|
|
863
|
-
return
|
|
935
|
+
return `${bullet} ${theme.info('Test')} ${theme.ui.muted('→')} ${event.content}\n`;
|
|
864
936
|
case 'stream':
|
|
865
937
|
return event.content;
|
|
866
938
|
case 'response':
|
|
867
939
|
default: {
|
|
868
|
-
//
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
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;
|
|
959
|
+
}
|
|
960
|
+
// Structural check: contains "to=functions." or "to=tools." (internal routing)
|
|
961
|
+
if (content.includes('to=functions.') || content.includes('to=tools.')) {
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
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 = '';
|
|
1001
|
+
for (const word of words) {
|
|
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();
|
|
1007
|
+
}
|
|
1008
|
+
else {
|
|
1009
|
+
currentLine += word;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
if (currentLine.trim()) {
|
|
1013
|
+
const prefix = result.length === 0 && lineIdx === 0 ? `${bullet} ` : ' ';
|
|
1014
|
+
result.push(`${prefix}${currentLine.trimEnd()}`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
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;
|
|
873
1062
|
}
|
|
874
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`;
|
|
875
1281
|
}
|
|
876
1282
|
/**
|
|
877
1283
|
* Format a compact conversation block (Claude Code style)
|
|
@@ -908,6 +1314,16 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
908
1314
|
setMode(mode) {
|
|
909
1315
|
const wasStreaming = this.mode === 'streaming';
|
|
910
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
|
+
}
|
|
911
1327
|
if (wasStreaming && mode === 'idle' && !this.lastOutputEndedWithNewline) {
|
|
912
1328
|
// Finish streaming on a fresh line so the next prompt/event doesn't collide
|
|
913
1329
|
this.write('\n');
|
|
@@ -918,6 +1334,61 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
918
1334
|
this.renderPrompt();
|
|
919
1335
|
}
|
|
920
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;
|
|
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;
|
|
1372
|
+
if (!this.plainMode) {
|
|
1373
|
+
this.renderPrompt();
|
|
1374
|
+
}
|
|
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
|
+
}
|
|
921
1392
|
getMode() {
|
|
922
1393
|
return this.mode;
|
|
923
1394
|
}
|
|
@@ -966,8 +1437,7 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
966
1437
|
}
|
|
967
1438
|
updateModeToggles(state) {
|
|
968
1439
|
this.toggleState = { ...this.toggleState, ...state };
|
|
969
|
-
if (!state.
|
|
970
|
-
!state.verificationHotkey &&
|
|
1440
|
+
if (!state.verificationHotkey &&
|
|
971
1441
|
!state.thinkingHotkey &&
|
|
972
1442
|
!state.criticalApprovalHotkey) {
|
|
973
1443
|
this.hotkeysInToggleLine.clear();
|
|
@@ -1013,13 +1483,9 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1013
1483
|
if (!this.allowPromptRender) {
|
|
1014
1484
|
return;
|
|
1015
1485
|
}
|
|
1016
|
-
// Rich mode:
|
|
1486
|
+
// Rich inline mode: prompt flows naturally with content
|
|
1017
1487
|
this.updateTerminalSize();
|
|
1018
1488
|
const maxWidth = this.safeWidth();
|
|
1019
|
-
if (this.lastRenderWidth !== null && maxWidth !== this.lastRenderWidth) {
|
|
1020
|
-
// Terminal resized; force a clean anchor so the overlay doesn't jitter.
|
|
1021
|
-
this.overlayInvalidated = true;
|
|
1022
|
-
}
|
|
1023
1489
|
this.lastRenderWidth = maxWidth;
|
|
1024
1490
|
const overlay = this.buildOverlayLines();
|
|
1025
1491
|
if (!overlay.lines.length) {
|
|
@@ -1029,81 +1495,183 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1029
1495
|
if (!renderedLines.length) {
|
|
1030
1496
|
return;
|
|
1031
1497
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
//
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
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
|
+
}
|
|
1051
1527
|
}
|
|
1052
|
-
//
|
|
1053
|
-
for (let
|
|
1054
|
-
|
|
1055
|
-
const line = renderedLines[idx] ?? '';
|
|
1056
|
-
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');
|
|
1057
1531
|
this.write(ESC.CLEAR_LINE);
|
|
1058
|
-
|
|
1059
|
-
|
|
1532
|
+
this.write(renderedLines[i] || '');
|
|
1533
|
+
if (i < renderedLines.length - 1) {
|
|
1534
|
+
this.write('\n');
|
|
1060
1535
|
}
|
|
1061
1536
|
}
|
|
1062
|
-
// Position cursor at prompt
|
|
1063
|
-
|
|
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`);
|
|
1064
1545
|
this.cursorVisibleColumn = promptCol;
|
|
1065
1546
|
this.hasRenderedPrompt = true;
|
|
1066
|
-
this.hasEverRenderedOverlay = true;
|
|
1547
|
+
this.hasEverRenderedOverlay = true;
|
|
1067
1548
|
this.isPromptActive = true;
|
|
1068
1549
|
this.lastOverlayHeight = height;
|
|
1069
|
-
this.lastPromptIndex = promptIndex;
|
|
1070
1550
|
this.lastOverlay = { lines: renderedLines, promptIndex };
|
|
1071
|
-
this.
|
|
1072
|
-
this.lastOutputEndedWithNewline = true;
|
|
1551
|
+
this.lastOutputEndedWithNewline = false;
|
|
1073
1552
|
this.promptHeight = height;
|
|
1074
1553
|
}
|
|
1075
1554
|
buildOverlayLines() {
|
|
1076
1555
|
const lines = [];
|
|
1077
1556
|
const maxWidth = this.safeWidth();
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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));
|
|
1081
1579
|
}
|
|
1082
|
-
|
|
1083
|
-
lines.push(
|
|
1580
|
+
// Top divider
|
|
1581
|
+
lines.push(divider);
|
|
1582
|
+
// Input prompt line
|
|
1084
1583
|
const promptIndex = lines.length;
|
|
1085
|
-
|
|
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) {
|
|
1588
|
+
lines.push(this.truncateLine(line, maxWidth));
|
|
1589
|
+
}
|
|
1590
|
+
// Bottom divider
|
|
1591
|
+
lines.push(divider);
|
|
1592
|
+
// Inline panel (pinned scroll box for live output/menus)
|
|
1593
|
+
if (this.inlinePanel.length > 0) {
|
|
1594
|
+
for (const panelLine of this.inlinePanel) {
|
|
1595
|
+
lines.push(this.truncateLine(` ${panelLine}`, maxWidth));
|
|
1596
|
+
}
|
|
1597
|
+
// Separate inline content from suggestions/toggles
|
|
1598
|
+
lines.push(divider);
|
|
1599
|
+
}
|
|
1600
|
+
// Slash command suggestions
|
|
1086
1601
|
if (this.suggestions.length > 0) {
|
|
1087
1602
|
for (let index = 0; index < this.suggestions.length; index++) {
|
|
1088
1603
|
const suggestion = this.suggestions[index];
|
|
1089
1604
|
const isActive = index === this.suggestionIndex;
|
|
1090
|
-
const marker = isActive ? theme.primary('
|
|
1605
|
+
const marker = isActive ? theme.primary('▸') : theme.ui.muted(' ');
|
|
1091
1606
|
const cmdText = isActive ? theme.primary(suggestion.command) : theme.ui.muted(suggestion.command);
|
|
1092
1607
|
const descText = isActive ? suggestion.description : theme.ui.muted(suggestion.description);
|
|
1093
|
-
lines.push(this.truncateLine(
|
|
1608
|
+
lines.push(this.truncateLine(` ${marker} ${cmdText} — ${descText}`, maxWidth));
|
|
1094
1609
|
}
|
|
1095
1610
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
}
|
|
1611
|
+
// Model and context info
|
|
1612
|
+
const modelContextLine = this.buildModelContextLine();
|
|
1613
|
+
if (modelContextLine) {
|
|
1614
|
+
lines.push(this.truncateLine(` ${modelContextLine}`, maxWidth));
|
|
1100
1615
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1616
|
+
// Mode toggles
|
|
1617
|
+
const toggleLine = this.buildInlineToggleLine();
|
|
1618
|
+
if (toggleLine) {
|
|
1619
|
+
lines.push(this.truncateLine(` ${toggleLine}`, maxWidth));
|
|
1104
1620
|
}
|
|
1621
|
+
// Help hint
|
|
1622
|
+
lines.push(this.truncateLine(` ${theme.ui.muted('? for shortcuts')}`, maxWidth));
|
|
1105
1623
|
return { lines, promptIndex };
|
|
1106
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));
|
|
1637
|
+
}
|
|
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')}`);
|
|
1647
|
+
}
|
|
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
|
+
}
|
|
1107
1675
|
buildChromeLines() {
|
|
1108
1676
|
const maxWidth = this.safeWidth();
|
|
1109
1677
|
const statusLines = this.buildStatusBlock(maxWidth);
|
|
@@ -1123,15 +1691,22 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1123
1691
|
return [];
|
|
1124
1692
|
}
|
|
1125
1693
|
const segments = [];
|
|
1126
|
-
|
|
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
|
+
}
|
|
1127
1703
|
if (this.statusMeta.sessionTime) {
|
|
1128
1704
|
segments.push(`${theme.ui.muted('runtime')} ${theme.ui.muted(this.statusMeta.sessionTime)}`);
|
|
1129
1705
|
}
|
|
1130
1706
|
if (this.statusMeta.contextPercent !== undefined) {
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
segments.push(`${theme.ui.muted('ctx')} ${color(`${ctx}%`)}`);
|
|
1707
|
+
// Use animated context meter for smooth color transitions
|
|
1708
|
+
this.contextMeter.update(this.statusMeta.contextPercent);
|
|
1709
|
+
segments.push(this.contextMeter.render());
|
|
1135
1710
|
}
|
|
1136
1711
|
return this.wrapSegments(segments, maxWidth);
|
|
1137
1712
|
}
|
|
@@ -1240,12 +1815,34 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1240
1815
|
}
|
|
1241
1816
|
return lines;
|
|
1242
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
|
+
}
|
|
1243
1841
|
buildToggleLine() {
|
|
1244
1842
|
const toggles = [];
|
|
1245
1843
|
const addToggle = (label, on, hotkey, value) => {
|
|
1246
1844
|
toggles.push({ label, on, hotkey: this.formatHotkey(hotkey), value });
|
|
1247
1845
|
};
|
|
1248
|
-
addToggle('Auto', this.toggleState.autoContinueEnabled, this.toggleState.autoContinueHotkey);
|
|
1249
1846
|
addToggle('Verify', this.toggleState.verificationEnabled, this.toggleState.verificationHotkey);
|
|
1250
1847
|
const approvalMode = this.toggleState.criticalApprovalMode || 'auto';
|
|
1251
1848
|
const approvalActive = approvalMode !== 'auto';
|
|
@@ -1290,7 +1887,6 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1290
1887
|
addHotkey('interrupt', 'Ctrl+C');
|
|
1291
1888
|
addHotkey('clear input', 'Ctrl+U');
|
|
1292
1889
|
// Feature toggles (only if hotkeys are defined)
|
|
1293
|
-
addHotkey('auto-run', this.toggleState.autoContinueHotkey);
|
|
1294
1890
|
addHotkey('verify', this.toggleState.verificationHotkey);
|
|
1295
1891
|
addHotkey('thinking', this.toggleState.thinkingHotkey);
|
|
1296
1892
|
if (parts.length === 0) {
|
|
@@ -1301,15 +1897,87 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1301
1897
|
buildInputLine() {
|
|
1302
1898
|
if (this.collapsedPaste) {
|
|
1303
1899
|
const summary = `[pasted ${this.collapsedPaste.lines} lines, ${this.collapsedPaste.chars} chars] (Ctrl+L insert, Backspace discard)`;
|
|
1304
|
-
return this.truncateLine(`${theme.primary('
|
|
1900
|
+
return this.truncateLine(`${theme.primary('> ')}${theme.ui.muted(summary)}`, this.safeWidth());
|
|
1305
1901
|
}
|
|
1306
|
-
|
|
1902
|
+
// Claude Code uses simple '>' prompt
|
|
1903
|
+
const prompt = theme.primary('> ');
|
|
1307
1904
|
const promptWidth = this.visibleLength(prompt);
|
|
1308
1905
|
const maxWidth = this.safeWidth();
|
|
1309
|
-
const
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
1906
|
+
const continuationIndent = ' '; // 2 spaces for continuation lines
|
|
1907
|
+
const continuationWidth = continuationIndent.length;
|
|
1908
|
+
// Handle multi-line input - split buffer on newlines first
|
|
1909
|
+
const normalized = this.buffer.replace(/\r/g, '\n');
|
|
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;
|
|
1915
|
+
let cursorCol = 0;
|
|
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;
|
|
1955
|
+
}
|
|
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');
|
|
1313
1981
|
}
|
|
1314
1982
|
buildInputWindow(available) {
|
|
1315
1983
|
if (available <= 0) {
|
|
@@ -1440,17 +2108,6 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1440
2108
|
}
|
|
1441
2109
|
return result;
|
|
1442
2110
|
}
|
|
1443
|
-
clearOverlayRows(rows, startRow) {
|
|
1444
|
-
const totalRows = this.rows || 24;
|
|
1445
|
-
const limit = Math.max(0, Math.min(rows, totalRows));
|
|
1446
|
-
for (let idx = 0; idx < limit; idx++) {
|
|
1447
|
-
const row = startRow + idx;
|
|
1448
|
-
if (row < 1 || row > totalRows)
|
|
1449
|
-
continue;
|
|
1450
|
-
this.write(ESC.TO(row, 1));
|
|
1451
|
-
this.write(ESC.CLEAR_LINE);
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
2111
|
getBuffer() {
|
|
1455
2112
|
return this.buffer;
|
|
1456
2113
|
}
|
|
@@ -1478,6 +2135,35 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1478
2135
|
setModeStatus(status) {
|
|
1479
2136
|
this.updateStatus(status);
|
|
1480
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
|
+
}
|
|
1481
2167
|
emitPrompt(content) {
|
|
1482
2168
|
this.pushPromptEvent(content);
|
|
1483
2169
|
}
|
|
@@ -1521,23 +2207,30 @@ export class UnifiedUIRenderer extends EventEmitter {
|
|
|
1521
2207
|
const height = this.lastOverlay?.lines.length ?? this.promptHeight ?? 0;
|
|
1522
2208
|
if (height === 0)
|
|
1523
2209
|
return;
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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');
|
|
1532
2220
|
this.write(ESC.CLEAR_LINE);
|
|
2221
|
+
if (i < height - 1) {
|
|
2222
|
+
this.write('\x1b[B');
|
|
2223
|
+
}
|
|
1533
2224
|
}
|
|
1534
|
-
// Move
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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');
|
|
1538
2230
|
this.lastOverlay = null;
|
|
1539
|
-
this.overlayInvalidated = true;
|
|
1540
2231
|
this.promptHeight = 0;
|
|
2232
|
+
this.lastOverlayHeight = 0;
|
|
2233
|
+
this.isPromptActive = false;
|
|
1541
2234
|
}
|
|
1542
2235
|
updateTerminalSize() {
|
|
1543
2236
|
if (this.output.isTTY) {
|