@pellux/goodvibes-tui 0.27.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 (101) hide show
  1. package/CHANGELOG.md +23 -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/eval.ts +4 -3
  9. package/src/input/handler-feed-routes.ts +61 -4
  10. package/src/input/handler-feed.ts +13 -0
  11. package/src/input/handler-picker-routes.ts +18 -13
  12. package/src/input/handler-ui-state.ts +4 -0
  13. package/src/input/keybindings.ts +5 -0
  14. package/src/input/model-picker.ts +5 -0
  15. package/src/input/panel-integration-actions.ts +10 -0
  16. package/src/main.ts +0 -1
  17. package/src/panels/agent-inspector-panel.ts +18 -13
  18. package/src/panels/agent-inspector-shared.ts +3 -3
  19. package/src/panels/agent-logs-panel.ts +22 -53
  20. package/src/panels/approval-panel.ts +146 -95
  21. package/src/panels/automation-control-panel.ts +24 -1
  22. package/src/panels/base-panel.ts +26 -2
  23. package/src/panels/builtin/development.ts +1 -1
  24. package/src/panels/cockpit-panel.ts +24 -5
  25. package/src/panels/cockpit-read-model.ts +2 -1
  26. package/src/panels/communication-panel.ts +108 -43
  27. package/src/panels/context-visualizer-panel.ts +25 -1
  28. package/src/panels/control-plane-panel.ts +27 -1
  29. package/src/panels/cost-tracker-panel.ts +82 -54
  30. package/src/panels/debug-panel.ts +51 -16
  31. package/src/panels/diff-panel.ts +59 -30
  32. package/src/panels/docs-panel.ts +19 -3
  33. package/src/panels/eval-panel.ts +130 -81
  34. package/src/panels/expandable-list-panel.ts +189 -0
  35. package/src/panels/file-explorer-panel.ts +40 -41
  36. package/src/panels/file-preview-panel.ts +9 -2
  37. package/src/panels/forensics-panel.ts +27 -13
  38. package/src/panels/git-panel.ts +58 -70
  39. package/src/panels/hooks-panel.ts +36 -2
  40. package/src/panels/incident-review-panel.ts +31 -10
  41. package/src/panels/intelligence-panel.ts +152 -71
  42. package/src/panels/knowledge-graph-panel.ts +89 -27
  43. package/src/panels/local-auth-panel.ts +17 -11
  44. package/src/panels/marketplace-panel.ts +31 -12
  45. package/src/panels/memory-panel.ts +5 -4
  46. package/src/panels/ops-control-panel.ts +69 -7
  47. package/src/panels/ops-strategy-panel.ts +61 -43
  48. package/src/panels/orchestration-panel.ts +34 -4
  49. package/src/panels/panel-list-panel.ts +33 -8
  50. package/src/panels/panel-manager.ts +23 -4
  51. package/src/panels/plan-dashboard-panel.ts +183 -66
  52. package/src/panels/plugins-panel.ts +48 -10
  53. package/src/panels/policy-panel.ts +60 -23
  54. package/src/panels/polish-core.ts +157 -0
  55. package/src/panels/polish-tables.ts +258 -0
  56. package/src/panels/polish.ts +44 -145
  57. package/src/panels/project-planning-panel.ts +22 -0
  58. package/src/panels/provider-accounts-panel.ts +55 -18
  59. package/src/panels/provider-health-panel.ts +65 -23
  60. package/src/panels/provider-stats-panel.ts +47 -22
  61. package/src/panels/qr-panel.ts +23 -11
  62. package/src/panels/remote-panel.ts +12 -5
  63. package/src/panels/routes-panel.ts +27 -5
  64. package/src/panels/sandbox-panel.ts +62 -27
  65. package/src/panels/schedule-panel.ts +40 -17
  66. package/src/panels/scrollable-list-panel.ts +124 -10
  67. package/src/panels/security-panel.ts +72 -20
  68. package/src/panels/services-panel.ts +68 -22
  69. package/src/panels/session-browser-panel.ts +29 -5
  70. package/src/panels/session-maintenance.ts +2 -2
  71. package/src/panels/settings-sync-panel.ts +30 -15
  72. package/src/panels/subscription-panel.ts +21 -3
  73. package/src/panels/symbol-outline-panel.ts +40 -47
  74. package/src/panels/system-messages-panel.ts +60 -27
  75. package/src/panels/tasks-panel.ts +34 -7
  76. package/src/panels/thinking-panel.ts +26 -25
  77. package/src/panels/token-budget-panel.ts +49 -38
  78. package/src/panels/tool-inspector-panel.ts +27 -21
  79. package/src/panels/types.ts +25 -0
  80. package/src/panels/watchers-panel.ts +25 -5
  81. package/src/panels/work-plan-panel.ts +125 -34
  82. package/src/panels/worktree-panel.ts +65 -20
  83. package/src/panels/wrfc-panel-format.ts +133 -0
  84. package/src/panels/wrfc-panel.ts +31 -127
  85. package/src/renderer/compaction-preview.ts +2 -1
  86. package/src/renderer/compositor.ts +46 -43
  87. package/src/renderer/model-picker-overlay.ts +9 -4
  88. package/src/renderer/model-workspace.ts +2 -3
  89. package/src/renderer/panel-composite.ts +7 -20
  90. package/src/renderer/panel-workspace-bar.ts +14 -8
  91. package/src/renderer/progress.ts +3 -2
  92. package/src/renderer/tab-strip.ts +148 -34
  93. package/src/renderer/ui-factory.ts +4 -6
  94. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  95. package/src/runtime/ui/model-picker/types.ts +4 -0
  96. package/src/utils/format-duration.ts +55 -0
  97. package/src/utils/format-number.ts +71 -0
  98. package/src/version.ts +1 -1
  99. package/src/panels/panel-picker.ts +0 -106
  100. package/src/renderer/panel-picker-overlay.ts +0 -202
  101. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -4,13 +4,17 @@ import type { UiCommunicationSnapshot, UiReadModel } from '../runtime/ui-read-mo
