@pellux/goodvibes-tui 0.27.0 → 0.29.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -4,21 +4,27 @@
4
4
 
5
5
  import type { Line } from '../types/grid.ts';
6
6
  import { createStyledCell, createEmptyLine } from '../types/grid.ts';
7
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
7
8
  import { BasePanel } from './base-panel.ts';
8
9
  import type { AgentEvent, TurnEvent } from '@/runtime/index.ts';
9
10
  import type { UiEventFeed } from '../runtime/ui-events.ts';
10
11
  import type { AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
11
12
  import {
12
13
  buildEmptyState,
14
+ buildKeyboardHints,
15
+ buildMeterLine,
13
16
  buildPanelLine,
14
17
  buildStyledPanelLine,
18
+ buildTable,
15
19
  buildPanelWorkspace,
16
20
  resolveScrollablePanelSection,
17
21
  extendPalette,
18
22
  DEFAULT_PANEL_PALETTE,
19
23
  type PanelWorkspaceSection,
20
24
  } from './polish.ts';
21
- import { getPricing } from '../export/cost-utils.ts';
25
+ import { calcSessionCost } from '../export/cost-utils.ts';
26
+ import { abbreviateCount } from '../utils/format-number.ts';
27
+ import { isTextBackspace } from '../input/delete-key-policy.ts';
22
28
 
23
29
  // Pricing lookups are provided by ../export/cost-utils.ts (single source of truth).
24
30
 
@@ -30,9 +36,7 @@ function formatCost(usd: number): string {
30
36
  }
31
37
 
32
38
  function formatTokens(n: number): string {
33
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
34
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
35
- return String(n);
39
+ return abbreviateCount(n);
36
40
  }
37
41
 
38
42
  // ---------------------------------------------------------------------------
@@ -100,15 +104,27 @@ export class CostTrackerPanel extends BasePanel {
100
104
  // Per-agent tracking (keyed by agent id)
101
105
  private agents = new Map<string, AgentEntry>();
102
106
 
103
- // Budget alert threshold in USD (0 = disabled)
107
+ // Budget alert threshold in USD (0 = disabled). Mutable at runtime via the
108
+ // in-panel 'b' numeric entry or the /cost budget <usd> command — both call
109
+ // setBudgetThreshold() below.
104
110
  private budgetThreshold: number;
105
111
 
112
+ // Draft buffer for the in-panel budget-entry mode ('b' key). Non-null while
113
+ // entry is active; unlike LocalAuthPanel's masked entry, the value is not
114
+ // secret so it is echoed directly.
115
+ private budgetEntry: string | null = null;
116
+
106
117
  // Scroll offset for agent list
107
118
  private scrollOffset = 0;
108
119
 
109
120
  // Unsubscribe functions
110
121
  private unsubs: Array<() => void> = [];
111
122
 
123
+ // Polls getAgentStatus() for agents still marked 'running' so their token/
124
+ // cost columns fill in as usage streams, instead of staying pinned at
125
+ // 'unknown'/$0 until AGENT_COMPLETED fires.
126
+ private statusPollTimer: ReturnType<typeof setInterval> | null = null;
127
+
112
128
  // Getter for live orchestrator usage
113
129
  private readonly getOrchestratorUsage: () => UsageSnapshot & { model?: string };
114
130
 
@@ -122,7 +138,7 @@ export class CostTrackerPanel extends BasePanel {
122
138
  getOrchestratorUsage: () => UsageSnapshot & { model?: string },
123
139
  opts: { budgetThreshold?: number; getAgentStatus?: (agentId: string) => AgentRecord | null } = {},
124
140
  ) {
125
- super('cost', 'Cost', '$', 'monitoring');
141
+ super('cost', 'Cost', '$', 'providers');
126
142
  this.getOrchestratorUsage = getOrchestratorUsage;
127
143
  this.getAgentStatus = opts.getAgentStatus;
128
144
  this.budgetThreshold = opts.budgetThreshold ?? 0;
@@ -136,7 +152,14 @@ export class CostTrackerPanel extends BasePanel {
136
152
  private attachEvents(turnEvents: UiEventFeed<TurnEvent>, agentEvents: UiEventFeed<AgentEvent>): void {
137
153
  // Refresh after every completed turn
138
154
  this.unsubs.push(
139
- turnEvents.on('TURN_COMPLETED', () => this.onTurnComplete()),
155
+ turnEvents.on('TURN_COMPLETED', () => this.refreshSessionCost()),
156
+ );
157
+
158
+ // Refresh mid-turn too: a single turn can span many LLM calls (tool
159
+ // loops), so waiting for TURN_COMPLETED leaves the meter frozen for the
160
+ // whole turn. LLM_RESPONSE_RECEIVED fires per call — same refresh path.
161
+ this.unsubs.push(
162
+ turnEvents.on('LLM_RESPONSE_RECEIVED', () => this.refreshSessionCost()),
140
163
  );
141
164
 
142
165
  // Track agent spawns
@@ -144,7 +167,7 @@ export class CostTrackerPanel extends BasePanel {
144
167
  agentEvents.on('AGENT_SPAWNING', (payload) => {
145
168
  this.agents.set(payload.agentId, {
146
169
  id: payload.agentId.slice(0, 8),
147
- task: payload.task.length > 40 ? payload.task.slice(0, 37) + '...' : payload.task,
170
+ task: truncateDisplay(payload.task, 40),
148
171
  model: 'unknown',
149
172
  inputTokens: 0,
150
173
  outputTokens: 0,
@@ -166,8 +189,7 @@ export class CostTrackerPanel extends BasePanel {
166
189
  if (rec?.usage) {
167
190
  entry.inputTokens = rec.usage.inputTokens + (rec.usage.cacheReadTokens ?? 0) + (rec.usage.cacheWriteTokens ?? 0);
168
191
  entry.outputTokens = rec.usage.outputTokens;
169
- const pricing = getPricing(rec.model ?? 'unknown');
170
- entry.cost = (entry.inputTokens * pricing.input + entry.outputTokens * pricing.output) / 1_000_000;
192
+ entry.cost = calcSessionCost(rec.usage.inputTokens, rec.usage.outputTokens, rec.usage.cacheReadTokens ?? 0, rec.usage.cacheWriteTokens ?? 0, rec.model ?? 'unknown');
171
193
  if (rec.model && rec.model !== 'unknown') entry.model = rec.model;
172
194
  }
173
195
  }
@@ -188,7 +210,7 @@ export class CostTrackerPanel extends BasePanel {
188
210
  );
189
211
  }
190
212
 
191
- private onTurnComplete(): void {
213
+ private refreshSessionCost(): void {
192
214
  const usage = this.getOrchestratorUsage();
193
215
  this.sessionUsage = {
194
216
  input: usage.input,
@@ -199,9 +221,7 @@ export class CostTrackerPanel extends BasePanel {
199
221
  if (usage.model) this.sessionModel = usage.model;
200
222
 
201
223
  // Record cost delta for sparkline
202
- const pricing = getPricing(this.sessionModel);
203
- const billableInput = usage.input + usage.cacheRead + usage.cacheWrite;
204
- const totalCost = (billableInput * pricing.input + usage.output * pricing.output) / 1_000_000;
224
+ const totalCost = calcSessionCost(usage.input, usage.output, usage.cacheRead, usage.cacheWrite, this.sessionModel);
205
225
  const delta = Math.max(0, totalCost - this.lastSessionCost);
206
226
  this.lastSessionCost = totalCost;
207
227
  this.costHistory.push(delta);
@@ -226,6 +246,13 @@ export class CostTrackerPanel extends BasePanel {
226
246
  cacheWrite: usage.cacheWrite,
227
247
  };
228
248
  this.needsRender = true;
249
+ this.startStatusPollTimer();
250
+ }
251
+
252
+ override onDeactivate(): void {
253
+ // Stop polling while hidden — nothing to catch up on that a fresh
254
+ // onActivate() poll won't cover.
255
+ this.stopStatusPollTimer();
229
256
  }
230
257
 
231
258
  override onDestroy(): void {
@@ -233,11 +260,69 @@ export class CostTrackerPanel extends BasePanel {
233
260
  this.unsubs = [];
234
261
  }
235
262
 
263
+ private startStatusPollTimer(): void {
264
+ if (this.statusPollTimer !== null || !this.getAgentStatus) return;
265
+ this.statusPollTimer = this.registerTimer(setInterval(() => this.pollRunningAgents(), 3_000));
266
+ }
267
+
268
+ private stopStatusPollTimer(): void {
269
+ if (this.statusPollTimer !== null) {
270
+ this.clearTimer(this.statusPollTimer);
271
+ this.statusPollTimer = null;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Poll getAgentStatus() for every agent still marked 'running'. Completion
277
+ * already captures real usage via AGENT_COMPLETED, but a long-running agent
278
+ * would otherwise show 'unknown' tokens / $0 cost for its entire lifetime —
279
+ * this fills the row in as usage becomes available mid-flight.
280
+ */
281
+ private pollRunningAgents(): void {
282
+ if (!this.getAgentStatus) return;
283
+ let changed = false;
284
+ for (const [agentId, entry] of this.agents) {
285
+ if (entry.status !== 'running') continue;
286
+ const rec = this.getAgentStatus(agentId);
287
+ if (!rec?.usage) continue;
288
+ const inputTokens = rec.usage.inputTokens + (rec.usage.cacheReadTokens ?? 0) + (rec.usage.cacheWriteTokens ?? 0);
289
+ const outputTokens = rec.usage.outputTokens;
290
+ const cost = calcSessionCost(rec.usage.inputTokens, rec.usage.outputTokens, rec.usage.cacheReadTokens ?? 0, rec.usage.cacheWriteTokens ?? 0, rec.model ?? entry.model);
291
+ if (inputTokens !== entry.inputTokens || outputTokens !== entry.outputTokens || cost !== entry.cost) {
292
+ entry.inputTokens = inputTokens;
293
+ entry.outputTokens = outputTokens;
294
+ entry.cost = cost;
295
+ changed = true;
296
+ }
297
+ if (rec.model && rec.model !== 'unknown' && rec.model !== entry.model) {
298
+ entry.model = rec.model;
299
+ changed = true;
300
+ }
301
+ }
302
+ if (changed) this.markDirty();
303
+ }
304
+
305
+ /** Set the budget alert threshold (USD; 0 disables it). Shared by the
306
+ * in-panel 'b' entry and the /cost budget <usd> command. */
307
+ public setBudgetThreshold(usd: number): void {
308
+ if (!Number.isFinite(usd) || usd < 0) return;
309
+ this.budgetThreshold = usd;
310
+ this.markDirty();
311
+ }
312
+
236
313
  // -------------------------------------------------------------------------
237
314
  // Input
238
315
  // -------------------------------------------------------------------------
239
316
 
240
317
  handleInput(key: string): boolean {
318
+ if (this.budgetEntry !== null) return this.handleBudgetEntryInput(key);
319
+
320
+ if (key === 'b') {
321
+ this.budgetEntry = this.budgetThreshold > 0 ? String(this.budgetThreshold) : '';
322
+ this.markDirty();
323
+ return true;
324
+ }
325
+
241
326
  switch (key) {
242
327
  case 'up': return this.scroll(-1);
243
328
  case 'down': return this.scroll(1);
@@ -247,9 +332,53 @@ export class CostTrackerPanel extends BasePanel {
247
332
  }
248
333
  }
249
334
 
335
+ private handleBudgetEntryInput(key: string): boolean {
336
+ if (key === 'escape') {
337
+ this.budgetEntry = null;
338
+ this.markDirty();
339
+ return true;
340
+ }
341
+ if (key === 'enter' || key === 'return') {
342
+ const raw = this.budgetEntry ?? '';
343
+ this.budgetEntry = null;
344
+ const parsed = Number(raw);
345
+ if (raw.length > 0 && Number.isFinite(parsed) && parsed >= 0) {
346
+ this.setBudgetThreshold(parsed);
347
+ } else {
348
+ this.markDirty();
349
+ }
350
+ return true;
351
+ }
352
+ if (isTextBackspace(key)) {
353
+ if (this.budgetEntry && this.budgetEntry.length > 0) {
354
+ this.budgetEntry = this.budgetEntry.slice(0, -1);
355
+ this.markDirty();
356
+ }
357
+ return true;
358
+ }
359
+ // Digits and a single decimal point only — this is a USD amount, not free text.
360
+ if (key.length === 1 && /[0-9.]/.test(key) && !(key === '.' && this.budgetEntry?.includes('.'))) {
361
+ this.budgetEntry = (this.budgetEntry ?? '') + key;
362
+ this.markDirty();
363
+ return true;
364
+ }
365
+ return true; // absorb everything else while entry is active
366
+ }
367
+
368
+ /**
369
+ * Scroll the agent list. Over-consumption fix: only absorbs the key (and
370
+ * only advances) when the agent list is actually long enough to scroll —
371
+ * mirrors the same `> 5` threshold render() uses to decide whether to show
372
+ * scroll hints (below). Previously this always returned true and grew
373
+ * scrollOffset unboundedly even with a handful of agents, swallowing
374
+ * up/down/page keys that had nothing to do.
375
+ */
250
376
  private scroll(delta: number): boolean {
377
+ const agentCount = this.agents.size;
378
+ if (agentCount <= 5) return false;
379
+ const maxOffset = Math.max(0, agentCount - 1);
251
380
  const prev = this.scrollOffset;
252
- this.scrollOffset = Math.max(0, this.scrollOffset + delta);
381
+ this.scrollOffset = Math.max(0, Math.min(maxOffset, this.scrollOffset + delta));
253
382
  if (this.scrollOffset !== prev) this.markDirty();
254
383
  return true;
255
384
  }
@@ -260,10 +389,10 @@ export class CostTrackerPanel extends BasePanel {
260
389
 
261
390
  render(width: number, height: number): Line[] {
262
391
  if (height <= 0 || width <= 0) return [];
392
+ if (this.budgetEntry !== null) return this.renderBudgetEntryPrompt(width, height);
263
393
 
264
- const pricing = getPricing(this.sessionModel);
265
394
  const totalInputTokens = this.sessionUsage.input + this.sessionUsage.cacheRead + this.sessionUsage.cacheWrite;
266
- const sessionCost = (totalInputTokens * pricing.input + this.sessionUsage.output * pricing.output) / 1_000_000;
395
+ const sessionCost = calcSessionCost(this.sessionUsage.input, this.sessionUsage.output, this.sessionUsage.cacheRead, this.sessionUsage.cacheWrite, this.sessionModel);
267
396
  const overBudget = this.budgetThreshold > 0 && sessionCost > this.budgetThreshold;
268
397
  const sparkline = buildSparkline(this.costHistory);
269
398
  const costStr = formatCost(sessionCost);
@@ -275,6 +404,20 @@ export class CostTrackerPanel extends BasePanel {
275
404
  const sessionLines: Line[] = [
276
405
  this.renderKeyValue(width, ' Total', `${costStr}${budgetStr}${alertStr}`, costFg),
277
406
  ];
407
+ // Budget meter — the single most important glance for this panel: how much
408
+ // of the configured budget the session has consumed. Only shown when a
409
+ // budget is set (otherwise the bar would be meaningless).
410
+ if (this.budgetThreshold > 0) {
411
+ const ratio = sessionCost / this.budgetThreshold;
412
+ const BAR_W = 24;
413
+ const filled = Math.max(0, Math.min(BAR_W, Math.round(ratio * BAR_W)));
414
+ const meterFg = overBudget ? C.bad : ratio >= 0.8 ? C.warn : C.good;
415
+ const pctStr = `${Math.round(ratio * 100)}%`;
416
+ sessionLines.push(buildMeterLine(width, filled, BAR_W,
417
+ { filled: meterFg, empty: C.separator, label: C.label },
418
+ { prefix: ' Budget [', suffix: `] ${pctStr}` },
419
+ ));
420
+ }
278
421
  if (sparkline.length > 0) sessionLines.push(this.renderLabeledLine(width, ' Trend', sparkline, C.good));
279
422
  sessionLines.push(this.renderKeyValue(width, ' Input', formatTokens(this.sessionUsage.input), C.label));
280
423
  sessionLines.push(this.renderKeyValue(width, ' Output', formatTokens(this.sessionUsage.output), C.label));
@@ -293,35 +436,64 @@ export class CostTrackerPanel extends BasePanel {
293
436
  ];
294
437
 
295
438
  const agentList = Array.from(this.agents.values());
439
+ const scrollableAgents = agentList.length > 5;
440
+ // Footer hints adapt to whether the agent list is long enough to scroll.
441
+ const hintRow = scrollableAgents
442
+ ? buildKeyboardHints(width, [
443
+ { keys: 'Up/Down', label: 'scroll agents' },
444
+ { keys: 'PgUp/PgDn', label: 'page' },
445
+ { keys: 'b', label: 'set budget' },
446
+ ], DEFAULT_PANEL_PALETTE)
447
+ : buildKeyboardHints(width, [
448
+ { keys: 'b', label: 'set budget alert' },
449
+ ], DEFAULT_PANEL_PALETTE);
296
450
  if (agentList.length > 0) {
297
451
  const planCost = agentList.reduce((sum, a) => sum + a.cost, 0);
452
+ const running = agentList.filter((a) => a.status === 'running').length;
453
+ const failed = agentList.filter((a) => a.status === 'failed').length;
298
454
  const agentRows: Line[] = [
299
- this.renderKeyValue(width, ' Plan total', formatCost(planCost + sessionCost), C.cost),
300
- this.renderDivider(width),
455
+ buildStyledPanelLine(width, [
456
+ { text: ' Plan total ', fg: C.label },
457
+ { text: formatCost(planCost + sessionCost), fg: C.cost, bold: true },
458
+ { text: ` ${agentList.length} agent${agentList.length === 1 ? '' : 's'}`, fg: C.dim },
459
+ ...(running > 0 ? [{ text: ` ${running} running`, fg: C.running }] : []),
460
+ ...(failed > 0 ? [{ text: ` ${failed} failed`, fg: C.bad }] : []),
461
+ ]),
462
+ // Per-agent cost ledger as an aligned table — agent, model, tokens, cost
463
+ // line up in columns instead of wrapping across two ragged rows.
464
+ ...buildTable(
465
+ width,
466
+ [
467
+ { label: 'Agent', width: 14 },
468
+ { label: 'Model', width: 18 },
469
+ { label: 'In', width: 7, align: 'right' },
470
+ { label: 'Out', width: 7, align: 'right' },
471
+ { label: 'Cost', align: 'right' },
472
+ ],
473
+ agentList.map((agent) => {
474
+ const statusFg = agent.status === 'running' ? C.running
475
+ : agent.status === 'failed' ? C.bad
476
+ : C.good;
477
+ const statusIcon = agent.status === 'running' ? '…'
478
+ : agent.status === 'failed' ? '✕'
479
+ : '✓';
480
+ return {
481
+ cells: [
482
+ { text: `${statusIcon} ${agent.id}`, fg: statusFg },
483
+ { text: agent.model, fg: C.model },
484
+ { text: agent.inputTokens > 0 ? formatTokens(agent.inputTokens) : '-', fg: C.dim },
485
+ { text: agent.outputTokens > 0 ? formatTokens(agent.outputTokens) : '-', fg: C.dim },
486
+ { text: agent.cost > 0 ? formatCost(agent.cost) : '-', fg: agent.cost > 0 ? C.cost : C.dim },
487
+ ],
488
+ };
489
+ }),
490
+ DEFAULT_PANEL_PALETTE,
491
+ ),
301
492
  ];
302
- for (const agent of agentList) {
303
- const statusFg = agent.status === 'running' ? C.running
304
- : agent.status === 'failed' ? C.bad
305
- : C.good;
306
- const statusIcon = agent.status === 'running' ? '…'
307
- : agent.status === 'failed' ? '✕'
308
- : '✓';
309
-
310
- const agentLabel = `${statusIcon} ${agent.id}`;
311
- const taskText = agent.task;
312
- agentRows.push(this.renderAgent(width, agentLabel, taskText, statusFg));
313
-
314
- if (agent.inputTokens > 0 || agent.outputTokens > 0) {
315
- const tokenInfo = ` in:${formatTokens(agent.inputTokens)} out:${formatTokens(agent.outputTokens)} ${formatCost(agent.cost)}`;
316
- agentRows.push(this.renderLabeledLine(width, '', tokenInfo, C.dim));
317
- }
318
- }
319
493
  const sessionSection: PanelWorkspaceSection = sections[0]!;
320
494
  const agentsSection = resolveScrollablePanelSection(width, height, {
321
495
  intro: 'Track per-session and per-agent token spend using model pricing and live usage snapshots.',
322
- footerLines: [
323
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' scroll agents', DEFAULT_PANEL_PALETTE.dim]]),
324
- ],
496
+ footerLines: [hintRow],
325
497
  palette: DEFAULT_PANEL_PALETTE,
326
498
  beforeSections: [sessionSection],
327
499
  section: {
@@ -329,6 +501,7 @@ export class CostTrackerPanel extends BasePanel {
329
501
  scrollableLines: agentRows,
330
502
  scrollOffset: this.scrollOffset,
331
503
  minRows: 4,
504
+ appendWindowSummary: scrollableAgents ? { dimColor: DEFAULT_PANEL_PALETTE.dim } : undefined,
332
505
  },
333
506
  });
334
507
  this.scrollOffset = agentsSection.scrollOffset;
@@ -340,7 +513,13 @@ export class CostTrackerPanel extends BasePanel {
340
513
  width,
341
514
  ' No agents spawned this session',
342
515
  'Agent-level cost estimates appear here once delegated or background agents start running.',
343
- [],
516
+ // WO-160: 'b' already opens the in-panel budget-entry field (see
517
+ // handleInput/handleBudgetEntryInput above) and is advertised in
518
+ // the footer's 'b: set budget' hint, so the printed
519
+ // '/cost budget <usd>' command was a redundant action substitute.
520
+ [
521
+ { command: 'b', summary: 'set a session budget alert to track spend against a cap' },
522
+ ],
344
523
  DEFAULT_PANEL_PALETTE,
345
524
  ),
346
525
  });
@@ -350,9 +529,7 @@ export class CostTrackerPanel extends BasePanel {
350
529
  title: ' Cost Tracker',
351
530
  intro: 'Track per-session and per-agent token spend using model pricing and live usage snapshots.',
352
531
  sections,
353
- footerLines: [
354
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' scroll agents', DEFAULT_PANEL_PALETTE.dim]]),
355
- ],
532
+ footerLines: [hintRow],
356
533
  palette: DEFAULT_PANEL_PALETTE,
357
534
  });
358
535
  }
@@ -364,7 +541,7 @@ export class CostTrackerPanel extends BasePanel {
364
541
  private renderKeyValue(width: number, label: string, value: string, valueFg: string): Line {
365
542
  const LABEL_W = 10;
366
543
  return buildStyledPanelLine(width, [
367
- { text: label.padEnd(LABEL_W).slice(0, LABEL_W), fg: C.label, bg: C.bg },
544
+ { text: fitDisplay(label, LABEL_W), fg: C.label, bg: C.bg },
368
545
  { text: ': ', fg: C.dim, bg: C.bg },
369
546
  { text: value, fg: valueFg, bg: C.bg, bold: true },
370
547
  ]);
@@ -372,23 +549,32 @@ export class CostTrackerPanel extends BasePanel {
372
549
 
373
550
  private renderLabeledLine(width: number, label: string, value: string, valueFg: string): Line {
374
551
  return buildStyledPanelLine(width, [
375
- ...(label.length > 0 ? [{ text: `${label.slice(0, 10).padEnd(10)} `, fg: C.label }] : []),
552
+ ...(label.length > 0 ? [{ text: `${fitDisplay(label, 10)} `, fg: C.label }] : []),
376
553
  { text: value, fg: valueFg },
377
554
  ]);
378
555
  }
379
556
 
380
- private renderAgent(width: number, label: string, task: string, fg: string): Line {
381
- const LABEL_W = 12;
382
- const remaining = width - LABEL_W - 1;
383
- const trimmed = task.length > remaining ? task.slice(0, remaining - 3) + '...' : task;
384
- return buildStyledPanelLine(width, [
385
- { text: `${label.padEnd(LABEL_W).slice(0, LABEL_W)} `, fg, bold: true },
386
- { text: trimmed, fg: C.label },
387
- ]);
388
- }
389
-
390
- private renderDivider(width: number): Line {
391
- return buildStyledPanelLine(width, [{ text: '─'.repeat(width), fg: C.separator }]);
557
+ // Non-masked numeric entry for the 'b' budget-alert key the value is a
558
+ // USD amount, not a secret, so (unlike LocalAuthPanel's masked entry) it is
559
+ // echoed directly rather than dotted out.
560
+ private renderBudgetEntryPrompt(width: number, height: number): Line[] {
561
+ const draft = this.budgetEntry ?? '';
562
+ const display = `$${draft}█`;
563
+ const promptLines: Line[] = [
564
+ buildPanelLine(width, [[' Set the session budget alert (USD). 0 disables it.', C.label]]),
565
+ buildPanelLine(width, [['', C.label]]),
566
+ buildPanelLine(width, [[' Budget ', C.label], [display, C.cost]]),
567
+ buildPanelLine(width, [['', C.label]]),
568
+ buildPanelLine(width, [[' [Enter] Confirm [Esc] Cancel [Backspace] Delete char', C.dim]]),
569
+ ];
570
+ const workspace = buildPanelWorkspace(width, height, {
571
+ title: ' Cost Tracker — Budget',
572
+ intro: 'Type a USD amount and press Enter to set the budget alert threshold.',
573
+ sections: [{ lines: promptLines }],
574
+ palette: DEFAULT_PANEL_PALETTE,
575
+ });
576
+ while (workspace.length < height) workspace.push(createEmptyLine(width));
577
+ return workspace.slice(0, height);
392
578
  }
393
579
 
394
580
  }