@pellux/goodvibes-tui 1.0.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 (52) hide show
  1. package/CHANGELOG.md +66 -127
  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.ts +40 -0
  8. package/src/core/stream-event-wiring.ts +104 -2
  9. package/src/core/stream-stall-watchdog.ts +41 -17
  10. package/src/export/cost-utils.ts +90 -8
  11. package/src/input/command-registry.ts +16 -0
  12. package/src/input/commands/diff-runtime.ts +61 -30
  13. package/src/input/commands/session-content.ts +12 -0
  14. package/src/input/commands/session-workflow.ts +3 -0
  15. package/src/input/commands/share-runtime.ts +9 -2
  16. package/src/input/handler-content-actions.ts +22 -8
  17. package/src/input/handler-feed-routes.ts +63 -1
  18. package/src/input/handler-feed.ts +12 -0
  19. package/src/input/handler-interactions.ts +2 -0
  20. package/src/input/handler-types.ts +1 -0
  21. package/src/input/handler.ts +4 -0
  22. package/src/main.ts +17 -17
  23. package/src/panels/agent-inspector-shared.ts +33 -10
  24. package/src/panels/builtin/development.ts +1 -1
  25. package/src/panels/cockpit-read-model.ts +16 -11
  26. package/src/panels/cost-tracker-panel.ts +28 -5
  27. package/src/panels/debug-panel.ts +11 -5
  28. package/src/panels/diff-panel.ts +112 -18
  29. package/src/panels/docs-panel.ts +9 -0
  30. package/src/panels/file-explorer-panel.ts +9 -0
  31. package/src/panels/git-panel.ts +9 -9
  32. package/src/panels/knowledge-graph-panel.ts +9 -0
  33. package/src/panels/local-auth-panel.ts +10 -0
  34. package/src/panels/panel-list-panel.ts +9 -0
  35. package/src/panels/project-planning-panel.ts +10 -0
  36. package/src/panels/provider-health-tracker.ts +5 -1
  37. package/src/panels/provider-health-views.ts +8 -1
  38. package/src/panels/scrollable-list-panel.ts +9 -0
  39. package/src/panels/session-browser-panel.ts +9 -0
  40. package/src/panels/token-budget-panel.ts +5 -3
  41. package/src/panels/types.ts +13 -0
  42. package/src/panels/work-plan-panel.ts +10 -0
  43. package/src/renderer/agent-detail-modal.ts +5 -4
  44. package/src/renderer/process-modal.ts +17 -2
  45. package/src/renderer/shell-surface.ts +8 -1
  46. package/src/renderer/ui-factory.ts +91 -7
  47. package/src/runtime/bootstrap-command-context.ts +3 -0
  48. package/src/runtime/bootstrap-command-parts.ts +3 -1
  49. package/src/runtime/bootstrap-core.ts +5 -0
  50. package/src/runtime/bootstrap-hook-bridge.ts +5 -0
  51. package/src/runtime/bootstrap-shell.ts +12 -1
  52. package/src/version.ts +1 -1
@@ -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;
package/src/main.ts CHANGED
@@ -130,13 +130,9 @@ async function main() {
130
130
  }
131
131
  }
132
132
 
133
- // TUI default: show token speed ON. The SDK schema default is false;
134
- // applyRuntimeConfigDefault reads both the global settings file and the project
135
- // settings file from disk before deciding whether to apply the default. If the
136
- // user has explicitly set this key to false in EITHER their global or project
137
- // persisted config, their value is respected and the default is NOT applied.
138
- // Only when the key is absent from both files (e.g. a new install) does the
139
- // 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.
140
136
  applyRuntimeConfigDefault(configManager, 'display.showTokenSpeed', true);
141
137
 
142
138
  const panelManager = ctx.services.panelManager;
