@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,28 +1,34 @@
1
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
1
2
  import type { Line } from '../types/grid.ts';
2
3
  import { createEmptyLine } from '../types/grid.ts';
4
+ import { truncateDisplay } from '../utils/terminal-width.ts';
3
5
  import { BasePanel } from './base-panel.ts';
4
- import type { PolicyRuntimeState } from '@/runtime/index.ts';
5
- import type { PolicyPanelSnapshot } from '../runtime/diagnostics/panels/policy.ts';
6
+ import type {
7
+ PolicyRuntimeState,
8
+ PolicyBundleVersion,
9
+ PolicyDiffResult,
10
+ DivergenceDashboardSnapshot,
11
+ DivergenceStats,
12
+ PermissionAuditEntry,
13
+ PolicyLintFinding,
14
+ PolicySimulationSummary,
15
+ PolicyPreflightReview,
16
+ } from '@/runtime/index.ts';
6
17
  import {
18
+ buildEmptyState,
19
+ buildKeyboardHints,
20
+ buildKeyValueLine,
7
21
  buildPanelLine,
8
22
  buildPanelWorkspace,
9
23
  resolveScrollablePanelSection,
10
24
  DEFAULT_PANEL_PALETTE,
11
25
  } from './polish.ts';
26
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
27
+ import type { PanelIntegrationContext } from './types.ts';
12
28
 
13
- const C = {
14
- ...DEFAULT_PANEL_PALETTE,
15
- header: '#94a3b8',
16
- headerBg: '#1e293b',
17
- label: '#64748b',
18
- value: '#e2e8f0',
19
- dim: '#475569',
20
- ok: '#22c55e',
21
- warn: '#eab308',
22
- error: '#ef4444',
23
- info: '#38bdf8',
24
- empty: '#334155',
25
- } as const;
29
+ // Base chrome only — no domain accents needed; the title band, state colors,
30
+ // and text tokens all come straight from DEFAULT_PANEL_PALETTE (WO-002).
31
+ const C = DEFAULT_PANEL_PALETTE;
26
32
 
