erosolar-cli 1.7.258 → 1.7.259
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 +148 -22
- package/dist/core/aiFlowOptimizer.d.ts +26 -0
- package/dist/core/aiFlowOptimizer.d.ts.map +1 -0
- package/dist/core/aiFlowOptimizer.js +31 -0
- package/dist/core/aiFlowOptimizer.js.map +1 -0
- package/dist/core/aiOptimizationEngine.d.ts +158 -0
- package/dist/core/aiOptimizationEngine.d.ts.map +1 -0
- package/dist/core/aiOptimizationEngine.js +428 -0
- package/dist/core/aiOptimizationEngine.js.map +1 -0
- package/dist/core/aiOptimizationIntegration.d.ts +93 -0
- package/dist/core/aiOptimizationIntegration.d.ts.map +1 -0
- package/dist/core/aiOptimizationIntegration.js +250 -0
- package/dist/core/aiOptimizationIntegration.js.map +1 -0
- package/dist/core/customCommands.d.ts +0 -1
- package/dist/core/customCommands.d.ts.map +1 -1
- package/dist/core/customCommands.js +0 -3
- package/dist/core/customCommands.js.map +1 -1
- package/dist/core/enhancedErrorRecovery.d.ts +100 -0
- package/dist/core/enhancedErrorRecovery.d.ts.map +1 -0
- package/dist/core/enhancedErrorRecovery.js +345 -0
- package/dist/core/enhancedErrorRecovery.js.map +1 -0
- package/dist/core/toolPreconditions.d.ts.map +1 -1
- package/dist/core/toolPreconditions.js +14 -0
- package/dist/core/toolPreconditions.js.map +1 -1
- package/dist/core/toolRuntime.d.ts.map +1 -1
- package/dist/core/toolRuntime.js +5 -0
- package/dist/core/toolRuntime.js.map +1 -1
- package/dist/core/toolValidation.d.ts.map +1 -1
- package/dist/core/toolValidation.js +3 -14
- package/dist/core/toolValidation.js.map +1 -1
- package/dist/core/validationRunner.d.ts +3 -1
- package/dist/core/validationRunner.d.ts.map +1 -1
- package/dist/core/validationRunner.js.map +1 -1
- package/dist/mcp/sseClient.d.ts.map +1 -1
- package/dist/mcp/sseClient.js +18 -9
- package/dist/mcp/sseClient.js.map +1 -1
- package/dist/plugins/tools/build/buildPlugin.d.ts +6 -0
- package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
- package/dist/plugins/tools/build/buildPlugin.js +10 -4
- package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
- package/dist/shell/claudeCodeStreamHandler.d.ts +145 -0
- package/dist/shell/claudeCodeStreamHandler.d.ts.map +1 -0
- package/dist/shell/claudeCodeStreamHandler.js +322 -0
- package/dist/shell/claudeCodeStreamHandler.js.map +1 -0
- package/dist/shell/inputQueueManager.d.ts +144 -0
- package/dist/shell/inputQueueManager.d.ts.map +1 -0
- package/dist/shell/inputQueueManager.js +290 -0
- package/dist/shell/inputQueueManager.js.map +1 -0
- package/dist/shell/interactiveShell.d.ts +2 -10
- package/dist/shell/interactiveShell.d.ts.map +1 -1
- package/dist/shell/interactiveShell.js +35 -190
- package/dist/shell/interactiveShell.js.map +1 -1
- package/dist/shell/streamingOutputManager.d.ts +115 -0
- package/dist/shell/streamingOutputManager.d.ts.map +1 -0
- package/dist/shell/streamingOutputManager.js +225 -0
- package/dist/shell/streamingOutputManager.js.map +1 -0
- package/dist/shell/terminalInput.d.ts +136 -66
- package/dist/shell/terminalInput.d.ts.map +1 -1
- package/dist/shell/terminalInput.js +662 -409
- package/dist/shell/terminalInput.js.map +1 -1
- package/dist/shell/terminalInputAdapter.d.ts +15 -20
- package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
- package/dist/shell/terminalInputAdapter.js +22 -14
- package/dist/shell/terminalInputAdapter.js.map +1 -1
- package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
- package/dist/ui/ShellUIAdapter.js +12 -13
- package/dist/ui/ShellUIAdapter.js.map +1 -1
- package/dist/ui/display.d.ts +0 -19
- package/dist/ui/display.d.ts.map +1 -1
- package/dist/ui/display.js +22 -135
- package/dist/ui/display.js.map +1 -1
- package/dist/ui/persistentPrompt.d.ts +50 -0
- package/dist/ui/persistentPrompt.d.ts.map +1 -0
- package/dist/ui/persistentPrompt.js +92 -0
- package/dist/ui/persistentPrompt.js.map +1 -0
- package/dist/ui/terminalUISchema.d.ts +195 -0
- package/dist/ui/terminalUISchema.d.ts.map +1 -0
- package/dist/ui/terminalUISchema.js +113 -0
- package/dist/ui/terminalUISchema.js.map +1 -0
- package/dist/ui/theme.d.ts.map +1 -1
- package/dist/ui/theme.js +8 -6
- package/dist/ui/theme.js.map +1 -1
- package/dist/ui/toolDisplay.d.ts +158 -0
- package/dist/ui/toolDisplay.d.ts.map +1 -1
- package/dist/ui/toolDisplay.js +348 -0
- package/dist/ui/toolDisplay.js.map +1 -1
- package/dist/ui/unified/layout.d.ts +0 -1
- package/dist/ui/unified/layout.d.ts.map +1 -1
- package/dist/ui/unified/layout.js +25 -15
- package/dist/ui/unified/layout.js.map +1 -1
- package/package.json +1 -1
|
@@ -3,18 +3,16 @@
|
|
|
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)
|
|
7
6
|
* - Native bracketed paste support (no heuristics)
|
|
8
7
|
* - Clean cursor model with render-time wrapping
|
|
9
8
|
* - State machine for different input modes
|
|
10
9
|
* - No readline dependency for display
|
|
11
10
|
*/
|
|
12
11
|
import { EventEmitter } from 'node:events';
|
|
13
|
-
import { isMultilinePaste } from '../core/multilinePasteHandler.js';
|
|
12
|
+
import { isMultilinePaste, generatePasteSummary } from '../core/multilinePasteHandler.js';
|
|
14
13
|
import { writeLock } from '../ui/writeLock.js';
|
|
15
|
-
import { renderDivider
|
|
16
|
-
import {
|
|
17
|
-
import { formatThinking } from '../ui/toolDisplay.js';
|
|
14
|
+
import { renderDivider } from '../ui/unified/layout.js';
|
|
15
|
+
import { UI_COLORS, DEFAULT_UI_CONFIG, } from '../ui/terminalUISchema.js';
|
|
18
16
|
// ANSI escape codes
|
|
19
17
|
const ESC = {
|
|
20
18
|
// Cursor control
|
|
@@ -69,11 +67,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
69
67
|
statusMessage = null;
|
|
70
68
|
overrideStatusMessage = null; // Secondary status (warnings, etc.)
|
|
71
69
|
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
|
|
77
70
|
reservedLines = 2;
|
|
78
71
|
scrollRegionActive = false;
|
|
79
72
|
lastRenderContent = '';
|
|
@@ -81,35 +74,45 @@ export class TerminalInput extends EventEmitter {
|
|
|
81
74
|
renderDirty = false;
|
|
82
75
|
isRendering = false;
|
|
83
76
|
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;
|
|
84
89
|
// Lifecycle
|
|
85
90
|
disposed = false;
|
|
86
91
|
enabled = true;
|
|
87
92
|
contextUsage = null;
|
|
88
|
-
contextAutoCompactThreshold = 90;
|
|
89
|
-
thinkingModeLabel = null;
|
|
90
93
|
editMode = 'display-edits';
|
|
91
94
|
verificationEnabled = true;
|
|
92
95
|
autoContinueEnabled = false;
|
|
93
96
|
verificationHotkey = 'alt+v';
|
|
94
97
|
autoContinueHotkey = 'alt+c';
|
|
95
|
-
thinkingHotkey = '/thinking';
|
|
96
|
-
modelLabel = null;
|
|
97
|
-
providerLabel = null;
|
|
98
98
|
// Output interceptor cleanup
|
|
99
99
|
outputInterceptorCleanup;
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
// Metrics tracking for status bar
|
|
101
|
+
streamingStartTime = null;
|
|
102
|
+
tokensUsed = 0;
|
|
103
|
+
thinkingEnabled = true;
|
|
104
|
+
// Streaming input area render timer (updates elapsed time display)
|
|
103
105
|
streamingRenderTimer = null;
|
|
104
106
|
constructor(writeStream = process.stdout, config = {}) {
|
|
105
107
|
super();
|
|
106
108
|
this.out = writeStream;
|
|
109
|
+
// Use schema defaults for configuration consistency
|
|
107
110
|
this.config = {
|
|
108
|
-
maxLines: config.maxLines ??
|
|
109
|
-
maxLength: config.maxLength ??
|
|
111
|
+
maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
|
|
112
|
+
maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
|
|
110
113
|
maxQueueSize: config.maxQueueSize ?? 100,
|
|
111
|
-
promptChar: config.promptChar ??
|
|
112
|
-
continuationChar: config.continuationChar ??
|
|
114
|
+
promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
|
|
115
|
+
continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
|
|
113
116
|
};
|
|
114
117
|
}
|
|
115
118
|
// ===========================================================================
|
|
@@ -188,6 +191,11 @@ export class TerminalInput extends EventEmitter {
|
|
|
188
191
|
if (handled)
|
|
189
192
|
return;
|
|
190
193
|
}
|
|
194
|
+
// Handle '?' for help hint (if buffer is empty)
|
|
195
|
+
if (str === '?' && this.buffer.length === 0) {
|
|
196
|
+
this.emit('showHelp');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
191
199
|
// Insert printable characters
|
|
192
200
|
if (str && !key?.ctrl && !key?.meta) {
|
|
193
201
|
this.insertText(str);
|
|
@@ -196,38 +204,291 @@ export class TerminalInput extends EventEmitter {
|
|
|
196
204
|
/**
|
|
197
205
|
* Set the input mode
|
|
198
206
|
*
|
|
199
|
-
* Streaming
|
|
200
|
-
*
|
|
207
|
+
* Streaming mode disables scroll region and lets content flow naturally.
|
|
208
|
+
* The input area will be re-rendered after streaming ends at wherever
|
|
209
|
+
* the cursor is (below the streamed content).
|
|
201
210
|
*/
|
|
202
211
|
setMode(mode) {
|
|
203
212
|
const prevMode = this.mode;
|
|
204
213
|
this.mode = mode;
|
|
205
214
|
if (mode === 'streaming' && prevMode !== 'streaming') {
|
|
206
|
-
//
|
|
207
|
-
this.
|
|
208
|
-
|
|
215
|
+
// Track streaming start time for elapsed display
|
|
216
|
+
this.streamingStartTime = Date.now();
|
|
217
|
+
// NO scroll regions - content flows naturally to terminal scrollback
|
|
218
|
+
// Input area renders at absolute bottom using cursor save/restore
|
|
219
|
+
this.pinnedTopRows = 0;
|
|
220
|
+
this.reservedLines = 5; // Reserve space for input area at bottom
|
|
221
|
+
// Disable any existing scroll region
|
|
222
|
+
this.disableScrollRegion();
|
|
223
|
+
// Initial render of input area at bottom
|
|
224
|
+
this.renderStreamingInputArea();
|
|
225
|
+
// Start timer to update streaming status and re-render input area
|
|
226
|
+
this.streamingRenderTimer = setInterval(() => {
|
|
227
|
+
if (this.mode === 'streaming') {
|
|
228
|
+
this.updateStreamingStatus();
|
|
229
|
+
this.renderStreamingInputArea();
|
|
230
|
+
}
|
|
231
|
+
}, 1000);
|
|
209
232
|
this.renderDirty = true;
|
|
210
|
-
this.render();
|
|
211
233
|
}
|
|
212
234
|
else if (mode !== 'streaming' && prevMode === 'streaming') {
|
|
213
|
-
//
|
|
214
|
-
this.
|
|
215
|
-
|
|
235
|
+
// Stop streaming render timer
|
|
236
|
+
if (this.streamingRenderTimer) {
|
|
237
|
+
clearInterval(this.streamingRenderTimer);
|
|
238
|
+
this.streamingRenderTimer = null;
|
|
239
|
+
}
|
|
240
|
+
// Reset streaming time
|
|
241
|
+
this.streamingStartTime = null;
|
|
242
|
+
this.pinnedTopRows = 0;
|
|
243
|
+
// Ensure no scroll region is active
|
|
244
|
+
this.disableScrollRegion();
|
|
245
|
+
// Show cursor again
|
|
246
|
+
this.write(ESC.SHOW);
|
|
247
|
+
// Add newline after streaming content, then render input area
|
|
248
|
+
this.write('\n');
|
|
249
|
+
// Reset flow mode tracking
|
|
250
|
+
this.flowModeRenderedLines = 0;
|
|
251
|
+
// Re-render the input area in normal mode
|
|
216
252
|
this.forceRender();
|
|
217
253
|
}
|
|
218
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Update streaming status label (called by timer)
|
|
257
|
+
*/
|
|
258
|
+
updateStreamingStatus() {
|
|
259
|
+
if (this.mode !== 'streaming' || !this.streamingStartTime)
|
|
260
|
+
return;
|
|
261
|
+
// Calculate elapsed time
|
|
262
|
+
const elapsed = Date.now() - this.streamingStartTime;
|
|
263
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
264
|
+
const minutes = Math.floor(seconds / 60);
|
|
265
|
+
const secs = seconds % 60;
|
|
266
|
+
// Format elapsed time
|
|
267
|
+
let elapsedStr;
|
|
268
|
+
if (minutes > 0) {
|
|
269
|
+
elapsedStr = `${minutes}m ${secs}s`;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
elapsedStr = `${secs}s`;
|
|
273
|
+
}
|
|
274
|
+
// Update streaming label
|
|
275
|
+
this.streamingLabel = `Streaming ${elapsedStr}`;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Render input area at absolute bottom during streaming.
|
|
279
|
+
* Uses cursor save/restore so content flow is not disrupted.
|
|
280
|
+
* Content writes to terminal scrollback, input area overlays at bottom.
|
|
281
|
+
*/
|
|
282
|
+
renderStreamingInputArea() {
|
|
283
|
+
if (this.mode !== 'streaming')
|
|
284
|
+
return;
|
|
285
|
+
const { rows, cols } = this.getSize();
|
|
286
|
+
const divider = renderDivider(cols - 2);
|
|
287
|
+
// Calculate elapsed time for status
|
|
288
|
+
let elapsedStr = '0s';
|
|
289
|
+
if (this.streamingStartTime) {
|
|
290
|
+
const elapsed = Date.now() - this.streamingStartTime;
|
|
291
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
292
|
+
const minutes = Math.floor(seconds / 60);
|
|
293
|
+
const secs = seconds % 60;
|
|
294
|
+
elapsedStr = minutes > 0 ? `${minutes}m ${secs}s` : `${secs}s`;
|
|
295
|
+
}
|
|
296
|
+
// Save cursor position (so content flow resumes correctly)
|
|
297
|
+
this.write(ESC.SAVE);
|
|
298
|
+
this.write(ESC.HIDE);
|
|
299
|
+
// Input area: 5 lines from bottom
|
|
300
|
+
// Row layout (from bottom): controls | bottomDiv | input | topDiv | status
|
|
301
|
+
const controlsRow = rows;
|
|
302
|
+
const bottomDivRow = rows - 1;
|
|
303
|
+
const inputRow = rows - 2;
|
|
304
|
+
const topDivRow = rows - 3;
|
|
305
|
+
const statusRow = rows - 4;
|
|
306
|
+
// Status bar with streaming info
|
|
307
|
+
this.write(ESC.TO(statusRow, 1));
|
|
308
|
+
this.write(ESC.CLEAR_LINE);
|
|
309
|
+
const statusText = `${UI_COLORS.dim}● Streaming ${elapsedStr} · type to queue message${UI_COLORS.reset}`;
|
|
310
|
+
this.write(statusText);
|
|
311
|
+
// Top divider
|
|
312
|
+
this.write(ESC.TO(topDivRow, 1));
|
|
313
|
+
this.write(ESC.CLEAR_LINE);
|
|
314
|
+
this.write(divider);
|
|
315
|
+
// Input line with buffer content
|
|
316
|
+
this.write(ESC.TO(inputRow, 1));
|
|
317
|
+
this.write(ESC.CLEAR_LINE);
|
|
318
|
+
const inputDisplay = this.buffer.length > 0 ? this.buffer.slice(0, cols - 4) : '';
|
|
319
|
+
const cursorChar = this.buffer.length > 0 ? '' : ' ';
|
|
320
|
+
this.write(`${this.config.promptChar}${inputDisplay}${ESC.REVERSE}${cursorChar}${ESC.RESET}`);
|
|
321
|
+
// Bottom divider
|
|
322
|
+
this.write(ESC.TO(bottomDivRow, 1));
|
|
323
|
+
this.write(ESC.CLEAR_LINE);
|
|
324
|
+
this.write(divider);
|
|
325
|
+
// Mode controls line
|
|
326
|
+
this.write(ESC.TO(controlsRow, 1));
|
|
327
|
+
this.write(ESC.CLEAR_LINE);
|
|
328
|
+
this.write(this.buildModeControls(cols));
|
|
329
|
+
// Restore cursor position (back to content area)
|
|
330
|
+
this.write(ESC.RESTORE);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Enable or disable flow mode.
|
|
334
|
+
* In flow mode, the input renders immediately after content (wherever cursor is).
|
|
335
|
+
* When disabled, input renders at the absolute bottom of terminal.
|
|
336
|
+
*/
|
|
337
|
+
setFlowMode(enabled) {
|
|
338
|
+
if (this.flowMode === enabled)
|
|
339
|
+
return;
|
|
340
|
+
this.flowMode = enabled;
|
|
341
|
+
this.renderDirty = true;
|
|
342
|
+
this.scheduleRender();
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Check if flow mode is enabled.
|
|
346
|
+
*/
|
|
347
|
+
isFlowMode() {
|
|
348
|
+
return this.flowMode;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Set available slash commands for auto-complete suggestions.
|
|
352
|
+
*/
|
|
353
|
+
setCommands(commands) {
|
|
354
|
+
this.commandSuggestions = commands;
|
|
355
|
+
this.updateSuggestions();
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Update filtered suggestions based on current input.
|
|
359
|
+
*/
|
|
360
|
+
updateSuggestions() {
|
|
361
|
+
const input = this.buffer.trim();
|
|
362
|
+
// Only show suggestions when input starts with "/"
|
|
363
|
+
if (!input.startsWith('/')) {
|
|
364
|
+
this.showSuggestions = false;
|
|
365
|
+
this.filteredSuggestions = [];
|
|
366
|
+
this.selectedSuggestionIndex = 0;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const query = input.toLowerCase();
|
|
370
|
+
this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
|
|
371
|
+
cmd.command.toLowerCase().includes(query.slice(1)));
|
|
372
|
+
// Show suggestions if we have matches
|
|
373
|
+
this.showSuggestions = this.filteredSuggestions.length > 0;
|
|
374
|
+
// Keep selection in bounds
|
|
375
|
+
if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
|
|
376
|
+
this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Select next suggestion (arrow down / tab).
|
|
381
|
+
*/
|
|
382
|
+
selectNextSuggestion() {
|
|
383
|
+
if (!this.showSuggestions || this.filteredSuggestions.length === 0)
|
|
384
|
+
return;
|
|
385
|
+
this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
|
|
386
|
+
this.renderDirty = true;
|
|
387
|
+
this.scheduleRender();
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Select previous suggestion (arrow up / shift+tab).
|
|
391
|
+
*/
|
|
392
|
+
selectPrevSuggestion() {
|
|
393
|
+
if (!this.showSuggestions || this.filteredSuggestions.length === 0)
|
|
394
|
+
return;
|
|
395
|
+
this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
|
|
396
|
+
? this.filteredSuggestions.length - 1
|
|
397
|
+
: this.selectedSuggestionIndex - 1;
|
|
398
|
+
this.renderDirty = true;
|
|
399
|
+
this.scheduleRender();
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Accept current suggestion and insert into buffer.
|
|
403
|
+
*/
|
|
404
|
+
acceptSuggestion() {
|
|
405
|
+
if (!this.showSuggestions || this.filteredSuggestions.length === 0)
|
|
406
|
+
return false;
|
|
407
|
+
const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
|
|
408
|
+
if (!selected)
|
|
409
|
+
return false;
|
|
410
|
+
// Replace buffer with selected command
|
|
411
|
+
this.buffer = selected.command + ' ';
|
|
412
|
+
this.cursor = this.buffer.length;
|
|
413
|
+
this.showSuggestions = false;
|
|
414
|
+
this.renderDirty = true;
|
|
415
|
+
this.scheduleRender();
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Check if suggestions are visible.
|
|
420
|
+
*/
|
|
421
|
+
areSuggestionsVisible() {
|
|
422
|
+
return this.showSuggestions && this.filteredSuggestions.length > 0;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Update token count for metrics display
|
|
426
|
+
*/
|
|
427
|
+
setTokensUsed(tokens) {
|
|
428
|
+
this.tokensUsed = tokens;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Toggle thinking/reasoning mode
|
|
432
|
+
*/
|
|
433
|
+
toggleThinking() {
|
|
434
|
+
this.thinkingEnabled = !this.thinkingEnabled;
|
|
435
|
+
this.emit('thinkingToggle', this.thinkingEnabled);
|
|
436
|
+
this.scheduleRender();
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Get thinking enabled state
|
|
440
|
+
*/
|
|
441
|
+
isThinkingEnabled() {
|
|
442
|
+
return this.thinkingEnabled;
|
|
443
|
+
}
|
|
219
444
|
/**
|
|
220
445
|
* Keep the top N rows pinned outside the scroll region (used for the launch banner).
|
|
221
446
|
*/
|
|
222
447
|
setPinnedHeaderLines(count) {
|
|
223
|
-
//
|
|
224
|
-
if (this.pinnedTopRows !==
|
|
225
|
-
this.pinnedTopRows =
|
|
448
|
+
// Set pinned header rows (banner area that scroll region excludes)
|
|
449
|
+
if (this.pinnedTopRows !== count) {
|
|
450
|
+
this.pinnedTopRows = count;
|
|
226
451
|
if (this.scrollRegionActive) {
|
|
227
452
|
this.applyScrollRegion();
|
|
228
453
|
}
|
|
229
454
|
}
|
|
230
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* Anchor prompt rendering near a specific row (inline layout). Pass null to
|
|
458
|
+
* restore the default bottom-aligned layout.
|
|
459
|
+
*/
|
|
460
|
+
setInlineAnchor(row) {
|
|
461
|
+
if (row === null || row === undefined) {
|
|
462
|
+
this.inlineAnchorRow = null;
|
|
463
|
+
this.inlineLayout = false;
|
|
464
|
+
this.renderDirty = true;
|
|
465
|
+
this.render();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const { rows } = this.getSize();
|
|
469
|
+
const clamped = Math.max(1, Math.min(Math.floor(row), rows));
|
|
470
|
+
this.inlineAnchorRow = clamped;
|
|
471
|
+
this.inlineLayout = true;
|
|
472
|
+
this.renderDirty = true;
|
|
473
|
+
this.render();
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Provide a dynamic anchor callback. When set, the prompt will follow the
|
|
477
|
+
* output by re-evaluating the anchor before each render.
|
|
478
|
+
*/
|
|
479
|
+
setInlineAnchorProvider(provider) {
|
|
480
|
+
this.anchorProvider = provider;
|
|
481
|
+
if (!provider) {
|
|
482
|
+
this.inlineLayout = false;
|
|
483
|
+
this.inlineAnchorRow = null;
|
|
484
|
+
this.renderDirty = true;
|
|
485
|
+
this.render();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
this.inlineLayout = true;
|
|
489
|
+
this.renderDirty = true;
|
|
490
|
+
this.render();
|
|
491
|
+
}
|
|
231
492
|
/**
|
|
232
493
|
* Get current mode
|
|
233
494
|
*/
|
|
@@ -337,37 +598,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
337
598
|
this.streamingLabel = next;
|
|
338
599
|
this.scheduleRender();
|
|
339
600
|
}
|
|
340
|
-
/**
|
|
341
|
-
* Surface meta status just above the divider (e.g., elapsed time or token usage).
|
|
342
|
-
*/
|
|
343
|
-
setMetaStatus(meta) {
|
|
344
|
-
const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
|
|
345
|
-
? Math.floor(meta.elapsedSeconds)
|
|
346
|
-
: null;
|
|
347
|
-
const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
|
|
348
|
-
? Math.floor(meta.tokensUsed)
|
|
349
|
-
: null;
|
|
350
|
-
const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
|
|
351
|
-
? Math.floor(meta.tokenLimit)
|
|
352
|
-
: null;
|
|
353
|
-
const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
|
|
354
|
-
? Math.floor(meta.thinkingMs)
|
|
355
|
-
: null;
|
|
356
|
-
const nextThinkingHasContent = !!meta.thinkingHasContent;
|
|
357
|
-
if (this.metaElapsedSeconds === nextElapsed &&
|
|
358
|
-
this.metaTokensUsed === nextTokens &&
|
|
359
|
-
this.metaTokenLimit === nextLimit &&
|
|
360
|
-
this.metaThinkingMs === nextThinking &&
|
|
361
|
-
this.metaThinkingHasContent === nextThinkingHasContent) {
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
this.metaElapsedSeconds = nextElapsed;
|
|
365
|
-
this.metaTokensUsed = nextTokens;
|
|
366
|
-
this.metaTokenLimit = nextLimit;
|
|
367
|
-
this.metaThinkingMs = nextThinking;
|
|
368
|
-
this.metaThinkingHasContent = nextThinkingHasContent;
|
|
369
|
-
this.scheduleRender();
|
|
370
|
-
}
|
|
371
601
|
/**
|
|
372
602
|
* Keep mode toggles (verification/auto-continue) visible in the control bar.
|
|
373
603
|
* Hotkey labels remain stable so the bar looks the same before/during streaming.
|
|
@@ -377,22 +607,16 @@ export class TerminalInput extends EventEmitter {
|
|
|
377
607
|
const nextAutoContinue = !!options.autoContinueEnabled;
|
|
378
608
|
const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
|
|
379
609
|
const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
|
|
380
|
-
const nextThinkingHotkey = options.thinkingHotkey ?? this.thinkingHotkey;
|
|
381
|
-
const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
|
|
382
610
|
if (this.verificationEnabled === nextVerification &&
|
|
383
611
|
this.autoContinueEnabled === nextAutoContinue &&
|
|
384
612
|
this.verificationHotkey === nextVerifyHotkey &&
|
|
385
|
-
this.autoContinueHotkey === nextAutoHotkey
|
|
386
|
-
this.thinkingHotkey === nextThinkingHotkey &&
|
|
387
|
-
this.thinkingModeLabel === nextThinkingLabel) {
|
|
613
|
+
this.autoContinueHotkey === nextAutoHotkey) {
|
|
388
614
|
return;
|
|
389
615
|
}
|
|
390
616
|
this.verificationEnabled = nextVerification;
|
|
391
617
|
this.autoContinueEnabled = nextAutoContinue;
|
|
392
618
|
this.verificationHotkey = nextVerifyHotkey;
|
|
393
619
|
this.autoContinueHotkey = nextAutoHotkey;
|
|
394
|
-
this.thinkingHotkey = nextThinkingHotkey;
|
|
395
|
-
this.thinkingModeLabel = nextThinkingLabel;
|
|
396
620
|
this.scheduleRender();
|
|
397
621
|
}
|
|
398
622
|
/**
|
|
@@ -404,104 +628,197 @@ export class TerminalInput extends EventEmitter {
|
|
|
404
628
|
this.streamingLabel = null;
|
|
405
629
|
this.scheduleRender();
|
|
406
630
|
}
|
|
407
|
-
/**
|
|
408
|
-
* Surface model/provider context in the controls bar.
|
|
409
|
-
*/
|
|
410
|
-
setModelContext(options) {
|
|
411
|
-
const nextModel = options.model?.trim() || null;
|
|
412
|
-
const nextProvider = options.provider?.trim() || null;
|
|
413
|
-
if (this.modelLabel === nextModel && this.providerLabel === nextProvider) {
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
this.modelLabel = nextModel;
|
|
417
|
-
this.providerLabel = nextProvider;
|
|
418
|
-
this.scheduleRender();
|
|
419
|
-
}
|
|
420
631
|
/**
|
|
421
632
|
* Render the input area - Claude Code style with mode controls
|
|
422
633
|
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
* naturally above while elapsed time and status stay fresh.
|
|
634
|
+
* Same rendering for both normal and streaming modes - just different status bar.
|
|
635
|
+
* During streaming, uses cursor save/restore to preserve streaming position.
|
|
426
636
|
*/
|
|
427
637
|
render() {
|
|
428
638
|
if (!this.canRender())
|
|
429
639
|
return;
|
|
430
640
|
if (this.isRendering)
|
|
431
641
|
return;
|
|
432
|
-
const streamingActive = this.mode === 'streaming' || isStreamingMode();
|
|
433
|
-
// During streaming we still render the pinned input/status region, but throttle
|
|
434
|
-
// to avoid fighting with the streamed content flow.
|
|
435
|
-
if (streamingActive && this.lastStreamingRender > 0) {
|
|
436
|
-
const elapsed = Date.now() - this.lastStreamingRender;
|
|
437
|
-
const waitMs = Math.max(0, this.streamingRenderInterval - elapsed);
|
|
438
|
-
if (waitMs > 0) {
|
|
439
|
-
this.renderDirty = true;
|
|
440
|
-
this.scheduleStreamingRender(waitMs);
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
642
|
const shouldSkip = !this.renderDirty &&
|
|
445
643
|
this.buffer === this.lastRenderContent &&
|
|
446
644
|
this.cursor === this.lastRenderCursor;
|
|
447
645
|
this.renderDirty = false;
|
|
448
|
-
// Skip if nothing changed
|
|
646
|
+
// Skip if nothing changed (unless explicitly forced)
|
|
449
647
|
if (shouldSkip) {
|
|
450
648
|
return;
|
|
451
649
|
}
|
|
452
|
-
// If write lock is held, defer render
|
|
650
|
+
// If write lock is held, defer render
|
|
453
651
|
if (writeLock.isLocked()) {
|
|
454
652
|
writeLock.safeWrite(() => this.render());
|
|
455
653
|
return;
|
|
456
654
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
655
|
+
this.isRendering = true;
|
|
656
|
+
writeLock.lock('terminalInput.render');
|
|
657
|
+
try {
|
|
658
|
+
// Render input area at bottom (outside scroll region)
|
|
659
|
+
this.renderBottomPinned();
|
|
660
|
+
}
|
|
661
|
+
finally {
|
|
662
|
+
writeLock.unlock();
|
|
663
|
+
this.isRendering = false;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Render in flow mode - delegates to bottom-pinned for stability.
|
|
668
|
+
*
|
|
669
|
+
* Flow mode attempted inline rendering but caused duplicate renders
|
|
670
|
+
* due to unreliable cursor position tracking. Bottom-pinned is reliable.
|
|
671
|
+
*/
|
|
672
|
+
renderFlowMode() {
|
|
673
|
+
// Use stable bottom-pinned approach
|
|
674
|
+
this.renderBottomPinned();
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Render in bottom-pinned mode - Claude Code style with suggestions
|
|
678
|
+
*
|
|
679
|
+
* Works for both normal and streaming modes:
|
|
680
|
+
* - During streaming: saves/restores cursor position
|
|
681
|
+
* - Status bar shows streaming info or "Type a message"
|
|
682
|
+
*
|
|
683
|
+
* Layout when suggestions visible:
|
|
684
|
+
* - Top divider
|
|
685
|
+
* - Input line(s)
|
|
686
|
+
* - Bottom divider
|
|
687
|
+
* - Suggestions (command list)
|
|
688
|
+
*
|
|
689
|
+
* Layout when suggestions hidden:
|
|
690
|
+
* - Status bar (Ready/Streaming)
|
|
691
|
+
* - Top divider
|
|
692
|
+
* - Input line(s)
|
|
693
|
+
* - Bottom divider
|
|
694
|
+
* - Mode controls
|
|
695
|
+
*/
|
|
696
|
+
renderBottomPinned() {
|
|
697
|
+
const { rows, cols } = this.getSize();
|
|
698
|
+
const maxWidth = Math.max(8, cols - 4);
|
|
699
|
+
const isStreaming = this.mode === 'streaming';
|
|
700
|
+
// During streaming, skip rendering input area entirely
|
|
701
|
+
// Content flows naturally to terminal - no positioning disruption
|
|
702
|
+
// Terminal scrollback preserves full history
|
|
703
|
+
if (isStreaming) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
// Wrap buffer into display lines
|
|
707
|
+
const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
|
|
708
|
+
const availableForContent = Math.max(1, rows - 3);
|
|
709
|
+
const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
|
|
710
|
+
const displayLines = Math.min(lines.length, maxVisible);
|
|
711
|
+
// Calculate display window (keep cursor visible)
|
|
712
|
+
let startLine = 0;
|
|
713
|
+
if (lines.length > displayLines) {
|
|
714
|
+
startLine = Math.max(0, cursorLine - displayLines + 1);
|
|
715
|
+
startLine = Math.min(startLine, lines.length - displayLines);
|
|
716
|
+
}
|
|
717
|
+
const visibleLines = lines.slice(startLine, startLine + displayLines);
|
|
718
|
+
const adjustedCursorLine = cursorLine - startLine;
|
|
719
|
+
// Calculate suggestion display (not during streaming)
|
|
720
|
+
const suggestionsToShow = (!isStreaming && this.showSuggestions)
|
|
721
|
+
? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
|
|
722
|
+
: [];
|
|
723
|
+
const suggestionLines = suggestionsToShow.length;
|
|
724
|
+
this.write(ESC.HIDE);
|
|
725
|
+
this.write(ESC.RESET);
|
|
726
|
+
const divider = renderDivider(cols - 2);
|
|
727
|
+
// Calculate positions from absolute bottom
|
|
728
|
+
let currentRow;
|
|
729
|
+
if (suggestionLines > 0) {
|
|
730
|
+
// With suggestions: input area + dividers + suggestions
|
|
731
|
+
// Layout: [topDiv] [input] [bottomDiv] [suggestions...]
|
|
732
|
+
const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
|
|
733
|
+
currentRow = Math.max(1, rows - totalHeight + 1);
|
|
734
|
+
this.updateReservedLines(totalHeight);
|
|
735
|
+
// Top divider
|
|
736
|
+
this.write(ESC.TO(currentRow, 1));
|
|
737
|
+
this.write(ESC.CLEAR_LINE);
|
|
738
|
+
this.write(divider);
|
|
739
|
+
currentRow++;
|
|
740
|
+
// Input lines
|
|
741
|
+
let finalRow = currentRow;
|
|
742
|
+
let finalCol = 3;
|
|
743
|
+
for (let i = 0; i < visibleLines.length; i++) {
|
|
744
|
+
this.write(ESC.TO(currentRow, 1));
|
|
745
|
+
this.write(ESC.CLEAR_LINE);
|
|
746
|
+
const line = visibleLines[i] ?? '';
|
|
747
|
+
const absoluteLineIdx = startLine + i;
|
|
748
|
+
const isFirstLine = absoluteLineIdx === 0;
|
|
749
|
+
const isCursorLine = i === adjustedCursorLine;
|
|
750
|
+
this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
|
|
751
|
+
if (isCursorLine) {
|
|
752
|
+
const col = Math.min(cursorCol, line.length);
|
|
753
|
+
this.write(line.slice(0, col));
|
|
754
|
+
this.write(ESC.REVERSE);
|
|
755
|
+
this.write(col < line.length ? line[col] : ' ');
|
|
756
|
+
this.write(ESC.RESET);
|
|
757
|
+
this.write(line.slice(col + 1));
|
|
758
|
+
finalRow = currentRow;
|
|
759
|
+
finalCol = this.config.promptChar.length + col + 1;
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
this.write(line);
|
|
763
|
+
}
|
|
764
|
+
currentRow++;
|
|
476
765
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
this.write(
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
let
|
|
484
|
-
// Clear the reserved block to avoid stale meta/status lines
|
|
485
|
-
this.clearReservedArea(startRow, this.reservedLines, cols);
|
|
486
|
-
// Meta/status header (elapsed, tokens/context)
|
|
487
|
-
for (const metaLine of metaLines) {
|
|
766
|
+
// Bottom divider
|
|
767
|
+
this.write(ESC.TO(currentRow, 1));
|
|
768
|
+
this.write(ESC.CLEAR_LINE);
|
|
769
|
+
this.write(divider);
|
|
770
|
+
currentRow++;
|
|
771
|
+
// Suggestions (Claude Code style)
|
|
772
|
+
for (let i = 0; i < suggestionsToShow.length; i++) {
|
|
488
773
|
this.write(ESC.TO(currentRow, 1));
|
|
489
774
|
this.write(ESC.CLEAR_LINE);
|
|
490
|
-
|
|
491
|
-
|
|
775
|
+
const suggestion = suggestionsToShow[i];
|
|
776
|
+
const isSelected = i === this.selectedSuggestionIndex;
|
|
777
|
+
// Indent and highlight selected
|
|
778
|
+
this.write(' ');
|
|
779
|
+
if (isSelected) {
|
|
780
|
+
this.write(ESC.REVERSE);
|
|
781
|
+
this.write(ESC.BOLD);
|
|
782
|
+
}
|
|
783
|
+
this.write(suggestion.command);
|
|
784
|
+
if (isSelected) {
|
|
785
|
+
this.write(ESC.RESET);
|
|
786
|
+
}
|
|
787
|
+
// Description (dimmed)
|
|
788
|
+
const descSpace = cols - suggestion.command.length - 8;
|
|
789
|
+
if (descSpace > 10 && suggestion.description) {
|
|
790
|
+
const desc = suggestion.description.slice(0, descSpace);
|
|
791
|
+
this.write(ESC.RESET);
|
|
792
|
+
this.write(ESC.DIM);
|
|
793
|
+
this.write(' ');
|
|
794
|
+
this.write(desc);
|
|
795
|
+
this.write(ESC.RESET);
|
|
796
|
+
}
|
|
797
|
+
currentRow++;
|
|
492
798
|
}
|
|
493
|
-
//
|
|
799
|
+
// Position cursor in input area
|
|
800
|
+
this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
// Without suggestions: normal layout with status bar and controls
|
|
804
|
+
const totalHeight = visibleLines.length + 4; // status + topDiv + input + bottomDiv + controls
|
|
805
|
+
currentRow = Math.max(1, rows - totalHeight + 1);
|
|
806
|
+
this.updateReservedLines(totalHeight);
|
|
807
|
+
// Status bar (streaming or normal)
|
|
808
|
+
this.write(ESC.TO(currentRow, 1));
|
|
809
|
+
this.write(ESC.CLEAR_LINE);
|
|
810
|
+
this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
|
|
811
|
+
currentRow++;
|
|
812
|
+
// Top divider
|
|
494
813
|
this.write(ESC.TO(currentRow, 1));
|
|
495
814
|
this.write(ESC.CLEAR_LINE);
|
|
496
|
-
const divider = renderDivider(cols - 2);
|
|
497
815
|
this.write(divider);
|
|
498
|
-
currentRow
|
|
499
|
-
//
|
|
816
|
+
currentRow++;
|
|
817
|
+
// Input lines
|
|
500
818
|
let finalRow = currentRow;
|
|
501
819
|
let finalCol = 3;
|
|
502
820
|
for (let i = 0; i < visibleLines.length; i++) {
|
|
503
|
-
|
|
504
|
-
this.write(ESC.TO(rowNum, 1));
|
|
821
|
+
this.write(ESC.TO(currentRow, 1));
|
|
505
822
|
this.write(ESC.CLEAR_LINE);
|
|
506
823
|
const line = visibleLines[i] ?? '';
|
|
507
824
|
const absoluteLineIdx = startLine + i;
|
|
@@ -515,7 +832,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
515
832
|
this.write(ESC.RESET);
|
|
516
833
|
this.write(ESC.BG_DARK);
|
|
517
834
|
if (isCursorLine) {
|
|
518
|
-
// Render with block cursor
|
|
519
835
|
const col = Math.min(cursorCol, line.length);
|
|
520
836
|
const before = line.slice(0, col);
|
|
521
837
|
const at = col < line.length ? line[col] : ' ';
|
|
@@ -525,251 +841,157 @@ export class TerminalInput extends EventEmitter {
|
|
|
525
841
|
this.write(at);
|
|
526
842
|
this.write(ESC.RESET + ESC.BG_DARK);
|
|
527
843
|
this.write(after);
|
|
528
|
-
finalRow =
|
|
844
|
+
finalRow = currentRow;
|
|
529
845
|
finalCol = this.config.promptChar.length + col + 1;
|
|
530
846
|
}
|
|
531
847
|
else {
|
|
532
848
|
this.write(line);
|
|
533
849
|
}
|
|
534
|
-
// Pad to edge
|
|
850
|
+
// Pad to edge
|
|
535
851
|
const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
|
|
536
852
|
const padding = Math.max(0, cols - lineLen - 1);
|
|
537
853
|
if (padding > 0)
|
|
538
854
|
this.write(' '.repeat(padding));
|
|
539
855
|
this.write(ESC.RESET);
|
|
856
|
+
currentRow++;
|
|
540
857
|
}
|
|
541
|
-
//
|
|
542
|
-
|
|
543
|
-
this.write(ESC.
|
|
858
|
+
// Bottom divider
|
|
859
|
+
this.write(ESC.TO(currentRow, 1));
|
|
860
|
+
this.write(ESC.CLEAR_LINE);
|
|
861
|
+
this.write(divider);
|
|
862
|
+
currentRow++;
|
|
863
|
+
// Mode controls
|
|
864
|
+
this.write(ESC.TO(currentRow, 1));
|
|
544
865
|
this.write(ESC.CLEAR_LINE);
|
|
545
866
|
this.write(this.buildModeControls(cols));
|
|
546
|
-
// Position cursor
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
this.lastStreamingRender = streamingActive ? Date.now() : 0;
|
|
553
|
-
if (this.streamingRenderTimer) {
|
|
554
|
-
clearTimeout(this.streamingRenderTimer);
|
|
555
|
-
this.streamingRenderTimer = null;
|
|
867
|
+
// Position cursor: restore for streaming, or position in input for normal
|
|
868
|
+
if (isStreaming) {
|
|
869
|
+
this.write(ESC.RESTORE);
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
|
|
556
873
|
}
|
|
557
|
-
};
|
|
558
|
-
// Use write lock during render to prevent interleaved output
|
|
559
|
-
writeLock.lock('terminalInput.render');
|
|
560
|
-
this.isRendering = true;
|
|
561
|
-
try {
|
|
562
|
-
performRender();
|
|
563
|
-
}
|
|
564
|
-
finally {
|
|
565
|
-
writeLock.unlock();
|
|
566
|
-
this.isRendering = false;
|
|
567
874
|
}
|
|
875
|
+
this.write(ESC.SHOW);
|
|
876
|
+
// Update state
|
|
877
|
+
this.lastRenderContent = this.buffer;
|
|
878
|
+
this.lastRenderCursor = this.cursor;
|
|
568
879
|
}
|
|
569
880
|
/**
|
|
570
|
-
* Build
|
|
881
|
+
* Build status bar for streaming mode (shows elapsed time, queue count).
|
|
571
882
|
*/
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
const statusParts = [];
|
|
585
|
-
const statusLabel = this.statusMessage ?? this.streamingLabel;
|
|
586
|
-
if (statusLabel) {
|
|
587
|
-
statusParts.push({ text: statusLabel, tone: 'info' });
|
|
588
|
-
}
|
|
589
|
-
if (this.mode === 'streaming' || isStreamingMode()) {
|
|
590
|
-
statusParts.push({ text: 'esc to interrupt', tone: 'warn' });
|
|
591
|
-
}
|
|
592
|
-
if (this.metaElapsedSeconds !== null) {
|
|
593
|
-
statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
|
|
594
|
-
}
|
|
595
|
-
const tokensRemaining = this.computeTokensRemaining();
|
|
596
|
-
if (tokensRemaining !== null) {
|
|
597
|
-
statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
|
|
598
|
-
}
|
|
599
|
-
if (statusParts.length) {
|
|
600
|
-
lines.push(renderStatusLine(statusParts, width));
|
|
601
|
-
}
|
|
602
|
-
const usageParts = [];
|
|
603
|
-
if (this.metaTokensUsed !== null) {
|
|
604
|
-
const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
|
|
605
|
-
const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
|
|
606
|
-
usageParts.push({ text: `tokens ${formattedUsed}${formattedLimit}`, tone: 'muted' });
|
|
607
|
-
}
|
|
608
|
-
if (this.contextUsage !== null) {
|
|
609
|
-
const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
|
|
610
|
-
const left = Math.max(0, 100 - this.contextUsage);
|
|
611
|
-
usageParts.push({ text: `context used ${this.contextUsage}% (${left}% left)`, tone });
|
|
612
|
-
}
|
|
883
|
+
buildStreamingStatusBar(cols) {
|
|
884
|
+
const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
|
|
885
|
+
// Streaming status with elapsed time
|
|
886
|
+
let elapsed = '0s';
|
|
887
|
+
if (this.streamingStartTime) {
|
|
888
|
+
const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
|
|
889
|
+
const mins = Math.floor(secs / 60);
|
|
890
|
+
elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
|
|
891
|
+
}
|
|
892
|
+
let status = `${GREEN}● Streaming${R} ${elapsed}`;
|
|
893
|
+
// Queue indicator
|
|
613
894
|
if (this.queue.length > 0) {
|
|
614
|
-
|
|
615
|
-
}
|
|
616
|
-
if (usageParts.length) {
|
|
617
|
-
lines.push(renderStatusLine(usageParts, width));
|
|
618
|
-
}
|
|
619
|
-
return lines;
|
|
620
|
-
}
|
|
621
|
-
/**
|
|
622
|
-
* Clear the reserved bottom block (meta + divider + input + controls) before repainting.
|
|
623
|
-
*/
|
|
624
|
-
clearReservedArea(startRow, reservedLines, cols) {
|
|
625
|
-
const width = Math.max(1, cols);
|
|
626
|
-
for (let i = 0; i < reservedLines; i++) {
|
|
627
|
-
const row = startRow + i;
|
|
628
|
-
this.write(ESC.TO(row, 1));
|
|
629
|
-
this.write(' '.repeat(width));
|
|
895
|
+
status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
|
|
630
896
|
}
|
|
897
|
+
// Hint for typing
|
|
898
|
+
status += ` ${DIM}· type to queue message${R}`;
|
|
899
|
+
return status;
|
|
631
900
|
}
|
|
632
901
|
/**
|
|
633
|
-
* Build
|
|
634
|
-
*
|
|
902
|
+
* Build status bar showing streaming/ready status and key info.
|
|
903
|
+
* This is the TOP line above the input area - minimal Claude Code style.
|
|
635
904
|
*/
|
|
636
|
-
|
|
637
|
-
const
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
if (this.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
const editHotkey = this.formatHotkey('shift+tab');
|
|
650
|
-
const editLabel = this.editMode === 'display-edits' ? 'edits: accept' : 'edits: ask';
|
|
651
|
-
const editTone = this.editMode === 'display-edits' ? 'success' : 'muted';
|
|
652
|
-
leftParts.push({ text: `${editHotkey} ${editLabel}`, tone: editTone });
|
|
653
|
-
const verifyHotkey = this.formatHotkey(this.verificationHotkey);
|
|
654
|
-
const verifyLabel = this.verificationEnabled ? 'verify:on' : 'verify:off';
|
|
655
|
-
leftParts.push({ text: `${verifyHotkey} ${verifyLabel}`, tone: this.verificationEnabled ? 'success' : 'muted' });
|
|
656
|
-
const continueHotkey = this.formatHotkey(this.autoContinueHotkey);
|
|
657
|
-
const continueLabel = this.autoContinueEnabled ? 'auto:on' : 'auto:off';
|
|
658
|
-
leftParts.push({ text: `${continueHotkey} ${continueLabel}`, tone: this.autoContinueEnabled ? 'info' : 'muted' });
|
|
659
|
-
if (this.queue.length > 0 && this.mode !== 'streaming') {
|
|
660
|
-
leftParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
|
|
905
|
+
buildStatusBar(cols) {
|
|
906
|
+
const maxWidth = cols - 2;
|
|
907
|
+
const parts = [];
|
|
908
|
+
// Streaming status with elapsed time (left side)
|
|
909
|
+
if (this.mode === 'streaming') {
|
|
910
|
+
let statusText = '● Streaming';
|
|
911
|
+
if (this.streamingStartTime) {
|
|
912
|
+
const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
|
|
913
|
+
const mins = Math.floor(elapsed / 60);
|
|
914
|
+
const secs = elapsed % 60;
|
|
915
|
+
statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
|
|
916
|
+
}
|
|
917
|
+
parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
|
|
661
918
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
919
|
+
// Queue indicator during streaming
|
|
920
|
+
if (this.mode === 'streaming' && this.queue.length > 0) {
|
|
921
|
+
parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
|
|
665
922
|
}
|
|
923
|
+
// Paste indicator
|
|
666
924
|
if (this.pastePlaceholders.length > 0) {
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
|
|
670
|
-
tone: 'info',
|
|
671
|
-
});
|
|
925
|
+
const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
|
|
926
|
+
parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
|
|
672
927
|
}
|
|
673
|
-
|
|
674
|
-
if (this.
|
|
675
|
-
|
|
676
|
-
rightParts.push({ text: `${thinkingHotkey} thinking:${this.thinkingModeLabel}`, tone: 'info' });
|
|
677
|
-
}
|
|
678
|
-
if (this.modelLabel) {
|
|
679
|
-
const modelText = this.providerLabel ? `${this.modelLabel} @ ${this.providerLabel}` : this.modelLabel;
|
|
680
|
-
rightParts.push({ text: modelText, tone: 'muted' });
|
|
681
|
-
}
|
|
682
|
-
if (contextRemaining !== null) {
|
|
683
|
-
const tone = contextRemaining <= 10 ? 'warn' : 'muted';
|
|
684
|
-
const label = contextRemaining === 0 && this.contextUsage !== null
|
|
685
|
-
? 'Context auto-compact imminent'
|
|
686
|
-
: `Context left until auto-compact: ${contextRemaining}%`;
|
|
687
|
-
rightParts.push({ text: label, tone });
|
|
688
|
-
}
|
|
689
|
-
if (!rightParts.length || width < 60) {
|
|
690
|
-
const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
|
|
691
|
-
return renderStatusLine(merged, width);
|
|
692
|
-
}
|
|
693
|
-
const leftWidth = Math.max(12, Math.floor(width * 0.6));
|
|
694
|
-
const rightWidth = Math.max(14, width - leftWidth - 1);
|
|
695
|
-
const leftText = renderStatusLine(leftParts, leftWidth);
|
|
696
|
-
const rightText = renderStatusLine(rightParts, rightWidth);
|
|
697
|
-
const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
|
|
698
|
-
return `${leftText}${' '.repeat(spacing)}${rightText}`;
|
|
699
|
-
}
|
|
700
|
-
formatHotkey(hotkey) {
|
|
701
|
-
const normalized = hotkey.trim().toLowerCase();
|
|
702
|
-
if (!normalized)
|
|
703
|
-
return hotkey;
|
|
704
|
-
const parts = normalized.split('+').filter(Boolean);
|
|
705
|
-
const map = {
|
|
706
|
-
shift: '⇧',
|
|
707
|
-
sh: '⇧',
|
|
708
|
-
alt: '⌥',
|
|
709
|
-
option: '⌥',
|
|
710
|
-
opt: '⌥',
|
|
711
|
-
ctrl: '⌃',
|
|
712
|
-
control: '⌃',
|
|
713
|
-
cmd: '⌘',
|
|
714
|
-
meta: '⌘',
|
|
715
|
-
};
|
|
716
|
-
const formatted = parts
|
|
717
|
-
.map((part) => {
|
|
718
|
-
const symbol = map[part];
|
|
719
|
-
if (symbol)
|
|
720
|
-
return symbol;
|
|
721
|
-
return part.length === 1 ? part.toUpperCase() : part.toUpperCase();
|
|
722
|
-
})
|
|
723
|
-
.join('');
|
|
724
|
-
return formatted || hotkey;
|
|
725
|
-
}
|
|
726
|
-
computeContextRemaining() {
|
|
727
|
-
if (this.contextUsage === null) {
|
|
728
|
-
return null;
|
|
729
|
-
}
|
|
730
|
-
return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
|
|
731
|
-
}
|
|
732
|
-
computeTokensRemaining() {
|
|
733
|
-
if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
|
|
734
|
-
return null;
|
|
735
|
-
}
|
|
736
|
-
const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
|
|
737
|
-
return this.formatTokenCount(remaining);
|
|
738
|
-
}
|
|
739
|
-
formatElapsedLabel(seconds) {
|
|
740
|
-
if (seconds < 60) {
|
|
741
|
-
return `${seconds}s`;
|
|
742
|
-
}
|
|
743
|
-
const mins = Math.floor(seconds / 60);
|
|
744
|
-
const secs = seconds % 60;
|
|
745
|
-
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
|
746
|
-
}
|
|
747
|
-
formatTokenCount(value) {
|
|
748
|
-
if (!Number.isFinite(value)) {
|
|
749
|
-
return `${value}`;
|
|
928
|
+
// Override/warning status
|
|
929
|
+
if (this.overrideStatusMessage) {
|
|
930
|
+
parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
|
|
750
931
|
}
|
|
751
|
-
|
|
752
|
-
|
|
932
|
+
// If idle with empty buffer, show quick shortcuts
|
|
933
|
+
if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
|
|
934
|
+
return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
|
|
753
935
|
}
|
|
754
|
-
|
|
755
|
-
|
|
936
|
+
// Multi-line indicator
|
|
937
|
+
if (this.buffer.includes('\n')) {
|
|
938
|
+
parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
|
|
756
939
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
return
|
|
940
|
+
if (parts.length === 0) {
|
|
941
|
+
return ''; // Empty status bar when idle
|
|
942
|
+
}
|
|
943
|
+
const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
|
|
944
|
+
return joined.slice(0, maxWidth);
|
|
762
945
|
}
|
|
763
946
|
/**
|
|
764
|
-
*
|
|
765
|
-
*
|
|
947
|
+
* Build mode controls line showing toggles and context info.
|
|
948
|
+
* This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
|
|
949
|
+
*
|
|
950
|
+
* Layout: [toggles on left] ... [context info on right]
|
|
766
951
|
*/
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
952
|
+
buildModeControls(cols) {
|
|
953
|
+
const maxWidth = cols - 2;
|
|
954
|
+
// Use schema-defined colors for consistency
|
|
955
|
+
const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
|
|
956
|
+
// Mode toggles with colors (following ModeControlsSchema)
|
|
957
|
+
const toggles = [];
|
|
958
|
+
// Edit mode (green=auto, yellow=ask) - per schema.editMode
|
|
959
|
+
if (this.editMode === 'display-edits') {
|
|
960
|
+
toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
|
|
964
|
+
}
|
|
965
|
+
// Thinking mode (cyan when on) - per schema.thinkingMode
|
|
966
|
+
toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
|
|
967
|
+
// Verification (green when on) - per schema.verificationMode
|
|
968
|
+
toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
|
|
969
|
+
// Auto-continue (magenta when on) - per schema.autoContinueMode
|
|
970
|
+
toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
|
|
971
|
+
const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
|
|
972
|
+
// Context usage with color - per schema.contextUsage thresholds
|
|
973
|
+
let rightPart = '';
|
|
974
|
+
if (this.contextUsage !== null) {
|
|
975
|
+
const rem = Math.max(0, 100 - this.contextUsage);
|
|
976
|
+
// Thresholds: critical < 10%, warning < 25%
|
|
977
|
+
if (rem < 10)
|
|
978
|
+
rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
|
|
979
|
+
else if (rem < 25)
|
|
980
|
+
rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
|
|
981
|
+
else
|
|
982
|
+
rightPart = `${DIM}ctx: ${rem}%${R}`;
|
|
983
|
+
}
|
|
984
|
+
// Calculate visible lengths (strip ANSI)
|
|
985
|
+
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
986
|
+
const leftLen = strip(leftPart).length;
|
|
987
|
+
const rightLen = strip(rightPart).length;
|
|
988
|
+
if (leftLen + rightLen < maxWidth - 4) {
|
|
989
|
+
return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
|
|
990
|
+
}
|
|
991
|
+
if (rightLen > 0 && leftLen + 8 < maxWidth) {
|
|
992
|
+
return `${leftPart} ${rightPart}`;
|
|
993
|
+
}
|
|
994
|
+
return leftPart;
|
|
773
995
|
}
|
|
774
996
|
/**
|
|
775
997
|
* Force a re-render
|
|
@@ -792,19 +1014,17 @@ export class TerminalInput extends EventEmitter {
|
|
|
792
1014
|
handleResize() {
|
|
793
1015
|
this.lastRenderContent = '';
|
|
794
1016
|
this.lastRenderCursor = -1;
|
|
795
|
-
this.resetStreamingRenderThrottle();
|
|
796
1017
|
// Re-clamp pinned header rows to the new terminal height
|
|
797
1018
|
this.setPinnedHeaderLines(this.pinnedTopRows);
|
|
798
|
-
if (this.scrollRegionActive) {
|
|
799
|
-
this.disableScrollRegion();
|
|
800
|
-
this.enableScrollRegion();
|
|
801
|
-
}
|
|
802
1019
|
this.scheduleRender();
|
|
803
1020
|
}
|
|
804
1021
|
/**
|
|
805
1022
|
* Register with display's output interceptor to position cursor correctly.
|
|
806
1023
|
* When scroll region is active, output needs to go to the scroll region,
|
|
807
1024
|
* not the protected bottom area where the input is rendered.
|
|
1025
|
+
*
|
|
1026
|
+
* NOTE: With scroll region properly set, content naturally stays within
|
|
1027
|
+
* the region boundaries - no cursor manipulation needed per-write.
|
|
808
1028
|
*/
|
|
809
1029
|
registerOutputInterceptor(display) {
|
|
810
1030
|
if (this.outputInterceptorCleanup) {
|
|
@@ -812,20 +1032,11 @@ export class TerminalInput extends EventEmitter {
|
|
|
812
1032
|
}
|
|
813
1033
|
this.outputInterceptorCleanup = display.registerOutputInterceptor({
|
|
814
1034
|
beforeWrite: () => {
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
if (this.scrollRegionActive) {
|
|
818
|
-
const { rows } = this.getSize();
|
|
819
|
-
const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
|
|
820
|
-
this.write(ESC.SAVE);
|
|
821
|
-
this.write(ESC.TO(scrollBottom, 1));
|
|
822
|
-
}
|
|
1035
|
+
// Scroll region handles content containment automatically
|
|
1036
|
+
// No per-write cursor manipulation needed
|
|
823
1037
|
},
|
|
824
1038
|
afterWrite: () => {
|
|
825
|
-
//
|
|
826
|
-
if (this.scrollRegionActive) {
|
|
827
|
-
this.write(ESC.RESTORE);
|
|
828
|
-
}
|
|
1039
|
+
// No cursor manipulation needed
|
|
829
1040
|
},
|
|
830
1041
|
});
|
|
831
1042
|
}
|
|
@@ -835,6 +1046,11 @@ export class TerminalInput extends EventEmitter {
|
|
|
835
1046
|
dispose() {
|
|
836
1047
|
if (this.disposed)
|
|
837
1048
|
return;
|
|
1049
|
+
// Clean up streaming render timer
|
|
1050
|
+
if (this.streamingRenderTimer) {
|
|
1051
|
+
clearInterval(this.streamingRenderTimer);
|
|
1052
|
+
this.streamingRenderTimer = null;
|
|
1053
|
+
}
|
|
838
1054
|
// Clean up output interceptor
|
|
839
1055
|
if (this.outputInterceptorCleanup) {
|
|
840
1056
|
this.outputInterceptorCleanup();
|
|
@@ -842,7 +1058,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
842
1058
|
}
|
|
843
1059
|
this.disposed = true;
|
|
844
1060
|
this.enabled = false;
|
|
845
|
-
this.resetStreamingRenderThrottle();
|
|
846
1061
|
this.disableScrollRegion();
|
|
847
1062
|
this.disableBracketedPaste();
|
|
848
1063
|
this.buffer = '';
|
|
@@ -948,7 +1163,22 @@ export class TerminalInput extends EventEmitter {
|
|
|
948
1163
|
this.toggleEditMode();
|
|
949
1164
|
return true;
|
|
950
1165
|
}
|
|
951
|
-
|
|
1166
|
+
// Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
|
|
1167
|
+
if (this.findPlaceholderAt(this.cursor)) {
|
|
1168
|
+
this.togglePasteExpansion();
|
|
1169
|
+
}
|
|
1170
|
+
else {
|
|
1171
|
+
this.toggleThinking();
|
|
1172
|
+
}
|
|
1173
|
+
return true;
|
|
1174
|
+
case 'escape':
|
|
1175
|
+
// Esc: interrupt if streaming, otherwise clear buffer
|
|
1176
|
+
if (this.mode === 'streaming') {
|
|
1177
|
+
this.emit('interrupt');
|
|
1178
|
+
}
|
|
1179
|
+
else if (this.buffer.length > 0) {
|
|
1180
|
+
this.clear();
|
|
1181
|
+
}
|
|
952
1182
|
return true;
|
|
953
1183
|
}
|
|
954
1184
|
return false;
|
|
@@ -966,6 +1196,7 @@ export class TerminalInput extends EventEmitter {
|
|
|
966
1196
|
this.insertPlainText(chunk, insertPos);
|
|
967
1197
|
this.cursor = insertPos + chunk.length;
|
|
968
1198
|
this.emit('change', this.buffer);
|
|
1199
|
+
this.updateSuggestions();
|
|
969
1200
|
this.scheduleRender();
|
|
970
1201
|
}
|
|
971
1202
|
insertNewline() {
|
|
@@ -990,6 +1221,7 @@ export class TerminalInput extends EventEmitter {
|
|
|
990
1221
|
this.cursor = Math.max(0, this.cursor - 1);
|
|
991
1222
|
}
|
|
992
1223
|
this.emit('change', this.buffer);
|
|
1224
|
+
this.updateSuggestions();
|
|
993
1225
|
this.scheduleRender();
|
|
994
1226
|
}
|
|
995
1227
|
deleteForward() {
|
|
@@ -1239,9 +1471,7 @@ export class TerminalInput extends EventEmitter {
|
|
|
1239
1471
|
if (available <= 0)
|
|
1240
1472
|
return;
|
|
1241
1473
|
const chunk = clean.slice(0, available);
|
|
1242
|
-
|
|
1243
|
-
const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
|
|
1244
|
-
if (isMultiline && !isShortMultiline) {
|
|
1474
|
+
if (isMultilinePaste(chunk)) {
|
|
1245
1475
|
this.insertPastePlaceholder(chunk);
|
|
1246
1476
|
}
|
|
1247
1477
|
else {
|
|
@@ -1261,7 +1491,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
1261
1491
|
return;
|
|
1262
1492
|
this.applyScrollRegion();
|
|
1263
1493
|
this.scrollRegionActive = true;
|
|
1264
|
-
this.forceRender();
|
|
1265
1494
|
}
|
|
1266
1495
|
disableScrollRegion() {
|
|
1267
1496
|
if (!this.scrollRegionActive)
|
|
@@ -1412,19 +1641,17 @@ export class TerminalInput extends EventEmitter {
|
|
|
1412
1641
|
this.shiftPlaceholders(position, text.length);
|
|
1413
1642
|
this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
|
|
1414
1643
|
}
|
|
1415
|
-
shouldInlineMultiline(content) {
|
|
1416
|
-
const lines = content.split('\n').length;
|
|
1417
|
-
const maxInlineLines = 4;
|
|
1418
|
-
const maxInlineChars = 240;
|
|
1419
|
-
return lines <= maxInlineLines && content.length <= maxInlineChars;
|
|
1420
|
-
}
|
|
1421
1644
|
findPlaceholderAt(position) {
|
|
1422
1645
|
return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
|
|
1423
1646
|
}
|
|
1424
|
-
buildPlaceholder(
|
|
1647
|
+
buildPlaceholder(summary) {
|
|
1425
1648
|
const id = ++this.pasteCounter;
|
|
1426
|
-
const
|
|
1427
|
-
|
|
1649
|
+
const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
|
|
1650
|
+
// Show first line preview (truncated)
|
|
1651
|
+
const preview = summary.preview.length > 30
|
|
1652
|
+
? `${summary.preview.slice(0, 30)}...`
|
|
1653
|
+
: summary.preview;
|
|
1654
|
+
const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
|
|
1428
1655
|
return { id, placeholder };
|
|
1429
1656
|
}
|
|
1430
1657
|
insertPastePlaceholder(content) {
|
|
@@ -1432,21 +1659,67 @@ export class TerminalInput extends EventEmitter {
|
|
|
1432
1659
|
if (available <= 0)
|
|
1433
1660
|
return;
|
|
1434
1661
|
const cleanContent = content.slice(0, available);
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1662
|
+
const summary = generatePasteSummary(cleanContent);
|
|
1663
|
+
// For short pastes (< 5 lines), show full content instead of placeholder
|
|
1664
|
+
if (summary.lineCount < 5) {
|
|
1665
|
+
const placeholder = this.findPlaceholderAt(this.cursor);
|
|
1666
|
+
const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
|
|
1667
|
+
this.insertPlainText(cleanContent, insertPos);
|
|
1668
|
+
this.cursor = insertPos + cleanContent.length;
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
const { id, placeholder } = this.buildPlaceholder(summary);
|
|
1437
1672
|
const insertPos = this.cursor;
|
|
1438
1673
|
this.shiftPlaceholders(insertPos, placeholder.length);
|
|
1439
1674
|
this.pastePlaceholders.push({
|
|
1440
1675
|
id,
|
|
1441
1676
|
content: cleanContent,
|
|
1442
|
-
lineCount,
|
|
1677
|
+
lineCount: summary.lineCount,
|
|
1443
1678
|
placeholder,
|
|
1444
1679
|
start: insertPos,
|
|
1445
1680
|
end: insertPos + placeholder.length,
|
|
1681
|
+
summary,
|
|
1682
|
+
expanded: false,
|
|
1446
1683
|
});
|
|
1447
1684
|
this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
|
|
1448
1685
|
this.cursor = insertPos + placeholder.length;
|
|
1449
1686
|
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Toggle expansion of a paste placeholder at the current cursor position.
|
|
1689
|
+
* When expanded, shows first 3 and last 2 lines of the content.
|
|
1690
|
+
*/
|
|
1691
|
+
togglePasteExpansion() {
|
|
1692
|
+
const placeholder = this.findPlaceholderAt(this.cursor);
|
|
1693
|
+
if (!placeholder)
|
|
1694
|
+
return false;
|
|
1695
|
+
placeholder.expanded = !placeholder.expanded;
|
|
1696
|
+
// Update the placeholder text in buffer
|
|
1697
|
+
const newPlaceholder = placeholder.expanded
|
|
1698
|
+
? this.buildExpandedPlaceholder(placeholder)
|
|
1699
|
+
: this.buildPlaceholder(placeholder.summary).placeholder;
|
|
1700
|
+
const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
|
|
1701
|
+
// Update buffer
|
|
1702
|
+
this.buffer =
|
|
1703
|
+
this.buffer.slice(0, placeholder.start) +
|
|
1704
|
+
newPlaceholder +
|
|
1705
|
+
this.buffer.slice(placeholder.end);
|
|
1706
|
+
// Update placeholder tracking
|
|
1707
|
+
placeholder.placeholder = newPlaceholder;
|
|
1708
|
+
placeholder.end = placeholder.start + newPlaceholder.length;
|
|
1709
|
+
// Shift other placeholders
|
|
1710
|
+
this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
|
|
1711
|
+
this.scheduleRender();
|
|
1712
|
+
return true;
|
|
1713
|
+
}
|
|
1714
|
+
buildExpandedPlaceholder(ph) {
|
|
1715
|
+
const lines = ph.content.split('\n');
|
|
1716
|
+
const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
|
|
1717
|
+
const lastLines = lines.length > 5
|
|
1718
|
+
? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
|
|
1719
|
+
: '';
|
|
1720
|
+
const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
|
|
1721
|
+
return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
|
|
1722
|
+
}
|
|
1450
1723
|
deletePlaceholder(placeholder) {
|
|
1451
1724
|
const length = placeholder.end - placeholder.start;
|
|
1452
1725
|
this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
|
|
@@ -1454,11 +1727,7 @@ export class TerminalInput extends EventEmitter {
|
|
|
1454
1727
|
this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
|
|
1455
1728
|
this.cursor = placeholder.start;
|
|
1456
1729
|
}
|
|
1457
|
-
updateContextUsage(value
|
|
1458
|
-
if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
|
|
1459
|
-
const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
|
|
1460
|
-
this.contextAutoCompactThreshold = boundedThreshold;
|
|
1461
|
-
}
|
|
1730
|
+
updateContextUsage(value) {
|
|
1462
1731
|
if (value === null || !Number.isFinite(value)) {
|
|
1463
1732
|
this.contextUsage = null;
|
|
1464
1733
|
}
|
|
@@ -1485,22 +1754,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
1485
1754
|
const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
|
|
1486
1755
|
this.setEditMode(next);
|
|
1487
1756
|
}
|
|
1488
|
-
scheduleStreamingRender(delayMs) {
|
|
1489
|
-
if (this.streamingRenderTimer)
|
|
1490
|
-
return;
|
|
1491
|
-
const wait = Math.max(16, delayMs);
|
|
1492
|
-
this.streamingRenderTimer = setTimeout(() => {
|
|
1493
|
-
this.streamingRenderTimer = null;
|
|
1494
|
-
this.render();
|
|
1495
|
-
}, wait);
|
|
1496
|
-
}
|
|
1497
|
-
resetStreamingRenderThrottle() {
|
|
1498
|
-
if (this.streamingRenderTimer) {
|
|
1499
|
-
clearTimeout(this.streamingRenderTimer);
|
|
1500
|
-
this.streamingRenderTimer = null;
|
|
1501
|
-
}
|
|
1502
|
-
this.lastStreamingRender = 0;
|
|
1503
|
-
}
|
|
1504
1757
|
scheduleRender() {
|
|
1505
1758
|
if (!this.canRender())
|
|
1506
1759
|
return;
|