@@ -180,6 +176,8 @@ async function main() {
180
176
  ttftRecorded: false,
181
177
  activeToolStartedAtMs: undefined,
182
178
  activeToolName: undefined,
179
+ lastDeltaAtMs: undefined, stallEpisode: 0,
180
+ reconnectAttempt: undefined, reconnectMaxAttempts: undefined,
183
181
  };
184
182
 
185
183
  const getPromptContentWidth = () => computePromptContentWidth(stdout.columns);
@@ -190,10 +188,8 @@ async function main() {
190
188
  const currentModel = providerRegistry.getCurrentModel();
191
189
  const contextWindow = providerRegistry.getContextWindowForModel(currentModel);
192
190
  const rows = stdout.rows || 24;
193
- // Compact threshold must match buildShellFooter's `compact: height < 30`
194
- // posture below — otherwise the cached-height fast path in
195
- // estimateShellFooterHeight can answer a compact-vs-non-compact query
196
- // with the wrong cached mode and throw the viewport math off by several rows.
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.
197
193
  return rows - 2 - estimateShellFooterHeight(promptLines, contextWindow, rows < 30);
198
194
  };
199
195
 
@@ -348,6 +344,7 @@ async function main() {
348
344
  allowTerminalWrite(() => stdout.write(CLEAR_SCREEN));
349
345
  render();
350
346
  };
347
+ commandContext.requestFullRepaint = () => { compositor.resetDiff(); render(); };
351
348
  permissionPromptRef.requestPermission = (request) =>
