@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
@@ -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,
@@ -568,16 +447,26 @@ export function resolveScrollablePanelSection(
568
447
  afterSections: options.afterSections,
569
448
  minRows: options.section.minRows ?? 1,
570
449
  });
450
+ // When a window-summary row will render (list longer than the budget can
451
+ // show), it occupies one of the section's rows. Reserve that row up front so
452
+ // the summary is accounted INSIDE the budget; otherwise the appended summary
453
+ // pushes total output one line past the panel's exact-height contract and the
454
+ // tail line (typically the footer's keyboard-hints row) is silently dropped.
455
+ const summaryReserved = options.section.appendWindowSummary
456
+ && options.section.scrollableLines.length > budget
457
+ ? 1
458
+ : 0;
459
+ const windowBudget = Math.max(0, budget - summaryReserved);
571
460
  const window = options.section.selectedIndex === undefined
572
461
  ? getVisibleWindow(
573
462
  options.section.scrollableLines.length,
574
463
  options.section.scrollOffset,
575
- budget,
464
+ windowBudget,
576
465
  )
577
466
  : getTrackedVisibleWindow(
578
467
  options.section.scrollableLines.length,
579
468
  options.section.selectedIndex,
580
- budget,
469
+ windowBudget,
581
470
  options.section.scrollOffset,
582
471
  options.section.guardRows ?? 1,
583
472
  );
