@pellux/goodvibes-tui 0.20.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +3 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +662 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/help.ts +4 -2
  11. package/src/cli/management-commands.ts +1 -1
  12. package/src/cli/management.ts +1 -8
  13. package/src/cli/parser.ts +14 -18
  14. package/src/cli/service-command.ts +1 -1
  15. package/src/cli/surface-command.ts +1 -1
  16. package/src/cli/tui-startup.ts +72 -10
  17. package/src/cli/types.ts +12 -3
  18. package/src/cli-flags.ts +1 -0
  19. package/src/config/atomic-write.ts +70 -0
  20. package/src/config/read-versioned.ts +115 -0
  21. package/src/core/conversation-rendering.ts +49 -15
  22. package/src/core/conversation.ts +101 -16
  23. package/src/core/format-user-error.ts +192 -0
  24. package/src/core/stream-event-wiring.ts +144 -0
  25. package/src/core/stream-stall-watchdog.ts +103 -0
  26. package/src/core/system-message-router.ts +5 -1
  27. package/src/export/cost-utils.ts +71 -0
  28. package/src/export/gist-uploader.ts +136 -0
  29. package/src/input/command-registry.ts +31 -1
  30. package/src/input/commands/control-room-runtime.ts +5 -5
  31. package/src/input/commands/experience-runtime.ts +5 -4
  32. package/src/input/commands/knowledge.ts +1 -1
  33. package/src/input/commands/local-auth-runtime.ts +27 -5
  34. package/src/input/commands/local-setup.ts +4 -6
  35. package/src/input/commands/memory-product-runtime.ts +8 -6
  36. package/src/input/commands/operator-panel-runtime.ts +1 -1
  37. package/src/input/commands/operator-runtime.ts +3 -10
  38. package/src/input/commands/platform-sandbox-qemu.ts +60 -16
  39. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  40. package/src/input/commands/recall-review.ts +26 -2
  41. package/src/input/commands/services-runtime.ts +2 -2
  42. package/src/input/commands/session-workflow.ts +3 -3
  43. package/src/input/commands/share-runtime.ts +99 -12
  44. package/src/input/commands/tts-runtime.ts +30 -4
  45. package/src/input/commands.ts +2 -2
  46. package/src/input/delete-key-policy.ts +46 -0
  47. package/src/input/feed-context-factory.ts +2 -0
  48. package/src/input/handler-feed.ts +3 -0
  49. package/src/input/handler-interactions.ts +2 -15
  50. package/src/input/handler-modal-routes.ts +91 -12
  51. package/src/input/handler-modal-token-routes.ts +3 -0
  52. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  53. package/src/input/handler-onboarding.ts +55 -69
  54. package/src/input/handler-types.ts +163 -0
  55. package/src/input/handler.ts +5 -2
  56. package/src/input/input-history.ts +76 -6
  57. package/src/input/model-picker-filter.ts +265 -0
  58. package/src/input/model-picker-items.ts +208 -0
  59. package/src/input/model-picker.ts +92 -325
  60. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  61. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  62. package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
  63. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
  64. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  65. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  66. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  67. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  68. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  69. package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
  70. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  71. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  72. package/src/input/settings-modal-data.ts +304 -0
  73. package/src/input/settings-modal-mutations.ts +154 -0
  74. package/src/input/settings-modal.ts +182 -220
  75. package/src/main.ts +57 -57
  76. package/src/panels/builtin/agent.ts +4 -1
  77. package/src/panels/builtin/development.ts +4 -1
  78. package/src/panels/confirm-state.ts +27 -12
  79. package/src/panels/cost-tracker-panel.ts +23 -67
  80. package/src/panels/eval-panel.ts +10 -9
  81. package/src/panels/knowledge-panel.ts +3 -5
  82. package/src/panels/local-auth-panel.ts +124 -4
  83. package/src/panels/project-planning-panel.ts +42 -4
  84. package/src/panels/search-focus.ts +11 -5
  85. package/src/panels/subscription-panel.ts +33 -25
  86. package/src/panels/types.ts +28 -1
  87. package/src/panels/wrfc-panel.ts +224 -41
  88. package/src/renderer/agent-detail-modal.ts +11 -10
  89. package/src/renderer/code-block.ts +10 -2
  90. package/src/renderer/compositor.ts +18 -4
  91. package/src/renderer/context-inspector.ts +1 -5
  92. package/src/renderer/diff.ts +94 -21
  93. package/src/renderer/markdown.ts +29 -13
  94. package/src/renderer/settings-modal-helpers.ts +1 -1
  95. package/src/renderer/settings-modal.ts +77 -8
  96. package/src/renderer/syntax-highlighter.ts +10 -3
  97. package/src/renderer/term-caps.ts +318 -0
  98. package/src/renderer/theme.ts +158 -0
  99. package/src/renderer/tool-call.ts +12 -2
  100. package/src/renderer/ui-factory.ts +50 -6
  101. package/src/runtime/bootstrap-command-context.ts +1 -0
  102. package/src/runtime/bootstrap-command-parts.ts +14 -0
  103. package/src/runtime/bootstrap-core.ts +121 -13
  104. package/src/runtime/bootstrap.ts +2 -0
  105. package/src/runtime/onboarding/apply.ts +4 -6
  106. package/src/runtime/onboarding/index.ts +1 -0
  107. package/src/runtime/onboarding/markers.ts +42 -49
  108. package/src/runtime/onboarding/progress.ts +148 -0
  109. package/src/runtime/onboarding/state.ts +133 -55
  110. package/src/runtime/onboarding/types.ts +20 -0
  111. package/src/runtime/sandbox-qemu-templates.ts +15 -0
  112. package/src/runtime/services.ts +21 -0
  113. package/src/runtime/wrfc-persistence.ts +237 -0
  114. package/src/shell/blocking-input.ts +20 -5
  115. package/src/tools/wrfc-agent-guard.ts +64 -3
  116. package/src/utils/format-elapsed.ts +30 -0
  117. package/src/utils/terminal-width.ts +45 -0
  118. package/src/version.ts +1 -1
  119. package/src/work-plans/work-plan-store.ts +4 -6
  120. package/src/planning/project-planning-coordinator.ts +0 -543
