@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
@@ -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
- const footerLines = [
165
- buildPanelLine(width, [
166
- [' Tab', DEFAULT_PANEL_PALETTE.info], [' next agent', DEFAULT_PANEL_PALETTE.dim],
167
- [' Space', DEFAULT_PANEL_PALETTE.info], [' pause', DEFAULT_PANEL_PALETTE.dim],
168
- [' f', DEFAULT_PANEL_PALETTE.info], [' filter', DEFAULT_PANEL_PALETTE.dim],
169
- [' g/G', DEFAULT_PANEL_PALETTE.info], [' scroll', DEFAULT_PANEL_PALETTE.dim],
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 will appear here.',
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 type { PolicyRuntimeState } from '@/runtime/index.ts';
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
- const APPROVAL_ROWS = [
18
- ['shell', 'why prompted: side effects, destructive ops, secret exposure, escalation', 'review via /security and /policy preflight'],
19
- ['file', 'why prompted: config mutation, notebook edits, secret-bearing paths', 'review via /approval review file'],
20
- ['network', 'why prompted: external hosts, fetch scope, egress policy', 'review via /approval review network'],
21
- ['delegate', 'why prompted: recursive agents, spawn ceilings, write-set inheritance', 'review via /approval review delegate'],
22
- ['mcp', 'why prompted: trust escalation, host scope, path scope, coherence mismatch', 'review via /mcp trust and /security'],
23
- ['remote', 'why prompted: runner trust, remote write scope, artifact requirements', 'review via /remote and /sandbox'],
24
- ['hook', 'why prompted: deny/mutate authority, blocking behavior, runner provenance', 'review via /hooks and /security'],
25
- ['plugin', 'why prompted: install/update lifecycle, provenance, capability grants', 'review via /marketplace and /security'],
26
- ['sandbox', 'why prompted: WSL/VM isolation changes alter host risk posture', 'review via /sandbox preset and /sandbox review'],
27
- ] as const;
28
-
29
- type ApprovalRow = (typeof APPROVAL_ROWS)[number];
30
-
31
- export class ApprovalPanel extends ScrollableListPanel<ApprovalRow> {
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 lanes defined.'; }
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
- protected getItems(): readonly ApprovalRow[] {
44
- return APPROVAL_ROWS;
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(row: ApprovalRow, index: number, selected: boolean, width: number): Line {
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
- [' ', C.label],
51
- [row[0].padEnd(10), C.info, bg],
52
- [row[1].slice(0, Math.max(0, width - 18)), C.value, bg],
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
- public handleInput(key: string): boolean {
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 selected = APPROVAL_ROWS[this.selectedIndex] ?? null;
75
- return selected ? selected[2].replace('review via ', '').trim() : null;
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 policySnapshot = this.policyRuntimeState.getSnapshot();
81
- const approvalCount = policySnapshot.recentPermissionAudit.filter((e) => e.approved === true).length;
82
- const denialCount = policySnapshot.recentPermissionAudit.filter((e) => e.approved === false).length;
83
- const pendingCount = policySnapshot.recentPermissionAudit.filter((e) => e.approved === undefined).length;
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
- const selected = APPROVAL_ROWS[this.selectedIndex] ?? null;
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
- detailLines.push(buildPanelLine(width, [[' Selected Lane', C.label]]));
89
- detailLines.push(buildKeyValueLine(width, [
90
- { label: 'lane', value: selected[0], valueColor: C.info },
91
- { label: 'next review', value: selected[2], valueColor: C.dim },
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(policySnapshot.recentPermissionAudit).slice(0, 3)) {
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 === 0) {
120
- ruleSuggestionLines.push(buildPanelLine(width, [[` No repeated denials currently suggest a durable rule.`, C.dim]]));
175
+ if (ruleSuggestionLines.length > 0) {
176
+ ruleSuggestionLines.unshift(buildPanelLine(width, [[' Suggested durable rules', C.label]]));
121
177
  }
122
178
 
123
- const headerLines: Line[] = [
124
- buildPanelLine(width, [[' Approval posture', C.label]]),
125
- buildKeyValueLine(width, [
126
- { label: 'why prompted', value: 'risk summary', valueColor: C.value },
127
- { label: 'what-if', value: '/policy simulate + preflight', valueColor: C.info },
128
- { label: 'operator', value: '/security + /cockpit', valueColor: C.good },
129
- ], C),
130
- buildPanelLine(width, [
131
- [' \u2713 ', C.good],
132
- [`approvals (${approvalCount}) `, C.good],
133
- ['\u2715 ', C.bad],
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: [buildPanelLine(width, [[` Up/Down move Home/End jump selected lane opens the next command path`, C.dim]])],
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(buildPanelLine(width, [[' Up/Down move through runs', C.dim]]));
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',
@@ -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}`.padEnd(width).slice(0, width),
85
+ fitDisplay(` ✕ ${this.lastError}`, width),
83
86
  width,
84
- { fg: '#ef4444', bold: true },
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.slice(0, Math.max(0, width - 30)), C.label],
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.slice(0, Math.max(0, width - 19 - snapshot.latestIncident.classification.length)), C.dim],
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: 'Selected Workspace', lines: workspaceLines },
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, [[` Left/Right move workspace focus Home/End jump focus=${selectedWorkspace}`, C.dim]])],
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.length > 50 ? rec.task.slice(0, 47) + '...' : rec.task;
141
+ const task = truncateDisplay(rec.task, 50);
141
142
 
142
143
  return {
143
144
  id: rec.id,