@pellux/goodvibes-tui 0.26.0 → 0.28.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 (148) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +4 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/context-usage.ts +53 -0
  8. package/src/core/conversation-rendering.ts +2 -2
  9. package/src/core/conversation.ts +53 -8
  10. package/src/core/session-recovery.ts +26 -18
  11. package/src/core/system-message-router.ts +42 -26
  12. package/src/core/turn-event-wiring.ts +13 -16
  13. package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
  14. package/src/daemon/handlers/inbox/index.ts +2 -1
  15. package/src/daemon/handlers/inbox/poller.ts +2 -1
  16. package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
  17. package/src/daemon/handlers/inbox/providers/email.ts +2 -1
  18. package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
  19. package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
  20. package/src/daemon/handlers/triage/integration.ts +2 -1
  21. package/src/daemon/handlers/triage/pipeline.ts +2 -1
  22. package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
  23. package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
  24. package/src/export/gist-uploader.ts +3 -1
  25. package/src/input/command-registry.ts +1 -0
  26. package/src/input/commands/cloudflare-runtime.ts +2 -1
  27. package/src/input/commands/eval.ts +4 -3
  28. package/src/input/commands/guidance-runtime.ts +2 -4
  29. package/src/input/commands/health-runtime.ts +13 -7
  30. package/src/input/commands/knowledge.ts +2 -1
  31. package/src/input/commands/runtime-services.ts +40 -10
  32. package/src/input/handler-command-route.ts +1 -1
  33. package/src/input/handler-feed-routes.ts +61 -4
  34. package/src/input/handler-feed.ts +13 -0
  35. package/src/input/handler-interactions.ts +2 -1
  36. package/src/input/handler-modal-routes.ts +2 -1
  37. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  38. package/src/input/handler-onboarding.ts +8 -8
  39. package/src/input/handler-picker-routes.ts +18 -13
  40. package/src/input/handler-ui-state.ts +5 -1
  41. package/src/input/keybindings.ts +5 -0
  42. package/src/input/model-picker.ts +5 -0
  43. package/src/input/panel-integration-actions.ts +10 -0
  44. package/src/input/tts-settings-actions.ts +2 -1
  45. package/src/main.ts +52 -60
  46. package/src/panels/agent-inspector-panel.ts +90 -15
  47. package/src/panels/agent-inspector-shared.ts +3 -3
  48. package/src/panels/agent-logs-panel.ts +149 -72
  49. package/src/panels/approval-panel.ts +146 -95
  50. package/src/panels/automation-control-panel.ts +24 -1
  51. package/src/panels/base-panel.ts +28 -3
  52. package/src/panels/builtin/agent.ts +3 -1
  53. package/src/panels/builtin/development.ts +2 -1
  54. package/src/panels/builtin/session.ts +1 -1
  55. package/src/panels/cockpit-panel.ts +24 -5
  56. package/src/panels/cockpit-read-model.ts +2 -1
  57. package/src/panels/communication-panel.ts +108 -43
  58. package/src/panels/context-visualizer-panel.ts +49 -15
  59. package/src/panels/control-plane-panel.ts +27 -1
  60. package/src/panels/cost-tracker-panel.ts +87 -65
  61. package/src/panels/debug-panel.ts +61 -37
  62. package/src/panels/diff-panel.ts +59 -30
  63. package/src/panels/docs-panel.ts +30 -24
  64. package/src/panels/eval-panel.ts +130 -81
  65. package/src/panels/expandable-list-panel.ts +189 -0
  66. package/src/panels/file-explorer-panel.ts +42 -42
  67. package/src/panels/file-preview-panel.ts +11 -3
  68. package/src/panels/forensics-panel.ts +27 -13
  69. package/src/panels/git-panel.ts +65 -84
  70. package/src/panels/hooks-panel.ts +36 -2
  71. package/src/panels/incident-review-panel.ts +31 -10
  72. package/src/panels/intelligence-panel.ts +152 -71
  73. package/src/panels/knowledge-graph-panel.ts +89 -27
  74. package/src/panels/local-auth-panel.ts +17 -11
  75. package/src/panels/marketplace-panel.ts +33 -13
  76. package/src/panels/memory-panel.ts +7 -5
  77. package/src/panels/ops-control-panel.ts +69 -7
  78. package/src/panels/ops-strategy-panel.ts +61 -43
  79. package/src/panels/orchestration-panel.ts +34 -4
  80. package/src/panels/panel-list-panel.ts +33 -8
  81. package/src/panels/panel-manager.ts +23 -4
  82. package/src/panels/plan-dashboard-panel.ts +183 -66
  83. package/src/panels/plugins-panel.ts +48 -10
  84. package/src/panels/policy-panel.ts +60 -23
  85. package/src/panels/polish-core.ts +157 -0
  86. package/src/panels/polish-tables.ts +258 -0
  87. package/src/panels/polish.ts +44 -145
  88. package/src/panels/project-planning-panel.ts +27 -4
  89. package/src/panels/provider-accounts-panel.ts +55 -18
  90. package/src/panels/provider-health-panel.ts +118 -87
  91. package/src/panels/provider-stats-panel.ts +47 -22
  92. package/src/panels/qr-panel.ts +23 -11
  93. package/src/panels/remote-panel.ts +12 -5
  94. package/src/panels/routes-panel.ts +27 -5
  95. package/src/panels/sandbox-panel.ts +62 -27
  96. package/src/panels/schedule-panel.ts +44 -24
  97. package/src/panels/scrollable-list-panel.ts +124 -10
  98. package/src/panels/security-panel.ts +72 -20
  99. package/src/panels/services-panel.ts +68 -22
  100. package/src/panels/session-browser-panel.ts +42 -26
  101. package/src/panels/session-maintenance.ts +2 -2
  102. package/src/panels/settings-sync-panel.ts +30 -15
  103. package/src/panels/skills-panel.ts +2 -1
  104. package/src/panels/subscription-panel.ts +21 -3
  105. package/src/panels/symbol-outline-panel.ts +40 -47
  106. package/src/panels/system-messages-panel.ts +60 -27
  107. package/src/panels/tasks-panel.ts +36 -14
  108. package/src/panels/thinking-panel.ts +32 -36
  109. package/src/panels/token-budget-panel.ts +80 -53
  110. package/src/panels/tool-inspector-panel.ts +37 -39
  111. package/src/panels/types.ts +25 -0
  112. package/src/panels/watchers-panel.ts +25 -5
  113. package/src/panels/work-plan-panel.ts +127 -35
  114. package/src/panels/worktree-panel.ts +65 -20
  115. package/src/panels/wrfc-panel-format.ts +133 -0
  116. package/src/panels/wrfc-panel.ts +53 -147
  117. package/src/renderer/agent-detail-modal.ts +2 -2
  118. package/src/renderer/compaction-preview.ts +2 -1
  119. package/src/renderer/compositor.ts +46 -43
  120. package/src/renderer/modal-utils.ts +0 -10
  121. package/src/renderer/model-picker-overlay.ts +9 -4
  122. package/src/renderer/model-workspace.ts +2 -3
  123. package/src/renderer/panel-composite.ts +7 -20
  124. package/src/renderer/panel-workspace-bar.ts +14 -8
  125. package/src/renderer/process-modal.ts +2 -2
  126. package/src/renderer/progress.ts +3 -2
  127. package/src/renderer/tab-strip.ts +148 -34
  128. package/src/renderer/ui-factory.ts +4 -6
  129. package/src/runtime/bootstrap-command-context.ts +3 -0
  130. package/src/runtime/bootstrap-command-parts.ts +3 -1
  131. package/src/runtime/bootstrap-core.ts +31 -31
  132. package/src/runtime/bootstrap-shell.ts +1 -0
  133. package/src/runtime/onboarding/apply.ts +4 -3
  134. package/src/runtime/onboarding/snapshot.ts +4 -3
  135. package/src/runtime/process-lifecycle.ts +195 -0
  136. package/src/runtime/services.ts +4 -1
  137. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  138. package/src/runtime/ui/model-picker/types.ts +4 -0
  139. package/src/runtime/wrfc-persistence.ts +20 -5
  140. package/src/shell/ui-openers.ts +3 -2
  141. package/src/utils/format-duration.ts +55 -0
  142. package/src/utils/format-number.ts +71 -0
  143. package/src/verification/live-verifier.ts +4 -3
  144. package/src/version.ts +1 -1
  145. package/src/core/context-auto-compact.ts +0 -110
  146. package/src/panels/panel-picker.ts +0 -106
  147. package/src/renderer/panel-picker-overlay.ts +0 -202
  148. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -4,20 +4,26 @@
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,
21
+ extendPalette,
17
22
  DEFAULT_PANEL_PALETTE,
