@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 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
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-1.16.0-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-1.16.1-blue.svg)](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.0",
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.0",
135
- "@pellux/goodvibes-tui-linux-arm64": "1.16.0",
136
- "@pellux/goodvibes-tui-darwin-x64": "1.16.0",
137
- "@pellux/goodvibes-tui-darwin-arm64": "1.16.0"
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 fieldStartRow = 6;
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 === 5) {
410
+ } else if (row < fieldStartRow) {
401
411
  fillWidth(line, centerStart, centerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
402
- putOverlayText(line, centerStart + 1, centerWidth - 2, truncateDisplay(selectedText.hint, 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 descriptionLines = wrapText(currentStep.description, Math.max(14, innerWidth - 2)).slice(0, 2);
485
- const fieldStartRow = 6;
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 === 5) {
576
+ } else if (row < fieldStartRow) {
563
577
  fillWidth(line, innerStart, innerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
564
- putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(selectedText.hint, 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: modal.allowSearch ? 5 : 4,
85
+ chromeRows,
46
86
  minContentRows: 6,
47
- maxContentRows: 10,
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 maxVisible = metrics.contentRows;
103
- let startIdx = 0;
104
- if (items.length > maxVisible) {
105
- startIdx = Math.max(0, Math.min(
106
- modal.selectedIndex - Math.floor(maxVisible / 2),
107
- items.length - maxVisible,
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
- const endIdx = Math.min(startIdx + maxVisible, items.length);
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 labelWidth = item.detail
133
- ? fitLabelDetailColumns(item.label, item.detail, remaining).labelWidth
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
- const detailWidth = fitLabelDetailColumns(item.label, item.detail, remaining).detailWidth;
148
- if (detailWidth >= 12) {
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(truncateDisplay(item.detail, detailWidth), detailWidth), {
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 wrappedDetails = wrapWithHangingIndent(item.detail, Math.max(8, remaining), '', 2);
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(truncateDisplay(detailLineText, remaining), remaining), {
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 > maxVisible) {
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.0';
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;