@pellux/goodvibes-tui 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +4 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/context-usage.ts +53 -0
  8. package/src/core/conversation-rendering.ts +2 -2
  9. package/src/core/conversation.ts +53 -8
  10. package/src/core/session-recovery.ts +26 -18
  11. package/src/core/system-message-router.ts +42 -26
  12. package/src/core/turn-event-wiring.ts +13 -16
  13. package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
  14. package/src/daemon/handlers/inbox/index.ts +2 -1
  15. package/src/daemon/handlers/inbox/poller.ts +2 -1
  16. package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
  17. package/src/daemon/handlers/inbox/providers/email.ts +2 -1
  18. package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
  19. package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
  20. package/src/daemon/handlers/triage/integration.ts +2 -1
  21. package/src/daemon/handlers/triage/pipeline.ts +2 -1
  22. package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
  23. package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
  24. package/src/export/gist-uploader.ts +3 -1
  25. package/src/input/command-registry.ts +1 -0
  26. package/src/input/commands/cloudflare-runtime.ts +2 -1
  27. package/src/input/commands/eval.ts +4 -3
  28. package/src/input/commands/guidance-runtime.ts +2 -4
  29. package/src/input/commands/health-runtime.ts +13 -7
  30. package/src/input/commands/knowledge.ts +2 -1
  31. package/src/input/commands/runtime-services.ts +40 -10
  32. package/src/input/handler-command-route.ts +1 -1
  33. package/src/input/handler-feed-routes.ts +61 -4
  34. package/src/input/handler-feed.ts +13 -0
  35. package/src/input/handler-interactions.ts +2 -1
  36. package/src/input/handler-modal-routes.ts +2 -1
  37. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  38. package/src/input/handler-onboarding.ts +8 -8
  39. package/src/input/handler-picker-routes.ts +18 -13
  40. package/src/input/handler-ui-state.ts +5 -1
  41. package/src/input/keybindings.ts +5 -0
  42. package/src/input/model-picker.ts +5 -0
  43. package/src/input/panel-integration-actions.ts +10 -0
  44. package/src/input/tts-settings-actions.ts +2 -1
  45. package/src/main.ts +52 -60
  46. package/src/panels/agent-inspector-panel.ts +90 -15
  47. package/src/panels/agent-inspector-shared.ts +3 -3
  48. package/src/panels/agent-logs-panel.ts +149 -72
  49. package/src/panels/approval-panel.ts +146 -95
  50. package/src/panels/automation-control-panel.ts +24 -1
  51. package/src/panels/base-panel.ts +28 -3
  52. package/src/panels/builtin/agent.ts +3 -1
  53. package/src/panels/builtin/development.ts +2 -1
  54. package/src/panels/builtin/session.ts +1 -1
  55. package/src/panels/cockpit-panel.ts +24 -5
  56. package/src/panels/cockpit-read-model.ts +2 -1
  57. package/src/panels/communication-panel.ts +108 -43
  58. package/src/panels/context-visualizer-panel.ts +49 -15
  59. package/src/panels/control-plane-panel.ts +27 -1
  60. package/src/panels/cost-tracker-panel.ts +87 -65
  61. package/src/panels/debug-panel.ts +61 -37
  62. package/src/panels/diff-panel.ts +59 -30
  63. package/src/panels/docs-panel.ts +30 -24
  64. package/src/panels/eval-panel.ts +130 -81
  65. package/src/panels/expandable-list-panel.ts +189 -0
  66. package/src/panels/file-explorer-panel.ts +42 -42
  67. package/src/panels/file-preview-panel.ts +11 -3
  68. package/src/panels/forensics-panel.ts +27 -13
  69. package/src/panels/git-panel.ts +65 -84
  70. package/src/panels/hooks-panel.ts +36 -2
  71. package/src/panels/incident-review-panel.ts +31 -10
  72. package/src/panels/intelligence-panel.ts +152 -71
  73. package/src/panels/knowledge-graph-panel.ts +89 -27
  74. package/src/panels/local-auth-panel.ts +17 -11
  75. package/src/panels/marketplace-panel.ts +33 -13
  76. package/src/panels/memory-panel.ts +7 -5
  77. package/src/panels/ops-control-panel.ts +69 -7
  78. package/src/panels/ops-strategy-panel.ts +61 -43
  79. package/src/panels/orchestration-panel.ts +34 -4
  80. package/src/panels/panel-list-panel.ts +33 -8
  81. package/src/panels/panel-manager.ts +23 -4
  82. package/src/panels/plan-dashboard-panel.ts +183 -66
  83. package/src/panels/plugins-panel.ts +48 -10
  84. package/src/panels/policy-panel.ts +60 -23
  85. package/src/panels/polish-core.ts +157 -0
  86. package/src/panels/polish-tables.ts +258 -0
  87. package/src/panels/polish.ts +44 -145
  88. package/src/panels/project-planning-panel.ts +27 -4
  89. package/src/panels/provider-accounts-panel.ts +55 -18
  90. package/src/panels/provider-health-panel.ts +118 -87
  91. package/src/panels/provider-stats-panel.ts +47 -22
  92. package/src/panels/qr-panel.ts +23 -11
  93. package/src/panels/remote-panel.ts +12 -5
  94. package/src/panels/routes-panel.ts +27 -5
  95. package/src/panels/sandbox-panel.ts +62 -27
  96. package/src/panels/schedule-panel.ts +44 -24
  97. package/src/panels/scrollable-list-panel.ts +124 -10
  98. package/src/panels/security-panel.ts +72 -20
  99. package/src/panels/services-panel.ts +68 -22
  100. package/src/panels/session-browser-panel.ts +42 -26
  101. package/src/panels/session-maintenance.ts +2 -2
  102. package/src/panels/settings-sync-panel.ts +30 -15
  103. package/src/panels/skills-panel.ts +2 -1
  104. package/src/panels/subscription-panel.ts +21 -3
  105. package/src/panels/symbol-outline-panel.ts +40 -47
  106. package/src/panels/system-messages-panel.ts +60 -27
  107. package/src/panels/tasks-panel.ts +36 -14
  108. package/src/panels/thinking-panel.ts +32 -36
  109. package/src/panels/token-budget-panel.ts +80 -53
  110. package/src/panels/tool-inspector-panel.ts +37 -39
  111. package/src/panels/types.ts +25 -0
  112. package/src/panels/watchers-panel.ts +25 -5
  113. package/src/panels/work-plan-panel.ts +127 -35
  114. package/src/panels/worktree-panel.ts +65 -20
  115. package/src/panels/wrfc-panel-format.ts +133 -0
  116. package/src/panels/wrfc-panel.ts +53 -147
  117. package/src/renderer/agent-detail-modal.ts +2 -2
  118. package/src/renderer/compaction-preview.ts +2 -1
  119. package/src/renderer/compositor.ts +46 -43
  120. package/src/renderer/modal-utils.ts +0 -10
  121. package/src/renderer/model-picker-overlay.ts +9 -4
  122. package/src/renderer/model-workspace.ts +2 -3
  123. package/src/renderer/panel-composite.ts +7 -20
  124. package/src/renderer/panel-workspace-bar.ts +14 -8
  125. package/src/renderer/process-modal.ts +2 -2
  126. package/src/renderer/progress.ts +3 -2
  127. package/src/renderer/tab-strip.ts +148 -34
  128. package/src/renderer/ui-factory.ts +4 -6
  129. package/src/runtime/bootstrap-command-context.ts +3 -0
  130. package/src/runtime/bootstrap-command-parts.ts +3 -1
  131. package/src/runtime/bootstrap-core.ts +31 -31
  132. package/src/runtime/bootstrap-shell.ts +1 -0
  133. package/src/runtime/onboarding/apply.ts +4 -3
  134. package/src/runtime/onboarding/snapshot.ts +4 -3
  135. package/src/runtime/process-lifecycle.ts +195 -0
  136. package/src/runtime/services.ts +4 -1
  137. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  138. package/src/runtime/ui/model-picker/types.ts +4 -0
  139. package/src/runtime/wrfc-persistence.ts +20 -5
  140. package/src/shell/ui-openers.ts +3 -2
  141. package/src/utils/format-duration.ts +55 -0
  142. package/src/utils/format-number.ts +71 -0
  143. package/src/verification/live-verifier.ts +4 -3
  144. package/src/version.ts +1 -1
  145. package/src/core/context-auto-compact.ts +0 -110
  146. package/src/panels/panel-picker.ts +0 -106
  147. package/src/renderer/panel-picker-overlay.ts +0 -202
  148. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -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
