@esso0428/pi-subagents 0.17.23 → 0.17.24

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
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.24] - 2026-10-02
11
+
12
+ ### Fixed
13
+ - **Restored agents roster ↑/↓ selection and stopped it stealing keys from this extension's modals**: v0.17.23 replaced the widget's focus probe with a structural check for the prompt editor, which broke roster navigation outright. The probe is unworkable in general — pi can replace the focused editor through `ctx.ui.setEditorComponent()` (pi-tmux-cursor-focus wraps it whenever pi runs under tmux), and an extension may also resolve its own copy of `@earendil-works/pi-tui` at a different version than the host's, so neither `instanceof` nor a structural test can identify it. The widget now tracks the state it actually controls: every `ctx.ui.select`, `ctx.ui.editor`, and `ctx.ui.custom` call in this extension runs inside `withModal()`, which suspends the widget's global terminal listener for as long as the modal holds the keyboard. Roster selection works again, and open modals receive `↑`/`↓`/`enter`/`escape` unimpeded.
14
+
10
15
  ## [0.17.23] - 2026-10-02
11
16
 
12
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esso0428/pi-subagents",
3
- "version": "0.17.23",
3
+ "version": "0.17.24",
4
4
  "description": "A pi extension that brings smart Claude Code-style autonomous sub-agents to pi, with npm:pi-subagents-style JSON agent overrides.",
5
5
  "author": "ESSO0428",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -674,6 +674,21 @@ export default function (pi: ExtensionAPI) {
674
674
  },
675
675
  },
676
676
  );
677
+
678
+ /**
679
+ * Run an AgentWidget-owned modal while the widget's global key listener
680
+ * stands down. The listener runs before the focused component, so without
681
+ * this it would consume ↑/↓/enter/escape from the modal itself.
682
+ */
683
+ async function withModal<T>(open: () => Promise<T>): Promise<T> {
684
+ widget.setModalOpen(true);
685
+ try {
686
+ return await open();
687
+ } finally {
688
+ widget.setModalOpen(false);
689
+ }
690
+ }
691
+
677
692
  function setWidgetMode(m: WidgetMode): void {
678
693
  widgetMode = m;
679
694
  widget.update();
@@ -1845,7 +1860,7 @@ Terse command-style prompts produce shallow, generic work.
1845
1860
  ctx.ui.notify(noAgentsMsg, "info");
1846
1861
  }
1847
1862
 
1848
- const choice = await ctx.ui.select("Agents", options);
1863
+ const choice = await withModal(() => ctx.ui.select("Agents", options));
1849
1864
  if (!choice) return;
1850
1865
 
1851
1866
  if (choice.startsWith("Running agents (")) {
@@ -1909,7 +1924,7 @@ Terse command-style prompts produce shallow, generic work.
1909
1924
  if (hasCustom) legendParts.push("• = project ◦ = global");
1910
1925
  if (hasDisabled) legendParts.push("✕ = disabled");
1911
1926
 
1912
- const selected = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
1927
+ const selected = await withModal(() => ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
1913
1928
  const slTheme = getSettingsListTheme();
1914
1929
  const list = new SettingsList(
1915
1930
  items,
@@ -1928,7 +1943,7 @@ Terse command-style prompts produce shallow, generic work.
1928
1943
  invalidate: () => container.invalidate(),
1929
1944
  handleInput: (data: string) => list.handleInput?.(data),
1930
1945
  };
1931
- });
1946
+ }));
1932
1947
 
1933
1948
  if (selected && getAgentConfig(selected)) {
1934
1949
  await showAgentDetail(ctx, selected);
@@ -1978,7 +1993,7 @@ Terse command-style prompts produce shallow, generic work.
1978
1993
  }
1979
1994
  };
1980
1995
 