27
33
  function fmtTime(value: string | undefined): string {
28
34
  if (!value) return 'n/a';
@@ -37,9 +43,9 @@ function fmtRate(value: number | undefined): string {
37
43
  function gateColor(status: string | undefined): string {
38
44
  switch (status) {
39
45
  case 'allowed':
40
- return C.ok;
46
+ return C.good;
41
47
  case 'blocked':
42
- return C.error;
48
+ return C.bad;
43
49
  case 'no_data':
44
50
  return C.warn;
45
51
  default:
@@ -47,13 +53,49 @@ function gateColor(status: string | undefined): string {
47
53
  }
48
54
  }
49
55
 
56
+ /**
57
+ * A point-in-time snapshot of policy state for diagnostics rendering.
58
+ */
59
+ interface PolicyPanelSnapshot {
60
+ /** The currently enforced bundle, or null if no policy is active. */
61
+ current: PolicyBundleVersion | null;
62
+ /** The pending candidate bundle, or null if none loaded. */
63
+ candidate: PolicyBundleVersion | null;
64
+ /** History of previous active bundles (most recent first). */
65
+ history: PolicyBundleVersion[];
66
+ /** Diff between current and candidate, or null if unavailable. */
67
+ diff: PolicyDiffResult | null;
68
+ /** Divergence dashboard snapshot, or null if no panel attached. */
69
+ divergence: DivergenceDashboardSnapshot | null;
70
+ /** Recent permission requests and decisions for operator audit review. */
71
+ recentPermissionAudit: readonly PermissionAuditEntry[];
72
+ /** Policy lint findings for current and candidate bundles. */
73
+ lintFindings: readonly PolicyLintFinding[];
74
+ /** Concrete scenario results from the most recent policy simulation run. */
75
+ lastSimulationSummary: PolicySimulationSummary | null;
76
+ /** Most recent proactive policy preflight review. */
77
+ lastPreflightReview: PolicyPreflightReview | null;
78
+ /** ISO 8601 timestamp of when this snapshot was captured. */
79
+ capturedAt: string;
80
+ }
81
+
82
+ /** Subactions dispatched through `/policy <name>` via `dispatchPolicyCommand`. */
83
+ type PolicyDispatchAction = 'simulate' | 'preflight' | 'lint' | 'promote' | 'rollback';
84
+
50
85
  export class PolicyPanel extends BasePanel {
51
86
  private readonly _state: PolicyRuntimeState;
52
87
  private readonly _unsub: (() => void) | null;
53
88
  private _scrollOffset = 0;
54
89
 
90
+ // Inline confirm for the gate-aware promote/rollback actions.
91
+ private _confirm: ConfirmState<PolicyDispatchAction> | null = null;
92
+
93
+ // Pending dispatch resolved via handlePanelIntegrationAction, which is the
94
+ // only place `executeCommand` is available (mirrors IncidentReviewPanel).
95
+ private _pendingAction: PolicyDispatchAction | null = null;
96
+
55
97
  public constructor(state: PolicyRuntimeState) {
56
- super('policy', 'Policy', 'U', 'monitoring');
98
+ super('policy', 'Policy', '', 'security-policy');
57
99
  this._state = state;
58
100
  this._unsub = state.subscribe(() => this.markDirty());
59
101
  }
@@ -68,6 +110,22 @@ export class PolicyPanel extends BasePanel {
68
110
  }
69
111
 
70
112
  public handleInput(key: string): boolean {
113
+ if (this._confirm) {
114
+ const outcome = handleConfirmInput(this._confirm, key);
115
+ if (outcome === 'confirmed') {
116
+ this._pendingAction = this._confirm.subject;
117
+ this._confirm = null;
118
+ this.markDirty();
119
+ return true;
120
+ }
121
+ if (outcome === 'cancelled') {
122
+ this._confirm = null;
123
+ this.markDirty();
124
+ return true;
125
+ }
126
+ return true; // absorbed — keep the confirm dialog pending
127
+ }
128
+
71
129
  if (key === 'up' || key === 'k') {
72
130
  this._scrollOffset = Math.max(0, this._scrollOffset - 1);
73
131
  this.markDirty();
@@ -78,7 +136,45 @@ export class PolicyPanel extends BasePanel {
78
136
  this.markDirty();
79
137
  return true;
80
138
  }
139
+ if (key === 's') {
140
+ this._pendingAction = 'simulate';
141
+ return true;
142
+ }
143
+ if (key === 'f') {
144
+ this._pendingAction = 'preflight';
145
+ return true;
146
+ }
147
+ if (key === 'l') {
148
+ this._pendingAction = 'lint';
149
+ return true;
150
+ }
151
+ if (key === 'p') {
152
+ const snapshot = this._state.getSnapshot();
153
+ if (!snapshot.candidate) return false;
154
+ const gateStatus = snapshot.divergence?.gate.status ?? 'no_data';
155
+ this._confirm = {
156
+ subject: 'promote',
157
+ label: `promote ${snapshot.candidate.bundle.bundleId} (gate: ${gateStatus})`,
158
+ verb: 'Promote',
159
+ };
160
+ this.markDirty();
161
+ return true;
162
+ }
163
+ if (key === 'b') {
164
+ const snapshot = this._state.getSnapshot();
165
+ if (!snapshot.current) return false;
166
+ this._confirm = {
167
+ subject: 'rollback',
168
+ label: `rollback active bundle ${snapshot.current.bundle.bundleId}`,
169
+ verb: 'Rollback',
170
+ };
171
+ this.markDirty();
172
+ return true;
173
+ }
81
174
  if (key === 'r') {
175
+ // Trend-record is only meaningful once a simulation dashboard exists;
176
+ // otherwise there is nothing to sample and the key is a no-op.
177
+ if (!this._state.getDashboard()) return false;
82
178
  this._state.recordTrendEntry();
83
179
  this.markDirty();
84
180
  return true;
@@ -86,25 +182,60 @@ export class PolicyPanel extends BasePanel {
86
182
  return false;
87
183
  }
88
184
 
185
+ public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
186
+ if (!this._pendingAction) return false;
187
+ const action = this._pendingAction;
188
+ this._pendingAction = null;
189
+ void ctx.executeCommand?.('policy', [action]).catch((err) => {
190
+ logger.debug('policy panel command dispatch failed', { err, action });
191
+ });
192
+ return true;
193
+ }
194
+
89
195
  public render(width: number, height: number): Line[] {
90
196
  this.needsRender = false;
197
+ if (this._confirm) {
198
+ const lines = buildPanelWorkspace(width, height, {
199
+ title: 'Policy And Governance',
200
+ sections: [{ title: 'Confirmation', lines: renderConfirmLines(width, this._confirm) }],
201
+ palette: C,
202
+ });
203
+ while (lines.length < height) {
204
+ lines.push(createEmptyLine(width));
205
+ }
206
+ return lines.slice(0, height);
207
+ }
91
208
  const snapshot = this._state.getSnapshot();
209
+ const summaryLine = this._buildSummary(width, snapshot);
92
210
  const content = this._buildContent(width, snapshot);
211
+ const dashboardActive = snapshot.divergence !== null;
212
+ const hintsLine = buildKeyboardHints(width, [
213
+ { keys: '↑/↓', label: 'scroll' },
214
+ { keys: 's', label: 'simulate' },
215
+ { keys: 'f', label: 'preflight' },
216
+ { keys: 'l', label: 'lint' },
217
+ { keys: 'p', label: 'promote' },
218
+ { keys: 'b', label: 'rollback' },
219
+ ...(dashboardActive ? [{ keys: 'r', label: 'record divergence snapshot' }] : []),
220
+ ], C);
221
+ const summarySection = { lines: [summaryLine] };
93
222
  const governanceSection = resolveScrollablePanelSection(width, height, {
94
- footerLines: [buildPanelLine(width, [[' Up/Down scroll r record divergence trend snapshot', C.dim]])],
223
+ footerLines: [hintsLine],
95
224
  palette: C,
225
+ beforeSections: [summarySection],
96
226
  section: {
97
227
  title: 'Governance',
98
228
  scrollableLines: content,
99
229
  scrollOffset: this._scrollOffset,
100
230
  minRows: 4,
231
+ appendWindowSummary: content.length > 0 ? { dimColor: C.dim } : undefined,
101
232
  },
102
233
  });
103
234
  this._scrollOffset = governanceSection.scrollOffset;
104
235
  const lines = buildPanelWorkspace(width, height, {
105
236
  title: 'Policy And Governance',
106
- sections: [governanceSection.section],
107
- footerLines: [buildPanelLine(width, [[' Up/Down scroll r record divergence trend snapshot', C.dim]])],
237
+ sections: [summarySection, governanceSection.section],
238
+ footerLines: [hintsLine],
108
239
  palette: C,
109
240
  });
110
241
  while (lines.length < height) {
@@ -113,6 +244,28 @@ export class PolicyPanel extends BasePanel {
113
244
  return lines.slice(0, height);
114
245
  }
115
246
 
247
+ /** Top-of-panel posture summary: the highest-signal governance state at a glance. */
248
+ private _buildSummary(width: number, snapshot: PolicyPanelSnapshot): Line {
249
+ const preflight = snapshot.lastPreflightReview;
250
+ const divergence = snapshot.divergence;
251
+ const lintCount = snapshot.lintFindings.length;
252
+ const preflightStatus = preflight ? preflight.status : 'none';
253
+ const preflightColor = !preflight
254
+ ? C.dim
255
+ : preflight.status === 'pass'
256
+ ? C.good
257
+ : preflight.status === 'warn'
258
+ ? C.warn
259
+ : C.bad;
260
+ const gateStatus = divergence?.gate.status ?? 'n/a';
261
+ return buildKeyValueLine(width, [
262
+ { label: 'bundles', value: `${snapshot.current ? 1 : 0}+${snapshot.candidate ? 1 : 0}c`, valueColor: snapshot.current || snapshot.candidate ? C.value : C.dim },
263
+ { label: 'preflight', value: preflightStatus.toUpperCase(), valueColor: preflightColor },
264
+ { label: 'gate', value: gateStatus, valueColor: gateColor(gateStatus) },
265
+ { label: 'lint', value: String(lintCount), valueColor: lintCount > 0 ? C.warn : C.dim },
266
+ ], C);
267
+ }
268
+
116
269
  private _buildContent(width: number, snapshot: PolicyPanelSnapshot): Line[] {
117
270
  const lines: Line[] = [];
118
271
  const current = snapshot.current;
@@ -123,8 +276,22 @@ export class PolicyPanel extends BasePanel {
123
276
  const simulationSummary = snapshot.lastSimulationSummary;
124
277
  const preflightReview = snapshot.lastPreflightReview;
125
278
 
126
- if (!current && !candidate) {
127
- lines.push(buildPanelLine(width, [[' No policy bundles loaded. Use /policy load to begin.', C.empty]]));
279
+ const nothingRecorded = !current && !candidate && !divergence
280
+ && snapshot.history.length === 0 && permissionAudit.length === 0
281
+ && lintFindings.length === 0 && !simulationSummary && !preflightReview;
282
+ if (nothingRecorded) {
283
+ // No divergence dashboard exists in this branch by construction
284
+ // (nothingRecorded requires !divergence), so the gate is honestly 'n/a'.
285
+ lines.push(...buildEmptyState(
286
+ width,
287
+ ' No policy bundles loaded.',
288
+ 'Bundle: none active, none candidate. Gate: n/a.',
289
+ [
290
+ { command: '/policy load', summary: 'load a policy bundle to begin governance review' },
291
+ ],
292
+ C,
293
+ ));
294
+ return lines;
128
295
  }
129
296
 
130
297
  if (current) {
@@ -160,17 +327,41 @@ export class PolicyPanel extends BasePanel {
160
327
  }
161
328
 
162
329
  if (snapshot.diff) {
330
+ const diff = snapshot.diff;
163
331
  lines.push(buildPanelLine(width, [[' Diff', C.label]]));
164
332
  lines.push(buildPanelLine(width, [
333
+ [' ', C.label],
334
+ [`${diff.fromBundleId} -> ${diff.toBundleId}`, C.value],
165
335
  [' Changes: ', C.label],
166
- [String(snapshot.diff.totalChanges), C.value],
167
- [' Added: ', C.label],
168
- [String(snapshot.diff.added.length), C.ok],
169
- [' Removed: ', C.label],
170
- [String(snapshot.diff.removed.length), C.error],
171
- [' Changed: ', C.label],
172
- [String(snapshot.diff.changed.length), C.warn],
336
+ [String(diff.totalChanges), C.value],
173
337
  ]));
338
+ if (diff.added.length > 0) {
339
+ lines.push(buildPanelLine(width, [[' Added', C.good]]));
340
+ for (const rule of diff.added.slice(0, 5)) {
341
+ lines.push(buildPanelLine(width, [
342
+ [' + ', C.good],
343
+ [truncateDisplay(`${rule.id} (${rule.type}, effect=${rule.effect})`, Math.max(0, width - 6)), C.value],
344
+ ]));
345
+ }
346
+ }
347
+ if (diff.removed.length > 0) {
348
+ lines.push(buildPanelLine(width, [[' Removed', C.bad]]));
349
+ for (const rule of diff.removed.slice(0, 5)) {
350
+ lines.push(buildPanelLine(width, [
351
+ [' - ', C.bad],
352
+ [truncateDisplay(`${rule.id} (${rule.type}, effect=${rule.effect})`, Math.max(0, width - 6)), C.value],
353
+ ]));
354
+ }
355
+ }
356
+ if (diff.changed.length > 0) {
357
+ lines.push(buildPanelLine(width, [[' Changed', C.warn]]));
358
+ for (const change of diff.changed.slice(0, 5)) {
359
+ lines.push(buildPanelLine(width, [
360
+ [' ~ ', C.warn],
361
+ [truncateDisplay(change.ruleId, Math.max(0, width - 6)), C.value],
362
+ ]));
363
+ }
364
+ }
174
365
  }
175
366
 
176
367
  if (divergence) {
@@ -189,6 +380,35 @@ export class PolicyPanel extends BasePanel {
189
380
  [' Trend points: ', C.label],
190
381
  [String(divergence.trend.length), C.value],
191
382
  ]));
383
+ const prefixEntries = Object.entries(divergence.report.byCommandPrefix);
384
+ if (prefixEntries.length > 0) {
385
+ lines.push(buildPanelLine(width, [[' Divergence by command prefix', C.label]]));
386
+ for (const [prefix, stats] of prefixEntries.slice(0, 5)) {
387
+ lines.push(buildPanelLine(width, [
388
+ [' ', C.label],
389
+ [truncateDisplay(prefix, Math.max(0, Math.floor(width * 0.4))), C.value],
390
+ [' ', C.label],
391
+ [fmtRate(stats.divergenceRate), stats.divergenceRate > 0 ? C.warn : C.good],
392
+ [' ', C.label],
393
+ [`${stats.total}/${stats.totalEvaluations}`, C.dim],
394
+ ]));
395
+ }
396
+ }
397
+ const classEntries = Object.entries(divergence.report.byToolClass) as Array<[string, DivergenceStats | undefined]>;
398
+ const populatedClassEntries = classEntries.filter((entry): entry is [string, DivergenceStats] => entry[1] !== undefined);
399
+ if (populatedClassEntries.length > 0) {
400
+ lines.push(buildPanelLine(width, [[' Divergence by tool class', C.label]]));
401
+ for (const [cls, stats] of populatedClassEntries.slice(0, 5)) {
402
+ lines.push(buildPanelLine(width, [
403
+ [' ', C.label],
404
+ [truncateDisplay(cls, Math.max(0, Math.floor(width * 0.4))), C.value],
405
+ [' ', C.label],
406
+ [fmtRate(stats.divergenceRate), stats.divergenceRate > 0 ? C.warn : C.good],
407
+ [' ', C.label],
408
+ [`${stats.total}/${stats.totalEvaluations}`, C.dim],
409
+ ]));
410
+ }
411
+ }
192
412
  }
193
413
 
194
414
  if (snapshot.history.length > 0) {
@@ -209,7 +429,7 @@ export class PolicyPanel extends BasePanel {
209
429
  lines.push(buildPanelLine(width, [[' Permission Audit', C.label]]));
210
430
  for (const entry of permissionAudit.slice(0, 5)) {
211
431
  const outcome = entry.approved === undefined ? 'pending' : entry.approved ? 'approved' : 'denied';
212
- const outcomeColor = entry.approved === undefined ? C.warn : entry.approved ? C.ok : C.error;
432
+ const outcomeColor = entry.approved === undefined ? C.warn : entry.approved ? C.good : C.bad;
213
433
  lines.push(buildPanelLine(width, [
214
434
  [' ', C.label],
215
435
  [entry.tool, C.value],
@@ -220,7 +440,7 @@ export class PolicyPanel extends BasePanel {
220
440
  ]));
221
441
  lines.push(buildPanelLine(width, [
222
442
  [' ', C.label],
223
- [entry.summary.slice(0, Math.max(0, width - 6)), C.dim],
443
+ [truncateDisplay(entry.summary, Math.max(0, width - 6)), C.dim],
224
444
  ]));
225
445
  }
226
446
  }
@@ -228,29 +448,24 @@ export class PolicyPanel extends BasePanel {
228
448
  if (lintFindings.length > 0) {
229
449
  lines.push(buildPanelLine(width, [[' Policy Lint', C.label]]));
230
450
  for (const finding of lintFindings.slice(0, 5)) {
231
- const color = finding.severity === 'error' ? C.error : finding.severity === 'warn' ? C.warn : C.info;
451
+ const color = finding.severity === 'error' ? C.bad : finding.severity === 'warn' ? C.warn : C.info;
232
452
  lines.push(buildPanelLine(width, [
233
453
  [' ', C.label],
234
454
  [finding.severity.toUpperCase(), color],
235
455
  [' ', C.label],
236
- [finding.message.slice(0, Math.max(0, width - 14)), color],
456
+ [truncateDisplay(finding.message, Math.max(0, width - 14)), color],
237
457
  ]));
238
458
  }
239
459
  }
240
460
 
241
- if (!preflightReview) {
242
- if (!current && !candidate && !divergence && snapshot.history.length === 0 && permissionAudit.length === 0 && lintFindings.length === 0 && !simulationSummary) {
243
- lines.push(buildPanelLine(width, [[' Preflight Review', C.label]]));
244
- lines.push(buildPanelLine(width, [[' No proactive preflight review recorded yet.', C.empty]]));
245
- }
246
- } else {
461
+ if (preflightReview) {
247
462
  lines.push(buildPanelLine(width, [[' Preflight Review', C.label]]));
248
463
  const statusColor =
249
464
  preflightReview.status === 'pass'
250
- ? C.ok
465
+ ? C.good
251
466
  : preflightReview.status === 'warn'
252
467
  ? C.warn
253
- : C.error;
468
+ : C.bad;
254
469
  lines.push(buildPanelLine(width, [
255
470
  [' Status: ', C.label],
256
471
  [preflightReview.status.toUpperCase(), statusColor],
@@ -261,39 +476,34 @@ export class PolicyPanel extends BasePanel {
261
476
  ]));
262
477
  lines.push(buildPanelLine(width, [
263
478
  [' ', C.label],
264
- [preflightReview.summary.slice(0, Math.max(0, width - 2)), C.dim],
479
+ [truncateDisplay(preflightReview.summary, Math.max(0, width - 2)), C.dim],
265
480
  ]));
266
481
  for (const issue of preflightReview.issues.slice(0, 4)) {
267
- const issueColor = issue.severity === 'error' ? C.error : issue.severity === 'warn' ? C.warn : C.info;
482
+ const issueColor = issue.severity === 'error' ? C.bad : issue.severity === 'warn' ? C.warn : C.info;
268
483
  lines.push(buildPanelLine(width, [
269
484
  [' ', C.label],
270
485
  [issue.severity.toUpperCase(), issueColor],
271
486
  [' ', C.label],
272
- [issue.message.slice(0, Math.max(0, width - 14)), issueColor],
487
+ [truncateDisplay(issue.message, Math.max(0, width - 14)), issueColor],
273
488
  ]));
274
489
  }
275
490
  }
276
491
 
277
- if (!simulationSummary) {
278
- if (!current && !candidate && !divergence && snapshot.history.length === 0 && permissionAudit.length === 0 && lintFindings.length === 0 && !preflightReview) {
279
- lines.push(buildPanelLine(width, [[' Simulation Samples', C.label]]));
280
- lines.push(buildPanelLine(width, [[' No concrete simulation samples recorded yet.', C.empty]]));
281
- }
282
- } else {
492
+ if (simulationSummary) {
283
493
  lines.push(buildPanelLine(width, [[' Simulation Samples', C.label]]));
284
494
  lines.push(buildPanelLine(width, [
285
495
  [' Mode: ', C.label],
286
496
  [simulationSummary.mode, C.info],
287
497
  [' Diverged: ', C.label],
288
- [`${simulationSummary.divergentScenarios}/${simulationSummary.totalScenarios}`, simulationSummary.divergentScenarios > 0 ? C.warn : C.ok],
498
+ [`${simulationSummary.divergentScenarios}/${simulationSummary.totalScenarios}`, simulationSummary.divergentScenarios > 0 ? C.warn : C.good],
289
499
  [' Allowed(actual/sim): ', C.label],
290
500
  [`${simulationSummary.allowedByActual}/${simulationSummary.allowedBySimulated}`, C.value],
291
501
  ]));
292
502
  for (const result of simulationSummary.results.slice(0, 4)) {
293
- const color = result.diverged ? C.warn : (result.authoritativeDecision.allowed ? C.ok : C.error);
503
+ const color = result.diverged ? C.warn : (result.authoritativeDecision.allowed ? C.good : C.bad);
294
504
  lines.push(buildPanelLine(width, [
295
505
  [' ', C.label],
296
- [result.scenario.label.slice(0, Math.max(0, width - 40)), C.value],
506
+ [truncateDisplay(result.scenario.label, Math.max(0, width - 40)), C.value],
297
507
  [' ', C.label],
298
508
  [(result.authoritativeDecision.allowed ? 'allow' : 'deny').toUpperCase(), color],
299
509
  [' ', C.label],
@@ -302,7 +512,6 @@ export class PolicyPanel extends BasePanel {
302
512
  }
303
513
  }
304
514
 
305
- lines.push(buildPanelLine(width, [[' /policy opens this panel. Press r to record a divergence trend snapshot.', C.dim]]));
306
515
  return lines;
307
516
  }
308
517
  }
@@ -0,0 +1,162 @@
1
+ import type { Line } from '../types/grid.ts';
2
+ import { createEmptyLine, createStyledCell } from '../types/grid.ts';
3
+ import { getDisplayWidth } from '../utils/terminal-width.ts';
4
+ import { resolveUiTones } from '../renderer/theme.ts';
5
+
6
+ // DEFAULT_PANEL_PALETTE is built from the mode-resolved chrome tones
7
+ // (resolveUiTones) rather than the static UI_TONES constant — WO-001 single
8
+ // read path. Mode is fixed to 'dark' until the terminal-bg-probe lands.
9
+ const UI_TONES = resolveUiTones('dark');
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Panel palette + core line primitives.
13
+ //
14
+ // The leaf foundation of the panel formatting toolkit: both `polish.ts` and
15
+ // `polish-tables.ts` build on these. Kept dependency-free (only grid /
16
+ // terminal-width / ui-primitives) so it never participates in an import cycle.
17
+ // All symbols are re-exported from `polish.ts` — panels import from there.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface PanelPalette {
21
+ readonly label: string;
22
+ readonly value: string;
23
+ readonly dim: string;
24
+ readonly info: string;
25
+ readonly good?: string;
26
+ readonly warn?: string;
27
+ readonly bad?: string;
28
+ readonly empty: string;
29
+ readonly header?: string;
30
+ readonly headerBg?: string;
31
+ readonly surfaceBg?: string;
32
+ readonly sectionBg?: string;
33
+ readonly summaryBg?: string;
34
+ readonly inputBg?: string;
35
+ readonly accent?: string;
36
+ readonly selectBg?: string;
37
+ }
38
+
39
+ export const DEFAULT_PANEL_PALETTE: Readonly<Required<PanelPalette>> = {
40
+ header: UI_TONES.fg.primary,
41
+ headerBg: UI_TONES.bg.title,
42
+ label: UI_TONES.fg.muted,
43
+ value: UI_TONES.fg.primary,
44
+ dim: UI_TONES.fg.dim,
45
+ info: UI_TONES.state.info,
46
+ good: UI_TONES.state.good,
47
+ warn: UI_TONES.state.warn,
48
+ bad: UI_TONES.state.bad,
49
+ empty: UI_TONES.fg.empty,
50
+ surfaceBg: UI_TONES.bg.surface,
51
+ sectionBg: UI_TONES.bg.section,
52
+ summaryBg: UI_TONES.bg.summary,
53
+ inputBg: UI_TONES.bg.input,
54
+ accent: UI_TONES.fg.secondary,
55
+ selectBg: UI_TONES.bg.selected,
56
+ } as const;
57
+
58
+ /**
59
+ * Extend the base panel palette with domain-specific colors.
60
+ *
61
+ * Convention: raw hex colors may only live inside a palette constant declared
62
+ * at the top of a panel file, not inline in render calls.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * const C = extendPalette(DEFAULT_PANEL_PALETTE, {
67
+ * decision: '#38bdf8',
68
+ * incident: '#ef4444',
69
+ * });
70
+ * ```
71
+ */
72
+ export function extendPalette<T extends Record<string, string>>(
73
+ base: typeof DEFAULT_PANEL_PALETTE,
74
+ extras: T,
75
+ ): typeof DEFAULT_PANEL_PALETTE & T {
76
+ return { ...base, ...extras };
77
+ }
78
+
79
+ export function buildPanelLine(
80
+ width: number,
81
+ segments: Array<StyledPanelSegment | [string, string, string?]>,
82
+ ): Line {
83
+ return buildStyledPanelLine(
84
+ width,
85
+ segments.map((seg) =>
86
+ Array.isArray(seg) ? { text: seg[0], fg: seg[1], bg: seg[2] } : seg,
87
+ ),
88
+ );
89
+ }
90
+
91
+ export interface StyledPanelSegment {
92
+ readonly text: string;
93
+ readonly fg: string;
94
+ readonly bg?: string;
95
+ readonly bold?: boolean;
96
+ readonly dim?: boolean;
97
+ }
98
+
99
+ export function buildSelectablePanelLine(
100
+ width: number,
101
+ segments: ReadonlyArray<StyledPanelSegment>,
102
+ options: { selected?: boolean; selectedBg?: string; fillFg?: string; fillBg?: string; leadingMarker?: string } = {},
103
+ ): Line {
104
+ const selected = options.selected ?? false;
105
+ const selectedBg = selected ? (options.selectedBg ?? DEFAULT_PANEL_PALETTE.selectBg) : '';
106
+ const fillBg = selectedBg || options.fillBg || '';
107
+ const fillFg = options.fillFg ?? '';
108
+ const cells = createEmptyLine(width);
109
+ if (fillBg) {
110
+ for (let col = 0; col < width; col++) {
111
+ cells[col] = createStyledCell(' ', { bg: fillBg, fg: fillFg });
112
+ }
113
+ }
114
+
115
+ let col = 0;
116
+ if (selected && options.leadingMarker) {
117
+ for (const ch of options.leadingMarker) {
118
+ const charWidth = getDisplayWidth(ch);
119
+ if (charWidth <= 0 || col + charWidth > width) break;
120
+ cells[col] = createStyledCell(ch, { fg: DEFAULT_PANEL_PALETTE.info, bg: selectedBg, bold: true });
121
+ if (charWidth > 1 && col + 1 < width) cells[col + 1] = createStyledCell(' ', { fg: DEFAULT_PANEL_PALETTE.info, bg: selectedBg, bold: true });
122
+ col += charWidth;
123
+ }
124
+ }
125
+ for (const segment of segments) {
126
+ const fg = segment.fg;
127
+ const bg = segment.bg ?? fillBg;
128
+ for (const ch of segment.text) {
129
+ const charWidth = getDisplayWidth(ch);
130
+ if (charWidth <= 0) continue;
131
+ if (col + charWidth > width) return cells;
132
+ cells[col] = createStyledCell(ch, {
133
+ fg,
134
+ bg,
135
+ bold: segment.bold ?? false,
136
+ dim: segment.dim ?? false,
137
+ });
138
+ if (charWidth > 1 && col + 1 < width) {
139
+ cells[col + 1] = createStyledCell(' ', {
140
+ fg,
141
+ bg,
142
+ bold: segment.bold ?? false,
143
+ dim: segment.dim ?? false,
144
+ });
145
+ }
146
+ col += charWidth;
147
+ }
148
+ }
149
+
150
+ while (col < width) {
151
+ cells[col++] = createStyledCell(' ', { bg: fillBg, fg: fillFg });
152
+ }
153
+ return cells;
154
+ }
155
+
156
+ export function buildStyledPanelLine(
157
+ width: number,
158
+ segments: ReadonlyArray<StyledPanelSegment>,
159
+ options: { fillBg?: string; fillFg?: string } = {},
160
+ ): Line {
161
+ return buildSelectablePanelLine(width, segments, options);
162
+ }