+ }
@@ -2,152 +2,31 @@ import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
3
3
  import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
4
4
  import { getSurfaceContentRows, getTrackedVisibleWindow, getVisibleWindow, type VisibleWindow } from '../renderer/surface-layout.ts';
5
- import { GLYPHS, UI_TONES } from '../renderer/ui-primitives.ts';
5
+ import { GLYPHS } from '../renderer/ui-primitives.ts';
6
6
  import { type StatusState, STATE_GLYPHS } from '../renderer/status-glyphs.ts';
7
-
8
- export interface PanelPalette {
9
- readonly label: string;
10
- readonly value: string;
11
- readonly dim: string;
12
- readonly info: string;
13
- readonly good?: string;
14
- readonly warn?: string;
15
- readonly bad?: string;
16
- readonly empty: string;
17
- readonly header?: string;
18
- readonly headerBg?: string;
19
- readonly surfaceBg?: string;
20
- readonly sectionBg?: string;
21
- readonly summaryBg?: string;
22
- readonly inputBg?: string;
23
- readonly accent?: string;
24
- readonly selectBg?: string;
25
- }
26
-
27
- export const DEFAULT_PANEL_PALETTE: Readonly<Required<PanelPalette>> = {
28
- header: UI_TONES.fg.primary,
29
- headerBg: UI_TONES.bg.title,
30
- label: UI_TONES.fg.muted,
31
- value: UI_TONES.fg.primary,
32
- dim: UI_TONES.fg.dim,
33
- info: UI_TONES.state.info,
34
- good: UI_TONES.state.good,
35
- warn: UI_TONES.state.warn,
36
- bad: UI_TONES.state.bad,
37
- empty: '#334155',
38
- surfaceBg: UI_TONES.bg.surface,
39
- sectionBg: UI_TONES.bg.section,
40
- summaryBg: UI_TONES.bg.summary,
41
- inputBg: UI_TONES.bg.input,
42
- accent: UI_TONES.fg.secondary,
43
- selectBg: UI_TONES.bg.selected,
44
- } as const;
45
-
46
- /**
47
- * Extend the base panel palette with domain-specific colors.
48
- *
49
- * Convention: raw hex colors may only live inside a palette constant declared
50
- * at the top of a panel file, not inline in render calls.
51
- *
52
- * @example
53
- * ```ts
54
- * const C = extendPalette(DEFAULT_PANEL_PALETTE, {
55
- * decision: '#38bdf8',
56
- * incident: '#ef4444',
57
- * });
58
- * ```
59
- */
60
- export function extendPalette<T extends Record<string, string>>(
61
- base: typeof DEFAULT_PANEL_PALETTE,
62
- extras: T,
63
- ): typeof DEFAULT_PANEL_PALETTE & T {
64
- return { ...base, ...extras };
65
- }
66
-
67
- export function buildPanelLine(
68
- width: number,
69
- segments: Array<StyledPanelSegment | [string, string, string?]>,
70
- ): Line {
71
- return buildStyledPanelLine(
72
- width,
73
- segments.map((seg) =>
74
- Array.isArray(seg) ? { text: seg[0], fg: seg[1], bg: seg[2] } : seg,
75
- ),
76
- );
77
- }
78
-
79
- export interface StyledPanelSegment {
80
- readonly text: string;
81
- readonly fg: string;
82
- readonly bg?: string;
83
- readonly bold?: boolean;
84
- readonly dim?: boolean;
85
- }
86
-
87
- export function buildSelectablePanelLine(
88
- width: number,
89
- segments: ReadonlyArray<StyledPanelSegment>,
90
- options: { selected?: boolean; selectedBg?: string; fillFg?: string; fillBg?: string; leadingMarker?: string } = {},
91
- ): Line {
92
- const selected = options.selected ?? false;
93
- const selectedBg = selected ? (options.selectedBg ?? DEFAULT_PANEL_PALETTE.selectBg) : '';
94
- const fillBg = selectedBg || options.fillBg || '';
95
- const fillFg = options.fillFg ?? '';
96
- const cells = createEmptyLine(width);
97
- if (fillBg) {
98
- for (let col = 0; col < width; col++) {
99
- cells[col] = createStyledCell(' ', { bg: fillBg, fg: fillFg });
100
- }
101
- }
102
-
103
- let col = 0;
104
- if (selected && options.leadingMarker) {
105
- for (const ch of options.leadingMarker) {
106
- const charWidth = getDisplayWidth(ch);
107
- if (charWidth <= 0 || col + charWidth > width) break;
108
- cells[col] = createStyledCell(ch, { fg: DEFAULT_PANEL_PALETTE.info, bg: selectedBg, bold: true });
109
- if (charWidth > 1 && col + 1 < width) cells[col + 1] = createStyledCell(' ', { fg: DEFAULT_PANEL_PALETTE.info, bg: selectedBg, bold: true });
110
- col += charWidth;
111
- }
112
- }
113
- for (const segment of segments) {
114
- const fg = segment.fg;
115
- const bg = segment.bg ?? fillBg;
116
- for (const ch of segment.text) {
117
- const charWidth = getDisplayWidth(ch);
118
- if (charWidth <= 0) continue;
119
- if (col + charWidth > width) return cells;
120
- cells[col] = createStyledCell(ch, {
121
- fg,
122
- bg,
123
- bold: segment.bold ?? false,
124
- dim: segment.dim ?? false,
125
- });
126
- if (charWidth > 1 && col + 1 < width) {
127
- cells[col + 1] = createStyledCell(' ', {
128
- fg,
129
- bg,
130
- bold: segment.bold ?? false,
131
- dim: segment.dim ?? false,
132
- });
133
- }
134
- col += charWidth;
135
- }
136
- }
137
-
138
- while (col < width) {
139
- cells[col++] = createStyledCell(' ', { bg: fillBg, fg: fillFg });
140
- }
141
- return cells;
142
- }
143
-
144
- export function buildStyledPanelLine(
145
- width: number,
146
- segments: ReadonlyArray<StyledPanelSegment>,
147
- options: { fillBg?: string; fillFg?: string } = {},
148
- ): Line {
149
- return buildSelectablePanelLine(width, segments, options);
150
- }
7
+ import { buildPanelLine, buildStyledPanelLine, DEFAULT_PANEL_PALETTE } from './polish-core.ts';
8
+ import type { PanelPalette, StyledPanelSegment } from './polish-core.ts';
9
+
10
+ // The panel formatting toolkit is split across three modules to stay under the
11
+ // architecture line-count cap; every symbol remains importable from './polish.ts':
12
+ // - ./polish-core.ts palette + base line primitives (leaf)
13
+ // - ./polish-tables.ts table / tree / badge / aligned-row primitives
14
+ // This module keeps the higher-level builders + workspace layout.
15
+ export * from './polish-core.ts';
16
+ export {
17
+ buildAlignedRow,
18
+ buildStatusBadge,
19
+ buildTreeRow,
20
+ buildTable,
21
+ type ColumnAlign,
22
+ type ColumnSpec,
23
+ type AlignedCell,
24
+ type StatusBadgeKind,
25
+ type TreeItemSpec,
26
+ type TableColumn,
27
+ type TableCell,
28
+ type TableRow,
29
+ } from './polish-tables.ts';
151
30
 