352
349
  new Promise((resolve) => {
353
350
  pendingPermission = {
@@ -514,6 +511,8 @@ async function main() {
514
511
  hitlMode: modeManager.getHITLMode(),
515
512
  runningAgentCount,
516
513
  runningProcessCount,
514
+ // Composer must not read as focused while the panel/process indicator owns keyboard focus.
515
+ promptFocused: !input.panelFocused && !input.indicatorFocused,
517
516
  indicatorFocused: input.indicatorFocused,
518
517
  runningAgentProgress: runningAgentSummary.progress,
519
518
  composerMode: composerState.modeLabel,
@@ -584,6 +583,8 @@ async function main() {
584
583
  const partialToolPreview = showPreview ? sessionSnapshot.streamToolPreview : undefined;
585
584
  // Elapsed from turn start (stream or tool execution), used for the thinking indicator timer.
586
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());
587
588
  const thinking = UIFactory.createThinkingFragment(
588
589
  conversationWidth,
589
590
  orchestrator.getSpinner(),
@@ -594,6 +595,7 @@ async function main() {
594
595
  orchestrator.streamingOutputTokens > 0 ? orchestrator.streamingOutputTokens : undefined,
595
596
  turnElapsedMs,
596
597
  streamMetrics.ttftMs,
598
+ stallInfo,
597
599
  );
598
600
  viewport.push(...thinking);
599
601
  // Live tool timer: render the currently executing tool row with ticking elapsed.
@@ -698,9 +700,8 @@ async function main() {
698
700
 
699
701
  // Stable turn context for failover retry — set in submitInput, read by retryTurn.
700
702
  let retryCtx: { count: number; text: string; content?: ContentPart[]; opts?: Parameters<typeof orchestrator.handleUserInput>[2] } | null = null;
701
- // One-key retry affordance: active immediately after a user-visible TURN_ERROR.
702
- // While active, 'r' re-submits on the current provider, 'm' opens the model
703
- // 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.
704
705
  let errorAffordanceActive = false;
705
706
  const retryTurn = (): void => {
706
707
  if (!retryCtx) return;
@@ -724,9 +725,8 @@ async function main() {
724
725
  }
725
726
  });
726
727
 
727
- // Register terminal-restoring crash/termination handlers BEFORE entering raw mode so a
728
- // throw during terminal setup or the initial render still restores the terminal; the
729
- // '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.
730
730
  process.on('uncaughtException', uncaughtExceptionHandler);
731
731
  process.on('SIGTERM', terminationSignalHandler);
732
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
  }
@@ -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
  }),
@@ -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,6 +5,7 @@
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
10
  import { UI_TONES, DIFF_TONES } from '../renderer/ui-primitives.ts';
10
11
  import { FilePreviewPanel } from './file-preview-panel.ts';
@@ -166,20 +167,49 @@ function parseDiff(raw: string): ParsedLine[] {
166
167
  // Split a full `git diff` output into per-file entries
167
168
  // ---------------------------------------------------------------------------
168
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
+
169
203
  function splitIntoDiffEntries(raw: string): DiffEntry[] {
170
204
  const entries: DiffEntry[] = [];
171
- // Split on "diff --git" lines
172
- 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);
173
207
  for (const chunk of chunks) {
174
208
  const trimmed = chunk.trim();
175
209
  if (!trimmed) continue;
176
210
 
177
- // Extract file path from "diff --git a/foo b/foo"
178
- const match = trimmed.match(/^diff --git a\/.+? b\/(.+)$/m);
179
- const filePath = match ? match[1]! : 'unknown';
180
-
181
211
  entries.push({
182
- filePath,
212
+ filePath: extractDiffFilePath(trimmed),
183
213
  raw: chunk,
184
214
  lines: parseDiff(chunk),
185
215
  });
@@ -235,7 +265,15 @@ export class DiffPanel extends BasePanel {
235
265
  /** Set by the 'o' key; consumed by handlePanelIntegrationAction on the next dispatch. */
236
266
  private pendingOpenPreview = false;
237
267
 
238
- 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
+ ) {
239
277
  super('diff', 'Diff', 'D', 'development');
240
278
  this.workingDirectory = workingDirectory;
241
279
  }
@@ -271,12 +309,33 @@ export class DiffPanel extends BasePanel {
271
309
  this.markDirty();
272
310
  }
273
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
+
274
321
  /** Run `git diff` against specific files and populate entries. */
275
322
  async showFileDiffs(files: string[], ref?: string): Promise<void> {
323
+ if (!GitService.isGitRepo(this.workingDirectory)) {
324
+ this.showNotGitRepo();
325
+ return;
326
+ }
276
327
  const args = ['diff', ...(ref ? [ref] : []), '--', ...files];
277
- const proc = Bun.spawn(['git', ...args], { stdout: 'pipe', cwd: this.workingDirectory });
278
- const raw = await new Response(proc.stdout).text();
279
- 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
+ }
280
339
  this.loadRawDiff(raw);
281
340
  this.enrichSemanticDiffs(files, ref ?? 'HEAD').catch((err) => { logger.debug('DiffPanel: semantic diff enrichment failed', { err }); });
282
341
  }
@@ -287,6 +346,10 @@ export class DiffPanel extends BasePanel {
287
346
  * enriches every loaded file with a semantic-diff summary in the background.
288
347
  */
289
348
  async showGitDiff(ref?: string): Promise<void> {
349
+ if (!GitService.isGitRepo(this.workingDirectory)) {
350
+ this.showNotGitRepo();
351
+ return;
352
+ }
290
353
  const args = ['diff', ...(ref ? [ref] : [])];
291
354
  const proc = Bun.spawn(['git', ...args], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
292
355
  const [raw, errText] = await Promise.all([
@@ -325,6 +388,10 @@ export class DiffPanel extends BasePanel {
325
388
  * `/diff staged` command handler.
326
389
  */
327
390
  async showStagedDiff(): Promise<void> {
391
+ if (!GitService.isGitRepo(this.workingDirectory)) {
392
+ this.showNotGitRepo();
393
+ return;
394
+ }
328
395
  const proc = Bun.spawn(['/bin/sh', '-c', 'git diff --cached'], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
329
396
  const [raw, errText] = await Promise.all([
330
397
  new Response(proc.stdout).text(),
@@ -353,7 +420,7 @@ export class DiffPanel extends BasePanel {
353
420
  if (files.length === 0) return;
354
421
  const { computeSemanticDiff, formatSemanticDiffSummary } = await import('../renderer/semantic-diff.ts');
355
422
  const { join, relative: pathRelative } = await import('path');
356
- 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 });
357
424
  await repoRootProc.exited;
358
425
  const repoRoot = (await new Response(repoRootProc.stdout).text()).trim() || this.workingDirectory;
359
426
  await Promise.allSettled(
@@ -418,6 +485,22 @@ export class DiffPanel extends BasePanel {
418
485
  // Input
419
486
  // -------------------------------------------------------------------------
420
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
+
421
504
  handleInput(key: string): boolean {
422
505
  switch (key) {
423
506
  case 'up': this.scrollUp(); return true;
@@ -433,9 +516,9 @@ export class DiffPanel extends BasePanel {
433
516
  case 'backtab': this.prevFile(); return true;
434
517
  case 'pageup': this.scrollPageUp(); return true;
435
518
  case 'pagedown': this.scrollPageDown(); return true;
436
- case 'w': void this.showGitDiff(); return true;
437
- case 'h': void this.showGitDiff('HEAD'); return true;
438
- 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;
439
522
  case 'o': {
440
523
  if (!this.currentEntry()) return false;
441
524
  this.pendingOpenPreview = true;
@@ -623,11 +706,15 @@ export class DiffPanel extends BasePanel {
623
706
  for (let i = 0; i < this.entries.length; i++) {
624
707
  const entry = this.entries[i]!;
625
708
  const active = i === this.selectedFile;
709
+ const placeholder = isPlaceholderPath(entry.filePath);
626
710
  const stat = diffStat(entry);
627
- 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);
628
715
  const bg = active ? COLOR.tabActiveBg : COLOR.tabBg;
629
716
  // Active file gets a leading marker; every tab shows +adds/-dels at a glance.
630
- const marker = active ? '▸ ' : ' ';
717
+ const marker = active ? '▸ ' : (placeholder ? '⚠ ' : ' ');
631
718
  const label = `${marker}${basename(entry.filePath)} `;
632
719
  for (const ch of label) push(ch, fg, bg, active);
633
720
  if (stat.added > 0) for (const ch of `+${stat.added}`) push(ch, COLOR.addition, bg, active);
@@ -650,11 +737,15 @@ export class DiffPanel extends BasePanel {
650
737
  return buildStyledPanelLine(width, [{ text: ' No file', fg: COLOR.tabInactive, bg: COLOR.statusBar }], { fillBg: COLOR.statusBar });
651
738
  }
652
739
  const stat = diffStat(entry);
740
+ const placeholder = isPlaceholderPath(entry.filePath);
653
741
  // Keep the file path display-width-aware so a long/wide path can't overflow.
654
742
  const pathBudget = Math.max(8, Math.floor(width / 2));
655
743
  const fileInfo = truncateDisplay(entry.filePath, pathBudget);
656
744
  const segments: Array<{ text: string; fg: string; bg?: string; bold?: boolean }> = [
657
- { 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 },
658
749
  { text: `[${this.selectedFile + 1}/${this.entries.length}]`, fg: COLOR.tabInactive, bg: COLOR.statusBar },
659
750
  { text: ' +', fg: COLOR.tabInactive, bg: COLOR.statusBar },
660
751
  { text: String(stat.added), fg: COLOR.addition, bg: COLOR.statusBar },
@@ -666,6 +757,9 @@ export class DiffPanel extends BasePanel {
666
757
  if (entry.semanticSummary) {
667
758
  segments.push({ text: ` ◈ ${entry.semanticSummary}`, fg: COLOR.hunk, bg: COLOR.statusBar });
668
759
  }
760
+ if (this.hotkeyStatus) {
761
+ segments.push({ text: ` ${this.hotkeyStatus}`, fg: COLOR.info, bg: COLOR.statusBar, bold: true });
762
+ }
669
763
  return buildStyledPanelLine(width, segments, { fillBg: COLOR.statusBar });
670
764
  }
671
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;