@pellux/goodvibes-tui 0.27.0 → 0.29.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 (143) hide show
  1. package/CHANGELOG.md +124 -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/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,38 +1,34 @@
1
1
  import type { Line } from '../types/grid.ts';
2
+ import { truncateDisplay } from '../utils/terminal-width.ts';
2
3
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
3
4
  import type { TokenAuditResult } from '@pellux/goodvibes-sdk/platform/security';
4
5
  import type { UiReadModel, UiSecuritySnapshot } from '../runtime/ui-read-models.ts';
5
6
  import {
7
+ buildAlignedRow,
6
8
  buildEmptyState,
7
- buildGuidanceLine,
9
+ buildKeyboardHints,
8
10
  buildPanelLine,
9
11
  buildPanelWorkspace,
10
12
  buildStatusPill,
11
13
  DEFAULT_PANEL_PALETTE,
12
14
  } from './polish.ts';
13
15
  import { createEmptyLine } from '../types/grid.ts';
16
+ import type { PanelIntegrationContext } from './types.ts';
14
17
 
15
- const C = {
16
- ...DEFAULT_PANEL_PALETTE,
17
- header: '#94a3b8',
18
- headerBg: '#1e293b',
19
- dim: '#475569',
20
- ok: '#22c55e',
21
- warn: '#eab308',
22
- error: '#ef4444',
23
- selectBg: '#0f172a',
24
- } as const;
18
+ // Base chrome only — title band, state colors, and text tokens all come
19
+ // straight from DEFAULT_PANEL_PALETTE (WO-002).
20
+ const C = DEFAULT_PANEL_PALETTE;
25
21
 
26
22
  function managedColor(managed: boolean): string {
27
23
  return managed ? C.warn : C.info;
28
24
  }
29
25
 
30
26
  function resultColor(result: TokenAuditResult): string {
31
- if (result.blocked) return C.error;
32
- if (result.scope.outcome === 'violation') return C.error;
33
- if (result.rotation.outcome === 'overdue') return C.error;
27
+ if (result.blocked) return C.bad;
28
+ if (result.scope.outcome === 'violation') return C.bad;
29
+ if (result.rotation.outcome === 'overdue') return C.bad;
34
30
  if (result.rotation.outcome === 'warning') return C.warn;
35
- return C.ok;
31
+ return C.good;
36
32
  }
37
33
 
38
34
  function resultSummary(result: TokenAuditResult): string {
@@ -48,21 +44,58 @@ function severityColor(severity: 'low' | 'medium' | 'high' | 'critical'): string
48
44
  switch (severity) {
49
45
  case 'critical':
50
46
  case 'high':
51
- return C.error;
47
+ return C.bad;
52
48
  case 'medium':
53
49
  return C.warn;
54
50
  case 'low':
55
51
  default:
56
- return C.ok;
52
+ return C.good;
57
53
  }
58
54
  }
59
55
 
56
+ // Relative-time formatting for audit/rotation timestamps — humanized instead
57
+ // of raw ISO strings (WO-137). Mirrors the fmtAgo pattern already used by
58
+ // approval-panel.ts / debug-panel.ts / communication-panel.ts.
59
+ function fmtAgo(ts: number): string {
60
+ const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
61
+ if (sec < 5) return 'just now';
62
+ if (sec < 60) return `${sec}s ago`;
63
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
64
+ if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
65
+ return `${Math.floor(sec / 86400)}d ago`;
66
+ }
67
+
68
+ function fmtDue(msUntilDue: number): string {
69
+ const overdue = msUntilDue < 0;
70
+ const sec = Math.floor(Math.abs(msUntilDue) / 1000);
71
+ const unit = sec < 3600
72
+ ? `${Math.max(1, Math.floor(sec / 60))}m`
73
+ : sec < 86400
74
+ ? `${Math.floor(sec / 3600)}h`
75
+ : `${Math.floor(sec / 86400)}d`;
76
+ return overdue ? `overdue by ${unit}` : `in ${unit}`;
77
+ }
78
+
79
+ // Attack-path finding rows: 3 rendered lines each (severity/route, reason,
80
+ // evidence). Bounds the per-render window to the panel's actual height
81
+ // instead of a fixed "3 findings" cap, and pages through the rest via
82
+ // attackPathScroll ('[' / ']').
83
+ const ATTACK_PATH_FINDING_LINES = 3;
84
+
60
85
  export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