@@ -650,12 +539,21 @@ export function resolveStackedScrollableSections(
650
539
 
651
540
  return options.sections.map((section, index) => {
652
541
  const fixedLines = [...(section.fixedLines ?? [])];
542
+ // Same reservation as resolveScrollablePanelSection: a window-summary row
543
+ // occupies one of this section's allocated rows, so shrink the visible
544
+ // window rather than overflowing the stack's exact-height budget.
545
+ const budget = budgets[index]!;
546
+ const summaryReserved = section.appendWindowSummary
547
+ && section.scrollableLines.length > budget
548
+ ? 1
549
+ : 0;
550
+ const windowBudget = Math.max(0, budget - summaryReserved);
653
551
  const window = section.selectedIndex === undefined
654
- ? getVisibleWindow(section.scrollableLines.length, section.scrollOffset, budgets[index]!)
552
+ ? getVisibleWindow(section.scrollableLines.length, section.scrollOffset, windowBudget)
655
553
  : getTrackedVisibleWindow(
656
554
  section.scrollableLines.length,
657
555
  section.selectedIndex,
658
- budgets[index]!,
556
+ windowBudget,
659
557
  section.scrollOffset,
660
558
  section.guardRows ?? 1,
661
559
  );
@@ -680,6 +578,26 @@ export function resolveStackedScrollableSections(
680
578
  });
681
579
  }
682
580
 
581
+ /**
582
+ * Standardized footer keyboard-hint row so every panel's hints look identical.
583
+ * Each hint renders as `keys` (info color) + `label` (dim).
584
+ */
585
+ export function buildKeyboardHints(
586
+ width: number,
587
+ hints: ReadonlyArray<{ keys: string; label: string }>,
588
+ palette: PanelPalette,
589
+ ): Line {
590
+ const segments: Array<[string, string, string?]> = [];
591
+ hints.forEach((hint, i) => {
592
+ if (i > 0) segments.push([' ', palette.dim]);
593
+ else segments.push([' ', palette.dim]);
594
+ segments.push([hint.keys, palette.info]);
595
+ segments.push([' ', palette.dim]);
596
+ segments.push([hint.label, palette.dim]);
597
+ });
598
+ return buildPanelLine(width, segments);
599
+ }
600
+
683
601
  export function buildPanelWorkspace(
684
602
  width: number,
685
603
  height: number,
@@ -0,0 +1,134 @@
1
+ import type { ProjectPlanningQuestion } from '@pellux/goodvibes-sdk/platform/knowledge';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Answer-action helpers for the Project Planning panel.
5
+ //
6
+ // Extracted from project-planning-panel.ts to keep that module under the
7
+ // architecture line-count cap. Leaf module (only the SDK question type);
8
+ // the panel is their public import site.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export interface PlanningAnswerAction {
12
+ readonly id: string;
13
+ readonly label: string;
14
+ readonly detail: string;
15
+ readonly answer: string;
16
+ readonly kind?: 'answer' | 'approve' | 'dismiss';
17
+ readonly disabled?: boolean;
18
+ }
19
+
20
+ /** SDK placeholder recommendations that carry no real signal — never surfaced as an answer action. */
21
+ export function isGenericRecommendation(value: string): boolean {
22
+ return /\bdefine the first-pass scope\b/i.test(value)
23
+ || /\bcreate task records\b/i.test(value)
24
+ || /\brecord concrete tests\b/i.test(value)
25
+ || /\bseparate out-of-scope work\b/i.test(value);
26
+ }
27
+
28
+ function compactAnswerDetail(text: string): string {
29
+ const normalized = text.replace(/\s+/g, ' ').trim();
30
+ return normalized.length > 86 ? `${normalized.slice(0, 83)}...` : normalized;
31
+ }
32
+
33
+ /**
34
+ * Builds the answer-action list for the current question: keyword-matched
35
+ * canned suggestions first (de-duplicated by normalized answer text — a
36
+ * question that matches more than one keyword category, e.g. scope +
37
+ * recommendedAnswer echoing one of the scope answers verbatim, must not show
38
+ * the same suggested answer twice), then the fixed ask-narrower/custom/
39
+ * dismiss actions.
40
+ */
41
+ export function buildAnswerActions(question: ProjectPlanningQuestion, draftAnswer: string): PlanningAnswerAction[] {
42
+ const canned: PlanningAnswerAction[] = [];
43
+ const prompt = question.prompt.toLowerCase();
44
+ const isScopeQuestion = prompt.includes('scope') || prompt.includes('in or out');
45
+ const isTaskQuestion = prompt.includes('task') || prompt.includes('dependency') || prompt.includes('work breakdown');
46
+ const isVerificationQuestion = prompt.includes('verification') || prompt.includes('test') || prompt.includes('prove');
47
+ const isApprovalQuestion = prompt.includes('approved') || prompt.includes('approve') || prompt.includes('execution');
48
+ if (isApprovalQuestion) {
49
+ canned.push({
50
+ id: 'approve-execution',
51
+ label: 'Approve execution',
52
+ detail: 'Mark this plan approved so execution may proceed.',
53
+ answer: 'Approve this planning state for execution.',
54
+ kind: 'approve',
55
+ });
56
+ }
57
+ if (isScopeQuestion) {
58
+ canned.push({
59
+ id: 'scope-focused-first-pass',
60
+ label: 'Use focused first-pass scope',
61
+ detail: 'Fill a concrete end-to-end scope for this goal and keep unrelated work out.',
62
+ answer: 'Use a focused first-pass scope for this goal.',
63
+ });
64
+ }
65
+ if (isTaskQuestion) {
66
+ canned.push({
67
+ id: 'tasks-default-breakdown',
68
+ label: 'Create default task breakdown',
69
+ detail: 'Create inspect, implement, wire, and verify tasks with dependencies.',
70
+ answer: 'Create the default task breakdown for this goal.',
71
+ });
72
+ }
73
+ if (isVerificationQuestion) {
74
+ canned.push({
75
+ id: 'verification-default-gates',
76
+ label: 'Use standard verification gates',
77
+ detail: 'Require focused regression coverage, typecheck/build validation, and a runtime smoke where feasible.',
78
+ answer: 'Use standard verification gates for this goal.',
79
+ });
80
+ }
81
+ if (question.recommendedAnswer?.trim() && !isGenericRecommendation(question.recommendedAnswer)) {
82
+ canned.push({
83
+ id: 'recommended',
84
+ label: 'Use recommended answer',
85
+ detail: compactAnswerDetail(question.recommendedAnswer),
86
+ answer: question.recommendedAnswer,
87
+ });
88
+ }
89
+ if (isScopeQuestion) {
90
+ canned.push({
91
+ id: 'scope-end-to-end',
92
+ label: 'End-to-end required scope',
93
+ detail: 'Let the plan include every component needed to make this work, but avoid unrelated cleanup.',
94
+ answer: 'Scope is everything required to make the requested outcome work end-to-end. Include TUI, daemon composition, configuration, docs, and tests if they are required. Do not include unrelated cleanup or broad refactors unless they are necessary for this task.',
95
+ });
96
+ canned.push({
97
+ id: 'scope-tui-first',
98
+ label: 'TUI-first scope',
99
+ detail: 'Fix TUI behavior here; report SDK blockers instead of patching around SDK-owned bugs.',
100
+ answer: 'Scope is TUI-owned behavior first. If a blocker is SDK-owned, report the exact SDK contract/runtime issue instead of patching around it in the TUI. Include daemon composition only where the TUI owns the wiring.',
101
+ });
102
+ }
103
+
104
+ const seenAnswers = new Set<string>();
105
+ const actions: PlanningAnswerAction[] = [];
106
+ for (const action of canned) {
107
+ const key = action.answer.trim().toLowerCase().replace(/\s+/g, ' ');
108
+ if (key.length > 0 && seenAnswers.has(key)) continue;
109
+ if (key.length > 0) seenAnswers.add(key);
110
+ actions.push(action);
111
+ }
112
+
113
+ actions.push({
114
+ id: 'ask-narrower',
115
+ label: 'I am not sure yet',
116
+ detail: 'Break this into smaller concrete choices with examples and a recommended default.',
117
+ answer: `I do not know enough to answer "${question.prompt}" as asked. Break it into smaller concrete questions with 2-4 specific choices, explain the tradeoffs, recommend a default, and ask me the first one.`,
118
+ });
119
+ actions.push({
120
+ id: 'custom',
121
+ label: 'Submit typed answer',
122
+ detail: draftAnswer ? compactAnswerDetail(draftAnswer) : 'Type an answer first; this row becomes the custom answer.',
123
+ answer: draftAnswer.trim(),
124
+ disabled: !draftAnswer.trim(),
125
+ });
126
+ actions.push({
127
+ id: 'dismiss-planning',
128
+ label: 'Close planning and continue without it',
129
+ detail: 'Pause project planning for this workspace. Normal chat continues; /plan can reopen it later.',
130
+ answer: 'Pause project planning for this workspace and continue without the planning panel.',
131
+ kind: 'dismiss',
132
+ });
133
+ return actions;
134
+ }