pi-pier 0.3.0 → 0.3.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.
- package/README.md +12 -11
- package/package.json +2 -2
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +8 -12
- package/src/compact-coordinator.ts +278 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/dashboard-command.ts +38 -19
- package/src/efficiency-config-core.ts +287 -484
- package/src/efficiency-store.ts +16 -29
- package/src/focus-poller.ts +14 -35
- package/src/gc-core.ts +10 -36
- package/src/herdr-client.ts +82 -208
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +24 -18
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +17 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +4 -8
- package/src/index.ts +305 -564
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +137 -87
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +54 -50
- package/src/pipe-channel.ts +27 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +167 -127
- package/src/{core → plugins}/todo.ts +61 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +24 -60
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +76 -87
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +23 -28
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +52 -21
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +253 -118
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +367 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +530 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +125 -106
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +52 -25
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- 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
|
|
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:
|
|
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
|
|
34
|
+
node install.mjs # user mode: installs both halves
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
The installer also verifies node / pi / herdr versions
|
|
37
|
+
The installer also verifies node / pi / herdr versions.
|
|
38
38
|
|
|
39
39
|
## What you get
|
|
40
40
|
|
|
@@ -43,9 +43,9 @@ 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 0.86 records the toolset change as a transcript delta, so it applies on the worker's next request and survives resume) |
|
|
47
|
+
| `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. TUI renders single- and multi-select through one picker dialog (multi adds checkboxes: space toggles, `a` all, live count; `recommended` seeds the cursor and pre-checks in multi); pane shows blocked in herdr while waiting |
|
|
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
|
|
|
@@ -53,7 +53,8 @@ The installer also verifies node / pi / herdr versions, probes local paths, and
|
|
|
53
53
|
|---|---|
|
|
54
54
|
| `/todos` | Show the todo list, or edit it: `/todos done|drop|rm|unblock <fuzzy match>` |
|
|
55
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-
|
|
56
|
+
| `/pier-role` | Show the current role + available role names, or switch mid-session: `/pier-role <name>` (pi ≥ 0.86; widening beyond the current toolset asks for confirmation; the switch lands as a transcript tool delta) |
|
|
57
|
+
| `/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
58
|
|
|
58
59
|
### Behaviors
|
|
59
60
|
|
|
@@ -62,7 +63,7 @@ The installer also verifies node / pi / herdr versions, probes local paths, and
|
|
|
62
63
|
- **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
64
|
- **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
65
|
- **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
|
|
66
|
+
- **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
67
|
- **Settlement notices folded**: background subagent completions inject between turns (max 3 shown, rest collapsed) instead of flood-filling at run end
|
|
67
68
|
|
|
68
69
|
## Scope & degradation
|
|
@@ -72,12 +73,12 @@ This extension mutates the pi session even outside herdr. After `pi install npm:
|
|
|
72
73
|
| Surface | Bare `pi` (no `HERDR_ENV`) | Inside herdr |
|
|
73
74
|
|---|---|---|
|
|
74
75
|
| `todo_write`, `/todos`, widget, anti-freeze, stop reminder | live | live |
|
|
75
|
-
| `ask_user_question` | live (TUI select/
|
|
76
|
+
| `ask_user_question` | live (TUI picker for single and multi; host select dialog / typed prompts as RPC fallback) | live + blocked marker |
|
|
76
77
|
| Blocked reporting for blocking `ctx.ui` dialogs (`ui_prompt_start/end`, `select`/`confirm`/`input`/`editor`) | live | live + blocked marker |
|
|
77
78
|
| Hidden inject (`before_agent_start` todo-read; settle reminder) | live | live |
|
|
78
79
|
| `subagent`, `terminal` | **not registered** | live |
|
|
79
80
|
| `/locks`, write-lock on `write`/`edit` | not installed | live |
|
|
80
|
-
| `/pier-config` (read-only config guide over
|
|
81
|
+
| `/pier-config` (read-only config guide over 4 planes) | live | live |
|
|
81
82
|
| slim-frame overlay, pane title, pipe, isolate worktree | off | live |
|
|
82
83
|
| `setActiveTools` role visible layer | off | on (herdr master) |
|
|
83
84
|
|
|
@@ -104,7 +105,7 @@ pi overwrites tools/commands by name; event listeners stack.
|
|
|
104
105
|
## Requirements
|
|
105
106
|
|
|
106
107
|
- Node ≥ 22
|
|
107
|
-
- pi ≥ 0.
|
|
108
|
+
- pi ≥ 0.86.0 (`@earendil-works/pi-coding-agent`) — dynamic toolset (transcript-backed `setActiveTools` deltas) and role switching require it
|
|
108
109
|
- **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
|
|
109
110
|
|
|
110
111
|
## Development
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pier",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"typebox": "*"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
56
|
+
"@earendil-works/pi-coding-agent": "0.86.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
|
-
"
|
|
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
|
|
96
|
-
D100–D103, roles, pi settings,
|
|
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`;
|
|
100
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
+
}
|