61
86
  private readonly unsub: (() => void) | null;
87
+ /** Scroll offset into attackPathReview.findings (in finding units, not lines). */
88
+ private attackPathScroll = 0;
89
+ /** Set by 'f'; consumed by handlePanelIntegrationAction to dispatch /policy preflight. */
90
+ private pendingPreflight = false;
91
+ /** Set by 'i'; consumed by handlePanelIntegrationAction to jump to the incident panel. */
92
+ private pendingIncidentJump = false;
62
93
 
63
94
  public constructor(private readonly readModel: UiReadModel<UiSecuritySnapshot>) {
64
- super('security', 'Security', 'U', 'monitoring');
95
+ super('security', 'Security', '', 'security-policy');
65
96
  this.showSelectionGutter = true; // I5: non-color selection affordance
97
+ this.filterEnabled = true;
98
+ this.filterLabel = 'Filter tokens';
66
99
  this.unsub = this.readModel.subscribe(() => this.markDirty());
67
100
  }
68
101
 
@@ -73,9 +106,13 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
73
106
  protected override getPalette() { return C; }
74
107
  protected override getEmptyStateMessage() { return ' No API tokens are registered with the security auditor yet.'; }
75
108
  protected override getEmptyStateActions() {
109
+ // WO-160: '/policy preflight' dropped — 'f' already dispatches it for
110
+ // real (see handleInput/handlePanelIntegrationAction) and is advertised
111
+ // as a live key hint in the footer even in this empty state, so listing
112
+ // it here too was a redundant action substitute. '/storage review' and
113
+ // '/mcp trust' stay: neither has an in-panel key equivalent.
76
114
  return [
77
115
  { command: '/storage review', summary: 'inspect secure secret storage and environment overrides' },
78
- { command: '/policy preflight', summary: 'run a live preflight posture review' },
79
116
  { command: '/mcp trust', summary: 'inspect active MCP trust and quarantine posture' },
80
117
  ];
81
118
  }
@@ -85,24 +122,84 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
85
122
  }
86
123
 
87
124
  protected renderItem(result: TokenAuditResult, index: number, selected: boolean, width: number): Line {
88
- const bg = selected ? C.selectBg : undefined;
89
- return buildPanelLine(width, [
90
- [' ', C.label, bg],
91
- [result.label.padEnd(22), C.value, bg],
92
- [` ${result.tokenId.padEnd(12)}`, C.info, bg],
93
- [` ${result.scope.policyId.padEnd(10)}`, C.label, bg],
94
- [` ${resultSummary(result).slice(0, Math.max(0, width - 49))}`, resultColor(result), bg],
95
- ]);
125
+ return buildAlignedRow(
126
+ width,
127
+ [
128
+ { text: result.label, fg: C.value },
129
+ { text: result.tokenId, fg: C.info },
130
+ { text: result.scope.policyId, fg: C.label },
131
+ { text: resultSummary(result), fg: resultColor(result) },
132
+ ],
133
+ [
134
+ { width: 22 },
135
+ { width: 12 },
136
+ { width: 10 },
137
+ { width: Math.max(8, width - 50) },
138
+ ],
139
+ { selected, selectedBg: C.selectBg },
140
+ );
96
141
  }
97
142
 