package/src/main.ts CHANGED
@@ -48,12 +48,15 @@ import { buildPersistedSessionContext, formatReturnContextForDisplay, getReturnC
48
48
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
49
49
  import { prepareShellCliRuntime } from './cli/entrypoint.ts';
50
50
  import { applyInitialTuiCliState } from './cli/tui-startup.ts';
51
+ import { applyRuntimeConfigDefault, applyRuntimeConfigValue } from './cli/config-overrides.ts';
52
+ import { renderToolCallBlock } from './renderer/tool-call.ts';
51
53
  import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
52
54
  import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
53
55
  import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
54
- import { ProjectPlanningCoordinator } from './planning/project-planning-coordinator.ts';
55
56
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
56
57
  import { summarizeRunningAgents } from './renderer/process-summary.ts';
58
+ import { formatUserFacingErrorLine } from './core/format-user-error.ts';
59
+ import { wireStreamEventMetrics, type StreamMetrics } from './core/stream-event-wiring.ts';
57
60
 
58
61
  const ALT_SCREEN_ENTER = '\x1b[?1049h';
59
62
  const ALT_SCREEN_EXIT = '\x1b[?1049l';
@@ -127,6 +130,15 @@ async function main() {
127
130
  }
128
131
  }
129
132
 
133
+ // TUI default: show token speed ON. The SDK schema default is false;
134
+ // applyRuntimeConfigDefault reads both the global settings file and the project
135
+ // settings file from disk before deciding whether to apply the default. If the
136
+ // user has explicitly set this key to false in EITHER their global or project
137
+ // persisted config, their value is respected and the default is NOT applied.
138
+ // Only when the key is absent from both files (e.g. a new install) does the
139
+ // TUI default of true take effect in-memory — no disk write occurs either way.
140
+ applyRuntimeConfigDefault(configManager, 'display.showTokenSpeed', true);
141
+
130
142
  const panelManager = ctx.services.panelManager;
131
143
  const buildSessionContinuityHints = () => {
132
144
  const sessionSnapshot = uiServices.readModels.session.getSnapshot();
@@ -154,12 +166,18 @@ async function main() {
154
166
  render();
155
167
  });
156
168
 
157
- let streamStartTime = 0;
158
- let streamDeltaCount = 0;
159
- let streamTokenSpeed = 0;
160
-
161
169
  let scrollTop = 0;
162
170
  let scrollLocked = true;