18
23
  type PanelWorkspaceSection,
19
24
  } from './polish.ts';
20
- import { getPricing } from '../export/cost-utils.ts';
25
+ import { calcSessionCost } from '../export/cost-utils.ts';
26
+ import { abbreviateCount } from '../utils/format-number.ts';
21
27
 
22
28
  // Pricing lookups are provided by ../export/cost-utils.ts (single source of truth).
23
29
 
@@ -29,9 +35,7 @@ function formatCost(usd: number): string {
29
35
  }
30
36
 
31
37
  function formatTokens(n: number): string {
32
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
33
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
34
- return String(n);
38
+ return abbreviateCount(n);
35
39
  }
36
40
 
37
41
  // ---------------------------------------------------------------------------
@@ -75,20 +79,13 @@ function buildSparkline(history: number[]): string {
75
79
  // Colour palette
76
80
  // ---------------------------------------------------------------------------
77
81
 
78
- const C = {
79
- header: '#ffffff',
80
- label: '#aaaaaa',
81
- value: '#00ff88',
82
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
82
83
  cost: '#ffdd44',
83
- alert: '#ff4444',
84
- dim: '#555555',
85
84
  model: '#88aaff',
86
85
  running: '#88aaff',
87
- done: '#00ff88',
88
- failed: '#ff4444',
89
86
  separator: '#333333',
90
87
  bg: '',
91
- } as const;
88
+ });
92
89
 
93
90
  // ---------------------------------------------------------------------------
94
91
  // CostTrackerPanel