152
31
  export function buildGuidanceLine(
153
32
  width: number,
@@ -680,6 +559,26 @@ export function resolveStackedScrollableSections(
680
559
  });
681
560
  }
682
561
 
562
+ /**
563
+ * Standardized footer keyboard-hint row so every panel's hints look identical.
564
+ * Each hint renders as `keys` (info color) + `label` (dim).
565
+ */
566
+ export function buildKeyboardHints(
567
+ width: number,
568
+ hints: ReadonlyArray<{ keys: string; label: string }>,
569
+ palette: PanelPalette,
570
+ ): Line {
571
+ const segments: Array<[string, string, string?]> = [];
572
+ hints.forEach((hint, i) => {
573
+ if (i > 0) segments.push([' ', palette.dim]);
574
+ else segments.push([' ', palette.dim]);
575
+ segments.push([hint.keys, palette.info]);
576
+ segments.push([' ', palette.dim]);
577
+ segments.push([hint.label, palette.dim]);
578
+ });
579
+ return buildPanelLine(width, segments);
580
+ }
581
+
683
582
  export function buildPanelWorkspace(
684
583
  width: number,
685
584
  height: number,
@@ -23,6 +23,7 @@ import {
23
23
  resolveScrollablePanelSection,
24
24
  type PanelWorkspaceSection,
25
25
  } from './polish.ts';
26
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
26
27
 
27
28
  const C = extendPalette(DEFAULT_PANEL_PALETTE, {
28
29
  planning: '#38bdf8',
@@ -366,12 +367,34 @@ export class ProjectPlanningPanel extends BasePanel {
366
367
  : readiness === 'needs-user-input'
367
368
  ? C.blocked
368
369
  : C.dim;
370
+ const blockingGaps = (evaluation?.gaps ?? []).filter((gap) => gap.severity === 'blocking').length;
371
+ const openQuestions = state.openQuestions.filter((q) => (q.status ?? 'open') === 'open').length;
372
+ // Surface the most important thing first: is this plan executable, and what
373
+ // is the single blocker to getting there.
374
+ const nextStep = state.executionApproved
375
+ ? 'approved — execution may proceed'
376
+ : openQuestions > 0
377
+ ? `answer ${openQuestions} open question${openQuestions === 1 ? '' : 's'}`
378
+ : blockingGaps > 0
379
+ ? `resolve ${blockingGaps} blocking gap${blockingGaps === 1 ? '' : 's'}`
380
+ : readiness === 'executable'
381
+ ? 'press a to approve execution'
382
+ : 'continue the planning interview';
369
383
  const lines: Line[] = [
370
384
  buildKeyValueLine(width, [
371
385
  { label: 'readiness', value: readiness, valueColor: readinessColor },
372
386
  { label: 'approved', value: state.executionApproved ? 'yes' : 'no', valueColor: state.executionApproved ? C.approved : C.blocked },
373
387
  { label: 'questions', value: `${state.openQuestions.length} open / ${state.answeredQuestions.length} answered`, valueColor: C.value },
374
388
  ], C),
389
+ buildKeyValueLine(width, [
390
+ { label: 'blocking gaps', value: String(blockingGaps), valueColor: blockingGaps > 0 ? C.blocked : C.good },
391
+ { label: 'tasks', value: String(state.tasks.length), valueColor: state.tasks.length > 0 ? C.value : C.dim },
392
+ { label: 'gates', value: String(state.verificationGates.length), valueColor: state.verificationGates.length > 0 ? C.good : C.dim },
393
+ ], C),
394
+ buildPanelLine(width, [
395
+ [' Next step ', C.label],
396
+ [nextStep, state.executionApproved ? C.approved : C.planning],
397
+ ]),
375
398
  ...buildBodyText(width, `Goal: ${state.goal || '(not set)'}`, C, state.goal ? C.value : C.blocked),
376
399
  ];
377
400
  if (state.scope) lines.push(...buildBodyText(width, `Scope: ${state.scope}`, C, C.value));
@@ -512,7 +535,7 @@ export class ProjectPlanningPanel extends BasePanel {
512
535
  };
513
536
  this.clearError();
514
537
  } catch (err) {
515
- this.setError(err instanceof Error ? err.message : String(err));
538
+ this.setError(summarizeError(err));
516
539
  } finally {
517
540
  this.loading = false;
518
541
  this.markDirty();
@@ -647,7 +670,7 @@ export class ProjectPlanningPanel extends BasePanel {
647
670
  this.refresh(true);
648
671
  this.registerTimer(setTimeout(() => this.refresh(true), 250));
649
672
  } catch (err) {
650
- this.setError(err instanceof Error ? err.message : String(err));
673
+ this.setError(summarizeError(err));
651
674
  this.requestRender();
652
675
  }
653
676
  })();
@@ -698,7 +721,7 @@ export class ProjectPlanningPanel extends BasePanel {
698
721
  this.dismissPlanning?.();
699
722
  this.refresh(true);
700
723
  } catch (err) {
701
- this.setError(err instanceof Error ? err.message : String(err));
724
+ this.setError(summarizeError(err));
702
725
  this.requestRender();
703
726
  }
704
727
  })();