98
143
  public handleInput(key: string): boolean {
99
- if (key === 'r') {
144
+ if (!this.filterActive && key === 'r') {
145
+ // Real re-audit: force the read model to recompute (ApiTokenAuditor.auditAll
146
+ // under the hood) right now instead of waiting on the next incidental
147
+ // render, so lastAuditAt visibly advances the instant 'r' is pressed.
148
+ this.readModel.getSnapshot();
149
+ this.markDirty();
150
+ return true;
151
+ }
152
+ if (!this.filterActive && key === 'f') {
153
+ this.pendingPreflight = true;
154
+ this.markDirty();
155
+ return true;
156
+ }
157
+ if (!this.filterActive && key === 'i') {
158
+ if (!this.readModel.getSnapshot().latestIncident) return false;
159
+ this.pendingIncidentJump = true;
160
+ this.markDirty();
161
+ return true;
162
+ }
163
+ if (!this.filterActive && key === '[') {
164
+ this.attackPathScroll = Math.max(0, this.attackPathScroll - 1);
165
+ this.markDirty();
166
+ return true;
167
+ }
168
+ if (!this.filterActive && key === ']') {
169
+ this.attackPathScroll += 1;
100
170
  this.markDirty();
101
171
  return true;
102
172
  }
103
173
  return super.handleInput(key);
104
174
  }
105
175
 
176
+ /**
177
+ * f (preflight) and i (jump to incident) both require the integration
178
+ * context (executeCommand / panelManager), which is only available here —
179
+ * same staged-pending-action pattern as worktree-panel.ts and
180
+ * incident-review-panel.ts.
181
+ */
182
+ public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
183
+ if (this.pendingPreflight) {
184
+ this.pendingPreflight = false;
185
+ if (!ctx.executeCommand) return false;
186
+ void ctx.executeCommand('policy', ['preflight']).catch(() => { /* surfaced via /policy output */ });
187
+ return true;
188
+ }
189
+ if (this.pendingIncidentJump) {
190
+ this.pendingIncidentJump = false;
191
+ ctx.panelManager.open('incident');
192
+ return true;
193
+ }
194
+ return false;
195
+ }
196
+
197
+ protected override filterMatches(result: TokenAuditResult, q: string): boolean {
198
+ return result.label.toLowerCase().includes(q)
199
+ || result.scope.policyId.toLowerCase().includes(q)
200
+ || result.tokenId.toLowerCase().includes(q);
201
+ }
202
+
106
203
  public render(width: number, height: number): Line[] {
107
204
  this.clampSelection();
108
205
  const snapshot = this.readModel.getSnapshot();
@@ -118,7 +215,10 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
118
215
  const untrustedPlugins = snapshot.untrustedPlugins;
119
216
  const attackPathReview = snapshot.attackPathReview;
120
217
  const intro = 'Token audit, policy posture, MCP attack-path review, plugin trust, and incident pressure.';
121
- const footerLine = buildGuidanceLine(width, '/policy preflight', 'run a proactive policy review before risky work starts', C);
218
+ // WO-160: dropped the printed '/policy preflight' guidance line 'f' is
219
+ // already bound to dispatch it for real (see handleInput/
220
+ // handlePanelIntegrationAction below) and is advertised in the keyboard
221
+ // hints footer, so the printed command was a pure action substitute.
122
222
 
123
223
  const governanceLines: Line[] = [
124
224
  buildPanelLine(width, [
@@ -168,7 +268,7 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
168
268
  [' critical ', C.label],
169
269
  ...buildStatusPill(attackPathReview.incoherentFindings > 0 ? 'warn' : 'good', String(attackPathReview.incoherentFindings)),
170
270
  [' review ', C.label],
171
- [attackPathReview.summary.slice(0, Math.max(0, width - 36)), C.dim],
271
+ [truncateDisplay(attackPathReview.summary, Math.max(0, width - 36)), C.dim],
172
272
  ]),
173
273
  ];
174
274
 
@@ -194,7 +294,7 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
194
294
  { title: 'Governance', lines: emptyStateLines },
195
295
  { title: 'Attack Paths', lines: attackPathLines },
196
296
  ],
197
- footerLines: [footerLine],
297
+ footerLines: [buildKeyboardHints(width, [{ keys: 'f', label: 'preflight' }], C)],
198
298
  palette: C,
199
299
  });
200
300
  while (workspace.length < height) workspace.push(createEmptyLine(width));
@@ -203,23 +303,60 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
203
303
 
