@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
package/src/panels/git-panel.ts
CHANGED
|
@@ -178,11 +178,9 @@ export class GitPanel extends BasePanel {
|
|
|
178
178
|
const changed = this.getChangedFiles ? new Set(this.getChangedFiles()) : null;
|
|
179
179
|
const markChanged = (path: string): boolean | undefined => (changed ? changed.has(path) : undefined);
|
|
180
180
|
|
|
181
|
-
// Classify from the raw per-file porcelain columns (index/working_dir)
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// 'M', working_dir ' '), which would otherwise show it as both staged
|
|
185
|
-
// AND unstaged and make 's'/'u' land on the wrong (phantom) row.
|
|
181
|
+
// Classify from the raw per-file porcelain columns (index/working_dir), not simple-git's
|
|
182
|
+
// modified/staged arrays: those double-count a staged-with-no-further-edits file (index 'M',
|
|
183
|
+
// working_dir ' ') as both staged AND unstaged, landing 's'/'u' on a phantom row.
|
|
186
184
|
const stagedFiles: GitFileEntry[] = [];
|
|
187
185
|
const unstagedFiles: GitFileEntry[] = [];
|
|
188
186
|
for (const f of statusResult.files) {
|
|
@@ -278,6 +276,11 @@ export class GitPanel extends BasePanel {
|
|
|
278
276
|
|
|
279
277
|
// Input handling
|
|
280
278
|
|
|
279
|
+
/** Commit-message entry wants every char of a burst delivered one at a time — see Panel.isCapturingTextBurst. */
|
|
280
|
+
isCapturingTextBurst(): boolean {
|
|
281
|
+
return this.commitMessage !== null;
|
|
282
|
+
}
|
|
283
|
+
|
|
281
284
|
handleInput(key: string): boolean {
|
|
282
285
|
// Confirm (init repo / commit) takes priority over everything else.
|
|
283
286
|
const confirmResult = handleConfirmInput(this.confirm, key);
|
|
@@ -728,10 +731,7 @@ export class GitPanel extends BasePanel {
|
|
|
728
731
|
});
|
|
729
732
|
}
|
|
730
733
|
|
|
731
|
-
/**
|
|
732
|
-
* Map an item index in `this.items` to the row index in the rendered row list.
|
|
733
|
-
* Header items expand to 2 rows (branch + spacer).
|
|
734
|
-
*/
|
|
734
|
+
/** Map an item index in `this.items` to its rendered row index (header items expand to 2 rows: branch + spacer). */
|
|
735
735
|
private getRowIndexForItem(itemIndex: number): number {
|
|
736
736
|
let row = 0;
|
|
737
737
|
for (let i = 0; i < itemIndex && i < this.items.length; i++) {
|
|
@@ -266,6 +266,15 @@ export class KnowledgeGraphPanel extends ScrollableListPanel<BrowseRow> {
|
|
|
266
266
|
// Input
|
|
267
267
|
// ---------------------------------------------------------------------------
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The `/`-to-search buffer wants every character of a burst (paste, or a
|
|
271
|
+
* fast-typed query landing in one input.feed() call), same as it always
|
|
272
|
+
* has — see the interface doc on `Panel.isCapturingTextBurst`.
|
|
273
|
+
*/
|
|
274
|
+
override isCapturingTextBurst(): boolean {
|
|
275
|
+
return this.searchFocused;
|
|
276
|
+
}
|
|
277
|
+
|
|
269
278
|
handleInput(key: string): boolean {
|
|
270
279
|
if (this.lastError !== null) this.clearError();
|
|
271
280
|
|
|
@@ -96,6 +96,16 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
|
|
|
96
96
|
return this.maskedState !== null;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Masked password/username entry wants every character of a burst (paste,
|
|
101
|
+
* or fast typing landing in one input.feed() call) delivered one at a
|
|
102
|
+
* time, same as it always has — see the interface doc on
|
|
103
|
+
* `Panel.isCapturingTextBurst`.
|
|
104
|
+
*/
|
|
105
|
+
public override isCapturingTextBurst(): boolean {
|
|
106
|
+
return this.isMaskedEntryActive || this.usernameEntry !== null || super.isCapturingTextBurst();
|
|
107
|
+
}
|
|
108
|
+
|
|
99
109
|
public override handleInput(key: KeyName): boolean {
|
|
100
110
|
// Masked entry takes priority when active — it must capture every
|
|
101
111
|
// keystroke (including letters that would otherwise be p/a/d/b actions)
|
|
@@ -224,6 +224,15 @@ export class PanelListPanel extends BasePanel {
|
|
|
224
224
|
});
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/**
|
|
228
|
+
* The `/`-to-filter buffer wants every character of a burst (paste, or a
|
|
229
|
+
* fast-typed query landing in one input.feed() call), same as it always
|
|
230
|
+
* has — see the interface doc on `Panel.isCapturingTextBurst`.
|
|
231
|
+
*/
|
|
232
|
+
public isCapturingTextBurst(): boolean {
|
|
233
|
+
return this._filterActive;
|
|
234
|
+
}
|
|
235
|
+
|
|
227
236
|
public handleInput(key: string): boolean {
|
|
228
237
|
const filterResult = this._handleFilterKey(key);
|
|
229
238
|
if (filterResult !== null) return filterResult;
|
|
@@ -81,6 +81,16 @@ export class ProjectPlanningPanel extends BasePanel {
|
|
|
81
81
|
this.refresh();
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The draft-answer text field wants every character of a burst (paste, or
|
|
86
|
+
* fast typing landing in one input.feed() call) delivered one at a time,
|
|
87
|
+
* same as it always has — see the interface doc on
|
|
88
|
+
* `Panel.isCapturingTextBurst`.
|
|
89
|
+
*/
|
|
90
|
+
public isCapturingTextBurst(): boolean {
|
|
91
|
+
return this.getCurrentQuestion() !== null;
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
public handleInput(key: string): boolean {
|
|
85
95
|
if (this.lastError !== null) this.clearError();
|
|
86
96
|
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
ProviderHealthRecord,
|
|
6
6
|
ProviderStatus,
|
|
7
7
|
} from '@/runtime/index.ts';
|
|
8
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
8
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
9
9
|
|
|
10
10
|
export type { ProviderStatus };
|
|
11
11
|
|
|
@@ -40,6 +40,8 @@ export interface ProviderHealth {
|
|
|
40
40
|
totalTokens: number;
|
|
41
41
|
/** Session USD cost accumulated per call via calcSessionCost. */
|
|
42
42
|
totalCostUsd: number;
|
|
43
|
+
/** True when at least one call's model resolved to no real price (cost-utils isModelPriced === false) — totalCostUsd may understate the true cost. */
|
|
44
|
+
hasUnpricedModel: boolean;
|
|
43
45
|
/** Ring buffer of recent request latencies in ms (most-recent last). */
|
|
44
46
|
latencies: number[];
|
|
45
47
|
}
|
|
@@ -237,6 +239,7 @@ export class ProviderHealthTracker {
|
|
|
237
239
|
cacheWriteTokens: 0,
|
|
238
240
|
totalTokens: 0,
|
|
239
241
|
totalCostUsd: 0,
|
|
242
|
+
hasUnpricedModel: false,
|
|
240
243
|
latencies: [],
|
|
241
244
|
};
|
|
242
245
|
this.records.set(name, record);
|
|
@@ -259,6 +262,7 @@ export class ProviderHealthTracker {
|
|
|
259
262
|
const model = usage.model ?? record.lastModelId;
|
|
260
263
|
if (model) {
|
|
261
264
|
record.totalCostUsd += calcSessionCost(input, output, cacheRead, cacheWrite, model);
|
|
265
|
+
if (!isModelPriced(model)) record.hasUnpricedModel = true;
|
|
262
266
|
}
|
|
263
267
|
}
|
|
264
268
|
|
|
@@ -357,7 +357,14 @@ export function buildProviderRow(width: number, tones: ProviderConsoleTones, inp
|
|
|
357
357
|
{ text: errPct, fg: hasCalls && (entry?.errorRate ?? 0) > 0 ? tones.bad : tones.dim },
|
|
358
358
|
{ text: buildSparkline(entry?.timeline.points ?? []), fg: hasCalls ? latencyColor(tones, avgMs) : tones.dim },
|
|
359
359
|
{ text: health && health.totalTokens > 0 ? fmtTokens(health.totalTokens) : '-', fg: tones.dim },
|
|
360
|
-
{
|
|
360
|
+
{
|
|
361
|
+
// Tokens flowed but the model never resolved to a real price — say so
|
|
362
|
+
// instead of collapsing into the same '-' shown for "no calls yet".
|
|
363
|
+
text: health && health.totalTokens > 0
|
|
364
|
+
? (health.hasUnpricedModel ? 'unpriced' : fmtUsd(health.totalCostUsd))
|
|
365
|
+
: '-',
|
|
366
|
+
fg: health?.hasUnpricedModel ? tones.dim : tones.value,
|
|
367
|
+
},
|
|
361
368
|
{ text: account ? account.authFreshness : '-', fg: account ? freshnessColor(tones, account.authFreshness) : tones.dim },
|
|
362
369
|
],
|
|
363
370
|
PROVIDER_TABLE_COLUMNS,
|
|
@@ -242,6 +242,15 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
|
|
|
242
242
|
* Returns `true` if the key was consumed, `false` to let the panel manager try another
|
|
243
243
|
* handler.
|
|
244
244
|
*/
|
|
245
|
+
/**
|
|
246
|
+
* The `/`-to-filter buffer wants every character of a burst (paste, or a
|
|
247
|
+
* fast-typed query landing in one input.feed() call), same as it always
|
|
248
|
+
* has — see the interface doc on `Panel.isCapturingTextBurst`.
|
|
249
|
+
*/
|
|
250
|
+
isCapturingTextBurst(): boolean {
|
|
251
|
+
return this.filterEnabled && this.filterActive;
|
|
252
|
+
}
|
|
253
|
+
|
|
245
254
|
handleInput(key: string): boolean {
|
|
246
255
|
// I2: auto-clear transient errors on the next keystroke so stale errors don't linger.
|
|
247
256
|
// Subclasses that override handleInput should call super.handleInput(key) OR manually
|
|
@@ -139,6 +139,15 @@ export class SessionBrowserPanel extends BasePanel {
|
|
|
139
139
|
super.onDeactivate();
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The `/`-to-search buffer wants every character of a burst (paste, or a
|
|
144
|
+
* fast-typed query landing in one input.feed() call), same as it always
|
|
145
|
+
* has — see the interface doc on `Panel.isCapturingTextBurst`.
|
|
146
|
+
*/
|
|
147
|
+
isCapturingTextBurst(): boolean {
|
|
148
|
+
return this.searching;
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
handleInput(key: string): boolean {
|
|
143
152
|
// Confirmation dialog — use shared handleConfirmInput for y/n/Esc UX
|
|
144
153
|
const confirmResult = handleConfirmInput(this.confirm, key);
|
|
@@ -7,7 +7,7 @@ import type { TurnEvent } from '@/runtime/index.ts';
|
|
|
7
7
|
import type { UiEventFeed } from '../runtime/ui-events.ts';
|
|
8
8
|
import type { UiReadModel, UiSessionSnapshot } from '../runtime/ui-read-models.ts';
|
|
9
9
|
import type { SessionMemoryQuery } from '../runtime/ui-service-queries.ts';
|
|
10
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
10
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
11
11
|
import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
|
|
12
12
|
import type { PanelIntegrationContext } from './types.ts';
|
|
13
13
|
import {
|
|
@@ -615,10 +615,12 @@ export class TokenBudgetPanel extends BasePanel {
|
|
|
615
615
|
// Inline cost estimate — absorbed pricing wiring (cost-utils.ts), shown
|
|
616
616
|
// whenever the active model id is known.
|
|
617
617
|
if (this.getCurrentModelId) {
|
|
618
|
-
const
|
|
618
|
+
const modelId = this.getCurrentModelId();
|
|
619
|
+
const priced = isModelPriced(modelId);
|
|
620
|
+
const cost = calcSessionCost(u.input, u.output, u.cacheRead, u.cacheWrite, modelId);
|
|
619
621
|
lines.push(buildStyledPanelLine(width, [
|
|
620
622
|
{ text: ' Cost: ', fg: C.label },
|
|
621
|
-
{ text: `$${cost.toFixed(4)}
|
|
623
|
+
{ text: priced ? `$${cost.toFixed(4)}` : 'unpriced', fg: C.value, bold: true },
|
|
622
624
|
]));
|
|
623
625
|
}
|
|
624
626
|
|
package/src/panels/types.ts
CHANGED
|
@@ -100,6 +100,19 @@ export interface Panel {
|
|
|
100
100
|
// Input (optional)
|
|
101
101
|
handleInput?(key: KeyName): boolean;
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Optional: report `true` while this panel has an active inline text
|
|
105
|
+
* capture (e.g. a `/`-to-filter or search buffer) that deliberately wants
|
|
106
|
+
* every character of a burst — paste, or several keystrokes typed fast
|
|
107
|
+
* enough to land in one `input.feed()` call — delivered to `handleInput`
|
|
108
|
+
* one at a time, same as it always has. When this returns `false` or is
|
|
109
|
+
* not implemented, the input router treats a printable burst as never a
|
|
110
|
+
* deliberate single-key panel hotkey and routes it to the composer
|
|
111
|
+
* instead of exploding it into per-char `handleInput` calls (see
|
|
112
|
+
* `handlePanelFocusToken` in `src/input/handler-feed-routes.ts`).
|
|
113
|
+
*/
|
|
114
|
+
isCapturingTextBurst?(): boolean;
|
|
115
|
+
|
|
103
116
|
// Scroll input (optional)
|
|
104
117
|
// Positive delta scrolls down; negative delta scrolls up.
|
|
105
118
|
handleScroll?(deltaRows: number): boolean;
|
|
@@ -100,6 +100,16 @@ export class WorkPlanPanel extends ScrollableListPanel<WorkPlanItem> {
|
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The add/edit draft form's title/owner/notes fields want every character
|
|
105
|
+
* of a burst (paste, or fast typing landing in one input.feed() call)
|
|
106
|
+
* delivered one at a time, same as it always has — see the interface doc
|
|
107
|
+
* on `Panel.isCapturingTextBurst`.
|
|
108
|
+
*/
|
|
109
|
+
isCapturingTextBurst(): boolean {
|
|
110
|
+
return this.draftMode !== null;
|
|
111
|
+
}
|
|
112
|
+
|
|
103
113
|
handleInput(key: string): boolean {
|
|
104
114
|
if (this.lastError !== null) this.clearError();
|
|
105
115
|
|
|
@@ -9,7 +9,7 @@ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
|
9
9
|
import { getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
|
|
10
10
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
11
11
|
import { handleConfirmInput, type ConfirmState } from '../panels/confirm-state.ts';
|
|
12
|
-
import { AGENT_TERMINAL_STATUSES as MODAL_TERMINAL_STATUSES, AGENT_STALL_THRESHOLD_MS as MODAL_STALL_THRESHOLD_MS } from '../panels/agent-inspector-shared.ts';
|
|
12
|
+
import { AGENT_TERMINAL_STATUSES as MODAL_TERMINAL_STATUSES, AGENT_STALL_THRESHOLD_MS as MODAL_STALL_THRESHOLD_MS, hasReportedUsage } from '../panels/agent-inspector-shared.ts';
|
|
13
13
|
import { UI_TONES } from './ui-primitives.ts';
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -283,10 +283,11 @@ export function renderAgentDetailModal(
|
|
|
283
283
|
|
|
284
284
|
// Metrics
|
|
285
285
|
sections.push({ type: 'text', content: `Tool calls : ${rec.toolCallCount}` });
|
|
286
|
-
if (rec.usage) {
|
|
287
|
-
const
|
|
286
|
+
if (hasReportedUsage(rec.usage)) {
|
|
287
|
+
const usage = rec.usage;
|
|
288
|
+
const totalIn = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
|
|
288
289
|
sections.push({ type: 'text', content: `Tokens in : ${totalIn.toLocaleString()}` });
|
|
289
|
-
sections.push({ type: 'text', content: `Tokens out : ${
|
|
290
|
+
sections.push({ type: 'text', content: `Tokens out : ${usage.outputTokens.toLocaleString()}` });
|
|
290
291
|
} else {
|
|
291
292
|
sections.push({
|
|
292
293
|
type: 'text',
|
|
@@ -85,7 +85,7 @@ function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
|
|
|
85
85
|
if (task.startsWith('WRFC Review Request')) {
|
|
86
86
|
const thresholdMatch = task.match(/threshold is (\d+(?:\.\d+)?)/);
|
|
87
87
|
const threshold = thresholdMatch ? thresholdMatch[1] : '9.9';
|
|
88
|
-
const desc = truncateFirst(originalTask ?? 'review in progress', 50);
|
|
88
|
+
const desc = truncateFirst(originalTask ?? embeddedTaskDescription(task) ?? 'review in progress', 50);
|
|
89
89
|
return `[Review] ${desc} (target: ${threshold}/10)`;
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -96,7 +96,7 @@ function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
|
|
|
96
96
|
const toScore = scoreMatch ? scoreMatch[3] : '?';
|
|
97
97
|
const attemptMatch = task.match(/Fix attempt:\s*(\d+)/);
|
|
98
98
|
const attempt = attemptMatch ? attemptMatch[1] : '?';
|
|
99
|
-
const desc = truncateFirst(originalTask ?? 'fix in progress', 45);
|
|
99
|
+
const desc = truncateFirst(originalTask ?? embeddedTaskDescription(task) ?? 'fix in progress', 45);
|
|
100
100
|
// Show constraint count when the chain has constraints to target (SDK 0.23.0)
|
|
101
101
|
const chain = rec.wrfcId ? safeGetChain(rec.wrfcId, deps) : null;
|
|
102
102
|
const constraintCount = chain && (chain.constraints?.length ?? 0) > 0 ? chain.constraints?.length ?? 0 : 0;
|
|
@@ -441,6 +441,21 @@ function getChainTask(wrfcId: string | undefined, deps: Pick<ProcessModalDeps, '
|
|
|
441
441
|
return safeGetChain(wrfcId, deps)?.task ?? null;
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Fallback for when the WRFC chain lookup comes back null (chain already
|
|
446
|
+
* completed/evicted, or wrfcId not populated on the record) — the record's
|
|
447
|
+
* own `rec.task` is seeded as `'WRFC Review Request\n<original task
|
|
448
|
+
* description>'` / `'WRFC Fix Request\n...'`, so the description is sitting
|
|
449
|
+
* right there even without the chain. Returns null (not the generic
|
|
450
|
+
* placeholder) when there's no second line to extract.
|
|
451
|
+
*/
|
|
452
|
+
function embeddedTaskDescription(task: string): string | null {
|
|
453
|
+
const firstNewline = task.indexOf('\n');
|
|
454
|
+
if (firstNewline === -1) return null;
|
|
455
|
+
const desc = task.slice(firstNewline + 1).trim();
|
|
456
|
+
return desc.length > 0 ? desc : null;
|
|
457
|
+
}
|
|
458
|
+
|
|
444
459
|
/** Truncate to first line, capped at max chars. */
|
|
445
460
|
function truncateFirst(text: string, max: number): string {
|
|
446
461
|
const line = text.split('\n')[0].trim();
|
|
@@ -8,6 +8,13 @@ export interface ShellFooterBuildOptions {
|
|
|
8
8
|
readonly promptLineCount: number;
|
|
9
9
|
readonly promptCursorPos?: number;
|
|
10
10
|
readonly promptFocused?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* True when the panel workspace owns keyboard focus. Only consulted as a
|
|
13
|
+
* fallback when `promptFocused` is not supplied — see buildShellFooter's
|
|
14
|
+
* default expression. Callers that already compute `promptFocused`
|
|
15
|
+
* themselves (main.ts does) do not need to also pass this.
|
|
16
|
+
*/
|
|
17
|
+
readonly panelFocused?: boolean;
|
|
11
18
|
readonly usage: { up: number; down: number };
|
|
12
19
|
readonly showExitNotice: boolean;
|
|
13
20
|
readonly lastCopyTime: number;
|
|
@@ -107,7 +114,7 @@ export function buildShellFooter(
|
|
|
107
114
|
options.lastInputTokens,
|
|
108
115
|
options.commandArgsHint,
|
|
109
116
|
options.hitlMode,
|
|
110
|
-
options.promptFocused ?? !options.indicatorFocused,
|
|
117
|
+
options.promptFocused ?? (!options.indicatorFocused && !options.panelFocused),
|
|
111
118
|
options.composerMode,
|
|
112
119
|
options.composerStatus,
|
|
113
120
|
options.composerFlags,
|
|
@@ -8,13 +8,36 @@ import { GLYPHS, UI_TONES } from './ui-primitives.ts';
|
|
|
8
8
|
import { formatElapsed } from '../utils/format-elapsed.ts';
|
|
9
9
|
import { abbreviateCount } from '../utils/format-number.ts';
|
|
10
10
|
import { computeContextUsage } from '../core/context-usage.ts';
|
|
11
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
11
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
12
12
|
import { buildFooterTip, isAgentActive } from './footer-tips.ts';
|
|
13
|
+
import type { StreamMetrics } from '../core/stream-event-wiring.ts';
|
|
13
14
|
|
|
14
15
|
/** Number of frames before the animated gradient completes one full cycle. */
|
|
15
16
|
const GRADIENT_CYCLE_FRAMES = 50;
|
|
16
17
|
/** Number of frames before rotating to the next thinking phrase (~30 seconds at 80ms/frame). */
|
|
17
18
|
const PHRASE_ROTATION_FRAMES = 375;
|
|
19
|
+
/**
|
|
20
|
+
* Ms since the last STREAM_DELTA before the whimsical phrase rotation freezes
|
|
21
|
+
* and createThinkingFragment shows an honest "stalled Ns" / "reconnecting"
|
|
22
|
+
* label instead. Deliberately much shorter than the 30s stream-stall-watchdog
|
|
23
|
+
* hint threshold (stream-stall-watchdog.ts) — that threshold gates a
|
|
24
|
+
* low-priority system message about a likely-dead connection; this one gates
|
|
25
|
+
* a cosmetic label so the UI stops claiming "Vibing..." within a couple of
|
|
26
|
+
* seconds of real silence, well before the stall is confirmed as a problem.
|
|
27
|
+
*/
|
|
28
|
+
const THINKING_STALL_FREEZE_MS = 2_500;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Stall/reconnect state for the live thinking indicator, computed by the
|
|
32
|
+
* caller every render frame from streamMetrics (see stream-event-wiring.ts).
|
|
33
|
+
* `reconnect` is populated only once the SDK's STREAM_RETRY event fires
|
|
34
|
+
* (structurally consumed — absent from SDK 0.35.0's TurnEvent union today).
|
|
35
|
+
*/
|
|
36
|
+
export interface ThinkingStallInfo {
|
|
37
|
+
/** Ms since the last STREAM_DELTA (or STREAM_START if none yet this turn). */
|
|
38
|
+
readonly msSinceLastDelta: number;
|
|
39
|
+
readonly reconnect?: { readonly attempt: number; readonly maxAttempts: number };
|
|
40
|
+
}
|
|
18
41
|
|
|
19
42
|
/** Build the git segment string and its display width. Single source of truth for header layout. */
|
|
20
43
|
function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number } {
|
|
@@ -313,7 +336,15 @@ export class UIFactory {
|
|
|
313
336
|
const cw = u.cacheWrite ?? 0;
|
|
314
337
|
const total = inp + out + cr + cw;
|
|
315
338
|
const tokenSep = ` ${GLYPHS.navigation.pipeSeparator} `;
|
|
316
|
-
|
|
339
|
+
// 'n/a' (not 'unpriced') to stay compact in the single-line footer and
|
|
340
|
+
// match the existing "no priceable data" convention used elsewhere
|
|
341
|
+
// (cockpit-panel formatCost, agent-inspector-shared) — the footer has no
|
|
342
|
+
// room for a longer marker before truncation kicks in.
|
|
343
|
+
const costSegment = model
|
|
344
|
+
? isModelPriced(model)
|
|
345
|
+
? `${tokenSep}~$${fmtCost(calcSessionCost(inp, out, cr, cw, model))}`
|
|
346
|
+
: `${tokenSep}~n/a`
|
|
347
|
+
: '';
|
|
317
348
|
const tokenLine = ` Token Usage [ Input: ${fmtNum(inp)}${tokenSep}Output: ${fmtNum(out)}${tokenSep}Cache Read: ${fmtNum(cr)}${tokenSep}Cache Write: ${fmtNum(cw)}${tokenSep}Total: ${fmtNum(total)}${costSegment} ]`;
|
|
318
349
|
const copiedNotice = isRecentlyCopied ? ` [COPIED] ` : '';
|
|
319
350
|
const statsLine = ' ' + tokenLine + ' '.repeat(Math.max(0, width - 4 - getDisplayWidth(tokenLine) - getDisplayWidth(copiedNotice))) + copiedNotice;
|
|
@@ -345,7 +376,11 @@ export class UIFactory {
|
|
|
345
376
|
ctxParts.push(model + (provider ? ` (${provider})` : ''));
|
|
346
377
|
}
|
|
347
378
|
if (toolCount) ctxParts.push(`${toolCount} tools`);
|
|
348
|
-
|
|
379
|
+
// Labeled "notify" (not "hitl") — /mode (aliased /hitl) governs UX
|
|
380
|
+
// notification verbosity (quiet/balanced/operator), not tool
|
|
381
|
+
// auto-approval, so it must not share vocabulary with the DANGER MODE
|
|
382
|
+
// risk banner rendered a few lines below.
|
|
383
|
+
if (hitlMode) ctxParts.push(`notify:${hitlMode}`);
|
|
349
384
|
const ctxLine = ' ' + ctxParts.join(` ${GLYPHS.navigation.pipeSeparator} `);
|
|
350
385
|
lines.push(this.stringToLine(truncateDisplay(ctxLine, width), width, { fg: '240', dim: true }));
|
|
351
386
|
}
|
|
@@ -406,10 +441,59 @@ export class UIFactory {
|
|
|
406
441
|
private static readonly THINK_GRADIENT_START = UI_TONES.accent.gradientStart;
|
|
407
442
|
private static readonly THINK_GRADIENT_END = UI_TONES.accent.gradientEnd;
|
|
408
443
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
444
|
+
/**
|
|
445
|
+
* Per-frame stall info from stream metrics — computed from lastDeltaAtMs every render (not
|
|
446
|
+
* from any event) so it degrades gracefully with zero new SDK events. Undefined until the
|
|
447
|
+
* first delta clock exists this turn.
|
|
448
|
+
*/
|
|
449
|
+
public static computeStallInfo(lastDeltaAtMs: number | undefined, reconnectAttempt: number | undefined, reconnectMaxAttempts: number | undefined, nowMs: number): ThinkingStallInfo | undefined {
|
|
450
|
+
if (lastDeltaAtMs === undefined) return undefined;
|
|
451
|
+
const reconnect = reconnectAttempt !== undefined && reconnectMaxAttempts !== undefined
|
|
452
|
+
? { attempt: reconnectAttempt, maxAttempts: reconnectMaxAttempts }
|
|
453
|
+
: undefined;
|
|
454
|
+
return { msSinceLastDelta: nowMs - lastDeltaAtMs, reconnect };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Render-frame stall-info decision used at the main render loop's call
|
|
459
|
+
* site: suppress stall detection entirely while a tool is actively
|
|
460
|
+
* executing. lastDeltaAtMs only tracks STREAM_START/STREAM_DELTA and is
|
|
461
|
+
* never advanced during tool execution (the model isn't producing tokens
|
|
462
|
+
* then), so without this gate any tool call longer than
|
|
463
|
+
* THINKING_STALL_FREEZE_MS would make the thinking fragment print
|
|
464
|
+
* "Stalled Ns..." directly above the ticking "executing (Ns)" tool row — a
|
|
465
|
+
* false positive during ordinary tool execution (see stream-event-wiring.ts
|
|
466
|
+
* TOOL_EXECUTING/TOOL_SUCCEEDED/TOOL_FAILED/TOOL_CANCELLED handlers).
|
|
467
|
+
* Genuine no-delta silence while waiting on the provider — including the
|
|
468
|
+
* pre-first-token case, where lastDeltaAtMs is seeded at STREAM_START —
|
|
469
|
+
* still stall-detects normally here, since no tool is active then; that is
|
|
470
|
+
* the honest stall case this indicator exists for.
|
|
471
|
+
*/
|
|
472
|
+
public static computeRenderStallInfo(
|
|
473
|
+
metrics: Pick<StreamMetrics, 'activeToolName' | 'lastDeltaAtMs' | 'reconnectAttempt' | 'reconnectMaxAttempts'>,
|
|
474
|
+
nowMs: number,
|
|
475
|
+
): ThinkingStallInfo | undefined {
|
|
476
|
+
return metrics.activeToolName === undefined
|
|
477
|
+
? this.computeStallInfo(metrics.lastDeltaAtMs, metrics.reconnectAttempt, metrics.reconnectMaxAttempts, nowMs)
|
|
478
|
+
: undefined;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number, stallInfo?: ThinkingStallInfo): Line[] {
|
|
482
|
+
// Freeze the whimsical phrase rotation once real silence has gone on
|
|
483
|
+
// long enough to be misleading (THINKING_STALL_FREEZE_MS), and show an
|
|
484
|
+
// honest label instead: the SDK's reconnect attempt/maxAttempts once
|
|
485
|
+
// STREAM_RETRY is available, else a plain elapsed-silence readout.
|
|
486
|
+
const isStalled = stallInfo !== undefined && stallInfo.msSinceLastDelta >= THINKING_STALL_FREEZE_MS;
|
|
487
|
+
let phrase: string;
|
|
488
|
+
if (stallInfo?.reconnect) {
|
|
489
|
+
phrase = `Reconnecting (attempt ${stallInfo.reconnect.attempt}/${stallInfo.reconnect.maxAttempts})...`;
|
|
490
|
+
} else if (isStalled) {
|
|
491
|
+
phrase = `Stalled ${Math.floor(stallInfo.msSinceLastDelta / 1000)}s...`;
|
|
492
|
+
} else {
|
|
493
|
+
// Rotate phrase every ~30 seconds (frame ticks at 80ms, so ~375 frames)
|
|
494
|
+
const phraseIndex = Math.floor(frame / PHRASE_ROTATION_FRAMES) % this.THINKING_PHRASES.length;
|
|
495
|
+
phrase = this.THINKING_PHRASES[phraseIndex];
|
|
496
|
+
}
|
|
413
497
|
const speedSuffix = (tokenSpeed !== undefined && tokenSpeed > 0) ? ` (${Math.round(tokenSpeed)} tok/s)` : '';
|
|
414
498
|
const elapsedSuffix = elapsedMs !== undefined ? ` (${formatElapsed(elapsedMs)})` : '';
|
|
415
499
|
const ttftSuffix = (ttftMs !== undefined && ttftMs > 0) ? ` ttft:${ttftMs}ms` : '';
|
|
@@ -114,6 +114,7 @@ export type CreateBootstrapCommandContextOptions = {
|
|
|
114
114
|
sessionLineageTracker?: import('@pellux/goodvibes-sdk/platform/core').SessionLineageTracker;
|
|
115
115
|
wrfcController?: import('@pellux/goodvibes-sdk/platform/agents').WrfcController;
|
|
116
116
|
componentHealthMonitor: import('@/runtime/index.ts').ComponentHealthMonitor;
|
|
117
|
+
hydrateSessionUsage?: () => void;
|
|
117
118
|
};
|
|
118
119
|
|
|
119
120
|
export function createBootstrapCommandContext(
|
|
@@ -167,6 +168,7 @@ export function createBootstrapCommandContext(
|
|
|
167
168
|
sessionLineageTracker,
|
|
168
169
|
wrfcController,
|
|
169
170
|
changeTracker,
|
|
171
|
+
hydrateSessionUsage,
|
|
170
172
|
planManager,
|
|
171
173
|
adaptivePlanner,
|
|
172
174
|
sessionOrchestration,
|
|
@@ -223,6 +225,7 @@ export function createBootstrapCommandContext(
|
|
|
223
225
|
sessionLineageTracker,
|
|
224
226
|
wrfcController,
|
|
225
227
|
changeTracker,
|
|
228
|
+
hydrateSessionUsage,
|
|
226
229
|
});
|
|
227
230
|
const provider = createBootstrapCommandProviderSection({
|
|
228
231
|
providerRegistry,
|
|
@@ -109,6 +109,7 @@ export interface BootstrapCommandSectionOptions {
|
|
|
109
109
|
readonly sessionLineageTracker?: import('@pellux/goodvibes-sdk/platform/core').SessionLineageTracker;
|
|
110
110
|
readonly wrfcController?: import('@pellux/goodvibes-sdk/platform/agents').WrfcController;
|
|
111
111
|
readonly changeTracker?: import('@pellux/goodvibes-sdk/platform/sessions').SessionChangeTracker;
|
|
112
|
+
readonly hydrateSessionUsage?: () => void;
|
|
112
113
|
readonly agentManager?: ShellAgentManagerService;
|
|
113
114
|
readonly modeManager?: ShellModeManagerService;
|
|
114
115
|
readonly automationManager?: ShellAutomationManagerRuntimeService;
|
|
@@ -300,7 +301,7 @@ export function createBootstrapCommandActions(
|
|
|
300
301
|
export function createBootstrapCommandSessionSection(
|
|
301
302
|
options: Pick<
|
|
302
303
|
BootstrapCommandSectionOptions,
|
|
303
|
-
'conversation' | 'runtime' | 'sessionManager' | 'sessionMemoryStore' | 'sessionLineageTracker' | 'wrfcController' | 'changeTracker'
|
|
304
|
+
'conversation' | 'runtime' | 'sessionManager' | 'sessionMemoryStore' | 'sessionLineageTracker' | 'wrfcController' | 'changeTracker' | 'hydrateSessionUsage'
|
|
304
305
|
>,
|
|
305
306
|
): BootstrapCommandSessionSection {
|
|
306
307
|
return {
|
|
@@ -311,6 +312,7 @@ export function createBootstrapCommandSessionSection(
|
|
|
311
312
|
sessionLineageTracker: options.sessionLineageTracker,
|
|
312
313
|
wrfcController: options.wrfcController,
|
|
313
314
|
changeTracker: options.changeTracker,
|
|
315
|
+
hydrateSessionUsage: options.hydrateSessionUsage,
|
|
314
316
|
};
|
|
315
317
|
}
|
|
316
318
|
|
|
@@ -28,6 +28,7 @@ import { loadBootstrapSystemPrompt, syncConfiguredServices } from '@/runtime/ind
|
|
|
28
28
|
import { registerBootstrapHookBridge } from '@/runtime/index.ts';
|
|
29
29
|
import { registerBootstrapRuntimeEvents } from '@/runtime/index.ts';
|
|
30
30
|
import { createRuntimeServices, type RuntimeServices } from './services.ts';
|
|
31
|
+
import { setPricingSource } from '../export/cost-utils.ts';
|
|
31
32
|
import { createUiRuntimeServices, type UiRuntimeServices } from './ui-services.ts';
|
|
32
33
|
import { join } from 'node:path';
|
|
33
34
|
import { installWrfcAgentToolGuard } from '../tools/wrfc-agent-guard.ts';
|
|
@@ -255,6 +256,10 @@ export async function initializeBootstrapCore(
|
|
|
255
256
|
providerRegistry.initModelLimits();
|
|
256
257
|
services.benchmarkStore.initBenchmarks();
|
|
257
258
|
providerRegistry.initCatalog();
|
|
259
|
+
// Wire cost-utils to the live catalog so cost displays distinguish real
|
|
260
|
+
// pricing from unpriced (WO-315) instead of silently reading zero for any
|
|
261
|
+
// model the small static fallback table doesn't cover.
|
|
262
|
+
setPricingSource(() => providerRegistry.getRawCatalogModels());
|
|
258
263
|
services.keybindingsManager.loadFromDisk();
|
|
259
264
|
domainDispatch.syncControlPlaneState({
|
|
260
265
|
enabled: Boolean(configManager.get('controlPlane.enabled')),
|
|
@@ -29,6 +29,8 @@ export interface ResumeSessionOptions {
|
|
|
29
29
|
readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
|
|
30
30
|
readonly providerRegistry: Pick<ProviderRegistry, 'get' | 'getCurrentModel' | 'getForModel' | 'require'>;
|
|
31
31
|
readonly homeDirectory: string;
|
|
32
|
+
/** See CommandSessionServices.hydrateSessionUsage (command-registry.ts). */
|
|
33
|
+
readonly hydrateSessionUsage?: () => void;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export function createResumeSessionHandler(options: ResumeSessionOptions): (sessionId: string) => void {
|
|
@@ -65,6 +67,9 @@ export function createResumeSessionHandler(options: ResumeSessionOptions): (sess
|
|
|
65
67
|
});
|
|
66
68
|
},
|
|
67
69
|
});
|
|
70
|
+
// Hydrate the footer's token counters from the resumed history now that
|
|
71
|
+
// fromJSON()/journal replay are both applied — before requestRender() below.
|
|
72
|
+
options.hydrateSessionUsage?.();
|
|
68
73
|
options.onSessionIdChanged?.(sessionId);
|
|
69
74
|
if (meta?.model) options.runtime.model = meta.model;
|
|
70
75
|
if (meta?.provider) options.runtime.provider = meta.provider;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import type
|
|
2
|
+
import { sumConversationUsage, type ConversationManager } from '../core/conversation';
|
|
3
3
|
import type { Orchestrator } from '../core/orchestrator';
|
|
4
4
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
5
5
|
import type { RuntimeEventBus } from '@/runtime/index.ts';
|
|
@@ -93,6 +93,15 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
93
93
|
} = options;
|
|
94
94
|
|
|
95
95
|
const systemMessagesPanel = new SystemMessagesPanel(configManager, services.componentHealthMonitor);
|
|
96
|
+
// W0.9: after any resume seam replays historical messages into `conversation`,
|
|
97
|
+
// the freshly-constructed `orchestrator` still has its zeroed default usage
|
|
98
|
+
// (SDK gap — Orchestrator.usage is never persisted/reseeded). Recompute it
|
|
99
|
+
// from the replayed history so the footer doesn't show Input: 0 post-resume.
|
|
100
|
+
const hydrateSessionUsage = (): void => {
|
|
101
|
+
const { usage, lastInputTokens } = sumConversationUsage(conversation.getMessageSnapshot());
|
|
102
|
+
orchestrator.usage = usage;
|
|
103
|
+
orchestrator.lastInputTokens = lastInputTokens;
|
|
104
|
+
};
|
|
96
105
|
const resumeSession = createResumeSessionHandler({
|
|
97
106
|
runtimeBus,
|
|
98
107
|
runtime,
|
|
@@ -107,6 +116,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
107
116
|
configManager,
|
|
108
117
|
providerRegistry: services.providerRegistry,
|
|
109
118
|
homeDirectory: services.homeDirectory,
|
|
119
|
+
hydrateSessionUsage,
|
|
110
120
|
});
|
|
111
121
|
|
|
112
122
|
const foundationClients = createRuntimeFoundationClients({
|
|
@@ -282,6 +292,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
282
292
|
},
|
|
283
293
|
completeModelSelectionSideEffect,
|
|
284
294
|
componentHealthMonitor: services.componentHealthMonitor,
|
|
295
|
+
hydrateSessionUsage,
|
|
285
296
|
});
|
|
286
297
|
commandContextRef = commandContext;
|
|
287
298
|
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '1.
|
|
9
|
+
let _version = '1.1.0';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|