@@ -741,7 +764,7 @@ export class ProjectPlanningPanel extends BasePanel {
741
764
  });
742
765
  this.refresh(true);
743
766
  } catch (err) {
744
- this.setError(err instanceof Error ? err.message : String(err));
767
+ this.setError(summarizeError(err));
745
768
  this.requestRender();
746
769
  }
747
770
  })();
@@ -1,12 +1,14 @@
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 { ScrollableListPanel } from './scrollable-list-panel.ts';
4
5
  import {
6
+ buildAlignedRow,
5
7
  buildDetailBlock,
6
8
  buildEmptyState,
7
9
  buildGuidanceLine,
10
+ buildKeyboardHints,
8
11
  buildKeyValueLine,
9
- buildPanelListRow,
10
12
  buildPanelLine,
11
13
  buildSummaryBlock,
12
14
  buildPanelWorkspace,
@@ -50,14 +52,32 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
50
52
  return this.records;
51
53
  }
52
54
 
55
+ private readonly listColumns = [
56
+ { width: 16 },
57
+ { width: 14 },
58
+ { width: 8, align: 'right' as const },
59
+ { width: 11 },
60
+ { width: 8, align: 'right' as const },
61
+ ];
62
+
53
63
  protected renderItem(item: ProviderAccountRecord, _index: number, selected: boolean, width: number): Line {
54
- return buildPanelListRow(width, [
55
- { text: item.providerId.padEnd(16), fg: item.active ? C.good : C.value },
56
- { text: ` ${item.activeRoute.padEnd(14)}`, fg: item.activeRoute === 'subscription' ? C.info : item.activeRoute === 'api-key' ? C.warn : item.activeRoute === 'service-oauth' ? C.value : C.dim },
57
- { text: ` models=${String(item.modelCount).padEnd(4)}`, fg: C.dim },
58
- { text: ` ${item.authFreshness.padEnd(10)}`, fg: item.authFreshness === 'expired' ? C.bad : item.authFreshness === 'expiring' || item.authFreshness === 'pending' ? C.warn : C.dim },
59
- { text: ` issues=${String(item.issues.length).padEnd(2)}`, fg: item.issues.length > 0 ? C.bad : C.good },
60
- ], C, { selected });
64
+ const routeFg = item.activeRoute === 'subscription' ? C.info
65
+ : item.activeRoute === 'api-key' ? C.warn
66
+ : item.activeRoute === 'service-oauth' ? C.value : C.dim;
67
+ const freshFg = item.authFreshness === 'expired' ? C.bad
68
+ : item.authFreshness === 'expiring' || item.authFreshness === 'pending' ? C.warn : C.dim;
69
+ return buildAlignedRow(
70
+ width,
71
+ [
72
+ { text: item.providerId, fg: item.active ? C.good : C.value, bold: selected },
73
+ { text: item.activeRoute, fg: routeFg },
74
+ { text: `${item.modelCount} mdl`, fg: C.dim },
75
+ { text: item.authFreshness, fg: freshFg },
76
+ { text: item.issues.length > 0 ? `${item.issues.length} !` : 'ok', fg: item.issues.length > 0 ? C.bad : C.good },
77
+ ],
78
+ this.listColumns,
79
+ { selected, selectedBg: C.selectBg, marker: '▸' },
80
+ );
61
81
  }