4
4
  import { truncateDisplay } from '../utils/terminal-width.ts';
5
5
  import {
6
6
  buildBodyText,
7
+ buildDetailBlock,
7
8
  buildEmptyState,
8
9
  buildGuidanceLine,
9
10
  buildKeyValueLine,
11
+ buildKeyboardHints,
10
12
  buildPanelLine,
11
13
  buildPanelWorkspace,
14
+ buildStatusBadge,
12
15
  DEFAULT_PANEL_PALETTE,
13
16
  type PanelPalette,
17
+ type StatusBadgeKind,
14
18
  } from './polish.ts';
15
19
  import { createEmptyLine } from '../types/grid.ts';
16
20
 
@@ -26,13 +30,28 @@ const C = {
26
30
 
27
31
  type CommunicationRecord = UiCommunicationSnapshot['records'][number];
28
32
 
33
+ function statusBadgeKind(status: CommunicationRecord['status']): StatusBadgeKind {
34
+ return status === 'blocked' ? 'blocked' : status === 'delivered' ? 'completed' : 'running';
35
+ }
36
+
37
+ function fmtAgo(ts: number): string {
38
+ const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
39
+ if (sec < 60) return `${sec}s`;
40
+ if (sec < 3600) return `${Math.floor(sec / 60)}m`;
41
+ return `${Math.floor(sec / 3600)}h`;
42
+ }
43
+
29
44
  export class CommunicationPanel extends ScrollableListPanel<CommunicationRecord> {
30
45
  private readonly readModel?: UiReadModel<UiCommunicationSnapshot>;
31
46
  private readonly unsub: (() => void) | null;
47
+ /** When true, the list is narrowed to blocked messages only (b toggles). */
48
+ private blockedOnly = false;
32
49
 
33
50
  public constructor(readModel?: UiReadModel<UiCommunicationSnapshot>) {
34
51
  super('communication', 'Communication', 'Y', 'monitoring');
35
52
  this.showSelectionGutter = true; // I5: non-color selection affordance
53
+ this.filterEnabled = true;
54
+ this.filterLabel = 'Filter messages';
36
55
  this.readModel = readModel;
37
56
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
38
57
  }
@@ -47,18 +66,47 @@ export class CommunicationPanel extends ScrollableListPanel<CommunicationRecord>
47
66
 
48
67
  protected getItems(): readonly CommunicationRecord[] {
49
68
  if (!this.readModel) return [];
50
- return this.readModel.getSnapshot().records;
69
+ const records = this.readModel.getSnapshot().records;
70
+ return this.blockedOnly ? records.filter((r) => r.status === 'blocked') : records;
51
71
  }
52
72
 
53
- protected renderItem(record: CommunicationRecord, index: number, selected: boolean, width: number): Line {
73
+ protected override filterMatches(record: CommunicationRecord, q: string): boolean {
74
+ return (record.content ?? '').toLowerCase().includes(q)
75
+ || record.kind.toLowerCase().includes(q)
76
+ || String(record.fromId ?? '').toLowerCase().includes(q)
77
+ || String(record.toId ?? '').toLowerCase().includes(q);
78
+ }
79
+
80
+ public override handleInput(key: string): boolean {
81
+ // `b` toggles the blocked-lanes-only view — guarded so it only acts when not
82
+ // typing into the filter input.
83
+ if (key === 'b' && !this.filterActive) {
84
+ this.blockedOnly = !this.blockedOnly;
85
+ this.selectedIndex = 0;
86
+ this.markDirty();
87
+ return true;
88
+ }
89
+ return super.handleInput(key);
90
+ }
91
+
92
+ protected renderItem(record: CommunicationRecord, _index: number, selected: boolean, width: number): Line {
54
93
  const bg = selected ? C.selectBg : undefined;
55
- const color = record.status === 'blocked' ? C.error : record.status === 'delivered' ? C.ok : C.info;
94
+ const badge = buildStatusBadge(statusBadgeKind(record.status), record.status)[0]!;
95
+ const route = truncateDisplay(`${record.fromId} → ${record.toId}`, 26);
96
+ const contentWidth = Math.max(0, width - 50);
97
+ // Blocked messages have no delivered content; surface the policy reason
98
+ // inline so the most important detail (why a lane is blocked) is visible
99
+ // without selecting the row.
100
+ const tail = record.status === 'blocked' && record.reason
101
+ ? { text: record.reason, fg: C.warn }
102
+ : { text: record.content, fg: C.dim };
56
103
  return buildPanelLine(width, [
57
104
  [' ', C.label, bg],
58
- [record.status.padEnd(10), color, bg],
59
- [` ${record.kind.padEnd(10)}`, C.info, bg],
60
- [` ${truncateDisplay(`${record.fromId} -> ${record.toId}`, 28).padEnd(28)}`, C.value, bg],
61
- [` ${truncateDisplay(record.content, Math.max(0, width - 53))}`, C.dim, bg],
105
+ [`${fmtAgo(record.timestamp)} `.padStart(5), C.dim, bg],
106
+ [` ${badge.text} `.padEnd(12), badge.fg, bg],
107
+ [`${record.kind} `.padEnd(9), C.info, bg],
108
+ [`${route} `.padEnd(27), C.value, bg],
109
+ [truncateDisplay(tail.text, contentWidth), tail.fg, bg],
62
110
  ]);
63
111
  }
64
112
 
@@ -68,7 +116,7 @@ export class CommunicationPanel extends ScrollableListPanel<CommunicationRecord>
68
116
 
69
117
  protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
70
118
  return [
71
- { command: '/orchestration', summary: 'review graphs and recursive agent activity' },
119
+ { command: '/orchestration', summary: 'review graphs and recursive agent activity that emit messages' },
72
120
  { command: '/communication', summary: 'reopen this workspace once the runtime emits message traffic' },
73
121
  ];
74
122
  }
@@ -98,56 +146,73 @@ export class CommunicationPanel extends ScrollableListPanel<CommunicationRecord>
98
146
  const snapshot = this.readModel.getSnapshot();
99
147
  const records = this.getItems();
100
148
 
101
- const postureLines: Line[] = [
102
- buildPanelLine(width, [[' Communication posture', C.label]]),
103
- buildKeyValueLine(width, [
104
- { label: 'sent', value: String(snapshot.totalSent), valueColor: snapshot.totalSent > 0 ? C.info : C.dim },
105
- { label: 'delivered', value: String(snapshot.totalDelivered), valueColor: snapshot.totalDelivered > 0 ? C.ok : C.dim },
106
- { label: 'blocked', value: String(snapshot.totalBlocked), valueColor: snapshot.totalBlocked > 0 ? C.error : C.dim },
107
- ], C),
108
- buildGuidanceLine(width, '/orchestration', 'inspect recursive routing, message handoff, and blocked broadcast posture', C),
149
+ // Posture: blocked lanes / message flow surfaced first per the panel's purpose.
150
+ const postureLines = (): Line[] => [
151
+ buildPanelLine(width, [
152
+ [' ', C.label],
153
+ ...buildStatusBadge('blocked', 'blocked', { count: snapshot.totalBlocked }),
154
+ [' ', C.dim],
155
+ ...buildStatusBadge('completed', 'delivered', { count: snapshot.totalDelivered }),
156
+ [' ', C.dim],
157
+ ...buildStatusBadge('running', 'sent', { count: snapshot.totalSent }),
158
+ ...(this.blockedOnly ? ([[' (blocked-only view)', C.warn]] as Array<[string, string]>) : []),
159
+ ]),
160
+ snapshot.totalBlocked > 0
161
+ ? buildPanelLine(width, [[` ${snapshot.totalBlocked} message${snapshot.totalBlocked !== 1 ? 's' : ''} blocked by routing policy — press b to isolate blocked lanes.`, C.warn]])
162
+ : buildGuidanceLine(width, '/orchestration', 'inspect recursive routing, message handoff, and broadcast posture', C),
109
163
  ];
110
164
 
111
165
  if (records.length === 0) {
112
166
  return this.renderList(width, height, {
113
167
  title: 'Communication Control Room',
114
- header: postureLines,
168
+ header: postureLines(),
169
+ footer: [buildKeyboardHints(width, this.footerHints(false), C)],
115
170
  });
116
171
  }
117
172
 
118
173
  this.clampSelection();
119
- const selected = records[this.selectedIndex];
120
-
121
- // Update posture with selected info
122
- const postureWithSelected: Line[] = [
123
- buildPanelLine(width, [[' Communication posture', C.label]]),
124
- buildKeyValueLine(width, [
125
- { label: 'sent', value: String(snapshot.totalSent), valueColor: snapshot.totalSent > 0 ? C.info : C.dim },
126
- { label: 'delivered', value: String(snapshot.totalDelivered), valueColor: snapshot.totalDelivered > 0 ? C.ok : C.dim },
127
- { label: 'blocked', value: String(snapshot.totalBlocked), valueColor: snapshot.totalBlocked > 0 ? C.error : C.dim },
128
- { label: 'selected', value: `${selected?.fromId ?? 'n/a'} -> ${selected?.toId ?? 'n/a'}`, valueColor: C.value },
129
- ], C),
130
- buildGuidanceLine(width, '/orchestration', 'inspect recursive routing, message handoff, and blocked broadcast posture', C),
131
- ];
174
+ const visible = this.getVisibleItems();
175
+ const selected = visible[this.selectedIndex];
132
176
 
133
- const footerLines: Line[] = [];
177
+ const detailLines: Line[] = [];
134
178
  if (selected) {
135
- footerLines.push(
136
- buildPanelLine(width, [[' Route: ', C.label], [`${selected.scope} / ${selected.kind}`, C.value], [' Status: ', C.label], [selected.status, selected.status === 'blocked' ? C.error : C.ok]]),
137
- buildPanelLine(width, [[' From: ', C.label], [selected.fromId, C.value], [' To: ', C.label], [selected.toId, C.value]]),
138
- buildPanelLine(width, [[' Roles: ', C.label], [`${selected.fromRole ?? 'unknown'} -> ${selected.toRole ?? 'unknown'}`, C.dim]]),
139
- );
140
- if (selected.reason) {
141
- footerLines.push(buildPanelLine(width, [[' Reason: ', C.label], [truncateDisplay(selected.reason, Math.max(0, width - 11)), C.warn]]));
142
- }
143
- footerLines.push(...buildBodyText(width, ` Content: ${selected.content}`, C));
179
+ detailLines.push(...buildDetailBlock(width, `Message · ${selected.kind}`, [
180
+ buildKeyValueLine(width, [
181
+ { label: 'status', value: selected.status, valueColor: selected.status === 'blocked' ? C.error : C.ok },
182
+ { label: 'scope', value: selected.scope, valueColor: C.value },
183
+ { label: 'when', value: `${fmtAgo(selected.timestamp)} ago`, valueColor: C.dim },
184
+ ], C),
185
+ buildKeyValueLine(width, [
186
+ { label: 'from', value: `${selected.fromId} (${selected.fromRole ?? 'unknown'})`, valueColor: C.value },
187
+ { label: 'to', value: `${selected.toId} (${selected.toRole ?? 'unknown'})`, valueColor: C.value },
188
+ ], C),
189
+ ...(selected.reason
190
+ ? buildBodyText(width, `blocked: ${selected.reason}`, C, C.warn)
191
+ : []),
192
+ ...buildBodyText(width, selected.content, C, C.value),
193
+ ], C));
144
194
  }
145
- footerLines.push(buildPanelLine(width, [[' Up/Down move through messages', C.dim]]));
146
195
 
147
196
  return this.renderList(width, height, {
148
197
  title: 'Communication Control Room',
149
- header: postureWithSelected,
150
- footer: footerLines,
198
+ header: postureLines(),
199
+ footer: [...detailLines, buildKeyboardHints(width, this.footerHints(true), C)],
151
200
  });
152
201
  }
202
+
203
+ // Context-aware hints: filter keys reflect filter state, `b` reflects the
204
+ // blocked-only toggle, and the inspect hint only appears when rows exist.
205
+ private footerHints(hasRows: boolean): Array<{ keys: string; label: string }> {
206
+ const hints: Array<{ keys: string; label: string }> = [];
207
+ if (hasRows) hints.push({ keys: '↑/↓', label: 'select' });
208
+ hints.push({ keys: 'b', label: this.blockedOnly ? 'show all lanes' : 'blocked only' });
209
+ if (this.filterActive) {
210
+ hints.push({ keys: 'Esc', label: 'clear filter' });
211
+ } else if (this.filterQuery) {
212
+ hints.push({ keys: '/', label: 'edit filter' }, { keys: 'Esc', label: 'clear filter' });
213
+ } else {
214
+ hints.push({ keys: '/', label: 'filter' });
215
+ }
216
+ return hints;
217
+ }
153
218
  }
@@ -13,8 +13,10 @@ import type { SessionMemoryQuery } from '../runtime/ui-service-queries.ts';
13
13
  import {
14
14
  buildEmptyState,
15
15
  buildGuidanceLine,
16
+ buildKeyboardHints,
16
17
  buildMeterLine,
17
18
  buildPanelLine,
19
+ buildStatusPill,
18
20
  buildStyledPanelLine,
19
21
  buildPanelWorkspace,
20
22
  DEFAULT_PANEL_PALETTE,
@@ -90,7 +92,10 @@ export class ContextVisualizerPanel extends BasePanel {
90
92
  width,
91
93
  ' Context limit unavailable',
92
94
  'Select a model with a known context window and submit or complete a turn to populate live context usage.',
93
- [],
95
+ [
96
+ { command: '/model', summary: 'pick an active model so its context window is known' },
97
+ { command: '/context', summary: 'review current context composition and pressure' },
98
+ ],
94
99
  DEFAULT_PANEL_PALETTE,
95
100
  ),
96
101
  },
@@ -99,6 +104,11 @@ export class ContextVisualizerPanel extends BasePanel {
99
104
  });
