pi-pier 0.2.7 → 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 +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
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,15 +43,27 @@ 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), `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
|
-
|
|
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-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 |
|
|
49
58
|
|
|
50
59
|
### Behaviors
|
|
51
60
|
|
|
52
61
|
- **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
|
-
- **
|
|
54
|
-
- **Role
|
|
62
|
+
- **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)
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
55
67
|
- **Settlement notices folded**: background subagent completions inject between turns (max 3 shown, rest collapsed) instead of flood-filling at run end
|
|
56
68
|
|
|
57
69
|
## Scope & degradation
|
|
@@ -61,10 +73,12 @@ This extension mutates the pi session even outside herdr. After `pi install npm:
|
|
|
61
73
|
| Surface | Bare `pi` (no `HERDR_ENV`) | Inside herdr |
|
|
62
74
|
|---|---|---|
|
|
63
75
|
| `todo_write`, `/todos`, widget, anti-freeze, stop reminder | live | live |
|
|
64
|
-
| `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 |
|
|
77
|
+
| Blocked reporting for blocking `ctx.ui` dialogs (`ui_prompt_start/end`, `select`/`confirm`/`input`/`editor`) | live | live + blocked marker |
|
|
65
78
|
| Hidden inject (`before_agent_start` todo-read; settle reminder) | live | live |
|
|
66
79
|
| `subagent`, `terminal` | **not registered** | live |
|
|
67
80
|
| `/locks`, write-lock on `write`/`edit` | not installed | live |
|
|
81
|
+
| `/pier-config` (read-only config guide over 4 planes) | live | live |
|
|
68
82
|
| slim-frame overlay, pane title, pipe, isolate worktree | off | live |
|
|
69
83
|
| `setActiveTools` role visible layer | off | on (herdr master) |
|
|
70
84
|
|
|
@@ -91,15 +105,15 @@ pi overwrites tools/commands by name; event listeners stack.
|
|
|
91
105
|
## Requirements
|
|
92
106
|
|
|
93
107
|
- Node ≥ 22
|
|
94
|
-
- pi ≥ 0.
|
|
95
|
-
- **herdr ≥ 0.
|
|
108
|
+
- pi ≥ 0.86.0 (`@earendil-works/pi-coding-agent`) — dynamic toolset (transcript-backed `setActiveTools` deltas) and role switching require it
|
|
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
|
|
96
110
|
|
|
97
111
|
## Development
|
|
98
112
|
|
|
99
113
|
```sh
|
|
100
114
|
git clone https://github.com/July24/pier && cd pier
|
|
101
115
|
npm install --ignore-scripts
|
|
102
|
-
npm test # node --test, ~
|
|
116
|
+
npm test # node --test, ~590 unit tests
|
|
103
117
|
```
|
|
104
118
|
|
|
105
119
|
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.
|
|
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",
|
|
@@ -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",
|
|
@@ -49,7 +53,8 @@
|
|
|
49
53
|
"typebox": "*"
|
|
50
54
|
},
|
|
51
55
|
"devDependencies": {
|
|
52
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
53
|
-
"typebox": "~1.3.7"
|
|
56
|
+
"@earendil-works/pi-coding-agent": "0.86.0",
|
|
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
|
+
}
|
|
@@ -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": {
|
|
@@ -88,6 +88,17 @@ 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 four planes (efficiency
|
|
96
|
+
D100–D103, roles, pi settings, `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`; change one plane at a time and show a
|
|
100
|
+
diff before writing. Overview: `docs/configuration.md`.
|
|
101
|
+
|
|
91
102
|
## 3. Human gate
|
|
92
103
|
|
|
93
104
|
- 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.
|
package/src/ansi-text.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI-aware text measurement and clipping for pier's TUI surfaces.
|
|
3
|
+
*
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SGR = /\x1b\[[0-9;]*m/y;
|
|
11
|
+
|
|
12
|
+
export function charWidth(cp: number): number {
|
|
13
|
+
if (cp < 32) return 0;
|
|
14
|
+
if (cp >= 0x7f && cp < 0xa0) return 0;
|
|
15
|
+
if (cp >= 0x0300 && cp <= 0x036f) return 0; // combining marks
|
|
16
|
+
if (
|
|
17
|
+
(cp >= 0x1100 && cp <= 0x115f)
|
|
18
|
+
|| (cp >= 0x2e80 && cp <= 0x303e)
|
|
19
|
+
|| (cp >= 0x3041 && cp <= 0x33ff)
|
|
20
|
+
|| (cp >= 0x3400 && cp <= 0x4dbf)
|
|
21
|
+
|| (cp >= 0x4e00 && cp <= 0x9fff)
|
|
22
|
+
|| (cp >= 0xa000 && cp <= 0xa4cf)
|
|
23
|
+
|| (cp >= 0xac00 && cp <= 0xd7a3)
|
|
24
|
+
|| (cp >= 0xf900 && cp <= 0xfaff)
|
|
25
|
+
|| (cp >= 0xfe30 && cp <= 0xfe6f)
|
|
26
|
+
|| (cp >= 0xff00 && cp <= 0xff60)
|
|
27
|
+
|| (cp >= 0xffe0 && cp <= 0xffe6)
|
|
28
|
+
|| (cp >= 0x1f300 && cp <= 0x1f9ff)
|
|
29
|
+
|| (cp >= 0x20000 && cp <= 0x3fffd)
|
|
30
|
+
) {
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Rendered cell width; SGR sequences count as zero cells. */
|
|
37
|
+
export function styledWidth(line: string): number {
|
|
38
|
+
let width = 0;
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < line.length) {
|
|
41
|
+
SGR.lastIndex = i;
|
|
42
|
+
const m = SGR.exec(line);
|
|
43
|
+
if (m && m.index === i) {
|
|
44
|
+
i += m[0].length;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const cp = line.codePointAt(i)!;
|
|
48
|
+
width += charWidth(cp);
|
|
49
|
+
i += cp > 0xffff ? 2 : 1;
|
|
50
|
+
}
|
|
51
|
+
return width;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Cut to `width` cells without breaking escape sequences; wide glyphs count as two cells. */
|
|
55
|
+
export function truncateStyled(line: string, width: number): string {
|
|
56
|
+
if (width <= 0) return '';
|
|
57
|
+
if (styledWidth(line) <= width) return line;
|
|
58
|
+
const keep = Math.max(0, width - 1); // reserve one cell for the ellipsis
|
|
59
|
+
let out = '';
|
|
60
|
+
let used = 0;
|
|
61
|
+
let i = 0;
|
|
62
|
+
while (i < line.length) {
|
|
63
|
+
SGR.lastIndex = i;
|
|
64
|
+
const m = SGR.exec(line);
|
|
65
|
+
if (m && m.index === i) {
|
|
66
|
+
out += m[0];
|
|
67
|
+
i += m[0].length;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const cp = line.codePointAt(i)!;
|
|
71
|
+
const w = charWidth(cp);
|
|
72
|
+
if (used + w > keep) break;
|
|
73
|
+
out += cp > 0xffff ? line.slice(i, i + 2) : line[i]!;
|
|
74
|
+
used += w;
|
|
75
|
+
i += cp > 0xffff ? 2 : 1;
|
|
76
|
+
}
|
|
77
|
+
return `${out}\x1b[0m…`;
|
|
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
|
+
}
|