62
82
 
63
83
  public handleInput(key: string): boolean {
@@ -85,7 +105,11 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
85
105
  public render(width: number, height: number): Line[] {
86
106
  this.needsRender = false;
87
107
  const intro = 'Provider auth routes, subscription posture, quota-window hints, and routing-safety notes.';
88
- const footerLines = [buildPanelLine(width, [[' Up/Down move r refresh /accounts routes <provider> /accounts repair <provider>', C.dim]])];
108
+ const footerLines = [buildKeyboardHints(width, [
109
+ { keys: 'Up/Down', label: 'select' },
110
+ { keys: 'r', label: 'refresh' },
111
+ { keys: '/accounts repair <provider>', label: 'recover routing' },
112
+ ], C)];
89
113
  if (this.loading && this.records.length === 0) {
90
114
  const lines = buildPanelWorkspace(width, height, {
91
115
  title: 'Provider Account Control Room',
@@ -140,8 +164,8 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
140
164
  { label: 'oauth ready', value: selected.oauthReady ? 'yes' : 'no', valueColor: selected.oauthReady ? C.info : C.dim },
141
165
  { label: 'pending login', value: selected.pendingLogin ? 'yes' : 'no', valueColor: selected.pendingLogin ? C.warn : C.dim },
142
166
  ], C),
143
- buildPanelLine(width, [[` Active route reason: ${selected.activeRouteReason}`.slice(0, width), C.dim]]),
144
- buildPanelLine(width, [[` Available routes: ${selected.availableRoutes.join(', ') || 'unconfigured'}`.slice(0, width), C.dim]]),
167
+ buildPanelLine(width, [[truncateDisplay(` Active route reason: ${selected.activeRouteReason}`, width), C.dim]]),
168
+ buildPanelLine(width, [[truncateDisplay(` Available routes: ${selected.availableRoutes.join(', ') || 'unconfigured'}`, width), C.dim]]),
145
169
  ];
146
170
  if (selected.expiresAt) {
147
171
  detailRows.push(buildPanelLine(width, [
@@ -152,28 +176,28 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
152
176
  ]));
153
177
  }
154
178
  if (selected.fallbackRisk) {
155
- detailRows.push(buildPanelLine(width, [[` fallback: ${selected.fallbackRisk}`.slice(0, width), C.warn]]));
179
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` fallback: ${selected.fallbackRisk}`, width), C.warn]]));
156
180
  }
