pi-pier 0.3.0 → 0.4.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.
Files changed (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # pi-pier
2
2
 
3
3
  > [!IMPORTANT]
4
- > **This npm package is only the pi-extension half of pier.** pier is a two-half system — this extension plus a **herdr plugin** (`pier.workbench`). Without the herdr side you still get `todo_write` / `ask_user_question`; `subagent` / `terminal` are not registered. Pane integration, notifications, and workspace bootstrap all require herdr. See [Install the herdr half](#install-the-herdr-half-companion-plugin) below.
4
+ > **This npm package is only the pi-extension half of pier.** pier is a two-half system — this extension plus a **herdr plugin** (`pier.workbench`). Without the herdr side you still get `todo_write` / `ask_user_question`; `subagent` / `terminal` are not registered. Pane integration and notifications require herdr. See [Install the herdr half](#install-the-herdr-half-companion-plugin) below.
5
5
 
6
6
  **pier** = **pi** × h**erdr** — a [pi](https://pi.dev/) extension that fuses coding agent sessions with the [herdr](https://herdr.dev/) pane/tab orchestrator.
7
7
 
@@ -21,7 +21,7 @@ pi install git:github.com/July24/pier
21
21
 
22
22
  ## Install the herdr half (companion plugin)
23
23
 
24
- The herdr plugin `pier.workbench` provides the other half: main-tab bootstrap (auto-start a pi master session per workspace), blocked-gate notifications (system notifications when a subagent waits for a human), and the focus-heat layout (focused pane grows in place). Install it from the same repo:
24
+ The herdr plugin `pier.workbench` provides the other half: blocked-gate notifications (system notifications when a subagent waits for a human), the focus-heat layout (focused pane grows in place), and the ops dashboard / sidebar agent view. Install it from the same repo:
25
25
 
26
26
  ```sh
27
27
  herdr plugin install July24/pier/packages/pier-workbench --yes
@@ -31,10 +31,10 @@ Or install **both halves at once** with the one-shot installer from the monorepo
31
31
 
32
32
  ```sh
33
33
  git clone https://github.com/July24/pier && cd pier
34
- node install.mjs # user mode: installs both halves + generates local bootstrap config
34
+ node install.mjs # user mode: installs both halves
35
35
  ```
36
36
 
37
- The installer also verifies node / pi / herdr versions, probes local paths, and writes the workbench bootstrap config (`boot-config.json`) into herdr's plugin config dir.
37
+ The installer also verifies node / pi / herdr versions.
38
38
 
39
39
  ## What you get
40
40
 
@@ -43,26 +43,30 @@ The installer also verifies node / pi / herdr versions, probes local paths, and
43
43
  | Tool | Purpose |
44
44
  |---|---|
45
45
  | `todo_write` | Full-replacement todo list. Session JSONL is the single source of truth; correct rollback on branch switches. Projected live onto the pane title (`▶i ○p ■b ✓c (N/M) · current task`). `/todos` command to view/edit/unblock |
46
- | `subagent` | Delegate self-contained subtasks to an isolated pi session in its own herdr pane. Actions: `spawn` (default; foreground / parallel / background), `list`, `send`, `interrupt`, `resume`, `output` (incremental preview of a running subagent's pane output) |
46
+ | `subagent` | Delegate self-contained subtasks to an isolated pi session in its own herdr pane. Actions: `spawn` (default; foreground / parallel / background), `list`, `send`, `interrupt`, `resume`, `output` (incremental preview of a running subagent's pane output), `role` (switch that worker's role profile mid-session — pi records the toolset change as a transcript delta, so it applies on the worker's next request and survives resume). Every spawned task gets a stable slug: outputs print `slug@paneId`, and `agentId` accepts the slug, the pane id, or the combined form |
47
+ | `report_progress` | Worker panes only: send the delegating master a short checkpoint update without ending the task. Arrives in the master's context as a display-less custom message (no turn, no chat flood), rendered as a `⤴ progress` card |
47
48
  | `terminal` | Persistent interactive shells in dedicated herdr panes. Actions: `open`, `send`, `read`, `signal`, `close`, `list` |
48
- | `ask_user_question` | Human gate: 2-5 authored options plus a trailing free-text row (`allowOther: false` for pure choice); multiple related questions per call. A `multi: true` question opens an interactive toggle list (space toggles, `a` all, enter confirms, esc declines); pane shows blocked in herdr while waiting |
49
49
 
50
50
  ### Commands
51
51
 
52
52
  | Command | Purpose |
53
53
  |---|---|
54
54
  | `/todos` | Show the todo list, or edit it: `/todos done|drop|rm|unblock <fuzzy match>` |
55
+ | `/dashboard [overlay]` | Ops dashboard. Default opens the shared Pier **popup** on its `Dashboard` tab (90% × 80%, singleton); `overlay` opens the same view as an independent full-window pane. Non-Herdr falls back to an in-pane text view. |
56
+ | `/pier-tree [overlay]` | Master only: the full delegation tree — your complete todo list plus every running subagent's full list (live-pushed over the pipe) and settled nodes with ages. Default opens the same shared popup on its `Tree` tab (the snapshot refreshes on every todo/worker push; `1`/`2`/`Tab` switch views inside the popup); `overlay` opens an independent full-window pane; bare pi / a missing plugin falls back to the in-pane notify view. The resident widget stays one line per agent and hides entirely once the self list is fully completed (D113/D114) |
55
57
  | `/locks` | Show write locks held by this pane and by live panes — the **human/operator view** of the write-lock beacons (herdr only). Agents see the holders inside their own write warnings; the raw tokens are readable via `herdr agent list` |
56
- | `/pier-config` | Read-only configuration guide over five planes: bare call = index + hands a guided change to the agent; `show [plane\|all]` = effective value + source (`env > workspace > user > default`); `check` = validate all planes; `doc [path]` = write a report |
58
+ | `/pier-role` | Show the current role + available role names, or switch mid-session: `/pier-role <name>` (widening beyond the current toolset asks for confirmation; the switch lands as a transcript tool delta) |
59
+ | `/pier-config` | Read-only configuration guide over four planes: bare call = index + hands a guided change to the agent; `show [plane\|all]` = effective value + source (`env > workspace > user > default`); `check` = validate all planes; `doc [path]` = write a report |
57
60
 
58
61
  ### Behaviors
59
62
 
60
63
  - **Human-in-the-loop**: every subagent is a visible, interactive TUI pane — step into it anytime to talk directly (fix bugs, take over, answer its `ask_user_question`). Blocked gates raise sidebar markers + notifications
64
+ - **Delegation identity**: the worker knows who delegated it — spawn env (`PI_HERDR_DELEGATION`) replays through the worker marker and the session's own first-frame entry; its system prompt carries a `pier-delegation` section (identity + delivery contract), the transcript opens with an "⟵ delegated by master …" card, the pane title gets an `↳` prefix, and `pi-parent`/`pi-task` tokens feed the workbench dashboard tree and blocked-alert attribution
61
65
  - **Blocking dialogs block the pane**: pi's `ui_prompt_start` / `ui_prompt_end` events cover the inherently blocking `ctx.ui` dialogs (`select` / `confirm` / `input` / `editor`), so a dialog from any extension (not only pier's own ask tool) reports the pane as blocked and emits the `herdr:blocked` edge; nested gates are coalesced. `custom` is excluded because pi also routes resident overlays through it (pier's slim-frame overlay never calls `done()`, and a resident overlay keeps pi's prompt span open anyway)
62
66
  - **Role gate stops early**: a tool call denied by the role manifest returns `terminate`, so a batch whose results are all terminating ends without another model round trip
63
67
  - **Readable transcript**: pier's own session entries (todo edits, subagent/terminal registries, role manifests, soft approvals) and its reminder messages render as compact cards instead of raw JSON
64
68
  - **Soft locks**: write paths register per-pane beacons; conflicts warn (default) or block (`PI_HERDR_WRITE_LOCK=1`) instead of racing silently. Warnings name **every** holder — the audience split is: agent reads its own tool-result warning, human reads `/locks` (or `herdr agent list` for the raw token table). `write`/`edit` only — `bash` writes are not covered
65
- - **Role profiles**: built-in `master` / `worker-default`; custom roles mount from `.pi-herdr/roles/<name>.json`. Toolsets converge per role — deny rules cannot be bypassed
69
+ - **Role profiles**: built-in `master` / `worker-default`; custom roles mount from `.pi-herdr/roles/<name>.json`. Toolsets converge per role — deny rules cannot be bypassed. A role manifest may carry `guidelines` (behavior constraints a toolset cannot express); they ride the system prompt as a `pier-role` section each turn. Masters can switch a worker's role remotely via `subagent action:"role"`; humans can switch the pane they are in via `/pier-role`
66
70
  - **Settlement notices folded**: background subagent completions inject between turns (max 3 shown, rest collapsed) instead of flood-filling at run end
67
71
 
68
72
  ## Scope & degradation
@@ -72,12 +76,12 @@ This extension mutates the pi session even outside herdr. After `pi install npm:
72
76
  | Surface | Bare `pi` (no `HERDR_ENV`) | Inside herdr |
73
77
  |---|---|---|
74
78
  | `todo_write`, `/todos`, widget, anti-freeze, stop reminder | live | live |
75
- | `ask_user_question` | live (TUI select/input; multi questions use the toggle list) | live + blocked marker |
79
+ | `ask_user_question` | live (TUI picker for single and multi; host select dialog / typed prompts as RPC fallback) | live + blocked marker |
76
80
  | Blocked reporting for blocking `ctx.ui` dialogs (`ui_prompt_start/end`, `select`/`confirm`/`input`/`editor`) | live | live + blocked marker |
77
81
  | Hidden inject (`before_agent_start` todo-read; settle reminder) | live | live |
78
82
  | `subagent`, `terminal` | **not registered** | live |
79
83
  | `/locks`, write-lock on `write`/`edit` | not installed | live |
80
- | `/pier-config` (read-only config guide over 5 planes) | live | live |
84
+ | `/pier-config` (read-only config guide over 4 planes) | live | live |
81
85
  | slim-frame overlay, pane title, pipe, isolate worktree | off | live |
82
86
  | `setActiveTools` role visible layer | off | on (herdr master) |
83
87
 
@@ -104,8 +108,8 @@ pi overwrites tools/commands by name; event listeners stack.
104
108
  ## Requirements
105
109
 
106
110
  - Node ≥ 22
107
- - pi ≥ 0.84.4 (`@earendil-works/pi-coding-agent`)
108
- - **herdr ≥ 0.9.0** — required for subagents / pane integration / notifications (see the IMPORTANT note above); without it only the todo loop and `ask_user_question` load
111
+ - pi ≥ 1.3.0 (`@earendil-works/pi-coding-agent`) — `before_agent_start.messageType` and resumed-session active tools
112
+ - **herdr ≥ 0.9.5** — required for subagents / pane integration / notifications (see the IMPORTANT note above); native `pane.focused`, self-reported resume, and shutdown-safe session saves restore subagent panes after a server restart
109
113
 
110
114
  ## Development
111
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pier",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "pier: pi extension that fuses pi agent sessions with the herdr pane/tab orchestrator — todo_write loop closure + interactive subagents in isolated panes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,11 +49,11 @@
49
49
  "@deepseek-ai/cordis-plugin-loader": "^1.0.2"
50
50
  },
51
51
  "peerDependencies": {
52
- "@earendil-works/pi-coding-agent": "*",
52
+ "@earendil-works/pi-coding-agent": ">=1.3.0",
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
- "@earendil-works/pi-coding-agent": "0.84.2",
56
+ "@earendil-works/pi-coding-agent": "1.3.0",
57
57
  "typebox": "~1.3.7",
58
58
  "typescript": "~5.9.3"
59
59
  }
@@ -8,7 +8,13 @@
8
8
  "role": { "type": "string", "pattern": "^[a-z0-9-]+$", "description": "角色名,须与文件名一致" },
9
9
  "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$", "description": "语义版本 x.y.z" },
10
10
  "model": { "type": "string", "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", "description": "按角色路由模型 provider/model(WS-D10;可省略 = 进程默认)" },
11
- "description": { "type": "string" },
11
+ "guidelines": {
12
+ "type": "array",
13
+ "minItems": 1,
14
+ "maxItems": 20,
15
+ "items": { "type": "string", "minLength": 1 },
16
+ "description": "P0 per-role 行为约束(RFC §4.6):工具集表达不了的“怎么用”约束,每轮作为 pier-role prompt section 注入;空数组请省略"
17
+ },
12
18
  "manifest": {
13
19
  "type": "object",
14
20
  "properties": {
@@ -92,13 +92,12 @@ Call: `subagent(action: "resume", taskId: "<uuid>")`
92
92
 
93
93
  When the user asks about pier/pi configuration (or asks to change it), start from the read-only
94
94
  guide instead of guessing: `/pier-config show all` reports every knob's **effective value and
95
- source** (`env > workspace > user > default`), `check` validates all five planes (efficiency
96
- D100–D103, roles, pi settings, boot-config, `PIER_*`/`PI_HERDR_*` env), and `doc` writes a report
95
+ source** (`env > workspace > user > default`), `check` validates all four planes (efficiency
96
+ D100–D103, roles, pi settings, `PIER_*`/`PI_HERDR_*` env), and `doc` writes a report
97
97
  into `<repo>/.pi-herdr/config-report.md`. Bare `/pier-config` injects the full guided-change
98
98
  workflow. Key rules: an untrusted project's workspace layer is ignored (report it, never edit
99
- blindly); pi-owned settings go through pi's own `/settings`; prefer
100
- `npx pier-setup@latest update --force` for boot-config; change one plane at a time and show a diff
101
- before writing. Overview: `docs/configuration.md`.
99
+ blindly); pi-owned settings go through pi's own `/settings`; change one plane at a time and show a
100
+ diff before writing. Overview: `docs/configuration.md`.
102
101
 
103
102
  ## 3. Human gate
104
103
 
package/src/ansi-text.ts CHANGED
@@ -1,16 +1,14 @@
1
1
  /**
2
2
  * ANSI-aware text measurement and clipping for pier's TUI surfaces.
3
3
  *
4
- * Why a local implementation: the todo widget, the pinned pane title and the
5
- * ask_user_question multi-select all need width-correct clipping, but importing
6
- * `visibleWidth`/`truncateToWidth` from pi-tui would make those paths depend on a
7
- * package that may be absent in stripped installs. The table below only has to be
8
- * right for ASCII plus the CJK/emoji ranges that actually appear in user content.
4
+ * Local implementation: the todo widget, the pinned pane title and the ask_user_question multi-select
5
+ * all need width-correct clipping, but importing `visibleWidth`/`truncateToWidth` from pi-tui would
6
+ * make those paths depend on a package that may be absent in stripped installs. The table below only
7
+ * has to be right for ASCII plus the CJK/emoji ranges that actually appear in user content.
9
8
  */
10
9
 
11
10
  const SGR = /\x1b\[[0-9;]*m/y;
12
11
 
13
- /** Approximate terminal cell width of one code point (0, 1 or 2). */
14
12
  export function charWidth(cp: number): number {
15
13
  if (cp < 32) return 0;
16
14
  if (cp >= 0x7f && cp < 0xa0) return 0;
@@ -35,7 +33,7 @@ export function charWidth(cp: number): number {
35
33
  return 1;
36
34
  }
37
35
 
38
- /** Rendered cell width of a string that may contain SGR sequences (zero width). */
36
+ /** Rendered cell width; SGR sequences count as zero cells. */
39
37
  export function styledWidth(line: string): number {
40
38
  let width = 0;
41
39
  let i = 0;
@@ -53,10 +51,7 @@ export function styledWidth(line: string): number {
53
51
  return width;
54
52
  }
55
53
 
56
- /**
57
- * Cut a styled line to `width` cells without breaking escape sequences.
58
- * Wide glyphs count as two cells, so CJK content stays inside the frame.
59
- */
54
+ /** Cut to `width` cells without breaking escape sequences; wide glyphs count as two cells. */
60
55
  export function truncateStyled(line: string, width: number): string {
61
56
  if (width <= 0) return '';
62
57
  if (styledWidth(line) <= width) return line;
@@ -81,3 +76,59 @@ export function truncateStyled(line: string, width: number): string {
81
76
  }
82
77
  return `${out}\x1b[0m…`;
83
78
  }
79
+
80
+ /**
81
+ * Wrap to `width` cells, preferring space breaks and never splitting a wide glyph or an escape
82
+ * sequence. Open SGR codes are re-emitted at continuation starts so a style spanning the break (a
83
+ * dim description) survives; emitted lines end with a reset when a style is open.
84
+ */
85
+ export function wrapStyled(line: string, width: number): string[] {
86
+ if (width <= 0) return [''];
87
+ const out: string[] = [];
88
+ let current = '';
89
+ let used = 0;
90
+ let spaceAt = -1; // index in `current` of the last breakable space
91
+ let open = ''; // SGR codes active at the scan position
92
+ let i = 0;
93
+ while (i < line.length) {
94
+ SGR.lastIndex = i;
95
+ const m = SGR.exec(line);
96
+ if (m && m.index === i) {
97
+ current += m[0];
98
+ open = m[0] === '\x1b[0m' ? '' : open + m[0];
99
+ i += m[0].length;
100
+ continue;
101
+ }
102
+ const cp = line.codePointAt(i)!;
103
+ const w = charWidth(cp);
104
+ const chunk = cp > 0xffff ? line.slice(i, i + 2) : line[i]!;
105
+ if (used + w > width && used > 0) {
106
+ // A space that overflows breaks there; otherwise rewind to the last space so words stay whole.
107
+ const atSpace = chunk === ' ';
108
+ const head = !atSpace && spaceAt > 0 ? current.slice(0, spaceAt) : current;
109
+ const tail = !atSpace && spaceAt > 0 ? current.slice(spaceAt + 1) : '';
110
+ out.push(head.replace(/ +$/, '') + (open ? '\x1b[0m' : ''));
111
+ current = open + tail;
112
+ used = styledWidth(tail);
113
+ const tailSpace = tail.lastIndexOf(' ');
114
+ spaceAt = tailSpace > 0 ? open.length + tailSpace : -1;
115
+ if (atSpace) i += 1; // the breaking space is consumed, not re-processed
116
+ continue; // re-evaluate the pending char on the fresh line
117
+ }
118
+ if (used + w > width) {
119
+ // A glyph wider than the whole line (width 1): emit it alone.
120
+ out.push(current + chunk + (open ? '\x1b[0m' : ''));
121
+ current = open;
122
+ used = 0;
123
+ spaceAt = -1;
124
+ i += cp > 0xffff ? 2 : 1;
125
+ continue;
126
+ }
127
+ current += chunk;
128
+ used += w;
129
+ if (chunk === ' ') spaceAt = current.length - 1;
130
+ i += cp > 0xffff ? 2 : 1;
131
+ }
132
+ if (current || out.length === 0) out.push(current);
133
+ return out;
134
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * ask_user_question dialog: one pier-owned selector for single- and multi-select questions, rendered
3
+ * through `ctx.ui.custom`. Both modes share it so the chrome — border rules, accent+bold title,
4
+ * `→ ` cursor, dim descriptions, keyHint footer — cannot diverge; only the checkbox column, the
5
+ * hints and the selection counter are mode-specific.
6
+ */
7
+ import { styledWidth, truncateStyled, wrapStyled } from './ansi-text.ts';
8
+
9
+ /** Label of the trailing free-text row; ask-user reuses it for the typed fallback list. */
10
+ export const OTHER_ROW_LABEL = 'Other (type your own)';
11
+
12
+ /** Marker the UI appends to the recommended row; the typed fallback list uses the same one. */
13
+ export const RECOMMENDED_SUFFIX = ' (Recommended)';
14
+
15
+ interface DialogOption {
16
+ label: string;
17
+ description?: string;
18
+ }
19
+
20
+ export interface DialogConfig {
21
+ options: readonly DialogOption[];
22
+ allowOther: boolean;
23
+ /** 0-based recommended option: the cursor starts on it, and multi mode also starts with it
24
+ * checked so a bare enter agrees with single mode in both modes. */
25
+ recommended?: number;
26
+ /** true = toggle list (space toggles, enter confirms the set); false = pick-one (enter/space takes the cursor row). */
27
+ multi: boolean;
28
+ }
29
+
30
+ export interface DialogState {
31
+ /** Cursor row: 0..options.length-1 are choices, options.length is the Other row. */
32
+ cursor: number;
33
+ /** Toggle state per authored option (never includes the Other row). */
34
+ selected: readonly boolean[];
35
+ }
36
+
37
+ type DialogKey = 'up' | 'down' | 'toggle' | 'confirm' | 'cancel' | 'all' | 'ignore';
38
+
39
+ export type DialogOutcome =
40
+ | { kind: 'selected'; labels: string[] }
41
+ | { kind: 'other' }
42
+ | { kind: 'cancel' };
43
+
44
+ export interface DialogStep {
45
+ state: DialogState;
46
+ outcome?: DialogOutcome;
47
+ }
48
+
49
+ export function createDialogState(config: DialogConfig): DialogState {
50
+ const hasRecommended =
51
+ config.recommended !== undefined
52
+ && config.recommended >= 0
53
+ && config.recommended < config.options.length;
54
+ const start = hasRecommended ? config.recommended! : 0;
55
+ return {
56
+ cursor: start,
57
+ selected: config.options.map((_, i) => config.multi && hasRecommended && i === start),
58
+ };
59
+ }
60
+
61
+ export function rowCount(config: DialogConfig): number {
62
+ return config.options.length + (config.allowOther ? 1 : 0);
63
+ }
64
+
65
+ export function isOtherRow(state: DialogState, config: DialogConfig): boolean {
66
+ return config.allowOther && state.cursor === config.options.length;
67
+ }
68
+
69
+ export function selectedLabels(state: DialogState, config: DialogConfig): string[] {
70
+ const labels: string[] = [];
71
+ config.options.forEach((option, i) => {
72
+ if (state.selected[i]) labels.push(option.label);
73
+ });
74
+ return labels;
75
+ }
76
+
77
+ /** Apply one key. A returned outcome ends the interaction. */
78
+ export function stepDialog(state: DialogState, key: DialogKey, config: DialogConfig): DialogStep {
79
+ const rows = rowCount(config);
80
+ if (rows === 0) return key === 'cancel' ? { state, outcome: { kind: 'cancel' } } : { state };
81
+ switch (key) {
82
+ case 'cancel':
83
+ return { state, outcome: { kind: 'cancel' } };
84
+ case 'up':
85
+ case 'down': {
86
+ const delta = key === 'up' ? -1 : 1;
87
+ const cursor = (state.cursor + delta + rows) % rows;
88
+ return { state: { ...state, cursor } };
89
+ }
90
+ case 'all': {
91
+ if (!config.multi) return { state };
92
+ const anyUnselected = state.selected.some((v) => !v);
93
+ return { state: { ...state, selected: state.selected.map(() => anyUnselected) } };
94
+ }
95
+ case 'toggle':
96
+ case 'confirm': {
97
+ if (isOtherRow(state, config)) return { state, outcome: { kind: 'other' } };
98
+ if (!config.multi) {
99
+ // Pick-one: enter and space both take the cursor row.
100
+ return { state, outcome: { kind: 'selected', labels: [config.options[state.cursor]!.label] } };
101
+ }
102
+ if (key === 'toggle') {
103
+ const selected = state.selected.map((v, i) => (i === state.cursor ? !v : v));
104
+ return { state: { ...state, selected } };
105
+ }
106
+ // Enter on a choice row confirms the current toggles; a single-choice
107
+ // question needs no separate space press.
108
+ if (config.options.length === 1) {
109
+ return { state, outcome: { kind: 'selected', labels: [config.options[0]!.label] } };
110
+ }
111
+ return { state, outcome: { kind: 'selected', labels: selectedLabels(state, config) } };
112
+ }
113
+ default:
114
+ return { state };
115
+ }
116
+ }
117
+
118
+ /** The slice of pi's KeybindingsManager custom factories receive as 3rd arg. */
119
+ export interface KeybindingsLike {
120
+ matches(data: string, keybinding: string): boolean;
121
+ getKeys(keybinding: string): string[];
122
+ }
123
+
124
+ /** Key resolution, in order (first match wins). `binding` is the host action id consulted when the
125
+ * factory received a keybindings manager; `sequences` are the raw fallbacks used when it did not
126
+ * (older hosts, tests). Mirrors pi's defaults, plus j/k for vim keys; `toggle` has no pi action. */
127
+ const KEY_RESOLUTION: ReadonlyArray<{ key: DialogKey; binding?: string; sequences: readonly string[] }> = [
128
+ { key: 'up', binding: 'tui.select.up', sequences: ['\x1b[A', '\x1bOA', 'k'] },
129
+ { key: 'down', binding: 'tui.select.down', sequences: ['\x1b[B', '\x1bOB', 'j'] },
130
+ { key: 'toggle', sequences: [' '] },
131
+ { key: 'confirm', binding: 'tui.select.confirm', sequences: ['\r', '\n'] },
132
+ { key: 'cancel', binding: 'tui.select.cancel', sequences: ['\x1b', '\x03'] },
133
+ ];
134
+
135
+ export function resolveDialogKey(data: string, keybindings?: KeybindingsLike): DialogKey {
136
+ for (const entry of KEY_RESOLUTION) {
137
+ if (entry.binding !== undefined && keybindings?.matches?.(data, entry.binding) === true) return entry.key;
138
+ if (entry.sequences.includes(data)) return entry.key;
139
+ }
140
+ return data === 'a' || data === 'A' ? 'all' : 'ignore';
141
+ }
142
+
143
+ export interface MinimalTheme {
144
+ fg(color: string, text: string): string;
145
+ bold(text: string): string;
146
+ }
147
+
148
+ interface DialogComponent {
149
+ render(width: number): string[];
150
+ handleInput(data: string): void;
151
+ invalidate(): void;
152
+ }
153
+
154
+ interface DialogUiOptions {
155
+ title: string;
156
+ config: DialogConfig;
157
+ theme: MinimalTheme;
158
+ keybindings?: KeybindingsLike;
159
+ /** Aborting cancels the dialog so the ask tool result and herdr gate settle. */
160
+ signal?: AbortSignal;
161
+ /** pi's ctrl+o tool-output expansion, when the host exposes it. */
162
+ toggleToolsExpanded?: () => void;
163
+ done(outcome: DialogOutcome): void;
164
+ requestRender?: () => void;
165
+ }
166
+
167
+ /** Numbered line for the typed-prompt fallback list (`1. Label (Recommended) — description`); the dialog
168
+ * itself renders unnumbered rows. */
169
+ export function numberedOptionLine(option: DialogOption, index: number, recommended?: number): string {
170
+ const marker = recommended === index ? RECOMMENDED_SUFFIX : '';
171
+ const description = option.description ? ` — ${option.description}` : '';
172
+ return `${index + 1}. ${option.label}${marker}${description}`;
173
+ }
174
+
175
+ export function dialogLines(state: DialogState, config: DialogConfig, theme: MinimalTheme): string[] {
176
+ const lines: string[] = [];
177
+ config.options.forEach((option, i) => {
178
+ const onCursor = i === state.cursor;
179
+ const cursor = onCursor ? theme.fg('accent', '→ ') : ' ';
180
+ const label = `${option.label}${config.recommended === i ? RECOMMENDED_SUFFIX : ''}`;
181
+ const desc = option.description ? theme.fg('dim', ` — ${option.description}`) : '';
182
+ if (config.multi) {
183
+ const box = state.selected[i] ? theme.fg('success', '[x]') : '[ ]';
184
+ const styled = state.selected[i] ? theme.bold(label) : label;
185
+ lines.push(`${cursor}${box} ${styled}${desc}`);
186
+ } else {
187
+ // Pick-one highlights the whole row — enter takes exactly what is lit.
188
+ const styled = onCursor ? theme.fg('accent', label) : label;
189
+ lines.push(`${cursor}${styled}${desc}`);
190
+ }
191
+ });
192
+ if (config.allowOther) {
193
+ const cursor = isOtherRow(state, config) ? theme.fg('accent', '→ ') : ' ';
194
+ const row = config.multi ? `[ ] ${OTHER_ROW_LABEL}` : OTHER_ROW_LABEL;
195
+ lines.push(`${cursor}${theme.fg('dim', row)}`);
196
+ }
197
+ return lines;
198
+ }
199
+
200
+ /** keyHint style shared with pi's selector: dim key + muted verb. */
201
+ function hint(theme: MinimalTheme, key: string, verb: string): string {
202
+ return theme.fg('dim', key) + theme.fg('muted', ` ${verb}`);
203
+ }
204
+
205
+ function displayKeys(keybindings: KeybindingsLike | undefined, id: 'confirm' | 'cancel', fallback: string): string {
206
+ const keys = keybindings?.getKeys?.(`tui.select.${id}`);
207
+ return keys && keys.length > 0 ? keys.join('/') : fallback;
208
+ }
209
+
210
+ export function dialogFooter(
211
+ state: DialogState,
212
+ config: DialogConfig,
213
+ theme: MinimalTheme,
214
+ keybindings?: KeybindingsLike,
215
+ width?: number,
216
+ ): string {
217
+ const confirm = displayKeys(keybindings, 'confirm', 'enter');
218
+ const cancel = displayKeys(keybindings, 'cancel', 'escape/ctrl+c');
219
+ const picked = selectedLabels(state, config).length;
220
+ const chip = config.multi && picked > 0 ? theme.fg('success', ` · ${picked} selected`) : '';
221
+ const navigate = hint(theme, '↑↓', 'navigate');
222
+ const toggle = hint(theme, 'space', 'toggle');
223
+ const all = hint(theme, 'a', 'all');
224
+ const action = hint(theme, confirm, config.multi ? 'confirm' : 'select');
225
+ const cancelHint = hint(theme, cancel, 'cancel');
226
+ const parts = config.multi ? [navigate, toggle, all, action, cancelHint] : [navigate, action, cancelHint];
227
+ const fits = (segs: string[]): boolean => styledWidth(segs.join(' ') + chip) <= (width ?? Infinity);
228
+ // Narrow panes shed the most guessable extras first; the action keys and the live count are the floor.
229
+ for (const droppable of config.multi ? [all, navigate, cancelHint] : [navigate]) {
230
+ if (fits(parts)) break;
231
+ parts.splice(parts.indexOf(droppable), 1);
232
+ }
233
+ return parts.join(' ') + chip;
234
+ }
235
+
236
+ export function createDialogComponent(opts: DialogUiOptions): DialogComponent {
237
+ let state = createDialogState(opts.config);
238
+ let finished = false;
239
+ const onAbort = (): void => finish({ kind: 'cancel' });
240
+ function finish(outcome: DialogOutcome): void {
241
+ if (finished) return;
242
+ finished = true;
243
+ opts.signal?.removeEventListener('abort', onAbort);
244
+ opts.done(outcome);
245
+ }
246
+ if (opts.signal) opts.signal.addEventListener('abort', onAbort, { once: true });
247
+ return {
248
+ render(width: number): string[] {
249
+ const border = opts.theme.fg('border', '─'.repeat(Math.max(1, width)));
250
+ // Content wraps inside a 1-col margin each side (like pi's Text(text, 1, 0)) instead of clipping.
251
+ const inner = Math.max(1, width - 2);
252
+ const pad = (line: string): string => ` ${line}`;
253
+ return [
254
+ border,
255
+ '',
256
+ ...wrapStyled(opts.theme.fg('accent', opts.theme.bold(opts.title)), inner).map(pad),
257
+ '',
258
+ ...dialogLines(state, opts.config, opts.theme)
259
+ .flatMap((row) => wrapStyled(row, inner))
260
+ .map(pad),
261
+ '',
262
+ truncateStyled(` ${dialogFooter(state, opts.config, opts.theme, opts.keybindings, inner)}`, width),
263
+ '',
264
+ border,
265
+ ];
266
+ },
267
+ handleInput(data: string): void {
268
+ if (finished) return;
269
+ if (opts.keybindings?.matches?.(data, 'app.tools.expand') === true) {
270
+ opts.toggleToolsExpanded?.();
271
+ return;
272
+ }
273
+ const key = resolveDialogKey(data, opts.keybindings);
274
+ if (key === 'ignore') return;
275
+ const next = stepDialog(state, key, opts.config);
276
+ state = next.state;
277
+ opts.requestRender?.();
278
+ if (next.outcome) finish(next.outcome);
279
+ },
280
+ invalidate(): void { /* No cached lines. */ },
281
+ };
282
+ }
283
+
284
+ /** Run the dialog through `ctx.ui.custom`. Returns null when the host cannot render custom components
285
+ * (RPC mode / older pi), so the caller can fall back to the select dialog or the typed prompt. */
286
+ export async function runSelectDialog(
287
+ custom: (factory: unknown) => Promise<unknown>,
288
+ title: string,
289
+ config: DialogConfig,
290
+ options?: { signal?: AbortSignal; toggleToolsExpanded?: () => void },
291
+ ): Promise<DialogOutcome | null> {
292
+ if (options?.signal?.aborted) return { kind: 'cancel' };
293
+ const result = await custom((tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) =>
294
+ createDialogComponent({
295
+ title,
296
+ config,
297
+ theme: theme as MinimalTheme,
298
+ keybindings: keybindings as KeybindingsLike | undefined,
299
+ signal: options?.signal,
300
+ toggleToolsExpanded: options?.toggleToolsExpanded,
301
+ done: (outcome) => done(outcome),
302
+ requestRender: () => (tui as { requestRender?: () => void } | undefined)?.requestRender?.(),
303
+ }));
304
+ if (result === undefined || result === null) return null;
305
+ const outcome = result as DialogOutcome;
306
+ if (outcome.kind === 'cancel' || outcome.kind === 'other' || outcome.kind === 'selected') return outcome;
307
+ return null;
308
+ }