@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
@@ -23,6 +23,29 @@ export interface StreamMetrics {
23
23
  activeToolStartedAtMs: number | undefined;
24
24
  /** Name of the currently executing tool; cleared when execution completes. */
25
25
  activeToolName: string | undefined;
26
+ /**
27
+ * Epoch ms of the most recent STREAM_START or STREAM_DELTA; undefined when
28
+ * idle (no turn in flight). Read every render frame — not just on the
29
+ * watchdog's one-shot hint — so "ms since last byte" can be computed even
30
+ * when no new SDK event has arrived at all (a no-delta stall watchdog for
31
+ * the render loop itself, independent of the low-priority system message).
32
+ */
33
+ lastDeltaAtMs: number | undefined;
34
+ /**
35
+ * 1-based count of stall episodes the watchdog has fired for the current
36
+ * turn; 0 = no stall yet. Increments each time a fresh silence (after a
37
+ * recovery) crosses the stall threshold.
38
+ */
39
+ stallEpisode: number;
40
+ /**
41
+ * Populated from the SDK's STREAM_RETRY event when present. SDK 0.35.0 (the
42
+ * pinned dependency) has no such event on the TurnEvent union yet — these
43
+ * fields are consumed structurally (see looseTurnsFeed below) and stay
44
+ * undefined until a future SDK version emits it. Cleared on STREAM_DELTA
45
+ * (a byte arriving means the reconnect, if any, succeeded).
46
+ */
47
+ reconnectAttempt: number | undefined;
48
+ reconnectMaxAttempts: number | undefined;
26
49
  }
27
50
 
28
51
  /** Minimal orchestrator surface required for stream token-speed calculation. */
@@ -60,6 +83,33 @@ interface StreamSystemMessageRouter {
60
83
  low(message: string): void;
61
84
  }
62
85
 
