@f5-sales-demo/pi-tui 21.45.3 → 21.46.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/README.md CHANGED
@@ -346,6 +346,7 @@ const list = new SelectList(
346
346
  ],
347
347
  5, // maxVisible
348
348
  theme, // SelectListTheme
349
+ { presentation: "compact-with-selected-detail" },
349
350
  );
350
351
 
351
352
  list.onSelect = (item) => console.log("Selected:", item);
@@ -354,6 +355,10 @@ list.onSelectionChange = (item) => console.log("Highlighted:", item);
354
355
  list.setFilter("opt"); // Filter items
355
356
  ```
356
357
 
358
+ Every list chooses a presentation contract. Use `compact-with-selected-detail` for dense one-line rows; when a
359
+ selected label or description does not fit, `SelectList` adds its complete wrapped value below the rows. Use
360
+ `wrapped-prose` when every visible item should wrap instead of truncating.
361
+
357
362
  **Controls:**
358
363
 
359
364
  - Arrow keys: Navigate
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-tui",
4
- "version": "21.45.3",
4
+ "version": "21.46.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@f5-sales-demo/pi-natives": "21.45.3",
41
- "@f5-sales-demo/pi-utils": "21.45.3",
40
+ "@f5-sales-demo/pi-natives": "21.46.1",
41
+ "@f5-sales-demo/pi-utils": "21.46.1",
42
42
  "marked": "18.0.11"
43
43
  },
44
44
  "devDependencies": {
@@ -24,9 +24,11 @@ import {
24
24
  import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
25
25
 
26
26
  const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
27
+ presentation: "compact-with-selected-detail",
27
28
  minPrimaryColumnWidth: 12,
28
29
  maxPrimaryColumnWidth: 32,
29
30
  };
31
+ const FILE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { presentation: "compact-with-selected-detail" };
30
32
 
31
33
  function sanitizeLoadedText(text: string): string {
32
34
  return replaceTabs(text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"))
@@ -2442,7 +2444,7 @@ export class Editor implements Component, Focusable {
2442
2444
  prefix: string,
2443
2445
  items: Array<{ value: string; label: string; description?: string }>,
2444
2446
  ): SelectList {
2445
- const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
2447
+ const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : FILE_SELECT_LIST_LAYOUT;
2446
2448
  return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList, layout);
2447
2449
  }
2448
2450
 
@@ -2,7 +2,7 @@ import { getKeybindings } from "../keybindings";
2
2
  import { type MouseRoutable, routeSelectListMouse, type SgrMouseEvent } from "../mouse";
3
3
  import type { SymbolTheme } from "../symbols";
4
4
  import type { Component } from "../tui";
5
- import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
5
+ import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
6
6
 
7
7
  const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
8
8
  const PRIMARY_COLUMN_GAP = 2;
@@ -44,6 +44,7 @@ export interface SelectListTruncatePrimaryContext {
44
44
  }
45
45
 
46
46
  export interface SelectListLayoutOptions {
47
+ presentation: "wrapped-prose" | "compact-with-selected-detail";
47
48
  minPrimaryColumnWidth?: number;
48
49
  maxPrimaryColumnWidth?: number;
49
50
  truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
@@ -63,8 +64,11 @@ export class SelectList implements Component, MouseRoutable {
63
64
  private readonly items: ReadonlyArray<SelectItem>,
64
65
  private readonly maxVisible: number,
65
66
  private readonly theme: SelectListTheme,
66
- private readonly layout: SelectListLayoutOptions = {},
67
+ private readonly layout: SelectListLayoutOptions,
67
68
  ) {
69
+ if (layout.presentation !== "wrapped-prose" && layout.presentation !== "compact-with-selected-detail") {
70
+ throw new TypeError("SelectList requires an explicit wrapped-prose or compact-with-selected-detail policy.");
71
+ }
68
72
  this.#filteredItems = items;
69
73
  }
70
74
 
@@ -114,6 +118,7 @@ export class SelectList implements Component, MouseRoutable {
114
118
 
115
119
  render(width: number): string[] {
116
120
  const lines: string[] = [];
121
+ let selectedDetailLines: string[] = [];
117
122
  this.#hitRows = [];
118
123
 
119
124
  // If no items match filter, show message
@@ -138,11 +143,37 @@ export class SelectList implements Component, MouseRoutable {
138
143
 
139
144
  const isSelected = i === this.#selectedIndex;
140
145
  const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
141
- const rendered = this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth);
146
+ if (this.layout.presentation === "wrapped-prose") {
147
+ for (const rendered of this.#renderWrappedItem(item, isSelected, width, descriptionText)) {
148
+ this.#hitRows[lines.length] = i;
149
+ lines.push(
150
+ !isSelected && i === this.#hoveredIndex && this.theme.hovered
151
+ ? this.theme.hovered(rendered)
152
+ : rendered,
153
+ );
154
+ }
155
+ continue;
156
+ }
157
+
158
+ const rendered = this.#renderCompactItem(item, isSelected, width, descriptionText, primaryColumnWidth);
142
159
  this.#hitRows[lines.length] = i;
143
160
  lines.push(
144
- !isSelected && i === this.#hoveredIndex && this.theme.hovered ? this.theme.hovered(rendered) : rendered,
161
+ !isSelected && i === this.#hoveredIndex && this.theme.hovered
162
+ ? this.theme.hovered(rendered.line)
163
+ : rendered.line,
145
164
  );
165
+ if (isSelected && rendered.omitted) {
166
+ const detail = descriptionText
167
+ ? `${this.#getDisplayValue(item)} — ${descriptionText}`
168
+ : this.#getDisplayValue(item);
169
+ selectedDetailLines = wrapTextWithAnsi(detail, Math.max(1, width - 2)).map(detailLine =>
170
+ this.theme.description(` ${detailLine}`),
171
+ );
172
+ }
173
+ }
174
+ for (const detailLine of selectedDetailLines) {
175
+ this.#hitRows[lines.length] = undefined;
176
+ lines.push(detailLine);
146
177
  }
