@oh-my-pi/pi-tui 15.10.8 → 15.10.9

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
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.9] - 2026-06-09
6
+
7
+ ### Added
8
+
9
+ - Added a `wrapDescription` option to `SelectListLayoutOptions`. When enabled, long descriptions wrap onto continuation rows indented under the description column instead of being silently truncated. The slash-command/skill autocomplete picker now opts in so descriptions like the bundled skills' remain fully readable at normal terminal widths. `maxVisible` becomes the picker's visual row budget so the popup height stays bounded even when items wrap (a single 5-row description with `maxVisible=3` clips with the scrollbar carrying the offscreen tail). Navigation stays item-to-item, the narrow-width fallback (`width <= 40`) is unchanged, and the `ScrollView` scrollbar tracks visual rows so the thumb stays correct when items wrap unevenly. ([#2169](https://github.com/can1357/oh-my-pi/issues/2169))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Ghostty's first inline image in a fresh TUI session sometimes rendering as an empty placeholder block by holding the initial Kitty graphics paint until the terminal startup settle window has passed. Direct Kitty placements also keep their zero-width reservation rows non-plain so image-only transcript blocks do not collapse when blank-edge trimming runs.
14
+
5
15
  ## [15.10.8] - 2026-06-09
6
16
 
7
17
  ### Fixed
@@ -73,6 +73,8 @@ export declare class ImageBudget {
73
73
  * repeated call (e.g. a width-change re-render) never re-sends the data.
74
74
  */
75
75
  enqueueTransmit(imageId: number, sequence: string): void;
76
+ /** Whether a frame has image data queued but not yet written to the terminal. */
77
+ hasPendingTransmits(): boolean;
76
78
  /** Transmit sequences to write before this frame's placements; clears the queue. */
77
79
  takeTransmits(): readonly string[];
78
80
  }
@@ -28,6 +28,14 @@ export interface SelectListLayoutOptions {
28
28
  truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
29
29
  /** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
30
30
  overflowSearch?: boolean;
31
+ /**
32
+ * Wrap long descriptions onto continuation rows indented under the
33
+ * description column instead of truncating. Defaults to false so existing
34
+ * single-line consumers are unaffected. Navigation remains item-to-item;
35
+ * the scrollbar tracks visual rows so the thumb stays correct when items
36
+ * wrap unevenly.
37
+ */
38
+ wrapDescription?: boolean;
31
39
  }
32
40
  export declare class SelectList implements Component {
33
41
  #private;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "15.10.8",
4
+ "version": "15.10.9",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "15.10.8",
41
- "@oh-my-pi/pi-utils": "15.10.8",
40
+ "@oh-my-pi/pi-natives": "15.10.9",
41
+ "@oh-my-pi/pi-utils": "15.10.9",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -27,6 +27,7 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
27
27
  minPrimaryColumnWidth: 12,
28
28
  maxPrimaryColumnWidth: 32,
29
29
  overflowSearch: false,
30
+ wrapDescription: true,
30
31
  };
31
32
 
32
33
  function sanitizeLoadedText(text: string): string {
@@ -27,6 +27,9 @@ export interface ImageOptions {
27
27
 
28
28
  const EMPTY_IDS: readonly number[] = [];
29
29
  const EMPTY_TRANSMITS: readonly string[] = [];
30
+ // Direct placements reserve height with leading zero-width rows. Keep them
31
+ // non-plain so transcript blank-edge trimming does not collapse image-only blocks.
32
+ const RESERVED_IMAGE_ROW = "\x1b[0m";
30
33
 
31
34
  /** Default count of inline images kept as live graphics before older ones fall back to text. */
32
35
  export const DEFAULT_MAX_INLINE_IMAGES = 8;
@@ -188,6 +191,11 @@ export class ImageBudget {
188
191
  this.#pendingTransmits.push(sequence);
189
192
  }
190
193
 
194
+ /** Whether a frame has image data queued but not yet written to the terminal. */
195
+ hasPendingTransmits(): boolean {
196
+ return this.#pendingTransmits.length > 0;
197
+ }
198
+
191
199
  /** Transmit sequences to write before this frame's placements; clears the queue. */
192
200
  takeTransmits(): readonly string[] {
193
201
  if (this.#pendingTransmits.length === 0) return EMPTY_TRANSMITS;
@@ -296,7 +304,7 @@ export class Image implements Component {
296
304
  // moves the cursor back up, then emits the image sequence.
297
305
  lines = [];
298
306
  for (let i = 0; i < result.rows - 1; i++) {
299
- lines.push("");
307
+ lines.push(RESERVED_IMAGE_ROW);
300
308
  }
301
309
  const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
302
310
  lines.push(moveUp + (result.sequence ?? ""));
@@ -3,7 +3,7 @@ import { getKeybindings } from "../keybindings";
3
3
  import { extractPrintableText } from "../keys";
4
4
  import type { SymbolTheme } from "../symbols";
5
5
  import type { Component } from "../tui";
6
- import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
6
+ import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
7
7
  import { ScrollView } from "./scroll-view";
8
8
 
9
9
  const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
@@ -50,8 +50,33 @@ export interface SelectListLayoutOptions {
50
50
  truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
51
51
  /** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
52
52
  overflowSearch?: boolean;
53
+ /**
54
+ * Wrap long descriptions onto continuation rows indented under the
55
+ * description column instead of truncating. Defaults to false so existing
56
+ * single-line consumers are unaffected. Navigation remains item-to-item;
57
+ * the scrollbar tracks visual rows so the thumb stays correct when items
58
+ * wrap unevenly.
59
+ */
60
+ wrapDescription?: boolean;
53
61
  }
54
62
 
63
+ type SelectItemLayout =
64
+ | {
65
+ kind: "description";
66
+ prefix: string;
67
+ truncatedValue: string;
68
+ spacing: string;
69
+ descriptionSingleLine: string;
70
+ descriptionStart: number;
71
+ remainingWidth: number;
72
+ }
73
+ | {
74
+ kind: "primary";
75
+ prefix: string;
76
+ truncatedValue: string;
77
+ spacing: "";
78
+ };
79
+
55
80
  export class SelectList implements Component {
56
81
  #filteredItems: ReadonlyArray<SelectItem>;
57
82
  #filterQuery = "";
@@ -96,34 +121,58 @@ export class SelectList implements Component {
96
121
  }
97
122
 
98
123
  const primaryColumnWidth = this.#getPrimaryColumnWidth();
124
+ const wrapEnabled = this.layout.wrapDescription === true;
125
+ // `maxVisible` is the picker's visual row budget. For non-wrap layouts
126
+ // every item is one row, so the budget matches the original item count.
127
+ const visualBudget = this.maxVisible;
128
+
129
+ // Compute per-item visual row counts at the conservative width (i.e.
130
+ // assume the scrollbar column might be reserved). For non-wrap layouts
131
+ // every count is 1, so visualTotal == #filteredItems and overflow falls
132
+ // back to the original `N > maxVisible` predicate exactly.
133
+ const conservativeRowWidth = Math.max(0, width - 1);
134
+ const rowCounts = new Array<number>(this.#filteredItems.length);
135
+ let visualTotal = 0;
136
+ for (let i = 0; i < this.#filteredItems.length; i++) {
137
+ const item = this.#filteredItems[i];
138
+ if (!item) {
139
+ rowCounts[i] = 0;
140
+ continue;
141
+ }
142
+ rowCounts[i] = wrapEnabled ? this.#computeItemRowCount(item, conservativeRowWidth, primaryColumnWidth) : 1;
143
+ visualTotal += rowCounts[i];
144
+ }
99
145
 
100
- // Calculate visible range with scrolling
101
- const startIndex = Math.max(
102
- 0,
103
- Math.min(this.#selectedIndex - Math.floor(this.maxVisible / 2), this.#filteredItems.length - this.maxVisible),
104
- );
105
- const endIndex = Math.min(startIndex + this.maxVisible, this.#filteredItems.length);
106
-
107
- // Render visible items
108
- const overflow = this.#filteredItems.length > this.maxVisible;
146
+ const overflow = visualTotal > visualBudget;
109
147
  const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
148
+
149
+ // Pick a window centered on the selected item that fits in visualBudget
150
+ // rows. Falls through to the original item-count window when every row
151
+ // count is 1.
152
+ const { startIndex, endIndex, visualOffset } = this.#pickWindow(rowCounts, visualBudget);
153
+
154
+ // Render visible items. Cap rows at the budget so a single item that
155
+ // wraps to more than `visualBudget` rows (pathological — e.g. a 5-row
156
+ // description with maxVisible=3) still keeps the popup bounded; the
157
+ // scrollbar carries the offscreen rows.
110
158
  const rows: string[] = [];
111
- for (let i = startIndex; i < endIndex; i++) {
159
+ for (let i = startIndex; i < endIndex && rows.length < visualBudget; i++) {
112
160
  const item = this.#filteredItems[i];
113
161
  if (!item) continue;
114
-
115
- const isSelected = i === this.#selectedIndex;
116
- const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
117
- rows.push(this.#renderItem(item, isSelected, rowWidth, descriptionText, primaryColumnWidth));
162
+ const itemRows = this.#renderItem(item, i === this.#selectedIndex, rowWidth, primaryColumnWidth);
163
+ for (const row of itemRows) {
164
+ if (rows.length >= visualBudget) break;
165
+ rows.push(row);
166
+ }
118
167
  }
119
168
 
120
169
  const sv = new ScrollView(rows, {
121
170
  height: rows.length,
122
171
  scrollbar: "auto",
123
- totalRows: this.#filteredItems.length,
172
+ totalRows: visualTotal,
124
173
  theme: { track: t => this.theme.scrollInfo(t), thumb: t => this.theme.selectedPrefix(t) },
125
174
  });
126
- sv.setScrollOffset(startIndex);
175
+ sv.setScrollOffset(visualOffset);
127
176
  lines.push(...sv.render(width));
128
177
 
129
178
  // Add search status when relevant (scrollbar now indicates overflow)
@@ -178,17 +227,112 @@ export class SelectList implements Component {
178
227
  }
179
228
  }
180
229
 
181
- #renderItem(
230
+ #renderItem(item: SelectItem, isSelected: boolean, width: number, primaryColumnWidth: number): string[] {
231
+ const layout = this.#computeItemLayout(item, isSelected, width, primaryColumnWidth);
232
+ const { prefix, truncatedValue, spacing } = layout;
233
+
234
+ if (layout.kind === "description") {
235
+ const { descriptionSingleLine, descriptionStart, remainingWidth } = layout;
236
+ if (this.layout.wrapDescription) {
237
+ const wrapped = wrapTextWithAnsi(descriptionSingleLine, remainingWidth);
238
+ if (wrapped.length === 0) wrapped.push("");
239
+ const indent = padding(descriptionStart);
240
+ const first = wrapped[0] ?? "";
241
+ if (isSelected) {
242
+ const rows = [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${first}`)];
243
+ for (let i = 1; i < wrapped.length; i++) {
244
+ rows.push(this.theme.selectedText(`${indent}${wrapped[i]}`));
245
+ }
246
+ return rows;
247
+ }
248
+ const rows = [prefix + truncatedValue + this.theme.description(spacing + first)];
249
+ for (let i = 1; i < wrapped.length; i++) {
250
+ rows.push(this.theme.description(`${indent}${wrapped[i]}`));
251
+ }
252
+ return rows;
253
+ }
254
+
255
+ const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
256
+ if (isSelected) {
257
+ return [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`)];
258
+ }
259
+ return [prefix + truncatedValue + this.theme.description(spacing + truncatedDesc)];
260
+ }
261
+
262
+ if (isSelected) {
263
+ return [this.theme.selectedText(`${prefix}${truncatedValue}`)];
264
+ }
265
+ return [prefix + truncatedValue];
266
+ }
267
+
268
+ #computeItemRowCount(item: SelectItem, width: number, primaryColumnWidth: number): number {
269
+ // Selection style does not change row count; pass isSelected=false to
270
+ // keep the cheap path uniform for items outside the visible window.
271
+ const layout = this.#computeItemLayout(item, false, width, primaryColumnWidth);
272
+ if (layout.kind !== "description") return 1;
273
+ const wrapped = wrapTextWithAnsi(layout.descriptionSingleLine, layout.remainingWidth);
274
+ return Math.max(1, wrapped.length);
275
+ }
276
+
277
+ /**
278
+ * Pick a contiguous window of items containing `selectedIndex` such that
279
+ * their visual rows fit within `budget`. Centers the selection roughly
280
+ * mid-window: first expands up by ⌊budget/2⌋ rows, then fills downward,
281
+ * then back upward with any remaining budget. For non-wrap layouts (every
282
+ * `rowCounts[i] === 1`) this resolves to the same `[start, start+maxVisible)`
283
+ * window the prior arithmetic produced.
284
+ */
285
+ #pickWindow(
286
+ rowCounts: ReadonlyArray<number>,
287
+ budget: number,
288
+ ): { startIndex: number; endIndex: number; visualOffset: number } {
289
+ const n = rowCounts.length;
290
+ const selected = Math.max(0, Math.min(this.#selectedIndex, n - 1));
291
+ if (n === 0) return { startIndex: 0, endIndex: 0, visualOffset: 0 };
292
+
293
+ const half = Math.floor(budget / 2);
294
+ let lo = selected;
295
+ let rowsAboveSelected = 0;
296
+ // Step 1: expand upward up to `half` rows above the selection so it
297
+ // lands near the visual middle, matching the prior centering.
298
+ while (lo > 0 && rowsAboveSelected + (rowCounts[lo - 1] ?? 0) <= half) {
299
+ lo--;
300
+ rowsAboveSelected += rowCounts[lo] ?? 0;
301
+ }
302
+
303
+ // Step 2: expand downward until the budget is filled. The selected
304
+ // item's own rows are always counted; if it alone exceeds `budget`
305
+ // the surplus is clipped at render time and the scrollbar carries it.
306
+ let hi = selected + 1;
307
+ let used = rowsAboveSelected + (rowCounts[selected] ?? 0);
308
+ while (hi < n && used + (rowCounts[hi] ?? 0) <= budget) {
309
+ used += rowCounts[hi] ?? 0;
310
+ hi++;
311
+ }
312
+
313
+ // Step 3: if room remains (selection sat near the bottom), keep
314
+ // expanding upward.
315
+ while (lo > 0 && used + (rowCounts[lo - 1] ?? 0) <= budget) {
316
+ lo--;
317
+ used += rowCounts[lo] ?? 0;
318
+ }
319
+
320
+ let visualOffset = 0;
321
+ for (let i = 0; i < lo; i++) visualOffset += rowCounts[i] ?? 0;
322
+ return { startIndex: lo, endIndex: hi, visualOffset };
323
+ }
324
+
325
+ #computeItemLayout(
182
326
  item: SelectItem,
183
327
  isSelected: boolean,
184
328
  width: number,
185
- descriptionSingleLine: string | undefined,
186
329
  primaryColumnWidth: number,
187
- ): string {
330
+ ): SelectItemLayout {
188
331
  const prefix = isSelected
189
332
  ? `${this.theme.symbols.cursor} `
190
333
  : padding(visibleWidth(this.theme.symbols.cursor) + 1);
191
334
  const prefixWidth = visibleWidth(prefix);
335
+ const descriptionSingleLine = item.description ? sanitizeSingleLine(item.description) : undefined;
192
336
 
193
337
  if (descriptionSingleLine && width > 40) {
194
338
  const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
@@ -200,23 +344,26 @@ export class SelectList implements Component {
200
344
  const remainingWidth = width - descriptionStart - 2; // -2 for safety
201
345
 
202
346
  if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
203
- const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
204
- if (isSelected) {
205
- return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
206
- }
207
-
208
- const descText = this.theme.description(spacing + truncatedDesc);
209
- return prefix + truncatedValue + descText;
347
+ return {
348
+ kind: "description",
349
+ prefix,
350
+ truncatedValue,
351
+ spacing,
352
+ descriptionSingleLine,
353
+ descriptionStart,
354
+ remainingWidth,
355
+ };
210
356
  }
211
357
  }
212
358
 
213
- const maxWidth = width - prefixWidth - 2;
214
- const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
215
- if (isSelected) {
216
- return this.theme.selectedText(`${prefix}${truncatedValue}`);
217
- }
218
-
219
- return prefix + truncatedValue;
359
+ const fallbackMax = width - prefixWidth - 2;
360
+ const truncatedValue = this.#truncatePrimary(item, isSelected, fallbackMax, fallbackMax);
361
+ return {
362
+ kind: "primary",
363
+ prefix,
364
+ truncatedValue,
365
+ spacing: "",
366
+ };
220
367
  }
221
368
 
222
369
  #getPrimaryColumnWidth(): number {
package/src/tui.ts CHANGED
@@ -494,6 +494,10 @@ export class TUI extends Container {
494
494
  // arrives (issue #2088). Coalescing every SIGWINCH inside this window into
495
495
  // a single forced render lets the multiplexer settle first.
496
496
  static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50;
497
+ // Ghostty can drop Kitty graphics commands sent during its first post-startup
498
+ // settle window, leaving only Unicode placeholder cells. Hold the first image
499
+ // paint until that window has passed; later images render normally.
500
+ static readonly #GHOSTTY_INITIAL_IMAGE_DELAY_MS = 100;
497
501
  // Post-paint settle window for ConPTY hosts. The `sessionReplace` /
498
502
  // `historyRebuild` / `overlayRebuild` intents drive `#emitFullPaint` over
499
503
  // a transcript that overflows the viewport, scroll-pushing everything past
@@ -560,6 +564,9 @@ export class TUI extends Container {
560
564
  // Caps how many inline images render as live graphics; older ones fall back
561
565
  // to text via a purge + full redraw. Cap is configured by the host app.
562
566
  #imageBudget = new ImageBudget(DEFAULT_MAX_INLINE_IMAGES, () => this.requestRender());
567
+ #ghosttyInitialImageDelayDone = false;
568
+ #ghosttyInitialImageDelayTimer: RenderTimer | undefined;
569
+ #ghosttyImageReadyAtMs = 0;
563
570
  #clearScrollbackOnNextRender = false;
564
571
  #forceViewportRepaintOnNextRender = false;
565
572
  #allowUnknownViewportMutationOnNextRender = false;
@@ -889,6 +896,8 @@ export class TUI extends Container {
889
896
 
890
897
  start(options?: TUIStartOptions): void {
891
898
  this.#stopped = false;
899
+ this.#ghosttyInitialImageDelayDone = false;
900
+ this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
892
901
  // A DECRQM report for mode 2026 is authoritative: enable synchronized
893
902
  // output when the terminal reports support (upgrading conservatively
894
903
  // defaulted-off hosts like zellij/tmux-master/foot) and disable it when
@@ -1127,6 +1136,10 @@ export class TUI extends Container {
1127
1136
  this.#renderTimer.cancel();
1128
1137
  this.#renderTimer = undefined;
1129
1138
  }
1139
+ if (this.#ghosttyInitialImageDelayTimer) {
1140
+ this.#ghosttyInitialImageDelayTimer.cancel();
1141
+ this.#ghosttyInitialImageDelayTimer = undefined;
1142
+ }
1130
1143
  if (this.#multiplexerResizeTimer) {
1131
1144
  this.#multiplexerResizeTimer.cancel();
1132
1145
  this.#multiplexerResizeTimer = undefined;
@@ -1371,6 +1384,32 @@ export class TUI extends Container {
1371
1384
  }
1372
1385
  this.#postFullPaintSettleUntilMs = 0;
1373
1386
  }
1387
+
1388
+ #maybeDeferGhosttyInitialImagePaint(): boolean {
1389
+ if (this.#ghosttyInitialImageDelayDone) return false;
1390
+ if (TERMINAL.id !== "ghostty" || TERMINAL.imageProtocol !== ImageProtocol.Kitty) {
1391
+ this.#ghosttyInitialImageDelayDone = true;
1392
+ return false;
1393
+ }
1394
+ if (!this.#imageBudget.hasPendingTransmits()) return false;
1395
+ if (this.#ghosttyInitialImageDelayTimer) return true;
1396
+
1397
+ const delayMs = Math.max(0, this.#ghosttyImageReadyAtMs - this.#renderScheduler.now());
1398
+ if (delayMs === 0) {
1399
+ this.#ghosttyInitialImageDelayDone = true;
1400
+ return false;
1401
+ }
1402
+
1403
+ this.#ghosttyInitialImageDelayTimer = this.#renderScheduler.scheduleRender(() => {
1404
+ this.#ghosttyInitialImageDelayTimer = undefined;
1405
+ this.#ghosttyInitialImageDelayDone = true;
1406
+ if (this.#stopped) return;
1407
+ this.#lastRenderAt = this.#renderScheduler.now();
1408
+ this.#doRender();
1409
+ if (this.#renderRequested) this.#scheduleRender();
1410
+ }, delayMs);
1411
+ return true;
1412
+ }
1374
1413
  #prepareForcedRender(clearScrollback: boolean): void {
1375
1414
  const geometryChanged =
1376
1415
  (this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
@@ -1984,6 +2023,7 @@ export class TUI extends Container {
1984
2023
  // paints, so subsequent frames re-emit only the tiny placement sequence.
1985
2024
  // `a=t` produces no display, so writing it ahead of the synchronized paint
1986
2025
  // is artifact-free.
2026
+ if (this.#maybeDeferGhosttyInitialImagePaint()) return;
1987
2027
  const imageTransmits = this.#imageBudget.takeTransmits();
1988
2028
  if (imageTransmits.length > 0) {
1989
2029
  let transmitBuffer = "";