@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +69 -98
  2. package/README.md +16 -7
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
  6. package/src/cli/entrypoint.ts +2 -0
  7. package/src/core/conversation-line-cache.ts +432 -0
  8. package/src/core/conversation-rendering.ts +11 -3
  9. package/src/core/conversation.ts +90 -4
  10. package/src/core/stream-event-wiring.ts +104 -2
  11. package/src/core/stream-stall-watchdog.ts +41 -17
  12. package/src/export/cost-utils.ts +90 -8
  13. package/src/input/command-registry.ts +16 -0
  14. package/src/input/commands/diff-runtime.ts +61 -30
  15. package/src/input/commands/session-content.ts +12 -0
  16. package/src/input/commands/session-workflow.ts +3 -0
  17. package/src/input/commands/share-runtime.ts +9 -2
  18. package/src/input/handler-content-actions.ts +30 -11
  19. package/src/input/handler-feed-routes.ts +63 -1
  20. package/src/input/handler-feed.ts +12 -0
  21. package/src/input/handler-interactions.ts +2 -0
  22. package/src/input/handler-types.ts +1 -0
  23. package/src/input/handler.ts +4 -0
  24. package/src/input/input-history.ts +17 -4
  25. package/src/main.ts +27 -23
  26. package/src/panels/agent-inspector-shared.ts +33 -10
  27. package/src/panels/base-panel.ts +1 -1
  28. package/src/panels/builtin/development.ts +1 -1
  29. package/src/panels/cockpit-read-model.ts +16 -11
  30. package/src/panels/cost-tracker-panel.ts +28 -5
  31. package/src/panels/debug-panel.ts +11 -5
  32. package/src/panels/diff-panel.ts +122 -22
  33. package/src/panels/docs-panel.ts +9 -0
  34. package/src/panels/file-explorer-panel.ts +9 -0
  35. package/src/panels/git-panel.ts +11 -11
  36. package/src/panels/knowledge-graph-panel.ts +9 -0
  37. package/src/panels/local-auth-panel.ts +10 -0
  38. package/src/panels/panel-list-panel.ts +9 -0
  39. package/src/panels/project-planning-panel.ts +10 -0
  40. package/src/panels/provider-health-tracker.ts +5 -1
  41. package/src/panels/provider-health-views.ts +8 -1
  42. package/src/panels/scrollable-list-panel.ts +9 -0
  43. package/src/panels/session-browser-panel.ts +9 -0
  44. package/src/panels/token-budget-panel.ts +5 -3
  45. package/src/panels/types.ts +13 -0
  46. package/src/panels/work-plan-panel.ts +10 -0
  47. package/src/renderer/agent-detail-modal.ts +42 -12
  48. package/src/renderer/code-block.ts +58 -28
  49. package/src/renderer/context-inspector.ts +3 -6
  50. package/src/renderer/conversation-surface.ts +1 -21
  51. package/src/renderer/diff-view.ts +6 -4
  52. package/src/renderer/fullscreen-primitives.ts +1 -1
  53. package/src/renderer/fullscreen-workspace.ts +2 -7
  54. package/src/renderer/hint-grammar.ts +1 -1
  55. package/src/renderer/history-search-overlay.ts +10 -16
  56. package/src/renderer/markdown.ts +9 -1
  57. package/src/renderer/model-picker-overlay.ts +8 -499
  58. package/src/renderer/model-workspace.ts +9 -24
  59. package/src/renderer/overlay-box.ts +7 -9
  60. package/src/renderer/process-indicator.ts +13 -25
  61. package/src/renderer/process-modal.ts +17 -2
  62. package/src/renderer/profile-picker-modal.ts +13 -14
  63. package/src/renderer/prompt-content-width.ts +16 -0
  64. package/src/renderer/selection-modal-overlay.ts +3 -1
  65. package/src/renderer/semantic-diff.ts +1 -1
  66. package/src/renderer/settings-modal-helpers.ts +10 -34
  67. package/src/renderer/shell-surface.ts +29 -9
  68. package/src/renderer/surface-layout.ts +0 -12
  69. package/src/renderer/term-caps.ts +1 -1
  70. package/src/renderer/tool-call.ts +6 -20
  71. package/src/renderer/ui-factory.ts +98 -14
  72. package/src/renderer/ui-primitives.ts +18 -1
  73. package/src/runtime/bootstrap-command-context.ts +3 -0
  74. package/src/runtime/bootstrap-command-parts.ts +3 -1
  75. package/src/runtime/bootstrap-core.ts +5 -0
  76. package/src/runtime/bootstrap-hook-bridge.ts +5 -0
  77. package/src/runtime/bootstrap-shell.ts +12 -1
  78. package/src/runtime/render-scheduler.ts +80 -0
  79. package/src/utils/splash-lines.ts +10 -2
  80. package/src/version.ts +1 -1
  81. package/src/renderer/file-tree.ts +0 -153
  82. package/src/renderer/progress.ts +0 -100
  83. package/src/renderer/status-token.ts +0 -67
