pi-subagents-lite 1.4.0 → 1.4.2

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.
@@ -4,8 +4,14 @@
4
4
  * Wraps a SettingsList or SelectList with:
5
5
  * - Top separator line
6
6
  * - Header with title
7
- * - List content
7
+ * - List content (SettingsList renders the highlighted item's description and a
8
+ * hint line below the items itself; SelectList renders inline descriptions)
8
9
  * - Bottom separator line
10
+ *
11
+ * The Back button has been removed. Menus still close via Escape, the
12
+ * back-arrow key, and Ctrl-C — the underlying list components call their
13
+ * `onCancel` on those keys, and the wrapper wires that to `closeMenu` for
14
+ * SelectList (SettingsList receives its own `onCancel` at construction).
9
15
  */
10
16
 
11
17
  import { type Component, isFocusable } from "@earendil-works/pi-tui";
@@ -40,108 +46,63 @@ export class SettingsListWrapper implements Component {
40
46
  this.separatorChar = options.separatorChar ?? "─";
41
47
  this.passthroughKeys = options.passthroughKeys ?? false;
42
48
 
43
- // Append Back item when onCancel provided
44
- if (options.onCancel) {
45
- const list = this.settingsList as any;
46
- if (Array.isArray(list.items)) {
47
- const closeMenu = options.onCancel;
48
- // SelectList has onSelect; SettingsList has onChange. Push correct item shape.
49
- const isSelectList = !!list.onSelect;
50
- if (isSelectList) {
51
- // SelectList expects SelectItem shape: { value, label }
52
- list.items.push(
53
- { value: "__sep__", label: " " },
54
- { value: "__back__", label: "Back" },
55
- );
56
- // Intercept onSelect so the Back item closes the menu. SelectList
57
- // reads its own onSelect property at dispatch time (this.onSelect on
58
- // the target), so reassigning it here is what actually works — a Proxy
59
- // cannot intercept the dispatch.
60
- const prevOnSelect = list.onSelect;
61
- list.onSelect = (item: any) => {
62
- if (item.value === "__back__") {
63
- closeMenu();
64
- return;
65
- }
66
- if (item.value === "__sep__") return;
67
- prevOnSelect?.(item);
68
- };
69
- list.onCancel = () => closeMenu();
70
- } else {
71
- // SettingsList expects SettingItem shape: { id, label, currentValue, submenu }
72
- list.items.push(
73
- { id: "__sep__", label: " ", currentValue: "" },
74
- {
75
- id: "__back__",
76
- label: "Back",
77
- currentValue: "",
78
- submenu: (_v: string, subDone: (v?: string) => void) => {
79
- subDone();
80
- closeMenu();
81
- return undefined as any;
82
- },
83
- },
84
- );
85
- }
86
- }
87
-
88
- // Auto-skip __sep__ items when navigating.
89
- const _rawIndex = Symbol("rawIndex");
90
- const isSep = (item: any) => item?.value === "__sep__" || item?.id === "__sep__";
91
- Object.defineProperty(list, "selectedIndex", {
92
- get() { return list[_rawIndex] ?? 0; },
93
- set(idx) {
94
- const curItems = list.items;
95
- const cur = list[_rawIndex] ?? 0;
96
- let i = Math.max(0, Math.min(idx, curItems.length - 1));
97
- if (isSep(curItems[i])) {
98
- const down = idx > cur;
99
- if (down) {
100
- let next = i + 1;
101
- while (next < curItems.length && isSep(curItems[next])) next++;
102
- if (next < curItems.length) i = next;
103
- else {
104
- next = i - 1;
105
- while (next >= 0 && isSep(curItems[next])) next--;
106
- if (next >= 0) i = next;
107
- }
108
- } else {
109
- let next = i - 1;
110
- while (next >= 0 && isSep(curItems[next])) next--;
111
- if (next >= 0) i = next;
112
- else {
113
- next = i + 1;
114
- while (next < curItems.length && isSep(curItems[next])) next++;
115
- if (next < curItems.length) i = next;
116
- }
117
- }
118
- }
119
- list[_rawIndex] = i;
120
- },
121
- configurable: true,
122
- });
123
- list[_rawIndex] = list.selectedIndex ?? 0;
124
-
125
- // Expose rebuild callback
126
- if (options.onRebuild) {
127
- const isSelectList = !!list.onSelect;
128
- const rebuild = (newItems: any[]) => {
129
- const wrapperItems = [
130
- isSelectList
131
- ? { value: "__sep__", label: " " }
132
- : { id: "__sep__", label: " ", currentValue: "" },
133
- isSelectList
134
- ? { value: "__back__", label: "Back" }
135
- : { id: "__back__", label: "Back", currentValue: "" },
136
- ];
137
- const fullItems = [...newItems, ...wrapperItems];
138
- list.items = fullItems;
139
- list.filteredItems = fullItems;
140
- list.selectedIndex = 0;
141
- list.submenuComponent = null;
142
- };
143
- options.onRebuild(rebuild);
144
- }
49
+ const list = this.settingsList as any;
50
+
51
+ // SelectList has no onCancel of its own; wire closeMenu so Escape,
52
+ // back-arrow (converted to Escape below), and Ctrl-C close the menu.
53
+ // SettingsList receives its own onCancel at construction, so leave it be.
54
+ if (options.onCancel && !list.onCancel) {
55
+ const closeMenu = options.onCancel;
56
+ list.onCancel = () => closeMenu();
57
+ }
58
+
59
+ // Auto-skip __sep__ items when navigating, so the cursor never lands on a
60
+ // separator section header. Menus push their own __sep__ items.
61
+ if (options.onCancel && Array.isArray(list.items)) {
62
+ const _rawIndex = Symbol("rawIndex");
63
+ const isSep = (item: any) => item?.value === "__sep__" || item?.id === "__sep__";
64
+ // Starting just past `start`, walk in `step` direction and return the
65
+ // first non-separator index (or an out-of-bounds sentinel if none).
66
+ const firstNonSepFrom = (start: number, step: number): number => {
67
+ let next = start + step;
68
+ while (next >= 0 && next < list.items.length && isSep(list.items[next])) next += step;
69
+ return next;
70
+ };
71
+ const inBounds = (i: number) => i >= 0 && i < list.items.length;
72
+ Object.defineProperty(list, "selectedIndex", {
73
+ get() { return list[_rawIndex] ?? 0; },
74
+ set(idx) {
75
+ const items = list.items;
76
+ const cur = list[_rawIndex] ?? 0;
77
+ const clamped = Math.max(0, Math.min(idx, items.length - 1));
78
+ if (!isSep(items[clamped])) {
79
+ list[_rawIndex] = clamped;
80
+ return;
81
+ }
82
+ // Landed on a separator: search in the travel direction first,
83
+ // fall back to the opposite direction so the cursor always ends on
84
+ // a real item (or stays put if everything is a separator).
85
+ const step = idx > cur ? 1 : -1;
86
+ const fwd = firstNonSepFrom(clamped, step);
87
+ const back = firstNonSepFrom(clamped, -step);
88
+ list[_rawIndex] = inBounds(fwd) ? fwd : inBounds(back) ? back : clamped;
89
+ },
90
+ configurable: true,
91
+ });
92
+ list[_rawIndex] = list.selectedIndex ?? 0;
93
+ }
94
+
95
+ // Expose rebuild callback. Items are set directly without appending any
96
+ // wrapper-controlled items: descriptions are read dynamically at render
97
+ // time, so they remain correct after a rebuild.
98
+ if (options.onRebuild) {
99
+ const rebuild = (newItems: any[]) => {
100
+ list.items = newItems;
101
+ list.filteredItems = newItems;
102
+ list.selectedIndex = 0;
103
+ list.submenuComponent = null;
104
+ };
105
+ options.onRebuild(rebuild);
145
106
  }
146
107
  }
147
108
 
@@ -192,9 +153,16 @@ export class SettingsListWrapper implements Component {
192
153
  lines.push(" " + styledTitle);
193
154
  lines.push("");
194
155
 
195
- // SettingsList content
156
+ // SettingsList content — strip the hint line that pi-tui always appends
157
+ // (empty line + "Enter/Space to change · Esc to cancel"). Descriptions
158
+ // already explain what each item does, so the hint is redundant.
196
159
  const settingsLines = this.settingsList.render(width);
197
- lines.push(...settingsLines);
160
+ const hintPattern = /Enter\/Space|Esc to cancel/;
161
+ if (settingsLines.length >= 2 && hintPattern.test(settingsLines[settingsLines.length - 1] ?? "")) {
162
+ lines.push(...settingsLines.slice(0, -2));
163
+ } else {
164
+ lines.push(...settingsLines);
165
+ }
198
166
 
199
167
  // Bottom separator
200
168
  lines.push("");
@@ -14,8 +14,8 @@ import {
14
14
  Spacer,
15
15
  Text,
16
16
  type MarkdownTheme,
17
+ visibleWidth,
17
18
  } from "@earendil-works/pi-tui";
18
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
19
19
  import { type LifetimeUsage, formatTokens } from "../agents/usage.js";
20
20
  import type { Theme } from "./agent-widget.js";
21
21
  import { formatMs } from "./format.js";
@@ -44,9 +44,15 @@ export interface ResultViewerStats {
44
44
  /** Lines scrolled per PageUp/PageDown (kept at a fixed, comfortable amount). */
45
45
  const PAGE_STEP = 14;
46
46
 
47
- /** Render width for markdown content and separator line. */
47
+ /** Total outer render width including side borders. */
48
48
  const RENDER_WIDTH = 78;
49
49
 
50
+ /** Horizontal margin (spaces) on each side of content. */
51
+ const MARGIN = 2;
52
+
53
+ /** Width available for content: outer width − side borders (2) − margins (2×MARGIN). */
54
+ const CONTENT_WIDTH = RENDER_WIDTH - 2 - MARGIN * 2;
55
+
50
56
  /** Fixed non-viewport lines in the component (borders, title, spacers, hints, etc.). */
51
57
  const BASE_OVERHEAD = 10;
52
58
 
@@ -143,15 +149,14 @@ export class ResultViewer extends Container implements Component {
143
149
  // Build markdown renderer (pre-render to get total lines)
144
150
  const mdTheme = buildMarkdownTheme(theme);
145
151
  this.markdown = new Markdown(text, 0, 0, mdTheme);
146
- this.renderedLines = this.markdown.render(RENDER_WIDTH);
152
+ this.renderedLines = this.markdown.render(CONTENT_WIDTH);
147
153
 
148
154
  this.buildUI(title, stats);
149
155
  this.updateViewport();
150
156
  }
151
157
 
152
- /** Build the full UI tree — borders, title, stats, viewport, hints. */
158
+ /** Build the full UI tree — title, stats, viewport, hints. Borders drawn by render(). */
153
159
  private buildUI(title: string, stats?: ResultViewerStats): void {
154
- this.addChild(new DynamicBorder());
155
160
  this.addChild(new Spacer(1));
156
161
 
157
162
  // Title bar
@@ -171,7 +176,7 @@ export class ResultViewer extends Container implements Component {
171
176
 
172
177
  // Separator
173
178
  this.addChild(
174
- new Text(this.theme.fg("muted", "─".repeat(RENDER_WIDTH)), 0, 0),
179
+ new Text(this.theme.fg("muted", "─".repeat(CONTENT_WIDTH)), 0, 0),
175
180
  );
176
181
  this.addChild(new Spacer(1));
177
182
 
@@ -183,7 +188,7 @@ export class ResultViewer extends Container implements Component {
183
188
  this.scrollIndicator = new Container();
184
189
  this.addChild(this.scrollIndicator);
185
190
 
186
- // Bottom spacer + key hints + border
191
+ // Bottom spacer + key hints
187
192
  this.addChild(new Spacer(1));
188
193
  const refreshHint = this.callbacks.onRefresh ? " · r refresh" : "";
189
194
  const hints = this.theme.fg(
@@ -192,7 +197,6 @@ export class ResultViewer extends Container implements Component {
192
197
  );
193
198
  this.addChild(new Text(hints, 0, 0));
194
199
  this.addChild(new Spacer(1));
195
- this.addChild(new DynamicBorder());
196
200
  }
197
201
 
198
202
  /**
@@ -278,7 +282,7 @@ export class ResultViewer extends Container implements Component {
278
282
  this.textRef.text = newText;
279
283
  const mdTheme = buildMarkdownTheme(this.theme);
280
284
  this.markdown = new Markdown(newText, 0, 0, mdTheme);
281
- this.renderedLines = this.markdown.render(RENDER_WIDTH);
285
+ this.renderedLines = this.markdown.render(CONTENT_WIDTH);
282
286
  // Preserve scroll position, clamped to new content bounds
283
287
  this.scrollOffset = Math.min(oldOffset, this.renderedLines.length - 1);
284
288
  this.updateViewport();
@@ -295,6 +299,33 @@ export class ResultViewer extends Container implements Component {
295
299
 
296
300
  invalidate(): void {}
297
301
 
302
+ /**
303
+ * Wrap all child output with box-drawing borders (┌─┐/│/└─┘).
304
+ *
305
+ * Children render at contentWidth so wrapped lines align with the side
306
+ * borders: innerWidth (between the two │) must equal the dash row width,
307
+ * and the 1-space padding on each side of the line is reserved inside it,
308
+ * otherwise content rows end up 2 columns wider than the borders and the
309
+ * TUI overlay compositor truncates the right │ away.
310
+ */
311
+ override render(width: number): string[] {
312
+ const innerWidth = Math.max(1, width - 2);
313
+ const contentWidth = Math.max(0, innerWidth - 2);
314
+ const innerLines = super.render(contentWidth);
315
+
316
+ const border = (str: string) => this.theme.fg("muted", str);
317
+ const vline = border("│");
318
+ const hbar = "─".repeat(innerWidth);
319
+
320
+ const result: string[] = [border(`┌${hbar}┐`)];
321
+ for (const line of innerLines) {
322
+ const pad = Math.max(0, contentWidth - visibleWidth(line));
323
+ result.push(`${vline} ${line}${" ".repeat(pad)} ${vline}`);
324
+ }
325
+ result.push(border(`└${hbar}┘`));
326
+ return result;
327
+ }
328
+
298
329
  private scrollTo(offset: number): void {
299
330
  this.scrollOffset = Math.max(0, Math.min(this.renderedLines.length - 1, offset));
300
331
  this.updateViewport();
@@ -311,7 +342,8 @@ export class ResultViewer extends Container implements Component {
311
342
  for (let i = 0; i < visibleLines; i++) {
312
343
  const lineIdx = this.scrollOffset + i;
313
344
  const line = this.renderedLines[lineIdx] ?? "";
314
- this.viewport.addChild(new Text(line, 0, 0));
345
+ const pad = " ".repeat(MARGIN);
346
+ this.viewport.addChild(new Text(pad + line, 0, 0));
315
347
  }
316
348
 
317
349
  // Pad AFTER content to keep viewport at fixed height so the footer
@@ -1,5 +1,5 @@
1
1
  /**
2
- * model-selector.ts — TUI model selection dialog.
2
+ * searchable-select.ts — TUI paginated, searchable pick-list dialog.
3
3
  *
4
4
  * Reuses the same building blocks as pi's ModelSelectorComponent but without
5
5
  * the SettingsManager dependency — no side effects, just callbacks.
@@ -15,52 +15,52 @@ import {
15
15
  Text,
16
16
  } from "@earendil-works/pi-tui";
17
17
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
18
- import type { Theme } from "../ui/agent-widget.js";
18
+ import type { Theme } from "./agent-widget.js";
19
19
 
20
20
  /* ------------------------------------------------------------------ */
21
21
  /* Types */
22
22
  /* ------------------------------------------------------------------ */
23
23
 
24
- export interface ModelOption {
25
- /** "provider/model-id" — the value returned on selection */
24
+ export interface SelectOption {
25
+ /** The value returned on selection (e.g. "provider/model-id"). */
26
26
  value: string;
27
- /** Display label (model-id without provider prefix) */
27
+ /** Display label. */
28
28
  label: string;
29
- /** Provider name for badge */
30
- provider: string;
29
+ /** Provider name for badge; omitted when the label already conveys it (e.g. provider/type lists). */
30
+ provider?: string;
31
31
  }
32
32
 
33
- interface ModelSelectorCallbacks {
33
+ interface SelectDialogCallbacks {
34
34
  onSelect: (value: string) => void;
35
35
  onCancel: () => void;
36
36
  }
37
37
 
38
38
  /* ------------------------------------------------------------------ */
39
- /* ModelSelectorDialog */
39
+ /* SearchableSelectDialog */
40
40
  /* ------------------------------------------------------------------ */
41
41
 
42
42
  const MAX_VISIBLE = 10;
43
43
 
44
44
  /**
45
- * A paginated, searchable model selector dialog.
45
+ * A paginated, searchable selection dialog.
46
46
  *
47
47
  * Rendering mirrors pi's ModelSelectorComponent:
48
48
  * - Top border
49
49
  * - Search input
50
- * - Paginated model list (10 at a time, centered on selection)
50
+ * - Paginated option list (10 at a time, centered on selection)
51
51
  * - Scroll indicator "(3/47)"
52
52
  * - Bottom border
53
53
  *
54
54
  * Key bindings: up/down/pageup/pagedown/enter/escape + pass-through to search.
55
55
  */
56
- export class ModelSelectorDialog extends Container implements Focusable {
56
+ export class SearchableSelectDialog extends Container implements Focusable {
57
57
  private searchInput: Input;
58
58
  private listContainer: Container;
59
- private items: ModelOption[];
60
- private filteredItems: ModelOption[];
59
+ private items: SelectOption[];
60
+ private filteredItems: SelectOption[];
61
61
  private selectedIndex: number;
62
- private currentModel: string | null;
63
- private callbacks: ModelSelectorCallbacks;
62
+ private currentValue: string | null;
63
+ private callbacks: SelectDialogCallbacks;
64
64
  private theme: Theme;
65
65
 
66
66
  // Focusable implementation — propagate to searchInput for IME cursor
@@ -76,21 +76,21 @@ export class ModelSelectorDialog extends Container implements Focusable {
76
76
  }
77
77
 
78
78
  constructor(
79
- items: ModelOption[],
80
- currentModel: string | null,
81
- callbacks: ModelSelectorCallbacks,
79
+ items: SelectOption[],
80
+ currentValue: string | null,
81
+ callbacks: SelectDialogCallbacks,
82
82
  theme: Theme,
83
83
  ) {
84
84
  super();
85
85
 
86
86
  this.items = items;
87
- this.currentModel = currentModel;
87
+ this.currentValue = currentValue;
88
88
  this.callbacks = callbacks;
89
89
  this.theme = theme;
90
90
  this.filteredItems = [...items];
91
91
 
92
- // Pre-select current model if present
93
- const currentIdx = items.findIndex((m) => m.value === currentModel);
92
+ // Pre-select current value if present
93
+ const currentIdx = items.findIndex((m) => m.value === currentValue);
94
94
  this.selectedIndex = currentIdx >= 0 ? currentIdx : 0;
95
95
 
96
96
  // Build UI
@@ -185,7 +185,7 @@ export class ModelSelectorDialog extends Container implements Focusable {
185
185
 
186
186
  // Everything else → search input (triggers fuzzy filter)
187
187
  this.searchInput.handleInput(keyData);
188
- this.filterModels();
188
+ this.filterItems();
189
189
  }
190
190
 
191
191
  invalidate(): void {
@@ -196,7 +196,7 @@ export class ModelSelectorDialog extends Container implements Focusable {
196
196
  /* Private helpers */
197
197
  /* ------------------------------------------------------------------ */
198
198
 
199
- private filterModels(): void {
199
+ private filterItems(): void {
200
200
  const query = this.searchInput.getValue();
201
201
  if (!query) {
202
202
  this.filteredItems = [...this.items];
@@ -221,7 +221,7 @@ export class ModelSelectorDialog extends Container implements Focusable {
221
221
  const { filteredItems } = this;
222
222
  if (filteredItems.length === 0) {
223
223
  this.listContainer.addChild(
224
- new Text(this.theme.fg("muted", " No matching models"), 0, 0),
224
+ new Text(this.theme.fg("muted", " No matching items"), 0, 0),
225
225
  );
226
226
  return;
227
227
  }
@@ -241,14 +241,14 @@ export class ModelSelectorDialog extends Container implements Focusable {
241
241
  if (!item) continue;
242
242
 
243
243
  const isSelected = i === this.selectedIndex;
244
- const isCurrent = item.value === this.currentModel;
244
+ const isCurrent = item.value === this.currentValue;
245
245
 
246
- const modelText = isSelected
246
+ const labelText = isSelected
247
247
  ? this.theme.fg("accent", "→ ") + this.theme.fg("accent", item.label)
248
248
  : ` ${item.label}`;
249
- const providerBadge = this.theme.fg("muted", `[${item.provider}]`);
249
+ const providerBadge = item.provider ? this.theme.fg("muted", `[${item.provider}]`) : "";
250
250
  const checkmark = isCurrent ? this.theme.fg("success", " ✓") : "";
251
- const line = `${modelText} ${providerBadge}${checkmark}`;
251
+ const line = `${labelText} ${providerBadge}${checkmark}`;
252
252
 
253
253
  this.listContainer.addChild(new Text(line, 0, 0));
254
254
  }