@pellux/goodvibes-tui 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +4 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/context-usage.ts +53 -0
  8. package/src/core/conversation-rendering.ts +2 -2
  9. package/src/core/conversation.ts +53 -8
  10. package/src/core/session-recovery.ts +26 -18
  11. package/src/core/system-message-router.ts +42 -26
  12. package/src/core/turn-event-wiring.ts +13 -16
  13. package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
  14. package/src/daemon/handlers/inbox/index.ts +2 -1
  15. package/src/daemon/handlers/inbox/poller.ts +2 -1
  16. package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
  17. package/src/daemon/handlers/inbox/providers/email.ts +2 -1
  18. package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
  19. package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
  20. package/src/daemon/handlers/triage/integration.ts +2 -1
  21. package/src/daemon/handlers/triage/pipeline.ts +2 -1
  22. package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
  23. package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
  24. package/src/export/gist-uploader.ts +3 -1
  25. package/src/input/command-registry.ts +1 -0
  26. package/src/input/commands/cloudflare-runtime.ts +2 -1
  27. package/src/input/commands/eval.ts +4 -3
  28. package/src/input/commands/guidance-runtime.ts +2 -4
  29. package/src/input/commands/health-runtime.ts +13 -7
  30. package/src/input/commands/knowledge.ts +2 -1
  31. package/src/input/commands/runtime-services.ts +40 -10
  32. package/src/input/handler-command-route.ts +1 -1
  33. package/src/input/handler-feed-routes.ts +61 -4
  34. package/src/input/handler-feed.ts +13 -0
  35. package/src/input/handler-interactions.ts +2 -1
  36. package/src/input/handler-modal-routes.ts +2 -1
  37. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  38. package/src/input/handler-onboarding.ts +8 -8
  39. package/src/input/handler-picker-routes.ts +18 -13
  40. package/src/input/handler-ui-state.ts +5 -1
  41. package/src/input/keybindings.ts +5 -0
  42. package/src/input/model-picker.ts +5 -0
  43. package/src/input/panel-integration-actions.ts +10 -0
  44. package/src/input/tts-settings-actions.ts +2 -1
  45. package/src/main.ts +52 -60
  46. package/src/panels/agent-inspector-panel.ts +90 -15
  47. package/src/panels/agent-inspector-shared.ts +3 -3
  48. package/src/panels/agent-logs-panel.ts +149 -72
  49. package/src/panels/approval-panel.ts +146 -95
  50. package/src/panels/automation-control-panel.ts +24 -1
  51. package/src/panels/base-panel.ts +28 -3
  52. package/src/panels/builtin/agent.ts +3 -1
  53. package/src/panels/builtin/development.ts +2 -1
  54. package/src/panels/builtin/session.ts +1 -1
  55. package/src/panels/cockpit-panel.ts +24 -5
  56. package/src/panels/cockpit-read-model.ts +2 -1
  57. package/src/panels/communication-panel.ts +108 -43
  58. package/src/panels/context-visualizer-panel.ts +49 -15
  59. package/src/panels/control-plane-panel.ts +27 -1
  60. package/src/panels/cost-tracker-panel.ts +87 -65
  61. package/src/panels/debug-panel.ts +61 -37
  62. package/src/panels/diff-panel.ts +59 -30
  63. package/src/panels/docs-panel.ts +30 -24
  64. package/src/panels/eval-panel.ts +130 -81
  65. package/src/panels/expandable-list-panel.ts +189 -0
  66. package/src/panels/file-explorer-panel.ts +42 -42
  67. package/src/panels/file-preview-panel.ts +11 -3
  68. package/src/panels/forensics-panel.ts +27 -13
  69. package/src/panels/git-panel.ts +65 -84
  70. package/src/panels/hooks-panel.ts +36 -2
  71. package/src/panels/incident-review-panel.ts +31 -10
  72. package/src/panels/intelligence-panel.ts +152 -71
  73. package/src/panels/knowledge-graph-panel.ts +89 -27
  74. package/src/panels/local-auth-panel.ts +17 -11
  75. package/src/panels/marketplace-panel.ts +33 -13
  76. package/src/panels/memory-panel.ts +7 -5
  77. package/src/panels/ops-control-panel.ts +69 -7
  78. package/src/panels/ops-strategy-panel.ts +61 -43
  79. package/src/panels/orchestration-panel.ts +34 -4
  80. package/src/panels/panel-list-panel.ts +33 -8
  81. package/src/panels/panel-manager.ts +23 -4
  82. package/src/panels/plan-dashboard-panel.ts +183 -66
  83. package/src/panels/plugins-panel.ts +48 -10
  84. package/src/panels/policy-panel.ts +60 -23
  85. package/src/panels/polish-core.ts +157 -0
  86. package/src/panels/polish-tables.ts +258 -0
  87. package/src/panels/polish.ts +44 -145
  88. package/src/panels/project-planning-panel.ts +27 -4
  89. package/src/panels/provider-accounts-panel.ts +55 -18
  90. package/src/panels/provider-health-panel.ts +118 -87
  91. package/src/panels/provider-stats-panel.ts +47 -22
  92. package/src/panels/qr-panel.ts +23 -11
  93. package/src/panels/remote-panel.ts +12 -5
  94. package/src/panels/routes-panel.ts +27 -5
  95. package/src/panels/sandbox-panel.ts +62 -27
  96. package/src/panels/schedule-panel.ts +44 -24
  97. package/src/panels/scrollable-list-panel.ts +124 -10
  98. package/src/panels/security-panel.ts +72 -20
  99. package/src/panels/services-panel.ts +68 -22
  100. package/src/panels/session-browser-panel.ts +42 -26
  101. package/src/panels/session-maintenance.ts +2 -2
  102. package/src/panels/settings-sync-panel.ts +30 -15
  103. package/src/panels/skills-panel.ts +2 -1
  104. package/src/panels/subscription-panel.ts +21 -3
  105. package/src/panels/symbol-outline-panel.ts +40 -47
  106. package/src/panels/system-messages-panel.ts +60 -27
  107. package/src/panels/tasks-panel.ts +36 -14
  108. package/src/panels/thinking-panel.ts +32 -36
  109. package/src/panels/token-budget-panel.ts +80 -53
  110. package/src/panels/tool-inspector-panel.ts +37 -39
  111. package/src/panels/types.ts +25 -0
  112. package/src/panels/watchers-panel.ts +25 -5
  113. package/src/panels/work-plan-panel.ts +127 -35
  114. package/src/panels/worktree-panel.ts +65 -20
  115. package/src/panels/wrfc-panel-format.ts +133 -0
  116. package/src/panels/wrfc-panel.ts +53 -147
  117. package/src/renderer/agent-detail-modal.ts +2 -2
  118. package/src/renderer/compaction-preview.ts +2 -1
  119. package/src/renderer/compositor.ts +46 -43
  120. package/src/renderer/modal-utils.ts +0 -10
  121. package/src/renderer/model-picker-overlay.ts +9 -4
  122. package/src/renderer/model-workspace.ts +2 -3
  123. package/src/renderer/panel-composite.ts +7 -20
  124. package/src/renderer/panel-workspace-bar.ts +14 -8
  125. package/src/renderer/process-modal.ts +2 -2
  126. package/src/renderer/progress.ts +3 -2
  127. package/src/renderer/tab-strip.ts +148 -34
  128. package/src/renderer/ui-factory.ts +4 -6
  129. package/src/runtime/bootstrap-command-context.ts +3 -0
  130. package/src/runtime/bootstrap-command-parts.ts +3 -1
  131. package/src/runtime/bootstrap-core.ts +31 -31
  132. package/src/runtime/bootstrap-shell.ts +1 -0
  133. package/src/runtime/onboarding/apply.ts +4 -3
  134. package/src/runtime/onboarding/snapshot.ts +4 -3
  135. package/src/runtime/process-lifecycle.ts +195 -0
  136. package/src/runtime/services.ts +4 -1
  137. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  138. package/src/runtime/ui/model-picker/types.ts +4 -0
  139. package/src/runtime/wrfc-persistence.ts +20 -5
  140. package/src/shell/ui-openers.ts +3 -2
  141. package/src/utils/format-duration.ts +55 -0
  142. package/src/utils/format-number.ts +71 -0
  143. package/src/verification/live-verifier.ts +4 -3
  144. package/src/version.ts +1 -1
  145. package/src/core/context-auto-compact.ts +0 -110
  146. package/src/panels/panel-picker.ts +0 -106
  147. package/src/renderer/panel-picker-overlay.ts +0 -202
  148. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -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
 
