@ferris1225/pi-subagents 4.3.2 → 4.3.4

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/src/ui.ts CHANGED
@@ -1,17 +1,4 @@
1
- /**
2
- * TUI pickers for /subagents-setup, built on @earendil-works/pi-tui.
3
- *
4
- * A single self-contained `Picker` component powers both selectors:
5
- * - single-select (model picker): type to fuzzy-filter, arrows to move,
6
- * PageUp/PageDown to page, Enter to choose, Esc to cancel.
7
- * - multi-select (module picker): same navigation, Space toggles a checkbox,
8
- * Enter confirms the selection set.
9
- *
10
- * pi-tui's built-in SelectList only handles up/down/confirm/cancel (no paging),
11
- * so we render the list ourselves and drive it with getKeybindings(). Every line
12
- * is passed through truncateToWidth() — pi hard-crashes if a rendered line is
13
- * wider than the terminal.
14
- */
1
+ /** Searchable, width-safe model picker used inside /subagents-setup. */
15
2
 
16
3
  import {
17
4
  fuzzyFilter,
@@ -22,40 +9,29 @@ import {
22
9
  type SelectItem,
23
10
  type TUI,
24
11
  } from "@earendil-works/pi-tui";
25
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
26
-
27
- /** The slice of the extension context the pickers need (mode + ui), so both
28
- * command handlers (ExtensionCommandContext) and tool execute handlers
29
- * (ExtensionContext) can use them. */
30
- export type PickerContext = Pick<ExtensionCommandContext, "mode" | "ui">;
31
12
 
32
13
  /** Rows shown at once; longer lists are reached with PageUp/PageDown. */
33
- export const PAGE_SIZE = 8;
34
-
35
- export interface PickerStyles {
36
- border: (t: string) => string;
37
- title: (t: string) => string;
38
- hint: (t: string) => string;
39
- cursorMark: (t: string) => string;
40
- selectedLabel: (t: string) => string;
41
- label: (t: string) => string;
42
- dim: (t: string) => string;
43
- checked: (t: string) => string;
44
- unchecked: (t: string) => string;
45
- filterEcho: (t: string) => string;
14
+ const PAGE_SIZE = 8;
15
+
16
+ interface PickerStyles {
17
+ border: (text: string) => string;
18
+ title: (text: string) => string;
19
+ hint: (text: string) => string;
20
+ cursorMark: (text: string) => string;
21
+ selectedLabel: (text: string) => string;
22
+ label: (text: string) => string;
23
+ dim: (text: string) => string;
24
+ filterEcho: (text: string) => string;
46
25
  }
47
26
 
48
- export type PickerItem = SelectItem;
27
+ type PickerItem = SelectItem;
49
28
 
50
- export function pickerItemSearchText(item: PickerItem): string {
29
+ function pickerItemSearchText(item: PickerItem): string {
51
30
  return `${item.value} ${item.label} ${item.description ?? ""}`;
52
31
  }
53
32
 
54
33
  interface PickerCallbacks {
55
- /** single-select: fired with the highlighted value on Enter. */
56
- onSelect?: (value: string) => void;
57
- /** multi-select: fired with the full chosen set on Enter. */
58
- onConfirm?: (values: string[]) => void;
34
+ onSelect: (value: string) => void;
59
35
  onCancel: () => void;
60
36
  }
61
37
 
@@ -67,64 +43,59 @@ export class Picker implements Component, Focusable {
67
43
 
68
44
  constructor(
69
45
  private readonly items: PickerItem[],
70
- private readonly multi: boolean,
71
- private readonly selected: Set<string>,
72
46
  private readonly styles: PickerStyles,
73
47
  private readonly headerLines: string[],
74
48
  private readonly tui: TUI,
75
49
  private readonly keybindings: KeybindingsManager,
76
- private readonly cb: PickerCallbacks,
50
+ private readonly callbacks: PickerCallbacks,
77
51
  initialValue?: string,
78
52
  ) {
79
53
  this.filtered = items;
80
- const initialIndex = initialValue === undefined ? -1 : items.findIndex((item) => item.value === initialValue);
54
+ const initialIndex = initialValue === undefined
55
+ ? -1
56
+ : items.findIndex((item) => item.value === initialValue);
81
57
  if (initialIndex >= 0) this.cursor = initialIndex;
82
58
  }
83
59
 
84
60
  get focused(): boolean {
85
61
  return this._focused;
86
62
  }
63
+
87
64
  set focused(value: boolean) {
88
65
  this._focused = value;
89
66
  }
90
67
 
91
68
  private recompute(): void {
92
- const q = this.query.trim();
93
- this.filtered = q ? fuzzyFilter(this.items, q, pickerItemSearchText) : this.items;
69
+ const query = this.query.trim();
70
+ this.filtered = query ? fuzzyFilter(this.items, query, pickerItemSearchText) : this.items;
94
71
  this.cursor = Math.max(0, Math.min(this.cursor, this.filtered.length - 1));
95
72
  }
96
73
 
97
74
  render(width: number): string[] {
98
- const s = this.styles;
99
75
  const fit = (line: string): string => truncateToWidth(line, width, "");
100
- const border = fit(s.border("─".repeat(Math.max(1, width))));
101
-
102
- const lines: string[] = [border];
103
- for (const h of this.headerLines) lines.push(fit(h));
104
- lines.push(fit(this.query ? s.filterEcho(`filter: ${this.query}`) : s.dim("filter: (type to narrow)")));
76
+ const border = fit(this.styles.border("─".repeat(Math.max(1, width))));
77
+ const lines = [border, ...this.headerLines.map(fit)];
78
+ lines.push(fit(this.query
79
+ ? this.styles.filterEcho(`filter: ${this.query}`)
80
+ : this.styles.dim("filter: (type to narrow)")));
105
81
  lines.push(border);
106
82
 
107
83
  if (this.filtered.length === 0) {
108
- lines.push(fit(s.dim(" (no matches)")));
84
+ lines.push(fit(this.styles.dim(" (no matches)")));
109
85
  } else {
110
86
  const start = Math.max(
111
87
  0,
112
88
  Math.min(this.cursor - Math.floor(PAGE_SIZE / 2), this.filtered.length - PAGE_SIZE),
113
89
  );
114
- const visible = this.filtered.slice(start, start + PAGE_SIZE);
115
- for (let i = 0; i < visible.length; i++) {
116
- const item = visible[i];
117
- const isCursor = start + i === this.cursor;
118
- const mark = isCursor ? s.cursorMark("❯ ") : " ";
119
- const label = isCursor ? s.selectedLabel(item.label) : s.label(item.label);
120
- const description = item.description ? s.dim(` — ${item.description}`) : "";
121
- const line = this.multi
122
- ? mark + (this.selected.has(item.value) ? s.checked("[x] ") : s.unchecked("[ ] ")) + label + description
123
- : mark + label + description;
124
- lines.push(fit(line));
90
+ for (const [index, item] of this.filtered.slice(start, start + PAGE_SIZE).entries()) {
91
+ const isCursor = start + index === this.cursor;
92
+ const mark = isCursor ? this.styles.cursorMark("❯ ") : " ";
93
+ const label = isCursor ? this.styles.selectedLabel(item.label) : this.styles.label(item.label);
94
+ const description = item.description ? this.styles.dim(` — ${item.description}`) : "";
95
+ lines.push(fit(mark + label + description));
125
96
  }
126
- const more = this.filtered.length > PAGE_SIZE ? " ↑/↓ move • PgUp/PgDn page" : "";
127
- lines.push(fit(s.dim(` (${this.cursor + 1}/${this.filtered.length})${more}`)));
97
+ const paging = this.filtered.length > PAGE_SIZE ? " ↑/↓ move • PgUp/PgDn page" : "";
98
+ lines.push(fit(this.styles.dim(` (${this.cursor + 1}/${this.filtered.length})${paging}`)));
128
99
  }
129
100
 
130
101
  lines.push(border);
@@ -132,41 +103,30 @@ export class Picker implements Component, Focusable {
132
103
  }
133
104
 
134
105
  handleInput(data: string): void {
135
- const kb = this.keybindings;
136
- if (kb.matches(data, "tui.select.up")) {
137
- if (this.filtered.length > 0) this.cursor = this.cursor === 0 ? this.filtered.length - 1 : this.cursor - 1;
138
- } else if (kb.matches(data, "tui.select.down")) {
139
- if (this.filtered.length > 0) this.cursor = this.cursor === this.filtered.length - 1 ? 0 : this.cursor + 1;
140
- } else if (kb.matches(data, "tui.select.pageUp")) {
106
+ const keybindings = this.keybindings;
107
+ if (keybindings.matches(data, "tui.select.up")) {
108
+ if (this.filtered.length > 0) {
109
+ this.cursor = this.cursor === 0 ? this.filtered.length - 1 : this.cursor - 1;
110
+ }
111
+ } else if (keybindings.matches(data, "tui.select.down")) {
112
+ if (this.filtered.length > 0) {
113
+ this.cursor = this.cursor === this.filtered.length - 1 ? 0 : this.cursor + 1;
114
+ }
115
+ } else if (keybindings.matches(data, "tui.select.pageUp")) {
141
116
  this.cursor = Math.max(0, this.cursor - PAGE_SIZE);
142
- } else if (kb.matches(data, "tui.select.pageDown")) {
117
+ } else if (keybindings.matches(data, "tui.select.pageDown")) {
143
118
  this.cursor = Math.min(Math.max(0, this.filtered.length - 1), this.cursor + PAGE_SIZE);
144
- } else if (kb.matches(data, "tui.select.confirm")) {
145
- if (this.multi) this.cb.onConfirm?.([...this.selected]);
146
- else {
147
- const item = this.filtered[this.cursor];
148
- if (item) this.cb.onSelect?.(item.value);
149
- }
119
+ } else if (keybindings.matches(data, "tui.select.confirm")) {
120
+ const item = this.filtered[this.cursor];
121
+ if (item) this.callbacks.onSelect(item.value);
150
122
  return;
151
- } else if (kb.matches(data, "tui.select.cancel")) {
152
- this.cb.onCancel();
123
+ } else if (keybindings.matches(data, "tui.select.cancel")) {
124
+ this.callbacks.onCancel();
153
125
  return;
154
126
  } else if (data === "\x7f" || data === "\b") {
155
127
  this.query = this.query.slice(0, -1);
156
128
  this.cursor = 0;
157
129
  this.recompute();
158
- } else if (data === " ") {
159
- if (this.multi) {
160
- const item = this.filtered[this.cursor];
161
- if (item) {
162
- if (this.selected.has(item.value)) this.selected.delete(item.value);
163
- else this.selected.add(item.value);
164
- }
165
- } else {
166
- this.query += data;
167
- this.cursor = 0;
168
- this.recompute();
169
- }
170
130
  } else if (isPrintable(data)) {
171
131
  this.query += data;
172
132
  this.cursor = 0;
@@ -179,70 +139,22 @@ export class Picker implements Component, Focusable {
179
139
  }
180
140
 
181
141
  function isPrintable(data: string): boolean {
182
- if (data.length === 0) return false;
183
- // Reject ESC-led escape sequences and other control characters.
184
- return data.charCodeAt(0) >= 0x20;
142
+ return data.length > 0 && data.charCodeAt(0) >= 0x20;
185
143
  }
186
144
 
187
- /** Build style functions from the pi theme. `any` on the color param dodges a
188
- * strict contravariance error when assigning the theme's narrow color union. */
189
- function makeStyles(theme: { fg: (color: any, text: string) => string; bold: (text: string) => string }): PickerStyles {
145
+ /** Build picker styles from the pi theme. */
146
+ export function makePickerStyles(theme: {
147
+ fg: (color: any, text: string) => string;
148
+ bold: (text: string) => string;
149
+ }): PickerStyles {
190
150
  return {
191
- border: (t) => theme.fg("accent", t),
192
- title: (t) => theme.fg("accent", theme.bold(t)),
193
- hint: (t) => theme.fg("dim", t),
194
- cursorMark: (t) => theme.fg("accent", t),
195
- selectedLabel: (t) => theme.fg("accent", theme.bold(t)),
196
- label: (t) => t,
197
- dim: (t) => theme.fg("dim", t),
198
- checked: (t) => theme.fg("accent", t),
199
- unchecked: (t) => theme.fg("dim", t),
200
- filterEcho: (t) => theme.fg("accent", t),
151
+ border: (text) => theme.fg("accent", text),
152
+ title: (text) => theme.fg("accent", theme.bold(text)),
153
+ hint: (text) => theme.fg("dim", text),
154
+ cursorMark: (text) => theme.fg("accent", text),
155
+ selectedLabel: (text) => theme.fg("accent", theme.bold(text)),
156
+ label: (text) => text,
157
+ dim: (text) => theme.fg("dim", text),
158
+ filterEcho: (text) => theme.fg("accent", text),
201
159
  };
202
160
  }
203
-
204
- function requireTui(ctx: PickerContext): boolean {
205
- if (ctx.mode !== "tui") {
206
- ctx.ui.notify("/subagents-setup requires Pi's interactive TUI.", "error");
207
- return false;
208
- }
209
- return true;
210
- }
211
-
212
- /** Single-select with fuzzy filter + paging. Resolves undefined on Esc. */
213
- export function promptSelectOne(
214
- ctx: PickerContext,
215
- title: string,
216
- hint: string,
217
- items: PickerItem[],
218
- initialValue?: string,
219
- ): Promise<string | undefined> {
220
- if (!requireTui(ctx)) return Promise.resolve(undefined);
221
- return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
222
- const styles = makeStyles(theme);
223
- const header = [styles.title(title), styles.hint(hint)];
224
- return new Picker(items, false, new Set<string>(), styles, header, tui, keybindings, {
225
- onSelect: (value) => done(value),
226
- onCancel: () => done(undefined),
227
- }, initialValue);
228
- });
229
- }
230
-
231
- /** Multi-select with fuzzy filter + paging. Resolves undefined on Esc. */
232
- export function promptSelectMany(
233
- ctx: PickerContext,
234
- title: string,
235
- hint: string,
236
- items: PickerItem[],
237
- initialSelected: readonly string[],
238
- ): Promise<string[] | undefined> {
239
- if (!requireTui(ctx)) return Promise.resolve(undefined);
240
- return ctx.ui.custom<string[] | undefined>((tui, theme, keybindings, done) => {
241
- const styles = makeStyles(theme);
242
- const header = [styles.title(title), styles.hint(hint)];
243
- return new Picker(items, true, new Set<string>(initialSelected), styles, header, tui, keybindings, {
244
- onConfirm: (values) => done(values),
245
- onCancel: () => done(undefined),
246
- });
247
- });
248
- }
package/src/widget.ts CHANGED
@@ -6,12 +6,12 @@
6
6
  * id and agent so every label starts at the same column; a resumed thread
7
7
  * carries a dim `↻` inside the agent column.
8
8
  * - A live run owns two lines. Line 1 is what it is: identity, task label,
9
- * then the telemetry flow (`provider/model`, token flow in the pi-footer
10
- * vocabulary `↑in ↓out R/W cache`, cost, wait state, seconds-precision
11
- * elapsed). Line 2 is what it is doing right now: the live activity, dim,
12
- * indented under the label column behind a `↳` marker.
9
+ * then the telemetry flow (worktree, wait state, token flow in the pi-footer
10
+ * vocabulary `↑in ↓out R/W cache`, cost, `provider/model`, effective
11
+ * `think:<level>`, seconds-precision elapsed). Line 2 is the current activity,
12
+ * dim and indented under the label column behind a `↳` marker.
13
13
  * - Telemetry drops leftmost-first under width pressure (badge, wait, usage,
14
- * model); the elapsed survives every width.
14
+ * model, thinking); the elapsed survives every width.
15
15
  * - Queued rows say what they actually wait for ("queued" for a process slot,
16
16
  * "repo lane" for shared-writer serialization, "starting" while the child
17
17
  * process launches) instead of one catch-all "queued".
@@ -129,24 +129,25 @@ function usagePart(usage: UsageStats | undefined): string | undefined {
129
129
  }
130
130
 
131
131
  /** Telemetry tail parts of a run row: badge and wait word first (dropped first
132
- * under pressure), then the usage part, the model, and the always-surviving
133
- * elapsed. */
132
+ * under pressure), then usage, model, effective thinking strength, and the
133
+ * always-surviving elapsed time. */
134
134
  function telemetryTailParts(run: RunView, now: number): Array<string | undefined> {
135
- // Queued rows omit the model (the route is re-resolved at actual start).
135
+ // Queued rows omit model and thinking because the route is re-resolved at actual start.
136
136
  // The full provider/model ref is kept — "which provider served this run" is
137
137
  // exactly what a multi-provider session needs to see.
138
138
  const modelPart = run.status === "queued" || !run.model ? undefined : run.model;
139
+ const thinkingPart = run.status === "queued" || !run.thinking ? undefined : `think:${run.thinking}`;
139
140
  const badge = run.isolation === "worktree" ? worktreeBadge(run) : undefined;
140
141
  const wait = run.status === "queued" ? waitWord(run) : undefined;
141
- // Drop order under pressure: badge, wait word, usage, model; elapsed
142
+ // Drop order under pressure: badge, wait word, usage, model, thinking; elapsed
142
143
  // survives every width the identity leaves room for.
143
- return [badge, wait, usagePart(run.usage), modelPart, formatElapsed(run, now) || undefined];
144
+ return [badge, wait, usagePart(run.usage), modelPart, thinkingPart, formatElapsed(run, now) || undefined];
144
145
  }
145
146
 
146
147
  /** Two lines for a live run. Line 1 is what the run is: identity, task label,
147
- * then the telemetry flow (worktree badge, token flow, cost, provider/model,
148
- * wait state, elapsed). Line 2 is what it is doing right now: the live
149
- * activity, dim, indented under the label column behind a `↳` marker. The
148
+ * then telemetry (worktree badge, wait state, token flow, cost, provider/model,
149
+ * effective thinking, elapsed). Line 2 is the current activity, dim and indented
150
+ * under the label column behind a `↳` marker. The
150
151
  * label takes the full content budget on line 1; the identity and the elapsed
151
152
  * survive every width. */
152
153
  function primaryLine(