@pellux/goodvibes-tui 1.16.0 → 1.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/package.json +5 -5
- package/src/renderer/onboarding/onboarding-wizard.ts +22 -8
- package/src/renderer/selection-modal-overlay.ts +101 -23
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ All notable changes to GoodVibes TUI.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [1.16.1] — 2026-07-12
|
|
8
|
+
|
|
9
|
+
### Changes
|
|
10
|
+
- 4f54b6e0 fix(renderer): never clip a modal's descriptive text to fit a fixed column or row
|
|
11
|
+
- 64ceb78a fix(install): never restart or skip-setup a systemd unit or bare process that can't actually run
|
|
12
|
+
|
|
7
13
|
## [1.16.0] — 2026-07-11
|
|
8
14
|
|
|
9
15
|
### Changes
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/mgd34msu/goodvibes-tui)
|
|
6
6
|
|
|
7
7
|
A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.1",
|
|
4
4
|
"description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.ts",
|
|
@@ -131,10 +131,10 @@
|
|
|
131
131
|
"typescript": "^5.9.3"
|
|
132
132
|
},
|
|
133
133
|
"optionalDependencies": {
|
|
134
|
-
"@pellux/goodvibes-tui-linux-x64": "1.16.
|
|
135
|
-
"@pellux/goodvibes-tui-linux-arm64": "1.16.
|
|
136
|
-
"@pellux/goodvibes-tui-darwin-x64": "1.16.
|
|
137
|
-
"@pellux/goodvibes-tui-darwin-arm64": "1.16.
|
|
134
|
+
"@pellux/goodvibes-tui-linux-x64": "1.16.1",
|
|
135
|
+
"@pellux/goodvibes-tui-linux-arm64": "1.16.1",
|
|
136
|
+
"@pellux/goodvibes-tui-darwin-x64": "1.16.1",
|
|
137
|
+
"@pellux/goodvibes-tui-darwin-arm64": "1.16.1"
|
|
138
138
|
},
|
|
139
139
|
"trustedDependencies": [],
|
|
140
140
|
"overrides": {
|
|
@@ -295,15 +295,25 @@ function renderWideLayout(
|
|
|
295
295
|
const centerStart = leftSeparatorX + 1;
|
|
296
296
|
const rightSeparatorX = centerStart + centerWidth;
|
|
297
297
|
const rightStart = rightSeparatorX + 1;
|
|
298
|
-
const descriptionLines = wrapText(currentStep.description, Math.max(18, centerWidth - 2)).slice(0, 2);
|
|
299
298
|
const summaryLines = [
|
|
300
299
|
currentStep.summaryTitle,
|
|
301
300
|
...currentStep.summaryLines.slice(0, 2),
|
|
302
301
|
`Fields ${wizard.getCompletedFieldCount(wizard.stepIndex)}/${wizard.getStepFieldCount(wizard.stepIndex)} complete`,
|
|
303
302
|
changedScreensLabel(wizard),
|
|
304
303
|
];
|
|
305
|
-
const
|
|
304
|
+
const centerTextWidth = Math.max(18, centerWidth - 2);
|
|
305
|
+
const descriptionLines = wrapText(currentStep.description, centerTextWidth).slice(0, 2);
|
|
306
306
|
const selectedText = selectedFieldText(wizard);
|
|
307
|
+
// The selected field's own hint is the direct analog of the reported
|
|
308
|
+
// defect (descriptive text explaining what the currently-focused option
|
|
309
|
+
// does) — some hints run past 400 characters, e.g. security-caveat text.
|
|
310
|
+
// It is never clipped to a single row: it wraps onto as many lines as it
|
|
311
|
+
// needs, and the field list below starts after it, growing/shrinking the
|
|
312
|
+
// field-list area rather than losing hint text. Title/description/controls
|
|
313
|
+
// stay fixed-row (short chrome strings, not the field-specific
|
|
314
|
+
// explanation), matching prior layout exactly when the hint fits on one line.
|
|
315
|
+
const hintLines = wrapText(selectedText.hint, centerTextWidth);
|
|
316
|
+
const fieldStartRow = 5 + hintLines.length;
|
|
307
317
|
const fieldRows = buildFieldRows(wizard, visibleFields, Math.max(0, bodyRows - fieldStartRow));
|
|
308
318
|
|
|
309
319
|
const topLine = createOverlayFilledBorderLine(
|
|
@@ -397,9 +407,9 @@ function renderWideLayout(
|
|
|
397
407
|
bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
|
|
398
408
|
bold: true,
|
|
399
409
|
});
|
|
400
|
-
} else if (row
|
|
410
|
+
} else if (row < fieldStartRow) {
|
|
401
411
|
fillWidth(line, centerStart, centerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
|
|
402
|
-
putOverlayText(line, centerStart + 1, centerWidth - 2,
|
|
412
|
+
putOverlayText(line, centerStart + 1, centerWidth - 2, hintLines[row - 5] ?? '', {
|
|
403
413
|
fg: UI_TONES.fg.secondary,
|
|
404
414
|
bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
|
|
405
415
|
});
|
|
@@ -481,9 +491,13 @@ function renderCollapsedLayout(
|
|
|
481
491
|
const bodyBg = UI_TONES.bg.base;
|
|
482
492
|
const innerStart = layout.margin + 1;
|
|
483
493
|
const innerWidth = layout.innerWidth;
|
|
484
|
-
const
|
|
485
|
-
const
|
|
494
|
+
const innerTextWidth = Math.max(14, innerWidth - 2);
|
|
495
|
+
const descriptionLines = wrapText(currentStep.description, innerTextWidth).slice(0, 2);
|
|
486
496
|
const selectedText = selectedFieldText(wizard);
|
|
497
|
+
// Same rule as the wide layout: the selected field's hint wraps onto as
|
|
498
|
+
// many lines as it needs rather than being clipped to a single row.
|
|
499
|
+
const hintLines = wrapText(selectedText.hint, innerTextWidth);
|
|
500
|
+
const fieldStartRow = 5 + hintLines.length;
|
|
487
501
|
const fieldRows = buildFieldRows(wizard, visibleFields, Math.max(0, bodyRows - fieldStartRow));
|
|
488
502
|
|
|
489
503
|
const topLine = createOverlayFilledBorderLine(
|
|
@@ -559,9 +573,9 @@ function renderCollapsedLayout(
|
|
|
559
573
|
bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
|
|
560
574
|
bold: true,
|
|
561
575
|
});
|
|
562
|
-
} else if (row
|
|
576
|
+
} else if (row < fieldStartRow) {
|
|
563
577
|
fillWidth(line, innerStart, innerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
|
|
564
|
-
putOverlayText(line, innerStart + 1, innerWidth - 2,
|
|
578
|
+
putOverlayText(line, innerStart + 1, innerWidth - 2, hintLines[row - 5] ?? '', {
|
|
565
579
|
fg: UI_TONES.fg.secondary,
|
|
566
580
|
bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
|
|
567
581
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Line } from '../types/grid.ts';
|
|
2
2
|
import { fitDisplay, getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
|
|
3
|
-
import type { SelectionModal } from '../input/selection-modal.ts';
|
|
3
|
+
import type { SelectionItem, SelectionModal } from '../input/selection-modal.ts';
|
|
4
4
|
import {
|
|
5
5
|
createOverlayBoxLayout,
|
|
6
6
|
createOverlayContentLine,
|
|
@@ -30,6 +30,45 @@ function putText(line: Line, startX: number, maxWidth: number, text: string, sty
|
|
|
30
30
|
putOverlayText(line, startX, maxWidth, text, style);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* How an item's `detail` text fits on screen. Descriptive text is never
|
|
35
|
+
* clipped: if it doesn't fit beside the label at a readable width, the full
|
|
36
|
+
* text wraps onto its own line(s) below the label instead of being
|
|
37
|
+
* truncated. `wrappedDetail` is null when the detail fits inline (no data
|
|
38
|
+
* lost either way); otherwise it holds every wrapped line the FULL detail
|
|
39
|
+
* needs (no line-count cap).
|
|
40
|
+
*/
|
|
41
|
+
interface DetailLayout {
|
|
42
|
+
readonly labelWidth: number;
|
|
43
|
+
readonly detailWidth: number;
|
|
44
|
+
readonly wrappedDetail: readonly string[] | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function computeDetailLayout(item: SelectionItem, remaining: number): DetailLayout {
|
|
48
|
+
if (!item.detail) {
|
|
49
|
+
return { labelWidth: remaining, detailWidth: 0, wrappedDetail: null };
|
|
50
|
+
}
|
|
51
|
+
const cols = fitLabelDetailColumns(item.label, item.detail, remaining);
|
|
52
|
+
const fitsInline = cols.detailWidth >= 12 && getDisplayWidth(item.detail) <= cols.detailWidth;
|
|
53
|
+
if (fitsInline) {
|
|
54
|
+
return { labelWidth: cols.labelWidth, detailWidth: cols.detailWidth, wrappedDetail: null };
|
|
55
|
+
}
|
|
56
|
+
// Nothing shares this row with the label once the detail moves below it,
|
|
57
|
+
// so the label itself gets the full row width too.
|
|
58
|
+
return {
|
|
59
|
+
labelWidth: remaining,
|
|
60
|
+
detailWidth: cols.detailWidth,
|
|
61
|
+
wrappedDetail: wrapWithHangingIndent(item.detail, Math.max(8, remaining), ''),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Physical rows an item occupies: 1 for the label/inline-detail row, plus
|
|
66
|
+
* one more per wrapped detail line when the detail didn't fit inline. */
|
|
67
|
+
function rowCostFor(item: SelectionItem, remaining: number): number {
|
|
68
|
+
if (!item.detail) return 1;
|
|
69
|
+
return 1 + (computeDetailLayout(item, remaining).wrappedDetail?.length ?? 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
/**
|
|
34
73
|
* Render the selection modal as Line[] for overlay in the viewport.
|
|
35
74
|
*/
|
|
@@ -39,12 +78,17 @@ export function renderSelectionModalOverlay(
|
|
|
39
78
|
viewportHeight = 24,
|
|
40
79
|
): Line[] {
|
|
41
80
|
const lines: Line[] = [];
|
|
81
|
+
const chromeRows = modal.allowSearch ? 5 : 4;
|
|
42
82
|
const metrics = getOverlaySurfaceMetrics(width, viewportHeight, {
|
|
43
83
|
margin: 4,
|
|
44
84
|
maxWidth: 72,
|
|
45
|
-
chromeRows
|
|
85
|
+
chromeRows,
|
|
46
86
|
minContentRows: 6,
|
|
47
|
-
|
|
87
|
+
// Modals size to content: items whose detail wraps onto extra lines can
|
|
88
|
+
// need more physical rows than a plain one-row-per-item modal, so this
|
|
89
|
+
// ceiling grows with the viewport rather than staying pinned at a small
|
|
90
|
+
// constant (still bounded — never larger than the terminal can show).
|
|
91
|
+
maxContentRows: Math.max(10, viewportHeight - chromeRows - 4),
|
|
48
92
|
});
|
|
49
93
|
const layout = createOverlayBoxLayout(width, metrics.margin, metrics.boxWidth);
|
|
50
94
|
|
|
@@ -99,15 +143,51 @@ export function renderSelectionModalOverlay(
|
|
|
99
143
|
putText(line, layout.margin + 2, layout.innerWidth, fitDisplay(message, layout.innerWidth), { fg: MUTED_FG, dim: true });
|
|
100
144
|
lines.push(line);
|
|
101
145
|
} else {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
146
|
+
const indicatorWidth = 2;
|
|
147
|
+
const remaining = layout.innerWidth - indicatorWidth;
|
|
148
|
+
const rowBudget = Math.max(1, metrics.contentRows);
|
|
149
|
+
|
|
150
|
+
// Row-budget-aware windowing: rather than assuming one physical row per
|
|
151
|
+
// item, account for items whose detail wraps onto extra lines, so the
|
|
152
|
+
// visible window never silently shows fewer full items than the budget
|
|
153
|
+
// allows just because a fixed item-count cap ran out. Always shows the
|
|
154
|
+
// selected item (even alone, if its wrapped detail is taller than the
|
|
155
|
+
// whole budget — descriptive text is never clipped to make it fit),
|
|
156
|
+
// then grows outward, roughly centered, until the budget is spent.
|
|
157
|
+
const selected = Math.max(0, Math.min(modal.selectedIndex, items.length - 1));
|
|
158
|
+
let startIdx = selected;
|
|
159
|
+
let endIdx = selected + 1;
|
|
160
|
+
let usedRows = rowCostFor(items[selected], remaining);
|
|
161
|
+
let growBefore = true;
|
|
162
|
+
while (startIdx > 0 || endIdx < items.length) {
|
|
163
|
+
const canGrowBefore = growBefore && startIdx > 0;
|
|
164
|
+
const canGrowAfter = !growBefore && endIdx < items.length;
|
|
165
|
+
if (canGrowBefore) {
|
|
166
|
+
const cost = rowCostFor(items[startIdx - 1], remaining);
|
|
167
|
+
if (usedRows + cost > rowBudget) break;
|
|
168
|
+
startIdx -= 1;
|
|
169
|
+
usedRows += cost;
|
|
170
|
+
} else if (canGrowAfter) {
|
|
171
|
+
const cost = rowCostFor(items[endIdx], remaining);
|
|
172
|
+
if (usedRows + cost > rowBudget) break;
|
|
173
|
+
endIdx += 1;
|
|
174
|
+
usedRows += cost;
|
|
175
|
+
} else if (startIdx > 0) {
|
|
176
|
+
const cost = rowCostFor(items[startIdx - 1], remaining);
|
|
177
|
+
if (usedRows + cost > rowBudget) break;
|
|
178
|
+
startIdx -= 1;
|
|
179
|
+
usedRows += cost;
|
|
180
|
+
} else if (endIdx < items.length) {
|
|
181
|
+
const cost = rowCostFor(items[endIdx], remaining);
|
|
182
|
+
if (usedRows + cost > rowBudget) break;
|
|
183
|
+
endIdx += 1;
|
|
184
|
+
usedRows += cost;
|
|
185
|
+
} else {
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
growBefore = !growBefore;
|
|
109
189
|
}
|
|
110
|
-
|
|
190
|
+
|
|
111
191
|
let lastCategory: string | undefined;
|
|
112
192
|
|
|
113
193
|
for (let i = startIdx; i < endIdx; i++) {
|
|
@@ -125,13 +205,10 @@ export function renderSelectionModalOverlay(
|
|
|
125
205
|
}
|
|
126
206
|
|
|
127
207
|
const indicator = isSelected ? `${OVERLAY_GLYPHS.selected} ` : ' ';
|
|
128
|
-
const indicatorWidth = 2;
|
|
129
|
-
const remaining = layout.innerWidth - indicatorWidth;
|
|
130
208
|
const labelColor = isSelected ? TITLE_FG : (item.fg ?? BODY_FG);
|
|
131
209
|
const detailColor = isSelected ? BODY_FG : MUTED_FG;
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
: remaining;
|
|
210
|
+
const detailLayout = computeDetailLayout(item, remaining);
|
|
211
|
+
const labelWidth = detailLayout.labelWidth;
|
|
135
212
|
const labelLine = createOverlayContentLine(width, layout, BORDER_FG, isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg);
|
|
136
213
|
putText(labelLine, layout.margin + 2, indicatorWidth, indicator, {
|
|
137
214
|
fg: isSelected ? TITLE_FG : MUTED_FG,
|
|
@@ -144,23 +221,24 @@ export function renderSelectionModalOverlay(
|
|
|
144
221
|
bold: isSelected,
|
|
145
222
|
});
|
|
146
223
|
if (item.detail) {
|
|
147
|
-
|
|
148
|
-
|
|
224
|
+
if (detailLayout.wrappedDetail === null) {
|
|
225
|
+
// Fits beside the label at a readable width — no truncation needed.
|
|
149
226
|
putText(labelLine, layout.margin + 2 + indicatorWidth + labelWidth, 2, ' ', {
|
|
150
227
|
fg: BODY_FG,
|
|
151
228
|
bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
|
|
152
229
|
});
|
|
153
|
-
putText(labelLine, layout.margin + 2 + indicatorWidth + labelWidth + 2, detailWidth, fitDisplay(
|
|
230
|
+
putText(labelLine, layout.margin + 2 + indicatorWidth + labelWidth + 2, detailLayout.detailWidth, fitDisplay(item.detail, detailLayout.detailWidth), {
|
|
154
231
|
fg: detailColor,
|
|
155
232
|
bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
|
|
156
233
|
});
|
|
157
234
|
lines.push(labelLine);
|
|
158
235
|
} else {
|
|
236
|
+
// Doesn't fit beside the label — wrap the FULL detail text onto as
|
|
237
|
+
// many lines as it needs below the label, rather than clipping it.
|
|
159
238
|
lines.push(labelLine);
|
|
160
|
-
const
|
|
161
|
-
for (const detailLineText of wrappedDetails) {
|
|
239
|
+
for (const detailLineText of detailLayout.wrappedDetail) {
|
|
162
240
|
const detailLine = createOverlayContentLine(width, layout, BORDER_FG, isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg);
|
|
163
|
-
putText(detailLine, layout.margin + 2 + indicatorWidth, remaining, fitDisplay(
|
|
241
|
+
putText(detailLine, layout.margin + 2 + indicatorWidth, remaining, fitDisplay(detailLineText, remaining), {
|
|
164
242
|
fg: detailColor,
|
|
165
243
|
bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
|
|
166
244
|
dim: !isSelected,
|
|
@@ -173,7 +251,7 @@ export function renderSelectionModalOverlay(
|
|
|
173
251
|
}
|
|
174
252
|
}
|
|
175
253
|
|
|
176
|
-
if (items.length
|
|
254
|
+
if (startIdx > 0 || endIdx < items.length) {
|
|
177
255
|
const above = startIdx;
|
|
178
256
|
const below = items.length - endIdx;
|
|
179
257
|
const scrollHint = above > 0 && below > 0
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '1.16.
|
|
9
|
+
let _version = '1.16.1';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|