86
+ /**
87
+ * Loosely-typed variant of the turns event feed, used only to subscribe to
88
+ * event names not yet present in the SDK's TurnEvent union (STREAM_RETRY,
89
+ * STREAM_STALL — see the structural-consumption comment at the subscription
90
+ * site below). `events.turns.on` itself stays fully typed against the real
91
+ * TurnEvent union everywhere else in this file.
92
+ */
93
+ interface LooseTurnEventFeed {
94
+ on(type: string, listener: (payload: unknown) => void): () => void;
95
+ }
96
+
97
+ /** Payload shape expected from a future SDK STREAM_RETRY event. */
98
+ interface StreamRetryLikePayload {
99
+ readonly attempt: number;
100
+ readonly maxAttempts: number;
101
+ }
102
+
103
+ /** Runtime guard validating an unknown STREAM_RETRY-like payload's shape. */
104
+ function isStreamRetryLikePayload(payload: unknown): payload is StreamRetryLikePayload {
105
+ return (
106
+ typeof payload === 'object' &&
107
+ payload !== null &&
108
+ typeof (payload as Record<string, unknown>).attempt === 'number' &&
109
+ typeof (payload as Record<string, unknown>).maxAttempts === 'number'
110
+ );
111
+ }
112
+
63
113
  /**
64
114
  * Minimal cost lookup surface for attaching cost-delta information to failover notices.
65
115
  * Returns USD-per-1M-token pricing for the given model ID.
@@ -197,10 +247,19 @@ export function wireStreamEventMetrics(
197
247
  metrics.tokenSpeed = 0;
198
248
  metrics.ttftMs = undefined;
199
249
  metrics.ttftRecorded = false;
250
+ metrics.lastDeltaAtMs = Date.now();
251
+ metrics.stallEpisode = 0;
252
+ metrics.reconnectAttempt = undefined;
253
+ metrics.reconnectMaxAttempts = undefined;
200
254
  }));
201
255
 
202
256
  unsubs.push(events.turns.on('STREAM_DELTA', () => {
203
257
  metrics.deltaCount++;
258
+ metrics.lastDeltaAtMs = Date.now();
259
+ // A byte arrived: any in-flight reconnect (if the SDK reported one)
260
+ // has succeeded.
261
+ metrics.reconnectAttempt = undefined;
262
+ metrics.reconnectMaxAttempts = undefined;
204
263
  const elapsed = (Date.now() - metrics.startTime) / 1000;
205
264
  // Record TTFT on the first delta of each turn.
206
265
  if (!metrics.ttftRecorded) {
@@ -296,10 +355,16 @@ export function wireStreamEventMetrics(
296
355
  render();
297
356
  }));
298
357
 
299
- // --- Stream stall watchdog: emit one low hint if STREAM_START has no delta within 30s ---
358
+ // --- Stream stall watchdog: emit a low hint each time a no-delta gap (at
359
+ // start of turn OR mid-stream, after a re-arm) crosses 30s. The transient
360
+ // system message below is the initial alert; streamMetrics.stallEpisode
361
+ // (read every render frame) drives the persistent "stalled Ns" indicator
362
+ // in the thinking fragment, so the two coexist rather than conflict — the
363
+ // message announces the stall, the indicator tracks it ongoing.
300
364
  const stallWatchdog = createStreamStallWatchdog({
301
365
  events: events.turns,
302
- onStall: (providerName) => {
366
+ onStall: (providerName, episode) => {
367
+ metrics.stallEpisode = episode;
303
368
  systemMessageRouter.low(`Still waiting on ${providerName}… Ctrl+C to cancel`);
304
369
  render();
305
370
  },
@@ -308,22 +373,59 @@ export function wireStreamEventMetrics(
308
373
  });
309
374
  unsubs.push(() => stallWatchdog.dispose());
310
375
 
376
+ // --- Structural consumption of STREAM_RETRY / STREAM_STALL ---
377
+ // Neither event exists on the pinned SDK's (0.35.0) TurnEvent union today —
378
+ // both are proposed additions for the transport-level withRetry() callback
379
+ // (see the stall-honesty audit brief). The typed `events.turns.on` feed
380
+ // rejects unknown event names at compile time, so this casts the feed to a
381
+ // loosely-typed variant and validates each payload with a runtime guard
382
+ // instead of importing a type name that doesn't exist yet — same pattern
383
+ // used elsewhere in this codebase for settings pending SDK schema additions
384
+ // (see src/input/settings-modal-data.ts). Compiles today against 0.35.0;
385
+ // lights up automatically once the SDK adds the real event.
386
+ const looseTurnsFeed = events.turns as unknown as LooseTurnEventFeed;
387
+ unsubs.push(looseTurnsFeed.on('STREAM_RETRY', (payload) => {
388
+ if (!isStreamRetryLikePayload(payload)) return;
389
+ metrics.reconnectAttempt = payload.attempt;
390
+ metrics.reconnectMaxAttempts = payload.maxAttempts;
391
+ render();
392
+ }));
393
+ unsubs.push(looseTurnsFeed.on('STREAM_STALL', () => {
394
+ // Informational only: the TUI's own createStreamStallWatchdog above is
395
+ // the authoritative no-delta detector and already drives the visible
396
+ // indicator via streamMetrics.stallEpisode. This subscription exists so
397
+ // the SDK's own signal (once it lands) is observed rather than silently
398
+ // dropped, without duplicating or fighting the local watchdog.
399
+ render();
400
+ }));
401
+
311
402
  unsubs.push(events.tools.on('TOOL_EXECUTING', (ev) => {
312
403
  metrics.activeToolStartedAtMs = ev.startedAt;
313
404
  metrics.activeToolName = ev.tool;
314
405
  render();
315
406
  }));
407
+ // On every tool-completion path, reset lastDeltaAtMs to "now" so the
408
+ // post-tool silence window starts fresh. Tool execution suppresses stall
409
+ // detection at the render call site (see main.ts), but lastDeltaAtMs itself
410
+ // keeps its pre-tool value the whole time the tool runs — without this
411
+ // reset, the instant a tool completes (potentially long after the last real
412
+ // delta), the very next frame would read msSinceLastDelta as the full
413
+ // tool-execution duration and immediately report a stall, even though the
414
+ // model hasn't had a chance to resume producing tokens yet.
316
415
  unsubs.push(events.tools.on('TOOL_SUCCEEDED', () => {
317
416
  metrics.activeToolStartedAtMs = undefined;
318
417
  metrics.activeToolName = undefined;
418
+ metrics.lastDeltaAtMs = Date.now();
319
419
  }));
320
420
  unsubs.push(events.tools.on('TOOL_FAILED', () => {
321
421
  metrics.activeToolStartedAtMs = undefined;
322
422
  metrics.activeToolName = undefined;
423
+ metrics.lastDeltaAtMs = Date.now();
323
424
  }));
324
425
  unsubs.push(events.tools.on('TOOL_CANCELLED', () => {
325
426
  metrics.activeToolStartedAtMs = undefined;
326
427
  metrics.activeToolName = undefined;
428
+ metrics.lastDeltaAtMs = Date.now();
327
429
  }));
328
430
 
329
431
  let _errorSurfacedCb: ((exhausted: boolean) => void) | undefined;
@@ -1,15 +1,26 @@
1
1
  /**
2
- * StreamStallWatchdog — detects STREAM_START events where no STREAM_DELTA
3
- * arrives within a configurable threshold, and emits a single low-priority
4
- * hint to the user.
2
+ * StreamStallWatchdog — detects gaps of silence (no STREAM_DELTA) longer than
3
+ * a configurable threshold, whether the gap is at the start of a turn or in
4
+ * the middle of an otherwise-flowing stream, and emits a low-priority hint
5
+ * each time a gap crosses the threshold.
5
6
  *
6
7
  * Design:
7
- * - Arm on STREAM_START: set a timeout for STALL_THRESHOLD_MS.
8
- * - Disarm on STREAM_DELTA: clear the pending timeout (stream is alive).
8
+ * - Arm on STREAM_START: reset the episode counter, set a timeout for
9
+ * STALL_THRESHOLD_MS.
10
+ * - Re-arm on STREAM_DELTA: every byte received resets the no-delta clock,
11
+ * so a stall that begins mid-stream (after tokens were already flowing)
12
+ * is caught just as reliably as a stall at turn start. This is the fix
13
+ * for the "silence after the first delta never re-triggers" gap: the
14
+ * previous behaviour disarmed permanently on the first STREAM_DELTA,
15
+ * which meant a multi-minute stall after output had already started
16
+ * produced zero indication.
9
17
  * - Disarm on STREAM_END / TURN_COMPLETED / TURN_ERROR / TURN_CANCEL:
10
18
  * clear the timeout (turn finished normally or with error).
11
- * - If the timeout fires: emit ONE hint, do NOT repeat until the next turn.
12
- * - Re-arm only on the next STREAM_START (next turn).
19
+ * - If the timeout fires: emit ONE hint for that episode (do not repeat
20
+ * while the same gap continues), then wait for the gap to close (another
21
+ * STREAM_DELTA/STREAM_START) before a further silence can fire again —
22
+ * each such re-arm-then-timeout cycle is a new "stall episode" and the
23
+ * episode counter passed to onStall increments each time.
13
24
  * - dispose(): clears all subscriptions and any pending timeout.
14
25
  *
15
26
  * @module
@@ -32,10 +43,13 @@ export interface StreamStallWatchdogOptions {
32
43
  /** The turns event surface to subscribe on. */
