@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
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { promises as fsPromises, watch, type FSWatcher } from 'fs';
|
|
2
2
|
import type { Line } from '../types/grid.ts';
|
|
3
|
-
import { createEmptyLine, createStyledCell } from '../types/grid.ts';
|
|
4
3
|
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
5
4
|
import type { AgentManager, AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
|
|
6
5
|
import type { AgentEvent } from '@/runtime/index.ts';
|
|
@@ -161,19 +160,28 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
|
|
|
161
160
|
|
|
162
161
|
render(width: number, height: number): Line[] {
|
|
163
162
|
this.needsRender = false;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
163
|
+
// Context-aware footer: when there are no agents only the Tab cycle is
|
|
164
|
+
// meaningless, so hide the per-stream keys; otherwise reflect live state
|
|
165
|
+
// (Space toggles pause→resume, g/G only matter once there is a tail).
|
|
166
|
+
const runningCount = this.agents.filter((a) => a.status === 'running' || a.status === 'pending').length;
|
|
167
|
+
const footerLines = this.agents.length === 0
|
|
168
|
+
? [buildPanelLine(width, [[' Waiting for an agent session to start...', DEFAULT_PANEL_PALETTE.dim]])]
|
|
169
|
+
: [
|
|
170
|
+
buildPanelLine(width, [
|
|
171
|
+
[` ${this.selectedAgentIndex + 1}/${this.agents.length}`, DEFAULT_PANEL_PALETTE.dim],
|
|
172
|
+
[' Tab', DEFAULT_PANEL_PALETTE.info], [' next agent', DEFAULT_PANEL_PALETTE.dim],
|
|
173
|
+
[' Space', DEFAULT_PANEL_PALETTE.info], [this.paused ? ' resume' : ' pause', DEFAULT_PANEL_PALETTE.dim],
|
|
174
|
+
[' f', DEFAULT_PANEL_PALETTE.info], [' filter', DEFAULT_PANEL_PALETTE.dim],
|
|
175
|
+
[' g/G', DEFAULT_PANEL_PALETTE.info], [this.autoFollow ? ' top/follow' : ' top/bottom', DEFAULT_PANEL_PALETTE.dim],
|
|
176
|
+
]),
|
|
177
|
+
];
|
|
172
178
|
|
|
173
179
|
const summaryLines = [
|
|
174
180
|
buildPanelLine(width, [
|
|
175
181
|
[' Agents ', DEFAULT_PANEL_PALETTE.label],
|
|
176
182
|
[String(this.agents.length), DEFAULT_PANEL_PALETTE.value],
|
|
183
|
+
[' Running ', DEFAULT_PANEL_PALETTE.label],
|
|
184
|
+
[String(runningCount), runningCount > 0 ? DEFAULT_PANEL_PALETTE.good : DEFAULT_PANEL_PALETTE.dim],
|
|
177
185
|
[' Filter ', DEFAULT_PANEL_PALETTE.label],
|
|
178
186
|
[FILTER_LABELS[this.filter], DEFAULT_PANEL_PALETTE.info],
|
|
179
187
|
[' Mode ', DEFAULT_PANEL_PALETTE.label],
|
|
@@ -191,8 +199,11 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
|
|
|
191
199
|
lines: buildEmptyState(
|
|
192
200
|
width,
|
|
193
201
|
' No agents running',
|
|
194
|
-
'Spawn or attach to an agent session and its structured logs
|
|
195
|
-
[
|
|
202
|
+
'Spawn or attach to an agent session and its structured logs stream here in real time.',
|
|
203
|
+
[
|
|
204
|
+
{ command: '/spawn <task>', summary: 'launch an agent and watch its live session log here' },
|
|
205
|
+
{ command: '/inspector', summary: 'open the inspector for a per-agent timeline and tool activity' },
|
|
206
|
+
],
|
|
196
207
|
DEFAULT_PANEL_PALETTE,
|
|
197
208
|
),
|
|
198
209
|
},
|
|
@@ -544,22 +555,6 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
|
|
|
544
555
|
|
|
545
556
|
// ── Private: rendering helpers ─────────────────────────────────────────────
|
|
546
557
|
|
|
547
|
-
/** Top header bar: title + filter label + mode indicators */
|
|
548
|
-
private _renderHeader(width: number): Line {
|
|
549
|
-
const title = ' Agent Logs ';
|
|
550
|
-
const filterLabel = `[${FILTER_LABELS[this.filter]}] `;
|
|
551
|
-
const pause = this.paused ? ' PAUSED ' : '';
|
|
552
|
-
const follow = this.autoFollow ? ' AUTO-FOLLOW ' : '';
|
|
553
|
-
const keyhints = ' Tab:next Space:pause f:filter g/G:scroll ';
|
|
554
|
-
return buildStyledPanelLine(width, [
|
|
555
|
-
{ text: title, fg: COLOR.header_accent, bold: true },
|
|
556
|
-
{ text: filterLabel, fg: COLOR.filter_active },
|
|
557
|
-
{ text: pause, fg: COLOR.paused },
|
|
558
|
-
{ text: follow, fg: COLOR.auto_follow },
|
|
559
|
-
{ text: keyhints, fg: COLOR.header_label },
|
|
560
|
-
]);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
558
|
/** Agent selector bar: shows running agents with cycle indicator */
|
|
564
559
|
private _renderAgentSelector(width: number): Line {
|
|
565
560
|
const prefix = ' Agents: ';
|
|
@@ -583,32 +578,6 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
|
|
|
583
578
|
return buildStyledPanelLine(width, segments);
|
|
584
579
|
}
|
|
585
580
|
|
|
586
|
-
private _renderNoAgents(width: number): Line {
|
|
587
|
-
const msg = ' No agents running. ';
|
|
588
|
-
return buildStyledPanelLine(width, [{ text: msg, fg: COLOR.dim }]);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
private _renderSeparator(width: number): Line {
|
|
592
|
-
return buildStyledPanelLine(width, [{ text: '─'.repeat(width), fg: COLOR.separator }]);
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
private _renderEmpty(width: number, bodyHeight: number): Line[] {
|
|
596
|
-
const lines: Line[] = [];
|
|
597
|
-
const msg = this.agents.length === 0
|
|
598
|
-
? ' No agents running '
|
|
599
|
-
: ` No ${this.filter === 'all' ? '' : this.filter + ' '}log entries yet `;
|
|
600
|
-
const offset = Math.max(0, Math.floor((width - msg.length) / 2));
|
|
601
|
-
const textLine = buildStyledPanelLine(width, [
|
|
602
|
-
{ text: ' '.repeat(offset), fg: COLOR.dim },
|
|
603
|
-
{ text: msg, fg: COLOR.dim },
|
|
604
|
-
]);
|
|
605
|
-
lines.push(textLine);
|
|
606
|
-
while (lines.length < bodyHeight) {
|
|
607
|
-
lines.push(createEmptyLine(width));
|
|
608
|
-
}
|
|
609
|
-
return lines;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
581
|
private _renderEntry(entry: LogEntry, width: number): Line {
|
|
613
582
|
// Indent non-session entries
|
|
614
583
|
const prefix = entry.type === 'session_start' ? '' : ' ';
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { Line } from '../types/grid.ts';
|
|
2
2
|
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
3
3
|
import {
|
|
4
|
+
buildBodyText,
|
|
5
|
+
buildDetailBlock,
|
|
4
6
|
buildGuidanceLine,
|
|
5
7
|
buildKeyValueLine,
|
|
8
|
+
buildKeyboardHints,
|
|
6
9
|
buildPanelLine,
|
|
10
|
+
buildStatusBadge,
|
|
7
11
|
DEFAULT_PANEL_PALETTE,
|
|
12
|
+
type StatusBadgeKind,
|
|
8
13
|
} from './polish.ts';
|
|
9
|
-
import
|
|
14
|
+
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
15
|
+
import type { PolicyRuntimeState, PermissionAuditEntry } from '@/runtime/index.ts';
|
|
10
16
|
import { buildPermissionRuleSuggestions } from '@/runtime/index.ts';
|
|
11
17
|
|
|
12
18
|
const C = {
|
|
@@ -14,21 +20,56 @@ const C = {
|
|
|
14
20
|
headerBg: '#111827',
|
|
15
21
|
} as const;
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
// Reference catalog of approval lanes and where each one is reviewed. Used to
|
|
24
|
+
// resolve the next-step command for a live request and as a fallback reference
|
|
25
|
+
// when no live requests are present.
|
|
26
|
+
const LANE_REVIEW: Record<string, { command: string; why: string }> = {
|
|
27
|
+
shell: { command: '/security', why: 'side effects, destructive ops, secret exposure, escalation' },
|
|
28
|
+
file: { command: '/approval review file', why: 'config mutation, notebook edits, secret-bearing paths' },
|
|
29
|
+
network: { command: '/approval review network', why: 'external hosts, fetch scope, egress policy' },
|
|
30
|
+
delegate: { command: '/approval review delegate', why: 'recursive agents, spawn ceilings, write-set inheritance' },
|
|
31
|
+
mcp: { command: '/mcp trust', why: 'trust escalation, host scope, path scope, coherence mismatch' },
|
|
32
|
+
remote: { command: '/remote', why: 'runner trust, remote write scope, artifact requirements' },
|
|
33
|
+
hook: { command: '/hooks', why: 'deny/mutate authority, blocking behavior, runner provenance' },
|
|
34
|
+
plugin: { command: '/marketplace', why: 'install/update lifecycle, provenance, capability grants' },
|
|
35
|
+
sandbox: { command: '/sandbox review', why: 'WSL/VM isolation changes alter host risk posture' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function laneOf(entry: PermissionAuditEntry): string {
|
|
39
|
+
const key = (entry.category || entry.tool || '').toLowerCase();
|
|
40
|
+
for (const lane of Object.keys(LANE_REVIEW)) {
|
|
41
|
+
if (key.includes(lane)) return lane;
|
|
42
|
+
}
|
|
43
|
+
return key || 'shell';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function reviewFor(entry: PermissionAuditEntry): { command: string; why: string } {
|
|
47
|
+
return LANE_REVIEW[laneOf(entry)] ?? { command: '/security', why: 'review approval posture' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decisionOf(entry: PermissionAuditEntry): 'pending' | 'approved' | 'denied' {
|
|
51
|
+
return entry.approved === undefined ? 'pending' : entry.approved ? 'approved' : 'denied';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function badgeKind(decision: 'pending' | 'approved' | 'denied'): StatusBadgeKind {
|
|
55
|
+
return decision === 'pending' ? 'pending' : decision === 'approved' ? 'completed' : 'failed';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function riskColor(risk: string): string {
|
|
59
|
+
const r = risk.toLowerCase();
|
|
60
|
+
if (r.includes('critical') || r.includes('high')) return C.bad;
|
|
61
|
+
if (r.includes('medium') || r.includes('moderate')) return C.warn;
|
|
62
|
+
return C.good;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fmtAgo(ts: number): string {
|
|
66
|
+
const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
67
|
+
if (sec < 60) return `${sec}s ago`;
|
|
68
|
+
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
|
69
|
+
return `${Math.floor(sec / 3600)}h ago`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class ApprovalPanel extends ScrollableListPanel<PermissionAuditEntry> {
|
|
32
73
|
private readonly policyRuntimeState: Pick<PolicyRuntimeState, 'getSnapshot'>;
|
|
33
74
|
|
|
34
75
|
public constructor(policyRuntimeState: Pick<PolicyRuntimeState, 'getSnapshot'>) {
|
|
@@ -38,113 +79,123 @@ export class ApprovalPanel extends ScrollableListPanel<ApprovalRow> {
|
|
|
38
79
|
}
|
|
39
80
|
|
|
40
81
|
protected override getPalette() { return C; }
|
|
41
|
-
protected override getEmptyStateMessage() { return ' No approval
|
|
82
|
+
protected override getEmptyStateMessage() { return ' No approval pressure right now.'; }
|
|
83
|
+
protected override getEmptyStateActions() {
|
|
84
|
+
return [
|
|
85
|
+
{ command: '/security', summary: 'inspect trust, tokens, quarantines, and incident pressure' },
|
|
86
|
+
{ command: '/policy simulate', summary: 'preview which requests a rule change would auto-approve' },
|
|
87
|
+
];
|
|
88
|
+
}
|
|
42
89
|
|
|
43
|
-
|
|
44
|
-
|
|
90
|
+
// Pending requests (those still needing a decision) are surfaced first so the
|
|
91
|
+
// operator sees actionable items at the top, then most-recent decisions.
|
|
92
|
+
protected getItems(): readonly PermissionAuditEntry[] {
|
|
93
|
+
const audit = this.policyRuntimeState.getSnapshot().recentPermissionAudit;
|
|
94
|
+
const pending = audit.filter((e) => e.approved === undefined);
|
|
95
|
+
const decided = audit.filter((e) => e.approved !== undefined);
|
|
96
|
+
return [...pending, ...decided];
|
|
45
97
|
}
|
|
46
98
|
|
|
47
|
-
protected renderItem(
|
|
99
|
+
protected renderItem(entry: PermissionAuditEntry, _index: number, selected: boolean, width: number): Line {
|
|
48
100
|
const bg = selected ? C.selectBg : undefined;
|
|
101
|
+
const decision = decisionOf(entry);
|
|
102
|
+
const badge = buildStatusBadge(badgeKind(decision), decision)[0]!;
|
|
103
|
+
const detailWidth = Math.max(0, width - 40);
|
|
49
104
|
return buildPanelLine(width, [
|
|
50
|
-
['
|
|
51
|
-
[
|
|
52
|
-
[
|
|
105
|
+
[' ', C.label, bg],
|
|
106
|
+
[`${badge.text} `.padEnd(12), badge.fg, bg],
|
|
107
|
+
[`${truncateDisplay(entry.tool, 12)} `.padEnd(13), C.value, bg],
|
|
108
|
+
[`${truncateDisplay(entry.riskLevel, 6)} `.padEnd(7), riskColor(entry.riskLevel), bg],
|
|
109
|
+
[truncateDisplay(entry.summary, detailWidth), C.dim, bg],
|
|
53
110
|
]);
|
|
54
111
|
}
|
|
55
112
|
|
|
56
|
-
|
|
57
|
-
if (key === 'home') {
|
|
58
|
-
this.selectedIndex = 0;
|
|
59
|
-
this.markDirty();
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
if (key === 'end') {
|
|
63
|
-
this.selectedIndex = APPROVAL_ROWS.length - 1;
|
|
64
|
-
this.markDirty();
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
if (key === 'enter' || key === 'return') {
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
return super.handleInput(key);
|
|
71
|
-
}
|
|
72
|
-
|
|
113
|
+
/** Next-step review command for the currently selected request (if any). */
|
|
73
114
|
public getSelectedCommand(): string | null {
|
|
74
|
-
const
|
|
75
|
-
|
|
115
|
+
const items = this.getVisibleItems();
|
|
116
|
+
const selected = items[this.selectedIndex];
|
|
117
|
+
return selected ? reviewFor(selected).command : null;
|
|
76
118
|
}
|
|
77
119
|
|
|
78
120
|
public render(width: number, height: number): Line[] {
|
|
79
121
|
this.clampSelection();
|
|
80
|
-
const
|
|
81
|
-
const approvalCount =
|
|
82
|
-
const denialCount =
|
|
83
|
-
const pendingCount =
|
|
122
|
+
const audit = this.policyRuntimeState.getSnapshot().recentPermissionAudit;
|
|
123
|
+
const approvalCount = audit.filter((e) => e.approved === true).length;
|
|
124
|
+
const denialCount = audit.filter((e) => e.approved === false).length;
|
|
125
|
+
const pendingCount = audit.filter((e) => e.approved === undefined).length;
|
|
126
|
+
|
|
127
|
+
const items = this.getVisibleItems();
|
|
128
|
+
const selected = items[this.selectedIndex] ?? null;
|
|
84
129
|
|
|
85
|
-
|
|
130
|
+
// ---- Posture summary (severity + counts first) ----
|
|
131
|
+
const headerLines: Line[] = [
|
|
132
|
+
buildPanelLine(width, [
|
|
133
|
+
[' ', C.label],
|
|
134
|
+
...buildStatusBadge('pending', 'pending', { count: pendingCount }),
|
|
135
|
+
[' ', C.dim],
|
|
136
|
+
...buildStatusBadge('completed', 'approved', { count: approvalCount }),
|
|
137
|
+
[' ', C.dim],
|
|
138
|
+
...buildStatusBadge('failed', 'denied', { count: denialCount }),
|
|
139
|
+
]),
|
|
140
|
+
pendingCount > 0
|
|
141
|
+
? buildPanelLine(width, [[` ${pendingCount} request${pendingCount !== 1 ? 's' : ''} awaiting a decision — select one to see its review path.`, C.warn]])
|
|
142
|
+
: buildGuidanceLine(width, '/policy simulate', 'preview which requests a scoped rule change would auto-approve', C),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// ---- Detail block for the selected request ----
|
|
86
146
|
const detailLines: Line[] = [];
|
|
87
147
|
if (selected) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
148
|
+
const review = reviewFor(selected);
|
|
149
|
+
const decision = decisionOf(selected);
|
|
150
|
+
detailLines.push(...buildDetailBlock(width, `Request · ${selected.tool}`, [
|
|
151
|
+
buildKeyValueLine(width, [
|
|
152
|
+
{ label: 'decision', value: decision, valueColor: decision === 'pending' ? C.info : decision === 'approved' ? C.good : C.bad },
|
|
153
|
+
{ label: 'risk', value: selected.riskLevel, valueColor: riskColor(selected.riskLevel) },
|
|
154
|
+
{ label: 'lane', value: laneOf(selected), valueColor: C.info },
|
|
155
|
+
], C),
|
|
156
|
+
buildKeyValueLine(width, [
|
|
157
|
+
{ label: 'requested', value: fmtAgo(selected.requestedAt), valueColor: C.dim },
|
|
158
|
+
...(selected.decidedAt ? [{ label: 'decided', value: fmtAgo(selected.decidedAt), valueColor: C.dim }] : []),
|
|
159
|
+
...(selected.target ? [{ label: 'target', value: truncateDisplay(selected.target, 24), valueColor: C.value }] : []),
|
|
160
|
+
], C),
|
|
161
|
+
...buildBodyText(width, selected.summary, C, C.value),
|
|
162
|
+
...(selected.reasons[0]
|
|
163
|
+
? buildBodyText(width, `why prompted: ${selected.reasons[0]}`, C, C.dim)
|
|
164
|
+
: buildBodyText(width, `why prompted: ${review.why}`, C, C.dim)),
|
|
165
|
+
buildGuidanceLine(width, review.command, `review and decide the ${laneOf(selected)} request`, C),
|
|
92
166
|
], C));
|
|
93
|
-
detailLines.push(buildPanelLine(width, [[` ${selected[1]}`, C.value]]));
|
|
94
|
-
detailLines.push(buildGuidanceLine(width, selected[2].replace('review via ', ''), `open the ${selected[0]} review path`, C));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const recentAuditLines: Line[] = [];
|
|
98
|
-
for (const entry of policySnapshot.recentPermissionAudit.slice(0, 5)) {
|
|
99
|
-
const decision = entry.approved === undefined ? 'pending' : entry.approved ? 'approved' : 'denied';
|
|
100
|
-
const decisionColor = entry.approved === undefined ? C.info : entry.approved ? C.good : C.bad;
|
|
101
|
-
recentAuditLines.push(buildPanelLine(width, [
|
|
102
|
-
[` ${decision.padEnd(8)}`, decisionColor],
|
|
103
|
-
[`${entry.tool}`.padEnd(14), C.label],
|
|
104
|
-
[entry.summary.slice(0, Math.max(0, width - 28)), C.value],
|
|
105
|
-
]));
|
|
106
|
-
if (entry.reasons[0]) {
|
|
107
|
-
recentAuditLines.push(buildPanelLine(width, [[` ${entry.reasons[0]}`, C.dim]]));
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (recentAuditLines.length === 0) {
|
|
111
|
-
recentAuditLines.push(buildPanelLine(width, [[` No recent approval pressure. Live requests and decisions will appear here.`, C.dim]]));
|
|
112
167
|
}
|
|
113
168
|
|
|
169
|
+
// ---- Durable-rule suggestions from repeated denials ----
|
|
114
170
|
const ruleSuggestionLines: Line[] = [];
|
|
115
|
-
for (const suggestion of buildPermissionRuleSuggestions(
|
|
116
|
-
ruleSuggestionLines.push(buildPanelLine(width, [[` ${suggestion.summary}`, C.info]]));
|
|
171
|
+
for (const suggestion of buildPermissionRuleSuggestions(audit).slice(0, 3)) {
|
|
172
|
+
ruleSuggestionLines.push(buildPanelLine(width, [[` ${truncateDisplay(suggestion.summary, Math.max(0, width - 4))}`, C.info]]));
|
|
117
173
|
ruleSuggestionLines.push(buildGuidanceLine(width, suggestion.command, suggestion.reason, C));
|
|
118
174
|
}
|
|
119
|
-
if (ruleSuggestionLines.length
|
|
120
|
-
ruleSuggestionLines.
|
|
175
|
+
if (ruleSuggestionLines.length > 0) {
|
|
176
|
+
ruleSuggestionLines.unshift(buildPanelLine(width, [[' Suggested durable rules', C.label]]));
|
|
121
177
|
}
|
|
122
178
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
[`denials (${denialCount}) `, C.bad],
|
|
135
|
-
['\u25cb ', C.info],
|
|
136
|
-
[`pending (${pendingCount})`, C.info],
|
|
137
|
-
]),
|
|
138
|
-
buildGuidanceLine(width, '/approval review shell', 'inspect the highest-risk approval lane and refine scoped review posture', C),
|
|
139
|
-
...detailLines,
|
|
140
|
-
...recentAuditLines,
|
|
141
|
-
...ruleSuggestionLines,
|
|
142
|
-
];
|
|
179
|
+
// ---- Context-aware footer: only show review key when a request is selected ----
|
|
180
|
+
const hints = selected
|
|
181
|
+
? [
|
|
182
|
+
{ keys: '↑/↓', label: 'select' },
|
|
183
|
+
{ keys: 'Enter', label: `review (${reviewFor(selected).command})` },
|
|
184
|
+
{ keys: 'g/G', label: 'top/bottom' },
|
|
185
|
+
]
|
|
186
|
+
: [
|
|
187
|
+
{ keys: '↑/↓', label: 'select' },
|
|
188
|
+
{ keys: 'g/G', label: 'top/bottom' },
|
|
189
|
+
];
|
|
143
190
|
|
|
144
191
|
return this.renderList(width, height, {
|
|
145
192
|
title: 'Approval Control Room',
|
|
146
193
|
header: headerLines,
|
|
147
|
-
footer: [
|
|
194
|
+
footer: [...detailLines, ...ruleSuggestionLines, buildKeyboardHints(width, hints, C)],
|
|
148
195
|
});
|
|
149
196
|
}
|
|
197
|
+
|
|
198
|
+
protected override onSelect(item: PermissionAuditEntry): void {
|
|
199
|
+
void item; // Enter is wired by the shell to open getSelectedCommand(); selection model lives here.
|
|
200
|
+
}
|
|
150
201
|
}
|
|
@@ -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';
|
|
@@ -46,6 +48,8 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
|
|
|
46
48
|
public constructor(readModel?: UiReadModel<UiAutomationSnapshot>) {
|
|
47
49
|
super('automation', 'Automation', 'M', 'monitoring');
|
|
48
50
|
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
51
|
+
this.filterEnabled = true;
|
|
52
|
+
this.filterLabel = 'Filter runs';
|
|
49
53
|
this.readModel = readModel;
|
|
50
54
|
this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
|
|
51
55
|
}
|
|
@@ -68,6 +72,12 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
|
|
|
68
72
|
return this.readModel.getSnapshot().runs;
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
protected override filterMatches(run: AutomationRun, q: string): boolean {
|
|
76
|
+
return run.jobId.toLowerCase().includes(q)
|
|
77
|
+
|| run.status.toLowerCase().includes(q)
|
|
78
|
+
|| run.target.kind.toLowerCase().includes(q);
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
protected renderItem(run: AutomationRun, _index: number, selected: boolean, width: number): Line {
|
|
72
82
|
const bg = selected ? C.selectBg : undefined;
|
|
73
83
|
const jobs = this.getJobs();
|
|
@@ -192,6 +202,8 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
|
|
|
192
202
|
|
|
193
203
|
// Jobs quick view
|
|
194
204
|
if (jobs.length > 0) {
|
|
205
|
+
const enabledJobs = jobs.filter((job) => job.enabled).length;
|
|
206
|
+
footerLines.push(buildSectionHeader(width, `Jobs (${enabledJobs} enabled / ${jobs.length})`, C));
|
|
195
207
|
footerLines.push(
|
|
196
208
|
...jobs.slice(0, 6).map((job) => buildPanelLine(width, [
|
|
197
209
|
[' ', C.label],
|
|
@@ -201,7 +213,18 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
|
|
|
201
213
|
])),
|
|
202
214
|
);
|
|
203
215
|
}
|
|
204
|
-
footerLines.push(
|
|
216
|
+
footerLines.push(
|
|
217
|
+
this.filterActive
|
|
218
|
+
? buildKeyboardHints(width, [
|
|
219
|
+
{ keys: 'type', label: 'filter runs' },
|
|
220
|
+
{ keys: 'Enter', label: 'apply' },
|
|
221
|
+
{ keys: 'Esc', label: 'clear' },
|
|
222
|
+
], C)
|
|
223
|
+
: buildKeyboardHints(width, [
|
|
224
|
+
{ keys: 'Up/Down', label: 'select run' },
|
|
225
|
+
{ keys: '/', label: 'filter' },
|
|
226
|
+
], C),
|
|
227
|
+
);
|
|
205
228
|
|
|
206
229
|
return this.renderList(width, height, {
|
|
207
230
|
title: 'Automation Control',
|
package/src/panels/base-panel.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { UIFactory } from '../renderer/ui-factory.ts';
|
|
|
6
6
|
import { SPINNER_FRAMES } from '../renderer/progress.ts';
|
|
7
7
|
import { fitDisplay } from '../utils/terminal-width.ts';
|
|
8
8
|
|
|
9
|
+
/** Canonical error-surface foreground (bad/red), kept out of the render body. */
|
|
10
|
+
const ERROR_FG = '#ef4444';
|
|
11
|
+
|
|
9
12
|
export abstract class BasePanel implements Panel {
|
|
10
13
|
public needsRender = true;
|
|
11
14
|
public isTransient = false;
|
|
@@ -79,9 +82,9 @@ export abstract class BasePanel implements Panel {
|
|
|
79
82
|
protected renderErrorLine(width: number): Line | null {
|
|
80
83
|
if (!this.lastError) return null;
|
|
81
84
|
return UIFactory.stringToLine(
|
|
82
|
-
` ✕ ${this.lastError}
|
|
85
|
+
fitDisplay(` ✕ ${this.lastError}`, width),
|
|
83
86
|
width,
|
|
84
|
-
{ fg:
|
|
87
|
+
{ fg: ERROR_FG, bold: true },
|
|
85
88
|
);
|
|
86
89
|
}
|
|
87
90
|
|
|
@@ -189,6 +192,27 @@ export abstract class BasePanel implements Panel {
|
|
|
189
192
|
|
|
190
193
|
abstract render(width: number, height: number): Line[];
|
|
191
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Default mouse-wheel handling: translate a wheel delta into the panel's
|
|
197
|
+
* existing up/down keyboard navigation, so every panel scrolls with the wheel
|
|
198
|
+
* without bespoke code. Panels with richer scroll state (e.g.
|
|
199
|
+
* `ScrollableListPanel`) override this. No-op for panels that don't implement
|
|
200
|
+
* `handleInput`. Returns true if any step was consumed.
|
|
201
|
+
*/
|
|
202
|
+
public handleScroll(deltaRows: number): boolean {
|
|
203
|
+
const self = this as unknown as { handleInput?: (key: string) => boolean };
|
|
204
|
+
if (typeof self.handleInput !== 'function') return false;
|
|
205
|
+
const rows = Math.trunc(deltaRows);
|
|
206
|
+
if (rows === 0) return false;
|
|
207
|
+
const key = rows > 0 ? 'down' : 'up';
|
|
208
|
+
const steps = Math.min(Math.abs(rows), 10); // clamp runaway wheel deltas
|
|
209
|
+
let consumed = false;
|
|
210
|
+
for (let i = 0; i < steps; i++) {
|
|
211
|
+
if (self.handleInput(key)) consumed = true;
|
|
212
|
+
}
|
|
213
|
+
return consumed;
|
|
214
|
+
}
|
|
215
|
+
|
|
192
216
|
/** R2: Mark this panel dirty — it will be re-rendered on the next compositor frame. */
|
|
193
217
|
public invalidate(): void { this.needsRender = true; }
|
|
194
218
|
|
|
@@ -18,7 +18,7 @@ export function registerDevelopmentPanels(manager: PanelManager, deps: ResolvedB
|
|
|
18
18
|
icon: 'G',
|
|
19
19
|
category: 'development',
|
|
20
20
|
description: 'Git status, staged/unstaged changes, and recent commits',
|
|
21
|
-
factory: () => new GitPanel(requireUiServices(deps).environment.workingDirectory),
|
|
21
|
+
factory: () => new GitPanel(requireUiServices(deps).environment.workingDirectory, deps.requestRender),
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
manager.registerType({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Line } from '../types/grid.ts';
|
|
2
2
|
import { createEmptyLine } from '../types/grid.ts';
|
|
3
|
+
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
3
4
|
import { BasePanel } from './base-panel.ts';
|
|
4
5
|
import type { UiCockpitSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
|
|
5
6
|
import type { CockpitRosterReadModel } from './cockpit-read-model.ts';
|
|
@@ -171,7 +172,7 @@ export class CockpitPanel extends BasePanel {
|
|
|
171
172
|
[entry.status, statusColor],
|
|
172
173
|
stalledBadge,
|
|
173
174
|
[' ', C.dim],
|
|
174
|
-
[entry.task
|
|
175
|
+
[truncateDisplay(entry.task, Math.max(0, width - 30)), C.label],
|
|
175
176
|
]));
|
|
176
177
|
}
|
|
177
178
|
if (roster.length === 0) {
|
|
@@ -238,7 +239,7 @@ export class CockpitPanel extends BasePanel {
|
|
|
238
239
|
[' latest incident ', C.label],
|
|
239
240
|
[snapshot.latestIncident.classification, C.bad],
|
|
240
241
|
[' ', C.label],
|
|
241
|
-
[snapshot.latestIncident.summary
|
|
242
|
+
[truncateDisplay(snapshot.latestIncident.summary, Math.max(0, width - 19 - snapshot.latestIncident.classification.length)), C.dim],
|
|
242
243
|
]));
|
|
243
244
|
}
|
|
244
245
|
const domainLines: Line[] = [buildPanelLine(width, [[
|
|
@@ -283,18 +284,36 @@ export class CockpitPanel extends BasePanel {
|
|
|
283
284
|
workspaceLines.push(...this.renderAgentsWorkspace(width));
|
|
284
285
|
}
|
|
285
286
|
|
|
287
|
+
// Visible workspace selector so focus and the available workspaces are
|
|
288
|
+
// obvious at a glance (not buried in a footer string). Rendered as a
|
|
289
|
+
// title-less row to keep it compact; the ▸ marker + accent show focus.
|
|
290
|
+
const selectorLine = buildPanelLine(width, [
|
|
291
|
+
[' switch ', C.label],
|
|
292
|
+
...WORKSPACE_IDS.flatMap((id, i): Array<[string, string, string?]> => {
|
|
293
|
+
const focused = i === this.selectedWorkspaceIndex;
|
|
294
|
+
return [
|
|
295
|
+
[focused ? '▸' : ' ', focused ? C.value : C.dim],
|
|
296
|
+
[`${id} `, focused ? C.header : C.dim, focused ? C.headerBg : undefined],
|
|
297
|
+
];
|
|
298
|
+
}),
|
|
299
|
+
]);
|
|
300
|
+
|
|
286
301
|
const sections: PanelWorkspaceSection[] = [
|
|
302
|
+
{ lines: [selectorLine] },
|
|
287
303
|
{ title: 'Flow', lines: flowLines },
|
|
288
304
|
{ title: 'Governance', lines: governanceLines },
|
|
289
305
|
{ title: 'Health', lines: healthLines },
|
|
290
306
|
{ title: 'Domains', lines: domainLines },
|
|
291
|
-
{ title:
|
|
307
|
+
{ title: `Workspace · ${selectedWorkspace}`, lines: workspaceLines },
|
|
292
308
|
];
|
|
309
|
+
// Context-aware footer: surface the keys that actually work in this view.
|
|
310
|
+
const footerHint = selectedWorkspace === 'agents'
|
|
311
|
+
? ' ←/→ workspace ↑/↓ select agent i inspect c cancel Home/End jump'
|
|
312
|
+
: ' ←/→ workspace Home/End jump → agents for per-agent inspect/cancel';
|
|
293
313
|
const lines = buildPanelWorkspace(width, height, {
|
|
294
314
|
title: 'Operator Cockpit',
|
|
295
|
-
intro: 'Live runtime pressure across orchestration, approvals, governance, integrations, and provider trust posture.',
|
|
296
315
|
sections,
|
|
297
|
-
footerLines: [buildPanelLine(width, [[
|
|
316
|
+
footerLines: [buildPanelLine(width, [[footerHint, C.dim]])],
|
|
298
317
|
palette: C,
|
|
299
318
|
});
|
|
300
319
|
while (lines.length < height) lines.push(createEmptyLine(width));
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
|
|
21
21
|
import type { AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
|
|
22
|
+
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
22
23
|
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
23
24
|
import {
|
|
24
25
|
AGENT_TERMINAL_STATUSES,
|
|
@@ -137,7 +138,7 @@ export function buildCockpitRosterSnapshot(
|
|
|
137
138
|
totalCost += agentCost;
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
const task = rec.task
|
|
141
|
+
const task = truncateDisplay(rec.task, 50);
|
|
141
142
|
|
|
142
143
|
return {
|
|
143
144
|
id: rec.id,
|