@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
@@ -22,7 +22,7 @@ import {
22
22
  import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
23
23
  import { abbreviateCount } from '../utils/format-number.ts';
24
24
  import { formatLatencyMs } from '../utils/format-duration.ts';
25
- import { calcSessionCost } from '../export/cost-utils.ts';
25
+ import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
26
26
 
27
27
  // ---------------------------------------------------------------------------
28
28
  // Types
@@ -97,7 +97,9 @@ function fmtAgo(ts: number): string {
97
97
  return `${Math.floor(sec / 3600)}h ago`;
98
98
  }
99
99
 
100
- function fmtUsd(value: number): string {
100
+ /** unpriced=true renders an honest "unpriced" marker instead of a $0 that could be mistaken for a real zero cost. */
101
+ function fmtUsd(value: number, unpriced = false): string {
102
+ if (unpriced) return 'unpriced';
101
103
  if (value <= 0) return '$0';
102
104
  return value >= 1 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`;
103
105
  }
@@ -394,7 +396,7 @@ export class DebugPanel extends ScrollableListPanel<ApiCallEntry> {
394
396
  { text: fmtTok(entry.inputTokens), fg: C.input },
395
397
  { text: fmtTok(entry.outputTokens), fg: C.output },
396
398
  { text: fmtMs(entry.latencyMs), fg: latColor(entry.latencyMs) },
397
- { text: fmtUsd(callCost(entry)), fg: C.value },
399
+ { text: fmtUsd(callCost(entry), !isModelPriced(entry.model)), fg: C.value },
398
400
  ],
399
401
  CALL_COLUMNS,
400
402
  { selected, selectedBg: C.selectBg },
@@ -498,7 +500,7 @@ export class DebugPanel extends ScrollableListPanel<ApiCallEntry> {
498
500
  { label: 'in', value: String(selected.inputTokens), valueColor: C.input },
499
501
  { label: 'out', value: String(selected.outputTokens), valueColor: C.output },
500
502
  { label: 'latency', value: fmtMs(selected.latencyMs), valueColor: latColor(selected.latencyMs) },
501
- { label: 'cost', value: fmtUsd(callCost(selected)), valueColor: C.value },
503
+ { label: 'cost', value: fmtUsd(callCost(selected), !isModelPriced(selected.model)), valueColor: C.value },
502
504
  ], C),
503
505
  ...(selected.errorMessage
504
506
  ? buildBodyText(width, selected.errorMessage, C, C.bad)
@@ -573,6 +575,10 @@ export class DebugPanel extends ScrollableListPanel<ApiCallEntry> {
573
575
  : 0;
574
576
  const sessionTokens = this._calls.reduce((s, c) => s + c.inputTokens + c.outputTokens, 0);
575
577
  const sessionCost = this._calls.reduce((s, c) => s + callCost(c), 0);
578
+ // Only flag the aggregate as unpriced when every call is — a partial mix
579
+ // still sums the priced calls honestly; only the fully-unpriced case
580
+ // would otherwise render as a misleading $0.
581
+ const sessionAllUnpriced = this._calls.length > 0 && this._calls.every((c) => !isModelPriced(c.model));
576
582
 
577
583
  const lines: Line[] = [
578
584
  buildStyledPanelLine(width, [
@@ -587,7 +593,7 @@ export class DebugPanel extends ScrollableListPanel<ApiCallEntry> {
587
593
  { text: ' Tokens ', fg: C.label },
588
594
  { text: fmtTok(sessionTokens), fg: C.value },
589
595
  { text: ' Cost ', fg: C.label },
590
- { text: fmtUsd(sessionCost), fg: C.value },
596
+ { text: fmtUsd(sessionCost, sessionAllUnpriced), fg: C.value },
591
597
  ]),
592
598
  ];
593
599
  // Live status: most recent call (latency / age) or wiring hint.
@@ -5,8 +5,9 @@
5
5
  import type { Line } from '../types/grid.ts';
6
6
  import { createStyledCell, createEmptyLine } from '../types/grid.ts';
7
7
  import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
8
+ import { GitService } from '@pellux/goodvibes-sdk/platform/git';
8
9
  import { BasePanel } from './base-panel.ts';
9
- import { UI_TONES } from '../renderer/ui-primitives.ts';
10
+ import { UI_TONES, DIFF_TONES } from '../renderer/ui-primitives.ts';
10
11
  import { FilePreviewPanel } from './file-preview-panel.ts';
11
12
  import type { PanelIntegrationContext } from './types.ts';
12
13
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
@@ -30,14 +31,20 @@ import {
30
31
  // DEFAULT_PANEL_PALETTE.headerBg (WO-002: one title band everywhere).
31
32
  // ---------------------------------------------------------------------------
32
33
 
33
- const HUNK_BLUE = '#88aaff';
34
+ // Hunk blue is the shared DIFF_TONES token (WO-204) — diff-view.ts (conversation)
35
+ // and git-panel.ts's inline diff converge onto this file's pre-existing value.
36
+ const HUNK_BLUE: string = DIFF_TONES.hunk;
34
37
  // Context rows and line-number gutter use the shared theme's muted/dim
35
38
  // foreground tones rather than dedicated gray hex literals.
36
39
  const CONTEXT_GRAY = UI_TONES.fg.muted;
37
40
  const FILENAME_WHITE = '#ffffff';
38
- const ADD_GREEN = '#00ff88';
41
+ // Add/del text colors are the shared DIFF_TONES tokens, whose values ARE this
42
+ // panel's shipped colors — this panel is the reference diff look (diff-view
43
+ // was unwired dead code until WO-204, so "majority of surfaces" was a mirage);
44
+ // the conversation surface converges onto these, not the reverse.
45
+ const ADD_GREEN: string = DIFF_TONES.add;
39
46
  const ADD_BG = '#001a0d';
40
- const DEL_RED = '#ff4444';
47
+ const DEL_RED: string = DIFF_TONES.del;
41
48
  const DEL_BG = '#1a0000';
42
49
  const HUNK_BG = '#0a0a1a';
43
50
  const MARKER_GRAY = '#aaaaaa';
@@ -160,20 +167,49 @@ function parseDiff(raw: string): ParsedLine[] {
160
167
  // Split a full `git diff` output into per-file entries
161
168
  // ---------------------------------------------------------------------------
162
169
 
170
+ /**
171
+ * Synthetic placeholder entries ('(error)', '(no changes)', '(no staged
172
+ * changes)', '(not a git repo)') are not real diffs — parenthesized so they
173
+ * can be styled distinctly in the tab/status bar instead of reading as a fake
174
+ * diff of a real file.
175
+ */
176
+ function isPlaceholderPath(filePath: string): boolean {
177
+ return filePath.startsWith('(') && filePath.endsWith(')');
178
+ }
179
+
180
+ /**
181
+ * Extract the file path from one `diff --git`-delimited chunk. Tries, in
182
+ * order: the standard two-sided header (`diff --git a/x b/y`, bare or
183
+ * quoted), the combined/merge-conflict header (`diff --cc <path>` /
184
+ * `diff --combined <path>`, which carries no a/b prefixes), then falls back
185
+ * to the `+++`/`---` file lines (present across every variant, uniformly) —
186
+ * skipping a `/dev/null` side (deleted/new file). 'unknown' is reserved for
187
+ * genuinely unparseable input.
188
+ */
189
+ function extractDiffFilePath(trimmed: string): string {
190
+ const quotedGit = trimmed.match(/^diff --git "a\/(.+)" "b\/(.+)"$/m);
191
+ if (quotedGit) return quotedGit[2]!;
192
+ const plainGit = trimmed.match(/^diff --git a\/.+? b\/(.+)$/m);
193
+ if (plainGit) return plainGit[1]!;
194
+ const combined = trimmed.match(/^diff --(?:cc|combined) "?([^"\n]+?)"?$/m);
195
+ if (combined) return combined[1]!;
196
+ const plus = trimmed.match(/^\+\+\+ (?:b\/)?"?([^"\n]+?)"?\s*$/m);
197
+ if (plus && plus[1] !== '/dev/null') return plus[1]!;
198
+ const minus = trimmed.match(/^--- (?:a\/)?"?([^"\n]+?)"?\s*$/m);
199
+ if (minus && minus[1] !== '/dev/null') return minus[1]!;
200
+ return 'unknown';
201
+ }
202
+
163
203
  function splitIntoDiffEntries(raw: string): DiffEntry[] {
164
204
  const entries: DiffEntry[] = [];
165
- // Split on "diff --git" lines
166
- const chunks = raw.split(/(?=^diff --git )/m);
205
+ // Split on "diff --git" / "diff --cc" / "diff --combined" lines
206
+ const chunks = raw.split(/(?=^diff --git |^diff --cc |^diff --combined )/m);
167
207
  for (const chunk of chunks) {
168
208
  const trimmed = chunk.trim();
169
209
  if (!trimmed) continue;
170
210
 
171
- // Extract file path from "diff --git a/foo b/foo"
172
- const match = trimmed.match(/^diff --git a\/.+? b\/(.+)$/m);
173
- const filePath = match ? match[1]! : 'unknown';
174
-
175
211
  entries.push({
176
- filePath,
212
+ filePath: extractDiffFilePath(trimmed),
177
213
  raw: chunk,
178
214
  lines: parseDiff(chunk),
179
215
  });
@@ -229,7 +265,15 @@ export class DiffPanel extends BasePanel {
229
265
  /** Set by the 'o' key; consumed by handlePanelIntegrationAction on the next dispatch. */
230
266
  private pendingOpenPreview = false;
231
267
 
232
- constructor(workingDirectory: string) {
268
+ /** One-line confirmation for the 'w'/'h'/'s' self-load hotkeys — otherwise a
269
+ * reload that produces identical content is visually indistinguishable
270
+ * from the keypress doing nothing at all. */
271
+ private hotkeyStatus: string | null = null;
272
+
273
+ constructor(
274
+ workingDirectory: string,
275
+ private readonly requestRender: () => void = () => {},
276
+ ) {
233
277
  super('diff', 'Diff', 'D', 'development');
234
278
  this.workingDirectory = workingDirectory;
235
279
  }
@@ -265,12 +309,33 @@ export class DiffPanel extends BasePanel {
265
309
  this.markDirty();
266
310
  }
267
311
 
312
+ /**
313
+ * Defensive gate mirroring GitPanel's own notGitRepo messaging — short-
314
+ * circuits before any of this panel's self-load git spawns run, instead of
315
+ * letting git fail per-subcommand with an inconsistent error shape.
316
+ */
317
+ private showNotGitRepo(): void {
318
+ this.showDiff('(not a git repo)', '@@ -0,0 +0,0 @@\n Not a git repository here.');
319
+ }
320
+
268
321
  /** Run `git diff` against specific files and populate entries. */
269
322
  async showFileDiffs(files: string[], ref?: string): Promise<void> {
323
+ if (!GitService.isGitRepo(this.workingDirectory)) {
324
+ this.showNotGitRepo();
325
+ return;
326
+ }
270
327
  const args = ['diff', ...(ref ? [ref] : []), '--', ...files];
271
- const proc = Bun.spawn(['git', ...args], { stdout: 'pipe', cwd: this.workingDirectory });
272
- const raw = await new Response(proc.stdout).text();
273
- await proc.exited;
328
+ const proc = Bun.spawn(['git', ...args], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
329
+ const [raw, errText] = await Promise.all([
330
+ new Response(proc.stdout).text(),
331
+ new Response(proc.stderr).text(),
332
+ ]);
333
+ const exitCode = await proc.exited;
334
+ if (exitCode !== 0) {
335
+ const errorText = errText.trim() || 'git diff failed';
336
+ this.showDiff('(error)', `--- error\n+++ error\n@@ -0,0 +1,1 @@\n+${errorText}`);
337
+ return;
338
+ }
274
339
  this.loadRawDiff(raw);
275
340
  this.enrichSemanticDiffs(files, ref ?? 'HEAD').catch((err) => { logger.debug('DiffPanel: semantic diff enrichment failed', { err }); });
276
341
  }
@@ -281,6 +346,10 @@ export class DiffPanel extends BasePanel {
281
346
  * enriches every loaded file with a semantic-diff summary in the background.
282
347
  */
283
348
  async showGitDiff(ref?: string): Promise<void> {
349
+ if (!GitService.isGitRepo(this.workingDirectory)) {
350
+ this.showNotGitRepo();
351
+ return;
352
+ }
284
353
  const args = ['diff', ...(ref ? [ref] : [])];
285
354
  const proc = Bun.spawn(['git', ...args], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
286
355
  const [raw, errText] = await Promise.all([
@@ -319,6 +388,10 @@ export class DiffPanel extends BasePanel {
319
388
  * `/diff staged` command handler.
320
389
  */
321
390
  async showStagedDiff(): Promise<void> {
391
+ if (!GitService.isGitRepo(this.workingDirectory)) {
392
+ this.showNotGitRepo();
393
+ return;
394
+ }
322
395
  const proc = Bun.spawn(['/bin/sh', '-c', 'git diff --cached'], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
323
396
  const [raw, errText] = await Promise.all([
324
397
  new Response(proc.stdout).text(),
@@ -347,7 +420,7 @@ export class DiffPanel extends BasePanel {
347
420
  if (files.length === 0) return;
348
421
  const { computeSemanticDiff, formatSemanticDiffSummary } = await import('../renderer/semantic-diff.ts');
349
422
  const { join, relative: pathRelative } = await import('path');
350
- const repoRootProc = Bun.spawn(['git', 'rev-parse', '--show-toplevel'], { stdout: 'pipe', cwd: this.workingDirectory });
423
+ const repoRootProc = Bun.spawn(['git', 'rev-parse', '--show-toplevel'], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
351
424
  await repoRootProc.exited;
352
425
  const repoRoot = (await new Response(repoRootProc.stdout).text()).trim() || this.workingDirectory;
353
426
  await Promise.allSettled(
@@ -412,6 +485,22 @@ export class DiffPanel extends BasePanel {
412
485
  // Input
413
486
  // -------------------------------------------------------------------------
414
487
 
488
+ /**
489
+ * Wraps a self-load hotkey ('w'/'h'/'s') with visible before/after status
490
+ * text in the status bar — otherwise a reload whose content is unchanged
491
+ * from what's already shown is indistinguishable from the keypress having
492
+ * done nothing at all (contrast with `/diff working`'s ctx.print() calls).
493
+ */
494
+ private runHotkeyReload(label: string, load: () => Promise<void>): void {
495
+ this.hotkeyStatus = `Loading ${label} diff…`;
496
+ this.markDirty();
497
+ void load().then(() => {
498
+ this.hotkeyStatus = `Reloaded ${label} diff.`;
499
+ this.markDirty();
500
+ this.requestRender();
501
+ });
502
+ }
503
+
415
504
  handleInput(key: string): boolean {
416
505
  switch (key) {
417
506
  case 'up': this.scrollUp(); return true;
@@ -427,9 +516,9 @@ export class DiffPanel extends BasePanel {
427
516
  case 'backtab': this.prevFile(); return true;
428
517
  case 'pageup': this.scrollPageUp(); return true;
429
518
  case 'pagedown': this.scrollPageDown(); return true;
430
- case 'w': void this.showGitDiff(); return true;
431
- case 'h': void this.showGitDiff('HEAD'); return true;
432
- case 's': void this.showStagedDiff(); return true;
519
+ case 'w': this.runHotkeyReload('working tree', () => this.showGitDiff()); return true;
520
+ case 'h': this.runHotkeyReload('HEAD', () => this.showGitDiff('HEAD')); return true;
521
+ case 's': this.runHotkeyReload('staged', () => this.showStagedDiff()); return true;
433
522
  case 'o': {
434
523
  if (!this.currentEntry()) return false;
435
524
  this.pendingOpenPreview = true;
@@ -617,11 +706,15 @@ export class DiffPanel extends BasePanel {
617
706
  for (let i = 0; i < this.entries.length; i++) {
618
707
  const entry = this.entries[i]!;
619
708
  const active = i === this.selectedFile;
709
+ const placeholder = isPlaceholderPath(entry.filePath);
620
710
  const stat = diffStat(entry);
621
- const fg = active ? COLOR.tabActive : COLOR.tabInactive;
711
+ // Placeholder entries ('(error)', '(no changes)', ...) get a distinct
712
+ // warn color regardless of active/inactive — they are not a real diff
713
+ // of a real file and must not read as one.
714
+ const fg = placeholder ? COLOR.warn : (active ? COLOR.tabActive : COLOR.tabInactive);
622
715
  const bg = active ? COLOR.tabActiveBg : COLOR.tabBg;
623
716
  // Active file gets a leading marker; every tab shows +adds/-dels at a glance.
624
- const marker = active ? '▸ ' : ' ';
717
+ const marker = active ? '▸ ' : (placeholder ? '⚠ ' : ' ');
625
718
  const label = `${marker}${basename(entry.filePath)} `;
626
719
  for (const ch of label) push(ch, fg, bg, active);
627
720
  if (stat.added > 0) for (const ch of `+${stat.added}`) push(ch, COLOR.addition, bg, active);
@@ -644,11 +737,15 @@ export class DiffPanel extends BasePanel {
644
737
  return buildStyledPanelLine(width, [{ text: ' No file', fg: COLOR.tabInactive, bg: COLOR.statusBar }], { fillBg: COLOR.statusBar });
645
738
  }
646
739
  const stat = diffStat(entry);
740
+ const placeholder = isPlaceholderPath(entry.filePath);
647
741
  // Keep the file path display-width-aware so a long/wide path can't overflow.
648
742
  const pathBudget = Math.max(8, Math.floor(width / 2));
649
743
  const fileInfo = truncateDisplay(entry.filePath, pathBudget);
650
744
  const segments: Array<{ text: string; fg: string; bg?: string; bold?: boolean }> = [
651
- { text: ` ${fileInfo} `, fg: COLOR.filename, bg: COLOR.statusBar, bold: true },
745
+ // Placeholder states ('(error)', '(no changes)', ...) are not a real
746
+ // diff of a real file — a distinct warn color keeps them from being
747
+ // mistaken for one.
748
+ { text: ` ${fileInfo} `, fg: placeholder ? COLOR.warn : COLOR.filename, bg: COLOR.statusBar, bold: true },
652
749
  { text: `[${this.selectedFile + 1}/${this.entries.length}]`, fg: COLOR.tabInactive, bg: COLOR.statusBar },
653
750
  { text: ' +', fg: COLOR.tabInactive, bg: COLOR.statusBar },
654
751
  { text: String(stat.added), fg: COLOR.addition, bg: COLOR.statusBar },
@@ -660,6 +757,9 @@ export class DiffPanel extends BasePanel {
660
757
  if (entry.semanticSummary) {
661
758
  segments.push({ text: ` ◈ ${entry.semanticSummary}`, fg: COLOR.hunk, bg: COLOR.statusBar });
662
759
  }
760
+ if (this.hotkeyStatus) {
761
+ segments.push({ text: ` ${this.hotkeyStatus}`, fg: COLOR.info, bg: COLOR.statusBar, bold: true });
762
+ }
663
763
  return buildStyledPanelLine(width, segments, { fillBg: COLOR.statusBar });
664
764
  }
665
765
 
@@ -122,6 +122,15 @@ export class DocsPanel extends BasePanel {
122
122
  return null;
123
123
  }
124
124
 
125
+ /**
126
+ * The `/`-to-search buffer wants every character of a burst (paste, or a
127
+ * fast-typed query landing in one input.feed() call), same as it always
128
+ * has — see the interface doc on `Panel.isCapturingTextBurst`.
129
+ */
130
+ isCapturingTextBurst(): boolean {
131
+ return this.searching;
132
+ }
133
+
125
134
  handleInput(key: string): boolean {
126
135
  const searchResult = this._handleSearchKey(key);
127
136
  if (searchResult !== null) return searchResult;
@@ -215,6 +215,15 @@ export class FileExplorerPanel extends BasePanel {
215
215
  return null;
216
216
  }
217
217
 
218
+ /**
219
+ * The `/`-to-search buffer wants every character of a burst (paste, or a
220
+ * fast-typed query landing in one input.feed() call), same as it always
221
+ * has — see the interface doc on `Panel.isCapturingTextBurst`.
222
+ */
223
+ isCapturingTextBurst(): boolean {
224
+ return this.searchMode;
225
+ }
226
+
218
227
  handleInput(key: string): boolean {
219
228
  const filterResult = this._handleFilterKey(key);
220
229
  if (filterResult !== null) return filterResult;
@@ -4,7 +4,7 @@ import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
4
4
  import { GitService } from '@pellux/goodvibes-sdk/platform/git';
5
5
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
6
6
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
7
- import { UI_TONES } from '../renderer/ui-primitives.ts';
7
+ import { UI_TONES, DIFF_TONES } from '../renderer/ui-primitives.ts';
8
8
  import {
9
9
  buildEmptyState,
10
10
  buildKeyboardHints,
@@ -77,7 +77,7 @@ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
77
77
  commitAuthor: '244',
78
78
  selected: '#1c1c1c',
79
79
  selectedFg: '#ffffff',
80
- diffMeta: '#5f87ff',
80
+ diffMeta: DIFF_TONES.hunk, // WO-204: shared diff-hunk token, was a local literal
81
81
  diffNeutral: '250',
82
82
  // Reuses the existing workflow accent token rather than adding a new hex
83
83
  // literal (architecture gate ratchets the raw-hex-literal count).
@@ -178,11 +178,9 @@ export class GitPanel extends BasePanel {
178
178
  const changed = this.getChangedFiles ? new Set(this.getChangedFiles()) : null;
179
179
  const markChanged = (path: string): boolean | undefined => (changed ? changed.has(path) : undefined);
180
180
 
181
- // Classify from the raw per-file porcelain columns (index/working_dir)
182
- // rather than simple-git's `modified`/`staged` convenience arrays: those
183
- // arrays both include a file that is staged with no further edits (index
184
- // 'M', working_dir ' '), which would otherwise show it as both staged
185
- // AND unstaged and make 's'/'u' land on the wrong (phantom) row.
181
+ // Classify from the raw per-file porcelain columns (index/working_dir), not simple-git's
182
+ // modified/staged arrays: those double-count a staged-with-no-further-edits file (index 'M',
183
+ // working_dir ' ') as both staged AND unstaged, landing 's'/'u' on a phantom row.
186
184
  const stagedFiles: GitFileEntry[] = [];
187
185
  const unstagedFiles: GitFileEntry[] = [];
188
186
  for (const f of statusResult.files) {
@@ -278,6 +276,11 @@ export class GitPanel extends BasePanel {
278
276
 
279
277
  // Input handling
280
278
 
279
+ /** Commit-message entry wants every char of a burst delivered one at a time — see Panel.isCapturingTextBurst. */
280
+ isCapturingTextBurst(): boolean {
281
+ return this.commitMessage !== null;
282
+ }
283
+
281
284
  handleInput(key: string): boolean {
282
285
  // Confirm (init repo / commit) takes priority over everything else.
283
286
  const confirmResult = handleConfirmInput(this.confirm, key);
@@ -728,10 +731,7 @@ export class GitPanel extends BasePanel {
728
731
  });
729
732
  }
730
733
 
731
- /**
732
- * Map an item index in `this.items` to the row index in the rendered row list.
733
- * Header items expand to 2 rows (branch + spacer).
734
- */
734
+ /** Map an item index in `this.items` to its rendered row index (header items expand to 2 rows: branch + spacer). */
735
735
  private getRowIndexForItem(itemIndex: number): number {
736
736
  let row = 0;
737
737
  for (let i = 0; i < itemIndex && i < this.items.length; i++) {
@@ -266,6 +266,15 @@ export class KnowledgeGraphPanel extends ScrollableListPanel<BrowseRow> {
266
266
  // Input
267
267
  // ---------------------------------------------------------------------------
268
268
 
269
+ /**
270
+ * The `/`-to-search buffer wants every character of a burst (paste, or a
271
+ * fast-typed query landing in one input.feed() call), same as it always
272
+ * has — see the interface doc on `Panel.isCapturingTextBurst`.
273
+ */
274
+ override isCapturingTextBurst(): boolean {
275
+ return this.searchFocused;
276
+ }
277
+
269
278
  handleInput(key: string): boolean {
270
279
  if (this.lastError !== null) this.clearError();
271
280
 
@@ -96,6 +96,16 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
96
96
  return this.maskedState !== null;
97
97
  }
98
98
 
99
+ /**
100
+ * Masked password/username entry wants every character of a burst (paste,
101
+ * or fast typing landing in one input.feed() call) delivered one at a
102
+ * time, same as it always has — see the interface doc on
103
+ * `Panel.isCapturingTextBurst`.
104
+ */
105
+ public override isCapturingTextBurst(): boolean {
106
+ return this.isMaskedEntryActive || this.usernameEntry !== null || super.isCapturingTextBurst();
107
+ }
108
+
99
109
  public override handleInput(key: KeyName): boolean {
100
110
  // Masked entry takes priority when active — it must capture every
101
111
  // keystroke (including letters that would otherwise be p/a/d/b actions)
@@ -224,6 +224,15 @@ export class PanelListPanel extends BasePanel {
224
224
  });
225
225
  }
226
226
 
227
+ /**
228
+ * The `/`-to-filter buffer wants every character of a burst (paste, or a
229
+ * fast-typed query landing in one input.feed() call), same as it always
230
+ * has — see the interface doc on `Panel.isCapturingTextBurst`.
231
+ */
232
+ public isCapturingTextBurst(): boolean {
233
+ return this._filterActive;
234
+ }
235
+
227
236
  public handleInput(key: string): boolean {
228
237
  const filterResult = this._handleFilterKey(key);
229
238
  if (filterResult !== null) return filterResult;
@@ -81,6 +81,16 @@ export class ProjectPlanningPanel extends BasePanel {
81
81
  this.refresh();
82
82
  }
83
83
 
84
+ /**
85
+ * The draft-answer text field wants every character of a burst (paste, or
86
+ * fast typing landing in one input.feed() call) delivered one at a time,
87
+ * same as it always has — see the interface doc on
88
+ * `Panel.isCapturingTextBurst`.
89
+ */
90
+ public isCapturingTextBurst(): boolean {
91
+ return this.getCurrentQuestion() !== null;
92
+ }
93
+
84
94
  public handleInput(key: string): boolean {
85
95
  if (this.lastError !== null) this.clearError();
86
96
 
@@ -5,7 +5,7 @@ import type {
5
5
  ProviderHealthRecord,
6
6
  ProviderStatus,
7
7
  } from '@/runtime/index.ts';
8
- import { calcSessionCost } from '../export/cost-utils.ts';
8
+ import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
9
9
 
10
10
  export type { ProviderStatus };
11
11
 
@@ -40,6 +40,8 @@ export interface ProviderHealth {
40
40
  totalTokens: number;
41
41
  /** Session USD cost accumulated per call via calcSessionCost. */
42
42
  totalCostUsd: number;
43
+ /** True when at least one call's model resolved to no real price (cost-utils isModelPriced === false) — totalCostUsd may understate the true cost. */
44
+ hasUnpricedModel: boolean;
43
45
  /** Ring buffer of recent request latencies in ms (most-recent last). */
44
46
  latencies: number[];
45
47
  }
@@ -237,6 +239,7 @@ export class ProviderHealthTracker {
237
239
  cacheWriteTokens: 0,
238
240
  totalTokens: 0,
239
241
  totalCostUsd: 0,
242
+ hasUnpricedModel: false,
240
243
  latencies: [],
241
244
  };
242
245
  this.records.set(name, record);
@@ -259,6 +262,7 @@ export class ProviderHealthTracker {
259
262
  const model = usage.model ?? record.lastModelId;
260
263
  if (model) {
261
264
  record.totalCostUsd += calcSessionCost(input, output, cacheRead, cacheWrite, model);
265
+ if (!isModelPriced(model)) record.hasUnpricedModel = true;
262
266
  }
263
267
  }
264
268
 
@@ -357,7 +357,14 @@ export function buildProviderRow(width: number, tones: ProviderConsoleTones, inp
357
357
  { text: errPct, fg: hasCalls && (entry?.errorRate ?? 0) > 0 ? tones.bad : tones.dim },
358
358
  { text: buildSparkline(entry?.timeline.points ?? []), fg: hasCalls ? latencyColor(tones, avgMs) : tones.dim },
359
359
  { text: health && health.totalTokens > 0 ? fmtTokens(health.totalTokens) : '-', fg: tones.dim },
360
- { text: health && health.totalCostUsd > 0 ? fmtUsd(health.totalCostUsd) : '-', fg: tones.value },
360
+ {
361
+ // Tokens flowed but the model never resolved to a real price — say so
362
+ // instead of collapsing into the same '-' shown for "no calls yet".
363
+ text: health && health.totalTokens > 0
364
+ ? (health.hasUnpricedModel ? 'unpriced' : fmtUsd(health.totalCostUsd))
365
+ : '-',
366
+ fg: health?.hasUnpricedModel ? tones.dim : tones.value,
367
+ },
361
368
  { text: account ? account.authFreshness : '-', fg: account ? freshnessColor(tones, account.authFreshness) : tones.dim },
362
369
  ],
363
370
  PROVIDER_TABLE_COLUMNS,
@@ -242,6 +242,15 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
242
242
  * Returns `true` if the key was consumed, `false` to let the panel manager try another
243
243
  * handler.
244
244
  */
245
+ /**
246
+ * The `/`-to-filter buffer wants every character of a burst (paste, or a
247
+ * fast-typed query landing in one input.feed() call), same as it always
248
+ * has — see the interface doc on `Panel.isCapturingTextBurst`.
249
+ */
250
+ isCapturingTextBurst(): boolean {
251
+ return this.filterEnabled && this.filterActive;
252
+ }
253
+
245
254
  handleInput(key: string): boolean {
246
255
  // I2: auto-clear transient errors on the next keystroke so stale errors don't linger.
247
256
  // Subclasses that override handleInput should call super.handleInput(key) OR manually
@@ -139,6 +139,15 @@ export class SessionBrowserPanel extends BasePanel {
139
139
  super.onDeactivate();
140
140
  }
141
141
 
142
+ /**
143
+ * The `/`-to-search buffer wants every character of a burst (paste, or a
144
+ * fast-typed query landing in one input.feed() call), same as it always
145
+ * has — see the interface doc on `Panel.isCapturingTextBurst`.
146
+ */
147
+ isCapturingTextBurst(): boolean {
148
+ return this.searching;
149
+ }
150
+
142
151
  handleInput(key: string): boolean {
143
152
  // Confirmation dialog — use shared handleConfirmInput for y/n/Esc UX
144
153
  const confirmResult = handleConfirmInput(this.confirm, key);
@@ -7,7 +7,7 @@ import type { TurnEvent } from '@/runtime/index.ts';
7
7
  import type { UiEventFeed } from '../runtime/ui-events.ts';
8
8
  import type { UiReadModel, UiSessionSnapshot } from '../runtime/ui-read-models.ts';
9
9
  import type { SessionMemoryQuery } from '../runtime/ui-service-queries.ts';
10
- import { calcSessionCost } from '../export/cost-utils.ts';
10
+ import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
11
11
  import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
12
12
  import type { PanelIntegrationContext } from './types.ts';
13
13
  import {
@@ -615,10 +615,12 @@ export class TokenBudgetPanel extends BasePanel {
615
615
  // Inline cost estimate — absorbed pricing wiring (cost-utils.ts), shown
616
616
  // whenever the active model id is known.
617
617
  if (this.getCurrentModelId) {
618
- const cost = calcSessionCost(u.input, u.output, u.cacheRead, u.cacheWrite, this.getCurrentModelId());
618
+ const modelId = this.getCurrentModelId();
619
+ const priced = isModelPriced(modelId);
620
+ const cost = calcSessionCost(u.input, u.output, u.cacheRead, u.cacheWrite, modelId);
619
621
  lines.push(buildStyledPanelLine(width, [
620
622
  { text: ' Cost: ', fg: C.label },
621
- { text: `$${cost.toFixed(4)}`, fg: C.value, bold: true },
623
+ { text: priced ? `$${cost.toFixed(4)}` : 'unpriced', fg: C.value, bold: true },
622
624
  ]));
623
625
  }
624
626
 
@@ -100,6 +100,19 @@ export interface Panel {
100
100
  // Input (optional)
101
101
  handleInput?(key: KeyName): boolean;
102
102
 
103
+ /**
104
+ * Optional: report `true` while this panel has an active inline text
105
+ * capture (e.g. a `/`-to-filter or search buffer) that deliberately wants
106
+ * every character of a burst — paste, or several keystrokes typed fast
107
+ * enough to land in one `input.feed()` call — delivered to `handleInput`
108
+ * one at a time, same as it always has. When this returns `false` or is
109
+ * not implemented, the input router treats a printable burst as never a
110
+ * deliberate single-key panel hotkey and routes it to the composer
111
+ * instead of exploding it into per-char `handleInput` calls (see
112
+ * `handlePanelFocusToken` in `src/input/handler-feed-routes.ts`).
113
+ */
114
+ isCapturingTextBurst?(): boolean;
115
+
103
116
  // Scroll input (optional)
104
117
  // Positive delta scrolls down; negative delta scrolls up.
105
118
  handleScroll?(deltaRows: number): boolean;
@@ -100,6 +100,16 @@ export class WorkPlanPanel extends ScrollableListPanel<WorkPlanItem> {
100
100
  });
101
101
  }
102
102
 
103
+ /**
104
+ * The add/edit draft form's title/owner/notes fields want every character
105
+ * of a burst (paste, or fast typing landing in one input.feed() call)
106
+ * delivered one at a time, same as it always has — see the interface doc
107
+ * on `Panel.isCapturingTextBurst`.
108
+ */
109
+ isCapturingTextBurst(): boolean {
110
+ return this.draftMode !== null;
111
+ }
112
+
103
113
  handleInput(key: string): boolean {
104
114
  if (this.lastError !== null) this.clearError();
105
115