pi-pier 0.2.7 → 0.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.
Files changed (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +12 -0
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +60 -10
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +121 -47
  19. package/src/core/todo.ts +25 -11
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -1
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +3 -1
  33. package/src/index.ts +330 -34
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +4 -2
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +82 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
package/README.md CHANGED
@@ -43,14 +43,25 @@ 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` |
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) |
47
47
  | `terminal` | Persistent interactive shells in dedicated herdr panes. Actions: `open`, `send`, `read`, `signal`, `close`, `list` |
48
- | `ask_user_question` | Human gate: structured options plus Other; pane shows blocked in herdr while waiting |
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
+
50
+ ### Commands
51
+
52
+ | Command | Purpose |
53
+ |---|---|
54
+ | `/todos` | Show the todo list, or edit it: `/todos done|drop|rm|unblock <fuzzy match>` |
55
+ | `/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 |
49
57
 
50
58
  ### Behaviors
51
59
 
52
60
  - **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
53
- - **Soft locks**: write paths lock per-pane; conflicts warn/block instead of racing
61
+ - **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
+ - **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
+ - **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
+ - **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
54
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
55
66
  - **Settlement notices folded**: background subagent completions inject between turns (max 3 shown, rest collapsed) instead of flood-filling at run end
56
67
 
@@ -61,10 +72,12 @@ This extension mutates the pi session even outside herdr. After `pi install npm:
61
72
  | Surface | Bare `pi` (no `HERDR_ENV`) | Inside herdr |
62
73
  |---|---|---|
63
74
  | `todo_write`, `/todos`, widget, anti-freeze, stop reminder | live | live |
64
- | `ask_user_question` | live (TUI select/input) | live + blocked marker |
75
+ | `ask_user_question` | live (TUI select/input; multi questions use the toggle list) | live + blocked marker |
76
+ | Blocked reporting for blocking `ctx.ui` dialogs (`ui_prompt_start/end`, `select`/`confirm`/`input`/`editor`) | live | live + blocked marker |
65
77
  | Hidden inject (`before_agent_start` todo-read; settle reminder) | live | live |
66
78
  | `subagent`, `terminal` | **not registered** | live |
67
79
  | `/locks`, write-lock on `write`/`edit` | not installed | live |
80
+ | `/pier-config` (read-only config guide over 5 planes) | live | live |
68
81
  | slim-frame overlay, pane title, pipe, isolate worktree | off | live |
69
82
  | `setActiveTools` role visible layer | off | on (herdr master) |
70
83
 
@@ -91,15 +104,15 @@ pi overwrites tools/commands by name; event listeners stack.
91
104
  ## Requirements
92
105
 
93
106
  - Node ≥ 22
94
- - pi ≥ 0.84 (`@earendil-works/pi-coding-agent`)
95
- - **herdr ≥ 0.8.0** — required for subagents / pane integration / notifications (see the IMPORTANT note above); without it only the todo loop and `ask_user_question` load
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
96
109
 
97
110
  ## Development
98
111
 
99
112
  ```sh
100
113
  git clone https://github.com/July24/pier && cd pier
101
114
  npm install --ignore-scripts
102
- npm test # node --test, ~250 unit tests
115
+ npm test # node --test, ~590 unit tests
103
116
  ```
104
117
 
105
118
  See the [monorepo README](https://github.com/July24/pier) for design principles, the herdr-plugin half, and the one-shot installer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pier",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
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",
@@ -39,6 +39,10 @@
39
39
  "./skills/pi-herdr-workflow"
40
40
  ]
41
41
  },
42
+ "scripts": {
43
+ "typecheck": "tsc --noEmit -p tsconfig.json",
44
+ "test": "node --test \"test/*.test.ts\""
45
+ },
42
46
  "dependencies": {
43
47
  "@deepseek-ai/cordis": "4.0.1",
44
48
  "@deepseek-ai/cordis-plugin-group": "^1.0.1",
@@ -50,6 +54,7 @@
50
54
  },
51
55
  "devDependencies": {
52
56
  "@earendil-works/pi-coding-agent": "0.84.2",
53
- "typebox": "~1.3.7"
57
+ "typebox": "~1.3.7",
58
+ "typescript": "~5.9.3"
54
59
  }
55
60
  }
@@ -0,0 +1,77 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://herdr-pi.local/schemas/efficiency-config.schema.json",
4
+ "title": "pier efficiency configuration (D100-D103)",
5
+ "type": "object",
6
+ "properties": {
7
+ "version": { "type": "integer", "const": 1 },
8
+ "onlineContextCompact": {
9
+ "type": "object",
10
+ "properties": {
11
+ "enabled": { "type": "boolean", "default": false, "description": "是否启用待办驱动的在线上下文压缩" },
12
+ "logEnabled": { "type": "boolean", "default": false, "description": "是否记录在线压缩的决策与节省量日志" },
13
+ "cacheWriteReadRatio": {
14
+ "oneOf": [
15
+ { "type": "number", "minimum": 0 },
16
+ { "type": "string", "enum": ["auto"] }
17
+ ],
18
+ "default": "auto",
19
+ "description": "KV 缓存写入与读取成本比。auto 表示从 ctx.model.cost 自动推导"
20
+ },
21
+ "firstCompactionRequestScale": { "type": "number", "default": 2.0, "minimum": 1.0 },
22
+ "subsequentCompactionMargin": { "type": "number", "default": 1.5, "minimum": 1.0 },
23
+ "keepRecentTokens": {
24
+ "type": "integer",
25
+ "default": 20000,
26
+ "minimum": 1000,
27
+ "description": "原生压缩保留区大小(同时用于 archiveTokens 估算与 nativeCompactionFeasible 预检)。缺省自动继承 pi 的 compaction.keepRecentTokens 设置(默认 20000)。"
28
+ }
29
+ },
30
+ "additionalProperties": false
31
+ },
32
+ "observationPack": {
33
+ "type": "object",
34
+ "properties": {
35
+ "enabled": { "type": "boolean", "default": false, "description": "是否启用大工具输出冷热分级打包" },
36
+ "logEnabled": { "type": "boolean", "default": false, "description": "是否记录观察结果打包与召回日志" },
37
+ "thresholdBytes": { "type": "integer", "default": 10240, "minimum": 1024 },
38
+ "fullSends": { "type": "integer", "default": 2, "minimum": 1 },
39
+ "recallChunkBytes": { "type": "integer", "default": 16384, "minimum": 1024 },
40
+ "excerptBytes": { "type": "integer", "default": 1024, "minimum": 128 }
41
+ },
42
+ "additionalProperties": false
43
+ },
44
+ "evidencePreservingReducer": {
45
+ "type": "object",
46
+ "properties": {
47
+ "enabled": { "type": "boolean", "default": false, "description": "是否启用长测试日志保真提炼" },
48
+ "logEnabled": { "type": "boolean", "default": false, "description": "是否记录提炼比对、耗时与回退日志" },
49
+ "model": {
50
+ "type": "string",
51
+ "description": "专用的轻量 Reducer 模型 (provider/model)。缺省使用当前模型并在日志中提示经济性"
52
+ },
53
+ "minBytes": { "type": "integer", "default": 4096, "minimum": 512 },
54
+ "maxChars": { "type": "integer", "default": 600000 },
55
+ "maxOutputTokens": { "type": "integer", "default": 2048 },
56
+ "timeoutMs": { "type": "integer", "default": 5000, "description": "同步拦截超时上限,默认 5 秒避免阻塞" },
57
+ "localOnly": { "type": "boolean", "default": false, "description": "仅本地归档长日志并跳过模型提炼" }
58
+ },
59
+ "additionalProperties": false
60
+ },
61
+ "jev": {
62
+ "type": "object",
63
+ "description": "jev 决策层(RFC docs/rfc-jev-integration.md):可选的 System One 分类调用,逐点 fail-open",
64
+ "properties": {
65
+ "enabled": { "type": "boolean", "default": false, "description": "是否启用 jev 决策层(还需配置 apiKey 才会真正发起调用)" },
66
+ "logEnabled": { "type": "boolean", "default": false, "description": "是否写 efficiency-logs/jev.jsonl(不记录请求/响应 body)" },
67
+ "baseUrl": { "type": "string", "description": "API 根地址覆盖(中转/网关);缺省 https://api.typesafe.ai" },
68
+ "model": { "type": "string", "default": "jev-1.13.0", "description": "钉住的版本化模型 id;别名会静默漂移导致校准阈值失真" },
69
+ "timeoutMs": { "type": "integer", "default": 2000, "minimum": 500, "description": "单次调用总预算(AbortController 硬杀,SDK 无总预算模式)" },
70
+ "minConfidence": { "type": "number", "default": 0.6, "minimum": 0, "maximum": 1, "description": "Choice/Score 答案被采纳的最低置信度,低于则按未回答处理" },
71
+ "apiKey": { "type": "string", "description": "API key。解析优先级:PIER_JEV_API_KEY 环境变量 > 此处 > TYPESAFE_API_KEY 环境变量" }
72
+ },
73
+ "additionalProperties": false
74
+ }
75
+ },
76
+ "additionalProperties": false
77
+ }
@@ -88,6 +88,18 @@ The ledger is an append-only JSONL file at `~/.pi/agent/herdr-pi/history/<flatte
88
88
 
89
89
  Call: `subagent(action: "resume", taskId: "<uuid>")`
90
90
 
91
+ ## 2.5 Configuration questions
92
+
93
+ When the user asks about pier/pi configuration (or asks to change it), start from the read-only
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
97
+ into `<repo>/.pi-herdr/config-report.md`. Bare `/pier-config` injects the full guided-change
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`.
102
+
91
103
  ## 3. Human gate
92
104
 
93
105
  - For a human decision (approval, tradeoff, direction), use `ask_user_question` (this plugin). Prefer 2-5 options with short labels and tradeoffs in `description`. Do **not** author an "Other" option — the UI appends "Other (type your own)". Use `recommended` (0-based) for the default and `questions` to batch related decisions (max 4). While waiting, your pane is **blocked** in herdr so the human can see and intervene. The answer comes back as the tool result, then you continue.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ANSI-aware text measurement and clipping for pier's TUI surfaces.
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.
9
+ */
10
+
11
+ const SGR = /\x1b\[[0-9;]*m/y;
12
+
13
+ /** Approximate terminal cell width of one code point (0, 1 or 2). */
14
+ export function charWidth(cp: number): number {
15
+ if (cp < 32) return 0;
16
+ if (cp >= 0x7f && cp < 0xa0) return 0;
17
+ if (cp >= 0x0300 && cp <= 0x036f) return 0; // combining marks
18
+ if (
19
+ (cp >= 0x1100 && cp <= 0x115f)
20
+ || (cp >= 0x2e80 && cp <= 0x303e)
21
+ || (cp >= 0x3041 && cp <= 0x33ff)
22
+ || (cp >= 0x3400 && cp <= 0x4dbf)
23
+ || (cp >= 0x4e00 && cp <= 0x9fff)
24
+ || (cp >= 0xa000 && cp <= 0xa4cf)
25
+ || (cp >= 0xac00 && cp <= 0xd7a3)
26
+ || (cp >= 0xf900 && cp <= 0xfaff)
27
+ || (cp >= 0xfe30 && cp <= 0xfe6f)
28
+ || (cp >= 0xff00 && cp <= 0xff60)
29
+ || (cp >= 0xffe0 && cp <= 0xffe6)
30
+ || (cp >= 0x1f300 && cp <= 0x1f9ff)
31
+ || (cp >= 0x20000 && cp <= 0x3fffd)
32
+ ) {
33
+ return 2;
34
+ }
35
+ return 1;
36
+ }
37
+
38
+ /** Rendered cell width of a string that may contain SGR sequences (zero width). */
39
+ export function styledWidth(line: string): number {
40
+ let width = 0;
41
+ let i = 0;
42
+ while (i < line.length) {
43
+ SGR.lastIndex = i;
44
+ const m = SGR.exec(line);
45
+ if (m && m.index === i) {
46
+ i += m[0].length;
47
+ continue;
48
+ }
49
+ const cp = line.codePointAt(i)!;
50
+ width += charWidth(cp);
51
+ i += cp > 0xffff ? 2 : 1;
52
+ }
53
+ return width;
54
+ }
55
+
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
+ */
60
+ export function truncateStyled(line: string, width: number): string {
61
+ if (width <= 0) return '';
62
+ if (styledWidth(line) <= width) return line;
63
+ const keep = Math.max(0, width - 1); // reserve one cell for the ellipsis
64
+ let out = '';
65
+ let used = 0;
66
+ let i = 0;
67
+ while (i < line.length) {
68
+ SGR.lastIndex = i;
69
+ const m = SGR.exec(line);
70
+ if (m && m.index === i) {
71
+ out += m[0];
72
+ i += m[0].length;
73
+ continue;
74
+ }
75
+ const cp = line.codePointAt(i)!;
76
+ const w = charWidth(cp);
77
+ if (used + w > keep) break;
78
+ out += cp > 0xffff ? line.slice(i, i + 2) : line[i]!;
79
+ used += w;
80
+ i += cp > 0xffff ? 2 : 1;
81
+ }
82
+ return `${out}\x1b[0m…`;
83
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * ask_user_question multi-select: pure state machine + a thin TUI component.
3
+ *
4
+ * Why: the previous multi path asked the human to *type* "1,3" into a text prompt,
5
+ * which is the weakest part of the human gate. This module renders a real toggle
6
+ * list (space to toggle, enter to confirm, esc to decline) with the authored
7
+ * choices, an optional trailing free-text row, and a live selection count.
8
+ *
9
+ * The transition function is pure so the UX is testable without a terminal; the
10
+ * component only maps keys to it. Installing the free-text row is opt-out
11
+ * (`allowOther: false`) for pure-choice questions.
12
+ */
13
+ import { truncateStyled } from './ansi-text.ts';
14
+
15
+ export interface MultiSelectOption {
16
+ label: string;
17
+ description?: string;
18
+ }
19
+
20
+ export interface MultiSelectConfig {
21
+ options: readonly MultiSelectOption[];
22
+ /** Render the trailing free-text row (pi-herdr's "Other"). */
23
+ allowOther: boolean;
24
+ /** 0-based index of the recommended option, marked in the list. */
25
+ recommended?: number;
26
+ }
27
+
28
+ export interface MultiSelectState {
29
+ /** Cursor row: 0..options.length-1 are choices, options.length is the Other row. */
30
+ cursor: number;
31
+ /** Toggle state per authored option (never includes the Other row). */
32
+ selected: readonly boolean[];
33
+ }
34
+
35
+ export type MultiSelectKey = 'up' | 'down' | 'toggle' | 'confirm' | 'cancel' | 'all' | 'ignore';
36
+
37
+ export type MultiSelectOutcome =
38
+ | { kind: 'selected'; labels: string[] }
39
+ | { kind: 'other' }
40
+ | { kind: 'cancel' };
41
+
42
+ export interface MultiSelectStep {
43
+ state: MultiSelectState;
44
+ outcome?: MultiSelectOutcome;
45
+ }
46
+
47
+ export function createMultiSelectState(config: MultiSelectConfig): MultiSelectState {
48
+ return { cursor: 0, selected: config.options.map(() => false) };
49
+ }
50
+
51
+ /** Total rows including the optional Other row; the cursor wraps inside this. */
52
+ export function rowCount(config: MultiSelectConfig): number {
53
+ return config.options.length + (config.allowOther ? 1 : 0);
54
+ }
55
+
56
+ export function isOtherRow(state: MultiSelectState, config: MultiSelectConfig): boolean {
57
+ return config.allowOther && state.cursor === config.options.length;
58
+ }
59
+
60
+ export function selectedLabels(state: MultiSelectState, config: MultiSelectConfig): string[] {
61
+ const labels: string[] = [];
62
+ config.options.forEach((option, i) => {
63
+ if (state.selected[i]) labels.push(option.label);
64
+ });
65
+ return labels;
66
+ }
67
+
68
+ /** Apply one key. A returned outcome ends the interaction. */
69
+ export function stepMultiSelect(state: MultiSelectState, key: MultiSelectKey, config: MultiSelectConfig): MultiSelectStep {
70
+ const rows = rowCount(config);
71
+ if (rows === 0) return key === 'cancel' ? { state, outcome: { kind: 'cancel' } } : { state };
72
+ switch (key) {
73
+ case 'cancel':
74
+ return { state, outcome: { kind: 'cancel' } };
75
+ case 'up':
76
+ case 'down': {
77
+ const delta = key === 'up' ? -1 : 1;
78
+ const cursor = (state.cursor + delta + rows) % rows;
79
+ return { state: { ...state, cursor } };
80
+ }
81
+ case 'all': {
82
+ const anyUnselected = state.selected.some((v) => !v);
83
+ return { state: { ...state, selected: state.selected.map(() => anyUnselected) } };
84
+ }
85
+ case 'toggle': {
86
+ if (isOtherRow(state, config)) return { state };
87
+ const selected = state.selected.map((v, i) => (i === state.cursor ? !v : v));
88
+ return { state: { ...state, selected } };
89
+ }
90
+ case 'confirm': {
91
+ if (isOtherRow(state, config)) return { state, outcome: { kind: 'other' } };
92
+ // Enter on a choice row confirms the current toggles; a single-choice
93
+ // question (multi: false) needs no separate space press.
94
+ if (config.options.length === 1) {
95
+ return { state, outcome: { kind: 'selected', labels: [config.options[0]!.label] } };
96
+ }
97
+ return { state, outcome: { kind: 'selected', labels: selectedLabels(state, config) } };
98
+ }
99
+ default:
100
+ return { state };
101
+ }
102
+ }
103
+
104
+ /* ── Key matching ──────────────────────────────────────────────────── */
105
+
106
+ export type KeyMatcher = (data: string, key: Exclude<MultiSelectKey, 'ignore'>) => boolean;
107
+
108
+ /**
109
+ * Fallback matcher for raw key sequences, used when pi-tui cannot be imported.
110
+ * Ordering inside `handleInput` matters for escape: arrow sequences are matched
111
+ * before the bare ESC key because both start with 0x1b.
112
+ */
113
+ export const FALLBACK_KEY_SEQUENCES: Record<Exclude<MultiSelectKey, 'ignore'>, readonly string[]> = {
114
+ up: ['\x1b[A', '\x1bOA', 'k'],
115
+ down: ['\x1b[B', '\x1bOB', 'j'],
116
+ toggle: [' '],
117
+ confirm: ['\r', '\n'],
118
+ cancel: ['\x1b', '\x03'],
119
+ all: ['a', 'A'],
120
+ };
121
+
122
+ export const fallbackKeyMatcher: KeyMatcher = (data, key) => FALLBACK_KEY_SEQUENCES[key].includes(data);
123
+
124
+ /**
125
+ * Prefer pi-tui's `matchesKey` (it understands terminal variations and keybindings
126
+ * remaps); fall back to the built-in sequences when the package is unavailable.
127
+ */
128
+ export async function loadKeyMatcher(): Promise<KeyMatcher> {
129
+ try {
130
+ const tui = await import('@earendil-works/pi-tui') as {
131
+ matchesKey?: (data: string, key: unknown) => boolean;
132
+ Key?: Record<string, unknown>;
133
+ };
134
+ const Key = tui.Key;
135
+ if (typeof tui.matchesKey === 'function' && Key && typeof Key.up === 'string') {
136
+ const map: Record<string, unknown> = {
137
+ up: Key.up,
138
+ down: Key.down,
139
+ toggle: Key.space,
140
+ confirm: Key.enter,
141
+ cancel: Key.escape,
142
+ all: 'a',
143
+ };
144
+ return (data, key) => tui.matchesKey!(data, map[key]);
145
+ }
146
+ } catch {
147
+ /* pi-tui absent: use the built-in sequences. */
148
+ }
149
+ return fallbackKeyMatcher;
150
+ }
151
+
152
+ /* ── TUI component ─────────────────────────────────────────────────── */
153
+
154
+ export interface MinimalTheme {
155
+ fg(color: string, text: string): string;
156
+ bold(text: string): string;
157
+ }
158
+
159
+ export interface MultiSelectComponent {
160
+ render(width: number): string[];
161
+ handleInput(data: string): void;
162
+ invalidate(): void;
163
+ }
164
+
165
+ export interface MultiSelectUiOptions {
166
+ title: string;
167
+ config: MultiSelectConfig;
168
+ theme: MinimalTheme;
169
+ matcher: KeyMatcher;
170
+ /** Called with the outcome; the host resolves ctx.ui.custom. */
171
+ done(outcome: MultiSelectOutcome): void;
172
+ /** Ask for the gate label used by the renderer (tests only). */
173
+ requestRender?: () => void;
174
+ }
175
+
176
+ const RECOMMENDED = ' (Recommended)';
177
+
178
+ export function multiSelectLines(state: MultiSelectState, config: MultiSelectConfig, theme: MinimalTheme): string[] {
179
+ const lines: string[] = [];
180
+ config.options.forEach((option, i) => {
181
+ const cursor = i === state.cursor ? theme.fg('accent', '> ') : ' ';
182
+ const box = state.selected[i] ? theme.fg('success', '[x]') : '[ ]';
183
+ const label = `${option.label}${config.recommended === i ? RECOMMENDED : ''}`;
184
+ const styled = state.selected[i] ? theme.bold(label) : label;
185
+ const desc = option.description ? theme.fg('dim', ` — ${option.description}`) : '';
186
+ lines.push(`${cursor}${box} ${styled}${desc}`);
187
+ });
188
+ if (config.allowOther) {
189
+ const cursor = isOtherRow(state, config) ? theme.fg('accent', '> ') : ' ';
190
+ lines.push(`${cursor}${theme.fg('dim', '[ ] Other (type your own)')}`);
191
+ }
192
+ return lines;
193
+ }
194
+
195
+ export function multiSelectFooter(state: MultiSelectState, config: MultiSelectConfig, theme: MinimalTheme): string {
196
+ const picked = selectedLabels(state, config).length;
197
+ const count = picked > 0 ? theme.fg('success', ` · ${picked} selected`) : '';
198
+ return theme.fg('dim', '↑/↓ move · space toggle · a all · enter confirm · esc cancel') + count;
199
+ }
200
+
201
+ export function createMultiSelectComponent(opts: MultiSelectUiOptions): MultiSelectComponent {
202
+ let state = createMultiSelectState(opts.config);
203
+ let finished = false;
204
+ const finish = (outcome: MultiSelectOutcome): void => {
205
+ if (finished) return;
206
+ finished = true;
207
+ opts.done(outcome);
208
+ };
209
+ return {
210
+ render(width: number): string[] {
211
+ const head = opts.theme.bold(opts.title);
212
+ const body = multiSelectLines(state, opts.config, opts.theme);
213
+ const footer = multiSelectFooter(state, opts.config, opts.theme);
214
+ return [head, ...body, '', footer].map((line) => truncateStyled(line, width));
215
+ },
216
+ handleInput(data: string): void {
217
+ if (finished) return;
218
+ // Arrow sequences are checked before the bare ESC key (shared 0x1b prefix).
219
+ const key: MultiSelectKey = opts.matcher(data, 'up') ? 'up'
220
+ : opts.matcher(data, 'down') ? 'down'
221
+ : opts.matcher(data, 'toggle') ? 'toggle'
222
+ : opts.matcher(data, 'confirm') ? 'confirm'
223
+ : opts.matcher(data, 'cancel') ? 'cancel'
224
+ : opts.matcher(data, 'all') ? 'all'
225
+ : 'ignore';
226
+ if (key === 'ignore') return;
227
+ const next = stepMultiSelect(state, key, opts.config);
228
+ state = next.state;
229
+ opts.requestRender?.();
230
+ if (next.outcome) finish(next.outcome);
231
+ },
232
+ invalidate(): void { /* No cached lines. */ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Run the toggle list through `ctx.ui.custom`.
238
+ * Returns null when the host cannot render custom components (RPC mode / older pi),
239
+ * so the caller can fall back to the typed-index prompt.
240
+ */
241
+ export async function runMultiSelect(
242
+ custom: (factory: unknown) => Promise<unknown>,
243
+ title: string,
244
+ config: MultiSelectConfig,
245
+ ): Promise<MultiSelectOutcome | null> {
246
+ const matcher = await loadKeyMatcher();
247
+ const result = await custom((tui: unknown, theme: unknown, _keybindings: unknown, done: (value: unknown) => void) =>
248
+ createMultiSelectComponent({
249
+ title,
250
+ config,
251
+ theme: theme as MinimalTheme,
252
+ matcher,
253
+ done: (outcome) => done(outcome),
254
+ requestRender: () => (tui as { requestRender?: () => void } | undefined)?.requestRender?.(),
255
+ }));
256
+ if (result === undefined || result === null) return null;
257
+ const outcome = result as MultiSelectOutcome;
258
+ if (outcome.kind === 'cancel' || outcome.kind === 'other' || outcome.kind === 'selected') return outcome;
259
+ return null;
260
+ }