100
105
  }
101
106
 
107
+ // Pressure state drives the headline pill and the footer hint so the most
108
+ // important signal — am I about to overflow context — is obvious at a glance.
109
+ const pressureState = overLimit ? 'bad' : pct >= 90 ? 'bad' : pct >= 75 ? 'warn' : 'good';
110
+ const pressureLabel = overLimit ? 'over limit' : pct >= 90 ? 'critical' : pct >= 75 ? 'elevated' : 'healthy';
111
+
102
112
  return buildPanelWorkspace(width, height, {
103
113
  title: ' Context Usage',
104
114
  intro: 'Visualize current input-token pressure against the active model context window.',
@@ -113,6 +123,8 @@ export class ContextVisualizerPanel extends BasePanel {
113
123
  [formatK(limit), DEFAULT_PANEL_PALETTE.info],
114
124
  [' Fill ', DEFAULT_PANEL_PALETTE.label],
115
125
  [`${pct}%`, overLimit ? DEFAULT_PANEL_PALETTE.bad : DEFAULT_PANEL_PALETTE.good],
126
+ [' ', DEFAULT_PANEL_PALETTE.dim],
127
+ ...buildStatusPill(pressureState, pressureLabel),
116
128
  ]),
117
129
  ],
118
130
  },
@@ -129,6 +141,18 @@ export class ContextVisualizerPanel extends BasePanel {
129
141
  lines: this._renderMaintenance(width),
130
142
  },
