@pellux/goodvibes-tui 0.29.0 → 1.1.0
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/CHANGELOG.md +69 -98
- package/README.md +16 -7
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
- package/src/cli/entrypoint.ts +2 -0
- package/src/core/conversation-line-cache.ts +432 -0
- package/src/core/conversation-rendering.ts +11 -3
- package/src/core/conversation.ts +90 -4
- package/src/core/stream-event-wiring.ts +104 -2
- package/src/core/stream-stall-watchdog.ts +41 -17
- package/src/export/cost-utils.ts +90 -8
- package/src/input/command-registry.ts +16 -0
- package/src/input/commands/diff-runtime.ts +61 -30
- package/src/input/commands/session-content.ts +12 -0
- package/src/input/commands/session-workflow.ts +3 -0
- package/src/input/commands/share-runtime.ts +9 -2
- package/src/input/handler-content-actions.ts +30 -11
- package/src/input/handler-feed-routes.ts +63 -1
- package/src/input/handler-feed.ts +12 -0
- package/src/input/handler-interactions.ts +2 -0
- package/src/input/handler-types.ts +1 -0
- package/src/input/handler.ts +4 -0
- package/src/input/input-history.ts +17 -4
- package/src/main.ts +27 -23
- package/src/panels/agent-inspector-shared.ts +33 -10
- package/src/panels/base-panel.ts +1 -1
- package/src/panels/builtin/development.ts +1 -1
- package/src/panels/cockpit-read-model.ts +16 -11
- package/src/panels/cost-tracker-panel.ts +28 -5
- package/src/panels/debug-panel.ts +11 -5
- package/src/panels/diff-panel.ts +122 -22
- package/src/panels/docs-panel.ts +9 -0
- package/src/panels/file-explorer-panel.ts +9 -0
- package/src/panels/git-panel.ts +11 -11
- package/src/panels/knowledge-graph-panel.ts +9 -0
- package/src/panels/local-auth-panel.ts +10 -0
- package/src/panels/panel-list-panel.ts +9 -0
- package/src/panels/project-planning-panel.ts +10 -0
- package/src/panels/provider-health-tracker.ts +5 -1
- package/src/panels/provider-health-views.ts +8 -1
- package/src/panels/scrollable-list-panel.ts +9 -0
- package/src/panels/session-browser-panel.ts +9 -0
- package/src/panels/token-budget-panel.ts +5 -3
- package/src/panels/types.ts +13 -0
- package/src/panels/work-plan-panel.ts +10 -0
- package/src/renderer/agent-detail-modal.ts +42 -12
- package/src/renderer/code-block.ts +58 -28
- package/src/renderer/context-inspector.ts +3 -6
- package/src/renderer/conversation-surface.ts +1 -21
- package/src/renderer/diff-view.ts +6 -4
- package/src/renderer/fullscreen-primitives.ts +1 -1
- package/src/renderer/fullscreen-workspace.ts +2 -7
- package/src/renderer/hint-grammar.ts +1 -1
- package/src/renderer/history-search-overlay.ts +10 -16
- package/src/renderer/markdown.ts +9 -1
- package/src/renderer/model-picker-overlay.ts +8 -499
- package/src/renderer/model-workspace.ts +9 -24
- package/src/renderer/overlay-box.ts +7 -9
- package/src/renderer/process-indicator.ts +13 -25
- package/src/renderer/process-modal.ts +17 -2
- package/src/renderer/profile-picker-modal.ts +13 -14
- package/src/renderer/prompt-content-width.ts +16 -0
- package/src/renderer/selection-modal-overlay.ts +3 -1
- package/src/renderer/semantic-diff.ts +1 -1
- package/src/renderer/settings-modal-helpers.ts +10 -34
- package/src/renderer/shell-surface.ts +29 -9
- package/src/renderer/surface-layout.ts +0 -12
- package/src/renderer/term-caps.ts +1 -1
- package/src/renderer/tool-call.ts +6 -20
- package/src/renderer/ui-factory.ts +98 -14
- package/src/renderer/ui-primitives.ts +18 -1
- package/src/runtime/bootstrap-command-context.ts +3 -0
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-core.ts +5 -0
- package/src/runtime/bootstrap-hook-bridge.ts +5 -0
- package/src/runtime/bootstrap-shell.ts +12 -1
- package/src/runtime/render-scheduler.ts +80 -0
- package/src/utils/splash-lines.ts +10 -2
- package/src/version.ts +1 -1
- package/src/renderer/file-tree.ts +0 -153
- package/src/renderer/progress.ts +0 -100
- package/src/renderer/status-token.ts +0 -67
|
@@ -121,10 +121,15 @@ export function registerPaste(
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
// Terminals transmit the line breaks inside a bracketed paste as \r (the
|
|
125
|
+
// byte Enter sends), and external clipboards can carry \r\n. Normalize to
|
|
126
|
+
// \n only here in the text branch — the image sniffing above must see the
|
|
127
|
+
// raw bytes (PNG's magic sequence itself contains \r\n).
|
|
128
|
+
const text = content.replace(/\r\n?/g, '\n');
|
|
129
|
+
const lines = text.split('\n');
|
|
130
|
+
if (lines.length <= 8) return { marker: text, nextPasteId: state.nextPasteId, nextImageId: state.nextImageId };
|
|
126
131
|
const id = `p${state.nextPasteId++}`;
|
|
127
|
-
state.pasteRegistry.set(id,
|
|
132
|
+
state.pasteRegistry.set(id, text);
|
|
128
133
|
return { marker: `[TEXT: ${id}, ${lines.length} lines]`, nextPasteId: state.nextPasteId, nextImageId: state.nextImageId };
|
|
129
134
|
}
|
|
130
135
|
|
|
@@ -422,6 +427,8 @@ export function handleCtrlC(
|
|
|
422
427
|
lastCtrlCTime: number,
|
|
423
428
|
setLastCtrlCTime: (value: number) => void,
|
|
424
429
|
setShowExitNotice: (value: boolean) => void,
|
|
430
|
+
lastCtrlCTimeoutId: ReturnType<typeof setTimeout> | null,
|
|
431
|
+
setLastCtrlCTimeoutId: (value: ReturnType<typeof setTimeout> | null) => void,
|
|
425
432
|
): void {
|
|
426
433
|
if (prompt.length > 0) {
|
|
427
434
|
saveUndoState();
|
|
@@ -431,17 +438,29 @@ export function handleCtrlC(
|
|
|
431
438
|
}
|
|
432
439
|
cancelGeneration?.();
|
|
433
440
|
const now = Date.now();
|
|
441
|
+
// Clear any hide-timer pending from a prior press before deciding this
|
|
442
|
+
// one's outcome, whichever branch below runs. Without this, a hide-timer
|
|
443
|
+
// scheduled by an earlier press could still fire after a newer notice
|
|
444
|
+
// window opened, or after exitApp() was just called (if exitApp isn't
|
|
445
|
+
// synchronous) — flipping showExitNotice/requestRender during a shutdown
|
|
446
|
+
// the user already believes is in progress.
|
|
447
|
+
if (lastCtrlCTimeoutId !== null) {
|
|
448
|
+
clearTimeout(lastCtrlCTimeoutId);
|
|
449
|
+
setLastCtrlCTimeoutId(null);
|
|
450
|
+
}
|
|
434
451
|
if (now - lastCtrlCTime < 1000) {
|
|
435
452
|
exitApp();
|
|
436
|
-
|
|
437
|
-
setLastCtrlCTime(now);
|
|
438
|
-
setShowExitNotice(true);
|
|
439
|
-
requestRender();
|
|
440
|
-
setTimeout(() => {
|
|
441
|
-
setShowExitNotice(false);
|
|
442
|
-
requestRender();
|
|
443
|
-
}, 1000);
|
|
453
|
+
return;
|
|
444
454
|
}
|
|
455
|
+
setLastCtrlCTime(now);
|
|
456
|
+
setShowExitNotice(true);
|
|
457
|
+
requestRender();
|
|
458
|
+
const timeoutId = setTimeout(() => {
|
|
459
|
+
setShowExitNotice(false);
|
|
460
|
+
setLastCtrlCTimeoutId(null);
|
|
461
|
+
requestRender();
|
|
462
|
+
}, 1000);
|
|
463
|
+
setLastCtrlCTimeoutId(timeoutId);
|
|
445
464
|
}
|
|
446
465
|
|
|
447
466
|
export function handleClipboardPaste(
|
|
@@ -30,6 +30,15 @@ export type PanelFocusRouteState = {
|
|
|
30
30
|
handlePathCompletion: () => boolean;
|
|
31
31
|
cyclePanelTab: (direction: 'next' | 'prev') => void;
|
|
32
32
|
onPanelInputConsumed?: (activePanel: import('../panels/types.ts').Panel | null, key: string) => void;
|
|
33
|
+
/**
|
|
34
|
+
* True when the tokens produced by the current feed() call carry more than
|
|
35
|
+
* one printable character total (a bracketed paste, or several 1-char text
|
|
36
|
+
* tokens from a fast-typed burst in one stdin chunk). A burst can never be
|
|
37
|
+
* a deliberate single-key panel hotkey, so it must not be exploded into
|
|
38
|
+
* per-char activePanel.handleInput(ch) calls — see the text-token branch
|
|
39
|
+
* below.
|
|
40
|
+
*/
|
|
41
|
+
isPrintableBurst: boolean;
|
|
33
42
|
};
|
|
34
43
|
|
|
35
44
|
export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputToken): {
|
|
@@ -113,6 +122,19 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
|
|
|
113
122
|
|
|
114
123
|
if (token.type === 'text' && token.value) {
|
|
115
124
|
const activePanel = state.panelManager.getActive();
|
|
125
|
+
// A burst (paste or several fast-typed chars in one stdin chunk) is never
|
|
126
|
+
// a deliberate single-key panel hotkey. Eating it silently as per-char
|
|
127
|
+
// hotkeys leaves the user with no echo and no error, looking exactly
|
|
128
|
+
// like dead keystrokes. Unless the active panel has its own text-capture
|
|
129
|
+
// buffer open (a `/`-search or draft-answer field that deliberately
|
|
130
|
+
// wants every character, burst or not — see isCapturingTextBurst), unfocus
|
|
131
|
+
// the panel and let the token fall through to the prompt route instead,
|
|
132
|
+
// same as it would from an unfocused panel.
|
|
133
|
+
if (state.isPrintableBurst && !activePanel?.isCapturingTextBurst?.()) {
|
|
134
|
+
panelFocused = false;
|
|
135
|
+
state.requestRender();
|
|
136
|
+
return { handled: false, panelFocused };
|
|
137
|
+
}
|
|
116
138
|
if (activePanel?.handleInput) {
|
|
117
139
|
for (const ch of token.value) {
|
|
118
140
|
activePanel.handleInput(ch);
|
|
@@ -229,7 +251,15 @@ export function handlePromptTextToken(state: TextRouteState, token: InputToken):
|
|
|
229
251
|
}
|
|
230
252
|
}
|
|
231
253
|
|
|
232
|
-
if (prompt === '/'
|
|
254
|
+
if (prompt === '/') {
|
|
255
|
+
// Arm commandMode as soon as the prompt becomes a bare '/', regardless of
|
|
256
|
+
// whether commandRegistry has been (re)attached yet — this is a one-shot
|
|
257
|
+
// transition (the only place commandMode ever becomes true), and gating
|
|
258
|
+
// it on commandRegistry meant a transient null during a modal/overlay
|
|
259
|
+
// handoff would permanently miss the window: every following keystroke
|
|
260
|
+
// is then processed as plain chat text with no way to recover. Dispatch
|
|
261
|
+
// (handleCommandModeToken, and the enter-key fallback below) still checks
|
|
262
|
+
// commandRegistry itself before actually running anything.
|
|
233
263
|
commandMode = true;
|
|
234
264
|
state.modalOpened('command');
|
|
235
265
|
state.autocomplete?.update('');
|
|
@@ -257,6 +287,14 @@ export type KeyRouteState = {
|
|
|
257
287
|
indicatorFocused: boolean;
|
|
258
288
|
conversationManager: ConversationManager | null;
|
|
259
289
|
commandContext: CommandContext | undefined;
|
|
290
|
+
/**
|
|
291
|
+
* Optional: only used by the enter-key desync safety net below (a stray
|
|
292
|
+
* slash-prefixed submission with commandMode somehow still false). When
|
|
293
|
+
* absent, that fallback simply doesn't trigger — the primary fix (arming
|
|
294
|
+
* commandMode on the '/' keystroke regardless of registry attachment, in
|
|
295
|
+
* handlePromptTextToken above) is what actually prevents the desync.
|
|
296
|
+
*/
|
|
297
|
+
commandRegistry?: CommandRegistry | null;
|
|
260
298
|
autocomplete: AutocompleteEngine | null;
|
|
261
299
|
blockActionsMenu: { open: (block: BlockMeta) => void };
|
|
262
300
|
processModal: { open: () => void };
|
|
@@ -351,6 +389,30 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
|
|
|
351
389
|
return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
|
|
352
390
|
}
|
|
353
391
|
if (text) {
|
|
392
|
+
// Safety net for a desynced commandMode: text is command-shaped (starts
|
|
393
|
+
// with '/') but commandMode never armed — e.g. the '/' keystroke landed
|
|
394
|
+
// during a modal/overlay handoff window. handleCommandModeToken (the
|
|
395
|
+
// normal dispatch path for '/name ...') never runs in this state since
|
|
396
|
+
// it early-returns when commandMode is false, so without this the text
|
|
397
|
+
// would fall straight through to submitInput as ordinary chat. Re-derive
|
|
398
|
+
// intent from the literal text instead of trusting commandMode alone.
|
|
399
|
+
if (!commandMode && text.startsWith('/') && state.commandRegistry && state.commandContext?.executeCommand) {
|
|
400
|
+
const parts = text.slice(1).trim().split(/\s+/);
|
|
401
|
+
const name = parts[0];
|
|
402
|
+
const args = parts.slice(1);
|
|
403
|
+
prompt = '';
|
|
404
|
+
cursorPos = 0;
|
|
405
|
+
if (name) {
|
|
406
|
+
const executeCommand = state.commandContext.executeCommand;
|
|
407
|
+
void executeCommand(name, args).then((handled) => {
|
|
408
|
+
if (!handled) {
|
|
409
|
+
state.commandContext?.submitInput?.(text);
|
|
410
|
+
}
|
|
411
|
+
state.requestRender();
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
|
|
415
|
+
}
|
|
354
416
|
const expanded = state.expandPrompt(text);
|
|
355
417
|
const historyRecallText = typeof expanded === 'string'
|
|
356
418
|
? expanded
|
|
@@ -180,6 +180,16 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
|
|
|
180
180
|
const lineCount = history.getLineCount();
|
|
181
181
|
const keybindings = context.keybindingsManager;
|
|
182
182
|
|
|
183
|
+
// A single stdin chunk can tokenize into several 'text' tokens (fast-typed
|
|
184
|
+
// burst) or one 'text' token with a multi-char value (bracketed paste).
|
|
185
|
+
// Computed once per feed() call, not per token, so per-token cost stays
|
|
186
|
+
// the same O(1) check it always was.
|
|
187
|
+
let totalPrintableChars = 0;
|
|
188
|
+
for (const t of tokens) {
|
|
189
|
+
if (t.type === 'text') totalPrintableChars += t.value.length;
|
|
190
|
+
}
|
|
191
|
+
const isPrintableBurst = totalPrintableChars > 1;
|
|
192
|
+
|
|
183
193
|
for (const token of tokens) {
|
|
184
194
|
if (token.type === 'key' && context.keybindingsManager.matches('clear-cancel', token)) {
|
|
185
195
|
context.handleCtrlC();
|
|
@@ -320,6 +330,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
|
|
|
320
330
|
panelManager: context.panelManager,
|
|
321
331
|
keybindingsManager: context.keybindingsManager,
|
|
322
332
|
onPanelInputConsumed: context.onPanelInputConsumed,
|
|
333
|
+
isPrintableBurst,
|
|
323
334
|
}, token);
|
|
324
335
|
context.panelFocused = panelRoute.panelFocused;
|
|
325
336
|
if (panelRoute.handled) {
|
|
@@ -408,6 +419,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
|
|
|
408
419
|
indicatorFocused: context.indicatorFocused,
|
|
409
420
|
conversationManager: context.conversationManager,
|
|
410
421
|
commandContext: context.commandContext,
|
|
422
|
+
commandRegistry: context.commandRegistry,
|
|
411
423
|
autocomplete: context.autocomplete,
|
|
412
424
|
blockActionsMenu: { open: (block: BlockMeta) => context.blockActionsMenu.open(block) },
|
|
413
425
|
processModal: context.processModal,
|
|
@@ -193,6 +193,8 @@ export function handleCtrlCForHandler(handler: InputHandler): void {
|
|
|
193
193
|
handler.lastCtrlCTime,
|
|
194
194
|
(value) => { handler.lastCtrlCTime = value; },
|
|
195
195
|
(value) => { handler.showExitNotice = value; },
|
|
196
|
+
handler.lastCtrlCTimeoutId,
|
|
197
|
+
(value) => { handler.lastCtrlCTimeoutId = value; },
|
|
196
198
|
);
|
|
197
199
|
}
|
|
198
200
|
|
|
@@ -88,6 +88,7 @@ export interface InputHandlerLike {
|
|
|
88
88
|
lastCopyTime: number;
|
|
89
89
|
lastBlockCopyTime: number;
|
|
90
90
|
lastCtrlCTime: number;
|
|
91
|
+
lastCtrlCTimeoutId: ReturnType<typeof setTimeout> | null;
|
|
91
92
|
|
|
92
93
|
// ── Modal state ───────────────────────────────────────────────────────────
|
|
93
94
|
commandMode: boolean;
|
package/src/input/handler.ts
CHANGED
|
@@ -172,6 +172,10 @@ export class InputHandler implements InputHandlerLike {
|
|
|
172
172
|
public pasteRegistry = new Map<string, string>();
|
|
173
173
|
public nextPasteId = 1;
|
|
174
174
|
public lastCtrlCTime = 0;
|
|
175
|
+
/** Pending "hide the exit notice" timer from the last empty-prompt Ctrl+C
|
|
176
|
+
* press, if any — cleared before every subsequent press decides its own
|
|
177
|
+
* outcome (see handleCtrlC in handler-content-actions.ts). */
|
|
178
|
+
public lastCtrlCTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
175
179
|
/** Long-lived feed context — reused across every feed() call to avoid per-keystroke allocation. */
|
|
176
180
|
public feedContext!: import('./handler-feed.ts').InputFeedContext;
|
|
177
181
|
public commandRegistry: CommandRegistry | null = null;
|
|
@@ -351,15 +351,28 @@ export class InputHistory {
|
|
|
351
351
|
return redactedText;
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Entries persisted before CR normalization was deployed (registerPaste now
|
|
356
|
+
* converts the \r line separators terminals send in bracketed pastes to \n)
|
|
357
|
+
* may still carry literal \r bytes; launder them on load so history recall
|
|
358
|
+
* cannot reintroduce \r into the composer.
|
|
359
|
+
*/
|
|
360
|
+
private launderLineBreaks(text: string): string {
|
|
361
|
+
return text.replace(/\r\n?/g, '\n');
|
|
362
|
+
}
|
|
363
|
+
|
|
354
364
|
private normalizeStoredEntry(entry: unknown): StoredInputHistoryEntry | null {
|
|
355
|
-
if (typeof entry === 'string') return entry;
|
|
365
|
+
if (typeof entry === 'string') return this.launderLineBreaks(entry);
|
|
356
366
|
if (!entry || typeof entry !== 'object') return null;
|
|
357
367
|
const record = entry as Record<string, unknown>;
|
|
358
368
|
if (typeof record.text !== 'string') return null;
|
|
359
|
-
const text = record.text.trim();
|
|
369
|
+
const text = this.launderLineBreaks(record.text).trim();
|
|
360
370
|
if (!text) return null;
|
|
361
|
-
|
|
362
|
-
|
|
371
|
+
const recallText = typeof record.recallText === 'string'
|
|
372
|
+
? this.launderLineBreaks(record.recallText).trim()
|
|
373
|
+
: '';
|
|
374
|
+
if (recallText && recallText !== text) {
|
|
375
|
+
return { text, recallText };
|
|
363
376
|
}
|
|
364
377
|
return text;
|
|
365
378
|
}
|
package/src/main.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { GitStatusProvider } from './renderer/git-status.ts';
|
|
|
24
24
|
import type { GitHeaderInfo } from './renderer/git-status.ts';
|
|
25
25
|
import { createShellLayout } from './renderer/layout-engine.ts';
|
|
26
26
|
import { buildShellFooter, estimateShellFooterHeight } from './renderer/shell-surface.ts';
|
|
27
|
+
import { computePromptContentWidth } from './renderer/prompt-content-width.ts';
|
|
27
28
|
import { buildConversationViewport } from './renderer/conversation-layout.ts';
|
|
28
29
|
import { applyConversationOverlays } from './renderer/conversation-overlays.ts';
|
|
29
30
|
import { buildPanelCompositeData } from './renderer/panel-composite.ts';
|
|
@@ -53,6 +54,7 @@ import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
|
|
|
53
54
|
import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
|
|
54
55
|
import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
|
|
55
56
|
import { installProcessLifecycle } from './runtime/process-lifecycle.ts';
|
|
57
|
+
import { createRenderScheduler } from './runtime/render-scheduler.ts';
|
|
56
58
|
import { buildCommandArgsHint } from './input/command-args-hint.ts';
|
|
57
59
|
import { summarizeRunningAgents } from './renderer/process-summary.ts';
|
|
58
60
|
import { formatUserFacingErrorLine } from './core/format-user-error.ts';
|
|
@@ -128,13 +130,9 @@ async function main() {
|
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
// TUI default: show token speed ON
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
// user has explicitly set this key to false in EITHER their global or project
|
|
135
|
-
// persisted config, their value is respected and the default is NOT applied.
|
|
136
|
-
// Only when the key is absent from both files (e.g. a new install) does the
|
|
137
|
-
// TUI default of true take effect in-memory — no disk write occurs either way.
|
|
133
|
+
// TUI default: show token speed ON (SDK schema default is false). applyRuntimeConfigDefault
|
|
134
|
+
// reads the global + project settings files and only applies the default in-memory when the
|
|
135
|
+
// key is absent from both (e.g. a new install); an explicit user value is respected. No disk write.
|
|
138
136
|
applyRuntimeConfigDefault(configManager, 'display.showTokenSpeed', true);
|
|
139
137
|
|
|
140
138
|
const panelManager = ctx.services.panelManager;
|
|
@@ -178,21 +176,21 @@ async function main() {
|
|
|
178
176
|
ttftRecorded: false,
|
|
179
177
|
activeToolStartedAtMs: undefined,
|
|
180
178
|
activeToolName: undefined,
|
|
179
|
+
lastDeltaAtMs: undefined, stallEpisode: 0,
|
|
180
|
+
reconnectAttempt: undefined, reconnectMaxAttempts: undefined,
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
-
const getPromptContentWidth = () =>
|
|
184
|
-
const w = stdout.columns || 80;
|
|
185
|
-
const boxMargin = 2;
|
|
186
|
-
const boxWidth = w - (boxMargin * 2);
|
|
187
|
-
return boxWidth - 4 - 3; // minus padding (4) minus prefix width (3: ' > ')
|
|
188
|
-
};
|
|
183
|
+
const getPromptContentWidth = () => computePromptContentWidth(stdout.columns);
|
|
189
184
|
|
|
190
185
|
const getViewportHeight = (): number => {
|
|
191
186
|
if (input.onboardingWizard.active) return stdout.rows || 24;
|
|
192
187
|
const promptLines: number = input.getVisiblePromptLineCount(getPromptContentWidth());
|
|
193
188
|
const currentModel = providerRegistry.getCurrentModel();
|
|
194
189
|
const contextWindow = providerRegistry.getContextWindowForModel(currentModel);
|
|
195
|
-
|
|
190
|
+
const rows = stdout.rows || 24;
|
|
191
|
+
// Compact threshold must match buildShellFooter's `compact: height < 30` posture below,
|
|
192
|
+
// else estimateShellFooterHeight's cached-height fast path answers with the wrong mode.
|
|
193
|
+
return rows - 2 - estimateShellFooterHeight(promptLines, contextWindow, rows < 30);
|
|
196
194
|
};
|
|
197
195
|
|
|
198
196
|
const scroll = (delta: number) => {
|
|
@@ -223,7 +221,7 @@ async function main() {
|
|
|
223
221
|
noAltScreen: cli.flags.noAltScreen,
|
|
224
222
|
ansi: { CLEAR_SCREEN, ALT_SCREEN_EXIT, PASTE_DISABLE, KEYBOARD_EXT_DISABLE, MOUSE_DISABLE, CURSOR_SHOW },
|
|
225
223
|
getInput: () => input,
|
|
226
|
-
render: () =>
|
|
224
|
+
render: () => renderScheduler.flushNow(), // resize: synchronous immediate path
|
|
227
225
|
getPromptContentWidth,
|
|
228
226
|
getTerminalOutputGuard: () => terminalOutputGuard,
|
|
229
227
|
buildSessionContinuityHints,
|
|
@@ -346,6 +344,7 @@ async function main() {
|
|
|
346
344
|
allowTerminalWrite(() => stdout.write(CLEAR_SCREEN));
|
|
347
345
|
render();
|
|
348
346
|
};
|
|
347
|
+
commandContext.requestFullRepaint = () => { compositor.resetDiff(); render(); };
|
|
349
348
|
permissionPromptRef.requestPermission = (request) =>
|
|
350
349
|
new Promise((resolve) => {
|
|
351
350
|
pendingPermission = {
|
|
@@ -432,7 +431,7 @@ async function main() {
|
|
|
432
431
|
lastSessionId: readLastSessionPointer({ workingDirectory: workingDir, homeDirectory, surfaceRoot: 'tui' }) ?? undefined,
|
|
433
432
|
};
|
|
434
433
|
|
|
435
|
-
const
|
|
434
|
+
const renderNow = () => {
|
|
436
435
|
const width = stdout.columns || 80;
|
|
437
436
|
const height = stdout.rows || 24;
|
|
438
437
|
|
|
@@ -512,6 +511,8 @@ async function main() {
|
|
|
512
511
|
hitlMode: modeManager.getHITLMode(),
|
|
513
512
|
runningAgentCount,
|
|
514
513
|
runningProcessCount,
|
|
514
|
+
// Composer must not read as focused while the panel/process indicator owns keyboard focus.
|
|
515
|
+
promptFocused: !input.panelFocused && !input.indicatorFocused,
|
|
515
516
|
indicatorFocused: input.indicatorFocused,
|
|
516
517
|
runningAgentProgress: runningAgentSummary.progress,
|
|
517
518
|
composerMode: composerState.modeLabel,
|
|
@@ -582,6 +583,8 @@ async function main() {
|
|
|
582
583
|
const partialToolPreview = showPreview ? sessionSnapshot.streamToolPreview : undefined;
|
|
583
584
|
// Elapsed from turn start (stream or tool execution), used for the thinking indicator timer.
|
|
584
585
|
const turnElapsedMs = streamMetrics.startTime > 0 ? Date.now() - streamMetrics.startTime : undefined;
|
|
586
|
+
// Suppressed while a tool executes — its ticking timer is the honest indicator then.
|
|
587
|
+
const stallInfo = UIFactory.computeRenderStallInfo(streamMetrics, Date.now());
|
|
585
588
|
const thinking = UIFactory.createThinkingFragment(
|
|
586
589
|
conversationWidth,
|
|
587
590
|
orchestrator.getSpinner(),
|
|
@@ -592,6 +595,7 @@ async function main() {
|
|
|
592
595
|
orchestrator.streamingOutputTokens > 0 ? orchestrator.streamingOutputTokens : undefined,
|
|
593
596
|
turnElapsedMs,
|
|
594
597
|
streamMetrics.ttftMs,
|
|
598
|
+
stallInfo,
|
|
595
599
|
);
|
|
596
600
|
viewport.push(...thinking);
|
|
597
601
|
// Live tool timer: render the currently executing tool row with ticking elapsed.
|
|
@@ -648,9 +652,11 @@ async function main() {
|
|
|
648
652
|
panelWidth: panelComposite.panelWidth,
|
|
649
653
|
});
|
|
650
654
|
};
|
|
655
|
+
const renderScheduler = createRenderScheduler(renderNow); // WO-208 same-tick coalescer
|
|
656
|
+
const render = (): void => renderScheduler.schedule();
|
|
651
657
|
const terminalOutputGuard = installTuiTerminalOutputGuard({ stdout, stderr: process.stderr, notify: (message) => { systemMessageRouter.low(message); render(); } });
|
|
652
658
|
|
|
653
|
-
setRenderRequest(
|
|
659
|
+
setRenderRequest(renderNow); // bootstrap's 16ms coalescer calls the composite directly
|
|
654
660
|
orchestratorRefs.requestRender = render;
|
|
655
661
|
commandContext.renderRequest = render;
|
|
656
662
|
wireShellUiOpeners({
|
|
@@ -694,9 +700,8 @@ async function main() {
|
|
|
694
700
|
|
|
695
701
|
// Stable turn context for failover retry — set in submitInput, read by retryTurn.
|
|
696
702
|
let retryCtx: { count: number; text: string; content?: ContentPart[]; opts?: Parameters<typeof orchestrator.handleUserInput>[2] } | null = null;
|
|
697
|
-
// One-key retry affordance
|
|
698
|
-
//
|
|
699
|
-
// picker. Any other character clears the affordance and routes normally.
|
|
703
|
+
// One-key retry affordance, active right after a user-visible TURN_ERROR: 'r' re-submits on the
|
|
704
|
+
// current provider, 'm' opens the model picker, any other character clears it and routes normally.
|
|
700
705
|
let errorAffordanceActive = false;
|
|
701
706
|
const retryTurn = (): void => {
|
|
702
707
|
if (!retryCtx) return;
|
|
@@ -720,9 +725,8 @@ async function main() {
|
|
|
720
725
|
}
|
|
721
726
|
});
|
|
722
727
|
|
|
723
|
-
// Register terminal-restoring crash/termination handlers BEFORE entering raw mode so a
|
|
724
|
-
//
|
|
725
|
-
// 'exit' listener is the final safety net for any process.exit path.
|
|
728
|
+
// Register terminal-restoring crash/termination handlers BEFORE entering raw mode so a throw
|
|
729
|
+
// during setup or the initial render still restores the terminal; 'exit' is the final safety net.
|
|
726
730
|
process.on('uncaughtException', uncaughtExceptionHandler);
|
|
727
731
|
process.on('SIGTERM', terminationSignalHandler);
|
|
728
732
|
process.on('SIGHUP', terminationSignalHandler);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatDuration } from '../utils/format-duration.ts';
|
|
2
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
2
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
3
3
|
import type { AgentEvent } from '@/runtime/index.ts';
|
|
4
4
|
import type { UiEventFeed } from '../runtime/ui-events.ts';
|
|
5
5
|
|
|
@@ -171,6 +171,8 @@ export function formatAgentCost(usd: number): string {
|
|
|
171
171
|
export interface AgentUsageSummary {
|
|
172
172
|
readonly tokens: number;
|
|
173
173
|
readonly cost: number;
|
|
174
|
+
/** False when the model never resolved to a real price — `cost` is a zero placeholder, not a real reading. */
|
|
175
|
+
readonly priced: boolean;
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
interface AgentUsageLike {
|
|
@@ -183,22 +185,43 @@ interface AgentUsageLike {
|
|
|
183
185
|
readonly model?: string | null;
|
|
184
186
|
}
|
|
185
187
|
|
|
188
|
+
/**
|
|
189
|
+
* True when a usage object carries actual reported token data rather than
|
|
190
|
+
* being present-but-empty. W0.9: the SDK's AgentRecord.usage is initialised
|
|
191
|
+
* to an all-zero object at agent spawn (platform/tools/agent/manager.ts) and
|
|
192
|
+
* — in the currently pinned SDK version — is never updated past that, so a
|
|
193
|
+
* plain truthiness check on `rec.usage` treats every agent, including ones
|
|
194
|
+
* that did real work, as "has data" and renders a fabricated $0.00/0-token
|
|
195
|
+
* reading. Guarding on real (nonzero) counts instead keeps today's "n/a"
|
|
196
|
+
* honest and lights up automatically once a future SDK actually populates
|
|
197
|
+
* usage — no further TUI change needed.
|
|
198
|
+
*/
|
|
199
|
+
export function hasReportedUsage(
|
|
200
|
+
usage: AgentUsageLike['usage'],
|
|
201
|
+
): usage is NonNullable<AgentUsageLike['usage']> {
|
|
202
|
+
if (!usage) return false;
|
|
203
|
+
return usage.inputTokens > 0 || usage.outputTokens > 0
|
|
204
|
+
|| (usage.cacheReadTokens ?? 0) > 0 || (usage.cacheWriteTokens ?? 0) > 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
186
207
|
/**
|
|
187
208
|
* Total token count + cost for an agent record's usage, or null when no usage
|
|
188
209
|
* data has landed yet (honest-UX: never fabricate a $0.00/0-token reading for
|
|
189
210
|
* an agent that simply hasn't reported usage).
|
|
190
211
|
*/
|
|
191
212
|
export function summarizeAgentUsage(rec: AgentUsageLike): AgentUsageSummary | null {
|
|
192
|
-
if (!rec.usage) return null;
|
|
193
|
-
const
|
|
213
|
+
if (!hasReportedUsage(rec.usage)) return null;
|
|
214
|
+
const usage = rec.usage;
|
|
215
|
+
const model = rec.model ?? 'unknown';
|
|
216
|
+
const inputTokens = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
|
|
194
217
|
const cost = calcSessionCost(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
218
|
+
usage.inputTokens,
|
|
219
|
+
usage.outputTokens,
|
|
220
|
+
usage.cacheReadTokens ?? 0,
|
|
221
|
+
usage.cacheWriteTokens ?? 0,
|
|
222
|
+
model,
|
|
200
223
|
);
|
|
201
|
-
return { tokens: inputTokens +
|
|
224
|
+
return { tokens: inputTokens + usage.outputTokens, cost, priced: isModelPriced(model) };
|
|
202
225
|
}
|
|
203
226
|
|
|
204
227
|
// ---------------------------------------------------------------------------
|
|
@@ -226,7 +249,7 @@ export function buildWrfcCostSegments(
|
|
|
226
249
|
segments.push([segments.length > 0 ? ' Tokens ' : ' Tokens ', palette.label]);
|
|
227
250
|
segments.push([formatTokens(usage.tokens), palette.info]);
|
|
228
251
|
segments.push([' Cost ', palette.label]);
|
|
229
|
-
segments.push([formatAgentCost(usage.cost), palette.info]);
|
|
252
|
+
segments.push([usage.priced ? formatAgentCost(usage.cost) : 'unpriced', palette.info]);
|
|
230
253
|
}
|
|
231
254
|
return segments.length > 0 ? segments : null;
|
|
232
255
|
}
|
package/src/panels/base-panel.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Panel, PanelCategory } from './types.ts';
|
|
|
3
3
|
import type { ComponentResourceContract, ComponentHealthState } from '../runtime/perf/panel-contracts.ts';
|
|
4
4
|
import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
|
|
5
5
|
import { UIFactory } from '../renderer/ui-factory.ts';
|
|
6
|
-
import { SPINNER_FRAMES } from '../renderer/
|
|
6
|
+
import { SPINNER_FRAMES } from '../renderer/ui-primitives.ts';
|
|
7
7
|
import { fitDisplay } from '../utils/terminal-width.ts';
|
|
8
8
|
|
|
9
9
|
/** Canonical error-surface foreground (bad/red), kept out of the render body. */
|
|
@@ -46,7 +46,7 @@ export function registerDevelopmentPanels(manager: PanelManager, deps: ResolvedB
|
|
|
46
46
|
icon: 'D',
|
|
47
47
|
category: 'development',
|
|
48
48
|
description: 'Unified diff view of agent file changes',
|
|
49
|
-
factory: () => new DiffPanel(requireUiServices(deps).environment.workingDirectory),
|
|
49
|
+
factory: () => new DiffPanel(requireUiServices(deps).environment.workingDirectory, deps.requestRender),
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
// WO-110: 'inspector' registration moved to builtin/agent.ts (category
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
|
|
21
21
|
import type { AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
|
|
22
22
|
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
23
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
23
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
24
24
|
import {
|
|
25
25
|
AGENT_TERMINAL_STATUSES,
|
|
26
26
|
AGENT_STALL_THRESHOLD_MS,
|
|
27
27
|
countStalledAgents,
|
|
28
|
+
hasReportedUsage,
|
|
28
29
|
} from './agent-inspector-shared.ts';
|
|
29
30
|
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
@@ -114,24 +115,28 @@ export function buildCockpitRosterSnapshot(
|
|
|
114
115
|
let outputTokens: number | null = null;
|
|
115
116
|
let cost: number | null = null;
|
|
116
117
|
|
|
117
|
-
if (rec.usage) {
|
|
118
|
+
if (hasReportedUsage(rec.usage)) {
|
|
118
119
|
hasUsage = true;
|
|
120
|
+
const usage = rec.usage;
|
|
119
121
|
const inp =
|
|
120
|
-
|
|
121
|
-
(
|
|
122
|
-
(
|
|
123
|
-
const out =
|
|
122
|
+
usage.inputTokens +
|
|
123
|
+
(usage.cacheReadTokens ?? 0) +
|
|
124
|
+
(usage.cacheWriteTokens ?? 0);
|
|
125
|
+
const out = usage.outputTokens;
|
|
124
126
|
const agentCost = calcSessionCost(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
usage.inputTokens,
|
|
128
|
+
usage.outputTokens,
|
|
129
|
+
usage.cacheReadTokens ?? 0,
|
|
130
|
+
usage.cacheWriteTokens ?? 0,
|
|
129
131
|
rec.model ?? 'unknown',
|
|
130
132
|
);
|
|
131
133
|
|
|
132
134
|
inputTokens = inp;
|
|
133
135
|
outputTokens = out;
|
|
134
|
-
|
|
136
|
+
// Usage exists but the model never resolved to a real price — reuse the
|
|
137
|
+
// existing "usage absent" null/n-a convention rather than showing a
|
|
138
|
+
// fabricated $0.00 (WO-315).
|
|
139
|
+
cost = isModelPriced(rec.model ?? 'unknown') ? agentCost : null;
|
|
135
140
|
|
|
136
141
|
totalInputTokens += inp;
|
|
137
142
|
totalOutputTokens += out;
|
|
@@ -22,13 +22,15 @@ import {
|
|
|
22
22
|
DEFAULT_PANEL_PALETTE,
|
|
23
23
|
type PanelWorkspaceSection,
|
|
24
24
|
} from './polish.ts';
|
|
25
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
25
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
26
26
|
import { abbreviateCount } from '../utils/format-number.ts';
|
|
27
27
|
import { isTextBackspace } from '../input/delete-key-policy.ts';
|
|
28
28
|
|
|
29
29
|
// Pricing lookups are provided by ../export/cost-utils.ts (single source of truth).
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/** unpriced=true renders an honest "unpriced" marker instead of a $0.00 that could be mistaken for a real zero cost. */
|
|
32
|
+
function formatCost(usd: number, unpriced = false): string {
|
|
33
|
+
if (unpriced) return 'unpriced';
|
|
32
34
|
if (usd === 0) return '$0.00';
|
|
33
35
|
if (usd < 0.0001) return '<$0.0001';
|
|
34
36
|
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
|
@@ -314,6 +316,16 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
314
316
|
// Input
|
|
315
317
|
// -------------------------------------------------------------------------
|
|
316
318
|
|
|
319
|
+
/**
|
|
320
|
+
* The budget-threshold entry field wants every character of a burst
|
|
321
|
+
* (paste, or fast typing landing in one input.feed() call) delivered one
|
|
322
|
+
* at a time, same as it always has — see the interface doc on
|
|
323
|
+
* `Panel.isCapturingTextBurst`.
|
|
324
|
+
*/
|
|
325
|
+
isCapturingTextBurst(): boolean {
|
|
326
|
+
return this.budgetEntry !== null;
|
|
327
|
+
}
|
|
328
|
+
|
|
317
329
|
handleInput(key: string): boolean {
|
|
318
330
|
if (this.budgetEntry !== null) return this.handleBudgetEntryInput(key);
|
|
319
331
|
|
|
@@ -395,7 +407,7 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
395
407
|
const sessionCost = calcSessionCost(this.sessionUsage.input, this.sessionUsage.output, this.sessionUsage.cacheRead, this.sessionUsage.cacheWrite, this.sessionModel);
|
|
396
408
|
const overBudget = this.budgetThreshold > 0 && sessionCost > this.budgetThreshold;
|
|
397
409
|
const sparkline = buildSparkline(this.costHistory);
|
|
398
|
-
const costStr = formatCost(sessionCost);
|
|
410
|
+
const costStr = formatCost(sessionCost, !isModelPriced(this.sessionModel));
|
|
399
411
|
const costFg = overBudget ? C.bad : C.cost;
|
|
400
412
|
const budgetStr = this.budgetThreshold > 0
|
|
401
413
|
? ` / ${formatCost(this.budgetThreshold)}`
|
|
@@ -451,10 +463,14 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
451
463
|
const planCost = agentList.reduce((sum, a) => sum + a.cost, 0);
|
|
452
464
|
const running = agentList.filter((a) => a.status === 'running').length;
|
|
453
465
|
const failed = agentList.filter((a) => a.status === 'failed').length;
|
|
466
|
+
// Plan total mixes the session model with every agent's model — flag it
|
|
467
|
+
// as unpriced (rather than showing a sum that silently omits unpriced
|
|
468
|
+
// agents' contribution) if any one of them has no real pricing.
|
|
469
|
+
const planHasUnpriced = !isModelPriced(this.sessionModel) || agentList.some((a) => !isModelPriced(a.model));
|
|
454
470
|
const agentRows: Line[] = [
|
|
455
471
|
buildStyledPanelLine(width, [
|
|
456
472
|
{ text: ' Plan total ', fg: C.label },
|
|
457
|
-
{ text: formatCost(planCost + sessionCost), fg: C.cost, bold: true },
|
|
473
|
+
{ text: formatCost(planCost + sessionCost, planHasUnpriced), fg: C.cost, bold: true },
|
|
458
474
|
{ text: ` ${agentList.length} agent${agentList.length === 1 ? '' : 's'}`, fg: C.dim },
|
|
459
475
|
...(running > 0 ? [{ text: ` ${running} running`, fg: C.running }] : []),
|
|
460
476
|
...(failed > 0 ? [{ text: ` ${failed} failed`, fg: C.bad }] : []),
|
|
@@ -483,7 +499,14 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
483
499
|
{ text: agent.model, fg: C.model },
|
|
484
500
|
{ text: agent.inputTokens > 0 ? formatTokens(agent.inputTokens) : '-', fg: C.dim },
|
|
485
501
|
{ text: agent.outputTokens > 0 ? formatTokens(agent.outputTokens) : '-', fg: C.dim },
|
|
486
|
-
{
|
|
502
|
+
{
|
|
503
|
+
// '-' means no usage reported yet; 'unpriced' means usage
|
|
504
|
+
// exists but the model has no real price; otherwise the cost.
|
|
505
|
+
text: agent.inputTokens > 0 && !isModelPriced(agent.model)
|
|
506
|
+
? 'unpriced'
|
|
507
|
+
: agent.cost > 0 ? formatCost(agent.cost) : '-',
|
|
508
|
+
fg: agent.cost > 0 ? C.cost : C.dim,
|
|
509
|
+
},
|
|
487
510
|
],
|
|
488
511
|
};
|
|
489
512
|
}),
|