@pellux/goodvibes-tui 0.19.24 → 0.19.26

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 (76) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +5 -5
  3. package/bin/goodvibes +10 -0
  4. package/bin/goodvibes-daemon +10 -0
  5. package/docs/foundation-artifacts/operator-contract.json +1 -1
  6. package/package.json +3 -2
  7. package/src/cli/bundle-command.ts +225 -0
  8. package/src/cli/completion.ts +90 -0
  9. package/src/cli/config-overrides.ts +159 -0
  10. package/src/cli/endpoints.ts +63 -0
  11. package/src/cli/entrypoint.ts +169 -0
  12. package/src/cli/help.ts +301 -0
  13. package/src/cli/index.ts +11 -0
  14. package/src/cli/management-commands.ts +426 -0
  15. package/src/cli/management.ts +719 -0
  16. package/src/cli/network-posture.ts +46 -0
  17. package/src/cli/package-verification.ts +119 -0
  18. package/src/cli/parser.ts +369 -0
  19. package/src/cli/provider-classification.ts +107 -0
  20. package/src/cli/redaction.ts +105 -0
  21. package/src/cli/service-command.ts +45 -0
  22. package/src/cli/service-posture.ts +247 -0
  23. package/src/cli/status.ts +382 -0
  24. package/src/cli/surface-command.ts +248 -0
  25. package/src/cli/tui-startup.ts +32 -0
  26. package/src/cli/types.ts +69 -0
  27. package/src/cli-flags.ts +18 -55
  28. package/src/config/index.ts +1 -1
  29. package/src/config/secrets.ts +44 -0
  30. package/src/daemon/cli.ts +62 -11
  31. package/src/input/command-registry.ts +3 -0
  32. package/src/input/commands/guidance-runtime.ts +9 -4
  33. package/src/input/commands/local-runtime.ts +21 -7
  34. package/src/input/commands/local-setup.ts +31 -38
  35. package/src/input/commands/onboarding-runtime.ts +14 -0
  36. package/src/input/commands/runtime-services.ts +9 -0
  37. package/src/input/commands.ts +2 -0
  38. package/src/input/feed-context-factory.ts +8 -1
  39. package/src/input/handler-feed.ts +13 -8
  40. package/src/input/handler-interactions.ts +266 -0
  41. package/src/input/handler-modal-stack.ts +23 -3
  42. package/src/input/handler-modal-token-routes.ts +23 -1
  43. package/src/input/handler-onboarding.ts +696 -0
  44. package/src/input/handler-picker-routes.ts +15 -7
  45. package/src/input/handler-ui-state.ts +58 -0
  46. package/src/input/handler.ts +120 -246
  47. package/src/input/onboarding/handler-onboarding-routes.ts +105 -0
  48. package/src/input/onboarding/onboarding-wizard-apply.ts +211 -0
  49. package/src/input/onboarding/onboarding-wizard-constants.ts +148 -0
  50. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +712 -0
  51. package/src/input/onboarding/onboarding-wizard-helpers.ts +218 -0
  52. package/src/input/onboarding/onboarding-wizard-rules.ts +224 -0
  53. package/src/input/onboarding/onboarding-wizard-state.ts +354 -0
  54. package/src/input/onboarding/onboarding-wizard-steps.ts +642 -0
  55. package/src/input/onboarding/onboarding-wizard-types.ts +170 -0
  56. package/src/input/onboarding/onboarding-wizard.ts +594 -0
  57. package/src/main.ts +32 -39
  58. package/src/panels/builtin/operations.ts +0 -10
  59. package/src/panels/index.ts +0 -1
  60. package/src/renderer/conversation-overlays.ts +6 -0
  61. package/src/renderer/help-overlay.ts +1 -1
  62. package/src/renderer/onboarding/onboarding-wizard.ts +533 -0
  63. package/src/runtime/bootstrap-core.ts +1 -0
  64. package/src/runtime/bootstrap.ts +123 -0
  65. package/src/runtime/onboarding/apply.ts +685 -0
  66. package/src/runtime/onboarding/derivation.ts +495 -0
  67. package/src/runtime/onboarding/index.ts +7 -0
  68. package/src/runtime/onboarding/markers.ts +161 -0
  69. package/src/runtime/onboarding/snapshot.ts +400 -0
  70. package/src/runtime/onboarding/state.ts +140 -0
  71. package/src/runtime/onboarding/types.ts +402 -0
  72. package/src/runtime/onboarding/verify.ts +233 -0
  73. package/src/runtime/ui-services.ts +16 -0
  74. package/src/shell/ui-openers.ts +12 -2
  75. package/src/version.ts +1 -1
  76. package/src/panels/welcome-panel.ts +0 -64