157
181
  for (const route of selected.routeRecords) {
158
182
  detailRows.push(buildPanelLine(width, [[
159
- ` route ${route.route}: ${route.usable ? 'usable' : 'blocked'} • ${route.freshness} • ${route.detail}`.slice(0, width),
183
+ truncateDisplay(` route ${route.route}: ${route.usable ? 'usable' : 'blocked'} • ${route.freshness} • ${route.detail}`, width),
160
184
  route.usable ? C.dim : C.bad,
161
185
  ]]));
162
186
  for (const issue of route.issues) {
163
- detailRows.push(buildPanelLine(width, [[` issue: ${issue}`.slice(0, width), C.bad]]));
187
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` issue: ${issue}`, width), C.bad]]));
164
188
  }
165
189
  }
166
190
  for (const windowHint of selected.usageWindows) {
167
- detailRows.push(buildPanelLine(width, [[` ${windowHint.label}: ${windowHint.detail}`.slice(0, width), C.dim]]));
191
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` ${windowHint.label}: ${windowHint.detail}`, width), C.dim]]));
168
192
  }
169
193
  for (const issue of selected.issues) {
170
- detailRows.push(buildPanelLine(width, [[` issue: ${issue}`.slice(0, width), C.bad]]));
194
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` issue: ${issue}`, width), C.bad]]));
171
195
  }
