@pellux/goodvibes-tui 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/context-usage.ts +53 -0
- package/src/core/conversation.ts +4 -6
- package/src/core/session-recovery.ts +2 -0
- package/src/core/turn-event-wiring.ts +1 -0
- package/src/input/commands/eval.ts +4 -3
- package/src/input/handler-feed-routes.ts +61 -4
- package/src/input/handler-feed.ts +13 -0
- package/src/input/handler-picker-routes.ts +18 -13
- package/src/input/handler-ui-state.ts +4 -0
- package/src/input/keybindings.ts +5 -0
- package/src/input/model-picker.ts +5 -0
- package/src/input/panel-integration-actions.ts +10 -0
- package/src/main.ts +0 -1
- package/src/panels/agent-inspector-panel.ts +18 -13
- package/src/panels/agent-inspector-shared.ts +3 -3
- package/src/panels/agent-logs-panel.ts +22 -53
- package/src/panels/approval-panel.ts +146 -95
- package/src/panels/automation-control-panel.ts +24 -1
- package/src/panels/base-panel.ts +26 -2
- package/src/panels/builtin/development.ts +1 -1
- package/src/panels/cockpit-panel.ts +24 -5
- package/src/panels/cockpit-read-model.ts +2 -1
- package/src/panels/communication-panel.ts +108 -43
- package/src/panels/context-visualizer-panel.ts +25 -1
- package/src/panels/control-plane-panel.ts +27 -1
- package/src/panels/cost-tracker-panel.ts +82 -54
- package/src/panels/debug-panel.ts +51 -16
- package/src/panels/diff-panel.ts +59 -30
- package/src/panels/docs-panel.ts +19 -3
- package/src/panels/eval-panel.ts +130 -81
- package/src/panels/expandable-list-panel.ts +189 -0
- package/src/panels/file-explorer-panel.ts +40 -41
- package/src/panels/file-preview-panel.ts +9 -2
- package/src/panels/forensics-panel.ts +27 -13
- package/src/panels/git-panel.ts +58 -70
- package/src/panels/hooks-panel.ts +36 -2
- package/src/panels/incident-review-panel.ts +31 -10
- package/src/panels/intelligence-panel.ts +152 -71
- package/src/panels/knowledge-graph-panel.ts +89 -27
- package/src/panels/local-auth-panel.ts +17 -11
- package/src/panels/marketplace-panel.ts +31 -12
- package/src/panels/memory-panel.ts +5 -4
- package/src/panels/ops-control-panel.ts +69 -7
- package/src/panels/ops-strategy-panel.ts +61 -43
- package/src/panels/orchestration-panel.ts +34 -4
- package/src/panels/panel-list-panel.ts +33 -8
- package/src/panels/panel-manager.ts +23 -4
- package/src/panels/plan-dashboard-panel.ts +183 -66
- package/src/panels/plugins-panel.ts +48 -10
- package/src/panels/policy-panel.ts +60 -23
- package/src/panels/polish-core.ts +157 -0
- package/src/panels/polish-tables.ts +258 -0
- package/src/panels/polish.ts +44 -145
- package/src/panels/project-planning-panel.ts +22 -0
- package/src/panels/provider-accounts-panel.ts +55 -18
- package/src/panels/provider-health-panel.ts +65 -23
- package/src/panels/provider-stats-panel.ts +47 -22
- package/src/panels/qr-panel.ts +23 -11
- package/src/panels/remote-panel.ts +12 -5
- package/src/panels/routes-panel.ts +27 -5
- package/src/panels/sandbox-panel.ts +62 -27
- package/src/panels/schedule-panel.ts +40 -17
- package/src/panels/scrollable-list-panel.ts +124 -10
- package/src/panels/security-panel.ts +72 -20
- package/src/panels/services-panel.ts +68 -22
- package/src/panels/session-browser-panel.ts +29 -5
- package/src/panels/session-maintenance.ts +2 -2
- package/src/panels/settings-sync-panel.ts +30 -15
- package/src/panels/subscription-panel.ts +21 -3
- package/src/panels/symbol-outline-panel.ts +40 -47
- package/src/panels/system-messages-panel.ts +60 -27
- package/src/panels/tasks-panel.ts +34 -7
- package/src/panels/thinking-panel.ts +26 -25
- package/src/panels/token-budget-panel.ts +49 -38
- package/src/panels/tool-inspector-panel.ts +27 -21
- package/src/panels/types.ts +25 -0
- package/src/panels/watchers-panel.ts +25 -5
- package/src/panels/work-plan-panel.ts +125 -34
- package/src/panels/worktree-panel.ts +65 -20
- package/src/panels/wrfc-panel-format.ts +133 -0
- package/src/panels/wrfc-panel.ts +31 -127
- package/src/renderer/compaction-preview.ts +2 -1
- package/src/renderer/compositor.ts +46 -43
- package/src/renderer/model-picker-overlay.ts +9 -4
- package/src/renderer/model-workspace.ts +2 -3
- package/src/renderer/panel-composite.ts +7 -20
- package/src/renderer/panel-workspace-bar.ts +14 -8
- package/src/renderer/progress.ts +3 -2
- package/src/renderer/tab-strip.ts +148 -34
- package/src/renderer/ui-factory.ts +4 -6
- package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
- package/src/runtime/ui/model-picker/types.ts +4 -0
- package/src/utils/format-duration.ts +55 -0
- package/src/utils/format-number.ts +71 -0
- package/src/version.ts +1 -1
- package/src/panels/panel-picker.ts +0 -106
- package/src/renderer/panel-picker-overlay.ts +0 -202
- package/src/renderer/panel-tab-bar.ts +0 -69
|
@@ -1,9 +1,13 @@
|
|
|
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 { PolicyRuntimeState } from '@/runtime/index.ts';
|
|
5
6
|
import type { PolicyPanelSnapshot } from '../runtime/diagnostics/panels/policy.ts';
|
|
6
7
|
import {
|
|
8
|
+
buildEmptyState,
|
|
9
|
+
buildKeyboardHints,
|
|
10
|
+
buildKeyValueLine,
|
|
7
11
|
buildPanelLine,
|
|
8
12
|
buildPanelWorkspace,
|
|
9
13
|
resolveScrollablePanelSection,
|
|
@@ -89,22 +93,30 @@ export class PolicyPanel extends BasePanel {
|
|
|
89
93
|
public render(width: number, height: number): Line[] {
|
|
90
94
|
this.needsRender = false;
|
|
91
95
|
const snapshot = this._state.getSnapshot();
|
|
96
|
+
const summaryLine = this._buildSummary(width, snapshot);
|
|
92
97
|
const content = this._buildContent(width, snapshot);
|
|
98
|
+
const hintsLine = buildKeyboardHints(width, [
|
|
99
|
+
{ keys: '↑/↓', label: 'scroll' },
|
|
100
|
+
{ keys: 'r', label: 'record divergence snapshot' },
|
|
101
|
+
], C);
|
|
102
|
+
const summarySection = { lines: [summaryLine] };
|
|
93
103
|
const governanceSection = resolveScrollablePanelSection(width, height, {
|
|
94
|
-
footerLines: [
|
|
104
|
+
footerLines: [hintsLine],
|
|
95
105
|
palette: C,
|
|
106
|
+
beforeSections: [summarySection],
|
|
96
107
|
section: {
|
|
97
108
|
title: 'Governance',
|
|
98
109
|
scrollableLines: content,
|
|
99
110
|
scrollOffset: this._scrollOffset,
|
|
100
111
|
minRows: 4,
|
|
112
|
+
appendWindowSummary: content.length > 0 ? { dimColor: C.dim } : undefined,
|
|
101
113
|
},
|
|
102
114
|
});
|
|
103
115
|
this._scrollOffset = governanceSection.scrollOffset;
|
|
104
116
|
const lines = buildPanelWorkspace(width, height, {
|
|
105
117
|
title: 'Policy And Governance',
|
|
106
|
-
sections: [governanceSection.section],
|
|
107
|
-
footerLines: [
|
|
118
|
+
sections: [summarySection, governanceSection.section],
|
|
119
|
+
footerLines: [hintsLine],
|
|
108
120
|
palette: C,
|
|
109
121
|
});
|
|
110
122
|
while (lines.length < height) {
|
|
@@ -113,6 +125,28 @@ export class PolicyPanel extends BasePanel {
|
|
|
113
125
|
return lines.slice(0, height);
|
|
114
126
|
}
|
|
115
127
|
|
|
128
|
+
/** Top-of-panel posture summary: the highest-signal governance state at a glance. */
|
|
129
|
+
private _buildSummary(width: number, snapshot: PolicyPanelSnapshot): Line {
|
|
130
|
+
const preflight = snapshot.lastPreflightReview;
|
|
131
|
+
const divergence = snapshot.divergence;
|
|
132
|
+
const lintCount = snapshot.lintFindings.length;
|
|
133
|
+
const preflightStatus = preflight ? preflight.status : 'none';
|
|
134
|
+
const preflightColor = !preflight
|
|
135
|
+
? C.dim
|
|
136
|
+
: preflight.status === 'pass'
|
|
137
|
+
? C.ok
|
|
138
|
+
: preflight.status === 'warn'
|
|
139
|
+
? C.warn
|
|
140
|
+
: C.error;
|
|
141
|
+
const gateStatus = divergence?.gate.status ?? 'n/a';
|
|
142
|
+
return buildKeyValueLine(width, [
|
|
143
|
+
{ label: 'bundles', value: `${snapshot.current ? 1 : 0}+${snapshot.candidate ? 1 : 0}c`, valueColor: snapshot.current || snapshot.candidate ? C.value : C.dim },
|
|
144
|
+
{ label: 'preflight', value: preflightStatus.toUpperCase(), valueColor: preflightColor },
|
|
145
|
+
{ label: 'gate', value: gateStatus, valueColor: gateColor(gateStatus) },
|
|
146
|
+
{ label: 'lint', value: String(lintCount), valueColor: lintCount > 0 ? C.warn : C.dim },
|
|
147
|
+
], C);
|
|
148
|
+
}
|
|
149
|
+
|
|
116
150
|
private _buildContent(width: number, snapshot: PolicyPanelSnapshot): Line[] {
|
|
117
151
|
const lines: Line[] = [];
|
|
118
152
|
const current = snapshot.current;
|
|
@@ -123,8 +157,22 @@ export class PolicyPanel extends BasePanel {
|
|
|
123
157
|
const simulationSummary = snapshot.lastSimulationSummary;
|
|
124
158
|
const preflightReview = snapshot.lastPreflightReview;
|
|
125
159
|
|
|
126
|
-
|
|
127
|
-
|
|
160
|
+
const nothingRecorded = !current && !candidate && !divergence
|
|
161
|
+
&& snapshot.history.length === 0 && permissionAudit.length === 0
|
|
162
|
+
&& lintFindings.length === 0 && !simulationSummary && !preflightReview;
|
|
163
|
+
if (nothingRecorded) {
|
|
164
|
+
lines.push(...buildEmptyState(
|
|
165
|
+
width,
|
|
166
|
+
' No policy bundles loaded.',
|
|
167
|
+
'Load a policy bundle to inspect governance gates, divergence trends, permission audit, lint findings, and preflight posture.',
|
|
168
|
+
[
|
|
169
|
+
{ command: '/policy load', summary: 'load a policy bundle to begin governance review' },
|
|
170
|
+
{ command: '/policy preflight', summary: 'run a proactive preflight posture review' },
|
|
171
|
+
{ command: '/policy simulate', summary: 'compare authoritative vs candidate decisions' },
|
|
172
|
+
],
|
|
173
|
+
C,
|
|
174
|
+
));
|
|
175
|
+
return lines;
|
|
128
176
|
}
|
|
129
177
|
|
|
130
178
|
if (current) {
|
|
@@ -220,7 +268,7 @@ export class PolicyPanel extends BasePanel {
|
|
|
220
268
|
]));
|
|
221
269
|
lines.push(buildPanelLine(width, [
|
|
222
270
|
[' ', C.label],
|
|
223
|
-
[entry.summary
|
|
271
|
+
[truncateDisplay(entry.summary, Math.max(0, width - 6)), C.dim],
|
|
224
272
|
]));
|
|
225
273
|
}
|
|
226
274
|
}
|
|
@@ -233,17 +281,12 @@ export class PolicyPanel extends BasePanel {
|
|
|
233
281
|
[' ', C.label],
|
|
234
282
|
[finding.severity.toUpperCase(), color],
|
|
235
283
|
[' ', C.label],
|
|
236
|
-
[finding.message
|
|
284
|
+
[truncateDisplay(finding.message, Math.max(0, width - 14)), color],
|
|
237
285
|
]));
|
|
238
286
|
}
|
|
239
287
|
}
|
|
240
288
|
|
|
241
|
-
if (
|
|
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 {
|
|
289
|
+
if (preflightReview) {
|
|
247
290
|
lines.push(buildPanelLine(width, [[' Preflight Review', C.label]]));
|
|
248
291
|
const statusColor =
|
|
249
292
|
preflightReview.status === 'pass'
|
|
@@ -261,7 +304,7 @@ export class PolicyPanel extends BasePanel {
|
|
|
261
304
|
]));
|
|
262
305
|
lines.push(buildPanelLine(width, [
|
|
263
306
|
[' ', C.label],
|
|
264
|
-
[preflightReview.summary
|
|
307
|
+
[truncateDisplay(preflightReview.summary, Math.max(0, width - 2)), C.dim],
|
|
265
308
|
]));
|
|
266
309
|
for (const issue of preflightReview.issues.slice(0, 4)) {
|
|
267
310
|
const issueColor = issue.severity === 'error' ? C.error : issue.severity === 'warn' ? C.warn : C.info;
|
|
@@ -269,17 +312,12 @@ export class PolicyPanel extends BasePanel {
|
|
|
269
312
|
[' ', C.label],
|
|
270
313
|
[issue.severity.toUpperCase(), issueColor],
|
|
271
314
|
[' ', C.label],
|
|
272
|
-
[issue.message
|
|
315
|
+
[truncateDisplay(issue.message, Math.max(0, width - 14)), issueColor],
|
|
273
316
|
]));
|
|
274
317
|
}
|
|
275
318
|
}
|
|
276
319
|
|
|
277
|
-
if (
|
|
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 {
|
|
320
|
+
if (simulationSummary) {
|
|
283
321
|
lines.push(buildPanelLine(width, [[' Simulation Samples', C.label]]));
|
|
284
322
|
lines.push(buildPanelLine(width, [
|
|
285
323
|
[' Mode: ', C.label],
|
|
@@ -293,7 +331,7 @@ export class PolicyPanel extends BasePanel {
|
|
|
293
331
|
const color = result.diverged ? C.warn : (result.authoritativeDecision.allowed ? C.ok : C.error);
|
|
294
332
|
lines.push(buildPanelLine(width, [
|
|
295
333
|
[' ', C.label],
|
|
296
|
-
[result.scenario.label
|
|
334
|
+
[truncateDisplay(result.scenario.label, Math.max(0, width - 40)), C.value],
|
|
297
335
|
[' ', C.label],
|
|
298
336
|
[(result.authoritativeDecision.allowed ? 'allow' : 'deny').toUpperCase(), color],
|
|
299
337
|
[' ', C.label],
|
|
@@ -302,7 +340,6 @@ export class PolicyPanel extends BasePanel {
|
|
|
302
340
|
}
|
|
303
341
|
}
|
|
304
342
|
|
|
305
|
-
lines.push(buildPanelLine(width, [[' /policy opens this panel. Press r to record a divergence trend snapshot.', C.dim]]));
|
|
306
343
|
return lines;
|
|
307
344
|
}
|
|
308
345
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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 { UI_TONES } from '../renderer/ui-primitives.ts';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Panel palette + core line primitives.
|
|
8
|
+
//
|
|
9
|
+
// The leaf foundation of the panel formatting toolkit: both `polish.ts` and
|
|
10
|
+
// `polish-tables.ts` build on these. Kept dependency-free (only grid /
|
|
11
|
+
// terminal-width / ui-primitives) so it never participates in an import cycle.
|
|
12
|
+
// All symbols are re-exported from `polish.ts` — panels import from there.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface PanelPalette {
|
|
16
|
+
readonly label: string;
|
|
17
|
+
readonly value: string;
|
|
18
|
+
readonly dim: string;
|
|
19
|
+
readonly info: string;
|
|
20
|
+
readonly good?: string;
|
|
21
|
+
readonly warn?: string;
|
|
22
|
+
readonly bad?: string;
|
|
23
|
+
readonly empty: string;
|
|
24
|
+
readonly header?: string;
|
|
25
|
+
readonly headerBg?: string;
|
|
26
|
+
readonly surfaceBg?: string;
|
|
27
|
+
readonly sectionBg?: string;
|
|
28
|
+
readonly summaryBg?: string;
|
|
29
|
+
readonly inputBg?: string;
|
|
30
|
+
readonly accent?: string;
|
|
31
|
+
readonly selectBg?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_PANEL_PALETTE: Readonly<Required<PanelPalette>> = {
|
|
35
|
+
header: UI_TONES.fg.primary,
|
|
36
|
+
headerBg: UI_TONES.bg.title,
|
|
37
|
+
label: UI_TONES.fg.muted,
|
|
38
|
+
value: UI_TONES.fg.primary,
|
|
39
|
+
dim: UI_TONES.fg.dim,
|
|
40
|
+
info: UI_TONES.state.info,
|
|
41
|
+
good: UI_TONES.state.good,
|
|
42
|
+
warn: UI_TONES.state.warn,
|
|
43
|
+
bad: UI_TONES.state.bad,
|
|
44
|
+
empty: '#334155',
|
|
45
|
+
surfaceBg: UI_TONES.bg.surface,
|
|
46
|
+
sectionBg: UI_TONES.bg.section,
|
|
47
|
+
summaryBg: UI_TONES.bg.summary,
|
|
48
|
+
inputBg: UI_TONES.bg.input,
|
|
49
|
+
accent: UI_TONES.fg.secondary,
|
|
50
|
+
selectBg: UI_TONES.bg.selected,
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extend the base panel palette with domain-specific colors.
|
|
55
|
+
*
|
|
56
|
+
* Convention: raw hex colors may only live inside a palette constant declared
|
|
57
|
+
* at the top of a panel file, not inline in render calls.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* const C = extendPalette(DEFAULT_PANEL_PALETTE, {
|
|
62
|
+
* decision: '#38bdf8',
|
|
63
|
+
* incident: '#ef4444',
|
|
64
|
+
* });
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function extendPalette<T extends Record<string, string>>(
|
|
68
|
+
base: typeof DEFAULT_PANEL_PALETTE,
|
|
69
|
+
extras: T,
|
|
70
|
+
): typeof DEFAULT_PANEL_PALETTE & T {
|
|
71
|
+
return { ...base, ...extras };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildPanelLine(
|
|
75
|
+
width: number,
|
|
76
|
+
segments: Array<StyledPanelSegment | [string, string, string?]>,
|
|
77
|
+
): Line {
|
|
78
|
+
return buildStyledPanelLine(
|
|
79
|
+
width,
|
|
80
|
+
segments.map((seg) =>
|
|
81
|
+
Array.isArray(seg) ? { text: seg[0], fg: seg[1], bg: seg[2] } : seg,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface StyledPanelSegment {
|
|
87
|
+
readonly text: string;
|
|
88
|
+
readonly fg: string;
|
|
89
|
+
readonly bg?: string;
|
|
90
|
+
readonly bold?: boolean;
|
|
91
|
+
readonly dim?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildSelectablePanelLine(
|
|
95
|
+
width: number,
|
|
96
|
+
segments: ReadonlyArray<StyledPanelSegment>,
|
|
97
|
+
options: { selected?: boolean; selectedBg?: string; fillFg?: string; fillBg?: string; leadingMarker?: string } = {},
|
|
98
|
+
): Line {
|
|
99
|
+
const selected = options.selected ?? false;
|
|
100
|
+
const selectedBg = selected ? (options.selectedBg ?? DEFAULT_PANEL_PALETTE.selectBg) : '';
|
|
101
|
+
const fillBg = selectedBg || options.fillBg || '';
|
|
102
|
+
const fillFg = options.fillFg ?? '';
|
|
103
|
+
const cells = createEmptyLine(width);
|
|
104
|
+
if (fillBg) {
|
|
105
|
+
for (let col = 0; col < width; col++) {
|
|
106
|
+
cells[col] = createStyledCell(' ', { bg: fillBg, fg: fillFg });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let col = 0;
|
|
111
|
+
if (selected && options.leadingMarker) {
|
|
112
|
+
for (const ch of options.leadingMarker) {
|
|
113
|
+
const charWidth = getDisplayWidth(ch);
|
|
114
|
+
if (charWidth <= 0 || col + charWidth > width) break;
|
|
115
|
+
cells[col] = createStyledCell(ch, { fg: DEFAULT_PANEL_PALETTE.info, bg: selectedBg, bold: true });
|
|
116
|
+
if (charWidth > 1 && col + 1 < width) cells[col + 1] = createStyledCell(' ', { fg: DEFAULT_PANEL_PALETTE.info, bg: selectedBg, bold: true });
|
|
117
|
+
col += charWidth;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const segment of segments) {
|
|
121
|
+
const fg = segment.fg;
|
|
122
|
+
const bg = segment.bg ?? fillBg;
|
|
123
|
+
for (const ch of segment.text) {
|
|
124
|
+
const charWidth = getDisplayWidth(ch);
|
|
125
|
+
if (charWidth <= 0) continue;
|
|
126
|
+
if (col + charWidth > width) return cells;
|
|
127
|
+
cells[col] = createStyledCell(ch, {
|
|
128
|
+
fg,
|
|
129
|
+
bg,
|
|
130
|
+
bold: segment.bold ?? false,
|
|
131
|
+
dim: segment.dim ?? false,
|
|
132
|
+
});
|
|
133
|
+
if (charWidth > 1 && col + 1 < width) {
|
|
134
|
+
cells[col + 1] = createStyledCell(' ', {
|
|
135
|
+
fg,
|
|
136
|
+
bg,
|
|
137
|
+
bold: segment.bold ?? false,
|
|
138
|
+
dim: segment.dim ?? false,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
col += charWidth;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
while (col < width) {
|
|
146
|
+
cells[col++] = createStyledCell(' ', { bg: fillBg, fg: fillFg });
|
|
147
|
+
}
|
|
148
|
+
return cells;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function buildStyledPanelLine(
|
|
152
|
+
width: number,
|
|
153
|
+
segments: ReadonlyArray<StyledPanelSegment>,
|
|
154
|
+
options: { fillBg?: string; fillFg?: string } = {},
|
|
155
|
+
): Line {
|
|
156
|
+
return buildSelectablePanelLine(width, segments, options);
|
|
157
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { Line } from '../types/grid.ts';
|
|
2
|
+
import { getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
|
|
3
|
+
import { GLYPHS, UI_TONES } from '../renderer/ui-primitives.ts';
|
|
4
|
+
// Build on the leaf primitives in ./polish-core.ts (palette + base line
|
|
5
|
+
// builders). polish-core has no back-edge to this file, so there is no cycle.
|
|
6
|
+
import { buildSelectablePanelLine, DEFAULT_PANEL_PALETTE } from './polish-core.ts';
|
|
7
|
+
import type { PanelPalette, StyledPanelSegment } from './polish-core.ts';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Shared formatting primitives (the toolkit panels standardize on).
|
|
11
|
+
//
|
|
12
|
+
// All are display-width aware (via getDisplayWidth / truncateDisplay) so they
|
|
13
|
+
// stay aligned across emoji, CJK, and other wide characters — never hand-roll
|
|
14
|
+
// .slice()/.padEnd() truncation or alignment in panels.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export type ColumnAlign = 'left' | 'right' | 'center';
|
|
18
|
+
|
|
19
|
+
export interface ColumnSpec {
|
|
20
|
+
/** Fixed display width of the column. */
|
|
21
|
+
readonly width: number;
|
|
22
|
+
/** Horizontal alignment within the column. Default 'left'. */
|
|
23
|
+
readonly align?: ColumnAlign;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AlignedCell {
|
|
27
|
+
readonly text: string;
|
|
28
|
+
readonly fg: string;
|
|
29
|
+
readonly bg?: string;
|
|
30
|
+
readonly bold?: boolean;
|
|
31
|
+
readonly dim?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Truncate + pad `text` to exactly `width` display columns with alignment. */
|
|
35
|
+
function alignText(text: string, width: number, align: ColumnAlign): string {
|
|
36
|
+
if (width <= 0) return '';
|
|
37
|
+
const truncated = truncateDisplay(text, width);
|
|
38
|
+
const pad = Math.max(0, width - getDisplayWidth(truncated));
|
|
39
|
+
if (pad === 0) return truncated;
|
|
40
|
+
if (align === 'right') return ' '.repeat(pad) + truncated;
|
|
41
|
+
if (align === 'center') {
|
|
42
|
+
const left = Math.floor(pad / 2);
|
|
43
|
+
return ' '.repeat(left) + truncated + ' '.repeat(pad - left);
|
|
44
|
+
}
|
|
45
|
+
return truncated + ' '.repeat(pad);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build a multi-column row with display-width-aware alignment. Replaces manual
|
|
50
|
+
* `.padEnd()`/`.padStart()` column math (which misaligns on wide chars).
|
|
51
|
+
*
|
|
52
|
+
* `cells` and `columns` are zip-aligned by index; extra cells are ignored.
|
|
53
|
+
*/
|
|
54
|
+
export function buildAlignedRow(
|
|
55
|
+
width: number,
|
|
56
|
+
cells: ReadonlyArray<AlignedCell>,
|
|
57
|
+
columns: ReadonlyArray<ColumnSpec>,
|
|
58
|
+
options: {
|
|
59
|
+
readonly gap?: number;
|
|
60
|
+
readonly selected?: boolean;
|
|
61
|
+
readonly selectedBg?: string;
|
|
62
|
+
readonly marker?: string;
|
|
63
|
+
readonly fillBg?: string;
|
|
64
|
+
} = {},
|
|
65
|
+
): Line {
|
|
66
|
+
const gap = options.gap ?? 1;
|
|
67
|
+
const segments: StyledPanelSegment[] = [];
|
|
68
|
+
const count = Math.min(cells.length, columns.length);
|
|
69
|
+
for (let i = 0; i < count; i++) {
|
|
70
|
+
const col = columns[i]!;
|
|
71
|
+
const cell = cells[i]!;
|
|
72
|
+
if (i > 0 && gap > 0) segments.push({ text: ' '.repeat(gap), fg: cell.fg, bg: cell.bg });
|
|
73
|
+
segments.push({
|
|
74
|
+
text: alignText(cell.text, col.width, col.align ?? 'left'),
|
|
75
|
+
fg: cell.fg,
|
|
76
|
+
bg: cell.bg,
|
|
77
|
+
bold: cell.bold,
|
|
78
|
+
dim: cell.dim,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return buildSelectablePanelLine(width, segments, {
|
|
82
|
+
selected: options.selected,
|
|
83
|
+
selectedBg: options.selectedBg,
|
|
84
|
+
fillBg: options.fillBg,
|
|
85
|
+
leadingMarker: options.marker,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type StatusBadgeKind =
|
|
90
|
+
| 'pending'
|
|
91
|
+
| 'running'
|
|
92
|
+
| 'completed'
|
|
93
|
+
| 'failed'
|
|
94
|
+
| 'cancelled'
|
|
95
|
+
| 'blocked'
|
|
96
|
+
| 'review';
|
|
97
|
+
|
|
98
|
+
interface BadgeSpec {
|
|
99
|
+
readonly glyph: string;
|
|
100
|
+
readonly fg: string;
|
|
101
|
+
readonly label: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const STATUS_BADGES: Record<StatusBadgeKind, BadgeSpec> = {
|
|
105
|
+
pending: { glyph: GLYPHS.status.pending, fg: DEFAULT_PANEL_PALETTE.info, label: 'Pending' },
|
|
106
|
+
running: { glyph: GLYPHS.status.active, fg: UI_TONES.state.active, label: 'Running' },
|
|
107
|
+
completed: { glyph: GLYPHS.status.success, fg: DEFAULT_PANEL_PALETTE.good, label: 'Completed' },
|
|
108
|
+
failed: { glyph: GLYPHS.status.failure, fg: DEFAULT_PANEL_PALETTE.bad, label: 'Failed' },
|
|
109
|
+
cancelled: { glyph: GLYPHS.status.skipped, fg: DEFAULT_PANEL_PALETTE.dim, label: 'Cancelled' },
|
|
110
|
+
blocked: { glyph: GLYPHS.status.blocked, fg: UI_TONES.state.blocked, label: 'Blocked' },
|
|
111
|
+
review: { glyph: GLYPHS.status.review, fg: DEFAULT_PANEL_PALETTE.info, label: 'Review' },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Canonical status badge (glyph + label) with a consistent color per lifecycle
|
|
116
|
+
* state. Returns StyledPanelSegment[] — spread into a buildPanelLine call:
|
|
117
|
+
* buildPanelLine(width, [[' ', C.label], ...buildStatusBadge('running')])
|
|
118
|
+
* Supersedes ad-hoc per-panel status→color maps.
|
|
119
|
+
*/
|
|
120
|
+
export function buildStatusBadge(
|
|
121
|
+
kind: StatusBadgeKind,
|
|
122
|
+
label?: string,
|
|
123
|
+
opts?: { glyph?: string; bg?: string; count?: number },
|
|
124
|
+
): StyledPanelSegment[] {
|
|
125
|
+
const spec = STATUS_BADGES[kind];
|
|
126
|
+
const glyph = opts?.glyph ?? spec.glyph;
|
|
127
|
+
const text = label ?? spec.label;
|
|
128
|
+
const rendered = opts?.count !== undefined ? `${glyph} ${text} (${opts.count})` : `${glyph} ${text}`;
|
|
129
|
+
return [{ text: rendered, fg: spec.fg, bg: opts?.bg }];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface TreeItemSpec {
|
|
133
|
+
readonly depth: number;
|
|
134
|
+
readonly label: string;
|
|
135
|
+
readonly icon?: string;
|
|
136
|
+
readonly expandable?: boolean;
|
|
137
|
+
readonly expanded?: boolean;
|
|
138
|
+
readonly labelColor?: string;
|
|
139
|
+
/** Right-aligned metadata columns (e.g. size, mtime). */
|
|
140
|
+
readonly metadata?: ReadonlyArray<{ text: string; fg: string }>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build a hierarchical tree row: indentation + expand/collapse glyph + optional
|
|
145
|
+
* icon + label, with right-aligned metadata. Replaces hand-rolled indentation
|
|
146
|
+
* in file-explorer / symbol-outline / orchestration panels.
|
|
147
|
+
*/
|
|
148
|
+
export function buildTreeRow(
|
|
149
|
+
width: number,
|
|
150
|
+
item: TreeItemSpec,
|
|
151
|
+
palette: PanelPalette,
|
|
152
|
+
options: { selected?: boolean; selectedBg?: string; indentWidth?: number } = {},
|
|
153
|
+
): Line {
|
|
154
|
+
const indent = ' '.repeat(Math.max(0, item.depth) * (options.indentWidth ?? 2));
|
|
155
|
+
const toggle = item.expandable
|
|
156
|
+
? (item.expanded ? GLYPHS.navigation.expanded : GLYPHS.navigation.collapsed)
|
|
157
|
+
: ' ';
|
|
158
|
+
const iconPart = item.icon ? `${item.icon} ` : '';
|
|
159
|
+
const prefix = `${indent}${toggle} ${iconPart}`;
|
|
160
|
+
const prefixW = getDisplayWidth(prefix);
|
|
161
|
+
|
|
162
|
+
const metadata = item.metadata ?? [];
|
|
163
|
+
const metaText = metadata.map((m) => m.text).join(' ');
|
|
164
|
+
const metaW = getDisplayWidth(metaText);
|
|
165
|
+
const reserve = metaW > 0 ? metaW + 1 : 0;
|
|
166
|
+
|
|
167
|
+
const labelBudget = Math.max(0, width - prefixW - reserve);
|
|
168
|
+
const label = truncateDisplay(item.label, labelBudget);
|
|
169
|
+
const usedW = prefixW + getDisplayWidth(label);
|
|
170
|
+
|
|
171
|
+
const segments: StyledPanelSegment[] = [
|
|
172
|
+
{ text: prefix, fg: palette.dim },
|
|
173
|
+
{ text: label, fg: item.labelColor ?? palette.value },
|
|
174
|
+
];
|
|
175
|
+
if (metaW > 0) {
|
|
176
|
+
const spacer = Math.max(1, width - usedW - metaW);
|
|
177
|
+
segments.push({ text: ' '.repeat(spacer), fg: palette.dim });
|
|
178
|
+
metadata.forEach((m, i) => {
|
|
179
|
+
if (i > 0) segments.push({ text: ' ', fg: palette.dim });
|
|
180
|
+
segments.push({ text: m.text, fg: m.fg });
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return buildSelectablePanelLine(width, segments, {
|
|
184
|
+
selected: options.selected,
|
|
185
|
+
selectedBg: options.selectedBg ?? palette.selectBg,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface TableColumn {
|
|
190
|
+
readonly label: string;
|
|
191
|
+
/** Fixed width; omit to auto-distribute remaining space. */
|
|
192
|
+
readonly width?: number;
|
|
193
|
+
readonly align?: ColumnAlign;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface TableCell {
|
|
197
|
+
readonly text: string;
|
|
198
|
+
readonly fg?: string;
|
|
199
|
+
readonly bg?: string;
|
|
200
|
+
readonly bold?: boolean;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface TableRow {
|
|
204
|
+
readonly cells: ReadonlyArray<TableCell>;
|
|
205
|
+
readonly selected?: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Resolve concrete per-column widths, auto-distributing any unsized columns. */
|
|
209
|
+
function resolveColumnWidths(width: number, columns: ReadonlyArray<TableColumn>, gap: number): number[] {
|
|
210
|
+
const totalGap = gap * Math.max(0, columns.length - 1);
|
|
211
|
+
const explicit = columns.reduce((s, c) => s + (c.width ?? 0), 0);
|
|
212
|
+
const autoIdx = columns.map((c, i) => (c.width === undefined ? i : -1)).filter((i) => i >= 0);
|
|
213
|
+
const autoBudget = Math.max(0, width - totalGap - explicit);
|
|
214
|
+
const each = autoIdx.length > 0 ? Math.floor(autoBudget / autoIdx.length) : 0;
|
|
215
|
+
const widths = columns.map((c) => c.width ?? each);
|
|
216
|
+
if (autoIdx.length > 0) {
|
|
217
|
+
const used = each * autoIdx.length;
|
|
218
|
+
widths[autoIdx[autoIdx.length - 1]!] += autoBudget - used; // remainder to last auto col
|
|
219
|
+
}
|
|
220
|
+
return widths;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build a table: a header row plus aligned data rows. Replaces ad-hoc
|
|
225
|
+
* row/column offset math in history/ledger/roster panels.
|
|
226
|
+
*/
|
|
227
|
+
export function buildTable(
|
|
228
|
+
width: number,
|
|
229
|
+
columns: ReadonlyArray<TableColumn>,
|
|
230
|
+
rows: ReadonlyArray<TableRow>,
|
|
231
|
+
palette: PanelPalette,
|
|
232
|
+
options: { gap?: number; selectedBg?: string } = {},
|
|
233
|
+
): Line[] {
|
|
234
|
+
const gap = options.gap ?? 1;
|
|
235
|
+
const widths = resolveColumnWidths(width, columns, gap);
|
|
236
|
+
const specs: ColumnSpec[] = columns.map((c, i) => ({ width: widths[i]!, align: c.align }));
|
|
237
|
+
|
|
238
|
+
const header = buildAlignedRow(
|
|
239
|
+
width,
|
|
240
|
+
columns.map((c) => ({ text: c.label, fg: palette.label, bold: true })),
|
|
241
|
+
specs,
|
|
242
|
+
{ gap },
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const dataRows = rows.map((row) =>
|
|
246
|
+
buildAlignedRow(
|
|
247
|
+
width,
|
|
248
|
+
columns.map((_, i) => {
|
|
249
|
+
const cell = row.cells[i] ?? { text: '', fg: palette.value };
|
|
250
|
+
return { text: cell.text, fg: cell.fg ?? palette.value, bg: cell.bg, bold: cell.bold };
|
|
251
|
+
}),
|
|
252
|
+
specs,
|
|
253
|
+
{ gap, selected: row.selected, selectedBg: options.selectedBg ?? palette.selectBg },
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
return [header, ...dataRows];
|
|
258
|
+
}
|