1981
- const choice = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
1996
+ const choice = await withModal(() => ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
1982
1997
  const list = new SelectList(
1983
1998
  options,
1984
1999
  Math.min(options.length, 10),
@@ -2003,7 +2018,7 @@ Terse command-style prompts produce shallow, generic work.
2003
2018
  invalidate: () => container.invalidate(),
2004
2019
  handleInput: (data: string) => list.handleInput(data),
2005
2020
  };
2006
- });
2021
+ }));
2007
2022
 
2008
2023
  if (!choice) return undefined;
2009
2024
  return pairs.find(({ record }) => record.id === choice)?.record;
@@ -2082,7 +2097,7 @@ Terse command-style prompts produce shallow, generic work.
2082
2097
 
2083
2098
  const activity = agentActivity.get(record.id);
2084
2099
  const isLive = mode === "live";
2085
- await ctx.ui.custom<undefined>(
2100
+ await withModal(() => ctx.ui.custom<undefined>(
2086
2101
  (tui, theme, keybindings, done) => {
2087
2102
  return new ConversationViewer(tui, session, record, activity, theme, done,
2088
2103
  isLive ? () => {
@@ -2098,7 +2113,7 @@ Terse command-style prompts produce shallow, generic work.
2098
2113
  overlay: true,
2099
2114
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
2100
2115
  },
2101
- );
2116
+ ));
2102
2117
  }
2103
2118
 
2104
2119
  async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
@@ -2129,12 +2144,12 @@ Terse command-style prompts produce shallow, generic work.
2129
2144
  menuOptions = ["Edit", "Disable", "Delete", "Back"];
2130
2145
  }
2131
2146
 
2132
- const choice = await ctx.ui.select(name, menuOptions);
2147
+ const choice = await withModal(() => ctx.ui.select(name, menuOptions));
2133
2148
  if (!choice || choice === "Back") return;
2134
2149
 