172
196
  for (const note of selected.notes) {
173
- detailRows.push(buildPanelLine(width, [[` note: ${note}`.slice(0, width), C.info]]));
197
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` note: ${note}`, width), C.info]]));
174
198
  }
175
199
  for (const action of selected.recommendedActions) {
176
- detailRows.push(buildPanelLine(width, [[` next: ${action}`.slice(0, width), C.value]]));
200
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` next: ${action}`, width), C.value]]));
177
201
  }
178
202
  if (selected.issues.length === 0 && selected.notes.length === 0 && selected.usageWindows.length === 0 && selected.recommendedActions.length === 0) {
179
203
  detailRows.push(buildPanelLine(width, [[' No active account warnings for this provider.', C.dim]]));
@@ -183,6 +207,18 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
183
207
  const rawProviderLines: Line[] = this.records.map((record, absolute) =>
184
208
  this.renderItem(record, absolute, absolute === this.selectedIndex, width),
185
209
  );
210
+ const columnHeader = buildAlignedRow(
211
+ width,
212
+ [
213
+ { text: 'provider', fg: C.label, bold: true },
214
+ { text: 'route', fg: C.label, bold: true },
215
+ { text: 'models', fg: C.label, bold: true },
216
+ { text: 'auth', fg: C.label, bold: true },
217
+ { text: 'issues', fg: C.label, bold: true },
218
+ ],
219
+ this.listColumns,
220
+ { marker: '▸' },
221
+ );
186
222
  const resolvedProvidersSection = resolvePrimaryScrollableSection(width, height, {
187
223
  intro,
188
224
  footerLines,
@@ -190,6 +226,7 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
190
226
  beforeSections: [postureSection],
191
227
  section: {
192
228
  title: 'Providers',
229
+ fixedLines: [columnHeader],
193
230
  scrollableLines: rawProviderLines,
194
231
  selectedIndex: this.selectedIndex,
195
232
  scrollOffset: this.scrollStart,