171
+ // Stream and tool-timer state; mutated by wireStreamEventMetrics handlers, read during render.
172
+ const streamMetrics: StreamMetrics = {
173
+ startTime: 0,
174
+ deltaCount: 0,
175
+ tokenSpeed: 0,
176
+ ttftMs: undefined,
177
+ ttftRecorded: false,
178
+ activeToolStartedAtMs: undefined,
179
+ activeToolName: undefined,
180
+ };
163
181
 
164
182
  const getPromptContentWidth = () => {
165
183
  const w = stdout.columns || 80;
@@ -213,7 +231,8 @@ async function main() {
213
231
  `[Critical] Multiple errors detected (${_unhandledRejectionCount} in 10s). If the issue persists, please restart. Latest: ${msg}`
214
232
  );
215
233
  } else {
216
- systemMessageRouter.high(`[Error] ${msg}`);
234
+ const formatted = formatUserFacingErrorLine(reason);
235
+ systemMessageRouter.high(`[Error] ${formatted}`);
217
236
  logger.error('unhandledRejection', { error: String(reason) });
218
237
  }
219
238
  render();
@@ -260,18 +279,6 @@ async function main() {
260
279
  configManager,
261
280
  notify: (message) => { systemMessageRouter.high(message); render(); },
262
281
  }));
