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.
Files changed (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. 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, 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,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
- | `ask_user_question` | Human gate: structured options plus Other; 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-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
- - **Soft locks**: write paths lock per-pane; conflicts warn/block instead of racing
54
- - **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
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/input) | live + blocked marker |
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.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
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, ~250 unit tests
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.2.7",
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.84.2",
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
- "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": {
@@ -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.
@@ -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
+ }