@@ -0,0 +1,533 @@
1
+ import type { Line } from '../../types/grid.ts';
2
+ import { createStyledCell } from '../../types/grid.ts';
3
+ import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText } from '../../utils/terminal-width.ts';
4
+ import {
5
+ createOverlayBoxLayout,
6
+ createOverlayContentLine,
7
+ createOverlayFilledBorderLine,
8
+ DEFAULT_OVERLAY_PALETTE,
9
+ OVERLAY_GLYPHS,
10
+ putOverlayText,
11
+ } from '../overlay-box.ts';
12
+ import { UI_TONES } from '../ui-primitives.ts';
13
+ import {
14
+ getOnboardingWizardBodyRows,
15
+ getOnboardingWizardVisibleFieldCount,
16
+ type OnboardingWizardController,
17
+ type OnboardingWizardFieldDefinition,
18
+ type OnboardingWizardStepDefinition,
19
+ } from '../../input/onboarding/onboarding-wizard.ts';
20
+
21
+ type RenderedFieldRow =
22
+ | { readonly kind: 'empty' }
23
+ | { readonly kind: 'moreAbove'; readonly text: string }
24
+ | { readonly kind: 'moreBelow'; readonly text: string }
25
+ | {
26
+ readonly kind: 'field';
27
+ readonly field: OnboardingWizardFieldDefinition;
28
+ readonly absoluteIndex: number;
29
+ readonly line: 0 | 1;
30
+ };
31
+
32
+ function clamp(value: number, min: number, max: number): number {
33
+ return Math.max(min, Math.min(max, value));
34
+ }
35
+
36
+ function fillRange(line: Line, startX: number, width: number, bg: string): void {
37
+ for (let x = startX; x < Math.min(line.length, startX + width); x += 1) {
38
+ const cell = line[x];
39
+ if (!cell) continue;
40
+ line[x] = createStyledCell(cell.char, {
41
+ fg: cell.fg,
42
+ bg,
43
+ bold: cell.bold,
44
+ dim: cell.dim,
45
+ underline: cell.underline,
46
+ italic: cell.italic,
47
+ strikethrough: cell.strikethrough,
48
+ link: cell.link,
49
+ });
50
+ }
51
+ }
52
+
53
+ function drawVerticalRule(line: Line, x: number, fg: string, bg = ''): void {
54
+ if (x < 0 || x >= line.length) return;
55
+ line[x] = createStyledCell('│', { fg, bg });
56
+ }
57
+
58
+ function modeLabel(mode: OnboardingWizardController['mode']): string {
59
+ if (mode === 'edit') return 'Edit existing';
60
+ if (mode === 'reopen') return 'Reopen review';
61
+ return 'New setup';
62
+ }
63
+
64
+ function stepGlyph(
65
+ wizard: OnboardingWizardController,
66
+ step: OnboardingWizardStepDefinition,
67
+ stepIndex: number,
68
+ ): { readonly glyph: string; readonly fg: string } {
69
+ if (stepIndex === wizard.stepIndex) {
70
+ return { glyph: OVERLAY_GLYPHS.selected, fg: UI_TONES.state.active };
71
+ }
72
+
73
+ const total = wizard.getStepFieldCount(stepIndex);
74
+ const completed = wizard.getCompletedFieldCount(stepIndex);
75
+ if (wizard.isStepDirty(stepIndex)) {
76
+ return { glyph: '◈', fg: UI_TONES.state.warn };
77
+ }
78
+ if (total > 0 && completed === total) {
79
+ return { glyph: '✓', fg: UI_TONES.state.good };
80
+ }
81
+ return { glyph: '•', fg: UI_TONES.fg.muted };
82
+ }
83
+
84
+ function fieldBadgeTone(
85
+ wizard: OnboardingWizardController,
86
+ field: OnboardingWizardFieldDefinition,
87
+ ): string {
88
+ if (field.kind === 'status') return UI_TONES.state.info;
89
+ if (field.kind === 'modelPicker') return UI_TONES.state.info;
90
+ if (field.kind === 'acknowledgement') {
91
+ const label = wizard.getFieldValueLabel(field);
92
+ return label === 'Accepted' ? UI_TONES.state.good : label === 'Pending' ? UI_TONES.state.warn : UI_TONES.fg.muted;
93
+ }
94
+ if (field.kind === 'checklist') {
95
+ return wizard.getFieldValue(field) ? UI_TONES.state.good : UI_TONES.fg.muted;
96
+ }
97
+ if (field.kind === 'radio') return UI_TONES.state.active;
98
+ if (field.kind === 'masked') return UI_TONES.state.warn;
99
+ return UI_TONES.fg.secondary;
100
+ }
101
+
102
+ function buildFieldRows(
103
+ wizard: OnboardingWizardController,
104
+ visibleFields: number,
105
+ capacity: number,
106
+ ): readonly RenderedFieldRow[] {
107
+ const fieldWindow = wizard.getFieldWindow(visibleFields);
108
+ const rows: RenderedFieldRow[] = [];
109
+
110
+ if (fieldWindow.start > 0) {
111
+ rows.push({
112
+ kind: 'moreAbove',
113
+ text: `${OVERLAY_GLYPHS.moreAbove} ${fieldWindow.start} more above`,
114
+ });
115
+ }
116
+
117
+ fieldWindow.fields.forEach((field, index) => {
118
+ const absoluteIndex = fieldWindow.start + index;
119
+ rows.push({ kind: 'field', field, absoluteIndex, line: 0 });
120
+ rows.push({ kind: 'field', field, absoluteIndex, line: 1 });
121
+ });
122
+
123
+ if (fieldWindow.end < fieldWindow.total) {
124
+ rows.push({
125
+ kind: 'moreBelow',
126
+ text: `${OVERLAY_GLYPHS.moreBelow} ${fieldWindow.total - fieldWindow.end} more below`,
127
+ });
128
+ }
129
+
130
+ while (rows.length < capacity) rows.push({ kind: 'empty' });
131
+ return rows.slice(0, capacity);
132
+ }
133
+
134
+ function fieldHint(
135
+ wizard: OnboardingWizardController,
136
+ field: OnboardingWizardFieldDefinition,
137
+ selected: boolean,
138
+ ): string {
139
+ if (
140
+ selected
141
+ && wizard.isEditingTextField()
142
+ && wizard.editingFieldId === field.id
143
+ && (field.kind === 'text' || field.kind === 'masked')
144
+ ) {
145
+ const rawValue = wizard.editBuffer.length > 0 ? wizard.editBuffer : field.placeholder;
146
+ const editingValue = field.kind === 'masked' && wizard.editBuffer.length > 0
147
+ ? '•'.repeat(Math.min(12, Math.max(4, wizard.editBuffer.length)))
148
+ : rawValue;
149
+ return `Editing: ${editingValue}█`;
150
+ }
151
+
152
+ if (selected && field.kind === 'modelPicker') return `${field.hint} Press Enter to open picker.`;
153
+ if (selected && field.kind === 'text') return `${field.hint} Press Enter to edit inline.`;
154
+ if (selected && field.kind === 'masked') return `${field.hint} Press Enter to edit inline.`;
155
+ return field.hint;
156
+ }
157
+
158
+ function renderFieldRow(
159
+ line: Line,
160
+ wizard: OnboardingWizardController,
161
+ fieldRow: RenderedFieldRow,
162
+ startX: number,
163
+ width: number,
164
+ ): void {
165
+ if (fieldRow.kind === 'empty') return;
166
+
167
+ if (fieldRow.kind === 'moreAbove' || fieldRow.kind === 'moreBelow') {
168
+ putOverlayText(line, startX + 1, width - 2, truncateDisplay(fieldRow.text, width - 2), {
169
+ fg: UI_TONES.fg.muted,
170
+ bg: UI_TONES.bg.base,
171
+ dim: true,
172
+ });
173
+ return;
174
+ }
175
+
176
+ const selected = fieldRow.absoluteIndex === wizard.getSelectedFieldIndex();
177
+ const field = fieldRow.field;
178
+ const fieldBg = selected ? DEFAULT_OVERLAY_PALETTE.selectedBg : UI_TONES.bg.base;
179
+ fillRange(line, startX, width, fieldBg);
180
+
181
+ if (fieldRow.line === 0) {
182
+ const badge = truncateDisplay(`[${wizard.getFieldValueLabel(field)}]`, Math.max(8, Math.floor(width * 0.38)));
183
+ const badgeWidth = getDisplayWidth(badge);
184
+ const prefix = selected ? `${OVERLAY_GLYPHS.selected} ` : wizard.isFieldDirty(field.id) ? '◇ ' : ' ';
185
+ const labelWidth = Math.max(0, width - badgeWidth - 4);
186
+
187
+ putOverlayText(line, startX + 1, labelWidth, truncateDisplay(`${prefix}${field.label}`, labelWidth), {
188
+ fg: UI_TONES.fg.primary,
189
+ bg: fieldBg,
190
+ bold: selected,
191
+ });
192
+ putOverlayText(line, startX + width - badgeWidth - 1, badgeWidth, badge, {
193
+ fg: fieldBadgeTone(wizard, field),
194
+ bg: fieldBg,
195
+ bold: selected,
196
+ });
197
+ return;
198
+ }
199
+
200
+ putOverlayText(line, startX + 3, Math.max(0, width - 4), truncateDisplay(fieldHint(wizard, field, selected), Math.max(0, width - 4)), {
201
+ fg: selected ? UI_TONES.fg.secondary : UI_TONES.fg.muted,
202
+ bg: fieldBg,
203
+ dim: !selected,
204
+ });
205
+ }
206
+
207
+ function footerText(wizard: OnboardingWizardController): string {
208
+ if (wizard.isEditingTextField()) {
209
+ return '[Enter] Save [Esc] Cancel [Backspace] Delete [Type] Edit value';
210
+ }
211
+
212
+ return '[Tab/Shift+Tab] Step [↑↓/j/k] Move [Enter/Space] Toggle/Open [1-9] Jump [Esc] Close';
213
+ }
214
+
215
+ function renderWideLayout(
216
+ wizard: OnboardingWizardController,
217
+ width: number,
218
+ viewportHeight: number,
219
+ layout: ReturnType<typeof createOverlayBoxLayout>,
220
+ ): Line[] {
221
+ const lines: Line[] = [];
222
+ const bodyRows = getOnboardingWizardBodyRows(viewportHeight);
223
+ const visibleFields = getOnboardingWizardVisibleFieldCount(viewportHeight);
224
+ const currentStep = wizard.currentStep;
225
+ const borderFg = DEFAULT_OVERLAY_PALETTE.borderFg;
226
+ const headerBg = UI_TONES.bg.title;
227
+ const railBg = UI_TONES.bg.section;
228
+ const bodyBg = UI_TONES.bg.base;
229
+ const summaryBg = UI_TONES.bg.summary;
230
+ const innerLeft = layout.margin + 1;
231
+ const availableInner = layout.innerWidth - 2;
232
+ const leftWidthBase = layout.innerWidth >= 108 ? 22 : 18;
233
+ const rightWidthBase = layout.innerWidth >= 108 ? 30 : 24;
234
+ const minCenterWidth = 34;
235
+ let leftWidth = Math.min(leftWidthBase, Math.max(16, availableInner - minCenterWidth - 12));
236
+ let rightWidth = Math.min(rightWidthBase, Math.max(20, availableInner - minCenterWidth - leftWidth));
237
+ let centerWidth = layout.innerWidth - leftWidth - rightWidth - 2;
238
+
239
+ if (centerWidth < minCenterWidth) {
240
+ const deficit = minCenterWidth - centerWidth;
241
+ const leftCut = Math.min(Math.max(0, leftWidth - 16), Math.ceil(deficit / 2));
242
+ leftWidth -= leftCut;
243
+ rightWidth -= Math.min(Math.max(0, rightWidth - 20), deficit - leftCut);
244
+ centerWidth = layout.innerWidth - leftWidth - rightWidth - 2;
245
+ }
246
+
247
+ const leftStart = innerLeft;
248
+ const leftSeparatorX = leftStart + leftWidth;
249
+ const centerStart = leftSeparatorX + 1;
250
+ const rightSeparatorX = centerStart + centerWidth;
251
+ const rightStart = rightSeparatorX + 1;
252
+ const descriptionLines = wrapText(currentStep.description, Math.max(18, centerWidth - 2)).slice(0, 2);
253
+ const summaryLines = [
254
+ currentStep.summaryTitle,
255
+ ...currentStep.summaryLines,
256
+ `Fields ${wizard.getCompletedFieldCount(wizard.stepIndex)}/${wizard.getStepFieldCount(wizard.stepIndex)} complete`,
257
+ `Dirty steps ${wizard.dirtyStepCount}`,
258
+ `Pending picker ${wizard.pendingModelPickerTarget ?? 'none'}`,
259
+ ];
260
+ const fieldRows = buildFieldRows(wizard, visibleFields, Math.max(0, bodyRows - 3));
261
+
262
+ const topLine = createOverlayFilledBorderLine(
263
+ width,
264
+ layout,
265
+ OVERLAY_GLYPHS.topLeft,
266
+ OVERLAY_GLYPHS.horizontal,
267
+ OVERLAY_GLYPHS.topRight,
268
+ borderFg,
269
+ headerBg,
270
+ );
271
+ putOverlayText(topLine, layout.margin + 2, layout.width - 4, 'Onboarding Wizard', {
272
+ fg: UI_TONES.fg.primary,
273
+ bg: headerBg,
274
+ bold: true,
275
+ });
276
+ const meta = `${modeLabel(wizard.mode)} ${wizard.stepIndex + 1}/${wizard.steps.length} dirty ${wizard.dirtyStepCount}`;
277
+ putOverlayText(topLine, Math.max(layout.margin + 2, layout.margin + layout.width - getDisplayWidth(meta) - 3), layout.width - 4, meta, {
278
+ fg: UI_TONES.fg.secondary,
279
+ bg: headerBg,
280
+ });
281
+ lines.push(topLine);
282
+
283
+ const headerLine = createOverlayContentLine(width, layout, borderFg, headerBg);
284
+ fillRange(headerLine, leftStart, leftWidth, railBg);
285
+ fillRange(headerLine, centerStart, centerWidth, headerBg);
286
+ fillRange(headerLine, rightStart, rightWidth, summaryBg);
287
+ drawVerticalRule(headerLine, leftSeparatorX, borderFg, headerBg);
288
+ drawVerticalRule(headerLine, rightSeparatorX, borderFg, headerBg);
289
+ putOverlayText(headerLine, leftStart + 1, leftWidth - 2, 'Steps', {
290
+ fg: UI_TONES.fg.secondary,
291
+ bg: railBg,
292
+ bold: true,
293
+ });
294
+ putOverlayText(headerLine, centerStart + 1, centerWidth - 2, truncateDisplay(currentStep.title, centerWidth - 2), {
295
+ fg: UI_TONES.state.active,
296
+ bg: headerBg,
297
+ bold: true,
298
+ });
299
+ putOverlayText(headerLine, rightStart + 1, rightWidth - 2, 'Summary Rail', {
300
+ fg: UI_TONES.fg.secondary,
301
+ bg: summaryBg,
302
+ bold: true,
303
+ });
304
+ lines.push(headerLine);
305
+
306
+ lines.push(createOverlayFilledBorderLine(
307
+ width,
308
+ layout,
309
+ OVERLAY_GLYPHS.teeLeft,
310
+ OVERLAY_GLYPHS.horizontal,
311
+ OVERLAY_GLYPHS.teeRight,
312
+ borderFg,
313
+ bodyBg,
314
+ ));
315
+
316
+ for (let row = 0; row < bodyRows; row += 1) {
317
+ const line = createOverlayContentLine(width, layout, borderFg, bodyBg);
318
+ fillRange(line, leftStart, leftWidth, railBg);
319
+ fillRange(line, centerStart, centerWidth, bodyBg);
320
+ fillRange(line, rightStart, rightWidth, summaryBg);
321
+ drawVerticalRule(line, leftSeparatorX, borderFg);
322
+ drawVerticalRule(line, rightSeparatorX, borderFg);
323
+
324
+ if (row === 0) {
325
+ putOverlayText(line, centerStart + 1, centerWidth - 2, truncateDisplay(currentStep.title, centerWidth - 2), {
326
+ fg: UI_TONES.fg.primary,
327
+ bg: bodyBg,
328
+ bold: true,
329
+ });
330
+ } else if (row === 1) {
331
+ putOverlayText(line, centerStart + 1, centerWidth - 2, descriptionLines[0] ?? '', {
332
+ fg: UI_TONES.fg.secondary,
333
+ bg: bodyBg,
334
+ });
335
+ } else if (row === 2) {
336
+ const status = fitDisplay(
337
+ `Fields ${wizard.getCompletedFieldCount(wizard.stepIndex)}/${wizard.getStepFieldCount(wizard.stepIndex)} complete • ${modeLabel(wizard.mode)}`,
338
+ centerWidth - 2,
339
+ );
340
+ putOverlayText(line, centerStart + 1, centerWidth - 2, descriptionLines[1] ?? status, {
341
+ fg: UI_TONES.fg.secondary,
342
+ bg: bodyBg,
343
+ });
344
+ } else {
345
+ renderFieldRow(line, wizard, fieldRows[row - 3] ?? { kind: 'empty' }, centerStart, centerWidth);
346
+ }
347
+
348
+ const step = wizard.steps[row] ?? null;
349
+ if (step) {
350
+ const stepState = stepGlyph(wizard, step, row);
351
+ const completion = `${wizard.getCompletedFieldCount(row)}/${wizard.getStepFieldCount(row)}`;
352
+ putOverlayText(line, leftStart + 1, leftWidth - 2, truncateDisplay(`${stepState.glyph} ${row + 1}. ${step.shortLabel}`, leftWidth - 2), {
353
+ fg: stepState.fg,
354
+ bg: railBg,
355
+ bold: row === wizard.stepIndex,
356
+ });
357
+ putOverlayText(line, Math.max(leftStart + 1, leftStart + leftWidth - getDisplayWidth(completion) - 2), leftWidth - 2, completion, {
358
+ fg: wizard.isStepDirty(row) ? UI_TONES.state.warn : UI_TONES.fg.muted,
359
+ bg: railBg,
360
+ });
361
+ }
362
+
363
+ const summaryText = summaryLines[row];
364
+ if (summaryText) {
365
+ putOverlayText(line, rightStart + 1, rightWidth - 2, truncateDisplay(summaryText, rightWidth - 2), {
366
+ fg: row === 0 ? UI_TONES.state.info : UI_TONES.fg.secondary,
367
+ bg: summaryBg,
368
+ bold: row === 0,
369
+ dim: row > 0,
370
+ });
371
+ }
372
+
373
+ lines.push(line);
374
+ }
375
+
376
+ lines.push(createOverlayFilledBorderLine(
377
+ width,
378
+ layout,
379
+ OVERLAY_GLYPHS.teeLeft,
380
+ OVERLAY_GLYPHS.horizontal,
381
+ OVERLAY_GLYPHS.teeRight,
382
+ borderFg,
383
+ bodyBg,
384
+ ));
385
+
386
+ const footer = createOverlayFilledBorderLine(
387
+ width,
388
+ layout,
389
+ OVERLAY_GLYPHS.bottomLeft,
390
+ OVERLAY_GLYPHS.horizontal,
391
+ OVERLAY_GLYPHS.bottomRight,
392
+ borderFg,
393
+ headerBg,
394
+ );
395
+ putOverlayText(footer, layout.margin + 2, layout.width - 4, truncateDisplay(footerText(wizard), layout.width - 4), {
396
+ fg: UI_TONES.fg.muted,
397
+ bg: headerBg,
398
+ dim: true,
399
+ });
400
+ lines.push(footer);
401
+
402
+ return lines.slice(0, viewportHeight);
403
+ }
404
+
405
+ function renderCollapsedLayout(
406
+ wizard: OnboardingWizardController,
407
+ width: number,
408
+ viewportHeight: number,
409
+ layout: ReturnType<typeof createOverlayBoxLayout>,
410
+ ): Line[] {
411
+ const lines: Line[] = [];
412
+ const bodyRows = getOnboardingWizardBodyRows(viewportHeight);
413
+ const visibleFields = getOnboardingWizardVisibleFieldCount(viewportHeight);
414
+ const currentStep = wizard.currentStep;
415
+ const borderFg = DEFAULT_OVERLAY_PALETTE.borderFg;
416
+ const headerBg = UI_TONES.bg.title;
417
+ const bodyBg = UI_TONES.bg.base;
418
+ const innerStart = layout.margin + 1;
419
+ const innerWidth = layout.innerWidth;
420
+ const descriptionLines = wrapText(currentStep.description, Math.max(14, innerWidth - 2)).slice(0, 2);
421
+ const fieldRows = buildFieldRows(wizard, visibleFields, Math.max(0, bodyRows - 3));
422
+
423
+ const topLine = createOverlayFilledBorderLine(
424
+ width,
425
+ layout,
426
+ OVERLAY_GLYPHS.topLeft,
427
+ OVERLAY_GLYPHS.horizontal,
428
+ OVERLAY_GLYPHS.topRight,
429
+ borderFg,
430
+ headerBg,
431
+ );
432
+ putOverlayText(topLine, layout.margin + 2, layout.width - 4, 'Onboarding Wizard', {
433
+ fg: UI_TONES.fg.primary,
434
+ bg: headerBg,
435
+ bold: true,
436
+ });
437
+ const meta = `${wizard.stepIndex + 1}/${wizard.steps.length} • dirty ${wizard.dirtyStepCount}`;
438
+ putOverlayText(topLine, Math.max(layout.margin + 2, layout.margin + layout.width - getDisplayWidth(meta) - 3), layout.width - 4, meta, {
439
+ fg: UI_TONES.fg.secondary,
440
+ bg: headerBg,
441
+ });
442
+ lines.push(topLine);
443
+
444
+ const headerLine = createOverlayContentLine(width, layout, borderFg, headerBg);
445
+ fillRange(headerLine, innerStart, innerWidth, headerBg);
446
+ putOverlayText(headerLine, innerStart + 1, innerWidth - 2, fitDisplay(`${modeLabel(wizard.mode)} • ${currentStep.shortLabel}`, innerWidth - 2), {
447
+ fg: UI_TONES.state.active,
448
+ bg: headerBg,
449
+ bold: true,
450
+ });
451
+ lines.push(headerLine);
452
+
453
+ lines.push(createOverlayFilledBorderLine(
454
+ width,
455
+ layout,
456
+ OVERLAY_GLYPHS.teeLeft,
457
+ OVERLAY_GLYPHS.horizontal,
458
+ OVERLAY_GLYPHS.teeRight,
459
+ borderFg,
460
+ bodyBg,
461
+ ));
462
+
463
+ for (let row = 0; row < bodyRows; row += 1) {
464
+ const line = createOverlayContentLine(width, layout, borderFg, bodyBg);
465
+ fillRange(line, innerStart, innerWidth, bodyBg);
466
+
467
+ if (row === 0) {
468
+ putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(currentStep.title, innerWidth - 2), {
469
+ fg: UI_TONES.fg.primary,
470
+ bg: bodyBg,
471
+ bold: true,
472
+ });
473
+ } else if (row === 1) {
474
+ putOverlayText(line, innerStart + 1, innerWidth - 2, descriptionLines[0] ?? '', {
475
+ fg: UI_TONES.fg.secondary,
476
+ bg: bodyBg,
477
+ });
478
+ } else if (row === 2) {
479
+ const compactMeta = fitDisplay(
480
+ `${currentStep.summaryTitle} • ${wizard.getCompletedFieldCount(wizard.stepIndex)}/${wizard.getStepFieldCount(wizard.stepIndex)} complete`,
481
+ innerWidth - 2,
482
+ );
483
+ putOverlayText(line, innerStart + 1, innerWidth - 2, descriptionLines[1] ?? compactMeta, {
484
+ fg: UI_TONES.fg.secondary,
485
+ bg: bodyBg,
486
+ });
487
+ } else {
488
+ renderFieldRow(line, wizard, fieldRows[row - 3] ?? { kind: 'empty' }, innerStart, innerWidth);
489
+ }
490
+
491
+ lines.push(line);
492
+ }
493
+
494
+ lines.push(createOverlayFilledBorderLine(
495
+ width,
496
+ layout,
497
+ OVERLAY_GLYPHS.teeLeft,
498
+ OVERLAY_GLYPHS.horizontal,
499
+ OVERLAY_GLYPHS.teeRight,
500
+ borderFg,
501
+ bodyBg,
502
+ ));
503
+
504
+ const footer = createOverlayFilledBorderLine(
505
+ width,
506
+ layout,
507
+ OVERLAY_GLYPHS.bottomLeft,
508
+ OVERLAY_GLYPHS.horizontal,
509
+ OVERLAY_GLYPHS.bottomRight,
510
+ borderFg,
511
+ headerBg,
512
+ );
513
+ putOverlayText(footer, layout.margin + 2, layout.width - 4, truncateDisplay(footerText(wizard), layout.width - 4), {
514
+ fg: UI_TONES.fg.muted,
515
+ bg: headerBg,
516
+ dim: true,
517
+ });
518
+ lines.push(footer);
519
+
520
+ return lines.slice(0, viewportHeight);
521
+ }
522
+
523
+ export function renderOnboardingWizard(
524
+ wizard: OnboardingWizardController,
525
+ width: number,
526
+ viewportHeight: number,
527
+ ): Line[] {
528
+ const layout = createOverlayBoxLayout(width, 0, width);
529
+ const collapsed = layout.innerWidth < 86;
530
+ return collapsed
531
+ ? renderCollapsedLayout(wizard, width, viewportHeight, layout)
532
+ : renderWideLayout(wizard, width, viewportHeight, layout);
533
+ }
@@ -432,6 +432,7 @@ export async function initializeBootstrapCore(
432
432
  createPermissionConfigReader(configManager),
433
433
  policyRuntimeState,
434
434
  services.hookDispatcher,
435
+ featureFlags,
435
436
  );
