erosolar-cli 1.7.258 → 1.7.260
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 +140 -66
- package/dist/shell/terminalInput.d.ts.map +1 -1
- package/dist/shell/terminalInput.js +685 -410
- 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,312 @@ 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
|
-
|
|
216
|
-
|
|
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
|
+
// Reset flow mode tracking
|
|
246
|
+
this.flowModeRenderedLines = 0;
|
|
247
|
+
// Render input area using unified method (same as streaming, but normal mode)
|
|
248
|
+
this.renderPinnedInputArea();
|
|
217
249
|
}
|
|
218
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Update streaming status label (called by timer)
|
|
253
|
+
*/
|
|
254
|
+
updateStreamingStatus() {
|
|
255
|
+
if (this.mode !== 'streaming' || !this.streamingStartTime)
|
|
256
|
+
return;
|
|
257
|
+
// Calculate elapsed time
|
|
258
|
+
const elapsed = Date.now() - this.streamingStartTime;
|
|
259
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
260
|
+
const minutes = Math.floor(seconds / 60);
|
|
261
|
+
const secs = seconds % 60;
|
|
262
|
+
// Format elapsed time
|
|
263
|
+
let elapsedStr;
|
|
264
|
+
if (minutes > 0) {
|
|
265
|
+
elapsedStr = `${minutes}m ${secs}s`;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
elapsedStr = `${secs}s`;
|
|
269
|
+
}
|
|
270
|
+
// Update streaming label
|
|
271
|
+
this.streamingLabel = `Streaming ${elapsedStr}`;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Render input area at absolute bottom - unified for streaming and normal modes.
|
|
275
|
+
* Uses cursor save/restore during streaming so content flow is not disrupted.
|
|
276
|
+
* In normal mode, cursor is positioned in the input area.
|
|
277
|
+
*/
|
|
278
|
+
renderPinnedInputArea() {
|
|
279
|
+
const { rows, cols } = this.getSize();
|
|
280
|
+
const divider = renderDivider(cols - 2);
|
|
281
|
+
const isStreaming = this.mode === 'streaming';
|
|
282
|
+
// Build status text based on mode
|
|
283
|
+
let statusText;
|
|
284
|
+
if (isStreaming && this.streamingStartTime) {
|
|
285
|
+
const elapsed = Date.now() - this.streamingStartTime;
|
|
286
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
287
|
+
const minutes = Math.floor(seconds / 60);
|
|
288
|
+
const secs = seconds % 60;
|
|
289
|
+
const elapsedStr = minutes > 0 ? `${minutes}m ${secs}s` : `${secs}s`;
|
|
290
|
+
statusText = `${UI_COLORS.dim}● Streaming ${elapsedStr} · type to queue message${UI_COLORS.reset}`;
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
statusText = `${UI_COLORS.dim}Type a message...${UI_COLORS.reset}`;
|
|
294
|
+
}
|
|
295
|
+
// Save cursor position during streaming (so content flow resumes correctly)
|
|
296
|
+
if (isStreaming) {
|
|
297
|
+
this.write(ESC.SAVE);
|
|
298
|
+
}
|
|
299
|
+
this.write(ESC.HIDE);
|
|
300
|
+
// Input area: 5 lines from bottom
|
|
301
|
+
// Row layout (from bottom): controls | bottomDiv | input | topDiv | status
|
|
302
|
+
const controlsRow = rows;
|
|
303
|
+
const bottomDivRow = rows - 1;
|
|
304
|
+
const inputRow = rows - 2;
|
|
305
|
+
const topDivRow = rows - 3;
|
|
306
|
+
const statusRow = rows - 4;
|
|
307
|
+
// Status bar
|
|
308
|
+
this.write(ESC.TO(statusRow, 1));
|
|
309
|
+
this.write(ESC.CLEAR_LINE);
|
|
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 and cursor
|
|
316
|
+
this.write(ESC.TO(inputRow, 1));
|
|
317
|
+
this.write(ESC.CLEAR_LINE);
|
|
318
|
+
const maxInputWidth = cols - 4;
|
|
319
|
+
const inputDisplay = this.buffer.slice(0, maxInputWidth);
|
|
320
|
+
const cursorPos = Math.min(this.cursor, maxInputWidth);
|
|
321
|
+
// Render with cursor highlight
|
|
322
|
+
this.write(this.config.promptChar);
|
|
323
|
+
this.write(inputDisplay.slice(0, cursorPos));
|
|
324
|
+
this.write(ESC.REVERSE);
|
|
325
|
+
this.write(cursorPos < inputDisplay.length ? inputDisplay[cursorPos] : ' ');
|
|
326
|
+
this.write(ESC.RESET);
|
|
327
|
+
this.write(inputDisplay.slice(cursorPos + 1));
|
|
328
|
+
// Bottom divider
|
|
329
|
+
this.write(ESC.TO(bottomDivRow, 1));
|
|
330
|
+
this.write(ESC.CLEAR_LINE);
|
|
331
|
+
this.write(divider);
|
|
332
|
+
// Mode controls line
|
|
333
|
+
this.write(ESC.TO(controlsRow, 1));
|
|
334
|
+
this.write(ESC.CLEAR_LINE);
|
|
335
|
+
this.write(this.buildModeControls(cols));
|
|
336
|
+
// Restore cursor position during streaming, or show cursor in normal mode
|
|
337
|
+
if (isStreaming) {
|
|
338
|
+
this.write(ESC.RESTORE);
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
// Position cursor in input area
|
|
342
|
+
const cursorCol = this.config.promptChar.length + cursorPos + 1;
|
|
343
|
+
this.write(ESC.TO(inputRow, cursorCol));
|
|
344
|
+
this.write(ESC.SHOW);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Render input area during streaming (alias for unified method)
|
|
349
|
+
*/
|
|
350
|
+
renderStreamingInputArea() {
|
|
351
|
+
this.renderPinnedInputArea();
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Enable or disable flow mode.
|
|
355
|
+
* In flow mode, the input renders immediately after content (wherever cursor is).
|
|
356
|
+
* When disabled, input renders at the absolute bottom of terminal.
|
|
357
|
+
*/
|
|
358
|
+
setFlowMode(enabled) {
|
|
359
|
+
if (this.flowMode === enabled)
|
|
360
|
+
return;
|
|
361
|
+
this.flowMode = enabled;
|
|
362
|
+
this.renderDirty = true;
|
|
363
|
+
this.scheduleRender();
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Check if flow mode is enabled.
|
|
367
|
+
*/
|
|
368
|
+
isFlowMode() {
|
|
369
|
+
return this.flowMode;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Set available slash commands for auto-complete suggestions.
|
|
373
|
+
*/
|
|
374
|
+
setCommands(commands) {
|
|
375
|
+
this.commandSuggestions = commands;
|
|
376
|
+
this.updateSuggestions();
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Update filtered suggestions based on current input.
|
|
380
|
+
*/
|
|
381
|
+
updateSuggestions() {
|
|
382
|
+
const input = this.buffer.trim();
|
|
383
|
+
// Only show suggestions when input starts with "/"
|
|
384
|
+
if (!input.startsWith('/')) {
|
|
385
|
+
this.showSuggestions = false;
|
|
386
|
+
this.filteredSuggestions = [];
|
|
387
|
+
this.selectedSuggestionIndex = 0;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const query = input.toLowerCase();
|
|
391
|
+
this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
|
|
392
|
+
cmd.command.toLowerCase().includes(query.slice(1)));
|
|
393
|
+
// Show suggestions if we have matches
|
|
394
|
+
this.showSuggestions = this.filteredSuggestions.length > 0;
|
|
395
|
+
// Keep selection in bounds
|
|
396
|
+
if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
|
|
397
|
+
this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Select next suggestion (arrow down / tab).
|
|
402
|
+
*/
|
|
403
|
+
selectNextSuggestion() {
|
|
404
|
+
if (!this.showSuggestions || this.filteredSuggestions.length === 0)
|
|
405
|
+
return;
|
|
406
|
+
this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
|
|
407
|
+
this.renderDirty = true;
|
|
408
|
+
this.scheduleRender();
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Select previous suggestion (arrow up / shift+tab).
|
|
412
|
+
*/
|
|
413
|
+
selectPrevSuggestion() {
|
|
414
|
+
if (!this.showSuggestions || this.filteredSuggestions.length === 0)
|
|
415
|
+
return;
|
|
416
|
+
this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
|
|
417
|
+
? this.filteredSuggestions.length - 1
|
|
418
|
+
: this.selectedSuggestionIndex - 1;
|
|
419
|
+
this.renderDirty = true;
|
|
420
|
+
this.scheduleRender();
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Accept current suggestion and insert into buffer.
|
|
424
|
+
*/
|
|
425
|
+
acceptSuggestion() {
|
|
426
|
+
if (!this.showSuggestions || this.filteredSuggestions.length === 0)
|
|
427
|
+
return false;
|
|
428
|
+
const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
|
|
429
|
+
if (!selected)
|
|
430
|
+
return false;
|
|
431
|
+
// Replace buffer with selected command
|
|
432
|
+
this.buffer = selected.command + ' ';
|
|
433
|
+
this.cursor = this.buffer.length;
|
|
434
|
+
this.showSuggestions = false;
|
|
435
|
+
this.renderDirty = true;
|
|
436
|
+
this.scheduleRender();
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Check if suggestions are visible.
|
|
441
|
+
*/
|
|
442
|
+
areSuggestionsVisible() {
|
|
443
|
+
return this.showSuggestions && this.filteredSuggestions.length > 0;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Update token count for metrics display
|
|
447
|
+
*/
|
|
448
|
+
setTokensUsed(tokens) {
|
|
449
|
+
this.tokensUsed = tokens;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Toggle thinking/reasoning mode
|
|
453
|
+
*/
|
|
454
|
+
toggleThinking() {
|
|
455
|
+
this.thinkingEnabled = !this.thinkingEnabled;
|
|
456
|
+
this.emit('thinkingToggle', this.thinkingEnabled);
|
|
457
|
+
this.scheduleRender();
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Get thinking enabled state
|
|
461
|
+
*/
|
|
462
|
+
isThinkingEnabled() {
|
|
463
|
+
return this.thinkingEnabled;
|
|
464
|
+
}
|
|
219
465
|
/**
|
|
220
466
|
* Keep the top N rows pinned outside the scroll region (used for the launch banner).
|
|
221
467
|
*/
|
|
222
468
|
setPinnedHeaderLines(count) {
|
|
223
|
-
//
|
|
224
|
-
if (this.pinnedTopRows !==
|
|
225
|
-
this.pinnedTopRows =
|
|
469
|
+
// Set pinned header rows (banner area that scroll region excludes)
|
|
470
|
+
if (this.pinnedTopRows !== count) {
|
|
471
|
+
this.pinnedTopRows = count;
|
|
226
472
|
if (this.scrollRegionActive) {
|
|
227
473
|
this.applyScrollRegion();
|
|
228
474
|
}
|
|
229
475
|
}
|
|
230
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Anchor prompt rendering near a specific row (inline layout). Pass null to
|
|
479
|
+
* restore the default bottom-aligned layout.
|
|
480
|
+
*/
|
|
481
|
+
setInlineAnchor(row) {
|
|
482
|
+
if (row === null || row === undefined) {
|
|
483
|
+
this.inlineAnchorRow = null;
|
|
484
|
+
this.inlineLayout = false;
|
|
485
|
+
this.renderDirty = true;
|
|
486
|
+
this.render();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const { rows } = this.getSize();
|
|
490
|
+
const clamped = Math.max(1, Math.min(Math.floor(row), rows));
|
|
491
|
+
this.inlineAnchorRow = clamped;
|
|
492
|
+
this.inlineLayout = true;
|
|
493
|
+
this.renderDirty = true;
|
|
494
|
+
this.render();
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Provide a dynamic anchor callback. When set, the prompt will follow the
|
|
498
|
+
* output by re-evaluating the anchor before each render.
|
|
499
|
+
*/
|
|
500
|
+
setInlineAnchorProvider(provider) {
|
|
501
|
+
this.anchorProvider = provider;
|
|
502
|
+
if (!provider) {
|
|
503
|
+
this.inlineLayout = false;
|
|
504
|
+
this.inlineAnchorRow = null;
|
|
505
|
+
this.renderDirty = true;
|
|
506
|
+
this.render();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
this.inlineLayout = true;
|
|
510
|
+
this.renderDirty = true;
|
|
511
|
+
this.render();
|
|
512
|
+
}
|
|
231
513
|
/**
|
|
232
514
|
* Get current mode
|
|
233
515
|
*/
|
|
@@ -337,37 +619,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
337
619
|
this.streamingLabel = next;
|
|
338
620
|
this.scheduleRender();
|
|
339
621
|
}
|
|
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
622
|
/**
|
|
372
623
|
* Keep mode toggles (verification/auto-continue) visible in the control bar.
|
|
373
624
|
* Hotkey labels remain stable so the bar looks the same before/during streaming.
|
|
@@ -377,22 +628,16 @@ export class TerminalInput extends EventEmitter {
|
|
|
377
628
|
const nextAutoContinue = !!options.autoContinueEnabled;
|
|
378
629
|
const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
|
|
379
630
|
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
631
|
if (this.verificationEnabled === nextVerification &&
|
|
383
632
|
this.autoContinueEnabled === nextAutoContinue &&
|
|
384
633
|
this.verificationHotkey === nextVerifyHotkey &&
|
|
385
|
-
this.autoContinueHotkey === nextAutoHotkey
|
|
386
|
-
this.thinkingHotkey === nextThinkingHotkey &&
|
|
387
|
-
this.thinkingModeLabel === nextThinkingLabel) {
|
|
634
|
+
this.autoContinueHotkey === nextAutoHotkey) {
|
|
388
635
|
return;
|
|
389
636
|
}
|
|
390
637
|
this.verificationEnabled = nextVerification;
|
|
391
638
|
this.autoContinueEnabled = nextAutoContinue;
|
|
392
639
|
this.verificationHotkey = nextVerifyHotkey;
|
|
393
640
|
this.autoContinueHotkey = nextAutoHotkey;
|
|
394
|
-
this.thinkingHotkey = nextThinkingHotkey;
|
|
395
|
-
this.thinkingModeLabel = nextThinkingLabel;
|
|
396
641
|
this.scheduleRender();
|
|
397
642
|
}
|
|
398
643
|
/**
|
|
@@ -404,104 +649,198 @@ export class TerminalInput extends EventEmitter {
|
|
|
404
649
|
this.streamingLabel = null;
|
|
405
650
|
this.scheduleRender();
|
|
406
651
|
}
|
|
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
652
|
/**
|
|
421
653
|
* Render the input area - Claude Code style with mode controls
|
|
422
654
|
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
* naturally above while elapsed time and status stay fresh.
|
|
655
|
+
* Same rendering for both normal and streaming modes - just different status bar.
|
|
656
|
+
* During streaming, uses cursor save/restore to preserve streaming position.
|
|
426
657
|
*/
|
|
427
658
|
render() {
|
|
428
659
|
if (!this.canRender())
|
|
429
660
|
return;
|
|
430
661
|
if (this.isRendering)
|
|
431
662
|
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
663
|
const shouldSkip = !this.renderDirty &&
|
|
445
664
|
this.buffer === this.lastRenderContent &&
|
|
446
665
|
this.cursor === this.lastRenderCursor;
|
|
447
666
|
this.renderDirty = false;
|
|
448
|
-
// Skip if nothing changed
|
|
667
|
+
// Skip if nothing changed (unless explicitly forced)
|
|
449
668
|
if (shouldSkip) {
|
|
450
669
|
return;
|
|
451
670
|
}
|
|
452
|
-
// If write lock is held, defer render
|
|
671
|
+
// If write lock is held, defer render
|
|
453
672
|
if (writeLock.isLocked()) {
|
|
454
673
|
writeLock.safeWrite(() => this.render());
|
|
455
674
|
return;
|
|
456
675
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
676
|
+
this.isRendering = true;
|
|
677
|
+
writeLock.lock('terminalInput.render');
|
|
678
|
+
try {
|
|
679
|
+
// Render input area at bottom (outside scroll region)
|
|
680
|
+
this.renderBottomPinned();
|
|
681
|
+
}
|
|
682
|
+
finally {
|
|
683
|
+
writeLock.unlock();
|
|
684
|
+
this.isRendering = false;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Render in flow mode - delegates to bottom-pinned for stability.
|
|
689
|
+
*
|
|
690
|
+
* Flow mode attempted inline rendering but caused duplicate renders
|
|
691
|
+
* due to unreliable cursor position tracking. Bottom-pinned is reliable.
|
|
692
|
+
*/
|
|
693
|
+
renderFlowMode() {
|
|
694
|
+
// Use stable bottom-pinned approach
|
|
695
|
+
this.renderBottomPinned();
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Render in bottom-pinned mode - Claude Code style with suggestions
|
|
699
|
+
*
|
|
700
|
+
* Works for both normal and streaming modes:
|
|
701
|
+
* - During streaming: saves/restores cursor position
|
|
702
|
+
* - Status bar shows streaming info or "Type a message"
|
|
703
|
+
*
|
|
704
|
+
* Layout when suggestions visible:
|
|
705
|
+
* - Top divider
|
|
706
|
+
* - Input line(s)
|
|
707
|
+
* - Bottom divider
|
|
708
|
+
* - Suggestions (command list)
|
|
709
|
+
*
|
|
710
|
+
* Layout when suggestions hidden:
|
|
711
|
+
* - Status bar (Ready/Streaming)
|
|
712
|
+
* - Top divider
|
|
713
|
+
* - Input line(s)
|
|
714
|
+
* - Bottom divider
|
|
715
|
+
* - Mode controls
|
|
716
|
+
*/
|
|
717
|
+
renderBottomPinned() {
|
|
718
|
+
const { rows, cols } = this.getSize();
|
|
719
|
+
const maxWidth = Math.max(8, cols - 4);
|
|
720
|
+
const isStreaming = this.mode === 'streaming';
|
|
721
|
+
// Use unified pinned input area (works for both streaming and normal)
|
|
722
|
+
// Only use complex rendering when suggestions are visible
|
|
723
|
+
const hasSuggestions = !isStreaming && this.showSuggestions && this.filteredSuggestions.length > 0;
|
|
724
|
+
if (!hasSuggestions) {
|
|
725
|
+
this.renderPinnedInputArea();
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
// Wrap buffer into display lines
|
|
729
|
+
const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
|
|
730
|
+
const availableForContent = Math.max(1, rows - 3);
|
|
731
|
+
const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
|
|
732
|
+
const displayLines = Math.min(lines.length, maxVisible);
|
|
733
|
+
// Calculate display window (keep cursor visible)
|
|
734
|
+
let startLine = 0;
|
|
735
|
+
if (lines.length > displayLines) {
|
|
736
|
+
startLine = Math.max(0, cursorLine - displayLines + 1);
|
|
737
|
+
startLine = Math.min(startLine, lines.length - displayLines);
|
|
738
|
+
}
|
|
739
|
+
const visibleLines = lines.slice(startLine, startLine + displayLines);
|
|
740
|
+
const adjustedCursorLine = cursorLine - startLine;
|
|
741
|
+
// Calculate suggestion display (not during streaming)
|
|
742
|
+
const suggestionsToShow = (!isStreaming && this.showSuggestions)
|
|
743
|
+
? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
|
|
744
|
+
: [];
|
|
745
|
+
const suggestionLines = suggestionsToShow.length;
|
|
746
|
+
this.write(ESC.HIDE);
|
|
747
|
+
this.write(ESC.RESET);
|
|
748
|
+
const divider = renderDivider(cols - 2);
|
|
749
|
+
// Calculate positions from absolute bottom
|
|
750
|
+
let currentRow;
|
|
751
|
+
if (suggestionLines > 0) {
|
|
752
|
+
// With suggestions: input area + dividers + suggestions
|
|
753
|
+
// Layout: [topDiv] [input] [bottomDiv] [suggestions...]
|
|
754
|
+
const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
|
|
755
|
+
currentRow = Math.max(1, rows - totalHeight + 1);
|
|
756
|
+
this.updateReservedLines(totalHeight);
|
|
757
|
+
// Top divider
|
|
758
|
+
this.write(ESC.TO(currentRow, 1));
|
|
759
|
+
this.write(ESC.CLEAR_LINE);
|
|
760
|
+
this.write(divider);
|
|
761
|
+
currentRow++;
|
|
762
|
+
// Input lines
|
|
763
|
+
let finalRow = currentRow;
|
|
764
|
+
let finalCol = 3;
|
|
765
|
+
for (let i = 0; i < visibleLines.length; i++) {
|
|
766
|
+
this.write(ESC.TO(currentRow, 1));
|
|
767
|
+
this.write(ESC.CLEAR_LINE);
|
|
768
|
+
const line = visibleLines[i] ?? '';
|
|
769
|
+
const absoluteLineIdx = startLine + i;
|
|
770
|
+
const isFirstLine = absoluteLineIdx === 0;
|
|
771
|
+
const isCursorLine = i === adjustedCursorLine;
|
|
772
|
+
this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
|
|
773
|
+
if (isCursorLine) {
|
|
774
|
+
const col = Math.min(cursorCol, line.length);
|
|
775
|
+
this.write(line.slice(0, col));
|
|
776
|
+
this.write(ESC.REVERSE);
|
|
777
|
+
this.write(col < line.length ? line[col] : ' ');
|
|
778
|
+
this.write(ESC.RESET);
|
|
779
|
+
this.write(line.slice(col + 1));
|
|
780
|
+
finalRow = currentRow;
|
|
781
|
+
finalCol = this.config.promptChar.length + col + 1;
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
this.write(line);
|
|
785
|
+
}
|
|
786
|
+
currentRow++;
|
|
476
787
|
}
|
|
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) {
|
|
788
|
+
// Bottom divider
|
|
789
|
+
this.write(ESC.TO(currentRow, 1));
|
|
790
|
+
this.write(ESC.CLEAR_LINE);
|
|
791
|
+
this.write(divider);
|
|
792
|
+
currentRow++;
|
|
793
|
+
// Suggestions (Claude Code style)
|
|
794
|
+
for (let i = 0; i < suggestionsToShow.length; i++) {
|
|
488
795
|
this.write(ESC.TO(currentRow, 1));
|
|
489
796
|
this.write(ESC.CLEAR_LINE);
|
|
490
|
-
|
|
491
|
-
|
|
797
|
+
const suggestion = suggestionsToShow[i];
|
|
798
|
+
const isSelected = i === this.selectedSuggestionIndex;
|
|
799
|
+
// Indent and highlight selected
|
|
800
|
+
this.write(' ');
|
|
801
|
+
if (isSelected) {
|
|
802
|
+
this.write(ESC.REVERSE);
|
|
803
|
+
this.write(ESC.BOLD);
|
|
804
|
+
}
|
|
805
|
+
this.write(suggestion.command);
|
|
806
|
+
if (isSelected) {
|
|
807
|
+
this.write(ESC.RESET);
|
|
808
|
+
}
|
|
809
|
+
// Description (dimmed)
|
|
810
|
+
const descSpace = cols - suggestion.command.length - 8;
|
|
811
|
+
if (descSpace > 10 && suggestion.description) {
|
|
812
|
+
const desc = suggestion.description.slice(0, descSpace);
|
|
813
|
+
this.write(ESC.RESET);
|
|
814
|
+
this.write(ESC.DIM);
|
|
815
|
+
this.write(' ');
|
|
816
|
+
this.write(desc);
|
|
817
|
+
this.write(ESC.RESET);
|
|
818
|
+
}
|
|
819
|
+
currentRow++;
|
|
492
820
|
}
|
|
493
|
-
//
|
|
821
|
+
// Position cursor in input area
|
|
822
|
+
this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
// Without suggestions: normal layout with status bar and controls
|
|
826
|
+
const totalHeight = visibleLines.length + 4; // status + topDiv + input + bottomDiv + controls
|
|
827
|
+
currentRow = Math.max(1, rows - totalHeight + 1);
|
|
828
|
+
this.updateReservedLines(totalHeight);
|
|
829
|
+
// Status bar (streaming or normal)
|
|
830
|
+
this.write(ESC.TO(currentRow, 1));
|
|
831
|
+
this.write(ESC.CLEAR_LINE);
|
|
832
|
+
this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
|
|
833
|
+
currentRow++;
|
|
834
|
+
// Top divider
|
|
494
835
|
this.write(ESC.TO(currentRow, 1));
|
|
495
836
|
this.write(ESC.CLEAR_LINE);
|
|
496
|
-
const divider = renderDivider(cols - 2);
|
|
497
837
|
this.write(divider);
|
|
498
|
-
currentRow
|
|
499
|
-
//
|
|
838
|
+
currentRow++;
|
|
839
|
+
// Input lines
|
|
500
840
|
let finalRow = currentRow;
|
|
501
841
|
let finalCol = 3;
|
|
502
842
|
for (let i = 0; i < visibleLines.length; i++) {
|
|
503
|
-
|
|
504
|
-
this.write(ESC.TO(rowNum, 1));
|
|
843
|
+
this.write(ESC.TO(currentRow, 1));
|
|
505
844
|
this.write(ESC.CLEAR_LINE);
|
|
506
845
|
const line = visibleLines[i] ?? '';
|
|
507
846
|
const absoluteLineIdx = startLine + i;
|
|
@@ -515,7 +854,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
515
854
|
this.write(ESC.RESET);
|
|
516
855
|
this.write(ESC.BG_DARK);
|
|
517
856
|
if (isCursorLine) {
|
|
518
|
-
// Render with block cursor
|
|
519
857
|
const col = Math.min(cursorCol, line.length);
|
|
520
858
|
const before = line.slice(0, col);
|
|
521
859
|
const at = col < line.length ? line[col] : ' ';
|
|
@@ -525,251 +863,157 @@ export class TerminalInput extends EventEmitter {
|
|
|
525
863
|
this.write(at);
|
|
526
864
|
this.write(ESC.RESET + ESC.BG_DARK);
|
|
527
865
|
this.write(after);
|
|
528
|
-
finalRow =
|
|
866
|
+
finalRow = currentRow;
|
|
529
867
|
finalCol = this.config.promptChar.length + col + 1;
|
|
530
868
|
}
|
|
531
869
|
else {
|
|
532
870
|
this.write(line);
|
|
533
871
|
}
|
|
534
|
-
// Pad to edge
|
|
872
|
+
// Pad to edge
|
|
535
873
|
const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
|
|
536
874
|
const padding = Math.max(0, cols - lineLen - 1);
|
|
537
875
|
if (padding > 0)
|
|
538
876
|
this.write(' '.repeat(padding));
|
|
539
877
|
this.write(ESC.RESET);
|
|
878
|
+
currentRow++;
|
|
540
879
|
}
|
|
541
|
-
//
|
|
542
|
-
|
|
543
|
-
this.write(ESC.
|
|
880
|
+
// Bottom divider
|
|
881
|
+
this.write(ESC.TO(currentRow, 1));
|
|
882
|
+
this.write(ESC.CLEAR_LINE);
|
|
883
|
+
this.write(divider);
|
|
884
|
+
currentRow++;
|
|
885
|
+
// Mode controls
|
|
886
|
+
this.write(ESC.TO(currentRow, 1));
|
|
544
887
|
this.write(ESC.CLEAR_LINE);
|
|
545
888
|
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;
|
|
889
|
+
// Position cursor: restore for streaming, or position in input for normal
|
|
890
|
+
if (isStreaming) {
|
|
891
|
+
this.write(ESC.RESTORE);
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
|
|
556
895
|
}
|
|
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
896
|
}
|
|
897
|
+
this.write(ESC.SHOW);
|
|
898
|
+
// Update state
|
|
899
|
+
this.lastRenderContent = this.buffer;
|
|
900
|
+
this.lastRenderCursor = this.cursor;
|
|
568
901
|
}
|
|
569
902
|
/**
|
|
570
|
-
* Build
|
|
903
|
+
* Build status bar for streaming mode (shows elapsed time, queue count).
|
|
571
904
|
*/
|
|
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
|
-
}
|
|
905
|
+
buildStreamingStatusBar(cols) {
|
|
906
|
+
const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
|
|
907
|
+
// Streaming status with elapsed time
|
|
908
|
+
let elapsed = '0s';
|
|
909
|
+
if (this.streamingStartTime) {
|
|
910
|
+
const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
|
|
911
|
+
const mins = Math.floor(secs / 60);
|
|
912
|
+
elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
|
|
913
|
+
}
|
|
914
|
+
let status = `${GREEN}● Streaming${R} ${elapsed}`;
|
|
915
|
+
// Queue indicator
|
|
613
916
|
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));
|
|
917
|
+
status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
|
|
630
918
|
}
|
|
919
|
+
// Hint for typing
|
|
920
|
+
status += ` ${DIM}· type to queue message${R}`;
|
|
921
|
+
return status;
|
|
631
922
|
}
|
|
632
923
|
/**
|
|
633
|
-
* Build
|
|
634
|
-
*
|
|
924
|
+
* Build status bar showing streaming/ready status and key info.
|
|
925
|
+
* This is the TOP line above the input area - minimal Claude Code style.
|
|
635
926
|
*/
|
|
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' });
|
|
927
|
+
buildStatusBar(cols) {
|
|
928
|
+
const maxWidth = cols - 2;
|
|
929
|
+
const parts = [];
|
|
930
|
+
// Streaming status with elapsed time (left side)
|
|
931
|
+
if (this.mode === 'streaming') {
|
|
932
|
+
let statusText = '● Streaming';
|
|
933
|
+
if (this.streamingStartTime) {
|
|
934
|
+
const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
|
|
935
|
+
const mins = Math.floor(elapsed / 60);
|
|
936
|
+
const secs = elapsed % 60;
|
|
937
|
+
statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
|
|
938
|
+
}
|
|
939
|
+
parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
|
|
661
940
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
941
|
+
// Queue indicator during streaming
|
|
942
|
+
if (this.mode === 'streaming' && this.queue.length > 0) {
|
|
943
|
+
parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
|
|
665
944
|
}
|
|
945
|
+
// Paste indicator
|
|
666
946
|
if (this.pastePlaceholders.length > 0) {
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
|
|
670
|
-
tone: 'info',
|
|
671
|
-
});
|
|
947
|
+
const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
|
|
948
|
+
parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
|
|
672
949
|
}
|
|
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}`;
|
|
950
|
+
// Override/warning status
|
|
951
|
+
if (this.overrideStatusMessage) {
|
|
952
|
+
parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
|
|
750
953
|
}
|
|
751
|
-
|
|
752
|
-
|
|
954
|
+
// If idle with empty buffer, show quick shortcuts
|
|
955
|
+
if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
|
|
956
|
+
return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
|
|
753
957
|
}
|
|
754
|
-
|
|
755
|
-
|
|
958
|
+
// Multi-line indicator
|
|
959
|
+
if (this.buffer.includes('\n')) {
|
|
960
|
+
parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
|
|
756
961
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
return
|
|
962
|
+
if (parts.length === 0) {
|
|
963
|
+
return ''; // Empty status bar when idle
|
|
964
|
+
}
|
|
965
|
+
const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
|
|
966
|
+
return joined.slice(0, maxWidth);
|
|
762
967
|
}
|
|
763
968
|
/**
|
|
764
|
-
*
|
|
765
|
-
*
|
|
969
|
+
* Build mode controls line showing toggles and context info.
|
|
970
|
+
* This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
|
|
971
|
+
*
|
|
972
|
+
* Layout: [toggles on left] ... [context info on right]
|
|
766
973
|
*/
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
974
|
+
buildModeControls(cols) {
|
|
975
|
+
const maxWidth = cols - 2;
|
|
976
|
+
// Use schema-defined colors for consistency
|
|
977
|
+
const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
|
|
978
|
+
// Mode toggles with colors (following ModeControlsSchema)
|
|
979
|
+
const toggles = [];
|
|
980
|
+
// Edit mode (green=auto, yellow=ask) - per schema.editMode
|
|
981
|
+
if (this.editMode === 'display-edits') {
|
|
982
|
+
toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
|
|
986
|
+
}
|
|
987
|
+
// Thinking mode (cyan when on) - per schema.thinkingMode
|
|
988
|
+
toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
|
|
989
|
+
// Verification (green when on) - per schema.verificationMode
|
|
990
|
+
toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
|
|
991
|
+
// Auto-continue (magenta when on) - per schema.autoContinueMode
|
|
992
|
+
toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
|
|
993
|
+
const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
|
|
994
|
+
// Context usage with color - per schema.contextUsage thresholds
|
|
995
|
+
let rightPart = '';
|
|
996
|
+
if (this.contextUsage !== null) {
|
|
997
|
+
const rem = Math.max(0, 100 - this.contextUsage);
|
|
998
|
+
// Thresholds: critical < 10%, warning < 25%
|
|
999
|
+
if (rem < 10)
|
|
1000
|
+
rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
|
|
1001
|
+
else if (rem < 25)
|
|
1002
|
+
rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
|
|
1003
|
+
else
|
|
1004
|
+
rightPart = `${DIM}ctx: ${rem}%${R}`;
|
|
1005
|
+
}
|
|
1006
|
+
// Calculate visible lengths (strip ANSI)
|
|
1007
|
+
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
1008
|
+
const leftLen = strip(leftPart).length;
|
|
1009
|
+
const rightLen = strip(rightPart).length;
|
|
1010
|
+
if (leftLen + rightLen < maxWidth - 4) {
|
|
1011
|
+
return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
|
|
1012
|
+
}
|
|
1013
|
+
if (rightLen > 0 && leftLen + 8 < maxWidth) {
|
|
1014
|
+
return `${leftPart} ${rightPart}`;
|
|
1015
|
+
}
|
|
1016
|
+
return leftPart;
|
|
773
1017
|
}
|
|
774
1018
|
/**
|
|
775
1019
|
* Force a re-render
|
|
@@ -792,19 +1036,17 @@ export class TerminalInput extends EventEmitter {
|
|
|
792
1036
|
handleResize() {
|
|
793
1037
|
this.lastRenderContent = '';
|
|
794
1038
|
this.lastRenderCursor = -1;
|
|
795
|
-
this.resetStreamingRenderThrottle();
|
|
796
1039
|
// Re-clamp pinned header rows to the new terminal height
|
|
797
1040
|
this.setPinnedHeaderLines(this.pinnedTopRows);
|
|
798
|
-
if (this.scrollRegionActive) {
|
|
799
|
-
this.disableScrollRegion();
|
|
800
|
-
this.enableScrollRegion();
|
|
801
|
-
}
|
|
802
1041
|
this.scheduleRender();
|
|
803
1042
|
}
|
|
804
1043
|
/**
|
|
805
1044
|
* Register with display's output interceptor to position cursor correctly.
|
|
806
1045
|
* When scroll region is active, output needs to go to the scroll region,
|
|
807
1046
|
* not the protected bottom area where the input is rendered.
|
|
1047
|
+
*
|
|
1048
|
+
* NOTE: With scroll region properly set, content naturally stays within
|
|
1049
|
+
* the region boundaries - no cursor manipulation needed per-write.
|
|
808
1050
|
*/
|
|
809
1051
|
registerOutputInterceptor(display) {
|
|
810
1052
|
if (this.outputInterceptorCleanup) {
|
|
@@ -812,20 +1054,11 @@ export class TerminalInput extends EventEmitter {
|
|
|
812
1054
|
}
|
|
813
1055
|
this.outputInterceptorCleanup = display.registerOutputInterceptor({
|
|
814
1056
|
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
|
-
}
|
|
1057
|
+
// Scroll region handles content containment automatically
|
|
1058
|
+
// No per-write cursor manipulation needed
|
|
823
1059
|
},
|
|
824
1060
|
afterWrite: () => {
|
|
825
|
-
//
|
|
826
|
-
if (this.scrollRegionActive) {
|
|
827
|
-
this.write(ESC.RESTORE);
|
|
828
|
-
}
|
|
1061
|
+
// No cursor manipulation needed
|
|
829
1062
|
},
|
|
830
1063
|
});
|
|
831
1064
|
}
|
|
@@ -835,6 +1068,11 @@ export class TerminalInput extends EventEmitter {
|
|
|
835
1068
|
dispose() {
|
|
836
1069
|
if (this.disposed)
|
|
837
1070
|
return;
|
|
1071
|
+
// Clean up streaming render timer
|
|
1072
|
+
if (this.streamingRenderTimer) {
|
|
1073
|
+
clearInterval(this.streamingRenderTimer);
|
|
1074
|
+
this.streamingRenderTimer = null;
|
|
1075
|
+
}
|
|
838
1076
|
// Clean up output interceptor
|
|
839
1077
|
if (this.outputInterceptorCleanup) {
|
|
840
1078
|
this.outputInterceptorCleanup();
|
|
@@ -842,7 +1080,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
842
1080
|
}
|
|
843
1081
|
this.disposed = true;
|
|
844
1082
|
this.enabled = false;
|
|
845
|
-
this.resetStreamingRenderThrottle();
|
|
846
1083
|
this.disableScrollRegion();
|
|
847
1084
|
this.disableBracketedPaste();
|
|
848
1085
|
this.buffer = '';
|
|
@@ -948,7 +1185,22 @@ export class TerminalInput extends EventEmitter {
|
|
|
948
1185
|
this.toggleEditMode();
|
|
949
1186
|
return true;
|
|
950
1187
|
}
|
|
951
|
-
|
|
1188
|
+
// Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
|
|
1189
|
+
if (this.findPlaceholderAt(this.cursor)) {
|
|
1190
|
+
this.togglePasteExpansion();
|
|
1191
|
+
}
|
|
1192
|
+
else {
|
|
1193
|
+
this.toggleThinking();
|
|
1194
|
+
}
|
|
1195
|
+
return true;
|
|
1196
|
+
case 'escape':
|
|
1197
|
+
// Esc: interrupt if streaming, otherwise clear buffer
|
|
1198
|
+
if (this.mode === 'streaming') {
|
|
1199
|
+
this.emit('interrupt');
|
|
1200
|
+
}
|
|
1201
|
+
else if (this.buffer.length > 0) {
|
|
1202
|
+
this.clear();
|
|
1203
|
+
}
|
|
952
1204
|
return true;
|
|
953
1205
|
}
|
|
954
1206
|
return false;
|
|
@@ -966,6 +1218,7 @@ export class TerminalInput extends EventEmitter {
|
|
|
966
1218
|
this.insertPlainText(chunk, insertPos);
|
|
967
1219
|
this.cursor = insertPos + chunk.length;
|
|
968
1220
|
this.emit('change', this.buffer);
|
|
1221
|
+
this.updateSuggestions();
|
|
969
1222
|
this.scheduleRender();
|
|
970
1223
|
}
|
|
971
1224
|
insertNewline() {
|
|
@@ -990,6 +1243,7 @@ export class TerminalInput extends EventEmitter {
|
|
|
990
1243
|
this.cursor = Math.max(0, this.cursor - 1);
|
|
991
1244
|
}
|
|
992
1245
|
this.emit('change', this.buffer);
|
|
1246
|
+
this.updateSuggestions();
|
|
993
1247
|
this.scheduleRender();
|
|
994
1248
|
}
|
|
995
1249
|
deleteForward() {
|
|
@@ -1239,9 +1493,7 @@ export class TerminalInput extends EventEmitter {
|
|
|
1239
1493
|
if (available <= 0)
|
|
1240
1494
|
return;
|
|
1241
1495
|
const chunk = clean.slice(0, available);
|
|
1242
|
-
|
|
1243
|
-
const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
|
|
1244
|
-
if (isMultiline && !isShortMultiline) {
|
|
1496
|
+
if (isMultilinePaste(chunk)) {
|
|
1245
1497
|
this.insertPastePlaceholder(chunk);
|
|
1246
1498
|
}
|
|
1247
1499
|
else {
|
|
@@ -1261,7 +1513,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
1261
1513
|
return;
|
|
1262
1514
|
this.applyScrollRegion();
|
|
1263
1515
|
this.scrollRegionActive = true;
|
|
1264
|
-
this.forceRender();
|
|
1265
1516
|
}
|
|
1266
1517
|
disableScrollRegion() {
|
|
1267
1518
|
if (!this.scrollRegionActive)
|
|
@@ -1412,19 +1663,17 @@ export class TerminalInput extends EventEmitter {
|
|
|
1412
1663
|
this.shiftPlaceholders(position, text.length);
|
|
1413
1664
|
this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
|
|
1414
1665
|
}
|
|
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
1666
|
findPlaceholderAt(position) {
|
|
1422
1667
|
return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
|
|
1423
1668
|
}
|
|
1424
|
-
buildPlaceholder(
|
|
1669
|
+
buildPlaceholder(summary) {
|
|
1425
1670
|
const id = ++this.pasteCounter;
|
|
1426
|
-
const
|
|
1427
|
-
|
|
1671
|
+
const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
|
|
1672
|
+
// Show first line preview (truncated)
|
|
1673
|
+
const preview = summary.preview.length > 30
|
|
1674
|
+
? `${summary.preview.slice(0, 30)}...`
|
|
1675
|
+
: summary.preview;
|
|
1676
|
+
const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
|
|
1428
1677
|
return { id, placeholder };
|
|
1429
1678
|
}
|
|
1430
1679
|
insertPastePlaceholder(content) {
|
|
@@ -1432,21 +1681,67 @@ export class TerminalInput extends EventEmitter {
|
|
|
1432
1681
|
if (available <= 0)
|
|
1433
1682
|
return;
|
|
1434
1683
|
const cleanContent = content.slice(0, available);
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1684
|
+
const summary = generatePasteSummary(cleanContent);
|
|
1685
|
+
// For short pastes (< 5 lines), show full content instead of placeholder
|
|
1686
|
+
if (summary.lineCount < 5) {
|
|
1687
|
+
const placeholder = this.findPlaceholderAt(this.cursor);
|
|
1688
|
+
const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
|
|
1689
|
+
this.insertPlainText(cleanContent, insertPos);
|
|
1690
|
+
this.cursor = insertPos + cleanContent.length;
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
const { id, placeholder } = this.buildPlaceholder(summary);
|
|
1437
1694
|
const insertPos = this.cursor;
|
|
1438
1695
|
this.shiftPlaceholders(insertPos, placeholder.length);
|
|
1439
1696
|
this.pastePlaceholders.push({
|
|
1440
1697
|
id,
|
|
1441
1698
|
content: cleanContent,
|
|
1442
|
-
lineCount,
|
|
1699
|
+
lineCount: summary.lineCount,
|
|
1443
1700
|
placeholder,
|
|
1444
1701
|
start: insertPos,
|
|
1445
1702
|
end: insertPos + placeholder.length,
|
|
1703
|
+
summary,
|
|
1704
|
+
expanded: false,
|
|
1446
1705
|
});
|
|
1447
1706
|
this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
|
|
1448
1707
|
this.cursor = insertPos + placeholder.length;
|
|
1449
1708
|
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Toggle expansion of a paste placeholder at the current cursor position.
|
|
1711
|
+
* When expanded, shows first 3 and last 2 lines of the content.
|
|
1712
|
+
*/
|
|
1713
|
+
togglePasteExpansion() {
|
|
1714
|
+
const placeholder = this.findPlaceholderAt(this.cursor);
|
|
1715
|
+
if (!placeholder)
|
|
1716
|
+
return false;
|
|
1717
|
+
placeholder.expanded = !placeholder.expanded;
|
|
1718
|
+
// Update the placeholder text in buffer
|
|
1719
|
+
const newPlaceholder = placeholder.expanded
|
|
1720
|
+
? this.buildExpandedPlaceholder(placeholder)
|
|
1721
|
+
: this.buildPlaceholder(placeholder.summary).placeholder;
|
|
1722
|
+
const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
|
|
1723
|
+
// Update buffer
|
|
1724
|
+
this.buffer =
|
|
1725
|
+
this.buffer.slice(0, placeholder.start) +
|
|
1726
|
+
newPlaceholder +
|
|
1727
|
+
this.buffer.slice(placeholder.end);
|
|
1728
|
+
// Update placeholder tracking
|
|
1729
|
+
placeholder.placeholder = newPlaceholder;
|
|
1730
|
+
placeholder.end = placeholder.start + newPlaceholder.length;
|
|
1731
|
+
// Shift other placeholders
|
|
1732
|
+
this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
|
|
1733
|
+
this.scheduleRender();
|
|
1734
|
+
return true;
|
|
1735
|
+
}
|
|
1736
|
+
buildExpandedPlaceholder(ph) {
|
|
1737
|
+
const lines = ph.content.split('\n');
|
|
1738
|
+
const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
|
|
1739
|
+
const lastLines = lines.length > 5
|
|
1740
|
+
? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
|
|
1741
|
+
: '';
|
|
1742
|
+
const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
|
|
1743
|
+
return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
|
|
1744
|
+
}
|
|
1450
1745
|
deletePlaceholder(placeholder) {
|
|
1451
1746
|
const length = placeholder.end - placeholder.start;
|
|
1452
1747
|
this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
|
|
@@ -1454,11 +1749,7 @@ export class TerminalInput extends EventEmitter {
|
|
|
1454
1749
|
this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
|
|
1455
1750
|
this.cursor = placeholder.start;
|
|
1456
1751
|
}
|
|
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
|
-
}
|
|
1752
|
+
updateContextUsage(value) {
|
|
1462
1753
|
if (value === null || !Number.isFinite(value)) {
|
|
1463
1754
|
this.contextUsage = null;
|
|
1464
1755
|
}
|
|
@@ -1485,22 +1776,6 @@ export class TerminalInput extends EventEmitter {
|
|
|
1485
1776
|
const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
|
|
1486
1777
|
this.setEditMode(next);
|
|
1487
1778
|
}
|
|
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
1779
|
scheduleRender() {
|
|
1505
1780
|
if (!this.canRender())
|
|
1506
1781
|
return;
|