@pi-unipi/ask-user 2.1.3 → 2.3.0

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/ask-ui.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, type TUI, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
9
  import type { Theme, AgentToolResult } from "@earendil-works/pi-coding-agent";
10
+ import { adaptiveInnerWidth, contentWidth, normalizeWidth, safeRepeat, shouldRenderBorder, WidthKeyedCache } from "@pi-unipi/core";
10
11
  import type { NormalizedOption, AskUserResponse } from "./types.js";
11
12
 
12
13
  /** Result returned by the ask UI */
@@ -60,7 +61,8 @@ export function renderAskUI(params: {
60
61
  let optionIndex = 0;
61
62
  let editMode = false;
62
63
  let editTarget: "freeform" | number = "freeform"; // which option is being edited
63
- let cachedLines: string[] | undefined;
64
+ // Width-keyed so a terminal resize can never serve stale, over-wide lines.
65
+ const lineCache = new WidthKeyedCache();
64
66
  const selected = new Set<string>();
65
67
  let customText: string | null = null; // Store custom text (global freeform)
66
68
  const optionCustomTexts = new Map<string, string>(); // Per-option custom text for allowCustom
@@ -157,7 +159,7 @@ export function renderAskUI(params: {
157
159
  }
158
160
 
159
161
  function refresh() {
160
- cachedLines = undefined;
162
+ lineCache.clear();
161
163
  tui.requestRender();
162
164
  }
163
165
 
@@ -363,29 +365,39 @@ export function renderAskUI(params: {
363
365
  }
364
366
  }
365
367
 
366
- function render(width: number): string[] {
367
- if (cachedLines) return cachedLines;
368
+ function render(rawWidth: number): string[] {
369
+ const width = normalizeWidth(rawWidth);
370
+ const cached = lineCache.get(width);
371
+ if (cached) return cached;
368
372
 
369
373
  const lines: string[] = [];
370
- const innerWidth = Math.max(40, width - 2);
374
+ // Never exceed the terminal width — pi-tui throws on over-wide lines.
375
+ // On very narrow terminals the box border is dropped so the little
376
+ // width available all goes to content.
377
+ const innerWidth = adaptiveInnerWidth(width);
378
+ const bordered = shouldRenderBorder(width);
371
379
  const border = (s: string) => theme.fg("accent", s);
372
380
 
373
381
  function padVisible(content: string, targetWidth: number): string {
374
382
  const vw = visibleWidth(content);
375
- const pad = Math.max(0, targetWidth - vw);
376
- return content + " ".repeat(pad);
383
+ return content + safeRepeat(" ", targetWidth - vw);
377
384
  }
378
385
 
379
- const add = (s: string) => lines.push(border("│") + padVisible(truncateToWidth(s, innerWidth), innerWidth) + border("│"));
386
+ const frame = (content: string) => {
387
+ const body = padVisible(truncateToWidth(content, innerWidth), innerWidth);
388
+ return bordered ? border("│") + body + border("│") : body;
389
+ };
390
+
391
+ const add = (s: string) => lines.push(frame(s));
380
392
  const addWrapped = (s: string) => {
381
393
  for (const line of wrapTextWithAnsi(s, innerWidth)) {
382
- lines.push(border("│") + padVisible(truncateToWidth(line, innerWidth), innerWidth) + border("│"));
394
+ lines.push(frame(line));
383
395
  }
384
396
  };
385
- const addEmpty = () => lines.push(border("") + " ".repeat(innerWidth) + border("│"));
397
+ const addEmpty = () => lines.push(frame(""));
386
398
 
387
399
  // Top border
388
- lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
400
+ if (bordered) lines.push(border(`╭${safeRepeat("─", innerWidth)}╮`));
389
401
 
390
402
  // Context
391
403
  if (context) {
@@ -438,10 +450,9 @@ export function renderAskUI(params: {
438
450
  }
439
451
 
440
452
  // Bottom border
441
- lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
453
+ if (bordered) lines.push(border(`╰${safeRepeat("─", innerWidth)}╯`));
442
454
 
443
- cachedLines = lines;
444
- return lines;
455
+ return lineCache.set(width, lines);
445
456
  }
446
457
 
447
458
  function renderOptions(
@@ -452,16 +463,28 @@ export function renderAskUI(params: {
452
463
  ) {
453
464
  const addWrappedOptionLine = (prefix: string, content: string) => {
454
465
  const prefixWidth = visibleWidth(prefix);
455
- const contentWidth = Math.max(1, width - prefixWidth);
456
- const continuationPrefix = " ".repeat(prefixWidth);
457
- const wrapped = wrapTextWithAnsi(content, contentWidth);
466
+ const wrapWidth = contentWidth(width, prefixWidth);
467
+ const continuationPrefix = safeRepeat(" ", prefixWidth);
468
+ const wrapped = wrapTextWithAnsi(content, wrapWidth);
458
469
  for (let lineIndex = 0; lineIndex < wrapped.length; lineIndex++) {
459
470
  add((lineIndex === 0 ? prefix : continuationPrefix) + wrapped[lineIndex]);
460
471
  }
461
472
  };
462
473
 
474
+ // Indent descriptions under the option label, but give up the indent
475
+ // entirely when the terminal is too narrow to afford it.
476
+ const descriptionIndent = safeRepeat(" ", width > 10 ? 5 : 0);
463
477
  const addWrappedDescription = (description: string) => {
464
- addWrappedOptionLine(" ", theme.fg("muted", description));
478
+ addWrappedOptionLine(descriptionIndent, theme.fg("muted", description));
479
+ };
480
+
481
+ // Inline editor, inset by 3 columns where there is room for it.
482
+ const editorIndent = safeRepeat(" ", width > 8 ? 3 : 0);
483
+ const addInlineEditor = () => {
484
+ add(`${editorIndent}${theme.fg("muted", "Type your response:")}`);
485
+ for (const line of editor.render(contentWidth(width, visibleWidth(editorIndent) + 1))) {
486
+ add(`${editorIndent}${line}`);
487
+ }
465
488
  };
466
489
 
467
490
  for (let i = 0; i < displayOptions.length; i++) {
@@ -488,10 +511,7 @@ export function renderAskUI(params: {
488
511
 
489
512
  // Show edit indicator if in edit mode for this option
490
513
  if (editMode && editTarget === "freeform" && isSelected) {
491
- add(` ${theme.fg("muted", "Type your response:")}`);
492
- for (const line of editor.render(width - 4)) {
493
- add(` ${line}`);
494
- }
514
+ addInlineEditor();
495
515
  }
496
516
  } else if (allowMultiple) {
497
517
  // Multi-select: show checkbox
@@ -512,10 +532,7 @@ export function renderAskUI(params: {
512
532
 
513
533
  // Show edit indicator if in edit mode for this option
514
534
  if (editMode && editTarget === i && isSelected) {
515
- add(` ${theme.fg("muted", "Type your response:")}`);
516
- for (const line of editor.render(width - 4)) {
517
- add(` ${line}`);
518
- }
535
+ addInlineEditor();
519
536
  }
520
537
  } else {
521
538
  // Single-select: option
@@ -544,10 +561,7 @@ export function renderAskUI(params: {
544
561
 
545
562
  // Show edit indicator if in edit mode for this option
546
563
  if (editMode && editTarget === i && isSelected) {
547
- add(` ${theme.fg("muted", "Type your response:")}`);
548
- for (const line of editor.render(width - 4)) {
549
- add(` ${line}`);
550
- }
564
+ addInlineEditor();
551
565
  }
552
566
  }
553
567
 
@@ -561,7 +575,7 @@ export function renderAskUI(params: {
561
575
  return {
562
576
  render,
563
577
  invalidate: () => {
564
- cachedLines = undefined;
578
+ lineCache.clear();
565
579
  },
566
580
  handleInput,
567
581
  };
package/launcher-ui.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { Key, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
9
9
  import type { Theme, KeybindingsManager } from "@earendil-works/pi-coding-agent";
10
+ import { adaptiveInnerWidth, contentWidth, normalizeWidth, safeRepeat, shouldRenderBorder, WidthKeyedCache } from "@pi-unipi/core";
10
11
  import type { SessionLauncherResult } from "./types.js";
11
12
 
12
13
  /** Launcher option definition */
@@ -45,10 +46,11 @@ export function renderLauncherUI(params: {
45
46
 
46
47
  // State
47
48
  let optionIndex = 0;
48
- let cachedLines: string[] | undefined;
49
+ // Width-keyed so a terminal resize can never serve stale, over-wide lines.
50
+ const lineCache = new WidthKeyedCache();
49
51
 
50
52
  function refresh() {
51
- cachedLines = undefined;
53
+ lineCache.clear();
52
54
  _tui.requestRender();
53
55
  }
54
56
 
@@ -79,34 +81,37 @@ export function renderLauncherUI(params: {
79
81
  }
80
82
  }
81
83
 
82
- function render(width: number): string[] {
83
- if (cachedLines) return cachedLines;
84
+ function render(rawWidth: number): string[] {
85
+ const width = normalizeWidth(rawWidth);
86
+ const cached = lineCache.get(width);
87
+ if (cached) return cached;
84
88
 
85
89
  const lines: string[] = [];
86
- const innerWidth = Math.max(40, width - 2);
90
+ // Never exceed the terminal width — pi-tui throws on over-wide lines.
91
+ const innerWidth = adaptiveInnerWidth(width);
92
+ const bordered = shouldRenderBorder(width);
87
93
  const border = (s: string) => theme.fg("accent", s);
88
94
 
89
95
  function padVisible(content: string, targetWidth: number): string {
90
96
  const vw = visibleWidth(content);
91
- const pad = Math.max(0, targetWidth - vw);
92
- return content + " ".repeat(pad);
97
+ return content + safeRepeat(" ", targetWidth - vw);
93
98
  }
94
99
 
95
- const add = (s: string) =>
96
- lines.push(
97
- border("│") +
98
- padVisible(truncateToWidth(s, innerWidth), innerWidth) +
99
- border("│"),
100
- );
101
- const addEmpty = () =>
102
- lines.push(border("│") + " ".repeat(innerWidth) + border("│"));
100
+ const frame = (content: string) => {
101
+ const body = padVisible(truncateToWidth(content, innerWidth), innerWidth);
102
+ return bordered ? border("│") + body + border("│") : body;
103
+ };
104
+
105
+ const add = (s: string) => lines.push(frame(s));
106
+ const addEmpty = () => lines.push(frame(""));
103
107
 
104
108
  // Top border
105
- lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
109
+ if (bordered) lines.push(border(`╭${safeRepeat("─", innerWidth)}╮`));
106
110
 
107
- // Header: show prefill command (truncated)
111
+ // Header: show prefill command (truncated).
112
+ // visibleWidth, not .length — the prefix holds an astral emoji.
108
113
  const headerPrefix = " 🚀 ";
109
- const maxPrefillWidth = innerWidth - headerPrefix.length - 1;
114
+ const maxPrefillWidth = contentWidth(innerWidth, visibleWidth(headerPrefix) + 1);
110
115
  const truncatedPrefill = truncateToWidth(prefill || "(no command)", maxPrefillWidth);
111
116
  add(theme.fg("accent", headerPrefix) + theme.fg("text", truncatedPrefill));
112
117
  addEmpty();
@@ -126,16 +131,15 @@ export function renderLauncherUI(params: {
126
131
  add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
127
132
 
128
133
  // Bottom border
129
- lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
134
+ if (bordered) lines.push(border(`╰${safeRepeat("─", innerWidth)}╯`));
130
135
 
131
- cachedLines = lines;
132
- return lines;
136
+ return lineCache.set(width, lines);
133
137
  }
134
138
 
135
139
  return {
136
140
  render,
137
141
  invalidate: () => {
138
- cachedLines = undefined;
142
+ lineCache.clear();
139
143
  },
140
144
  handleInput,
141
145
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "2.1.3",
3
+ "version": "2.3.0",
4
4
  "description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@pi-unipi/core": "2.1.3"
43
+ "@pi-unipi/core": "2.3.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "^0.80.0",
package/settings-tui.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { Component } from "@earendil-works/pi-tui";
9
9
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import { adaptiveInnerWidth, normalizeWidth, safeRepeat, shouldRenderBorder } from "@pi-unipi/core";
10
11
  import { getAskUserSettings, saveAskUserSettings, type AskUserSettings } from "./config.js";
11
12
 
12
13
  /** ANSI escape codes */
@@ -140,27 +141,39 @@ export class AskUserSettingsOverlay implements Component {
140
141
  /**
141
142
  * Render the overlay.
142
143
  */
143
- render(width: number): string[] {
144
+ render(rawWidth: number): string[] {
145
+ const width = normalizeWidth(rawWidth);
144
146
  const lines: string[] = [];
145
- const innerWidth = Math.max(40, width - 2);
147
+ // Never exceed the given width — pi-tui throws on over-wide lines.
148
+ const innerWidth = adaptiveInnerWidth(width);
149
+ const bordered = shouldRenderBorder(width);
146
150
 
147
151
  function padVisible(content: string, targetWidth: number): string {
148
152
  const vw = visibleWidth(content);
149
- const pad = Math.max(0, targetWidth - vw);
150
- return content + " ".repeat(pad);
153
+ return content + safeRepeat(" ", targetWidth - vw);
151
154
  }
152
155
 
153
- const add = (s: string) => lines.push(`${ansi.cyan}│${ansi.reset}` + padVisible(truncateToWidth(s, innerWidth), innerWidth) + `${ansi.cyan}│${ansi.reset}`);
154
- const addEmpty = () => lines.push(`${ansi.cyan}│${ansi.reset}` + " ".repeat(innerWidth) + `${ansi.cyan}│${ansi.reset}`);
156
+ const frame = (content: string) => {
157
+ const body = padVisible(truncateToWidth(content, innerWidth), innerWidth);
158
+ return bordered
159
+ ? `${ansi.cyan}│${ansi.reset}` + body + `${ansi.cyan}│${ansi.reset}`
160
+ : body;
161
+ };
162
+
163
+ const add = (s: string) => lines.push(frame(s));
164
+ const addEmpty = () => lines.push(frame(""));
155
165
 
156
166
  // Top border
157
- lines.push(`${ansi.cyan}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
167
+ if (bordered) lines.push(`${ansi.cyan}╭${safeRepeat("─", innerWidth)}╮${ansi.reset}`);
158
168
 
159
169
  // Header
160
170
  add(`${ansi.bold}${ansi.cyan}Ask User Settings${ansi.reset}`);
161
171
  add(`${ansi.dim}Configure how the agent can ask you questions${ansi.reset}`);
162
172
  addEmpty();
163
173
 
174
+ // Indent descriptions, unless the terminal is too narrow to afford it.
175
+ const descIndent = safeRepeat(" ", width > 10 ? 3 : 0);
176
+
164
177
  // Settings list
165
178
  for (let i = 0; i < SETTINGS.length; i++) {
166
179
  const item = SETTINGS[i];
@@ -171,7 +184,7 @@ export class AskUserSettingsOverlay implements Component {
171
184
  const descColor = ansi.gray;
172
185
 
173
186
  add(`${isSelected ? ansi.cyan + "▸" + ansi.reset : " "} ${toggle} ${labelColor}${item.label}${ansi.reset}`);
174
- add(` ${descColor}${item.description}${ansi.reset}`);
187
+ add(`${descIndent}${descColor}${item.description}${ansi.reset}`);
175
188
  }
176
189
 
177
190
  // Footer
@@ -179,7 +192,7 @@ export class AskUserSettingsOverlay implements Component {
179
192
  add(`${ansi.dim}↑↓ navigate • Space toggle • Enter save • Esc cancel${ansi.reset}`);
180
193
 
181
194
  // Bottom border
182
- lines.push(`${ansi.cyan}╰${"─".repeat(innerWidth)}╯${ansi.reset}`);
195
+ if (bordered) lines.push(`${ansi.cyan}╰${safeRepeat("─", innerWidth)}╯${ansi.reset}`);
183
196
 
184
197
  return lines;
185
198
  }