33
44
  events: WatchdogTurnEvents;
34
45
  /**
35
- * Called once per turn when the stall threshold is exceeded.
36
- * Receives the provider display name for the hint message.
46
+ * Called once per stall episode when the no-delta threshold is exceeded.
47
+ * Receives the provider display name and a 1-based episode counter that
48
+ * increments each time a new silence (after a re-arm) crosses the
49
+ * threshold within the same turn — so a second mid-stream stall after a
50
+ * recovery is distinguishable from the first.
37
51
  */
38
- onStall: (providerName: string) => void;
52
+ onStall: (providerName: string, episode: number) => void;
39
53
  /**
40
54
  * Provides the current provider display name at the moment the hint fires.
41
55
  * Optional — defaults to 'provider' when not supplied.
@@ -63,17 +77,19 @@ export function createStreamStallWatchdog(opts: StreamStallWatchdogOptions): Str
63
77
  const { events, onStall, getProviderName, thresholdMs = STALL_THRESHOLD_MS } = opts;
64
78
 
65
79
  let stallTimer: ReturnType<typeof setTimeout> | null = null;
66
- let hintFiredForTurn = false;
80
+ let hintFiredForEpisode = false;
81
+ let episodeCount = 0;
67
82
 
68
83
  function arm(): void {
69
84
  disarm();
70
- hintFiredForTurn = false;
85
+ hintFiredForEpisode = false;
71
86
  stallTimer = setTimeout(() => {
72
87
  stallTimer = null;
73
- if (!hintFiredForTurn) {
74
- hintFiredForTurn = true;
88
+ if (!hintFiredForEpisode) {
89
+ hintFiredForEpisode = true;
90
+ episodeCount++;
75
91
  const provider = getProviderName ? getProviderName() : 'provider';
76
- onStall(provider);
92
+ onStall(provider, episodeCount);
77
93
  }
78
94
  }, thresholdMs);
79
95
  }
@@ -85,9 +101,17 @@ export function createStreamStallWatchdog(opts: StreamStallWatchdogOptions): Str
85
101
  }
86
102
  }
87
103
 
104
+ function onStreamStart(): void {
105
+ episodeCount = 0;
106
+ arm();
107
+ }
108
+
88
109
  const unsubs: Array<() => void> = [
89
- events.on('STREAM_START', arm),
90
- events.on('STREAM_DELTA', disarm),
110
+ events.on('STREAM_START', onStreamStart),
111
+ // Re-arm (not disarm) on every delta: the no-delta clock must reset on
112
+ // each byte so a stall that starts mid-stream is still caught, not just
113
+ // a stall before the first byte.
114
+ events.on('STREAM_DELTA', arm),
91
115
  events.on('STREAM_END', disarm),
92
116
  events.on('TURN_COMPLETED', disarm),
93
117
  events.on('TURN_ERROR', disarm),
@@ -1,13 +1,38 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // cost-utils — canonical pricing source for token cost calculations (consumed by CostTrackerPanel and share-runtime)
3
+ //
4
+ // Pricing resolution order (resolvePricing):
5
+ // 1. `:free` suffix (OpenRouter free-tier convention) — always priced at $0.
6
+ // 2. The live SDK model catalog, when a source has been wired via
7
+ // setPricingSource() (bootstrap-core.ts wires ProviderRegistry.getRawCatalogModels()
8
+ // once catalog init completes) — exact id match, then prefix/substring.
9
+ // 3. STATIC_FALLBACK_PRICING — a small hand-maintained safety net for common
10
+ // frontier models, used when the catalog has no source wired (e.g. tests)
11
+ // or hasn't matched (catalog not yet loaded, or a model this net covers
12
+ // that the catalog happens to miss).
13
+ // 4. Unpriced: the model is genuinely unknown to every source above. This is
14
+ // reported explicitly via `priced: false` so consumers can render an
15
+ // honest "unpriced" state instead of a silent $0.
3
16
  // ---------------------------------------------------------------------------
4
17
 
18
+ import type { CatalogModel } from '@pellux/goodvibes-sdk/platform/providers';
19
+
5
20
  export interface ModelPricing {
6
21
  input: number;
7
22
  output: number;
8
23
  }
9
24
 
10
- const MODEL_PRICING: Record<string, ModelPricing> = {
25
+ /** Result of a pricing lookup: the resolved (possibly zero) price, and whether it's real. */
26
+ export interface PricingResult {
27
+ pricing: ModelPricing;
28
+ /** False when no source (catalog or static fallback) recognized the model — the zero pricing is a placeholder, not a real price. */
29
+ priced: boolean;
30
+ }
31
+
32
+ // Hand-maintained safety net, used only when the live catalog has no source
33
+ // wired or does not recognize the model. NOT the primary pricing source —
34
+ // see resolvePricing() below.
35
+ const STATIC_FALLBACK_PRICING: Record<string, ModelPricing> = {
11
36
  // Free tier
12
37
  'openrouter/free': { input: 0, output: 0 },
13
38
 
@@ -34,17 +59,71 @@ const MODEL_PRICING: Record<string, ModelPricing> = {
34
59
  'gemini-2.5-pro': { input: 1.25, output: 5 },
35
60
  };
36
61
 
62
+ // Module-level injection point. Kept as a bare singleton (not threaded through
63
+ // call sites) because the 9 existing consumers are pure functions with no
64
+ // ProviderRegistry in scope; see WO-315 brief for the tradeoff. Tests that
65
+ // never call setPricingSource() fall through to the static-fallback path,
66
+ // which keeps them isolated from catalog state by default.
67
+ let pricingSource: (() => readonly CatalogModel[]) | null = null;
68
+
69
+ /** Wire (or clear, with null) the live catalog as the primary pricing source. */
70
+ export function setPricingSource(source: (() => readonly CatalogModel[]) | null): void {
71
+ pricingSource = source;
72
+ }
73
+
74
+ function findInCatalog(modelId: string, models: readonly CatalogModel[]): ModelPricing | null {
75
+ const exact = models.find((m) => m.id === modelId);
76
+ if (exact) return exact.tier === 'free' ? { input: 0, output: 0 } : exact.pricing;
77
+ for (const m of models) {
78
+ if (modelId.startsWith(m.id) || modelId.includes(m.id)) {
79
+ return m.tier === 'free' ? { input: 0, output: 0 } : m.pricing;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function findInStaticFallback(modelId: string): ModelPricing | null {
86
+ const exact = STATIC_FALLBACK_PRICING[modelId];
87
+ if (exact) return exact;
88
+ for (const [key, pricing] of Object.entries(STATIC_FALLBACK_PRICING)) {
89
+ if (modelId.startsWith(key) || modelId.includes(key)) return pricing;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * resolvePricing — resolve USD-per-1M-token pricing for a model ID, and
96
+ * whether that pricing is real (vs. an unpriced placeholder). See the module
97
+ * header for the full resolution order.
98
+ */
99
+ export function resolvePricing(modelId: string): PricingResult {
100
+ if (modelId.endsWith(':free')) return { pricing: { input: 0, output: 0 }, priced: true };
101
+
102
+ const models = pricingSource?.() ?? [];
103
+ const catalogHit = findInCatalog(modelId, models);
104
+ if (catalogHit) return { pricing: catalogHit, priced: true };
105
+
106
+ const fallbackHit = findInStaticFallback(modelId);
107
+ if (fallbackHit) return { pricing: fallbackHit, priced: true };
108
+
109
+ // Genuinely unknown — no source recognizes this model. Report honestly
110
+ // rather than collapsing into the same zero a free model would return.
111
+ return { pricing: { input: 0, output: 0 }, priced: false };
112
+ }
113
+
37
114
  /**
38
115
  * getPricing — resolve USD-per-1M-token pricing for a model ID.
39
- * Exact match first; then prefix/substring; falls back to zero.
116
+ * Back-compat wrapper over resolvePricing(); returns zero for both genuinely
117
+ * free and genuinely unknown models (use resolvePricing/isModelPriced to
118
+ * distinguish the two).
40
119
  */
41
120
  export function getPricing(modelId: string): ModelPricing {
42
- if (MODEL_PRICING[modelId]) return MODEL_PRICING[modelId]!;
43
- if (modelId.endsWith(':free')) return { input: 0, output: 0 };
44
- for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
45
- if (modelId.startsWith(key) || modelId.includes(key)) return pricing;
46
- }
47
- return { input: 0, output: 0 };
121
+ return resolvePricing(modelId).pricing;
122
+ }
123
+
124
+ /** True when `modelId` resolves to a real price (free or paid), false when it's an unpriced placeholder. */
125
+ export function isModelPriced(modelId: string): boolean {
126
+ return resolvePricing(modelId).priced;
48
127
  }
49
128
 
50
129
  /**
@@ -56,6 +135,9 @@ export function getPricing(modelId: string): ModelPricing {
56
135
  * cacheRead — cumulative cache-read tokens
57
136
  * cacheWrite — cumulative cache-write tokens
58
137
  * modelId — registry model identifier
138
+ *
139
+ * Unpriced models contribute 0 to the total (same as before this WO) — only
140
+ * the display layer distinguishes "genuinely free/priced" from "unpriced".
59
141
  */
60
142
  export function calcSessionCost(
61
143
  inputTokens: number,
@@ -78,6 +78,14 @@ export interface CommandUiActions {
78
78
  clearScreen?: () => void;
79
79
  activatePlan?: (planId: string, task: string) => void;
80
80
  requestPermission?: PermissionRequestHandler;
81
+ /**
82
+ * Force a full-screen repaint on the next frame (reuses Compositor.resetDiff(),
83
+ * the same call resize/bootstrap already use). Defense-in-depth for command
84
+ * handlers whose spawned subprocess may have written to the real tty (e.g. a
85
+ * stderr-capture regression) — nulls the diff buffers so the next composite()
86
+ * repaints over any stray output instead of leaving it until an unrelated resize.
87
+ */
88
+ requestFullRepaint?: () => void;
81
89
  }
82
90
 
83
91
  export interface CommandShellUiOpeners {
@@ -141,6 +149,14 @@ export interface CommandSessionServices {
141
149
  readonly sessionLineageTracker?: import('@pellux/goodvibes-sdk/platform/core').SessionLineageTracker;
142
150
  readonly wrfcController?: import('@pellux/goodvibes-sdk/platform/agents').WrfcController;
143
151
  readonly changeTracker?: import('@pellux/goodvibes-sdk/platform/sessions').SessionChangeTracker;
152
+ /**
153
+ * Recompute the Orchestrator's session-wide usage totals from the
154
+ * conversation's current message history. Call after a session resume
155
+ * replays historical messages, before the next render, so the footer's
156
+ * token counter reflects the resumed session's real usage instead of a
157
+ * fresh Orchestrator's zeroed default (W0.9).
158
+ */
159
+ readonly hydrateSessionUsage?: () => void;
144
160
  }
145
161
 
146
162
  export interface CommandProviderServices {
@@ -1,8 +1,39 @@
1
1
  import { join } from 'path';
2
2
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
3
- import type { CommandRegistry } from '../command-registry.ts';
3
+ import { GitService } from '@pellux/goodvibes-sdk/platform/git';
4
+ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
4
5
  import { requirePanelManager, requireSessionChangeTracker, requireShellPaths } from './runtime-services.ts';
5
6
 
7
+ /**
8
+ * Run a git command that lists changed file names, capturing stderr instead
9
+ * of letting it fall through to the process's real stderr (which, for a
10
+ * spawned child with no stdio option, is inherited from the parent — i.e.
11
+ * written straight to the TUI's controlling terminal, corrupting the screen
12
+ * outside the renderer's front/back-buffer diffing). Mirrors the 'staged'
13
+ * subcommand's existing pattern below.
14
+ */
15
+ async function runGitNameOnly(args: string[], cwd: string): Promise<{ files: string[]; errText: string; ok: boolean }> {
16
+ const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe', cwd });
17
+ const [out, errRaw] = await Promise.all([
18
+ new Response(proc.stdout).text(),
19
+ new Response(proc.stderr).text(),
20
+ ]);
21
+ const exitCode = await proc.exited;
22
+ return {
23
+ files: out.trim().split('\n').filter(Boolean),
24
+ errText: errRaw.trim(),
25
+ ok: exitCode === 0,
26
+ };
27
+ }
28
+
29
+ /** Report a failed name-only fetch through the normal print channel and force
30
+ * a full repaint on the next frame as defense-in-depth, in case anything did
31
+ * reach the real tty. */
32
+ function reportGitNameOnlyFailure(ctx: CommandContext, label: string, errText: string): void {
33
+ ctx.print(`${label} failed: ${errText || 'unknown error'}`);
34
+ ctx.requestFullRepaint?.();
35
+ }
36
+
6
37
  async function enrichSemanticDiff(
7
38
  panel: InstanceType<typeof import('../../panels/diff-panel.ts').DiffPanel>,
8
39
  files: string[],
@@ -12,7 +43,7 @@ async function enrichSemanticDiff(
12
43
  ): Promise<void> {
13
44
  const { computeSemanticDiff, formatSemanticDiffSummary } = await import('../../renderer/semantic-diff.ts');
14
45
  const { relative: pathRelative } = await import('path');
15
- const repoRootProc = Bun.spawn(['git', 'rev-parse', '--show-toplevel'], { stdout: 'pipe', cwd: workingDirectory });
46
+ const repoRootProc = Bun.spawn(['git', 'rev-parse', '--show-toplevel'], { stdout: 'pipe', stderr: 'pipe', cwd: workingDirectory });
16
47
  await repoRootProc.exited;
17
48
  const repoRoot = (await new Response(repoRootProc.stdout).text()).trim() || workingDirectory;
18
49
  await Promise.allSettled(
@@ -57,6 +88,14 @@ export function registerDiffRuntimeCommands(registry: CommandRegistry): void {
57
88
  async handler(args, ctx) {
58
89
  const shellPaths = requireShellPaths(ctx);
59
90
  const workingDirectory = shellPaths.workingDirectory;
91
+ // Gate before any git spawn happens (same defensive check /git already
92
+ // has) — every subcommand below shells out to git, and without this
93
+ // they each produced a differently-shaped error for the same
94
+ // not-a-git-repo condition.
95
+ if (!GitService.isGitRepo(workingDirectory)) {
96
+ ctx.print('Not a git repository here. Run /git to initialize one.');
97
+ return;
98
+ }
60
99
  const { DiffPanel } = await import('../../panels/diff-panel.ts');
61
100
 
62
101
  const pm = requirePanelManager(ctx);
@@ -81,13 +120,11 @@ export function registerDiffRuntimeCommands(registry: CommandRegistry): void {
81
120
  ctx.print('Loading working-tree diff...');
82
121
  await diffPanel.showGitDiff();
83
122
  ctx.print('Diff panel updated: working tree changes.');
84
- const workingChangedFiles = await (async () => {
85
- const proc = Bun.spawn(['git', 'diff', '--name-only'], { stdout: 'pipe', cwd: workingDirectory });
86
- await proc.exited;
87
- return (await new Response(proc.stdout).text()).trim().split('\n').filter(Boolean);
88
- })();
89
- if (workingChangedFiles.length > 0) {
90
- enrichSemanticDiff(diffPanel, workingChangedFiles, 'HEAD', () => ctx.renderRequest(), workingDirectory).catch((err) => { logger.debug('semantic diff enrichment failed', { err }); });
123
+ const workingChanged = await runGitNameOnly(['git', 'diff', '--name-only'], workingDirectory);
124
+ if (!workingChanged.ok) {
125
+ reportGitNameOnlyFailure(ctx, 'git diff --name-only', workingChanged.errText);
126
+ } else if (workingChanged.files.length > 0) {
127
+ enrichSemanticDiff(diffPanel, workingChanged.files, 'HEAD', () => ctx.renderRequest(), workingDirectory).catch((err) => { logger.debug('semantic diff enrichment failed', { err }); });
91
128
  }
92
129
  break;
93
130
  }
@@ -106,13 +143,11 @@ export function registerDiffRuntimeCommands(registry: CommandRegistry): void {
106
143
  } else {
107
144
  diffPanel.loadRawDiff(raw);
108
145
  ctx.print('Diff panel updated: staged changes.');
109
- const stagedChangedFiles = await (async () => {
110
- const stagedProc = Bun.spawn(['git', 'diff', '--cached', '--name-only'], { stdout: 'pipe', cwd: workingDirectory });
111
- await stagedProc.exited;
112
- return (await new Response(stagedProc.stdout).text()).trim().split('\n').filter(Boolean);
113
- })();
114
- if (stagedChangedFiles.length > 0) {
115
- enrichSemanticDiff(diffPanel, stagedChangedFiles, 'HEAD', () => ctx.renderRequest(), workingDirectory).catch((err) => { logger.debug('semantic diff enrichment failed', { err }); });
146
+ const stagedChanged = await runGitNameOnly(['git', 'diff', '--cached', '--name-only'], workingDirectory);
147
+ if (!stagedChanged.ok) {
148
+ reportGitNameOnlyFailure(ctx, 'git diff --cached --name-only', stagedChanged.errText);
149
+ } else if (stagedChanged.files.length > 0) {
150
+ enrichSemanticDiff(diffPanel, stagedChanged.files, 'HEAD', () => ctx.renderRequest(), workingDirectory).catch((err) => { logger.debug('semantic diff enrichment failed', { err }); });
116
151
  }
117
152
  }
118
153
  break;
@@ -121,13 +156,11 @@ export function registerDiffRuntimeCommands(registry: CommandRegistry): void {
121
156
  ctx.print('Loading diff vs HEAD...');
122
157
  await diffPanel.showGitDiff('HEAD');
123
158
  ctx.print('Diff panel updated: all changes vs HEAD.');
124
- const headChangedFiles = await (async () => {
125
- const proc = Bun.spawn(['/bin/sh', '-c', 'git diff HEAD --name-only'], { stdout: 'pipe', cwd: workingDirectory });
126
- await proc.exited;
127
- return (await new Response(proc.stdout).text()).trim().split('\n').filter(Boolean);
128
- })();
129
- if (headChangedFiles.length > 0) {
130
- enrichSemanticDiff(diffPanel, headChangedFiles, 'HEAD', () => ctx.renderRequest(), workingDirectory).catch((err) => { logger.debug('semantic diff enrichment failed', { err }); });
159
+ const headChanged = await runGitNameOnly(['git', 'diff', 'HEAD', '--name-only'], workingDirectory);
160
+ if (!headChanged.ok) {
161
+ reportGitNameOnlyFailure(ctx, 'git diff HEAD --name-only', headChanged.errText);
162
+ } else if (headChanged.files.length > 0) {
163
+ enrichSemanticDiff(diffPanel, headChanged.files, 'HEAD', () => ctx.renderRequest(), workingDirectory).catch((err) => { logger.debug('semantic diff enrichment failed', { err }); });
131
164
  }
132
165
  break;
133
166
  }
@@ -143,13 +176,11 @@ export function registerDiffRuntimeCommands(registry: CommandRegistry): void {
143
176
  ctx.print('No session changes tracked yet. Showing diff vs HEAD...');
144
177
  await diffPanel.showGitDiff('HEAD');
145
178
  ctx.print('Diff panel updated: all changes vs HEAD.');
146
- const fallbackFiles = await (async () => {
147
- const proc = Bun.spawn(['/bin/sh', '-c', 'git diff HEAD --name-only'], { stdout: 'pipe', cwd: workingDirectory });
148
- await proc.exited;
149
- return (await new Response(proc.stdout).text()).trim().split('\n').filter(Boolean);
150
- })();
151
- if (fallbackFiles.length > 0) {
152
- enrichSemanticDiff(diffPanel, fallbackFiles, 'HEAD', () => ctx.renderRequest(), workingDirectory).catch((err) => { logger.debug('semantic diff enrichment failed', { err }); });
179
+ const fallback = await runGitNameOnly(['git', 'diff', 'HEAD', '--name-only'], workingDirectory);
180
+ if (!fallback.ok) {
181
+ reportGitNameOnlyFailure(ctx, 'git diff HEAD --name-only', fallback.errText);
182
+ } else if (fallback.files.length > 0) {
183
+ enrichSemanticDiff(diffPanel, fallback.files, 'HEAD', () => ctx.renderRequest(), workingDirectory).catch((err) => { logger.debug('semantic diff enrichment failed', { err }); });
153
184
  }
154
185
  }
155
186
  break;
@@ -7,6 +7,7 @@ import { exportToMarkdown } from '@pellux/goodvibes-sdk/platform/export';
7
7
  import { TemplateManager, parseTemplateArgs } from '@pellux/goodvibes-sdk/platform/templates';
8
8
  import { requireSessionManager, requireSessionMemoryStore, requireShellPaths } from './runtime-services.ts';
9
9
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
10
+ import { sessionCommand } from './session.ts';
10
11
 
11
12
  export function registerSessionContentCommands(registry: CommandRegistry): void {
12
13
  registry.register({
@@ -238,7 +239,18 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
238
239
  registry.register({
239
240
  name: 'sessions',
240
241
  description: 'List saved sessions',
242
+ usage: '[resume <id|name>]',
243
+ argsHint: '[resume <id|name>]',
241
244
  async handler(_args, ctx) {
245
+ // Splash's "resume last session" hint and users' muscle memory both say
246
+ // `/sessions resume <id>` (plural, matching this command's own name) —
247
+ // but subcommands like `resume` are only implemented on the singular
248
+ // `/session` command. Forward instead of silently listing and dropping
249
+ // the subcommand+id on the floor.
250
+ if (_args.length > 0) {
251
+ await sessionCommand.handler(_args, ctx);
252
+ return;
253
+ }
242
254
  const sessionManager = requireSessionManager(ctx);
243
255
  const sessions = sessionManager.list();
244
256
  if (ctx.openSelection) {
@@ -260,6 +260,9 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
260
260
  });
261
261
  },
262
262
  });
263
+ // Hydrate the footer's token counters from the resumed (+ journal-replayed)
264
+ // history now, before ctx.renderRequest() below (W0.9).
265
+ ctx.session.hydrateSessionUsage?.();
263
266
 
264
267
  if (meta.model) {
265
268
  try {
@@ -10,7 +10,7 @@ import {
10
10
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
11
11
  import { requireShellPaths } from './runtime-services.ts';
12
12
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
- import { calcSessionCost } from '../../export/cost-utils.ts';
13
+ import { calcSessionCost, isModelPriced } from '../../export/cost-utils.ts';
14
14
  import {
15
15
  GistUploadTarget,
16
16
  NO_TOKEN_GUIDANCE,
@@ -113,6 +113,13 @@ export function registerShareRuntimeCommands(registry: CommandRegistry): void {
113
113
  const sessionCostUsd = calcSessionCost(
114
114
  totalInput, totalOutput, totalCacheRead, totalCacheWrite, sessionModel,
115
115
  );
116
+ // The exported document has no field for "this cost is a placeholder" —
117
+ // omit `cost` entirely rather than embed a misleading 0 when the model
118
+ // never resolved to a real price (WO-315).
119
+ const costIsUnpriced = !isModelPriced(sessionModel);
120
+ if (costIsUnpriced) {
121
+ ctx.print(`Note: cost omitted from export — no pricing data for model "${sessionModel}".`);
122
+ }
116
123
 
117
124
  const metadata = {
118
125
  model: sessionModel,
@@ -120,7 +127,7 @@ export function registerShareRuntimeCommands(registry: CommandRegistry): void {
120
127
  sessionId: ctx.session.runtime.sessionId,
121
128
  title: ctx.session.conversationManager.title || undefined,
122
129
  };
123
- const options = { redact, cost: sessionCostUsd };
130
+ const options = { redact, cost: costIsUnpriced ? undefined : sessionCostUsd };
124
131
 
125
132
  let outputContent: string;
126
133
  try {