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