@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
@@ -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 {
@@ -427,6 +427,8 @@ export function handleCtrlC(
427
427
  lastCtrlCTime: number,
428
428
  setLastCtrlCTime: (value: number) => void,
429
429
  setShowExitNotice: (value: boolean) => void,
430
+ lastCtrlCTimeoutId: ReturnType<typeof setTimeout> | null,
431
+ setLastCtrlCTimeoutId: (value: ReturnType<typeof setTimeout> | null) => void,
430
432
  ): void {
431
433
  if (prompt.length > 0) {
432
434
  saveUndoState();
@@ -436,17 +438,29 @@ export function handleCtrlC(
436
438
  }
437
439
  cancelGeneration?.();
438
440
  const now = Date.now();
441
+ // Clear any hide-timer pending from a prior press before deciding this
442
+ // one's outcome, whichever branch below runs. Without this, a hide-timer
443
+ // scheduled by an earlier press could still fire after a newer notice
444
+ // window opened, or after exitApp() was just called (if exitApp isn't
445
+ // synchronous) — flipping showExitNotice/requestRender during a shutdown
446
+ // the user already believes is in progress.
447
+ if (lastCtrlCTimeoutId !== null) {
448
+ clearTimeout(lastCtrlCTimeoutId);
449
+ setLastCtrlCTimeoutId(null);
450
+ }
439
451
  if (now - lastCtrlCTime < 1000) {
440
452
  exitApp();
441
- } else {
442
- setLastCtrlCTime(now);
443
- setShowExitNotice(true);
444
- requestRender();
445
- setTimeout(() => {
446
- setShowExitNotice(false);
447
- requestRender();
448
- }, 1000);
453
+ return;
449
454
  }
455
+ setLastCtrlCTime(now);
456
+ setShowExitNotice(true);
457
+ requestRender();
458
+ const timeoutId = setTimeout(() => {
459
+ setShowExitNotice(false);
460
+ setLastCtrlCTimeoutId(null);
461
+ requestRender();
462
+ }, 1000);
463
+ setLastCtrlCTimeoutId(timeoutId);
450
464
  }
451
465
 
452
466
  export function handleClipboardPaste(
@@ -30,6 +30,15 @@ export type PanelFocusRouteState = {
30
30
  handlePathCompletion: () => boolean;
31
31
  cyclePanelTab: (direction: 'next' | 'prev') => void;
32
32
  onPanelInputConsumed?: (activePanel: import('../panels/types.ts').Panel | null, key: string) => void;
33
+ /**
34
+ * True when the tokens produced by the current feed() call carry more than
35
+ * one printable character total (a bracketed paste, or several 1-char text
36
+ * tokens from a fast-typed burst in one stdin chunk). A burst can never be
37
+ * a deliberate single-key panel hotkey, so it must not be exploded into
38
+ * per-char activePanel.handleInput(ch) calls — see the text-token branch
39
+ * below.
40
+ */
41
+ isPrintableBurst: boolean;
33
42
  };
34
43
 
35
44
  export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputToken): {
@@ -113,6 +122,19 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
113
122
 
114
123
  if (token.type === 'text' && token.value) {
115
124
  const activePanel = state.panelManager.getActive();
125
+ // A burst (paste or several fast-typed chars in one stdin chunk) is never
126
+ // a deliberate single-key panel hotkey. Eating it silently as per-char
127
+ // hotkeys leaves the user with no echo and no error, looking exactly
128
+ // like dead keystrokes. Unless the active panel has its own text-capture
129
+ // buffer open (a `/`-search or draft-answer field that deliberately
130
+ // wants every character, burst or not — see isCapturingTextBurst), unfocus
131
+ // the panel and let the token fall through to the prompt route instead,
132
+ // same as it would from an unfocused panel.
133
+ if (state.isPrintableBurst && !activePanel?.isCapturingTextBurst?.()) {
134
+ panelFocused = false;
135
+ state.requestRender();
136
+ return { handled: false, panelFocused };
137
+ }
116
138
  if (activePanel?.handleInput) {
117
139
  for (const ch of token.value) {
118
140
  activePanel.handleInput(ch);
@@ -229,7 +251,15 @@ export function handlePromptTextToken(state: TextRouteState, token: InputToken):
229
251
  }
230
252
  }
231
253
 
232
- if (prompt === '/' && state.commandRegistry) {
254
+ if (prompt === '/') {
255
+ // Arm commandMode as soon as the prompt becomes a bare '/', regardless of
256
+ // whether commandRegistry has been (re)attached yet — this is a one-shot
257
+ // transition (the only place commandMode ever becomes true), and gating
258
+ // it on commandRegistry meant a transient null during a modal/overlay
259
+ // handoff would permanently miss the window: every following keystroke
260
+ // is then processed as plain chat text with no way to recover. Dispatch
261
+ // (handleCommandModeToken, and the enter-key fallback below) still checks
262
+ // commandRegistry itself before actually running anything.
233
263
  commandMode = true;
234
264
  state.modalOpened('command');
235
265
  state.autocomplete?.update('');
@@ -257,6 +287,14 @@ export type KeyRouteState = {
257
287
  indicatorFocused: boolean;
258
288
  conversationManager: ConversationManager | null;
259
289
  commandContext: CommandContext | undefined;
290
+ /**
291
+ * Optional: only used by the enter-key desync safety net below (a stray
292
+ * slash-prefixed submission with commandMode somehow still false). When
293
+ * absent, that fallback simply doesn't trigger — the primary fix (arming
294
+ * commandMode on the '/' keystroke regardless of registry attachment, in
295
+ * handlePromptTextToken above) is what actually prevents the desync.
296
+ */
297
+ commandRegistry?: CommandRegistry | null;
260
298
  autocomplete: AutocompleteEngine | null;
261
299
  blockActionsMenu: { open: (block: BlockMeta) => void };
262
300
  processModal: { open: () => void };
@@ -351,6 +389,30 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
351
389
  return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
352
390
  }
353
391
  if (text) {
392
+ // Safety net for a desynced commandMode: text is command-shaped (starts
393
+ // with '/') but commandMode never armed — e.g. the '/' keystroke landed
394
+ // during a modal/overlay handoff window. handleCommandModeToken (the
395
+ // normal dispatch path for '/name ...') never runs in this state since
396
+ // it early-returns when commandMode is false, so without this the text
397
+ // would fall straight through to submitInput as ordinary chat. Re-derive
398
+ // intent from the literal text instead of trusting commandMode alone.
399
+ if (!commandMode && text.startsWith('/') && state.commandRegistry && state.commandContext?.executeCommand) {
400
+ const parts = text.slice(1).trim().split(/\s+/);
401
+ const name = parts[0];
402
+ const args = parts.slice(1);
403
+ prompt = '';
404
+ cursorPos = 0;
405
+ if (name) {
406
+ const executeCommand = state.commandContext.executeCommand;
407
+ void executeCommand(name, args).then((handled) => {
408
+ if (!handled) {
409
+ state.commandContext?.submitInput?.(text);
410
+ }
411
+ state.requestRender();
412
+ });
413
+ }
414
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
415
+ }
354
416
  const expanded = state.expandPrompt(text);
355
417
  const historyRecallText = typeof expanded === 'string'
356
418
  ? expanded
@@ -180,6 +180,16 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
180
180
  const lineCount = history.getLineCount();
181
181
  const keybindings = context.keybindingsManager;
182
182
 
183
+ // A single stdin chunk can tokenize into several 'text' tokens (fast-typed
184
+ // burst) or one 'text' token with a multi-char value (bracketed paste).
185
+ // Computed once per feed() call, not per token, so per-token cost stays
186
+ // the same O(1) check it always was.
187
+ let totalPrintableChars = 0;
188
+ for (const t of tokens) {
189
+ if (t.type === 'text') totalPrintableChars += t.value.length;
190
+ }
191
+ const isPrintableBurst = totalPrintableChars > 1;
192
+
183
193
  for (const token of tokens) {
184
194
  if (token.type === 'key' && context.keybindingsManager.matches('clear-cancel', token)) {
185
195
  context.handleCtrlC();
@@ -320,6 +330,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
320
330
  panelManager: context.panelManager,
321
331
  keybindingsManager: context.keybindingsManager,
322
332
  onPanelInputConsumed: context.onPanelInputConsumed,
333
+ isPrintableBurst,
323
334
  }, token);
324
335
  context.panelFocused = panelRoute.panelFocused;
325
336
  if (panelRoute.handled) {
@@ -408,6 +419,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
408
419
  indicatorFocused: context.indicatorFocused,
409
420
  conversationManager: context.conversationManager,
410
421
  commandContext: context.commandContext,
422
+ commandRegistry: context.commandRegistry,
411
423
  autocomplete: context.autocomplete,
412
424
  blockActionsMenu: { open: (block: BlockMeta) => context.blockActionsMenu.open(block) },
413
425
  processModal: context.processModal,
@@ -193,6 +193,8 @@ export function handleCtrlCForHandler(handler: InputHandler): void {
193
193
  handler.lastCtrlCTime,
194
194
  (value) => { handler.lastCtrlCTime = value; },
195
195
  (value) => { handler.showExitNotice = value; },
196
+ handler.lastCtrlCTimeoutId,
197
+ (value) => { handler.lastCtrlCTimeoutId = value; },
196
198
  );
197
199
  }
198
200