@@ -121,10 +121,15 @@ export function registerPaste(
121
121
  }
122
122
  }
123
123
 
124
- const lines = content.split('\n');
125
- if (lines.length <= 8) return { marker: content, nextPasteId: state.nextPasteId, nextImageId: state.nextImageId };
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, content);
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
- } else {
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 === '/' && state.commandRegistry) {
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;
@@ -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
- if (typeof record.recallText === 'string' && record.recallText.trim() && record.recallText.trim() !== text) {
362
- return { text, recallText: record.recallText.trim() };
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. The SDK schema default is false;
132
- // applyRuntimeConfigDefault reads both the global settings file and the project
133
- // settings file from disk before deciding whether to apply the default. If the
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
- return (stdout.rows || 24) - 2 - estimateShellFooterHeight(promptLines, contextWindow);
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: () => 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 render = () => {
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(render);
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: active immediately after a user-visible TURN_ERROR.
698
- // While active, 'r' re-submits on the current provider, 'm' opens the model
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
- // throw during terminal setup or the initial render still restores the terminal; the
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 inputTokens = rec.usage.inputTokens + (rec.usage.cacheReadTokens ?? 0) + (rec.usage.cacheWriteTokens ?? 0);
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
- rec.usage.inputTokens,
196
- rec.usage.outputTokens,
197
- rec.usage.cacheReadTokens ?? 0,
198
- rec.usage.cacheWriteTokens ?? 0,
199
- rec.model ?? 'unknown',
218
+ usage.inputTokens,
219
+ usage.outputTokens,
220
+ usage.cacheReadTokens ?? 0,
221
+ usage.cacheWriteTokens ?? 0,
222
+ model,
200
223
  );
201
- return { tokens: inputTokens + rec.usage.outputTokens, cost };
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
  }
@@ -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/progress.ts';
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
- rec.usage.inputTokens +
121
- (rec.usage.cacheReadTokens ?? 0) +
122
- (rec.usage.cacheWriteTokens ?? 0);
123
- const out = rec.usage.outputTokens;
122
+ usage.inputTokens +
123
+ (usage.cacheReadTokens ?? 0) +
124
+ (usage.cacheWriteTokens ?? 0);
125
+ const out = usage.outputTokens;
124
126
  const agentCost = calcSessionCost(
125
- rec.usage.inputTokens,
126
- rec.usage.outputTokens,
127
- rec.usage.cacheReadTokens ?? 0,
128
- rec.usage.cacheWriteTokens ?? 0,
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
- cost = agentCost;
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
- function formatCost(usd: number): string {
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
- { text: agent.cost > 0 ? formatCost(agent.cost) : '-', fg: agent.cost > 0 ? C.cost : C.dim },
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
  }),