2135
2150
  if (choice === "Edit" && file) {
2136
2151
  const content = readFileSync(file.path, "utf-8");
2137
- const edited = await ctx.ui.editor(`Edit ${name}`, content);
2152
+ const edited = await withModal(() => ctx.ui.editor(`Edit ${name}`, content));
2138
2153
  if (edited !== undefined && edited !== content) {
2139
2154
  const { writeFileSync } = await import("node:fs");
2140
2155
  writeFileSync(file.path, edited, "utf-8");
@@ -2168,10 +2183,10 @@ Terse command-style prompts produce shallow, generic work.
2168
2183
 
2169
2184
  /** Eject a default agent: write its embedded config as a .md file. */
2170
2185
  async function ejectAgent(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) {
2171
- const location = await ctx.ui.select("Choose location", [
2186
+ const location = await withModal(() => ctx.ui.select("Choose location", [
2172
2187
  "Project (.pi/agents/)",
2173
2188
  `Personal (${personalAgentsDir()})`,
2174
- ]);
2189
+ ]));
2175
2190
  if (!location) return;
2176
2191
 
2177
2192
  const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
@@ -2232,10 +2247,10 @@ Terse command-style prompts produce shallow, generic work.
2232
2247
  }
2233
2248
 
2234
2249
  // No file (built-in default) — create a stub
2235
- const location = await ctx.ui.select("Choose location", [
2250
+ const location = await withModal(() => ctx.ui.select("Choose location", [
2236
2251
  "Project (.pi/agents/)",
2237
2252
  `Personal (${personalAgentsDir()})`,
2238
- ]);
2253
+ ]));
2239
2254
  if (!location) return;
2240
2255
 
2241
2256
  const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
@@ -2270,18 +2285,18 @@ Terse command-style prompts produce shallow, generic work.
2270
2285
  }
2271
2286
 
2272
2287
  async function showCreateWizard(ctx: ExtensionCommandContext) {
2273
- const location = await ctx.ui.select("Choose location", [
2288
+ const location = await withModal(() => ctx.ui.select("Choose location", [
2274
2289
  "Project (.pi/agents/)",
2275
2290
  `Personal (${personalAgentsDir()})`,
2276
- ]);
2291
+ ]));
2277
2292
  if (!location) return;
2278
2293
 
2279
2294
  const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
2280
2295
 
2281
- const method = await ctx.ui.select("Creation method", [
2296
+ const method = await withModal(() => ctx.ui.select("Creation method", [
2282
2297
  "Generate with Claude (recommended)",
2283
2298
  "Manual configuration",
2284
- ]);
2299
+ ]));
2285
2300
  if (!method) return;
2286
2301
 
2287
2302
  if (method.startsWith("Generate")) {
@@ -2377,7 +2392,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
2377
2392
  if (!description) return;
2378
2393
 
2379
2394
  // 3. Tools
2380
- const toolChoice = await ctx.ui.select("Tools", ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."]);
2395
+ const toolChoice = await withModal(() => ctx.ui.select("Tools", ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."]));
2381
2396
  if (!toolChoice) return;
2382
2397
 
2383
2398
  let tools: string;
@@ -2394,13 +2409,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
2394
2409
  }
2395
2410
 
2396
2411
  // 4. Model
2397
- const modelChoice = await ctx.ui.select("Model", [
2412
+ const modelChoice = await withModal(() => ctx.ui.select("Model", [
2398
2413
  "inherit (parent model)",
2399
2414
  "haiku",
2400
2415
  "sonnet",
2401
2416
  "opus",
2402
2417
  "custom...",
2403
- ]);
2418
+ ]));
2404
2419
  if (!modelChoice) return;
2405
2420
 
2406
2421
  let modelLine = "";
@@ -2414,14 +2429,14 @@ Write the file using the write tool. Only write the file, nothing else.`;
2414
2429
 
2415
2430
  // 5. Thinking
2416
2431
  // "inherit" is a UI-only pseudo-choice (omit the field); the rest mirror pi.
2417
- const thinkingChoice = await ctx.ui.select("Thinking level", ["inherit", ...THINKING_LEVELS]);
2432
+ const thinkingChoice = await withModal(() => ctx.ui.select("Thinking level", ["inherit", ...THINKING_LEVELS]));
2418
2433
  if (!thinkingChoice) return;
2419
2434
 
2420
2435
  let thinkingLine = "";
2421
2436
  if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
2422
2437
 
2423
2438
  // 6. System prompt
2424
- const systemPrompt = await ctx.ui.editor("System prompt", "");
2439
+ const systemPrompt = await withModal(() => ctx.ui.editor("System prompt", ""));
2425
2440
  if (systemPrompt === undefined) return;
2426
2441
 
2427
2442
  // Build the file
@@ -2610,7 +2625,7 @@ ${systemPrompt}
2610
2625
  // Updated on arrow keys so Enter knows which field is selected immediately.
2611
2626
  let currentIndex = 0;
2612
2627
 
2613
- const result = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
2628
+ const result = await withModal(() => ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
2614
2629
  const items = buildItems();
2615
2630
 
2616
2631
  list = new SettingsList(
@@ -2647,7 +2662,7 @@ ${systemPrompt}
2647
2662
  list.handleInput?.(data);
2648
2663
  },
2649
2664
  };
2650
- });
2665
+ }));
2651
2666
 
2652
2667
  // If a numeric field ID was returned, prompt for typed input
2653
2668
  if (result && NUMERIC_IDS.has(result)) {
@@ -238,24 +238,6 @@ export function describeActivity(activeTools: Map<string, string>, responseText?
238
238
  return "thinking…";
239
239
  }
240
240
 
241
- /**
242
- * Structural check for pi's prompt editor.
243
- *
244
- * `instanceof Editor` is unusable here: an extension can resolve its own copy
245
- * of `@earendil-works/pi-tui` at a different version than the host's, and the
246
- * two `Editor` class objects are then unrelated identities, so the check is
247
- * always false. Modals opened with `ctx.ui.custom()` — including the `/agents`
248
- * menus — focus a plain `{ render, invalidate, handleInput }` object instead,
249
- * so the editor's text-buffer methods identify it across pi-tui copies.
250
- */
251
- function isPromptEditor(component: unknown): boolean {
252
- if (typeof component !== "object" || component === null) return false;
253
- const candidate = component as { getText?: unknown; setText?: unknown; submitValue?: unknown };
254
- return typeof candidate.getText === "function"
255
- && typeof candidate.setText === "function"
256
- && typeof candidate.submitValue === "function";
257
- }
258
-
259
241
  // ---- Widget manager ----
260
242
 
261
243
  export class AgentWidget {
@@ -265,6 +247,8 @@ export class AgentWidget {
265
247
  private inputUnsub: (() => void) | undefined;
266
248
  /** Whether arrow keys currently navigate the agent roster. */
267
249
  private navigationActive = false;
250
+ /** Depth of AgentWidget-owned modals currently holding the keyboard. */
251
+ private modalDepth = 0;
268
252
  /** Stable identity of the selected row, so roster changes do not jump selection. */
269
253
  private selectedAgentId: string | undefined;
270
254
  /** Last logical roster index of the selected row, used when it disappears. */
@@ -392,16 +376,25 @@ export class AgentWidget {
392
376
  }
393
377
 
394
378
  /**
395
- * True when pi's prompt editor owns the keyboard.
379
+ * Track an AgentWidget-owned modal (the `/agents` menus and friends).
396
380
  *
397
- * Deliberately fails closed: an unknown or absent focus target means the
398
- * widget does not own the keyboard, so its global terminal listener stays out
399
- * of the way. The previous `focused == null` fallback claimed input during
400
- * teardown and transient render states, which let roster navigation swallow
401
- * ↑/↓/enter/escape from an open modal.
381
+ * The widget's terminal listener runs before the focused component, so while
382
+ * a modal is open the listener must not claim ↑/↓/enter/escape. Whether pi's
383
+ * prompt editor currently owns the keyboard is deliberately not consulted:
384
+ * pi can replace it through `ctx.ui.setEditorComponent()` (for example
385
+ * pi-tmux-cursor-focus wraps it under tmux), and the extension may also
386
+ * resolve its own copy of `@earendil-works/pi-tui` at a different version
387
+ * than the host's. Neither `instanceof` nor a structural probe can identify
388
+ * the editor reliably in that environment, and a wrong answer silently
389
+ * disables roster navigation entirely.
402
390
  */
403
- private editorHasFocus(): boolean {
404
- return isPromptEditor((this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent);
391
+ setModalOpen(open: boolean): void {
392
+ this.modalDepth = open ? this.modalDepth + 1 : Math.max(0, this.modalDepth - 1);
393
+ }
394
+
395
+ /** True when no AgentWidget-owned modal is holding the keyboard. */
396
+ private keyboardAvailable(): boolean {
397
+ return this.modalDepth === 0;
405
398
  }
406
399
 
407
400
  private selectedIndexOf(records: readonly AgentRecord[]): number {
@@ -443,7 +436,7 @@ export class AgentWidget {
443
436
  if (records.length === 0 || !ui) return false;
444
437
 
445
438
  if (!this.navigationActive) {
446
- if (direction !== 1 || !this.editorHasFocus() || (ui.getEditorText?.() ?? "") !== "") return false;
439
+ if (direction !== 1 || !this.keyboardAvailable() || (ui.getEditorText?.() ?? "") !== "") return false;
447
440
  this.activate(records);
448
441
  return true;
449
442
  }
@@ -471,7 +464,7 @@ export class AgentWidget {
471
464
  /** Handle terminal input before it reaches the focused prompt editor. */
472
465
  handleKey(data: string): { consume?: boolean; data?: string } | undefined {
473
466
  if (!this.uiCtx || isKeyRelease(data)) return undefined;
474
- if (!this.editorHasFocus()) {
467
+ if (!this.keyboardAvailable()) {
475
468
  if (this.navigationActive) this.deactivate();
476
469
  return undefined;
477
470
  }