@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.
- package/CHANGELOG.md +66 -127
- 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.ts +40 -0
- 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 +22 -8
- 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/main.ts +17 -17
- package/src/panels/agent-inspector-shared.ts +33 -10
- 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 +112 -18
- package/src/panels/docs-panel.ts +9 -0
- package/src/panels/file-explorer-panel.ts +9 -0
- package/src/panels/git-panel.ts +9 -9
- 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 +5 -4
- package/src/renderer/process-modal.ts +17 -2
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/ui-factory.ts +91 -7
- 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/version.ts +1 -1
|
@@ -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 {
|
|
@@ -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
|
-
|
|
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 === '/'
|
|
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
|
|