@@ -142,7 +145,8 @@ export abstract class BasePanel implements Panel {
142
145
  */
143
146
  protected renderLoadingLine(width: number, frame = 0): Line | null {
144
147
  if (this.loadingState !== 'loading') return null;
145
- const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0]!;
148
+ const idx = (frame || Math.floor(Date.now() / 100)) % SPINNER_FRAMES.length;
149
+ const spinner = SPINNER_FRAMES[idx] ?? SPINNER_FRAMES[0]!;
146
150
  const text = ` ${spinner} ${this._loadingLabel}`;
147
151
  return UIFactory.stringToLine(fitDisplay(text, width), width, { fg: '135', bold: true });
148
152
  }
@@ -188,6 +192,27 @@ export abstract class BasePanel implements Panel {
188
192
 
189
193
  abstract render(width: number, height: number): Line[];
190
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
+
191
216
  /** R2: Mark this panel dirty — it will be re-rendered on the next compositor frame. */
192
217
  public invalidate(): void { this.needsRender = true; }
193
218
 
@@ -46,8 +46,9 @@ export function registerAgentPanels(manager: PanelManager, deps: ResolvedBuiltin
46
46
  deps.sessionMemoryStore,
47
47
  deps.configManager,
48
48
  deps.getOrchestratorUsage,
49
- deps.contextWindow,
49
+ deps.getCtxWindow ?? deps.contextWindow,
50
50
  requireUiServices(deps).readModels.session,
51
+ () => deps.orchestrator?.lastInputTokens ?? 0,
51
52
  ),
52
53
  });