436
437
  await hookWorkbench.loadAndApplyManagedHooks();
437
438
 
@@ -10,6 +10,7 @@
10
10
  * - lifecycle.ts: save/shutdown helpers
11
11
  */
12
12
  import { join } from 'node:path';
13
+ import net from 'node:net';
13
14
  import { Orchestrator } from '../core/orchestrator.ts';
14
15
  import { AcpManager } from '@pellux/goodvibes-sdk/platform/acp/manager';
15
16
  import { getTierPromptSupplement, getTierForContextWindow } from '@pellux/goodvibes-sdk/platform/providers/tier-prompts';
@@ -292,6 +293,83 @@ export async function bootstrapRuntime(
292
293
 
293
294
  const deferredStartup = createDeferredStartupCoordinator();
294
295
 
296
+ interface ExternalServiceBindingSnapshot {
297
+ readonly daemon: {
298
+ readonly host: string;
299
+ readonly port: number;
300
+ };
301
+ readonly httpListener: {
302
+ readonly host: string;
303
+ readonly port: number;
304
+ };
305
+ }
306
+
307
+ interface ExternalServicePortState {
308
+ readonly daemonPortInUse: boolean;
309
+ readonly httpListenerPortInUse: boolean;
310
+ }
311
+
312
+ const readExternalServiceBindings = (): ExternalServiceBindingSnapshot => ({
313
+ daemon: {
314
+ host: String(configManager.get('controlPlane.host') ?? '127.0.0.1'),
315
+ port: Number(configManager.get('controlPlane.port') ?? 3421),
316
+ },
317
+ httpListener: {
318
+ host: String(configManager.get('httpListener.host') ?? '127.0.0.1'),
319
+ port: Number(configManager.get('httpListener.port') ?? 3422),
320
+ },
321
+ });
322
+
323
+ const getProbeHosts = (host: string): readonly string[] => {
324
+ const normalized = host.trim().toLowerCase();
325
+ if (normalized === '0.0.0.0') return ['127.0.0.1'];
326
+ if (normalized === '::' || normalized === '[::]') return ['::1'];
327
+ if (normalized.length === 0) return ['127.0.0.1'];
328
+ return [host];
329
+ };
330
+
331
+ const isTcpPortInUse = async (host: string, port: number): Promise<boolean> => new Promise((resolve) => {
332
+ const socket = new net.Socket();
333
+ let settled = false;
334
+ const finish = (result: boolean): void => {
335
+ if (settled) return;
336
+ settled = true;
337
+ socket.destroy();
338
+ resolve(result);
339
+ };
340
+
341
+ socket.setTimeout(250);
342
+ socket.once('connect', () => finish(true));
343
+ socket.once('timeout', () => finish(false));
344
+ socket.once('error', () => finish(false));
345
+ socket.connect(port, host);
346
+ });
347
+
348
+ const inspectExternalPorts = async (
349
+ bindings: readonly ExternalServiceBindingSnapshot[],
350
+ ): Promise<ExternalServicePortState> => {
351
+ const daemonTargets = new Map<string, { readonly host: string; readonly port: number }>();
352
+ const listenerTargets = new Map<string, { readonly host: string; readonly port: number }>();
353
+ for (const binding of bindings) {
354
+ for (const host of getProbeHosts(binding.daemon.host)) {
355
+ daemonTargets.set(`${host}:${binding.daemon.port}`, { host, port: binding.daemon.port });
356
+ }
357
+ for (const host of getProbeHosts(binding.httpListener.host)) {
358
+ listenerTargets.set(`${host}:${binding.httpListener.port}`, { host, port: binding.httpListener.port });
359
+ }
360
+ }
361
+
362
+ const [daemonResults, listenerResults] = await Promise.all([
363
+ Promise.all([...daemonTargets.values()].map((target) => isTcpPortInUse(target.host, target.port))),
364
+ Promise.all([...listenerTargets.values()].map((target) => isTcpPortInUse(target.host, target.port))),
365
+ ]);
366
+
367
+ return {
368
+ daemonPortInUse: daemonResults.some(Boolean),
369
+ httpListenerPortInUse: listenerResults.some(Boolean),
370
+ };
371
+ };
372
+
295
373
  let externalServices: ExternalServicesHandle = {
296
374
  daemonServer: null,
297
375
  httpListener: null,
@@ -299,6 +377,49 @@ export async function bootstrapRuntime(
299
377
  async stop(): Promise<void> {},
300
378
  };
301
379
  let externalServicesPromise: Promise<ExternalServicesHandle> | null = null;
380
+ let externalServiceBindings = readExternalServiceBindings();
381
+ let externalServicePortState: ExternalServicePortState = {
382
+ daemonPortInUse: false,
383
+ httpListenerPortInUse: false,
384
+ };
385
+ const inspectExternalServices = () => ({
386
+ daemonRunning: externalServices.daemonServer !== null,
387
+ daemonPortInUse: externalServicePortState.daemonPortInUse,
388
+ httpListenerRunning: externalServices.httpListener !== null,
389
+ httpListenerPortInUse: externalServicePortState.httpListenerPortInUse,
390
+ });
391
+ const platformExternalServices = uiServices.platform as typeof uiServices.platform & {
392
+ externalServices: NonNullable<typeof uiServices.platform.externalServices>;
393
+ };
394
+ platformExternalServices.externalServices = {
395
+ inspect: inspectExternalServices,
396
+ restart: async () => {
397
+ if (externalServicesPromise) {
398
+ try {
399
+ externalServices = await externalServicesPromise;
400
+ } catch {
401
+ // A failed previous startup should not prevent a restart attempt.
402
+ }
403
+ }
404
+ await externalServices.stop();
405
+ const previousBindings = externalServiceBindings;
406
+ externalServiceBindings = readExternalServiceBindings();
407
+ const daemonHomeDir = join(services.homeDirectory, '.goodvibes', 'daemon');
408
+ const companionTokenRecord = getOrCreateCompanionToken('tui', { daemonHomeDir });
409
+ externalServicesPromise = startExternalServices(
410
+ configManager,
411
+ runtimeBus,
412
+ hookDispatcher,
413
+ { sharedDaemonToken: companionTokenRecord.token },
414
+ services,
415
+ );
416
+ externalServices = await externalServicesPromise;
417
+ controlPlaneRecentEventsRef.value = (limit) => externalServices.listRecentControlPlaneEvents(limit);
418
+ externalServicePortState = await inspectExternalPorts([previousBindings, externalServiceBindings]);
419
+ requestRender();
420
+ return inspectExternalServices();
421
+ },
422
+ };
302
423
  deferredStartup.schedule({
303
424
  label: 'plugins',
304
425
  run: async () => {
@@ -349,6 +470,7 @@ export async function bootstrapRuntime(
349
470
  if (prune.failedPaths.length > 0) {
350
471
  logger.warn(`[bootstrap] Failed to prune ${prune.failedPaths.length} stale operator-token file(s) (permission/race): ${prune.failedPaths.join(', ')}`);
351
472
  }
473
+ externalServiceBindings = readExternalServiceBindings();
352
474
  externalServicesPromise = startExternalServices(
353
475
  configManager,
354
476
  runtimeBus,
@@ -358,6 +480,7 @@ export async function bootstrapRuntime(
358
480
  );
359
481
  externalServices = await externalServicesPromise;
360
482
  controlPlaneRecentEventsRef.value = (limit) => externalServices.listRecentControlPlaneEvents(limit);
483
+ externalServicePortState = await inspectExternalPorts([externalServiceBindings]);
361
484
  requestRender();
362
485
  },
363
486
  onError: (error) => {