@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.
- package/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/context-usage.ts +53 -0
- package/src/core/conversation.ts +4 -6
- package/src/core/session-recovery.ts +2 -0
- package/src/core/turn-event-wiring.ts +1 -0
- package/src/input/commands/eval.ts +4 -3
- package/src/input/handler-feed-routes.ts +61 -4
- package/src/input/handler-feed.ts +13 -0
- package/src/input/handler-picker-routes.ts +18 -13
- package/src/input/handler-ui-state.ts +4 -0
- package/src/input/keybindings.ts +5 -0
- package/src/input/model-picker.ts +5 -0
- package/src/input/panel-integration-actions.ts +10 -0
- package/src/main.ts +0 -1
- package/src/panels/agent-inspector-panel.ts +18 -13
- package/src/panels/agent-inspector-shared.ts +3 -3
- package/src/panels/agent-logs-panel.ts +22 -53
- package/src/panels/approval-panel.ts +146 -95
- package/src/panels/automation-control-panel.ts +24 -1
- package/src/panels/base-panel.ts +26 -2
- package/src/panels/builtin/development.ts +1 -1
- package/src/panels/cockpit-panel.ts +24 -5
- package/src/panels/cockpit-read-model.ts +2 -1
- package/src/panels/communication-panel.ts +108 -43
- package/src/panels/context-visualizer-panel.ts +25 -1
- package/src/panels/control-plane-panel.ts +27 -1
- package/src/panels/cost-tracker-panel.ts +82 -54
- package/src/panels/debug-panel.ts +51 -16
- package/src/panels/diff-panel.ts +59 -30
- package/src/panels/docs-panel.ts +19 -3
- package/src/panels/eval-panel.ts +130 -81
- package/src/panels/expandable-list-panel.ts +189 -0
- package/src/panels/file-explorer-panel.ts +40 -41
- package/src/panels/file-preview-panel.ts +9 -2
- package/src/panels/forensics-panel.ts +27 -13
- package/src/panels/git-panel.ts +58 -70
- package/src/panels/hooks-panel.ts +36 -2
- package/src/panels/incident-review-panel.ts +31 -10
- package/src/panels/intelligence-panel.ts +152 -71
- package/src/panels/knowledge-graph-panel.ts +89 -27
- package/src/panels/local-auth-panel.ts +17 -11
- package/src/panels/marketplace-panel.ts +31 -12
- package/src/panels/memory-panel.ts +5 -4
- package/src/panels/ops-control-panel.ts +69 -7
- package/src/panels/ops-strategy-panel.ts +61 -43
- package/src/panels/orchestration-panel.ts +34 -4
- package/src/panels/panel-list-panel.ts +33 -8
- package/src/panels/panel-manager.ts +23 -4
- package/src/panels/plan-dashboard-panel.ts +183 -66
- package/src/panels/plugins-panel.ts +48 -10
- package/src/panels/policy-panel.ts +60 -23
- package/src/panels/polish-core.ts +157 -0
- package/src/panels/polish-tables.ts +258 -0
- package/src/panels/polish.ts +44 -145
- package/src/panels/project-planning-panel.ts +22 -0
- package/src/panels/provider-accounts-panel.ts +55 -18
- package/src/panels/provider-health-panel.ts +65 -23
- package/src/panels/provider-stats-panel.ts +47 -22
- package/src/panels/qr-panel.ts +23 -11
- package/src/panels/remote-panel.ts +12 -5
- package/src/panels/routes-panel.ts +27 -5
- package/src/panels/sandbox-panel.ts +62 -27
- package/src/panels/schedule-panel.ts +40 -17
- package/src/panels/scrollable-list-panel.ts +124 -10
- package/src/panels/security-panel.ts +72 -20
- package/src/panels/services-panel.ts +68 -22
- package/src/panels/session-browser-panel.ts +29 -5
- package/src/panels/session-maintenance.ts +2 -2
- package/src/panels/settings-sync-panel.ts +30 -15
- package/src/panels/subscription-panel.ts +21 -3
- package/src/panels/symbol-outline-panel.ts +40 -47
- package/src/panels/system-messages-panel.ts +60 -27
- package/src/panels/tasks-panel.ts +34 -7
- package/src/panels/thinking-panel.ts +26 -25
- package/src/panels/token-budget-panel.ts +49 -38
- package/src/panels/tool-inspector-panel.ts +27 -21
- package/src/panels/types.ts +25 -0
- package/src/panels/watchers-panel.ts +25 -5
- package/src/panels/work-plan-panel.ts +125 -34
- package/src/panels/worktree-panel.ts +65 -20
- package/src/panels/wrfc-panel-format.ts +133 -0
- package/src/panels/wrfc-panel.ts +31 -127
- package/src/renderer/compaction-preview.ts +2 -1
- package/src/renderer/compositor.ts +46 -43
- package/src/renderer/model-picker-overlay.ts +9 -4
- package/src/renderer/model-workspace.ts +2 -3
- package/src/renderer/panel-composite.ts +7 -20
- package/src/renderer/panel-workspace-bar.ts +14 -8
- package/src/renderer/progress.ts +3 -2
- package/src/renderer/tab-strip.ts +148 -34
- package/src/renderer/ui-factory.ts +4 -6
- package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
- package/src/runtime/ui/model-picker/types.ts +4 -0
- package/src/utils/format-duration.ts +55 -0
- package/src/utils/format-number.ts +71 -0
- package/src/version.ts +1 -1
- package/src/panels/panel-picker.ts +0 -106
- package/src/renderer/panel-picker-overlay.ts +0 -202
- 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
|
-
|
|
69
|
+
const records = this.readModel.getSnapshot().records;
|
|
70
|
+
return this.blockedOnly ? records.filter((r) => r.status === 'blocked') : records;
|
|
51
71
|
}
|
|
52
72
|
|
|
53
|
-
protected
|
|
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
|
|
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.
|
|
59
|
-
[` ${
|
|
60
|
-
[
|
|
61
|
-
[
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
177
|
+
const detailLines: Line[] = [];
|
|
134
178
|
if (selected) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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:
|
|
150
|
-
footer:
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
300
|
-
|
|
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
|
|
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
|
|
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
|
}
|