@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.
- package/CHANGELOG.md +69 -98
- package/README.md +16 -7
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
- package/src/cli/entrypoint.ts +2 -0
- package/src/core/conversation-line-cache.ts +432 -0
- package/src/core/conversation-rendering.ts +11 -3
- package/src/core/conversation.ts +90 -4
- package/src/core/stream-event-wiring.ts +104 -2
- package/src/core/stream-stall-watchdog.ts +41 -17
- package/src/export/cost-utils.ts +90 -8
- package/src/input/command-registry.ts +16 -0
- package/src/input/commands/diff-runtime.ts +61 -30
- package/src/input/commands/session-content.ts +12 -0
- package/src/input/commands/session-workflow.ts +3 -0
- package/src/input/commands/share-runtime.ts +9 -2
- package/src/input/handler-content-actions.ts +30 -11
- package/src/input/handler-feed-routes.ts +63 -1
- package/src/input/handler-feed.ts +12 -0
- package/src/input/handler-interactions.ts +2 -0
- package/src/input/handler-types.ts +1 -0
- package/src/input/handler.ts +4 -0
- package/src/input/input-history.ts +17 -4
- package/src/main.ts +27 -23
- package/src/panels/agent-inspector-shared.ts +33 -10
- package/src/panels/base-panel.ts +1 -1
- package/src/panels/builtin/development.ts +1 -1
- package/src/panels/cockpit-read-model.ts +16 -11
- package/src/panels/cost-tracker-panel.ts +28 -5
- package/src/panels/debug-panel.ts +11 -5
- package/src/panels/diff-panel.ts +122 -22
- package/src/panels/docs-panel.ts +9 -0
- package/src/panels/file-explorer-panel.ts +9 -0
- package/src/panels/git-panel.ts +11 -11
- package/src/panels/knowledge-graph-panel.ts +9 -0
- package/src/panels/local-auth-panel.ts +10 -0
- package/src/panels/panel-list-panel.ts +9 -0
- package/src/panels/project-planning-panel.ts +10 -0
- package/src/panels/provider-health-tracker.ts +5 -1
- package/src/panels/provider-health-views.ts +8 -1
- package/src/panels/scrollable-list-panel.ts +9 -0
- package/src/panels/session-browser-panel.ts +9 -0
- package/src/panels/token-budget-panel.ts +5 -3
- package/src/panels/types.ts +13 -0
- package/src/panels/work-plan-panel.ts +10 -0
- package/src/renderer/agent-detail-modal.ts +42 -12
- package/src/renderer/code-block.ts +58 -28
- package/src/renderer/context-inspector.ts +3 -6
- package/src/renderer/conversation-surface.ts +1 -21
- package/src/renderer/diff-view.ts +6 -4
- package/src/renderer/fullscreen-primitives.ts +1 -1
- package/src/renderer/fullscreen-workspace.ts +2 -7
- package/src/renderer/hint-grammar.ts +1 -1
- package/src/renderer/history-search-overlay.ts +10 -16
- package/src/renderer/markdown.ts +9 -1
- package/src/renderer/model-picker-overlay.ts +8 -499
- package/src/renderer/model-workspace.ts +9 -24
- package/src/renderer/overlay-box.ts +7 -9
- package/src/renderer/process-indicator.ts +13 -25
- package/src/renderer/process-modal.ts +17 -2
- package/src/renderer/profile-picker-modal.ts +13 -14
- package/src/renderer/prompt-content-width.ts +16 -0
- package/src/renderer/selection-modal-overlay.ts +3 -1
- package/src/renderer/semantic-diff.ts +1 -1
- package/src/renderer/settings-modal-helpers.ts +10 -34
- package/src/renderer/shell-surface.ts +29 -9
- package/src/renderer/surface-layout.ts +0 -12
- package/src/renderer/term-caps.ts +1 -1
- package/src/renderer/tool-call.ts +6 -20
- package/src/renderer/ui-factory.ts +98 -14
- package/src/renderer/ui-primitives.ts +18 -1
- package/src/runtime/bootstrap-command-context.ts +3 -0
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-core.ts +5 -0
- package/src/runtime/bootstrap-hook-bridge.ts +5 -0
- package/src/runtime/bootstrap-shell.ts +12 -1
- package/src/runtime/render-scheduler.ts +80 -0
- package/src/utils/splash-lines.ts +10 -2
- package/src/version.ts +1 -1
- package/src/renderer/file-tree.ts +0 -153
- package/src/renderer/progress.ts +0 -100
- 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
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
8
|
-
*
|
|
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
|
|
12
|
-
*
|
|
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
|
|
36
|
-
* Receives the provider display name
|
|
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
|
|
80
|
+
let hintFiredForEpisode = false;
|
|
81
|
+
let episodeCount = 0;
|
|
67
82
|
|
|
68
83
|
function arm(): void {
|
|
69
84
|
disarm();
|
|
70
|
-
|
|
85
|
+
hintFiredForEpisode = false;
|
|
71
86
|
stallTimer = setTimeout(() => {
|
|
72
87
|
stallTimer = null;
|
|
73
|
-
if (!
|
|
74
|
-
|
|
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',
|
|
90
|
-
|
|
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),
|
package/src/export/cost-utils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return
|
|
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
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 {
|