131
143
  ],
144
+ footerLines: [
145
+ buildKeyboardHints(width, pressureState === 'good'
146
+ ? [
147
+ { keys: '/context', label: 'composition' },
148
+ { keys: '/model', label: 'switch model' },
149
+ ]
150
+ : [
151
+ { keys: '/compact', label: 'reduce context now' },
152
+ { keys: '/context', label: 'composition' },
153
+ { keys: '/model', label: 'larger window' },
154
+ ], DEFAULT_PANEL_PALETTE),
155
+ ],
132
156
  palette: DEFAULT_PANEL_PALETTE,
133
157
  });
134
158
  });
@@ -7,8 +7,10 @@ import {
7
7
  buildEmptyState,
8
8
  buildGuidanceLine,
9
9
  buildKeyValueLine,
10
+ buildKeyboardHints,
10
11
  buildPanelLine,
11
12
  buildPanelWorkspace,
13
+ buildSectionHeader,
12
14
  DEFAULT_PANEL_PALETTE,
13
15
  type PanelPalette,
14
16
  } from './polish.ts';
@@ -44,6 +46,8 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
44
46
  public constructor(private readonly readModel?: UiReadModel<UiControlPlaneSnapshot>) {
45
47
  super('control-plane', 'Control Plane', 'C', 'monitoring');
46
48
  this.showSelectionGutter = true; // I5: non-color selection affordance
49
+ this.filterEnabled = true;
50
+ this.filterLabel = 'Filter clients';
47
51
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
48
52
  }