53
54
 
@@ -63,6 +64,7 @@ export function registerAgentPanels(manager: PanelManager, deps: ResolvedBuiltin
63
64
  return new AgentLogsPanel(ui.events.agents, {
64
65
  agentManager: ui.agents.agentManager,
65
66
  workingDirectory: ui.environment.workingDirectory,
67
+ requestRender: deps.requestRender,
66
68
  });
67
69
  },
68
70
  });
@@ -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({
@@ -52,6 +52,7 @@ export function registerDevelopmentPanels(manager: PanelManager, deps: ResolvedB
52
52
  agentMessageBus: ui.agents.agentMessageBus,
53
53
  workingDirectory: ui.environment.workingDirectory,
54
54
  cancelAgent: (agentId: string) => ui.agents.agentManager.cancel(agentId),
55
+ requestRender: deps.requestRender,
55
56
  });
56
57
  },
57
58
  });
@@ -119,7 +119,7 @@ export function registerSessionPanels(manager: PanelManager, deps: ResolvedBuilt
119
119
  category: 'monitoring',
120
120
  description: 'Token budget tracker: per-turn and cumulative usage with context window gauge',
121
121
  factory: () => {
122
- const panel = new TokenBudgetPanel(deps.sessionMemoryStore, deps.configManager);
122
+ const panel = new TokenBudgetPanel(deps.sessionMemoryStore, deps.configManager, deps.requestRender);
123
123
  if (deps.orchestrator && deps.getCtxWindow) {
124
124
  panel.wire(deps.orchestrator, deps.getCtxWindow, requireUiServices(deps).readModels.session);
125
125
  }
@@ -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,