erosolar-cli 1.7.244 → 1.7.245
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 +22 -148
- package/dist/core/customCommands.d.ts +1 -0
- package/dist/core/customCommands.d.ts.map +1 -1
- package/dist/core/customCommands.js +3 -0
- package/dist/core/customCommands.js.map +1 -1
- package/dist/core/toolPreconditions.d.ts.map +1 -1
- package/dist/core/toolPreconditions.js +0 -14
- package/dist/core/toolPreconditions.js.map +1 -1
- package/dist/core/toolRuntime.d.ts.map +1 -1
- package/dist/core/toolRuntime.js +0 -5
- package/dist/core/toolRuntime.js.map +1 -1
- package/dist/core/toolValidation.d.ts.map +1 -1
- package/dist/core/toolValidation.js +14 -3
- package/dist/core/toolValidation.js.map +1 -1
- package/dist/mcp/sseClient.d.ts.map +1 -1
- package/dist/mcp/sseClient.js +9 -18
- package/dist/mcp/sseClient.js.map +1 -1
- package/dist/plugins/tools/build/buildPlugin.d.ts +0 -6
- package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
- package/dist/plugins/tools/build/buildPlugin.js +4 -10
- package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
- package/dist/shell/interactiveShell.d.ts +9 -2
- package/dist/shell/interactiveShell.d.ts.map +1 -1
- package/dist/shell/interactiveShell.js +135 -20
- package/dist/shell/interactiveShell.js.map +1 -1
- package/dist/shell/terminalInput.d.ts +48 -116
- package/dist/shell/terminalInput.d.ts.map +1 -1
- package/dist/shell/terminalInput.js +317 -522
- package/dist/shell/terminalInput.js.map +1 -1
- package/dist/shell/terminalInputAdapter.d.ts +12 -15
- package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
- package/dist/shell/terminalInputAdapter.js +8 -22
- package/dist/shell/terminalInputAdapter.js.map +1 -1
- package/dist/ui/display.d.ts +19 -0
- package/dist/ui/display.d.ts.map +1 -1
- package/dist/ui/display.js +108 -0
- package/dist/ui/display.js.map +1 -1
- package/dist/ui/theme.d.ts.map +1 -1
- package/dist/ui/theme.js +6 -8
- package/dist/ui/theme.js.map +1 -1
- package/dist/ui/unified/layout.d.ts +1 -0
- package/dist/ui/unified/layout.d.ts.map +1 -1
- package/dist/ui/unified/layout.js +52 -25
- package/dist/ui/unified/layout.js.map +1 -1
- package/package.json +1 -1
- package/dist/core/aiFlowOptimizer.d.ts +0 -26
- package/dist/core/aiFlowOptimizer.d.ts.map +0 -1
- package/dist/core/aiFlowOptimizer.js +0 -31
- package/dist/core/aiFlowOptimizer.js.map +0 -1
- package/dist/core/aiOptimizationEngine.d.ts +0 -158
- package/dist/core/aiOptimizationEngine.d.ts.map +0 -1
- package/dist/core/aiOptimizationEngine.js +0 -428
- package/dist/core/aiOptimizationEngine.js.map +0 -1
- package/dist/core/aiOptimizationIntegration.d.ts +0 -93
- package/dist/core/aiOptimizationIntegration.d.ts.map +0 -1
- package/dist/core/aiOptimizationIntegration.js +0 -250
- package/dist/core/aiOptimizationIntegration.js.map +0 -1
- package/dist/core/enhancedErrorRecovery.d.ts +0 -100
- package/dist/core/enhancedErrorRecovery.d.ts.map +0 -1
- package/dist/core/enhancedErrorRecovery.js +0 -345
- package/dist/core/enhancedErrorRecovery.js.map +0 -1
- package/dist/shell/claudeCodeStreamHandler.d.ts +0 -145
- package/dist/shell/claudeCodeStreamHandler.d.ts.map +0 -1
- package/dist/shell/claudeCodeStreamHandler.js +0 -322
- package/dist/shell/claudeCodeStreamHandler.js.map +0 -1
- package/dist/shell/inputQueueManager.d.ts +0 -144
- package/dist/shell/inputQueueManager.d.ts.map +0 -1
- package/dist/shell/inputQueueManager.js +0 -290
- package/dist/shell/inputQueueManager.js.map +0 -1
- package/dist/shell/streamingOutputManager.d.ts +0 -115
- package/dist/shell/streamingOutputManager.d.ts.map +0 -1
- package/dist/shell/streamingOutputManager.js +0 -225
- package/dist/shell/streamingOutputManager.js.map +0 -1
- package/dist/ui/persistentPrompt.d.ts +0 -50
- package/dist/ui/persistentPrompt.d.ts.map +0 -1
- package/dist/ui/persistentPrompt.js +0 -92
- package/dist/ui/persistentPrompt.js.map +0 -1
|
@@ -3,16 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Design principles:
|
|
5
5
|
* - Single source of truth for input state
|
|
6
|
+
* - One bottom-pinned chat box for the entire session (no inline anchors)
|
|
6
7
|
* - Native bracketed paste support (no heuristics)
|
|
7
8
|
* - Clean cursor model with render-time wrapping
|
|
8
9
|
* - State machine for different input modes
|
|
9
10
|
* - No readline dependency for display
|
|
10
11
|
*/
|
|
11
12
|
import { EventEmitter } from 'node:events';
|
|
12
|
-
import { isMultilinePaste
|
|
13
|
+
import { isMultilinePaste } from '../core/multilinePasteHandler.js';
|
|
13
14
|
import { writeLock } from '../ui/writeLock.js';
|
|
14
|
-
import { renderDivider } from '../ui/unified/layout.js';
|
|
15
|
+
import { renderDivider, renderStatusLine } from '../ui/unified/layout.js';
|
|
15
16
|
import { isStreamingMode } from '../ui/globalWriteLock.js';
|
|
17
|
+
import { formatThinking } from '../ui/toolDisplay.js';
|
|
16
18
|
// ANSI escape codes
|
|
17
19
|
const ESC = {
|
|
18
20
|
// Cursor control
|
|
@@ -67,6 +69,11 @@ export class TerminalInput extends EventEmitter {
|
|
|
67
69
|
statusMessage = null;
|
|
68
70
|
overrideStatusMessage = null; // Secondary status (warnings, etc.)
|
|
69
71
|
streamingLabel = null; // Streaming progress indicator
|
|
72
|
+
metaElapsedSeconds = null; // Optional elapsed time for header line
|
|
73
|
+
metaTokensUsed = null; // Optional token usage
|
|
74
|
+
metaTokenLimit = null; // Optional token window
|
|
75
|
+
metaThinkingMs = null; // Optional thinking duration
|
|
76
|
+
metaThinkingHasContent = false; // Whether collapsed thinking content exists
|
|
70
77
|
reservedLines = 2;
|
|
71
78
|
scrollRegionActive = false;
|
|
72
79
|
lastRenderContent = '';
|
|
@@ -74,22 +81,12 @@ export class TerminalInput extends EventEmitter {
|
|
|
74
81
|
renderDirty = false;
|
|
75
82
|
isRendering = false;
|
|
76
83
|
pinnedTopRows = 0;
|
|
77
|
-
inlineAnchorRow = null;
|
|
78
|
-
inlineLayout = false;
|
|
79
|
-
anchorProvider = null;
|
|
80
|
-
// Flow mode: when true, renders inline after content (no absolute positioning)
|
|
81
|
-
flowMode = true;
|
|
82
|
-
flowModeRenderedLines = 0; // Track lines rendered for clearing
|
|
83
|
-
// Command suggestions (Claude Code style auto-complete)
|
|
84
|
-
commandSuggestions = [];
|
|
85
|
-
filteredSuggestions = [];
|
|
86
|
-
selectedSuggestionIndex = 0;
|
|
87
|
-
showSuggestions = false;
|
|
88
|
-
maxVisibleSuggestions = 10;
|
|
89
84
|
// Lifecycle
|
|
90
85
|
disposed = false;
|
|
91
86
|
enabled = true;
|
|
92
87
|
contextUsage = null;
|
|
88
|
+
contextAutoCompactThreshold = 90;
|
|
89
|
+
thinkingModeLabel = null;
|
|
93
90
|
editMode = 'display-edits';
|
|
94
91
|
verificationEnabled = true;
|
|
95
92
|
autoContinueEnabled = false;
|
|
@@ -97,10 +94,9 @@ export class TerminalInput extends EventEmitter {
|
|
|
97
94
|
autoContinueHotkey = 'alt+c';
|
|
98
95
|
// Output interceptor cleanup
|
|
99
96
|
outputInterceptorCleanup;
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
thinkingEnabled = true;
|
|
97
|
+
// Streaming render throttle
|
|
98
|
+
lastStreamingRender = 0;
|
|
99
|
+
streamingRenderInterval = 250; // ms between renders during streaming
|
|
104
100
|
constructor(writeStream = process.stdout, config = {}) {
|
|
105
101
|
super();
|
|
106
102
|
this.out = writeStream;
|
|
@@ -188,11 +184,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
188
184
|
if (handled)
|
|
189
185
|
return;
|
|
190
186
|
}
|
|
191
|
-
// Handle '?' for help hint (if buffer is empty)
|
|
192
|
-
if (str === '?' && this.buffer.length === 0) {
|
|
193
|
-
this.emit('showHelp');
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
187
|
// Insert printable characters
|
|
197
188
|
if (str && !key?.ctrl && !key?.meta) {
|
|
198
189
|
this.insertText(str);
|
|
@@ -201,149 +192,24 @@ export class TerminalInput extends EventEmitter {
|
|
|
201
192
|
/**
|
|
202
193
|
* Set the input mode
|
|
203
194
|
*
|
|
204
|
-
* Streaming
|
|
205
|
-
*
|
|
206
|
-
* the cursor is (below the streamed content).
|
|
195
|
+
* Streaming keeps the scroll region active so the prompt/status stay pinned
|
|
196
|
+
* below the streaming output. When streaming ends, we refresh the input area.
|
|
207
197
|
*/
|
|
208
198
|
setMode(mode) {
|
|
209
199
|
const prevMode = this.mode;
|
|
210
200
|
this.mode = mode;
|
|
211
201
|
if (mode === 'streaming' && prevMode !== 'streaming') {
|
|
212
|
-
//
|
|
213
|
-
this.
|
|
214
|
-
// Disable scroll region - let content flow naturally from current position
|
|
215
|
-
this.disableScrollRegion();
|
|
216
|
-
// Hide cursor during streaming to avoid racing chars
|
|
217
|
-
this.write(ESC.HIDE);
|
|
218
|
-
// Reset flow mode tracking
|
|
219
|
-
this.flowModeRenderedLines = 0;
|
|
202
|
+
// Keep scroll region active so status/prompt stay pinned while streaming
|
|
203
|
+
this.enableScrollRegion();
|
|
220
204
|
this.renderDirty = true;
|
|
205
|
+
this.render();
|
|
221
206
|
}
|
|
222
207
|
else if (mode !== 'streaming' && prevMode === 'streaming') {
|
|
223
|
-
//
|
|
224
|
-
this.
|
|
225
|
-
// Show cursor again
|
|
226
|
-
this.write(ESC.SHOW);
|
|
227
|
-
// Add a newline to separate content from input area
|
|
228
|
-
this.write('\n');
|
|
229
|
-
// Reset flow mode tracking for fresh render
|
|
230
|
-
this.flowModeRenderedLines = 0;
|
|
231
|
-
// Re-render the input area below the content
|
|
208
|
+
// Streaming ended - render the input area
|
|
209
|
+
this.enableScrollRegion();
|
|
232
210
|
this.forceRender();
|
|
233
211
|
}
|
|
234
212
|
}
|
|
235
|
-
/**
|
|
236
|
-
* Enable or disable flow mode.
|
|
237
|
-
* In flow mode, the input renders immediately after content (wherever cursor is).
|
|
238
|
-
* When disabled, input renders at the absolute bottom of terminal.
|
|
239
|
-
*/
|
|
240
|
-
setFlowMode(enabled) {
|
|
241
|
-
if (this.flowMode === enabled)
|
|
242
|
-
return;
|
|
243
|
-
this.flowMode = enabled;
|
|
244
|
-
this.renderDirty = true;
|
|
245
|
-
this.scheduleRender();
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* Check if flow mode is enabled.
|
|
249
|
-
*/
|
|
250
|
-
isFlowMode() {
|
|
251
|
-
return this.flowMode;
|
|
252
|
-
}
|
|
253
|
-
/**
|
|
254
|
-
* Set available slash commands for auto-complete suggestions.
|
|
255
|
-
*/
|
|
256
|
-
setCommands(commands) {
|
|
257
|
-
this.commandSuggestions = commands;
|
|
258
|
-
this.updateSuggestions();
|
|
259
|
-
}
|
|
260
|
-
/**
|
|
261
|
-
* Update filtered suggestions based on current input.
|
|
262
|
-
*/
|
|
263
|
-
updateSuggestions() {
|
|
264
|
-
const input = this.buffer.trim();
|
|
265
|
-
// Only show suggestions when input starts with "/"
|
|
266
|
-
if (!input.startsWith('/')) {
|
|
267
|
-
this.showSuggestions = false;
|
|
268
|
-
this.filteredSuggestions = [];
|
|
269
|
-
this.selectedSuggestionIndex = 0;
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
const query = input.toLowerCase();
|
|
273
|
-
this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
|
|
274
|
-
cmd.command.toLowerCase().includes(query.slice(1)));
|
|
275
|
-
// Show suggestions if we have matches
|
|
276
|
-
this.showSuggestions = this.filteredSuggestions.length > 0;
|
|
277
|
-
// Keep selection in bounds
|
|
278
|
-
if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
|
|
279
|
-
this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
/**
|
|
283
|
-
* Select next suggestion (arrow down / tab).
|
|
284
|
-
*/
|
|
285
|
-
selectNextSuggestion() {
|
|
286
|
-
if (!this.showSuggestions || this.filteredSuggestions.length === 0)
|
|
287
|
-
return;
|
|
288
|
-
this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
|
|
289
|
-
this.renderDirty = true;
|
|
290
|
-
this.scheduleRender();
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Select previous suggestion (arrow up / shift+tab).
|
|
294
|
-
*/
|
|
295
|
-
selectPrevSuggestion() {
|
|
296
|
-
if (!this.showSuggestions || this.filteredSuggestions.length === 0)
|
|
297
|
-
return;
|
|
298
|
-
this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
|
|
299
|
-
? this.filteredSuggestions.length - 1
|
|
300
|
-
: this.selectedSuggestionIndex - 1;
|
|
301
|
-
this.renderDirty = true;
|
|
302
|
-
this.scheduleRender();
|
|
303
|
-
}
|
|
304
|
-
/**
|
|
305
|
-
* Accept current suggestion and insert into buffer.
|
|
306
|
-
*/
|
|
307
|
-
acceptSuggestion() {
|
|
308
|
-
if (!this.showSuggestions || this.filteredSuggestions.length === 0)
|
|
309
|
-
return false;
|
|
310
|
-
const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
|
|
311
|
-
if (!selected)
|
|
312
|
-
return false;
|
|
313
|
-
// Replace buffer with selected command
|
|
314
|
-
this.buffer = selected.command + ' ';
|
|
315
|
-
this.cursor = this.buffer.length;
|
|
316
|
-
this.showSuggestions = false;
|
|
317
|
-
this.renderDirty = true;
|
|
318
|
-
this.scheduleRender();
|
|
319
|
-
return true;
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* Check if suggestions are visible.
|
|
323
|
-
*/
|
|
324
|
-
areSuggestionsVisible() {
|
|
325
|
-
return this.showSuggestions && this.filteredSuggestions.length > 0;
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* Update token count for metrics display
|
|
329
|
-
*/
|
|
330
|
-
setTokensUsed(tokens) {
|
|
331
|
-
this.tokensUsed = tokens;
|
|
332
|
-
}
|
|
333
|
-
/**
|
|
334
|
-
* Toggle thinking/reasoning mode
|
|
335
|
-
*/
|
|
336
|
-
toggleThinking() {
|
|
337
|
-
this.thinkingEnabled = !this.thinkingEnabled;
|
|
338
|
-
this.emit('thinkingToggle', this.thinkingEnabled);
|
|
339
|
-
this.scheduleRender();
|
|
340
|
-
}
|
|
341
|
-
/**
|
|
342
|
-
* Get thinking enabled state
|
|
343
|
-
*/
|
|
344
|
-
isThinkingEnabled() {
|
|
345
|
-
return this.thinkingEnabled;
|
|
346
|
-
}
|
|
347
213
|
/**
|
|
348
214
|
* Keep the top N rows pinned outside the scroll region (used for the launch banner).
|
|
349
215
|
*/
|
|
@@ -356,42 +222,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
356
222
|
}
|
|
357
223
|
}
|
|
358
224
|
}
|
|
359
|
-
/**
|
|
360
|
-
* Anchor prompt rendering near a specific row (inline layout). Pass null to
|
|
361
|
-
* restore the default bottom-aligned layout.
|
|
362
|
-
*/
|
|
363
|
-
setInlineAnchor(row) {
|
|
364
|
-
if (row === null || row === undefined) {
|
|
365
|
-
this.inlineAnchorRow = null;
|
|
366
|
-
this.inlineLayout = false;
|
|
367
|
-
this.renderDirty = true;
|
|
368
|
-
this.render();
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
const { rows } = this.getSize();
|
|
372
|
-
const clamped = Math.max(1, Math.min(Math.floor(row), rows));
|
|
373
|
-
this.inlineAnchorRow = clamped;
|
|
374
|
-
this.inlineLayout = true;
|
|
375
|
-
this.renderDirty = true;
|
|
376
|
-
this.render();
|
|
377
|
-
}
|
|
378
|
-
/**
|
|
379
|
-
* Provide a dynamic anchor callback. When set, the prompt will follow the
|
|
380
|
-
* output by re-evaluating the anchor before each render.
|
|
381
|
-
*/
|
|
382
|
-
setInlineAnchorProvider(provider) {
|
|
383
|
-
this.anchorProvider = provider;
|
|
384
|
-
if (!provider) {
|
|
385
|
-
this.inlineLayout = false;
|
|
386
|
-
this.inlineAnchorRow = null;
|
|
387
|
-
this.renderDirty = true;
|
|
388
|
-
this.render();
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
this.inlineLayout = true;
|
|
392
|
-
this.renderDirty = true;
|
|
393
|
-
this.render();
|
|
394
|
-
}
|
|
395
225
|
/**
|
|
396
226
|
* Get current mode
|
|
397
227
|
*/
|
|
@@ -501,6 +331,37 @@ export class TerminalInput extends EventEmitter {
|
|
|
501
331
|
this.streamingLabel = next;
|
|
502
332
|
this.scheduleRender();
|
|
503
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Surface meta status just above the divider (e.g., elapsed time or token usage).
|
|
336
|
+
*/
|
|
337
|
+
setMetaStatus(meta) {
|
|
338
|
+
const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
|
|
339
|
+
? Math.floor(meta.elapsedSeconds)
|
|
340
|
+
: null;
|
|
341
|
+
const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
|
|
342
|
+
? Math.floor(meta.tokensUsed)
|
|
343
|
+
: null;
|
|
344
|
+
const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
|
|
345
|
+
? Math.floor(meta.tokenLimit)
|
|
346
|
+
: null;
|
|
347
|
+
const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
|
|
348
|
+
? Math.floor(meta.thinkingMs)
|
|
349
|
+
: null;
|
|
350
|
+
const nextThinkingHasContent = !!meta.thinkingHasContent;
|
|
351
|
+
if (this.metaElapsedSeconds === nextElapsed &&
|
|
352
|
+
this.metaTokensUsed === nextTokens &&
|
|
353
|
+
this.metaTokenLimit === nextLimit &&
|
|
354
|
+
this.metaThinkingMs === nextThinking &&
|
|
355
|
+
this.metaThinkingHasContent === nextThinkingHasContent) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
this.metaElapsedSeconds = nextElapsed;
|
|
359
|
+
this.metaTokensUsed = nextTokens;
|
|
360
|
+
this.metaTokenLimit = nextLimit;
|
|
361
|
+
this.metaThinkingMs = nextThinking;
|
|
362
|
+
this.metaThinkingHasContent = nextThinkingHasContent;
|
|
363
|
+
this.scheduleRender();
|
|
364
|
+
}
|
|
504
365
|
/**
|
|
505
366
|
* Keep mode toggles (verification/auto-continue) visible in the control bar.
|
|
506
367
|
* Hotkey labels remain stable so the bar looks the same before/during streaming.
|
|
@@ -510,16 +371,19 @@ export class TerminalInput extends EventEmitter {
|
|
|
510
371
|
const nextAutoContinue = !!options.autoContinueEnabled;
|
|
511
372
|
const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
|
|
512
373
|
const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
|
|
374
|
+
const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
|
|
513
375
|
if (this.verificationEnabled === nextVerification &&
|
|
514
376
|
this.autoContinueEnabled === nextAutoContinue &&
|
|
515
377
|
this.verificationHotkey === nextVerifyHotkey &&
|
|
516
|
-
this.autoContinueHotkey === nextAutoHotkey
|
|
378
|
+
this.autoContinueHotkey === nextAutoHotkey &&
|
|
379
|
+
this.thinkingModeLabel === nextThinkingLabel) {
|
|
517
380
|
return;
|
|
518
381
|
}
|
|
519
382
|
this.verificationEnabled = nextVerification;
|
|
520
383
|
this.autoContinueEnabled = nextAutoContinue;
|
|
521
384
|
this.verificationHotkey = nextVerifyHotkey;
|
|
522
385
|
this.autoContinueHotkey = nextAutoHotkey;
|
|
386
|
+
this.thinkingModeLabel = nextThinkingLabel;
|
|
523
387
|
this.scheduleRender();
|
|
524
388
|
}
|
|
525
389
|
/**
|
|
@@ -534,188 +398,88 @@ export class TerminalInput extends EventEmitter {
|
|
|
534
398
|
/**
|
|
535
399
|
* Render the input area - Claude Code style with mode controls
|
|
536
400
|
*
|
|
537
|
-
* During streaming, we skip
|
|
538
|
-
*
|
|
401
|
+
* IMPORTANT: During streaming mode, we skip the full render to avoid
|
|
402
|
+
* interfering with streaming content. Streaming content writes naturally
|
|
403
|
+
* to stdout, and cursor positioning during streaming would cause garbled output.
|
|
404
|
+
* A full render is done when streaming ends via forceRender().
|
|
539
405
|
*/
|
|
540
406
|
render() {
|
|
541
407
|
if (!this.canRender())
|
|
542
408
|
return;
|
|
543
409
|
if (this.isRendering)
|
|
544
410
|
return;
|
|
545
|
-
//
|
|
411
|
+
// CRITICAL: During streaming, skip full render to avoid interfering
|
|
412
|
+
// with streaming content. The streaming chunks need to flow naturally
|
|
413
|
+
// without cursor repositioning breaking them up.
|
|
414
|
+
// Check both local mode and global streaming mode flag
|
|
546
415
|
if (this.mode === 'streaming' || isStreamingMode()) {
|
|
547
|
-
this.renderDirty = true;
|
|
416
|
+
this.renderDirty = true; // Mark dirty so we render after streaming ends
|
|
548
417
|
return;
|
|
549
418
|
}
|
|
550
419
|
const shouldSkip = !this.renderDirty &&
|
|
551
420
|
this.buffer === this.lastRenderContent &&
|
|
552
421
|
this.cursor === this.lastRenderCursor;
|
|
553
422
|
this.renderDirty = false;
|
|
554
|
-
// Skip if nothing changed
|
|
423
|
+
// Skip if nothing changed and no explicit refresh requested
|
|
555
424
|
if (shouldSkip) {
|
|
556
425
|
return;
|
|
557
426
|
}
|
|
558
|
-
// If write lock is held, defer render
|
|
427
|
+
// If write lock is held, defer render to avoid race conditions
|
|
559
428
|
if (writeLock.isLocked()) {
|
|
560
429
|
writeLock.safeWrite(() => this.render());
|
|
561
430
|
return;
|
|
562
431
|
}
|
|
563
432
|
this.isRendering = true;
|
|
433
|
+
// Use write lock during render to prevent interleaved output
|
|
564
434
|
writeLock.lock('terminalInput.render');
|
|
565
435
|
try {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
finally {
|
|
570
|
-
writeLock.unlock();
|
|
571
|
-
this.isRendering = false;
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
/**
|
|
575
|
-
* Render in flow mode - delegates to bottom-pinned for stability.
|
|
576
|
-
*
|
|
577
|
-
* Flow mode attempted inline rendering but caused duplicate renders
|
|
578
|
-
* due to unreliable cursor position tracking. Bottom-pinned is reliable.
|
|
579
|
-
*/
|
|
580
|
-
renderFlowMode() {
|
|
581
|
-
// Use stable bottom-pinned approach
|
|
582
|
-
this.renderBottomPinned();
|
|
583
|
-
}
|
|
584
|
-
/**
|
|
585
|
-
* Render in bottom-pinned mode - Claude Code style with suggestions
|
|
586
|
-
*
|
|
587
|
-
* Layout when suggestions visible:
|
|
588
|
-
* - Top divider
|
|
589
|
-
* - Input line(s)
|
|
590
|
-
* - Bottom divider
|
|
591
|
-
* - Suggestions (command list)
|
|
592
|
-
*
|
|
593
|
-
* Layout when suggestions hidden:
|
|
594
|
-
* - Status bar (Ready/Streaming)
|
|
595
|
-
* - Top divider
|
|
596
|
-
* - Input line(s)
|
|
597
|
-
* - Bottom divider
|
|
598
|
-
* - Mode controls
|
|
599
|
-
*/
|
|
600
|
-
renderBottomPinned() {
|
|
601
|
-
const { rows, cols } = this.getSize();
|
|
602
|
-
const maxWidth = Math.max(8, cols - 4);
|
|
603
|
-
// Wrap buffer into display lines
|
|
604
|
-
const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
|
|
605
|
-
const availableForContent = Math.max(1, rows - 3);
|
|
606
|
-
const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
|
|
607
|
-
const displayLines = Math.min(lines.length, maxVisible);
|
|
608
|
-
// Calculate display window (keep cursor visible)
|
|
609
|
-
let startLine = 0;
|
|
610
|
-
if (lines.length > displayLines) {
|
|
611
|
-
startLine = Math.max(0, cursorLine - displayLines + 1);
|
|
612
|
-
startLine = Math.min(startLine, lines.length - displayLines);
|
|
613
|
-
}
|
|
614
|
-
const visibleLines = lines.slice(startLine, startLine + displayLines);
|
|
615
|
-
const adjustedCursorLine = cursorLine - startLine;
|
|
616
|
-
// Calculate suggestion display
|
|
617
|
-
const suggestionsToShow = this.showSuggestions
|
|
618
|
-
? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
|
|
619
|
-
: [];
|
|
620
|
-
const suggestionLines = suggestionsToShow.length;
|
|
621
|
-
this.write(ESC.HIDE);
|
|
622
|
-
this.write(ESC.RESET);
|
|
623
|
-
const divider = renderDivider(cols - 2);
|
|
624
|
-
// Calculate positions from absolute bottom
|
|
625
|
-
let currentRow;
|
|
626
|
-
if (suggestionLines > 0) {
|
|
627
|
-
// With suggestions: input area + dividers + suggestions
|
|
628
|
-
// Layout: [topDiv] [input] [bottomDiv] [suggestions...]
|
|
629
|
-
const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
|
|
630
|
-
currentRow = Math.max(1, rows - totalHeight + 1);
|
|
631
|
-
this.updateReservedLines(totalHeight);
|
|
632
|
-
// Top divider
|
|
633
|
-
this.write(ESC.TO(currentRow, 1));
|
|
634
|
-
this.write(ESC.CLEAR_LINE);
|
|
635
|
-
this.write(divider);
|
|
636
|
-
currentRow++;
|
|
637
|
-
// Input lines
|
|
638
|
-
let finalRow = currentRow;
|
|
639
|
-
let finalCol = 3;
|
|
640
|
-
for (let i = 0; i < visibleLines.length; i++) {
|
|
641
|
-
this.write(ESC.TO(currentRow, 1));
|
|
642
|
-
this.write(ESC.CLEAR_LINE);
|
|
643
|
-
const line = visibleLines[i] ?? '';
|
|
644
|
-
const absoluteLineIdx = startLine + i;
|
|
645
|
-
const isFirstLine = absoluteLineIdx === 0;
|
|
646
|
-
const isCursorLine = i === adjustedCursorLine;
|
|
647
|
-
this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
|
|
648
|
-
if (isCursorLine) {
|
|
649
|
-
const col = Math.min(cursorCol, line.length);
|
|
650
|
-
this.write(line.slice(0, col));
|
|
651
|
-
this.write(ESC.REVERSE);
|
|
652
|
-
this.write(col < line.length ? line[col] : ' ');
|
|
653
|
-
this.write(ESC.RESET);
|
|
654
|
-
this.write(line.slice(col + 1));
|
|
655
|
-
finalRow = currentRow;
|
|
656
|
-
finalCol = this.config.promptChar.length + col + 1;
|
|
657
|
-
}
|
|
658
|
-
else {
|
|
659
|
-
this.write(line);
|
|
660
|
-
}
|
|
661
|
-
currentRow++;
|
|
436
|
+
if (!this.scrollRegionActive) {
|
|
437
|
+
this.enableScrollRegion();
|
|
662
438
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
this.
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
439
|
+
const { rows, cols } = this.getSize();
|
|
440
|
+
const maxWidth = Math.max(8, cols - 4);
|
|
441
|
+
// Wrap buffer into display lines
|
|
442
|
+
const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
|
|
443
|
+
const availableForContent = Math.max(1, rows - 3); // room for separator + input + controls
|
|
444
|
+
const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
|
|
445
|
+
const displayLines = Math.min(lines.length, maxVisible);
|
|
446
|
+
const metaLines = this.buildMetaLines(cols - 2);
|
|
447
|
+
// Reserved lines: optional meta lines + separator(1) + input lines + controls(1)
|
|
448
|
+
this.updateReservedLines(displayLines + 2 + metaLines.length);
|
|
449
|
+
// Calculate display window (keep cursor visible)
|
|
450
|
+
let startLine = 0;
|
|
451
|
+
if (lines.length > displayLines) {
|
|
452
|
+
startLine = Math.max(0, cursorLine - displayLines + 1);
|
|
453
|
+
startLine = Math.min(startLine, lines.length - displayLines);
|
|
454
|
+
}
|
|
455
|
+
const visibleLines = lines.slice(startLine, startLine + displayLines);
|
|
456
|
+
const adjustedCursorLine = cursorLine - startLine;
|
|
457
|
+
// Render
|
|
458
|
+
this.write(ESC.HIDE);
|
|
459
|
+
this.write(ESC.RESET);
|
|
460
|
+
const startRow = Math.max(1, rows - this.reservedLines + 1);
|
|
461
|
+
let currentRow = startRow;
|
|
462
|
+
// Clear the reserved block to avoid stale meta/status lines
|
|
463
|
+
this.clearReservedArea(startRow, this.reservedLines, cols);
|
|
464
|
+
// Meta/status header (elapsed, tokens/context)
|
|
465
|
+
for (const metaLine of metaLines) {
|
|
670
466
|
this.write(ESC.TO(currentRow, 1));
|
|
671
467
|
this.write(ESC.CLEAR_LINE);
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
// Indent and highlight selected
|
|
675
|
-
this.write(' ');
|
|
676
|
-
if (isSelected) {
|
|
677
|
-
this.write(ESC.REVERSE);
|
|
678
|
-
this.write(ESC.BOLD);
|
|
679
|
-
}
|
|
680
|
-
this.write(suggestion.command);
|
|
681
|
-
if (isSelected) {
|
|
682
|
-
this.write(ESC.RESET);
|
|
683
|
-
}
|
|
684
|
-
// Description (dimmed)
|
|
685
|
-
const descSpace = cols - suggestion.command.length - 8;
|
|
686
|
-
if (descSpace > 10 && suggestion.description) {
|
|
687
|
-
const desc = suggestion.description.slice(0, descSpace);
|
|
688
|
-
this.write(ESC.RESET);
|
|
689
|
-
this.write(ESC.DIM);
|
|
690
|
-
this.write(' ');
|
|
691
|
-
this.write(desc);
|
|
692
|
-
this.write(ESC.RESET);
|
|
693
|
-
}
|
|
694
|
-
currentRow++;
|
|
468
|
+
this.write(metaLine);
|
|
469
|
+
currentRow += 1;
|
|
695
470
|
}
|
|
696
|
-
//
|
|
697
|
-
this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
|
|
698
|
-
}
|
|
699
|
-
else {
|
|
700
|
-
// Without suggestions: normal layout with status bar and controls
|
|
701
|
-
const totalHeight = visibleLines.length + 4; // status + topDiv + input + bottomDiv + controls
|
|
702
|
-
currentRow = Math.max(1, rows - totalHeight + 1);
|
|
703
|
-
this.updateReservedLines(totalHeight);
|
|
704
|
-
// Status bar
|
|
705
|
-
this.write(ESC.TO(currentRow, 1));
|
|
706
|
-
this.write(ESC.CLEAR_LINE);
|
|
707
|
-
this.write(this.buildStatusBar(cols));
|
|
708
|
-
currentRow++;
|
|
709
|
-
// Top divider
|
|
471
|
+
// Separator line
|
|
710
472
|
this.write(ESC.TO(currentRow, 1));
|
|
711
473
|
this.write(ESC.CLEAR_LINE);
|
|
474
|
+
const divider = renderDivider(cols - 2);
|
|
712
475
|
this.write(divider);
|
|
713
|
-
currentRow
|
|
714
|
-
//
|
|
476
|
+
currentRow += 1;
|
|
477
|
+
// Render input lines
|
|
715
478
|
let finalRow = currentRow;
|
|
716
479
|
let finalCol = 3;
|
|
717
480
|
for (let i = 0; i < visibleLines.length; i++) {
|
|
718
|
-
|
|
481
|
+
const rowNum = currentRow + i;
|
|
482
|
+
this.write(ESC.TO(rowNum, 1));
|
|
719
483
|
this.write(ESC.CLEAR_LINE);
|
|
720
484
|
const line = visibleLines[i] ?? '';
|
|
721
485
|
const absoluteLineIdx = startLine + i;
|
|
@@ -729,6 +493,7 @@ export class TerminalInput extends EventEmitter {
|
|
|
729
493
|
this.write(ESC.RESET);
|
|
730
494
|
this.write(ESC.BG_DARK);
|
|
731
495
|
if (isCursorLine) {
|
|
496
|
+
// Render with block cursor
|
|
732
497
|
const col = Math.min(cursorCol, line.length);
|
|
733
498
|
const before = line.slice(0, col);
|
|
734
499
|
const at = col < line.length ? line[col] : ' ';
|
|
@@ -738,135 +503,209 @@ export class TerminalInput extends EventEmitter {
|
|
|
738
503
|
this.write(at);
|
|
739
504
|
this.write(ESC.RESET + ESC.BG_DARK);
|
|
740
505
|
this.write(after);
|
|
741
|
-
finalRow =
|
|
506
|
+
finalRow = rowNum;
|
|
742
507
|
finalCol = this.config.promptChar.length + col + 1;
|
|
743
508
|
}
|
|
744
509
|
else {
|
|
745
510
|
this.write(line);
|
|
746
511
|
}
|
|
747
|
-
// Pad to edge
|
|
512
|
+
// Pad to edge for clean look
|
|
748
513
|
const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
|
|
749
514
|
const padding = Math.max(0, cols - lineLen - 1);
|
|
750
515
|
if (padding > 0)
|
|
751
516
|
this.write(' '.repeat(padding));
|
|
752
517
|
this.write(ESC.RESET);
|
|
753
|
-
currentRow++;
|
|
754
518
|
}
|
|
755
|
-
//
|
|
756
|
-
|
|
757
|
-
this.write(ESC.
|
|
758
|
-
this.write(divider);
|
|
759
|
-
currentRow++;
|
|
760
|
-
// Mode controls
|
|
761
|
-
this.write(ESC.TO(currentRow, 1));
|
|
519
|
+
// Mode controls line (Claude Code style)
|
|
520
|
+
const controlRow = currentRow + visibleLines.length;
|
|
521
|
+
this.write(ESC.TO(controlRow, 1));
|
|
762
522
|
this.write(ESC.CLEAR_LINE);
|
|
763
523
|
this.write(this.buildModeControls(cols));
|
|
764
|
-
// Position cursor
|
|
524
|
+
// Position cursor
|
|
765
525
|
this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
|
|
526
|
+
this.write(ESC.SHOW);
|
|
527
|
+
// Update state
|
|
528
|
+
this.lastRenderContent = this.buffer;
|
|
529
|
+
this.lastRenderCursor = this.cursor;
|
|
530
|
+
}
|
|
531
|
+
finally {
|
|
532
|
+
writeLock.unlock();
|
|
533
|
+
this.isRendering = false;
|
|
766
534
|
}
|
|
767
|
-
this.write(ESC.SHOW);
|
|
768
|
-
// Update state
|
|
769
|
-
this.lastRenderContent = this.buffer;
|
|
770
|
-
this.lastRenderCursor = this.cursor;
|
|
771
535
|
}
|
|
772
536
|
/**
|
|
773
|
-
* Build
|
|
774
|
-
* This is the TOP line above the input area - minimal Claude Code style.
|
|
537
|
+
* Build one or more compact meta lines above the divider (thinking, status, usage).
|
|
775
538
|
*/
|
|
776
|
-
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
let statusText = '● Streaming';
|
|
782
|
-
if (this.streamingStartTime) {
|
|
783
|
-
const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
|
|
784
|
-
const mins = Math.floor(elapsed / 60);
|
|
785
|
-
const secs = elapsed % 60;
|
|
786
|
-
statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
|
|
787
|
-
}
|
|
788
|
-
parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
|
|
539
|
+
buildMetaLines(width) {
|
|
540
|
+
const lines = [];
|
|
541
|
+
if (this.metaThinkingMs !== null) {
|
|
542
|
+
const thinkingText = formatThinking(this.metaThinkingMs, this.metaThinkingHasContent);
|
|
543
|
+
lines.push(renderStatusLine([{ text: thinkingText, tone: 'info' }], width));
|
|
789
544
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
545
|
+
const statusParts = [];
|
|
546
|
+
const statusLabel = this.statusMessage ?? this.streamingLabel;
|
|
547
|
+
if (statusLabel) {
|
|
548
|
+
statusParts.push({ text: statusLabel, tone: 'info' });
|
|
793
549
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
|
|
797
|
-
parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
|
|
550
|
+
if (this.mode === 'streaming' || isStreamingMode()) {
|
|
551
|
+
statusParts.push({ text: 'esc to interrupt', tone: 'warn' });
|
|
798
552
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
|
|
553
|
+
if (this.metaElapsedSeconds !== null) {
|
|
554
|
+
statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
|
|
802
555
|
}
|
|
803
|
-
|
|
804
|
-
if (
|
|
805
|
-
|
|
556
|
+
const tokensRemaining = this.computeTokensRemaining();
|
|
557
|
+
if (tokensRemaining !== null) {
|
|
558
|
+
statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
|
|
806
559
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
560
|
+
if (statusParts.length) {
|
|
561
|
+
lines.push(renderStatusLine(statusParts, width));
|
|
562
|
+
}
|
|
563
|
+
const usageParts = [];
|
|
564
|
+
if (this.metaTokensUsed !== null) {
|
|
565
|
+
const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
|
|
566
|
+
const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
|
|
567
|
+
usageParts.push({ text: `tokens ${formattedUsed}${formattedLimit}`, tone: 'muted' });
|
|
568
|
+
}
|
|
569
|
+
if (this.contextUsage !== null) {
|
|
570
|
+
const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
|
|
571
|
+
const left = Math.max(0, 100 - this.contextUsage);
|
|
572
|
+
usageParts.push({ text: `context used ${this.contextUsage}% (${left}% left)`, tone });
|
|
573
|
+
}
|
|
574
|
+
if (this.queue.length > 0) {
|
|
575
|
+
usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
|
|
810
576
|
}
|
|
811
|
-
if (
|
|
812
|
-
|
|
577
|
+
if (usageParts.length) {
|
|
578
|
+
lines.push(renderStatusLine(usageParts, width));
|
|
813
579
|
}
|
|
814
|
-
|
|
815
|
-
return joined.slice(0, maxWidth);
|
|
580
|
+
return lines;
|
|
816
581
|
}
|
|
817
582
|
/**
|
|
818
|
-
*
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
583
|
+
* Clear the reserved bottom block (meta + divider + input + controls) before repainting.
|
|
584
|
+
*/
|
|
585
|
+
clearReservedArea(startRow, reservedLines, cols) {
|
|
586
|
+
const width = Math.max(1, cols);
|
|
587
|
+
for (let i = 0; i < reservedLines; i++) {
|
|
588
|
+
const row = startRow + i;
|
|
589
|
+
this.write(ESC.TO(row, 1));
|
|
590
|
+
this.write(' '.repeat(width));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Build Claude Code style mode controls line.
|
|
595
|
+
* Combines streaming label + override status + main status for simultaneous display.
|
|
822
596
|
*/
|
|
823
597
|
buildModeControls(cols) {
|
|
824
|
-
const
|
|
825
|
-
const
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
const RED = '\x1b[31m';
|
|
830
|
-
const DIM = '\x1b[2m';
|
|
831
|
-
const R = '\x1b[0m';
|
|
832
|
-
// Mode toggles with colors
|
|
833
|
-
const toggles = [];
|
|
834
|
-
// Edit mode (green=auto, yellow=ask)
|
|
835
|
-
if (this.editMode === 'display-edits') {
|
|
836
|
-
toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
|
|
598
|
+
const width = Math.max(8, cols - 2);
|
|
599
|
+
const leftParts = [];
|
|
600
|
+
const rightParts = [];
|
|
601
|
+
if (this.streamingLabel) {
|
|
602
|
+
leftParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
|
|
837
603
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
if (
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
604
|
+
if (this.overrideStatusMessage) {
|
|
605
|
+
leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
|
|
606
|
+
}
|
|
607
|
+
if (this.statusMessage) {
|
|
608
|
+
leftParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
|
|
609
|
+
}
|
|
610
|
+
const editLabel = this.editMode === 'display-edits' ? 'accept edits on' : 'ask before edits';
|
|
611
|
+
const editIcon = this.editMode === 'display-edits' ? '⏵⏵' : '🛡';
|
|
612
|
+
leftParts.push({
|
|
613
|
+
text: `${editIcon} ${editLabel} (shift+tab to cycle)`,
|
|
614
|
+
tone: this.editMode === 'display-edits' ? 'success' : 'muted',
|
|
615
|
+
});
|
|
616
|
+
const verifyLabel = this.verificationEnabled ? 'verify+build on' : 'verify+build off';
|
|
617
|
+
leftParts.push({
|
|
618
|
+
text: `${verifyLabel} (${this.verificationHotkey.toLowerCase()})`,
|
|
619
|
+
tone: this.verificationEnabled ? 'success' : 'muted',
|
|
620
|
+
});
|
|
621
|
+
const continueLabel = this.autoContinueEnabled ? 'auto-continue on' : 'auto-continue off';
|
|
622
|
+
leftParts.push({
|
|
623
|
+
text: `${continueLabel} (${this.autoContinueHotkey.toLowerCase()})`,
|
|
624
|
+
tone: this.autoContinueEnabled ? 'info' : 'muted',
|
|
625
|
+
});
|
|
626
|
+
if (this.queue.length > 0 && this.mode !== 'streaming') {
|
|
627
|
+
leftParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
|
|
628
|
+
}
|
|
629
|
+
if (this.buffer.includes('\n')) {
|
|
630
|
+
const lineCount = this.buffer.split('\n').length;
|
|
631
|
+
leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
|
|
632
|
+
}
|
|
633
|
+
if (this.pastePlaceholders.length > 0) {
|
|
634
|
+
const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
|
|
635
|
+
leftParts.push({
|
|
636
|
+
text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
|
|
637
|
+
tone: 'info',
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
const contextRemaining = this.computeContextRemaining();
|
|
641
|
+
if (this.thinkingModeLabel) {
|
|
642
|
+
rightParts.push({ text: `thinking ${this.thinkingModeLabel} (/thinking)`, tone: 'info' });
|
|
643
|
+
}
|
|
644
|
+
if (contextRemaining !== null) {
|
|
645
|
+
const tone = contextRemaining <= 10 ? 'warn' : 'muted';
|
|
646
|
+
const label = contextRemaining === 0 && this.contextUsage !== null
|
|
647
|
+
? 'Context auto-compact imminent'
|
|
648
|
+
: `Context left until auto-compact: ${contextRemaining}%`;
|
|
649
|
+
rightParts.push({ text: label, tone });
|
|
650
|
+
}
|
|
651
|
+
if (!rightParts.length || width < 60) {
|
|
652
|
+
const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
|
|
653
|
+
return renderStatusLine(merged, width);
|
|
654
|
+
}
|
|
655
|
+
const leftWidth = Math.max(12, Math.floor(width * 0.6));
|
|
656
|
+
const rightWidth = Math.max(14, width - leftWidth - 1);
|
|
657
|
+
const leftText = renderStatusLine(leftParts, leftWidth);
|
|
658
|
+
const rightText = renderStatusLine(rightParts, rightWidth);
|
|
659
|
+
const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
|
|
660
|
+
return `${leftText}${' '.repeat(spacing)}${rightText}`;
|
|
661
|
+
}
|
|
662
|
+
computeContextRemaining() {
|
|
663
|
+
if (this.contextUsage === null) {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
|
|
667
|
+
}
|
|
668
|
+
computeTokensRemaining() {
|
|
669
|
+
if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
|
|
673
|
+
return this.formatTokenCount(remaining);
|
|
674
|
+
}
|
|
675
|
+
formatElapsedLabel(seconds) {
|
|
676
|
+
if (seconds < 60) {
|
|
677
|
+
return `${seconds}s`;
|
|
678
|
+
}
|
|
679
|
+
const mins = Math.floor(seconds / 60);
|
|
680
|
+
const secs = seconds % 60;
|
|
681
|
+
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
|
682
|
+
}
|
|
683
|
+
formatTokenCount(value) {
|
|
684
|
+
if (!Number.isFinite(value)) {
|
|
685
|
+
return `${value}`;
|
|
686
|
+
}
|
|
687
|
+
if (value >= 1_000_000) {
|
|
688
|
+
return `${(value / 1_000_000).toFixed(1)}M`;
|
|
689
|
+
}
|
|
690
|
+
if (value >= 1_000) {
|
|
691
|
+
return `${(value / 1_000).toFixed(1)}k`;
|
|
692
|
+
}
|
|
693
|
+
return `${Math.round(value)}`;
|
|
694
|
+
}
|
|
695
|
+
visibleLength(value) {
|
|
696
|
+
const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
|
697
|
+
return value.replace(ansiPattern, '').length;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Debug-only snapshot used by tests to assert rendered strings without
|
|
701
|
+
* needing a TTY. Not used by production code.
|
|
702
|
+
*/
|
|
703
|
+
getDebugUiSnapshot(width) {
|
|
704
|
+
const cols = Math.max(8, width ?? this.getSize().cols);
|
|
705
|
+
return {
|
|
706
|
+
meta: this.buildMetaLines(cols - 2),
|
|
707
|
+
controls: this.buildModeControls(cols),
|
|
708
|
+
};
|
|
870
709
|
}
|
|
871
710
|
/**
|
|
872
711
|
* Force a re-render
|
|
@@ -891,15 +730,16 @@ export class TerminalInput extends EventEmitter {
|
|
|
891
730
|
this.lastRenderCursor = -1;
|
|
892
731
|
// Re-clamp pinned header rows to the new terminal height
|
|
893
732
|
this.setPinnedHeaderLines(this.pinnedTopRows);
|
|
733
|
+
if (this.scrollRegionActive) {
|
|
734
|
+
this.disableScrollRegion();
|
|
735
|
+
this.enableScrollRegion();
|
|
736
|
+
}
|
|
894
737
|
this.scheduleRender();
|
|
895
738
|
}
|
|
896
739
|
/**
|
|
897
740
|
* Register with display's output interceptor to position cursor correctly.
|
|
898
741
|
* When scroll region is active, output needs to go to the scroll region,
|
|
899
742
|
* not the protected bottom area where the input is rendered.
|
|
900
|
-
*
|
|
901
|
-
* NOTE: With scroll region properly set, content naturally stays within
|
|
902
|
-
* the region boundaries - no cursor manipulation needed per-write.
|
|
903
743
|
*/
|
|
904
744
|
registerOutputInterceptor(display) {
|
|
905
745
|
if (this.outputInterceptorCleanup) {
|
|
@@ -907,11 +747,20 @@ export class TerminalInput extends EventEmitter {
|
|
|
907
747
|
}
|
|
908
748
|
this.outputInterceptorCleanup = display.registerOutputInterceptor({
|
|
909
749
|
beforeWrite: () => {
|
|
910
|
-
//
|
|
911
|
-
//
|
|
750
|
+
// When the scroll region is active, temporarily move the cursor into
|
|
751
|
+
// the scrollable area so streamed output lands above the pinned prompt.
|
|
752
|
+
if (this.scrollRegionActive) {
|
|
753
|
+
const { rows } = this.getSize();
|
|
754
|
+
const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
|
|
755
|
+
this.write(ESC.SAVE);
|
|
756
|
+
this.write(ESC.TO(scrollBottom, 1));
|
|
757
|
+
}
|
|
912
758
|
},
|
|
913
759
|
afterWrite: () => {
|
|
914
|
-
//
|
|
760
|
+
// Restore cursor back to the pinned prompt after output completes.
|
|
761
|
+
if (this.scrollRegionActive) {
|
|
762
|
+
this.write(ESC.RESTORE);
|
|
763
|
+
}
|
|
915
764
|
},
|
|
916
765
|
});
|
|
917
766
|
}
|
|
@@ -1033,22 +882,7 @@ export class TerminalInput extends EventEmitter {
|
|
|
1033
882
|
this.toggleEditMode();
|
|
1034
883
|
return true;
|
|
1035
884
|
}
|
|
1036
|
-
|
|
1037
|
-
if (this.findPlaceholderAt(this.cursor)) {
|
|
1038
|
-
this.togglePasteExpansion();
|
|
1039
|
-
}
|
|
1040
|
-
else {
|
|
1041
|
-
this.toggleThinking();
|
|
1042
|
-
}
|
|
1043
|
-
return true;
|
|
1044
|
-
case 'escape':
|
|
1045
|
-
// Esc: interrupt if streaming, otherwise clear buffer
|
|
1046
|
-
if (this.mode === 'streaming') {
|
|
1047
|
-
this.emit('interrupt');
|
|
1048
|
-
}
|
|
1049
|
-
else if (this.buffer.length > 0) {
|
|
1050
|
-
this.clear();
|
|
1051
|
-
}
|
|
885
|
+
this.insertText(' ');
|
|
1052
886
|
return true;
|
|
1053
887
|
}
|
|
1054
888
|
return false;
|
|
@@ -1066,7 +900,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
1066
900
|
this.insertPlainText(chunk, insertPos);
|
|
1067
901
|
this.cursor = insertPos + chunk.length;
|
|
1068
902
|
this.emit('change', this.buffer);
|
|
1069
|
-
this.updateSuggestions();
|
|
1070
903
|
this.scheduleRender();
|
|
1071
904
|
}
|
|
1072
905
|
insertNewline() {
|
|
@@ -1091,7 +924,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
1091
924
|
this.cursor = Math.max(0, this.cursor - 1);
|
|
1092
925
|
}
|
|
1093
926
|
this.emit('change', this.buffer);
|
|
1094
|
-
this.updateSuggestions();
|
|
1095
927
|
this.scheduleRender();
|
|
1096
928
|
}
|
|
1097
929
|
deleteForward() {
|
|
@@ -1341,7 +1173,9 @@ export class TerminalInput extends EventEmitter {
|
|
|
1341
1173
|
if (available <= 0)
|
|
1342
1174
|
return;
|
|
1343
1175
|
const chunk = clean.slice(0, available);
|
|
1344
|
-
|
|
1176
|
+
const isMultiline = isMultilinePaste(chunk);
|
|
1177
|
+
const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
|
|
1178
|
+
if (isMultiline && !isShortMultiline) {
|
|
1345
1179
|
this.insertPastePlaceholder(chunk);
|
|
1346
1180
|
}
|
|
1347
1181
|
else {
|
|
@@ -1361,6 +1195,7 @@ export class TerminalInput extends EventEmitter {
|
|
|
1361
1195
|
return;
|
|
1362
1196
|
this.applyScrollRegion();
|
|
1363
1197
|
this.scrollRegionActive = true;
|
|
1198
|
+
this.forceRender();
|
|
1364
1199
|
}
|
|
1365
1200
|
disableScrollRegion() {
|
|
1366
1201
|
if (!this.scrollRegionActive)
|
|
@@ -1511,17 +1346,19 @@ export class TerminalInput extends EventEmitter {
|
|
|
1511
1346
|
this.shiftPlaceholders(position, text.length);
|
|
1512
1347
|
this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
|
|
1513
1348
|
}
|
|
1349
|
+
shouldInlineMultiline(content) {
|
|
1350
|
+
const lines = content.split('\n').length;
|
|
1351
|
+
const maxInlineLines = 4;
|
|
1352
|
+
const maxInlineChars = 240;
|
|
1353
|
+
return lines <= maxInlineLines && content.length <= maxInlineChars;
|
|
1354
|
+
}
|
|
1514
1355
|
findPlaceholderAt(position) {
|
|
1515
1356
|
return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
|
|
1516
1357
|
}
|
|
1517
|
-
buildPlaceholder(
|
|
1358
|
+
buildPlaceholder(lineCount) {
|
|
1518
1359
|
const id = ++this.pasteCounter;
|
|
1519
|
-
const
|
|
1520
|
-
|
|
1521
|
-
const preview = summary.preview.length > 30
|
|
1522
|
-
? `${summary.preview.slice(0, 30)}...`
|
|
1523
|
-
: summary.preview;
|
|
1524
|
-
const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
|
|
1360
|
+
const plural = lineCount === 1 ? '' : 's';
|
|
1361
|
+
const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
|
|
1525
1362
|
return { id, placeholder };
|
|
1526
1363
|
}
|
|
1527
1364
|
insertPastePlaceholder(content) {
|
|
@@ -1529,67 +1366,21 @@ export class TerminalInput extends EventEmitter {
|
|
|
1529
1366
|
if (available <= 0)
|
|
1530
1367
|
return;
|
|
1531
1368
|
const cleanContent = content.slice(0, available);
|
|
1532
|
-
const
|
|
1533
|
-
|
|
1534
|
-
if (summary.lineCount < 5) {
|
|
1535
|
-
const placeholder = this.findPlaceholderAt(this.cursor);
|
|
1536
|
-
const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
|
|
1537
|
-
this.insertPlainText(cleanContent, insertPos);
|
|
1538
|
-
this.cursor = insertPos + cleanContent.length;
|
|
1539
|
-
return;
|
|
1540
|
-
}
|
|
1541
|
-
const { id, placeholder } = this.buildPlaceholder(summary);
|
|
1369
|
+
const lineCount = cleanContent.split('\n').length;
|
|
1370
|
+
const { id, placeholder } = this.buildPlaceholder(lineCount);
|
|
1542
1371
|
const insertPos = this.cursor;
|
|
1543
1372
|
this.shiftPlaceholders(insertPos, placeholder.length);
|
|
1544
1373
|
this.pastePlaceholders.push({
|
|
1545
1374
|
id,
|
|
1546
1375
|
content: cleanContent,
|
|
1547
|
-
lineCount
|
|
1376
|
+
lineCount,
|
|
1548
1377
|
placeholder,
|
|
1549
1378
|
start: insertPos,
|
|
1550
1379
|
end: insertPos + placeholder.length,
|
|
1551
|
-
summary,
|
|
1552
|
-
expanded: false,
|
|
1553
1380
|
});
|
|
1554
1381
|
this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
|
|
1555
1382
|
this.cursor = insertPos + placeholder.length;
|
|
1556
1383
|
}
|
|
1557
|
-
/**
|
|
1558
|
-
* Toggle expansion of a paste placeholder at the current cursor position.
|
|
1559
|
-
* When expanded, shows first 3 and last 2 lines of the content.
|
|
1560
|
-
*/
|
|
1561
|
-
togglePasteExpansion() {
|
|
1562
|
-
const placeholder = this.findPlaceholderAt(this.cursor);
|
|
1563
|
-
if (!placeholder)
|
|
1564
|
-
return false;
|
|
1565
|
-
placeholder.expanded = !placeholder.expanded;
|
|
1566
|
-
// Update the placeholder text in buffer
|
|
1567
|
-
const newPlaceholder = placeholder.expanded
|
|
1568
|
-
? this.buildExpandedPlaceholder(placeholder)
|
|
1569
|
-
: this.buildPlaceholder(placeholder.summary).placeholder;
|
|
1570
|
-
const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
|
|
1571
|
-
// Update buffer
|
|
1572
|
-
this.buffer =
|
|
1573
|
-
this.buffer.slice(0, placeholder.start) +
|
|
1574
|
-
newPlaceholder +
|
|
1575
|
-
this.buffer.slice(placeholder.end);
|
|
1576
|
-
// Update placeholder tracking
|
|
1577
|
-
placeholder.placeholder = newPlaceholder;
|
|
1578
|
-
placeholder.end = placeholder.start + newPlaceholder.length;
|
|
1579
|
-
// Shift other placeholders
|
|
1580
|
-
this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
|
|
1581
|
-
this.scheduleRender();
|
|
1582
|
-
return true;
|
|
1583
|
-
}
|
|
1584
|
-
buildExpandedPlaceholder(ph) {
|
|
1585
|
-
const lines = ph.content.split('\n');
|
|
1586
|
-
const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
|
|
1587
|
-
const lastLines = lines.length > 5
|
|
1588
|
-
? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
|
|
1589
|
-
: '';
|
|
1590
|
-
const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
|
|
1591
|
-
return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
|
|
1592
|
-
}
|
|
1593
1384
|
deletePlaceholder(placeholder) {
|
|
1594
1385
|
const length = placeholder.end - placeholder.start;
|
|
1595
1386
|
this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
|
|
@@ -1597,7 +1388,11 @@ export class TerminalInput extends EventEmitter {
|
|
|
1597
1388
|
this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
|
|
1598
1389
|
this.cursor = placeholder.start;
|
|
1599
1390
|
}
|
|
1600
|
-
updateContextUsage(value) {
|
|
1391
|
+
updateContextUsage(value, autoCompactThreshold) {
|
|
1392
|
+
if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
|
|
1393
|
+
const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
|
|
1394
|
+
this.contextAutoCompactThreshold = boundedThreshold;
|
|
1395
|
+
}
|
|
1601
1396
|
if (value === null || !Number.isFinite(value)) {
|
|
1602
1397
|
this.contextUsage = null;
|
|
1603
1398
|
}
|