@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
@@ -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
 
@@ -9,7 +9,7 @@ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
9
  import { getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
10
10
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
11
11
  import { handleConfirmInput, type ConfirmState } from '../panels/confirm-state.ts';
12
- import { AGENT_TERMINAL_STATUSES as MODAL_TERMINAL_STATUSES, AGENT_STALL_THRESHOLD_MS as MODAL_STALL_THRESHOLD_MS } from '../panels/agent-inspector-shared.ts';
12
+ import { AGENT_TERMINAL_STATUSES as MODAL_TERMINAL_STATUSES, AGENT_STALL_THRESHOLD_MS as MODAL_STALL_THRESHOLD_MS, hasReportedUsage } from '../panels/agent-inspector-shared.ts';
13
13
  import { UI_TONES } from './ui-primitives.ts';
14
14
 
15
15
  /**
@@ -283,10 +283,11 @@ export function renderAgentDetailModal(
283
283
 
284
284
  // Metrics
285
285
  sections.push({ type: 'text', content: `Tool calls : ${rec.toolCallCount}` });
286
- if (rec.usage) {
287
- const totalIn = rec.usage.inputTokens + (rec.usage.cacheReadTokens ?? 0) + (rec.usage.cacheWriteTokens ?? 0);
286
+ if (hasReportedUsage(rec.usage)) {
287
+ const usage = rec.usage;
288
+ const totalIn = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
288
289
  sections.push({ type: 'text', content: `Tokens in : ${totalIn.toLocaleString()}` });
289
- sections.push({ type: 'text', content: `Tokens out : ${rec.usage.outputTokens.toLocaleString()}` });
290
+ sections.push({ type: 'text', content: `Tokens out : ${usage.outputTokens.toLocaleString()}` });
290
291
  } else {
291
292
  sections.push({
292
293
  type: 'text',
@@ -85,7 +85,7 @@ function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
85
85
  if (task.startsWith('WRFC Review Request')) {
86
86
  const thresholdMatch = task.match(/threshold is (\d+(?:\.\d+)?)/);
87
87
  const threshold = thresholdMatch ? thresholdMatch[1] : '9.9';
88
- const desc = truncateFirst(originalTask ?? 'review in progress', 50);
88
+ const desc = truncateFirst(originalTask ?? embeddedTaskDescription(task) ?? 'review in progress', 50);
89
89
  return `[Review] ${desc} (target: ${threshold}/10)`;
90
90
  }
91
91
 
@@ -96,7 +96,7 @@ function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
96
96
  const toScore = scoreMatch ? scoreMatch[3] : '?';
97
97
  const attemptMatch = task.match(/Fix attempt:\s*(\d+)/);
98
98
  const attempt = attemptMatch ? attemptMatch[1] : '?';
99
- const desc = truncateFirst(originalTask ?? 'fix in progress', 45);
99
+ const desc = truncateFirst(originalTask ?? embeddedTaskDescription(task) ?? 'fix in progress', 45);
100
100
  // Show constraint count when the chain has constraints to target (SDK 0.23.0)
101
101
  const chain = rec.wrfcId ? safeGetChain(rec.wrfcId, deps) : null;
102
102
  const constraintCount = chain && (chain.constraints?.length ?? 0) > 0 ? chain.constraints?.length ?? 0 : 0;
@@ -441,6 +441,21 @@ function getChainTask(wrfcId: string | undefined, deps: Pick<ProcessModalDeps, '
441
441
  return safeGetChain(wrfcId, deps)?.task ?? null;
442
442
  }
443
443
 
444
+ /**
445
+ * Fallback for when the WRFC chain lookup comes back null (chain already
446
+ * completed/evicted, or wrfcId not populated on the record) — the record's
447
+ * own `rec.task` is seeded as `'WRFC Review Request\n<original task
448
+ * description>'` / `'WRFC Fix Request\n...'`, so the description is sitting
449
+ * right there even without the chain. Returns null (not the generic
450
+ * placeholder) when there's no second line to extract.
451
+ */
452
+ function embeddedTaskDescription(task: string): string | null {
453
+ const firstNewline = task.indexOf('\n');
454
+ if (firstNewline === -1) return null;
455
+ const desc = task.slice(firstNewline + 1).trim();
456
+ return desc.length > 0 ? desc : null;
457
+ }
458
+
444
459
  /** Truncate to first line, capped at max chars. */
445
460
  function truncateFirst(text: string, max: number): string {
446
461
  const line = text.split('\n')[0].trim();
@@ -8,6 +8,13 @@ export interface ShellFooterBuildOptions {
8
8
  readonly promptLineCount: number;
9
9
  readonly promptCursorPos?: number;
10
10
  readonly promptFocused?: boolean;
11
+ /**
12
+ * True when the panel workspace owns keyboard focus. Only consulted as a
13
+ * fallback when `promptFocused` is not supplied — see buildShellFooter's
14
+ * default expression. Callers that already compute `promptFocused`
15
+ * themselves (main.ts does) do not need to also pass this.
16
+ */
17
+ readonly panelFocused?: boolean;
11
18
  readonly usage: { up: number; down: number };
12
19
  readonly showExitNotice: boolean;
13
20
  readonly lastCopyTime: number;
@@ -107,7 +114,7 @@ export function buildShellFooter(
107
114
  options.lastInputTokens,
108
115
  options.commandArgsHint,
109
116
  options.hitlMode,
110
- options.promptFocused ?? !options.indicatorFocused,
117
+ options.promptFocused ?? (!options.indicatorFocused && !options.panelFocused),
111
118
  options.composerMode,
112
119
  options.composerStatus,
113
120
  options.composerFlags,
@@ -8,13 +8,36 @@ import { GLYPHS, UI_TONES } from './ui-primitives.ts';
8
8
  import { formatElapsed } from '../utils/format-elapsed.ts';
9
9
  import { abbreviateCount } from '../utils/format-number.ts';
10
10
  import { computeContextUsage } from '../core/context-usage.ts';
11
- import { calcSessionCost } from '../export/cost-utils.ts';
11
+ import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
12
12
  import { buildFooterTip, isAgentActive } from './footer-tips.ts';
13
+ import type { StreamMetrics } from '../core/stream-event-wiring.ts';
13
14
 
14
15
  /** Number of frames before the animated gradient completes one full cycle. */
15
16
  const GRADIENT_CYCLE_FRAMES = 50;
16
17
  /** Number of frames before rotating to the next thinking phrase (~30 seconds at 80ms/frame). */
17
18
  const PHRASE_ROTATION_FRAMES = 375;
19
+ /**
20
+ * Ms since the last STREAM_DELTA before the whimsical phrase rotation freezes
21
+ * and createThinkingFragment shows an honest "stalled Ns" / "reconnecting"
22
+ * label instead. Deliberately much shorter than the 30s stream-stall-watchdog
23
+ * hint threshold (stream-stall-watchdog.ts) — that threshold gates a
24
+ * low-priority system message about a likely-dead connection; this one gates
25
+ * a cosmetic label so the UI stops claiming "Vibing..." within a couple of
26
+ * seconds of real silence, well before the stall is confirmed as a problem.
27
+ */
28
+ const THINKING_STALL_FREEZE_MS = 2_500;
29
+
30
+ /**
31
+ * Stall/reconnect state for the live thinking indicator, computed by the
32
+ * caller every render frame from streamMetrics (see stream-event-wiring.ts).
33
+ * `reconnect` is populated only once the SDK's STREAM_RETRY event fires
34
+ * (structurally consumed — absent from SDK 0.35.0's TurnEvent union today).
35
+ */
36
+ export interface ThinkingStallInfo {
37
+ /** Ms since the last STREAM_DELTA (or STREAM_START if none yet this turn). */
38
+ readonly msSinceLastDelta: number;
39
+ readonly reconnect?: { readonly attempt: number; readonly maxAttempts: number };
40
+ }
18
41
 
19
42
  /** Build the git segment string and its display width. Single source of truth for header layout. */
20
43
  function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number } {
@@ -313,7 +336,15 @@ export class UIFactory {
313
336
  const cw = u.cacheWrite ?? 0;
314
337
  const total = inp + out + cr + cw;
315
338
  const tokenSep = ` ${GLYPHS.navigation.pipeSeparator} `;
316
- const costSegment = model ? `${tokenSep}~$${fmtCost(calcSessionCost(inp, out, cr, cw, model))}` : '';
339
+ // 'n/a' (not 'unpriced') to stay compact in the single-line footer and
340
+ // match the existing "no priceable data" convention used elsewhere
341
+ // (cockpit-panel formatCost, agent-inspector-shared) — the footer has no
342
+ // room for a longer marker before truncation kicks in.
343
+ const costSegment = model
344
+ ? isModelPriced(model)
345
+ ? `${tokenSep}~$${fmtCost(calcSessionCost(inp, out, cr, cw, model))}`
346
+ : `${tokenSep}~n/a`
347
+ : '';
317
348
  const tokenLine = ` Token Usage [ Input: ${fmtNum(inp)}${tokenSep}Output: ${fmtNum(out)}${tokenSep}Cache Read: ${fmtNum(cr)}${tokenSep}Cache Write: ${fmtNum(cw)}${tokenSep}Total: ${fmtNum(total)}${costSegment} ]`;
318
349
  const copiedNotice = isRecentlyCopied ? ` [COPIED] ` : '';
319
350
  const statsLine = ' ' + tokenLine + ' '.repeat(Math.max(0, width - 4 - getDisplayWidth(tokenLine) - getDisplayWidth(copiedNotice))) + copiedNotice;
@@ -345,7 +376,11 @@ export class UIFactory {
345
376
  ctxParts.push(model + (provider ? ` (${provider})` : ''));
346
377
  }
347
378
  if (toolCount) ctxParts.push(`${toolCount} tools`);
348
- if (hitlMode) ctxParts.push(`hitl:${hitlMode}`);
379
+ // Labeled "notify" (not "hitl") — /mode (aliased /hitl) governs UX
380
+ // notification verbosity (quiet/balanced/operator), not tool
381
+ // auto-approval, so it must not share vocabulary with the DANGER MODE
382
+ // risk banner rendered a few lines below.
383
+ if (hitlMode) ctxParts.push(`notify:${hitlMode}`);
349
384
  const ctxLine = ' ' + ctxParts.join(` ${GLYPHS.navigation.pipeSeparator} `);
350
385
  lines.push(this.stringToLine(truncateDisplay(ctxLine, width), width, { fg: '240', dim: true }));
351
386
  }
@@ -406,10 +441,59 @@ export class UIFactory {
406
441
  private static readonly THINK_GRADIENT_START = UI_TONES.accent.gradientStart;
407
442
  private static readonly THINK_GRADIENT_END = UI_TONES.accent.gradientEnd;
408
443
 
409
- public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number): Line[] {
410
- // Rotate phrase every ~30 seconds (frame ticks at 80ms, so ~375 frames)
411
- const phraseIndex = Math.floor(frame / PHRASE_ROTATION_FRAMES) % this.THINKING_PHRASES.length;
412
- const phrase = this.THINKING_PHRASES[phraseIndex];
444
+ /**
445
+ * Per-frame stall info from stream metrics computed from lastDeltaAtMs every render (not
446
+ * from any event) so it degrades gracefully with zero new SDK events. Undefined until the
447
+ * first delta clock exists this turn.
448
+ */
449
+ public static computeStallInfo(lastDeltaAtMs: number | undefined, reconnectAttempt: number | undefined, reconnectMaxAttempts: number | undefined, nowMs: number): ThinkingStallInfo | undefined {
450
+ if (lastDeltaAtMs === undefined) return undefined;
451
+ const reconnect = reconnectAttempt !== undefined && reconnectMaxAttempts !== undefined
452
+ ? { attempt: reconnectAttempt, maxAttempts: reconnectMaxAttempts }
453
+ : undefined;
454
+ return { msSinceLastDelta: nowMs - lastDeltaAtMs, reconnect };
455
+ }
456
+
457
+ /**
458
+ * Render-frame stall-info decision used at the main render loop's call
459
+ * site: suppress stall detection entirely while a tool is actively
460
+ * executing. lastDeltaAtMs only tracks STREAM_START/STREAM_DELTA and is
461
+ * never advanced during tool execution (the model isn't producing tokens
462
+ * then), so without this gate any tool call longer than
463
+ * THINKING_STALL_FREEZE_MS would make the thinking fragment print
464
+ * "Stalled Ns..." directly above the ticking "executing (Ns)" tool row — a
465
+ * false positive during ordinary tool execution (see stream-event-wiring.ts
466
+ * TOOL_EXECUTING/TOOL_SUCCEEDED/TOOL_FAILED/TOOL_CANCELLED handlers).
467
+ * Genuine no-delta silence while waiting on the provider — including the
468
+ * pre-first-token case, where lastDeltaAtMs is seeded at STREAM_START —
469
+ * still stall-detects normally here, since no tool is active then; that is
470
+ * the honest stall case this indicator exists for.
471
+ */
472
+ public static computeRenderStallInfo(
473
+ metrics: Pick<StreamMetrics, 'activeToolName' | 'lastDeltaAtMs' | 'reconnectAttempt' | 'reconnectMaxAttempts'>,
474
+ nowMs: number,
475
+ ): ThinkingStallInfo | undefined {
476
+ return metrics.activeToolName === undefined
477
+ ? this.computeStallInfo(metrics.lastDeltaAtMs, metrics.reconnectAttempt, metrics.reconnectMaxAttempts, nowMs)
478
+ : undefined;
479
+ }
480
+
481
+ public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number, stallInfo?: ThinkingStallInfo): Line[] {
482
+ // Freeze the whimsical phrase rotation once real silence has gone on
483
+ // long enough to be misleading (THINKING_STALL_FREEZE_MS), and show an
484
+ // honest label instead: the SDK's reconnect attempt/maxAttempts once
485
+ // STREAM_RETRY is available, else a plain elapsed-silence readout.
486
+ const isStalled = stallInfo !== undefined && stallInfo.msSinceLastDelta >= THINKING_STALL_FREEZE_MS;
487
+ let phrase: string;
488
+ if (stallInfo?.reconnect) {
489
+ phrase = `Reconnecting (attempt ${stallInfo.reconnect.attempt}/${stallInfo.reconnect.maxAttempts})...`;
490
+ } else if (isStalled) {
491
+ phrase = `Stalled ${Math.floor(stallInfo.msSinceLastDelta / 1000)}s...`;
492
+ } else {
493
+ // Rotate phrase every ~30 seconds (frame ticks at 80ms, so ~375 frames)
494
+ const phraseIndex = Math.floor(frame / PHRASE_ROTATION_FRAMES) % this.THINKING_PHRASES.length;
495
+ phrase = this.THINKING_PHRASES[phraseIndex];
496
+ }
413
497
  const speedSuffix = (tokenSpeed !== undefined && tokenSpeed > 0) ? ` (${Math.round(tokenSpeed)} tok/s)` : '';
414
498
  const elapsedSuffix = elapsedMs !== undefined ? ` (${formatElapsed(elapsedMs)})` : '';
415
499
  const ttftSuffix = (ttftMs !== undefined && ttftMs > 0) ? ` ttft:${ttftMs}ms` : '';
@@ -114,6 +114,7 @@ export type CreateBootstrapCommandContextOptions = {
114
114
  sessionLineageTracker?: import('@pellux/goodvibes-sdk/platform/core').SessionLineageTracker;
115
115
  wrfcController?: import('@pellux/goodvibes-sdk/platform/agents').WrfcController;
116
116
  componentHealthMonitor: import('@/runtime/index.ts').ComponentHealthMonitor;
117
+ hydrateSessionUsage?: () => void;
117
118
  };
118
119
 
119
120
  export function createBootstrapCommandContext(
@@ -167,6 +168,7 @@ export function createBootstrapCommandContext(
167
168
  sessionLineageTracker,
168
169
  wrfcController,
169
170
  changeTracker,
171
+ hydrateSessionUsage,
170
172
  planManager,
171
173
  adaptivePlanner,
172
174
  sessionOrchestration,
@@ -223,6 +225,7 @@ export function createBootstrapCommandContext(
223
225
  sessionLineageTracker,
224
226
  wrfcController,
225
227
  changeTracker,
228
+ hydrateSessionUsage,
226
229
  });
227
230
  const provider = createBootstrapCommandProviderSection({
228
231
  providerRegistry,
@@ -109,6 +109,7 @@ export interface BootstrapCommandSectionOptions {
109
109
  readonly sessionLineageTracker?: import('@pellux/goodvibes-sdk/platform/core').SessionLineageTracker;
110
110
  readonly wrfcController?: import('@pellux/goodvibes-sdk/platform/agents').WrfcController;
111
111
  readonly changeTracker?: import('@pellux/goodvibes-sdk/platform/sessions').SessionChangeTracker;
112
+ readonly hydrateSessionUsage?: () => void;
112
113
  readonly agentManager?: ShellAgentManagerService;
113
114
  readonly modeManager?: ShellModeManagerService;
114
115
  readonly automationManager?: ShellAutomationManagerRuntimeService;
@@ -300,7 +301,7 @@ export function createBootstrapCommandActions(
300
301
  export function createBootstrapCommandSessionSection(
301
302
  options: Pick<
302
303
  BootstrapCommandSectionOptions,
303
- 'conversation' | 'runtime' | 'sessionManager' | 'sessionMemoryStore' | 'sessionLineageTracker' | 'wrfcController' | 'changeTracker'
304
+ 'conversation' | 'runtime' | 'sessionManager' | 'sessionMemoryStore' | 'sessionLineageTracker' | 'wrfcController' | 'changeTracker' | 'hydrateSessionUsage'
304
305
  >,
305
306
  ): BootstrapCommandSessionSection {
306
307
  return {
@@ -311,6 +312,7 @@ export function createBootstrapCommandSessionSection(
311
312
  sessionLineageTracker: options.sessionLineageTracker,
312
313
  wrfcController: options.wrfcController,
313
314
  changeTracker: options.changeTracker,
315
+ hydrateSessionUsage: options.hydrateSessionUsage,
314
316
  };
315
317
  }
316
318
 
@@ -28,6 +28,7 @@ import { loadBootstrapSystemPrompt, syncConfiguredServices } from '@/runtime/ind
28
28
  import { registerBootstrapHookBridge } from '@/runtime/index.ts';
29
29
  import { registerBootstrapRuntimeEvents } from '@/runtime/index.ts';
30
30
  import { createRuntimeServices, type RuntimeServices } from './services.ts';
31
+ import { setPricingSource } from '../export/cost-utils.ts';
31
32
  import { createUiRuntimeServices, type UiRuntimeServices } from './ui-services.ts';
32
33
  import { join } from 'node:path';
33
34
  import { installWrfcAgentToolGuard } from '../tools/wrfc-agent-guard.ts';
@@ -255,6 +256,10 @@ export async function initializeBootstrapCore(
255
256
  providerRegistry.initModelLimits();
256
257
  services.benchmarkStore.initBenchmarks();
257
258
  providerRegistry.initCatalog();
259
+ // Wire cost-utils to the live catalog so cost displays distinguish real
260
+ // pricing from unpriced (WO-315) instead of silently reading zero for any
261
+ // model the small static fallback table doesn't cover.
262
+ setPricingSource(() => providerRegistry.getRawCatalogModels());
258
263
  services.keybindingsManager.loadFromDisk();
259
264
  domainDispatch.syncControlPlaneState({
260
265
  enabled: Boolean(configManager.get('controlPlane.enabled')),
@@ -29,6 +29,8 @@ export interface ResumeSessionOptions {
29
29
  readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
30
30
  readonly providerRegistry: Pick<ProviderRegistry, 'get' | 'getCurrentModel' | 'getForModel' | 'require'>;
31
31
  readonly homeDirectory: string;
32
+ /** See CommandSessionServices.hydrateSessionUsage (command-registry.ts). */
33
+ readonly hydrateSessionUsage?: () => void;
32
34
  }
33
35
 
34
36
  export function createResumeSessionHandler(options: ResumeSessionOptions): (sessionId: string) => void {
@@ -65,6 +67,9 @@ export function createResumeSessionHandler(options: ResumeSessionOptions): (sess
65
67
  });
66
68
  },
67
69
  });
70
+ // Hydrate the footer's token counters from the resumed history now that
71
+ // fromJSON()/journal replay are both applied — before requestRender() below.
72
+ options.hydrateSessionUsage?.();
68
73
  options.onSessionIdChanged?.(sessionId);
69
74
  if (meta?.model) options.runtime.model = meta.model;
70
75
  if (meta?.provider) options.runtime.provider = meta.provider;
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import type { ConversationManager } from '../core/conversation';
2
+ import { sumConversationUsage, type ConversationManager } from '../core/conversation';
3
3
  import type { Orchestrator } from '../core/orchestrator';
4
4
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
5
5
  import type { RuntimeEventBus } from '@/runtime/index.ts';
@@ -93,6 +93,15 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
93
93
  } = options;
94
94
 
95
95
  const systemMessagesPanel = new SystemMessagesPanel(configManager, services.componentHealthMonitor);
96
+ // W0.9: after any resume seam replays historical messages into `conversation`,
97
+ // the freshly-constructed `orchestrator` still has its zeroed default usage
98
+ // (SDK gap — Orchestrator.usage is never persisted/reseeded). Recompute it
99
+ // from the replayed history so the footer doesn't show Input: 0 post-resume.
100
+ const hydrateSessionUsage = (): void => {
101
+ const { usage, lastInputTokens } = sumConversationUsage(conversation.getMessageSnapshot());
102
+ orchestrator.usage = usage;
103
+ orchestrator.lastInputTokens = lastInputTokens;
104
+ };
96
105
  const resumeSession = createResumeSessionHandler({
97
106
  runtimeBus,
98
107
  runtime,
@@ -107,6 +116,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
107
116
  configManager,
108
117
  providerRegistry: services.providerRegistry,
109
118
  homeDirectory: services.homeDirectory,
119
+ hydrateSessionUsage,
110
120
  });
111
121
 
112
122
  const foundationClients = createRuntimeFoundationClients({
@@ -282,6 +292,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
282
292
  },
283
293
  completeModelSelectionSideEffect,
284
294
  componentHealthMonitor: services.componentHealthMonitor,
295
+ hydrateSessionUsage,
285
296
  });
286
297
  commandContextRef = commandContext;
287
298
 
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.0.0';
9
+ let _version = '1.1.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;