49
53
 
@@ -55,6 +59,12 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
55
59
  return C;
56
60
  }
57
61
 
62
+ protected override filterMatches(client: ControlPlaneClient, q: string): boolean {
63
+ return client.kind.toLowerCase().includes(q)
64
+ || client.label.toLowerCase().includes(q)
65
+ || client.transport.toLowerCase().includes(q);
66
+ }
67
+
58
68
  protected getItems(): readonly ControlPlaneClient[] {
59
69
  if (!this.readModel) return [];
60
70
  return this.readModel.getSnapshot().clients;
@@ -170,6 +180,8 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
170
180
  }
171
181
 
172
182
  if (approvals.length > 0) {
183
+ const pending = approvals.filter((entry) => entry.status === 'pending').length;
184
+ footerLines.push(buildSectionHeader(width, `Approvals (${pending} pending / ${approvals.length})`, C));
173
185
  footerLines.push(
174
186
  ...approvals.slice(0, 6).map((approval) => buildPanelLine(width, [
175
187
  [' ', C.label],
@@ -181,6 +193,8 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
181
193
  }
182
194
 
183
195
  if (sessions.length > 0) {
196
+ const activeSessions = sessions.filter((session) => session.status === 'active').length;
197
+ footerLines.push(buildSectionHeader(width, `Sessions (${activeSessions} active / ${sessions.length})`, C));
184
198
  footerLines.push(
185
199
  ...sessions.slice(0, 6).map((session) => buildPanelLine(width, [
186
200
  [' ', C.label],
@@ -192,6 +206,7 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
192
206
  }
193
207
 
194
208
  if (recentEvents.length > 0) {
209
+ footerLines.push(buildSectionHeader(width, `Recent Events (${recentEvents.length})`, C));
195
210
  footerLines.push(
196
211
  ...recentEvents.slice(0, 6).map((event) => buildPanelLine(width, [
197
212
  [' ', C.label],
@@ -200,7 +215,18 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
200
215
  ])),
201
216
  );
202
217
  }
203
- footerLines.push(buildPanelLine(width, [[' Up/Down move through connected clients', C.dim]]));
218
+ footerLines.push(
219
+ this.filterActive
220
+ ? buildKeyboardHints(width, [
221
+ { keys: 'type', label: 'filter clients' },
222
+ { keys: 'Enter', label: 'apply' },
223
+ { keys: 'Esc', label: 'clear' },
224
+ ], C)
225
+ : buildKeyboardHints(width, [
226
+ { keys: 'Up/Down', label: 'select client' },
227
+ { keys: '/', label: 'filter' },
228
+ ], C),
229
+ );
204
230
 
205
231
  return this.renderList(width, height, {
206
232
  title: 'Control Plane',
@@ -4,21 +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,
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';
22
27
 
23
28
  // Pricing lookups are provided by ../export/cost-utils.ts (single source of truth).
24
29
 
@@ -30,9 +35,7 @@ function formatCost(usd: number): string {
30
35
  }
31
36
 
32
37
  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);
38
+ return abbreviateCount(n);
36
39
  }
37
40
 
38
41
  // ---------------------------------------------------------------------------
@@ -144,7 +147,7 @@ export class CostTrackerPanel extends BasePanel {
144
147
  agentEvents.on('AGENT_SPAWNING', (payload) => {
145
148
  this.agents.set(payload.agentId, {
146
149
  id: payload.agentId.slice(0, 8),
147
- task: payload.task.length > 40 ? payload.task.slice(0, 37) + '...' : payload.task,
150
+ task: truncateDisplay(payload.task, 40),
148
151
  model: 'unknown',
149
152
  inputTokens: 0,
150
153
  outputTokens: 0,
@@ -166,8 +169,7 @@ export class CostTrackerPanel extends BasePanel {
166
169
  if (rec?.usage) {
167
170
  entry.inputTokens = rec.usage.inputTokens + (rec.usage.cacheReadTokens ?? 0) + (rec.usage.cacheWriteTokens ?? 0);
168
171
  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;
172
+ entry.cost = calcSessionCost(rec.usage.inputTokens, rec.usage.outputTokens, rec.usage.cacheReadTokens ?? 0, rec.usage.cacheWriteTokens ?? 0, rec.model ?? 'unknown');
171
173
  if (rec.model && rec.model !== 'unknown') entry.model = rec.model;
172
174
  }
173
175
  }
@@ -199,9 +201,7 @@ export class CostTrackerPanel extends BasePanel {
199
201
  if (usage.model) this.sessionModel = usage.model;
200
202
 
201
203
  // 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;
204
+ const totalCost = calcSessionCost(usage.input, usage.output, usage.cacheRead, usage.cacheWrite, this.sessionModel);
205
205
  const delta = Math.max(0, totalCost - this.lastSessionCost);
206
206
  this.lastSessionCost = totalCost;
207
207
  this.costHistory.push(delta);
@@ -261,9 +261,8 @@ export class CostTrackerPanel extends BasePanel {
261
261
  render(width: number, height: number): Line[] {
262
262
  if (height <= 0 || width <= 0) return [];
263
263
 
264
- const pricing = getPricing(this.sessionModel);
265
264
  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;
265
+ const sessionCost = calcSessionCost(this.sessionUsage.input, this.sessionUsage.output, this.sessionUsage.cacheRead, this.sessionUsage.cacheWrite, this.sessionModel);
267
266
  const overBudget = this.budgetThreshold > 0 && sessionCost > this.budgetThreshold;
268
267
  const sparkline = buildSparkline(this.costHistory);
269
268
  const costStr = formatCost(sessionCost);
@@ -275,6 +274,20 @@ export class CostTrackerPanel extends BasePanel {
275
274
  const sessionLines: Line[] = [
276
275
  this.renderKeyValue(width, ' Total', `${costStr}${budgetStr}${alertStr}`, costFg),
277
276
  ];
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
+ }
278
291
  if (sparkline.length > 0) sessionLines.push(this.renderLabeledLine(width, ' Trend', sparkline, C.good));
279
292
  sessionLines.push(this.renderKeyValue(width, ' Input', formatTokens(this.sessionUsage.input), C.label));
280
293
  sessionLines.push(this.renderKeyValue(width, ' Output', formatTokens(this.sessionUsage.output), C.label));
@@ -293,35 +306,63 @@ export class CostTrackerPanel extends BasePanel {
293
306
  ];
294
307
 
295
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);
296
319
  if (agentList.length > 0) {
297
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;
298
323
  const agentRows: Line[] = [
299
- this.renderKeyValue(width, ' Plan total', formatCost(planCost + sessionCost), C.cost),
300
- 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
+ ),
301
361
  ];
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
362
  const sessionSection: PanelWorkspaceSection = sections[0]!;
320
363
  const agentsSection = resolveScrollablePanelSection(width, height, {
321
364
  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
- ],
365
+ footerLines: [hintRow],
325
366
  palette: DEFAULT_PANEL_PALETTE,
326
367
  beforeSections: [sessionSection],
327
368
  section: {
@@ -329,6 +370,7 @@ export class CostTrackerPanel extends BasePanel {
329
370
  scrollableLines: agentRows,
330
371
  scrollOffset: this.scrollOffset,
331
372
  minRows: 4,
373
+ appendWindowSummary: scrollableAgents ? { dimColor: DEFAULT_PANEL_PALETTE.dim } : undefined,
332
374
  },
333
375
  });
334
376
  this.scrollOffset = agentsSection.scrollOffset;
@@ -340,7 +382,9 @@ export class CostTrackerPanel extends BasePanel {
340
382
  width,
341
383
  ' No agents spawned this session',
342
384
  'Agent-level cost estimates appear here once delegated or background agents start running.',
343
- [],
385
+ [
386
+ { command: '/cost budget <usd>', summary: 'set a session budget alert to track spend against a cap' },
387
+ ],
344
388
  DEFAULT_PANEL_PALETTE,
345
389
  ),
346
390
  });
@@ -350,9 +394,7 @@ export class CostTrackerPanel extends BasePanel {
350
394
  title: ' Cost Tracker',
351
395
  intro: 'Track per-session and per-agent token spend using model pricing and live usage snapshots.',
352
396
  sections,
353
- footerLines: [
354
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' scroll agents', DEFAULT_PANEL_PALETTE.dim]]),
355
- ],
397
+ footerLines: [hintRow],
356
398
  palette: DEFAULT_PANEL_PALETTE,
357
399
  });
358
400
  }
@@ -364,7 +406,7 @@ export class CostTrackerPanel extends BasePanel {
364
406
  private renderKeyValue(width: number, label: string, value: string, valueFg: string): Line {
365
407
  const LABEL_W = 10;
366
408
  return buildStyledPanelLine(width, [
367
- { 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 },
368
410
  { text: ': ', fg: C.dim, bg: C.bg },
369
411
  { text: value, fg: valueFg, bg: C.bg, bold: true },
370
412
  ]);
@@ -372,23 +414,9 @@ export class CostTrackerPanel extends BasePanel {
372
414
 
373
415
  private renderLabeledLine(width: number, label: string, value: string, valueFg: string): Line {
374
416
  return buildStyledPanelLine(width, [
375
- ...(label.length > 0 ? [{ text: `${label.slice(0, 10).padEnd(10)} `, fg: C.label }] : []),
417
+ ...(label.length > 0 ? [{ text: `${fitDisplay(label, 10)} `, fg: C.label }] : []),
376
418
  { text: value, fg: valueFg },
377
419
  ]);
378
420
  }
379
421
 
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 }]);
392
- }
393
-
394
422
  }