263
- const projectPlanningCoordinator = new ProjectPlanningCoordinator({
264
- service: ctx.services.projectPlanningService,
265
- projectId: ctx.services.projectPlanningProjectId,
266
- workingDirectory: workingDir,
267
- notify: (message) => { systemMessageRouter.high(message); render(); },
268
- openPanel: () => {
269
- panelManager.open('project-planning');
270
- panelManager.show();
271
- render();
272
- },
273
- });
274
-
275
282
  const submitInput = (text: string, content?: ContentPart[], options: { readonly spokenOutput?: boolean } = {}) => {
276
283
  input.clearModalStack();
277
284
  scrollLocked = true; // Re-lock on any user input
@@ -307,32 +314,6 @@ async function main() {
307
314
  if (processedText || content) {
308
315
  void (async () => {
309
316
  let inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
310
- if (!options.spokenOutput && processedText) {
311
- try {
312
- const planning = await projectPlanningCoordinator.prepareTurn(processedText);
313
- if (planning) {
314
- if (planning.handledLocally) {
315
- systemMessageRouter.high(planning.statusMessage);
316
- render();
317
- return;
318
- }
319
- conversation.addSystemMessage(planning.systemMessage);
320
- inputOptions = {
321
- origin: {
322
- source: 'project-planning',
323
- surface: 'tui',
324
- metadata: {
325
- projectId: ctx.services.projectPlanningProjectId,
326
- knowledgeSpaceId: planning.state.knowledgeSpaceId,
327
- readiness: planning.evaluation.readiness,
328
- },
329
- },
330
- };
331
- }
332
- } catch (err) {
333
- systemMessageRouter.high(`[Planning] ${summarizeError(err)}`);
334
- }
335
- }
336
317
  if (options.spokenOutput && processedText) {
337
318
  spokenTurns.submitNextTurn(processedText);
338
319
  }
@@ -515,7 +496,9 @@ async function main() {
515
496
  workingDir,
516
497
  provider: runtime.provider,
517
498
  contextWindow: currentModel.contextWindow,
518
- compactThreshold: configManager.get('behavior.autoCompactThreshold') as number,
499
+ // behavior.autoCompactThreshold is stored as a percent integer (e.g. 80);
500
+ // the meter expects a fraction [0..1]. Clamp to [0,1] to guard nonsense values.
501
+ compactThreshold: Math.min(1, Math.max(0, (configManager.get('behavior.autoCompactThreshold') as number) / 100)),
519
502
  dangerMode: (() => {
520
503
  if (configManager.get('behavior.autoApprove')) return true;
521
504
  const permMode = configManager.get('permissions.mode');
@@ -598,16 +581,25 @@ async function main() {
598
581
  const showSpeed = configManager.get('display.showTokenSpeed') as boolean;
599
582
  const showPreview = configManager.get('display.showToolPreview') as boolean;
600
583
  const partialToolPreview = showPreview ? sessionSnapshot.streamToolPreview : undefined;
584
+ // Elapsed from turn start (stream or tool execution), used for the thinking indicator timer.
585
+ const turnElapsedMs = streamMetrics.startTime > 0 ? Date.now() - streamMetrics.startTime : undefined;
601
586
  const thinking = UIFactory.createThinkingFragment(
602
587
  conversationWidth,
603
588
  orchestrator.getSpinner(),
604
589
  orchestrator.thinkingFrame,
605
- showSpeed ? streamTokenSpeed : undefined,
590
+ showSpeed ? streamMetrics.tokenSpeed : undefined,
606
591
  showPreview ? partialToolPreview : undefined,
607
592
  orchestrator.streamingInputTokens > 0 ? orchestrator.streamingInputTokens : undefined,
608
593
  orchestrator.streamingOutputTokens > 0 ? orchestrator.streamingOutputTokens : undefined,
594
+ turnElapsedMs,
595
+ streamMetrics.ttftMs,
609
596
  );
610
597
  viewport.push(...thinking);
598
+ // Live tool timer: render the currently executing tool row with ticking elapsed.
599
+ if (streamMetrics.activeToolName !== undefined && streamMetrics.activeToolStartedAtMs !== undefined) {
600
+ const liveToolCall = { id: 'live', name: streamMetrics.activeToolName, arguments: {} };
601
+ viewport.push(...renderToolCallBlock(liveToolCall, 'executing', undefined, conversationWidth, undefined, undefined, undefined, streamMetrics.activeToolStartedAtMs));
602
+ }
611
603
  }
612
604
 
613
605
  if (pendingPermission) {
@@ -709,17 +701,16 @@ async function main() {
709
701
  refreshGit();
710
702
  }));
711
703
 
712
- unsubs.push(uiServices.events.turns.on('STREAM_START', () => {
713
- streamStartTime = Date.now();
714
- streamDeltaCount = 0;
715
- streamTokenSpeed = 0;
716
- }));
717
- unsubs.push(uiServices.events.turns.on('STREAM_DELTA', () => {
718
- streamDeltaCount++;
719
- const elapsed = (Date.now() - streamStartTime) / 1000;
720
- // Note: counts stream deltas, not actual tokens. ~1 delta per token for most providers.
721
- streamTokenSpeed = elapsed > 0 ? streamDeltaCount / elapsed : 0;
722
- }));
704
+ // --- Stream metrics + tool-timer event wiring ---
705
+ const streamUnsubs = wireStreamEventMetrics({
706
+ events: uiServices.events,
707
+ orchestrator,
708
+ providerRegistry,
709
+ systemMessageRouter,
710
+ render,
711
+ metrics: streamMetrics,
712
+ });
713
+ unsubs.push(...streamUnsubs);
723
714
 
724
715
  // --- Terminal setup ---
725
716
  stdin.setRawMode(true);
@@ -747,6 +738,15 @@ async function main() {
747
738
  render,
748
739
  loadRecoveryConversation: () => loadRecoveryConversation({ homeDirectory }),
749
740
  deleteRecoveryFile: () => deleteRecoveryFile({ homeDirectory }),
741
+ reopenPanels: (snapshot) => {
742
+ const panels = snapshot.returnContext?.openPanels;
743
+ if (!panels || panels.length === 0) return;
744
+ for (const panelId of panels.slice(0, 4)) {
745
+ try { panelManager.open(panelId); } catch { /* unknown panel id — skip */ }
746
+ }
747
+ panelManager.show();
748
+ render();
749
+ },
750
750
  });
751
751
  pendingPermission = blocking.pendingPermission;
752
752
  recoveryPending = blocking.recoveryPending;
@@ -76,7 +76,10 @@ export function registerAgentPanels(manager: PanelManager, deps: ResolvedBuiltin
76
76
  preload: true,
77
77
  factory: () => {
78
78
  const ui = requireUiServices(deps);
79
- return new WrfcPanel(ui.events.workflows, { controller: ui.agents.wrfcController });
79
+ return new WrfcPanel(ui.events.workflows, {
80
+ controller: ui.agents.wrfcController,
81
+ cancelChain: (agentId: string) => ui.agents.agentManager.cancel(agentId),
82
+ });
80
83
  },
81
84
  });
82
85
 
@@ -65,7 +65,10 @@ export function registerDevelopmentPanels(manager: PanelManager, deps: ResolvedB
65
65
  description: 'Estimated costs per session, agent, and plan with budget alerts',
66
66
  factory: () => {
67
67
  const ui = requireUiServices(deps);
68
- return new CostTrackerPanel(ui.events.turns, ui.events.agents, getOrchestratorUsage, { budgetThreshold });
68
+ return new CostTrackerPanel(ui.events.turns, ui.events.agents, getOrchestratorUsage, {
69
+ budgetThreshold,
70
+ getAgentStatus: (id) => ui.agents.agentManager.getStatus(id),
71
+ });
69
72
  },
70
73
  });
71
74
  }
@@ -1,12 +1,22 @@
1
1
  // ---------------------------------------------------------------------------
2
- // useConfirmState<T> — reusable inline y/n confirmation helper
2
+ // useConfirmState<T> — reusable inline confirm/cancel helper
3
3
  //
4
- // Pattern (chosen over ConfirmableListPanel base class):
5
- // - Composable: any panel holds a ConfirmState field, not a new base class
6
- // - Identical y/n UX everywhere: y confirms, n/Esc cancels, any other key
7
- // is absorbed (does nothing) while confirm is active
8
- // - Render: caller calls renderConfirmLines(width, state) to get the two
9
- // lines that replace the normal content area when confirming
4
+ // ── Project-standard confirm contract (all panels must match) ──────────────
5
+ //
6
+ // CONFIRM: Enter, Return, or y
7
+ // CANCEL: Esc or n
8
+ // ABSORBED: any other key while confirm is active (keeps confirm pending)
9
+ //
10
+ // Implementation:
11
+ // - Composable: any panel holds a ConfirmState<T> field; no new base class
12
+ // - Call handleConfirmInput(confirm, key) BEFORE normal key dispatch.
13
+ // It handles all four outcomes and returns one of the four result tokens.
14
+ // - Call renderConfirmLines(width, state) to render the two-line overlay
15
+ // that replaces normal content while a confirm is pending.
16
+ //
17
+ // ── This file is the canonical contract for all confirm flows ─────────────
18
+ // Any new panel confirm flow must use ConfirmState<T> and handleConfirmInput;
19
+ // do not implement a bespoke two-press or Enter-only variant.
10
20
  // ---------------------------------------------------------------------------
11
21
 
12
22
  import type { Line } from '../types/grid.ts';
@@ -23,9 +33,14 @@ export interface ConfirmState<T = string> {
23
33
  /**
24
34
  * Call this from a panel's handleInput() BEFORE any other key handling.
25
35
  *
36
+ * Project-standard confirm contract:
37
+ * - CONFIRM: Enter, Return, or y
38
+ * - CANCEL: Esc or n
39
+ * - ABSORBED: any other key while confirm is active (keeps confirm pending)
40
+ *
26
41
  * Returns:
27
- * - `'confirmed'` — user pressed y; caller must execute the action and
28
- * clear state (set confirm to null)
42
+ * - `'confirmed'` — user pressed Enter, Return, or y; caller must execute
43
+ * the action and clear state (set confirm to null)
29
44
  * - `'cancelled'` — user pressed n or Esc; caller must clear state
30
45
  * - `'absorbed'` — any other key while confirm is active; caller returns true
31
46
  * - `'inactive'` — no confirm pending; caller continues normal dispatch
@@ -35,7 +50,7 @@ export function handleConfirmInput<T = string>(
35
50
  key: string,
36
51
  ): 'confirmed' | 'cancelled' | 'absorbed' | 'inactive' {
37
52
  if (!confirm) return 'inactive';
38
- if (key === 'y') return 'confirmed';
53
+ if (key === 'y' || key === 'enter' || key === 'return') return 'confirmed';
39
54
  if (key === 'n' || key === 'escape') return 'cancelled';
40
55
  return 'absorbed';
41
56
  }
@@ -52,8 +67,8 @@ export function renderConfirmLines<T = string>(width: number, state: ConfirmStat
52
67
  palette.warn,
53
68
  ]]),
54
69
  buildPanelLine(width, [
55
- [' y', palette.info],
56
- [' confirm delete', palette.dim],
70
+ [' Enter / y', palette.info],
71
+ [' confirm', palette.dim],
57
72
  [' n / Esc', palette.info],
58
73
  [' cancel', palette.dim],
59
74
  ]),
@@ -7,6 +7,7 @@ import { createStyledCell, createEmptyLine } from '../types/grid.ts';
7
7
  import { BasePanel } from './base-panel.ts';
8
8
  import type { AgentEvent, TurnEvent } from '@/runtime/index.ts';
9
9
  import type { UiEventFeed } from '../runtime/ui-events.ts';
10
+ import type { AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
10
11
  import {
11
12
  buildEmptyState,
12
13
  buildPanelLine,
@@ -16,68 +17,9 @@ import {
16
17
  DEFAULT_PANEL_PALETTE,
17
18
  type PanelWorkspaceSection,
18
19
  } from './polish.ts';
20
+ import { getPricing } from '../export/cost-utils.ts';
19
21
 
20
- // ---------------------------------------------------------------------------
21
- // Pricing table (USD per 1M tokens)
22
- // ---------------------------------------------------------------------------
23
-
24
- interface ModelPricing {
25
- input: number;
26
- output: number;
27
- }
28
-
29
- const MODEL_PRICING: Record<string, ModelPricing> = {
30
- // Free tier
31
- 'openrouter/free': { input: 0, output: 0 },
32
-
33
- // InceptionLabs
34
- 'mercury-2': { input: 0.50, output: 1.50 },
35
- 'mercury-edit': { input: 0.50, output: 1.50 },
36
-
37
- // OpenAI
38
- 'gpt-5.4': { input: 5, output: 15 },
39
- 'gpt-5.3-chat-latest': { input: 3, output: 10 },
40
- 'gpt-5-mini': { input: 0.15, output: 0.60 },
41
- 'gpt-5-nano': { input: 0.05, output: 0.20 },
42
- 'gpt-oss-120b': { input: 0, output: 0 },
43
-
44
- // Anthropic (correct registry IDs)
45
- 'claude-opus-4-6': { input: 15, output: 75 },
46
- 'claude-sonnet-4-6': { input: 3, output: 15 },
47
- 'claude-haiku-4-5': { input: 0.80, output: 4 },
48
-
49
- // Google
50
- 'gemini-3.1-pro': { input: 1.25, output: 5 },
51
- 'gemini-3-flash': { input: 0.075, output: 0.30 },
52
- 'gemini-3.1-flash-lite': { input: 0.02, output: 0.10 },
53
- 'gemini-2.5-pro': { input: 1.25, output: 5 },
54
- };
55
-
56
- /**
57
- * Look up pricing from the model catalog.
58
- * Returns { input: 0, output: 0 } for free models and unknown models.
59
- */
60
- function getCostFromCatalogForPanel(modelId: string): ModelPricing {
61
- if (modelId.endsWith(':free')) return { input: 0, output: 0 };
62
- return { input: 0, output: 0 };
63
- }
64
-
65
- function getPricing(modelId: string): ModelPricing {
66
- // 2. Hardcoded table — exact match
67
- if (MODEL_PRICING[modelId]) return MODEL_PRICING[modelId]!;
68
- // 1. OpenRouter :free suffix — treat as free
69
- if (modelId.endsWith(':free')) return { input: 0, output: 0 };
70
- // 2. Prefix match (e.g. "openrouter/free:..." or "claude-sonnet-4-6-20..")
71
- for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
72
- if (modelId.startsWith(key) || modelId.includes(key)) return pricing;
73
- }
74
- // 3. Unknown model — default to free-ish safe fallback
75
- return getCostFromCatalogForPanel(modelId);
76
- }
77
-
78
- function calcCost(inputTokens: number, outputTokens: number, pricing: ModelPricing): number {
79
- return (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;
80
- }
22
+ // Pricing lookups are provided by ../export/cost-utils.ts (single source of truth).
81
23
 
82
24
  function formatCost(usd: number): string {
83
25
  if (usd === 0) return '$0.00';
@@ -176,14 +118,19 @@ export class CostTrackerPanel extends BasePanel {
176
118
  // Getter for live orchestrator usage
177
119
  private readonly getOrchestratorUsage: () => UsageSnapshot & { model?: string };
178
120
 
121
+ // Optional resolver for agent usage on completion — enables real cost attribution.
122
+ // When omitted, completed agents show $0 (honest: data unavailable).
123
+ private readonly getAgentStatus: ((agentId: string) => AgentRecord | null) | undefined;
124
+
179
125
  constructor(
180
126
  turnEvents: UiEventFeed<TurnEvent>,
181
127
  agentEvents: UiEventFeed<AgentEvent>,
182
128
  getOrchestratorUsage: () => UsageSnapshot & { model?: string },
183
- opts: { budgetThreshold?: number } = {},
129
+ opts: { budgetThreshold?: number; getAgentStatus?: (agentId: string) => AgentRecord | null } = {},
184
130
  ) {
185
131
  super('cost', 'Cost', '$', 'monitoring');
186
132
  this.getOrchestratorUsage = getOrchestratorUsage;
133
+ this.getAgentStatus = opts.getAgentStatus;
187
134
  this.budgetThreshold = opts.budgetThreshold ?? 0;
188
135
  this.attachEvents(turnEvents, agentEvents);
189
136
  }
@@ -214,12 +161,22 @@ export class CostTrackerPanel extends BasePanel {
214
161
  }),
215
162
  );
216
163
 
217
- // Agent completed — capture token data from result if available
164
+ // Agent completed — capture real token usage via AgentRecord when available
218
165
  this.unsubs.push(
219
166
  agentEvents.on('AGENT_COMPLETED', (payload) => {
220
167
  const entry = this.agents.get(payload.agentId);
221
168
  if (entry) {
222
169
  entry.status = 'done';
170
+ if (this.getAgentStatus) {
171
+ const rec = this.getAgentStatus(payload.agentId);
172
+ if (rec?.usage) {
173
+ entry.inputTokens = rec.usage.inputTokens + (rec.usage.cacheReadTokens ?? 0) + (rec.usage.cacheWriteTokens ?? 0);
174
+ entry.outputTokens = rec.usage.outputTokens;
175
+ const pricing = getPricing(rec.model ?? 'unknown');
176
+ entry.cost = (entry.inputTokens * pricing.input + entry.outputTokens * pricing.output) / 1_000_000;
177
+ if (rec.model && rec.model !== 'unknown') entry.model = rec.model;
178
+ }
179
+ }
223
180
  this.markDirty();
224
181
  }
225
182
  }),
@@ -248,9 +205,9 @@ export class CostTrackerPanel extends BasePanel {
248
205
  if (usage.model) this.sessionModel = usage.model;
249
206
 
250
207
  // Record cost delta for sparkline
251
- const sessionProvider = this.sessionModel.includes('/') ? this.sessionModel.split('/')[0]! : '';
252
208
  const pricing = getPricing(this.sessionModel);
253
- const totalCost = calcCost(usage.input + usage.cacheRead + usage.cacheWrite, usage.output, pricing);
209
+ const billableInput = usage.input + usage.cacheRead + usage.cacheWrite;
210
+ const totalCost = (billableInput * pricing.input + usage.output * pricing.output) / 1_000_000;
254
211
  const delta = Math.max(0, totalCost - this.lastSessionCost);
255
212
  this.lastSessionCost = totalCost;
256
213
  this.costHistory.push(delta);
@@ -310,10 +267,9 @@ export class CostTrackerPanel extends BasePanel {
310
267
  render(width: number, height: number): Line[] {
311
268
  if (height <= 0 || width <= 0) return [];
312
269
 
313
- const sessionProvider = this.sessionModel.includes('/') ? this.sessionModel.split('/')[0]! : '';
314
270
  const pricing = getPricing(this.sessionModel);
315
271
  const totalInputTokens = this.sessionUsage.input + this.sessionUsage.cacheRead + this.sessionUsage.cacheWrite;
316
- const sessionCost = calcCost(this.sessionUsage.input + this.sessionUsage.cacheRead + this.sessionUsage.cacheWrite, this.sessionUsage.output, pricing);
272
+ const sessionCost = (totalInputTokens * pricing.input + this.sessionUsage.output * pricing.output) / 1_000_000;
317
273
  const overBudget = this.budgetThreshold > 0 && sessionCost > this.budgetThreshold;
318
274
  const sparkline = buildSparkline(this.costHistory);
319
275
  const costStr = formatCost(sessionCost);
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { BasePanel } from './base-panel.ts';
9
9
  import type { Line } from '../types/grid.ts';
10
+ import type { KeyName } from './types.ts';
10
11
  import { createEmptyLine } from '../types/grid.ts';
11
12
  import {
12
13
  buildEmptyState,
@@ -136,21 +137,21 @@ export class EvalPanel extends BasePanel {
136
137
  this._unsub = null;
137
138
  }
138
139
 
139
- public handleInput(key: string): boolean {
140
+ public handleInput(key: KeyName): boolean {
140
141
  const suites = this._registry.getSuiteResults();
141
142
 
142
143
  if (this._mode === 'list') {
143
- if (key === 'ArrowUp' || key === 'k') {
144
+ if (key === 'up' || key === 'k') {
144
145
  this._selectedSuiteIdx = Math.max(0, this._selectedSuiteIdx - 1);
145
146
  this.markDirty();
146
147
  return true;
147
148
  }
148
- if (key === 'ArrowDown' || key === 'j') {
149
+ if (key === 'down' || key === 'j') {
149
150
  this._selectedSuiteIdx = Math.min(suites.length - 1, this._selectedSuiteIdx + 1);
150
151
  this.markDirty();
151
152
  return true;
152
153
  }
153
- if ((key === 'Enter' || key === 'Return' || key === 'l') && suites.length > 0) {
154
+ if ((key === 'enter' || key === 'return' || key === 'l') && suites.length > 0) {
154
155
  this._mode = 'detail';
155
156
  this._selectedScenarioIdx = 0;
156
157
  this._scrollOffset = 0;
@@ -161,12 +162,12 @@ export class EvalPanel extends BasePanel {
161
162
  }
162
163
 
163
164
  // detail mode
164
- if (key === 'Escape' || key === 'q' || key === 'h') {
165
+ if (key === 'escape' || key === 'q' || key === 'h') {
165
166
  this._mode = 'list';
166
167
  this.markDirty();
167
168
  return true;
168
169
  }
169
- if (key === 'ArrowUp' || key === 'k') {
170
+ if (key === 'up' || key === 'k') {
170
171
  const suite = suites[this._selectedSuiteIdx];
171
172
  if (suite) {
172
173
  this._selectedScenarioIdx = Math.max(0, this._selectedScenarioIdx - 1);
@@ -175,7 +176,7 @@ export class EvalPanel extends BasePanel {
175
176
  }
176
177
  return true;
177
178
  }
178
- if (key === 'ArrowDown' || key === 'j') {
179
+ if (key === 'down' || key === 'j') {
179
180
  const suite = suites[this._selectedSuiteIdx];
180
181
  if (suite) {
181
182
  this._selectedScenarioIdx = Math.min(
@@ -187,12 +188,12 @@ export class EvalPanel extends BasePanel {
187
188
  }
188
189
  return true;
189
190
  }
190
- if (key === 'PageUp') {
191
+ if (key === 'pageup') {
191
192
  this._scrollOffset = Math.max(0, this._scrollOffset - 5);
192
193
  this.markDirty();
193
194
  return true;
194
195
  }
195
- if (key === 'PageDown') {
196
+ if (key === 'pagedown') {
196
197
  this._scrollOffset += 5;
197
198
  this.markDirty();
198
199
  return true;
@@ -1,5 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
3
+ import type { KeyName } from './types.ts';
3
4
  import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
4
5
  import type { MemoryClass, MemoryRecord, MemoryRegistry, MemoryReviewState } from '@pellux/goodvibes-sdk/platform/state';
5
6
  import {
@@ -102,7 +103,7 @@ export class KnowledgePanel extends ScrollableListPanel<MemoryRecord> {
102
103
  // Input
103
104
  // ---------------------------------------------------------------------------
104
105
 
105
- public handleInput(key: string): boolean {
106
+ public handleInput(key: KeyName): boolean {
106
107
  // I1: y/n confirm for stale/contradict
107
108
  if (this.confirm) {
108
109
  const result = handleConfirmInput(this.confirm, key);
@@ -149,7 +150,7 @@ export class KnowledgePanel extends ScrollableListPanel<MemoryRecord> {
149
150
 
150
151
  const selected = this.records[this.selectedIndex];
151
152
 
152
- if (key === 'Enter' || key === 'return' || key === 'r') {
153
+ if (key === 'enter' || key === 'return' || key === 'r') {
153
154
  if (!selected) return false;
154
155
  this.registry.review(selected.id, {
155
156
  state: 'reviewed',
@@ -186,9 +187,6 @@ export class KnowledgePanel extends ScrollableListPanel<MemoryRecord> {
186
187
  return true;
187
188
  }
188
189
 
189
- // Normalize arrow keys to base class format
190
- if (key === 'ArrowUp') return super.handleInput('up');
191
- if (key === 'ArrowDown') return super.handleInput('down');
192
190
  return super.handleInput(key);
193
191
  }
194
192