147
178
 
148
179
  // Add scroll indicators if needed
@@ -193,13 +224,13 @@ export class SelectList implements Component, MouseRoutable {
193
224
  }
194
225
  }
195
226
 
196
- #renderItem(
227
+ #renderCompactItem(
197
228
  item: SelectItem,
198
229
  isSelected: boolean,
199
230
  width: number,
200
231
  descriptionSingleLine: string | undefined,
201
232
  primaryColumnWidth: number,
202
- ): string {
233
+ ): { line: string; omitted: boolean } {
203
234
  const prefix = isSelected
204
235
  ? `${this.theme.symbols.cursor} `
205
236
  : padding(visibleWidth(this.theme.symbols.cursor) + 1);
@@ -216,22 +247,47 @@ export class SelectList implements Component, MouseRoutable {
216
247
 
217
248
  if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
218
249
  const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
250
+ const omitted =
251
+ visibleWidth(this.#getDisplayValue(item)) > maxPrimaryWidth ||
252
+ visibleWidth(descriptionSingleLine) > remainingWidth;
219
253
  if (isSelected) {
220
- return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
254
+ return {
255
+ line: this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`),
256
+ omitted,
257
+ };
221
258
  }
222
259
 
223
260
  const descText = this.theme.description(spacing + truncatedDesc);
224
- return prefix + truncatedValue + descText;
261
+ return { line: prefix + truncatedValue + descText, omitted };
225
262
  }
226
263
  }
227
264
 
228
265
  const maxWidth = width - prefixWidth - 2;
229
266
  const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
267
+ const omitted = visibleWidth(this.#getDisplayValue(item)) > maxWidth || descriptionSingleLine !== undefined;
230
268
  if (isSelected) {
231
- return this.theme.selectedText(`${prefix}${truncatedValue}`);
269
+ return { line: this.theme.selectedText(`${prefix}${truncatedValue}`), omitted };
232
270
  }
233
271
 
234
- return prefix + truncatedValue;
272
+ return { line: prefix + truncatedValue, omitted };
273
+ }
274
+
275
+ #renderWrappedItem(
276
+ item: SelectItem,
277
+ isSelected: boolean,
278
+ width: number,
279
+ descriptionSingleLine: string | undefined,
280
+ ): string[] {
281
+ const cursor = `${this.theme.symbols.cursor} `;
282
+ const prefixWidth = visibleWidth(cursor);
283
+ const content = descriptionSingleLine
284
+ ? `${this.#getDisplayValue(item)} — ${descriptionSingleLine}`
285
+ : this.#getDisplayValue(item);
286
+ return wrapTextWithAnsi(content, Math.max(1, width - prefixWidth - 2)).map((wrapped, index) => {
287
+ const prefix = index === 0 && isSelected ? cursor : padding(prefixWidth);
288
+ const line = `${prefix}${wrapped}`;
289
+ return isSelected ? this.theme.selectedText(line) : line;
290
+ });
235
291
  }
236
292
 
237
293
  #getPrimaryColumnWidth(): number {
package/src/terminal.ts CHANGED
@@ -106,6 +106,7 @@ export interface Terminal {
106
106
  * Fires when the detected appearance changes, including the initial detection.
107
107
  */
108
108
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
109
+ onFocusChange?(callback: (focused: boolean) => void): void;
109
110
 
110
111
  /** The last detected terminal appearance, or undefined if not yet known. */
111
112
  get appearance(): TerminalAppearance | undefined;
@@ -132,6 +133,8 @@ export class ProcessTerminal implements Terminal {
132
133
  #writeLogPath = $env.PI_TUI_WRITE_LOG || "";
133
134
  #windowsVTInputRestore?: () => void;
134
135
  #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
136
+ #focusCallbacks: Array<(focused: boolean) => void> = [];
137
+ #focused = true;
135
138
  #appearance: TerminalAppearance | undefined;
136
139
  #osc11Pending = false;
137
140
  #osc11QueryQueued = false;
@@ -162,6 +165,10 @@ export class ProcessTerminal implements Terminal {
162
165
  this.#appearanceCallbacks.push(callback);
163
166
  }
164
167
 
168
+ onFocusChange(callback: (focused: boolean) => void): void {
169
+ this.#focusCallbacks.push(callback);
170
+ }
171
+
165
172
  start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void {
166
173
  this.#resizeHandler = onResize;
167
174
  this.#disconnectHandler = onDisconnect;
@@ -184,6 +191,7 @@ export class ProcessTerminal implements Terminal {
184
191
 
185
192
  // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
186
193
  this.#safeWrite("\x1b[?2004h");
194
+ this.#safeWrite("\x1b[?1004h");
187
195
 
188
196
  // Set up resize handler immediately
189
197
  process.stdout.on("resize", this.#resizeHandler);
@@ -319,6 +327,14 @@ export class ProcessTerminal implements Terminal {
319
327
 
320
328
  // Forward individual sequences to the input handler
321
329
  this.#stdinBuffer.on("data", (sequence: string) => {
330
+ if (sequence === "\x1b[I" || sequence === "\x1b[O") {
331
+ const focused = sequence === "\x1b[I";
332
+ if (focused !== this.#focused) {
333
+ this.#focused = focused;
334
+ for (const callback of this.#focusCallbacks) callback(focused);
335
+ }
336
+ return;
337
+ }
322
338
  // Kitty protocol response — always swallow, enable protocol on first match
323
339
  const kittyMatch = sequence.match(kittyResponsePattern);
324
340
  if (kittyMatch) {
@@ -593,6 +609,7 @@ export class ProcessTerminal implements Terminal {
593
609
 
594
610
  // Disable bracketed paste mode
595
611
  this.#safeWrite("\x1b[?2004l");
612
+ this.#safeWrite("\x1b[?1004l");
596
613
 
597
614
  // Disable Mode 2031 appearance change notifications
598
615
  this.#safeWrite("\x1b[?2031l");