@@ -150,7 +147,7 @@ export class CostTrackerPanel extends BasePanel {
150
147
  agentEvents.on('AGENT_SPAWNING', (payload) => {
151
148
  this.agents.set(payload.agentId, {
152
149
  id: payload.agentId.slice(0, 8),
153
- task: payload.task.length > 40 ? payload.task.slice(0, 37) + '...' : payload.task,
150
+ task: truncateDisplay(payload.task, 40),
154
151
  model: 'unknown',
155
152
  inputTokens: 0,
156
153
  outputTokens: 0,
@@ -172,8 +169,7 @@ export class CostTrackerPanel extends BasePanel {
172
169
  if (rec?.usage) {
173
170
  entry.inputTokens = rec.usage.inputTokens + (rec.usage.cacheReadTokens ?? 0) + (rec.usage.cacheWriteTokens ?? 0);
174
171
  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;
172
+ entry.cost = calcSessionCost(rec.usage.inputTokens, rec.usage.outputTokens, rec.usage.cacheReadTokens ?? 0, rec.usage.cacheWriteTokens ?? 0, rec.model ?? 'unknown');
177
173
  if (rec.model && rec.model !== 'unknown') entry.model = rec.model;
178
174
  }
179
175
  }
@@ -205,9 +201,7 @@ export class CostTrackerPanel extends BasePanel {
205
201
  if (usage.model) this.sessionModel = usage.model;
206
202
 
207
203
  // Record cost delta for sparkline
208
- const pricing = getPricing(this.sessionModel);
209
- const billableInput = usage.input + usage.cacheRead + usage.cacheWrite;
210
- const totalCost = (billableInput * pricing.input + usage.output * pricing.output) / 1_000_000;
204
+ const totalCost = calcSessionCost(usage.input, usage.output, usage.cacheRead, usage.cacheWrite, this.sessionModel);
211
205
  const delta = Math.max(0, totalCost - this.lastSessionCost);
212
206
  this.lastSessionCost = totalCost;
213
207
  this.costHistory.push(delta);
@@ -267,13 +261,12 @@ export class CostTrackerPanel extends BasePanel {
267
261
  render(width: number, height: number): Line[] {
268
262
  if (height <= 0 || width <= 0) return [];
269
263
 
270
- const pricing = getPricing(this.sessionModel);
271
264
  const totalInputTokens = this.sessionUsage.input + this.sessionUsage.cacheRead + this.sessionUsage.cacheWrite;
272
- const sessionCost = (totalInputTokens * pricing.input + this.sessionUsage.output * pricing.output) / 1_000_000;
265
+ const sessionCost = calcSessionCost(this.sessionUsage.input, this.sessionUsage.output, this.sessionUsage.cacheRead, this.sessionUsage.cacheWrite, this.sessionModel);
273
266
  const overBudget = this.budgetThreshold > 0 && sessionCost > this.budgetThreshold;
274
267
  const sparkline = buildSparkline(this.costHistory);
275
268
  const costStr = formatCost(sessionCost);
276
- const costFg = overBudget ? C.alert : C.cost;
269
+ const costFg = overBudget ? C.bad : C.cost;
277
270
  const budgetStr = this.budgetThreshold > 0
278
271
  ? ` / ${formatCost(this.budgetThreshold)}`
279
272
  : '';
@@ -281,7 +274,21 @@ export class CostTrackerPanel extends BasePanel {
281
274
  const sessionLines: Line[] = [
282
275
  this.renderKeyValue(width, ' Total', `${costStr}${budgetStr}${alertStr}`, costFg),
283
276
  ];
284
- if (sparkline.length > 0) sessionLines.push(this.renderLabeledLine(width, ' Trend', sparkline, C.value));
277
+ // Budget meter the single most important glance for this panel: how much
278
+ // of the configured budget the session has consumed. Only shown when a
279
+ // budget is set (otherwise the bar would be meaningless).
280
+ if (this.budgetThreshold > 0) {
281
+ const ratio = sessionCost / this.budgetThreshold;
282
+ const BAR_W = 24;
283
+ const filled = Math.max(0, Math.min(BAR_W, Math.round(ratio * BAR_W)));
284
+ const meterFg = overBudget ? C.bad : ratio >= 0.8 ? C.warn : C.good;
285
+ const pctStr = `${Math.round(ratio * 100)}%`;
286
+ sessionLines.push(buildMeterLine(width, filled, BAR_W,
287
+ { filled: meterFg, empty: C.separator, label: C.label },
288
+ { prefix: ' Budget [', suffix: `] ${pctStr}` },
289
+ ));
290
+ }
291
+ if (sparkline.length > 0) sessionLines.push(this.renderLabeledLine(width, ' Trend', sparkline, C.good));
285
292
  sessionLines.push(this.renderKeyValue(width, ' Input', formatTokens(this.sessionUsage.input), C.label));
286
293
  sessionLines.push(this.renderKeyValue(width, ' Output', formatTokens(this.sessionUsage.output), C.label));
287
294
  if (this.sessionUsage.cacheRead > 0 || this.sessionUsage.cacheWrite > 0) {
@@ -299,35 +306,63 @@ export class CostTrackerPanel extends BasePanel {
299
306
  ];
300
307
 
301
308
  const agentList = Array.from(this.agents.values());
309
+ const scrollableAgents = agentList.length > 5;
310
+ // Footer hints adapt to whether the agent list is long enough to scroll.
311
+ const hintRow = scrollableAgents
312
+ ? buildKeyboardHints(width, [
313
+ { keys: 'Up/Down', label: 'scroll agents' },
314
+ { keys: 'PgUp/PgDn', label: 'page' },
315
+ ], DEFAULT_PANEL_PALETTE)
316
+ : buildKeyboardHints(width, [
317
+ { keys: '/cost budget <usd>', label: 'set budget alert' },
318
+ ], DEFAULT_PANEL_PALETTE);
302
319
  if (agentList.length > 0) {
303
320
  const planCost = agentList.reduce((sum, a) => sum + a.cost, 0);
321
+ const running = agentList.filter((a) => a.status === 'running').length;
322
+ const failed = agentList.filter((a) => a.status === 'failed').length;
304
323
  const agentRows: Line[] = [
305
- this.renderKeyValue(width, ' Plan total', formatCost(planCost + sessionCost), C.cost),
306
- this.renderDivider(width),
324
+ buildStyledPanelLine(width, [
325
+ { text: ' Plan total ', fg: C.label },
326
+ { text: formatCost(planCost + sessionCost), fg: C.cost, bold: true },
327
+ { text: ` ${agentList.length} agent${agentList.length === 1 ? '' : 's'}`, fg: C.dim },
328
+ ...(running > 0 ? [{ text: ` ${running} running`, fg: C.running }] : []),
329
+ ...(failed > 0 ? [{ text: ` ${failed} failed`, fg: C.bad }] : []),
330
+ ]),
331
+ // Per-agent cost ledger as an aligned table — agent, model, tokens, cost
332
+ // line up in columns instead of wrapping across two ragged rows.
333
+ ...buildTable(
334
+ width,
335
+ [
336
+ { label: 'Agent', width: 14 },
337
+ { label: 'Model', width: 18 },
338
+ { label: 'In', width: 7, align: 'right' },
339
+ { label: 'Out', width: 7, align: 'right' },
340
+ { label: 'Cost', align: 'right' },
341
+ ],
342
+ agentList.map((agent) => {
343
+ const statusFg = agent.status === 'running' ? C.running
344
+ : agent.status === 'failed' ? C.bad
345
+ : C.good;
346
+ const statusIcon = agent.status === 'running' ? '…'
347
+ : agent.status === 'failed' ? '✕'
348
+ : '✓';
349
+ return {
350
+ cells: [
351
+ { text: `${statusIcon} ${agent.id}`, fg: statusFg },
352
+ { text: agent.model, fg: C.model },
353
+ { text: agent.inputTokens > 0 ? formatTokens(agent.inputTokens) : '-', fg: C.dim },
354
+ { text: agent.outputTokens > 0 ? formatTokens(agent.outputTokens) : '-', fg: C.dim },
355
+ { text: agent.cost > 0 ? formatCost(agent.cost) : '-', fg: agent.cost > 0 ? C.cost : C.dim },
356
+ ],
357
+ };
358
+ }),
359
+ DEFAULT_PANEL_PALETTE,
360
+ ),
307
361
  ];
308
- for (const agent of agentList) {
309
- const statusFg = agent.status === 'running' ? C.running
310
- : agent.status === 'failed' ? C.failed
311
- : C.done;
312
- const statusIcon = agent.status === 'running' ? '…'
313
- : agent.status === 'failed' ? '✕'
314
- : '✓';
315
-
316
- const agentLabel = `${statusIcon} ${agent.id}`;
317
- const taskText = agent.task;
318
- agentRows.push(this.renderAgent(width, agentLabel, taskText, statusFg));
319
-
320
- if (agent.inputTokens > 0 || agent.outputTokens > 0) {
321
- const tokenInfo = ` in:${formatTokens(agent.inputTokens)} out:${formatTokens(agent.outputTokens)} ${formatCost(agent.cost)}`;
322
- agentRows.push(this.renderLabeledLine(width, '', tokenInfo, C.dim));
323
- }
324
- }
325
362
  const sessionSection: PanelWorkspaceSection = sections[0]!;
326
363
  const agentsSection = resolveScrollablePanelSection(width, height, {
327
364
  intro: 'Track per-session and per-agent token spend using model pricing and live usage snapshots.',
328
- footerLines: [
329
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' scroll agents', DEFAULT_PANEL_PALETTE.dim]]),
330
- ],
365
+ footerLines: [hintRow],
331
366
  palette: DEFAULT_PANEL_PALETTE,
332
367
  beforeSections: [sessionSection],
333
368
  section: {
@@ -335,6 +370,7 @@ export class CostTrackerPanel extends BasePanel {
335
370
  scrollableLines: agentRows,
336
371
  scrollOffset: this.scrollOffset,
337
372
  minRows: 4,
373
+ appendWindowSummary: scrollableAgents ? { dimColor: DEFAULT_PANEL_PALETTE.dim } : undefined,
338
374
  },
339
375
  });
340
376
  this.scrollOffset = agentsSection.scrollOffset;
@@ -346,7 +382,9 @@ export class CostTrackerPanel extends BasePanel {
346
382
  width,
347
383
  ' No agents spawned this session',
348
384
  'Agent-level cost estimates appear here once delegated or background agents start running.',
349
- [],
385
+ [
386
+ { command: '/cost budget <usd>', summary: 'set a session budget alert to track spend against a cap' },
387
+ ],
350
388
  DEFAULT_PANEL_PALETTE,
351
389
  ),
352
390
  });
@@ -356,9 +394,7 @@ export class CostTrackerPanel extends BasePanel {
356
394
  title: ' Cost Tracker',
357
395
  intro: 'Track per-session and per-agent token spend using model pricing and live usage snapshots.',
358
396
  sections,
359
- footerLines: [
360
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' scroll agents', DEFAULT_PANEL_PALETTE.dim]]),
361
- ],
397
+ footerLines: [hintRow],
362
398
  palette: DEFAULT_PANEL_PALETTE,
363
399
  });
364
400
  }
@@ -370,7 +406,7 @@ export class CostTrackerPanel extends BasePanel {
370
406
  private renderKeyValue(width: number, label: string, value: string, valueFg: string): Line {
371
407
  const LABEL_W = 10;
372
408
  return buildStyledPanelLine(width, [
373
- { text: label.padEnd(LABEL_W).slice(0, LABEL_W), fg: C.label, bg: C.bg },
409
+ { text: fitDisplay(label, LABEL_W), fg: C.label, bg: C.bg },
374
410
  { text: ': ', fg: C.dim, bg: C.bg },
375
411
  { text: value, fg: valueFg, bg: C.bg, bold: true },
376
412
  ]);
@@ -378,23 +414,9 @@ export class CostTrackerPanel extends BasePanel {
378
414
 
379
415
  private renderLabeledLine(width: number, label: string, value: string, valueFg: string): Line {
380
416
  return buildStyledPanelLine(width, [
381
- ...(label.length > 0 ? [{ text: `${label.slice(0, 10).padEnd(10)} `, fg: C.label }] : []),
417
+ ...(label.length > 0 ? [{ text: `${fitDisplay(label, 10)} `, fg: C.label }] : []),
382
418
  { text: value, fg: valueFg },
383
419
  ]);
384
420
  }
385
421
 
386
- private renderAgent(width: number, label: string, task: string, fg: string): Line {
387
- const LABEL_W = 12;
388
- const remaining = width - LABEL_W - 1;
389
- const trimmed = task.length > remaining ? task.slice(0, remaining - 3) + '...' : task;
390
- return buildStyledPanelLine(width, [
391
- { text: `${label.padEnd(LABEL_W).slice(0, LABEL_W)} `, fg, bold: true },
392
- { text: trimmed, fg: C.label },
393
- ]);
394
- }
395
-
396
- private renderDivider(width: number): Line {
397
- return buildStyledPanelLine(width, [{ text: '─'.repeat(width), fg: C.separator }]);
398
- }
399
-
400
422
  }
@@ -1,5 +1,6 @@
1
1
  import { BasePanel } from './base-panel.ts';
2
2
  import { createEmptyLine, createStyledCell, type Line } from '../types/grid.ts';
3
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import type { TurnEvent } from '@/runtime/index.ts';
4
5
  import type { UiEventFeed } from '../runtime/ui-events.ts';
5
6
  import type { Orchestrator } from '../core/orchestrator';
@@ -11,8 +12,11 @@ import {
11
12
  resolveScrollablePanelSection,
12
13
  resolveStackedScrollableSections,
13
14
  DEFAULT_PANEL_PALETTE,
15
+ extendPalette,
14
16
  type PanelWorkspaceSection,
15
17
  } from './polish.ts';
18
+ import { abbreviateCount } from '../utils/format-number.ts';
19
+ import { formatLatencyMs } from '../utils/format-duration.ts';
16
20
 
17
21
  // ---------------------------------------------------------------------------
18
22
  // Types
@@ -52,24 +56,12 @@ const MAX_ERROR_LOG = 20;
52
56
  // Colors
53
57
  // ---------------------------------------------------------------------------
54
58
 
55
- const C = {
56
- title: '#00ffff',
57
- ok: '#5fd700',
58
- error: '#ff5f5f',
59
- warn: '#ffaf00',
60
- label: '244',
61
- value: '252',
62
- dim: '240',
59
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
63
60
  provName: '#e2e8f0',
64
- separator: '#374151',
65
61
  input: '#00ffff',
66
62
  output: '#d000ff',
67
- latGood: '#5fd700',
68
- latWarn: '#ffaf00',
69
- latBad: '#ff5f5f',
70
- sectionHdr: '238',
71
63
  colHdr: '242',
72
- } as const;
64
+ });
73
65
 
74
66
  const LATENCY_WARN_MS = 2_000;
75
67
  const LATENCY_BAD_MS = 5_000;
@@ -79,16 +71,11 @@ const LATENCY_BAD_MS = 5_000;
79
71
  // ---------------------------------------------------------------------------
80
72
 
81
73
  function fmtTok(n: number): string {
82
- if (n < 10_000) return String(n);
83
- if (n < 1_000_000) return (n / 1000).toFixed(1) + 'k';
84
- return (n / 1_000_000).toFixed(2) + 'M';
74
+ return abbreviateCount(n, { guard: 10_000, decimals: 1, mDecimals: 2 });
85
75
  }
86
76
 
87
77
  function fmtMs(ms: number): string {
88
- if (ms <= 0) return 'n/a';
89
- if (ms >= 10_000) return `${(ms / 1000).toFixed(1)}s`;
90
- if (ms >= 1_000) return `${(ms / 1000).toFixed(2)}s`;
91
- return `${Math.round(ms)}ms`;
78
+ return formatLatencyMs(ms);
92
79
  }
93
80
 
94
81
  function fmtAgo(ts: number): string {
@@ -99,9 +86,9 @@ function fmtAgo(ts: number): string {
99
86
  }
100
87
 
101
88
  function latColor(ms: number): string {
102
- if (ms >= LATENCY_BAD_MS) return C.latBad;
103
- if (ms >= LATENCY_WARN_MS) return C.latWarn;
104
- return C.latGood;
89
+ if (ms >= LATENCY_BAD_MS) return C.bad;
90
+ if (ms >= LATENCY_WARN_MS) return C.warn;
91
+ return C.good;
105
92
  }
106
93
 
107
94
  function statusCodeFromError(msg: string): number {
@@ -326,10 +313,21 @@ export class DebugPanel extends BasePanel {
326
313
  }
327
314
  }
328
315
 
316
+ const latestError = this._errors[this._errors.length - 1];
317
+ const footerLines = latestError
318
+ ? [buildStyledPanelLine(width, [
319
+ { text: ' ✕ latest error ', fg: C.bad },
320
+ { text: truncateDisplay(latestError.errorMessage ?? 'unknown error', Math.max(0, width - 18)), fg: C.dim },
321
+ ])]
322
+ : [buildStyledPanelLine(width, [
323
+ { text: ' Live feed — newest calls at the bottom; auto-follows.', fg: C.dim },
324
+ ])];
325
+
329
326
  return buildPanelWorkspace(width, height, {
330
327
  title: ' API Debug',
331
328
  intro: 'Recent provider calls, token deltas, latency, status codes, and error history.',
332
329
  sections,
330
+ footerLines,
333
331
  palette: DEFAULT_PANEL_PALETTE,
334
332
  });
335
333
  }
@@ -341,16 +339,42 @@ export class DebugPanel extends BasePanel {
341
339
  private _renderSummary(width: number): Line[] {
342
340
  const errCount = this._totalErrors;
343
341
  const okCount = this._totalCalls - this._totalErrors;
344
- return [
342
+ const last = this._calls[this._calls.length - 1];
343
+ const recent = this._calls.slice(-10);
344
+ const avgLat = recent.length > 0
345
+ ? Math.round(recent.reduce((s, c) => s + c.latencyMs, 0) / recent.length)
346
+ : 0;
347
+ const sessionTokens = this._calls.reduce((s, c) => s + c.inputTokens + c.outputTokens, 0);
348
+
349
+ const lines: Line[] = [
345
350
  buildStyledPanelLine(width, [
346
- { text: ' Calls: ', fg: C.label },
351
+ { text: ' Calls ', fg: C.label },
347
352
  { text: String(this._totalCalls), fg: C.value },
348
- { text: ' OK: ', fg: C.label },
349
- { text: String(okCount), fg: C.ok },
350
- { text: ' Errors: ', fg: C.label },
351
- { text: String(errCount), fg: errCount > 0 ? C.error : C.dim },
353
+ { text: ' OK ', fg: C.label },
354
+ { text: String(okCount), fg: C.good },
355
+ { text: ' Errors ', fg: C.label },
356
+ { text: String(errCount), fg: errCount > 0 ? C.bad : C.dim },
357
+ { text: ' Avg latency ', fg: C.label },
358
+ { text: fmtMs(avgLat), fg: avgLat > 0 ? latColor(avgLat) : C.dim },
359
+ { text: ' Tokens ', fg: C.label },
360
+ { text: fmtTok(sessionTokens), fg: C.value },
352
361
  ]),
353
362
  ];
363
+ // Live status: most recent call (latency / age) or wiring hint.
364
+ if (last) {
365
+ lines.push(buildStyledPanelLine(width, [
366
+ { text: ' Last ', fg: C.label },
367
+ { text: last.status === 'ok' ? '✓ ' : '✕ ', fg: last.status === 'ok' ? C.good : C.bad },
368
+ { text: fitDisplay(last.model, 22), fg: C.value },
369
+ { text: ' ' + fmtMs(last.latencyMs), fg: latColor(last.latencyMs) },
370
+ { text: ' ' + fmtAgo(last.ts), fg: C.dim },
371
+ ]));
372
+ } else if (!this._orchestrator) {
373
+ lines.push(buildStyledPanelLine(width, [
374
+ { text: ' Per-call token deltas need the orchestrator wired (wireOrchestrator).', fg: C.dim },
375
+ ]));
376
+ }
377
+ return lines;
354
378
  }
355
379
 
356
380
  private _renderCallLog(width: number): Line[] {
@@ -366,15 +390,15 @@ export class DebugPanel extends BasePanel {
366
390
  private _callLogHeader(width: number): Line {
367
391
  // Layout: time(8) status(2) provider(12) model(20) in(8) out(8) lat(8)
368
392
  const header = ' Time S Provider Model In Out Lat';
369
- return this._textLine(header.slice(0, width), C.colHdr, width, { dim: true });
393
+ return this._textLine(truncateDisplay(header, width), C.colHdr, width, { dim: true });
370
394
  }
371
395
 
372
396
  private _callLogRow(e: ApiCallEntry, width: number): Line {
373
397
  const timeStr = fmtAgo(e.ts).padEnd(8);
374
398
  const statusChar = e.status === 'ok' ? '✓' : '✕';
375
- const statusFg = e.status === 'ok' ? C.ok : C.error;
376
- const provStr = e.provider.slice(0, 11).padEnd(12);
377
- const modelStr = e.model.slice(0, 19).padEnd(20);
399
+ const statusFg = e.status === 'ok' ? C.good : C.bad;
400
+ const provStr = fitDisplay(e.provider, 12);
401
+ const modelStr = fitDisplay(e.model, 20);
378
402
  const inStr = fmtTok(e.inputTokens).padStart(8);
379
403
  const outStr = fmtTok(e.outputTokens).padStart(8);
380
404
  const latStr = fmtMs(e.latencyMs).padStart(8);
@@ -391,7 +415,7 @@ export class DebugPanel extends BasePanel {
391
415
 
392
416
  // Append status code for errors
393
417
  if (e.status === 'error' && e.statusCode > 0) {
394
- segments.push({ text: ` [${e.statusCode}]`, fg: C.error });
418
+ segments.push({ text: ` [${e.statusCode}]`, fg: C.bad });
395
419
  }
396
420
 
397
421
  return buildStyledPanelLine(
@@ -412,9 +436,9 @@ export class DebugPanel extends BasePanel {
412
436
  private _errorRow(e: ApiCallEntry, width: number): Line {
413
437
  const timeStr = fmtAgo(e.ts).padEnd(8);
414
438
  const codeStr = e.statusCode > 0 ? `[${e.statusCode}] ` : '';
415
- const msgStr = (e.errorMessage ?? 'unknown error').slice(0, width - 12 - codeStr.length);
439
+ const msgStr = truncateDisplay(e.errorMessage ?? 'unknown error', Math.max(0, width - 12 - codeStr.length));
416
440
  const full = ` ${timeStr} ${codeStr}${msgStr}`;
417
- return this._textLine(full.slice(0, width), C.error, width);
441
+ return this._textLine(truncateDisplay(full, width), C.bad, width);
418
442
  }
419
443
 
420
444
  // -------------------------------------------------------------------------
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { Line } from '../types/grid.ts';
6
6
  import { createStyledCell, createEmptyLine } from '../types/grid.ts';
7
+ import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
7
8
  import { BasePanel } from './base-panel.ts';
8
9
  import {
9
10
  buildBodyText,
@@ -21,14 +22,18 @@ import {
21
22
 
22
23
  const COLOR = {
23
24
  addition: '#00ff88',
25
+ additionBg: '#001a0d',
24
26
  deletion: '#ff4444',
27
+ deletionBg: '#1a0000',
25
28
  hunk: '#88aaff',
29
+ hunkBg: '#0a0a1a',
26
30
  header: '#aaaaaa',
27
31
  lineNum: '#555555',
28
32
  lineNumAdd: '#00aa55',
29
33
  lineNumDel: '#aa2222',
30
34
  filename: '#ffffff',
31
35
  tabActive: '#ffffff',
36
+ tabActiveBg: '#333333',
32
37
  tabInactive: '#666666',
33
38
  tabBg: '#222222',
34
39
  context: '#888888',
@@ -47,6 +52,17 @@ interface DiffEntry {
47
52
  semanticSummary?: string;
48
53
  }
49
54
 
55
+ /** +added / -removed line counts for a parsed diff entry. */
56
+ function diffStat(entry: DiffEntry): { added: number; removed: number } {
57
+ let added = 0;
58
+ let removed = 0;
59
+ for (const line of entry.lines) {
60
+ if (line.kind === 'addition') added++;
61
+ else if (line.kind === 'deletion') removed++;
62
+ }
63
+ return { added, removed };
64
+ }
65
+
50
66
  type LineKind = 'addition' | 'deletion' | 'context' | 'hunk' | 'header';
51
67
 
52
68
  interface ParsedLine {
@@ -147,9 +163,7 @@ function makeLine(
147
163
  const LEFT_W = 5;
148
164
  const usedForNums = LEFT_W + 1 + LEFT_W + 1 + 2; // 14
149
165
  const contentWidth = Math.max(0, width - usedForNums);
150
- const truncated = content.length > contentWidth
151
- ? content.slice(0, contentWidth)
152
- : content;
166
+ const truncated = truncateDisplay(content, contentWidth);
153
167
  return buildStyledPanelLine(width, [
154
168
  { text: leftNum.padStart(LEFT_W), fg: numFg, bg, dim: true },
155
169
  { text: ' ', fg: '', bg },
@@ -161,8 +175,7 @@ function makeLine(
161
175
  }
162
176
 
163
177
  function renderText(width: number, text: string, fg: string, bg: string, bold = false): Line {
164
- const truncated = text.length > width ? text.slice(0, width) : text;
165
- return buildStyledPanelLine(width, [{ text: truncated, fg, bg, bold }]);
178
+ return buildStyledPanelLine(width, [{ text: truncateDisplay(text, width), fg, bg, bold }]);
166
179
  }
167
180
 
168
181
  // ---------------------------------------------------------------------------
@@ -448,44 +461,60 @@ export class DiffPanel extends BasePanel {
448
461
 
449
462
  private renderTabBar(width: number): Line {
450
463
  const cells: Line = [];
464
+ const push = (ch: string, fg: string, bg: string, bold = false): void => {
465
+ const cw = getDisplayWidth(ch);
466
+ if (cells.length + cw > width) return;
467
+ cells.push(createStyledCell(ch, { fg, bg, bold }));
468
+ if (cw === 2 && cells.length < width) cells.push(createStyledCell('', { fg, bg }));
469
+ };
451
470
 
452
471
  for (let i = 0; i < this.entries.length; i++) {
453
472
  const entry = this.entries[i]!;
454
473
  const active = i === this.selectedFile;
455
- const label = ` ${basename(entry.filePath)} `;
474
+ const stat = diffStat(entry);
456
475
  const fg = active ? COLOR.tabActive : COLOR.tabInactive;
457
- const bg = active ? '#333333' : COLOR.tabBg;
458
-
459
- for (const ch of label) {
460
- if (cells.length >= width) break;
461
- cells.push(createStyledCell(ch, { fg, bg, bold: active }));
462
- }
463
-
464
- if (cells.length < width) {
465
- cells.push(createStyledCell('', { fg: COLOR.lineNum, bg: COLOR.tabBg }));
466
- }
476
+ const bg = active ? COLOR.tabActiveBg : COLOR.tabBg;
477
+ // Active file gets a leading marker; every tab shows +adds/-dels at a glance.
478
+ const marker = active ? '▸ ' : ' ';
479
+ const label = `${marker}${basename(entry.filePath)} `;
480
+ for (const ch of label) push(ch, fg, bg, active);
481
+ if (stat.added > 0) for (const ch of `+${stat.added}`) push(ch, COLOR.addition, bg, active);
482
+ if (stat.added > 0 && stat.removed > 0) push(' ', fg, bg);
483
+ if (stat.removed > 0) for (const ch of `-${stat.removed}`) push(ch, COLOR.deletion, bg, active);
484
+ push(' ', fg, bg);
485
+ push('│', COLOR.lineNum, COLOR.tabBg);
467
486
  }
468
487
 
469
- // Fill remaining
470
488
  while (cells.length < width) {
471
489
  cells.push(createStyledCell(' ', { fg: '', bg: COLOR.tabBg }));
472
490
  }
473
-
474
491
  return cells.slice(0, width);
475
492
  }
476
493
 
477
494
  // ── Status bar ───────────────────────────────────────────────────────────
478
495
 
479
496
  private renderStatusBar(width: number, entry: DiffEntry | null): Line {
480
- const fileInfo = entry
481
- ? `${entry.filePath} [${this.selectedFile + 1}/${this.entries.length}]`
482
- : 'No file';
483
- const scroll = entry
484
- ? ` L${this.scrollOffset + 1}/${entry.lines.length} Tab: next file Up/Down: scroll`
485
- : '';
486
- const semantic = entry?.semanticSummary ? ` * ${entry.semanticSummary}` : '';
487
- const text = ` ${fileInfo}${scroll}${semantic}`;
488
- return renderText(width, text, COLOR.tabActive, COLOR.statusBar);
497
+ if (!entry) {
498
+ return buildStyledPanelLine(width, [{ text: ' No file', fg: COLOR.tabInactive, bg: COLOR.statusBar }], { fillBg: COLOR.statusBar });
499
+ }
500
+ const stat = diffStat(entry);
501
+ // Keep the file path display-width-aware so a long/wide path can't overflow.
502
+ const pathBudget = Math.max(8, Math.floor(width / 2));
503
+ const fileInfo = truncateDisplay(entry.filePath, pathBudget);
504
+ const segments: Array<{ text: string; fg: string; bg?: string; bold?: boolean }> = [
505
+ { text: ` ${fileInfo} `, fg: COLOR.filename, bg: COLOR.statusBar, bold: true },
506
+ { text: `[${this.selectedFile + 1}/${this.entries.length}]`, fg: COLOR.tabInactive, bg: COLOR.statusBar },
507
+ { text: ' +', fg: COLOR.tabInactive, bg: COLOR.statusBar },
508
+ { text: String(stat.added), fg: COLOR.addition, bg: COLOR.statusBar },
509
+ { text: ' -', fg: COLOR.tabInactive, bg: COLOR.statusBar },
510
+ { text: String(stat.removed), fg: COLOR.deletion, bg: COLOR.statusBar },
511
+ { text: ` L${this.scrollOffset + 1}/${entry.lines.length}`, fg: COLOR.tabInactive, bg: COLOR.statusBar },
512
+ { text: ' Tab next ↑/↓ scroll', fg: COLOR.context, bg: COLOR.statusBar },
513
+ ];
514
+ if (entry.semanticSummary) {
515
+ segments.push({ text: ` ◈ ${entry.semanticSummary}`, fg: COLOR.hunk, bg: COLOR.statusBar });
516
+ }
517
+ return buildStyledPanelLine(width, segments, { fillBg: COLOR.statusBar });
489
518
  }
490
519
 
491
520
  // ── Parsed line ──────────────────────────────────────────────────────────
@@ -496,11 +525,11 @@ export class DiffPanel extends BasePanel {
496
525
 
497
526
  switch (pl.kind) {
498
527
  case 'addition':
499
- return makeLine(width, left, right, `+ ${pl.text}`, COLOR.addition, '#001a0d', COLOR.lineNumAdd, true);
528
+ return makeLine(width, left, right, `+ ${pl.text}`, COLOR.addition, COLOR.additionBg, COLOR.lineNumAdd, true);
500
529
  case 'deletion':
501
- return makeLine(width, left, right, `- ${pl.text}`, COLOR.deletion, '#1a0000', COLOR.lineNumDel, false);
530
+ return makeLine(width, left, right, `- ${pl.text}`, COLOR.deletion, COLOR.deletionBg, COLOR.lineNumDel, false);
502
531
  case 'hunk':
503
- return renderText(width, pl.text, COLOR.hunk, '#0a0a1a', false);
532
+ return renderText(width, pl.text, COLOR.hunk, COLOR.hunkBg, false);
504
533
  case 'header':
505
534
  return renderText(width, pl.text, COLOR.header, '', false);
506
535
  case 'context':