204
304
  if (attackPathReview.findings.length > 0) {
205
305
  attackPathLines.push(buildPanelLine(width, [[' MCP attack-path review', C.label]]));
206
- for (const finding of attackPathReview.findings.slice(0, 3)) {
306
+ // Fully scrollable no fixed "3 findings" cap. The visible window
307
+ // scales with the panel's actual height; '[' / ']' page through the
308
+ // rest when there are more findings than currently fit.
309
+ const windowSize = Math.max(1, Math.floor((height - 16) / ATTACK_PATH_FINDING_LINES));
310
+ const maxScroll = Math.max(0, attackPathReview.findings.length - windowSize);
311
+ this.attackPathScroll = Math.min(this.attackPathScroll, maxScroll);
312
+ const start = this.attackPathScroll;
313
+ const end = Math.min(attackPathReview.findings.length, start + windowSize);
314
+ for (const finding of attackPathReview.findings.slice(start, end)) {
207
315
  attackPathLines.push(buildPanelLine(width, [[
208
- ` ${finding.severity.toUpperCase()} ${finding.serverName}: ${finding.route}`.slice(0, width),
316
+ truncateDisplay(` ${finding.severity.toUpperCase()} ${finding.serverName}: ${finding.route}`, width),
209
317
  severityColor(finding.severity),
210
318
  ]]));
211
319
  attackPathLines.push(buildPanelLine(width, [[
212
- ` ${finding.reason}`.slice(0, width),
320
+ truncateDisplay(` ${finding.reason}`, width),
213
321
  C.dim,
214
322
  ]]));
215
323
  attackPathLines.push(buildPanelLine(width, [[
216
- ` evidence: ${finding.evidence.join(' | ')}`.slice(0, width),
324
+ truncateDisplay(` evidence: ${finding.evidence.join(' | ')}`, width),
325
+ C.dim,
326
+ ]]));
327
+ }
328
+ if (attackPathReview.findings.length > windowSize) {
329
+ attackPathLines.push(buildPanelLine(width, [[
330
+ ` showing ${start + 1}-${end} of ${attackPathReview.findings.length} findings ([ / ] to scroll)`,
217
331
  C.dim,
218
332
  ]]));
219
333
  }
220
334
  }
221
335
 
222
- const selected = view.results[this.selectedIndex];
336
+ // Column header for the token audit list so the aligned columns are legible.
337
+ const listHeader: Line[] = [
338
+ ...governanceLines,
339
+ buildAlignedRow(
340
+ width,
341
+ [
342
+ { text: 'TOKEN LABEL', fg: C.label, bold: true },
343
+ { text: 'TOKEN ID', fg: C.label, bold: true },
344
+ { text: 'POLICY', fg: C.label, bold: true },
345
+ { text: `STATUS (${view.results.length} audited)`, fg: C.label, bold: true },
346
+ ],
347
+ [
348
+ { width: 22 },
349
+ { width: 12 },
350
+ { width: 10 },
351
+ { width: Math.max(8, width - 50) },
352
+ ],
353
+ {},
354
+ ),
355
+ ];
356
+
357
+ // Detail must track the filtered view the list highlights, not the raw
358
+ // audit results — under an applied token filter they diverge.
359
+ const selected = this.getSelectedItem();
223
360
  const detailLines: Line[] = [];
224
361
  if (selected) {
225
362
  detailLines.push(buildPanelLine(width, [
@@ -228,68 +365,83 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
228
365
  [' Policy: ', C.label],
229
366
  [selected.scope.policyId, C.info],
230
367
  [' Blocked: ', C.label],
231
- [selected.blocked ? 'yes' : 'no', selected.blocked ? C.error : C.ok],
368
+ [selected.blocked ? 'yes' : 'no', selected.blocked ? C.bad : C.good],
232
369
  ]));
233
370
  detailLines.push(buildPanelLine(width, [
234
371
  [' Scope: ', C.label],
235
- [selected.scope.outcome, selected.scope.outcome === 'violation' ? C.error : C.ok],
372
+ [selected.scope.outcome, selected.scope.outcome === 'violation' ? C.bad : C.good],
236
373
  [' Excess: ', C.label],
237
- [(selected.scope.excessScopes.length > 0 ? selected.scope.excessScopes.join(', ') : 'none').slice(0, Math.max(0, width - 27)), selected.scope.excessScopes.length > 0 ? C.error : C.dim],
374
+ [truncateDisplay(selected.scope.excessScopes.length > 0 ? selected.scope.excessScopes.join(', ') : 'none', Math.max(0, width - 27)), selected.scope.excessScopes.length > 0 ? C.bad : C.dim],
238
375
  ]));
239
376
  detailLines.push(buildPanelLine(width, [
240
377
  [' Rotation: ', C.label],
241
- [selected.rotation.outcome, selected.rotation.outcome === 'ok' ? C.ok : selected.rotation.outcome === 'warning' ? C.warn : C.error],
378
+ [selected.rotation.outcome, selected.rotation.outcome === 'ok' ? C.good : selected.rotation.outcome === 'warning' ? C.warn : C.bad],
242
379
  [' Due: ', C.label],
243
- [new Date(selected.rotation.dueAt).toISOString(), C.value],
380
+ [fmtDue(selected.rotation.msUntilDue), selected.rotation.msUntilDue < 0 ? C.bad : C.value],
244
381
  [' Age(d): ', C.label],
245
382
  [String(Math.floor(selected.rotation.ageMs / (24 * 60 * 60 * 1000))), C.value],
246
383
  ]));
247
384
  detailLines.push(buildPanelLine(width, [[
248
- `Last audit: ${view.lastAuditAt ? new Date(view.lastAuditAt).toISOString() : 'never'} Press r to refresh.`,
385
+ `Last audit: ${view.lastAuditAt ? fmtAgo(view.lastAuditAt) : 'never'} Press r to refresh.`,
249
386
  C.dim,
250
387
  ]]));
251
388
  if (preflightStatus !== 'n/a') {
252
389
  detailLines.push(buildPanelLine(width, [[
253
- `Policy preflight: ${preflightStatus} (${preflightIssueCount} issue${preflightIssueCount === 1 ? '' : 's'})`.slice(0, width),
254
- preflightStatus === 'block' ? C.error : preflightStatus === 'warn' ? C.warn : C.dim,
390
+ truncateDisplay(`Policy preflight: ${preflightStatus} (${preflightIssueCount} issue${preflightIssueCount === 1 ? '' : 's'})`, width),
391
+ preflightStatus === 'block' ? C.bad : preflightStatus === 'warn' ? C.warn : C.dim,
255
392
  ]]));
256
393
  }
257
394
  if (quarantinedMcp.length > 0) {
258
395
  const server = quarantinedMcp[0]!;
259
396
  detailLines.push(buildPanelLine(width, [[
260
- `MCP quarantine: ${server.name} ${server.quarantineReason ?? 'unknown'}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`.slice(0, width),
261
- C.error,
397
+ truncateDisplay(`MCP quarantine: ${server.name} ${server.quarantineReason ?? 'unknown'}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`, width),
398
+ C.bad,
262
399
  ]]));
263
400
  }
264
401
  if (quarantinedPlugins.length > 0) {
265
402
  const plugin = quarantinedPlugins[0]!;
266
403
  detailLines.push(buildPanelLine(width, [[
267
- `Plugin quarantine: ${plugin.name} (${plugin.trustTier})`.slice(0, width),
268
- C.error,
404
+ truncateDisplay(`Plugin quarantine: ${plugin.name} (${plugin.trustTier})`, width),
405
+ C.bad,
269
406
  ]]));
270
407
  } else if (untrustedPlugins.length > 0) {
271
408
  const plugin = untrustedPlugins[0]!;
272
409
  detailLines.push(buildPanelLine(width, [[
273
- `Plugin trust warning: ${plugin.name} remains untrusted.`.slice(0, width),
410
+ truncateDisplay(`Plugin trust warning: ${plugin.name} remains untrusted.`, width),
274
411
  C.warn,
275
412
  ]]));
276
413
  }
277
414
  if (latestIncident) {
278
415
  detailLines.push(buildPanelLine(width, [[
279
- `Latest incident: ${latestIncident.classification} - ${latestIncident.summary}`.slice(0, width),
416
+ truncateDisplay(`Latest incident: ${latestIncident.classification} - ${latestIncident.summary}`, width),
280
417
  C.warn,
281
418
  ]]));
282
419
  }
283
420
  }
284
421
 
422
+ const hints = this.filterActive
423
+ ? [
424
+ { keys: 'type', label: 'filter tokens' },
425
+ { keys: 'Enter', label: 'apply' },
426
+ { keys: 'Esc', label: 'clear' },
427
+ ]
428
+ : [
429
+ { keys: '↑/↓', label: 'select token' },
430
+ { keys: '/', label: 'filter' },
431
+ { keys: 'r', label: 'refresh audit' },
432
+ { keys: 'f', label: 'preflight' },
433
+ ...(latestIncident ? [{ keys: 'i', label: 'jump to incident' }] : []),
434
+ ...(attackPathReview.findings.length > 0 ? [{ keys: '[ / ]', label: 'scroll attack paths' }] : []),
435
+ ];
436
+
285
437
  return this.renderList(width, height, {
286
438
  title: 'Security Control Room',
287
- header: governanceLines,
439
+ header: listHeader,
288
440
  footer: [
289
441
  ...detailLines,
290
442
  ...attackPathLines,
291
- footerLine,
292
443
  ],
444
+ hints,
293
445
  });
294
446
  }
295
447
  }