@zhuxixi/pi-agent-board 0.5.1 → 0.5.2

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.
@@ -0,0 +1,146 @@
1
+ # Detach-Gate Tier-2 Glyph Fallback Tightening Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Tighten the attach-surface `←` detach gate's tier-2 glyph fallback so content glyph lines (markdown table rows / quotes / drafts) no longer trap the user when `editor_state` is unavailable (issue #69).
6
+
7
+ **Architecture:** One-line condition change in `PtyAttachComponent.childInputLooksEmpty()` (src/ui/pty-attach.ts): the tier-2 loop now only treats an EMPTY prompt-glyph line as proof of an empty editor (`isProbablyPiInputLine(line) && isProbablyEmptyPiInputLine(line)`), skipping content glyph lines; loop-end escape stays authoritative. Tier-1 (inverse fake-cursor anchor), the `editor_state` authoritative path (#71), and all protocol/public API surfaces are untouched. Behavior is pinned by two new detach-gate smoke scenarios (K1/K2) asserted inside the existing `test/pty-attach-detach-gate.test.mjs` case.
8
+
9
+ **Tech Stack:** TypeScript (Node `--experimental-transform-types`), node:test, @xterm/headless in-memory smoke harness.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md` (accepted 2026-09-03, Option B).
12
+
13
+ **Acceptance traceability:** Task 1 → A1, A2, A3, A4 (automated). Task 2 → U1, U2 (manual, human-executed after push, before merge). No orphan tasks, no orphan acceptance IDs.
14
+
15
+ ## Global Constraints
16
+
17
+ - Work only inside this worktree: `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-69-detach-gate-glyph-fallback`. Never edit the main checkout at `/home/elling/git-repo/github/pi-agent-board` (`node_modules` there is only the read-only symlink target).
18
+ - Commit messages: English, conventional commits, suffix `(issue #69)`.
19
+ - Stage files explicitly (`git add <file> ...`); never `git add -A`.
20
+ - Do NOT touch: tier-1 inverse anchor, `editor_state` path (`resolveEditorEmpty`, reporter, runner), README.md, or the pure helpers in `src/core/pty-input.mjs` (spec non-goals).
21
+ - Do NOT extract new pure functions — the tightened tier-2 is constant-true by design; the discriminating tests live at smoke level (spec §4).
22
+ - Every new online-gate smoke scenario MUST pin `connected=true` explicitly before `handleInput` (B-section convention; otherwise the disconnected-escape semantics fire instead of the online gate).
23
+ - `node --test` case count stays 437: the new smoke keys are assertions inside the existing test case in `test/pty-attach-detach-gate.test.mjs`, not new `test()` blocks.
24
+ - Verification commands: `npm test` (expect 437 pass, 0 fail) and `npm run typecheck` (expect 0 errors). Faster smoke-only loop: `node --experimental-transform-types test-support/detach-gate-smoke.ts` (prints a JSON of scenario keys).
25
+
26
+ ---
27
+
28
+ ### Task 1: Tighten tier-2 glyph fallback (TDD) — covers A1, A2, A3, A4
29
+
30
+ **Files:**
31
+ - Modify: `src/ui/pty-attach.ts` (the tier-2 loop inside `childInputLooksEmpty()`, right after the `// Fallback: Pi variants that render no fake cursor` comment, line 359)
32
+ - Test: `test-support/detach-gate-smoke.ts` (insert two scenarios after the J-scenario block, before the `// E2.` comment)
33
+ - Test: `test/pty-attach-detach-gate.test.mjs` (two assertions after the `leftHelloNullResetsStaleEditorState` assertion)
34
+
35
+ **Interfaces:**
36
+ - Consumes: `isProbablyPiInputLine(line)`, `isProbablyEmptyPiInputLine(line)` from `src/core/pty-input.mjs` (both already imported in `pty-attach.ts`); smoke harness helpers `makeAttach()`, `writeToTerm(attach, data)` (already defined in the smoke file).
37
+ - Produces: smoke output keys `leftDetachesOnTableRowsWithoutFakeCursor` (A1) and `leftDetachesOnContentGlyphFallback` (A2) in the JSON printed by `test-support/detach-gate-smoke.ts`.
38
+
39
+ - [ ] **Step 1: Write the failing smoke scenarios (A1, A2)**
40
+
41
+ In `test-support/detach-gate-smoke.ts`, insert these two blocks immediately BEFORE the line starting with `// E2. A terminal at the minimum supported size`:
42
+
43
+ ```ts
44
+ // K1. Issue #69 real-world shape: zero inverse cells anywhere in the buffer,
45
+ // the chat area carries a markdown table row (`│ … │`) and a quote line
46
+ // (`> …`) that isProbablyPiInputLine misreads as a draft-bearing input line,
47
+ // and editor_state never arrives (editorEmpty stays null — child without the
48
+ // reporter). tier-2 must skip content glyph lines and ← must detach: the
49
+ // gate philosophy is "never trap the user" (issues #42/#48).
50
+ {
51
+ const { attach, sent, didDetach } = makeAttach();
52
+ await writeToTerm(attach, "chat content\r\n│ Issue #778 │ open │\r\n> quote line\r\n");
53
+ (attach as unknown as { connected: boolean }).connected = true;
54
+ attach.handleInput("\x1b[D");
55
+ out.leftDetachesOnTableRowsWithoutFakeCursor = didDetach() && sent.length === 1 && sent[0].type === "detach";
56
+ attach.dispose();
57
+ }
58
+
59
+ // K2. The deliberate flip side of K1 — pair with scenario B: the SAME draft
60
+ // shape (`> draft`) is gated when the fake cursor is present (tier-1, scenario
61
+ // B) but detaches when the buffer carries no inverse cells (tier-2 fallback
62
+ // cannot tell a real draft from a table row; a spurious detach beats a trapped
63
+ // user, and detach never loses the draft — the child session keeps running).
64
+ // This pins the intentional loss of fallback draft protection (issue #69);
65
+ // restoring it needs the mid-term dock-structure anchor, not a revert.
66
+ {
67
+ const { attach, sent, didDetach } = makeAttach();
68
+ await writeToTerm(attach, "chat content\r\n> draft\r\n");
69
+ (attach as unknown as { connected: boolean }).connected = true;
70
+ attach.handleInput("\x1b[D");
71
+ out.leftDetachesOnContentGlyphFallback = didDetach() && sent.length === 1 && sent[0].type === "detach";
72
+ attach.dispose();
73
+ }
74
+ ```
75
+
76
+ In `test/pty-attach-detach-gate.test.mjs`, insert these two assertions immediately AFTER the `assert.equal(parsed.leftHelloNullResetsStaleEditorState, true, ...)` line:
77
+
78
+ ```js
79
+ assert.equal(parsed.leftDetachesOnTableRowsWithoutFakeCursor, true, "← must detach when a zero-inverse buffer holds only table/quote glyph lines and editor_state is unknown (issue #69)");
80
+ assert.equal(parsed.leftDetachesOnContentGlyphFallback, true, "← must detach on a content glyph line in the no-fake-cursor fallback — spurious detach beats trapping (issue #69)");
81
+ ```
82
+
83
+ - [ ] **Step 2: Run to verify both fail (A1, A2 red)**
84
+
85
+ Run: `node --experimental-transform-types test-support/detach-gate-smoke.ts`
86
+ Expected: JSON contains `"leftDetachesOnTableRowsWithoutFakeCursor":false` and `"leftDetachesOnContentGlyphFallback":false` (all pre-existing keys stay `true`).
87
+
88
+ - [ ] **Step 3: Implement the tier-2 tightening (minimal change)**
89
+
90
+ In `src/ui/pty-attach.ts`, inside `childInputLooksEmpty()`, replace this exact block:
91
+
92
+ ```ts
93
+ // Fallback: Pi variants that render no fake cursor — look for a
94
+ // prompt-glyph line.
95
+ for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
96
+ const line = active.getLine(y)?.translateToString(true) ?? "";
97
+ if (isProbablyPiInputLine(line)) return isProbablyEmptyPiInputLine(line);
98
+ }
99
+ ```
100
+
101
+ with:
102
+
103
+ ```ts
104
+ // Fallback: Pi variants that render no fake cursor — look for an EMPTY
105
+ // prompt-glyph line. Only an empty glyph line proves an empty editor:
106
+ // content glyph lines (markdown table rows `│ … │`, quotes `> …`, or a
107
+ // real draft in a no-fake-cursor Pi variant) cannot be told apart, and
108
+ // trapping the user is worse than a spurious detach (issue #69) — skip
109
+ // them and keep scanning; the loop-end escape below stays authoritative.
110
+ for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
111
+ const line = active.getLine(y)?.translateToString(true) ?? "";
112
+ if (isProbablyPiInputLine(line) && isProbablyEmptyPiInputLine(line)) return true;
113
+ }
114
+ ```
115
+
116
+ - [ ] **Step 4: Run smoke to verify the fix (A1, A2 green)**
117
+
118
+ Run: `node --experimental-transform-types test-support/detach-gate-smoke.ts`
119
+ Expected: EVERY key in the JSON is `true`, including the two new ones.
120
+
121
+ - [ ] **Step 5: Full automated acceptance (A3, A4)**
122
+
123
+ Run: `npm test`
124
+ Expected: `pass 437`, `fail 0` (count unchanged — the new keys are assertions inside the existing test case).
125
+
126
+ Run: `npm run typecheck`
127
+ Expected: exit 0, no output errors.
128
+
129
+ - [ ] **Step 6: Commit**
130
+
131
+ ```bash
132
+ git add src/ui/pty-attach.ts test-support/detach-gate-smoke.ts test/pty-attach-detach-gate.test.mjs
133
+ git commit -m "fix: tighten ← detach tier-2 glyph fallback to empty-only lines (issue #69)"
134
+ ```
135
+
136
+ ### Task 2: Manual verification U1/U2 (human-executed; after PR branch is pushed, before merge) — covers U1, U2
137
+
138
+ > Not a subagent task. The controller surfaces this checklist to the human partner at the pre-merge gate.
139
+
140
+ **Environment:** the running extension copy is the git-source clone at `~/.pi/agent/git/github.com/zhuxixi/pi-agent-board` (currently at 0.5.1 / fac9e91). Steps:
141
+
142
+ - [ ] **Step 1:** `git -C ~/.pi/agent/git/github.com/zhuxixi/pi-agent-board fetch origin && git -C ~/.pi/agent/git/github.com/zhuxixi/pi-agent-board checkout issue-69-detach-gate-glyph-fallback`
143
+ - [ ] **Step 2:** Restart pi IN ANOTHER TERMINAL (restarting kills the session running this agent — do not run inside the controlling session's pi instance).
144
+ - [ ] **Step 3 (U1):** Open dashboard → attach a warm session whose chat area contains markdown table output (`│ … │` rows) → with an empty input box press `←`. Pass = returns to dashboard, not trapped.
145
+ - [ ] **Step 4 (U2):** In the same or another reporter-active hosted session, type a draft → press `←`. Pass = cursor moves left inside the draft, no detach (same as #67 U3).
146
+ - [ ] **Step 5:** `git -C ~/.pi/agent/git/github.com/zhuxixi/pi-agent-board checkout main` and restart pi to restore the running copy.
@@ -0,0 +1,78 @@
1
+ # Issue #66 Spec:← detach 门禁锚点重构(反白假光标)
2
+
3
+ > Draft:2026-09-01。state: **pending user review**(⏸ 等用户确认后再进 worktree)
4
+
5
+ ## 1. 根因(调研结论,详见 issue 评论)
6
+
7
+ `childInputLooksEmpty()` 以 **xterm 硬件光标所在行**为锚点(`baseY + cursorY`),而 pi 渲染时:
8
+ - streaming:差分帧只重绘 Working 行,光标**持续**停在 `⠙ Working...` 行(~120ms/帧);
9
+ - attach 完成瞬间:光标停在输出区/提示行。
10
+
11
+ 两态下输入框为空但光标行非空 → `isProbablyEmptyPiInputLine` 判非空 → `←` 被转发给子进程。
12
+ v0.5.1 起 `ctrl+]` 已透传 Pi,`←` 是 attach 表面**唯一 detach 键** → 用户被困。
13
+
14
+ **可靠锚点(实证)**:pi 输入行恒渲染**反白假光标**(`\x1b[7m...\x1b[27m`,pi-tui `input.js` render + 本机 screen.log 双重确认),且反白 cell 在 xterm buffer 中**持久存在**(差分帧不重绘也保留)。从 buffer 底部向上扫"含 inverse cell 的行"即可定位输入行,**不依赖光标位置**。
15
+
16
+ ## 2. 修复设计
17
+
18
+ ### 2.1 `src/core/pty-input.mjs`
19
+
20
+ 新增纯函数(现有 `isProbablyEmptyPiInputLine` 不变):
21
+
22
+ ```js
23
+ export function isProbablyPiInputLine(line) // 行首(trim 左空白)为 prompt/continuation 字形
24
+ ```
25
+
26
+ 字形集:`›>┃│|┆╎╏:`(与 isProbablyEmptyPiInputLine 的 trim 字符集一致,兼容有字形 pi 版本)。
27
+
28
+ ### 2.2 `src/ui/pty-attach.ts` — `childInputLooksEmpty()` 重构
29
+
30
+ 三层判定,按优先级:
31
+
32
+ 1. **反白假光标锚点**(主):从 `active.baseY + active.length - 1` 向上扫,找第一个含 `isInverse()` cell 的行 → 返回 `isProbablyEmptyPiInputLine(line.translateToString(true))`;
33
+ 2. **字形行 fallback**(兼容无反白光标渲染的 pi 变体):找不到反白行时,同向扫 `isProbablyPiInputLine` 行 → 判空;
34
+ 3. **逃生兜底**:都找不到(损坏 buffer / replay 窗口无输入行帧)→ 视为空 → `←` 可 detach。
35
+
36
+ 行为矩阵:
37
+
38
+ | 状态 | 反白行 | 字形行 | 判定 | ← 行为 |
39
+ |------|--------|--------|------|--------|
40
+ | 输入框空(attach 后 / streaming 中) | 空行 | — | 空 | detach ✓ |
41
+ | 输入框有草稿 | 非空行 | — | 非空 | 转发(不抢编辑键)✓ |
42
+ | 损坏残影 buffer | 无 | 无 | 空 | detach(逃生)✓ |
43
+ | 有字形 pi 版本 | 空行(`> `) | `> ` | 空 | detach ✓ |
44
+
45
+ ## 3. 非目标
46
+
47
+ - ❌ ← 无条件 detach(违背"编辑中不抢键"产品意图,attach-flow.ts 亦保留门禁)
48
+ - ❌ 控制 socket 编辑器状态查询(pi 无此协议)
49
+ - ❌ 修改 `ctrl+]` 语义(v0.5.1 已透传 Pi,保持)
50
+
51
+ ## 4. 验收矩阵
52
+
53
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
54
+ |----|--------|----------|----------|----------|
55
+ | A1 | `isProbablyPiInputLine` 纯函数行为 | 自动化验证(unit) | `node --test test/pty-input.test.mjs` | 字形行 true;内容行/空行 false |
56
+ | A2 | 反白锚点:空输入行(光标漂移)→ detach | 自动化验证(unit) | `node --test test/pty-attach-detach-gate.test.mjs`(smoke B3) | `leftDetachesWhenCursorOffEmptyInputLine === true` |
57
+ | A3 | 反白锚点:草稿行 → 转发 | 自动化验证(unit) | 同上(smoke B) | `leftStaysGatedOnNonEmptyLine === true` |
58
+ | A4 | 反白锚点:无假光标行 + 空行 fallback | 自动化验证(unit) | 同上(smoke 新增) | `leftDetachesOnEmptyInput === true` 保持 |
59
+ | A5 | 损坏 buffer(无输入行)→ 逃生 | 自动化验证(unit) | 同上(smoke B1) | `leftEscapesOnGarbledBuffer === true` |
60
+ | A6 | 全套回归 + 覆盖率门禁 | 自动化验证(build/static + unit) | `npm run verify`(typecheck + 全部测试 + coverage 门禁 lines85/funcs80/branches70 + pack:dry,Node 22/24) | 全绿;无既有测试被改语义 |
61
+ | U1 | attach 后输入框空按 ← 回退 | 用户实测 | attach 进入 pi session → 立即按 ← | 回退到 dashboard,无需 ↑↓/输入删除 |
62
+ | U2 | pi 思考中(Working...)按 ← 回退 | 用户实测 | 触发 pi 思考(Working 动画)→ 按 ← | 回退到 dashboard |
63
+
64
+ ## 5. 可测性拆分设计
65
+
66
+ | 函数 | 位置 | 职责 | 测试边界 |
67
+ |------|------|------|----------|
68
+ | `isProbablyPiInputLine(line)` | pty-input.mjs(纯函数) | 行首字形判定 | 输入字符串 → boolean;不碰 buffer/term |
69
+ | `findLastInverseCellLine(active)` | pty-attach.ts(私有,仅依赖 xterm buffer 接口) | 底部向上扫含 inverse cell 的行号 | 输入 fake buffer({baseY, length, getLine})→ 行号/null;**不依赖 cursorY** |
70
+ | `childInputLooksEmpty()` | pty-attach.ts | 三层组合判定 | 输入 buffer 状态 → boolean;经 smoke 场景验证 |
71
+
72
+ 约束:`findLastInverseCellLine` 只读 buffer(无副作用);`isProbablyPiInputLine` 无状态;smoke 场景构造 xterm 渲染序列(`\x1b[7m \x1b[27m` 等)验证端到端判定,不 mock 内部函数。
73
+
74
+ ## 6. 风险
75
+
76
+ - pi 未来版本假光标不再反白渲染 → 字形/兜底 fallback 接管(判定仍可用,只是可能放宽为"视为空")
77
+ - 多行编辑器最后一行空 → 判空 detach(与现状光标行逻辑一致,非回归)
78
+ - buffer 中其他反白元素(选中文本等)在输入行下方 → 极罕见;底部扫描以"最靠下"优先,选中文本在输出区(输入行上方)不干扰
@@ -0,0 +1,120 @@
1
+ # Issue #68 Spec:editor_state 推送 —— detach 门禁从渲染启发式迁移到子 pi 编辑器真实状态
2
+
3
+ > Draft:2026-09-02。state: **pending user review**(⏸ 等用户确认后再进 worktree)
4
+
5
+ ## 1. 背景与根因
6
+
7
+ #66 的三层渲染启发式(反色假光标锚点 → 字形行 → 逃生兜底)在真实 pi 渲染下全部失效:
8
+ - 空编辑器行行首**无 prompt 字形**;attach buffer 常**无反色 cell**(差分渲染不重绘编辑器行);
9
+ - 结果 tier-1 落空 → tier-2 误命中聊天区 markdown 表格行(`│ ... │`)/ 冒号行(`:` 开头)→ 判"有草稿" → `←` 被吞(#68/#69 实锤)。
10
+
11
+ 渲染流**不是**编辑器状态的可靠载体。根治方案(本 spec):子 pi 扩展直接读取 `ctx.ui.getEditorText()`(pi 官方 API,权威状态),经现有 control socket 推送给 runner → broadcast 给 attach 面板 → 门禁直接用。渲染启发式降级为 fallback。
12
+
13
+ ## 2. 架构设计
14
+
15
+ ```
16
+ 子 pi 扩展(isHostedChild 时 session_start 启动上报循环)
17
+ └─ 每 100ms 轮询 ctx.ui.getEditorText(),文本变化时:
18
+ → {type:"editor_state", empty} → control socket(JSONL client)
19
+ pty-runner(handleClientLine 新 case "editor_state")
20
+ ├─ 缓存 host.editorEmpty + persist 到 host.json(可选,见 §7)
21
+ ├─ broadcast({type:"editor_state", empty}) 给所有 attach clients
22
+ └─ hello 消息带初始 editorEmpty(新 attach 立即同步)
23
+ attach 面板(pty-attach.ts)
24
+ ├─ 缓存 this.editorEmpty: boolean | null(null = 未知)
25
+ ├─ onSocketData 新 case "editor_state" 更新缓存
26
+ └─ ← 判定:resolveEditorEmpty(editorEmpty, 启发式结果)
27
+ editorEmpty !== null ? editorEmpty : 现有启发式(fallback)
28
+ ```
29
+
30
+ ### 2.1 协议(JSONL,向后兼容)
31
+
32
+ ```jsonc
33
+ // 子 pi 扩展 → runner
34
+ { "type": "editor_state", "empty": true }
35
+
36
+ // runner → attach clients(广播 + hello 初始值)
37
+ { "type": "editor_state", "empty": true }
38
+ { "type": "hello", "status": {...}, "editorEmpty": true }
39
+ ```
40
+
41
+ 旧端忽略未知字段/消息(现有 parser 已忽略未知 type)→ 协议向后兼容。
42
+
43
+ ### 2.2 子 pi 扩展上报循环(新文件 `src/core/editor-state-reporter.mjs`)
44
+
45
+ - 激活条件:`process.env.AGENT_BOARD_CHILD === "1" || process.env.AGENT_VIEW_CHILD === "1"`(与 index.ts 同判据)
46
+ - socket 路径:`controlSocketPathFor(process.platform, root, viewId)`(复用 `src/core/paths.mjs`)
47
+ - 连接策略:runner **先 spawn 子 pi 后 listen** → 初始连接带重试(退避 1s→2s→…封顶 5s,永久重试);断开后同样重连
48
+ - 轮询:`setInterval(100ms)` 读 `getEditorText()`,`text !== lastText` 才发送(dedupe,避免常发)
49
+ - 生命周期:session_start 启动、进程退出自然回收;`stop()` 供测试
50
+ - 依赖注入设计(可测性,§5):`createEditorStateReporter({ getEditorText, connect, intervalMs = 100, scheduler = defaultScheduler })` → `{ start, stop }`;`scheduler = { interval(fn, ms), timeout(fn, ms), clear(handle) }`(默认 setInterval/setTimeout 包装);生产接线在 index.ts
51
+
52
+ ### 2.3 runner 改动(`runner/pty-runner.mjs`)
53
+
54
+ - `handleClientLine` switch 加 `case "editor_state"`:`host.editorEmpty = !!msg.empty; broadcast({type:"editor_state", empty: host.editorEmpty})`(不 persist,见 §7)
55
+ - `hello` 消息:`{ type:"hello", status: host, editorEmpty: host.editorEmpty ?? null }`
56
+ - 子 pi 断开(child exit)→ editorEmpty 复位 null 并 broadcast(attach 端退回启发式)
57
+
58
+ ### 2.4 attach 面板改动(`src/ui/pty-attach.ts`)
59
+
60
+ - 字段 `private editorEmpty: boolean | null = null`
61
+ - `onSocketData`:`case "editor_state"` → `this.editorEmpty = !!msg.empty`;`case "hello"` → 读 `msg.editorEmpty`
62
+ - `←` 判定(handleInput):`shouldEscapeAttach(this.connected, this.resolveEditorEmpty())`
63
+ - 纯函数 `resolveEditorEmpty(editorEmpty, heuristic)`(放 `src/core/pty-input.mjs` 或新纯函数文件):
64
+ `editorEmpty === null ? heuristic : editorEmpty`
65
+ - `ctrl+]` 语义不变;现有三层启发式**原样保留**(fallback)
66
+
67
+ ## 3. 行为矩阵
68
+
69
+ | 场景 | editorEmpty | 判定 | ← 行为 |
70
+ |------|-------------|------|--------|
71
+ | 空输入(attach/streaming/任意时刻) | true | 空 | detach ✓ |
72
+ | 草稿(多行/单行/autocomplete 后/↑ 召回) | false | 非空 | 转发(编辑保护)✓ |
73
+ | 提交瞬间 | true(清空) | 空 | detach ✓ |
74
+ | 子 pi 扩展缺失/socket 断流 | null | 启发式 | 现状 fallback 行为 |
75
+ | 损坏 buffer + editor_state 正常 | true/false | 权威 | 正确判定(不再被残影误导)✓ |
76
+
77
+ ## 4. 非目标
78
+
79
+ - ❌ 删除现有渲染启发式(保留为 fallback)
80
+ - ❌ #69 的短期 tier-2 收紧(#68 长期方案落地后启发式仅 fallback;#69 由维护者按需处理)
81
+ - ❌ RPC 模式特判(子 pi 恒 PTY/TUI;RPC 下 getEditorText 恒 "" → empty 恒 true → ← 恒 detach,无 TUI 草稿概念,方向安全)
82
+ - ❌ 击键级拦截(评论 2 已排除:会覆盖编辑器光标行为,hack 且危险)
83
+
84
+ ## 5. 可测性拆分设计
85
+
86
+ | 单元 | 位置 | 职责 | 测试边界 |
87
+ |------|------|------|----------|
88
+ | `createEditorStateReporter({getEditorText, connect, intervalMs, scheduler})` | src/core/editor-state-reporter.mjs | 轮询、变化 dedupe、断线重连退避、stop | 依赖全注入(scheduler 为 {interval, timeout, clear} 手动时钟);fake connect/getEditorText/scheduler → 断言 send 序列与时机;不碰真实 socket/interval |
89
+ | `resolveEditorEmpty(editorEmpty, heuristic)` | src/core/pty-input.mjs | 判定优先级 | 纯函数:4 种输入组合 → boolean |
90
+ | runner `editor_state` case + hello 字段 | runner/pty-runner.mjs | 缓存、broadcast、复位 | 现有 integration harness(pty-runner.integration.test.mjs 模式):注入 client line → 断言 broadcast 与 hello 载荷 |
91
+ | attach `editorEmpty` 缓存 + 判定 | src/ui/pty-attach.ts | 消息 case、字段、← 判定 | detach-gate-smoke 场景 H(editor_state 消息 + 草稿 buffer → detach);场景 I(草稿 + editor_state:false → 转发) |
92
+
93
+ 约束:reporter 的生产接线只做「注入真实依赖」,纯逻辑全在工厂函数内;实现不得把轮询/重连逻辑重新耦合进 index.ts。
94
+
95
+ ## 6. 验收矩阵
96
+
97
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
98
+ |----|--------|----------|----------|----------|
99
+ | A1 | reporter:轮询变化才上报、dedupe、stop | 自动化验证(unit) | `node --test test/editor-state-reporter.test.mjs` | 变化序列上报次数/内容精确匹配;无变化零上报 |
100
+ | A2 | reporter:初始连接重试退避 + 断线重连 | 自动化验证(unit) | 同上 | fake connect 先拒后通 → 按退避序列重试并在连通后恢复上报 |
101
+ | A3 | runner:editor_state case 缓存 + broadcast + hello 初始值 + exit 复位 | 自动化验证(integration) | `node --test test/pty-runner.integration.test.mjs` | 注入 editor_state line → 其他 client 收到广播;新 client hello 带 editorEmpty;exit 后 editorEmpty=null 广播 |
102
+ | A4 | attach:editor_state 消息更新缓存、判定优先级(null 走启发式) | 自动化验证(unit) | detach-gate-smoke 场景 H/I | 空输入+editorEmpty=true → detach;草稿+editorEmpty=false → 转发;无消息 → 走启发式(既有场景全绿) |
103
+ | A5 | 纯函数 resolveEditorEmpty | 自动化验证(unit) | `node --test test/pty-input.test.mjs` | 4 组合精确匹配 |
104
+ | A6 | 全套回归 + 覆盖率门禁 | 自动化验证(build/static + unit) | `npm run verify`(typecheck + 全测 + c8 门禁 85/80/70 + pack:dry);CI Node 22/24 | 全绿;既有 430+ 测试无语义改动 |
105
+ | U1 | 空输入 attach 后 ← 回退 | 用户实测 | attach 进入 → 立即 ← | 回 dashboard |
106
+ | U2 | Working... 中 ← 回退 | 用户实测 | 思考动画中 ← | 回 dashboard |
107
+ | U3 | 草稿中 ← 左移不被抢 | 用户实测 | 输入草稿 → ← | 光标左移,不 detach |
108
+ | U4 | 断流 fallback(子 pi 扩展被禁用时仍可退出) | 用户实测 | 临时禁用扩展的 editor_state 上报(env 开关)→ ← | 仍可回退(启发式兜底) |
109
+
110
+ ## 7. 决策与风险
111
+
112
+ - **不 persist editorEmpty**(首版):host.json 是行状态持久化,editor 状态是瞬态(毫秒级),重启后 attach 的 hello 里 null → 启发式兜底 1-2s 内收到首个 editor_state 修正。避免 host.json 写放大(100ms 级变化频率)。
113
+ - **100ms 轮询开销**:getEditorText 是内存 join,忽略不计;socket 仅在变化时写。
114
+ - **风险:editorEmpty=false 但用户实际想退出**(草稿场景):与既有"编辑中不抢键"哲学一致(attach-flow 同样);用户清空草稿即可退出。
115
+ - **风险:子 pi 扩展未安装/旧版**(fallback 启发式接管,U4 验证)。
116
+ - **风险:runner 老版本**(不认识 editor_state case,忽略)→ attach 收不到 → 启发式兜底,向后兼容。
117
+
118
+ ## 8. 关联
119
+
120
+ - #68(本 issue 长期方案)、#66/#67(启发式起源与失效)、#69(同症状短期方案,由维护者处理)、#42/#48(门禁可靠性原则:视图必须始终可退出)
@@ -0,0 +1,99 @@
1
+ # SPEC:#69 tier-2 字形兜底收紧 —— fallback 路径下 ← 恒可逃生
2
+
3
+ - Issue: zhuxixi/pi-agent-board#69
4
+ - 基线: main @ 9a61dd5(含 PR #71 editor_state 门禁)
5
+ - 状态: 已批准(方案 B,2026-09-03)
6
+ - 调研依据: `~/.claude/github-issue-driven/zhuxixi/pi-agent-board/issue-69/research/`(R1 代码影响面 / R2 KB / R3 PR 上下文),结论已评论回 issue
7
+
8
+ ## 1. 背景与根因(已实锤,不再重复调试)
9
+
10
+ `←` detach 门禁三层结构(#71 后):
11
+
12
+ ```
13
+ handleInput(←)
14
+ └─ connected? ── no ──→ 无条件逃生(#48,不动)
15
+ └─ yes → resolveEditorEmpty(editorEmpty, heuristic)
16
+ ├─ editorEmpty ≠ null → 权威子进程状态(#71,不动)
17
+ └─ editorEmpty = null → 启发式 childInputLooksEmpty()
18
+ ├─ tier-1: 反色假光标锚点(不动)
19
+ ├─ tier-2: 字形兜底 ← ★ 本次唯一改动点 ★
20
+ └─ 兜底: return true 逃生
21
+ ```
22
+
23
+ **根因**:fallback 模式(子会话无 editor-state reporter:旧版 pi 无 `ctx.ui.getEditorText`、扩展未加载、socket 未建立)下,真实 attach buffer 常零反色 cell → tier-1 落空 → tier-2 自底向上扫到 `isProbablyPiInputLine` 命中的聊天区 markdown 表格行(`│ … │`)或引用行(`> …`)→ `isProbablyEmptyPiInputLine` 判非空 → return false → `←` 被转发给子进程,用户被困。已在 main(9a61dd5) 上以 smoke 场景 K 复现(零反色 + `│ Issue #778 │ open │` + `> quote` + editorEmpty=null → didDetach=false)。
24
+
25
+ ## 2. 修复设计
26
+
27
+ ### 2.1 tier-2 行为决策表(唯一行为变化面)
28
+
29
+ | tier-2 扫到的行(自底向上首个 glyph 行) | 现行为 | 新行为 | 理由 |
30
+ |---|---|---|---|
31
+ | 空 glyph 行(`> `、`› `、`┃ `) | true(detach) | true(不变) | 编辑器为空 |
32
+ | 内容 glyph 行(`│ table │`、`> quote`、真草稿 `> draft`) | **false(gated,#69 bug)** | **true(detach)** | fallback 下无法区分表格/引用/草稿;可退出性优先 |
33
+ | 无 glyph 行 / 扫不到 | true | true(不变) | 既有逃生 |
34
+
35
+ **收紧后 tier-2 语义恒为 true**(内容行跳过 + 扫不到逃生)——fallback 恒放行,这是有意取舍:草稿保护的可靠路径已由 #71 editor_state 承担;fallback 模式下误判代价不对称(误 detach = 意外退视图、草稿不丢、重新 attach 即回;误 gate = 用户被困)。与 #42/#48「视图必须始终可退出」哲学同向。
36
+
37
+ ### 2.2 改动点(两案行为完全等价,纯代码形态选择)
38
+
39
+ **方案 B(推荐,与维护者在 #69 评论中已本地验证的实现一致)**:
40
+
41
+ ```ts
42
+ // src/ui/pty-attach.ts childInputLooksEmpty() tier-2 循环
43
+ for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
44
+ const line = active.getLine(y)?.translateToString(true) ?? "";
45
+ // Only an EMPTY glyph line proves an empty editor. Content glyph lines
46
+ // (markdown table rows `│ … │`, quotes `> …`, or a real draft in a
47
+ // no-fake-cursor Pi variant) cannot be told apart, and trapping the
48
+ // user is worse than a spurious detach (issue #69) — skip and keep
49
+ // scanning; the loop-end escape stays authoritative.
50
+ if (isProbablyPiInputLine(line) && isProbablyEmptyPiInputLine(line)) return true;
51
+ }
52
+ return true;
53
+ ```
54
+
55
+ - 保留扫描骨架:为中期方案(按 pi TUI dock 结构:底部 `─` 分隔线与 footer 间定位编辑器行,可恢复 fallback 草稿保护)留结构;注释固化取舍理由。
56
+ - `isProbablyPiInputLine` 继续有生产调用点,不需要删除。
57
+
58
+ **方案 A(备选)**:直接删循环 `return true` + 注释。语义/测试与 B 完全一致,diff 更小,但丢掉骨架与意图表达。
59
+
60
+ ### 2.3 数据流与组件契约
61
+
62
+ - 无新组件、无协议消息、无公共 API 变化;`resolveEditorEmpty`、tier-1、editor_state 链路(reporter → runner broadcast → attach 缓存 → hello 重置)一概不动。
63
+ - 唯一触碰的生产文件:`src/ui/pty-attach.ts`(tier-2 循环体)。
64
+ - 触发窗口说明:收紧只影响 editorEmpty=null 的降级判定(reporter 缺席);reporter 活跃时 `resolveEditorEmpty` 短路,主路径零变化。
65
+
66
+ ## 3. 验收矩阵
67
+
68
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
69
+ |----|--------|----------|----------|----------|
70
+ | A1 | #69 真实场景放行:零反色 + 聊天区 `│` 表格行/`>` 引用行 + editorEmpty=null + connected=true → `←` detach | 自动化验证(integration:detach-gate smoke,经 `test/pty-attach-detach-gate.test.mjs` 驱动) | `npm test`;新增 smoke key `leftDetachesOnTableRowsWithoutFakeCursor` | 新 key 断言 true,全量测试通过 |
71
+ | A2 | 有意回归被钉住:内容 glyph 行(真草稿形态 `> draft`)+ 零反色 + editorEmpty=null → `←` detach(fallback 草稿保护失效是有意取舍,防止未来被"顺手修")。**与既有 smoke B 互为对照**:同为草稿形态,B 带 inverse 走 tier-1 → gated,A2 零反色走 fallback → detach——两种判定不矛盾,正是本取舍的教科书示例(smoke 场景注释中须写明此对照) | 自动化验证(integration:同上) | 新增 smoke key `leftDetachesOnContentGlyphFallback` | 新 key 断言 true |
72
+ | A3 | 既有行为零回归:B(tier-1 草稿 gate)、J(hello null 重置)、空 glyph 行 detach、editor_state 系列(H/J 全部 key)、ctrl+] 透传、断线逃生等 | 自动化验证(integration + unit 全量) | `npm test`(基线 437;新 smoke key 是既有 test 块内的新断言,node:test 计数不变) | 全量 437 全绿,既有 key 结果不变 |
73
+ | A4 | 类型/静态约束 | 自动化验证(static/build) | `npm run typecheck` | 0 错误(CI Node 22/24 等价覆盖) |
74
+ | U1 | 真实仪表盘复测 #69 场景:attach 一个聊天区含 markdown 表格输出的 warm 会话,输入框空时按 `←` | 用户实测 | 在运行副本 `~/.pi/agent/git/github.com/zhuxixi/pi-agent-board` 里 `git fetch && git checkout <PR 分支>`(#67 实测已验证的本地加载法),重启 pi 后打开 dashboard → attach → 空输入按 `←`;测完 checkout 回 main 并重启 | 回到 dashboard,不被困(可执行时机:实现完成、PR 分支推送后、合并前) |
75
+ | U2 | 主路径草稿保护不受影响:reporter 活跃会话输入草稿后按 `←` | 用户实测 | 同 U1 环境,attach 后输入草稿,按 `←` | 光标在草稿内左移,不 detach(复测 #67 U3 同款;可执行时机同 U1) |
76
+
77
+ **用户实测不可自动化的原因(U1/U2)**:真实 dashboard attach 涉及宿主 pi TUI 差分渲染 + pty-runner + editor-state reporter socket 的全链路,headless harness 用内存 xterm 无法复现真实终端渲染与扩展加载,只能人测。注意:实测需重启 pi,当前开发会话所在的 pi 实例会中断——在另一个终端/pi 实例里执行。
78
+
79
+ ## 4. 可测性拆分设计(自动化项)
80
+
81
+ - **纯函数层(不变)**:`isProbablyEmptyPiInputLine` / `isProbablyPiInputLine` / `resolveEditorEmpty` 已在 `test/pty-input.test.mjs` 有 unit 钉语义;本次不改其行为,不新增纯函数——收紧后 tier-2 决策恒为 true,为常量行为建纯函数无判别价值,强行抽取只会产生恒真断言的空转测试。
82
+ - **组件决策层(改动面)**:`childInputLooksEmpty()` 本身无副作用(只读 xterm buffer + 纯判定),判别性(detach vs forward)落在组件级 smoke harness(`test-support/detach-gate-smoke.ts`,经 `test/pty-attach-detach-gate.test.mjs` 以子进程运行并断言全部 key)——沿用 #42/#48/#66/#68 的既有测试边界,不引入新测试形态。
83
+ - **测试边界总结**:unit 钉 helper 语义(A3 覆盖)→ integration smoke 钉组件门禁决策(A1/A2/A3)→ static/typecheck 钉类型(A4)。A1/A2 ↔ smoke 新场景;A3 ↔ smoke 既有 key + unit 全量;A4 ↔ typecheck。双向可追溯。
84
+ - **smoke 惯例**:在线 gate 场景必须显式 pin `connected=true`(B 段既有惯例;不 pin 时断线逃生语义会抢跑,测不到在线门禁);新场景 K1(A1)/K2(A2)同样遵守。
85
+
86
+ ## 5. 非目标
87
+
88
+ - ❌ 中期 dock 结构锚点(底部 `─` 分隔线与 footer 间定位编辑器行)——未来若要恢复 fallback 草稿保护再立项
89
+ - ❌ 触碰 tier-1 反色锚点、editor_state 链路(#71 成果)
90
+ - ❌ 删除渲染启发式整体(继续作为 #71 的 fallback 层存在)
91
+ - ❌ 方案 A 下的 `isProbablyPiInputLine` 去留不在本 spec 讨论(选 B 则无此问题)
92
+ - ❌ 不改 README:line 96/195 的 `←` 行为描述以受支持主路径(reporter 活跃)为准,fallback 降级细节属实现层,由 A2 钉住——此为明确决定,防止未来被当成文档漂移
93
+
94
+ ## 6. 风险与降级
95
+
96
+ - 风险 1:fallback 下真草稿按 `←` 变为 detach(有意回归,A2 钉住;草稿不丢,重新 attach 即回)。
97
+ - 风险 2:`←` 在 attach 初建窗口(hello 尚未带回 editorEmpty、reporter 首推未达)若子进程恰有草稿,会 detach——窗口极短且后果同上,接受。
98
+ - 无运行时降级路径需求:本改动本身就是降级路径的加固;不合并时现状是「fallback 可被困」(更差)。
99
+ - **post-merge 生效**:运行副本 `~/.pi/agent/git/github.com/zhuxixi/pi-agent-board`(当前停在 0.5.1 / fac9e91,连 #66 都没有)需 `git pull`(或 `pi update`)并重启 pi 后才带本修复——人工部署步骤,不在自动化环内。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -72,6 +72,9 @@ function main() {
72
72
  // "exited" update (the handler kills the child, so its exit callback fires
73
73
  // inside the 50ms flush window — CR round-1, issue #48).
74
74
  let crashed = false;
75
+ /** Authoritative child editor emptiness, pushed by the child Pi extension
76
+ * (issue #68). null = unknown (extension missing / not yet reported). */
77
+ let editorEmpty = null;
75
78
  /** @type {import("../src/core/types.mjs").HostStatus} */
76
79
  let host = {
77
80
  version: 1,
@@ -195,6 +198,8 @@ function main() {
195
198
  // exit; this callback must not overwrite that state.
196
199
  if (!crashed) {
197
200
  update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null });
201
+ editorEmpty = null;
202
+ broadcast({ type: "editor_state", empty: null });
198
203
  broadcast({ type: "exit", exitCode });
199
204
  }
200
205
  if (!shutdownStarted) setTimeout(() => process.exit(exitCode ?? 0), 50).unref?.();
@@ -209,7 +214,7 @@ function main() {
209
214
  server = createServer((socket) => {
210
215
  clients.add(socket);
211
216
  update({ attachedEver: true });
212
- socket.write(JSON.stringify({ type: "hello", status: host }) + "\n");
217
+ socket.write(JSON.stringify({ type: "hello", status: host, editorEmpty }) + "\n");
213
218
  let buffer = "";
214
219
  socket.on("data", (chunk) => {
215
220
  buffer += chunk.toString("utf8");
@@ -238,7 +243,7 @@ function main() {
238
243
  try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
239
244
  switch (msg.type) {
240
245
  case "hello":
241
- send(socket, { type: "hello", status: host });
246
+ send(socket, { type: "hello", status: host, editorEmpty });
242
247
  break;
243
248
  case "input":
244
249
  if (typeof msg.data === "string") child.write(msg.data);
@@ -264,6 +269,11 @@ function main() {
264
269
  case "get_status":
265
270
  send(socket, { type: "status", status: host });
266
271
  break;
272
+ case "editor_state": {
273
+ editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
274
+ broadcast({ type: "editor_state", empty: editorEmpty });
275
+ break;
276
+ }
267
277
  }
268
278
  }
269
279
 
@@ -0,0 +1,102 @@
1
+ /** Child-Pi editor-state reporter (issue #68): polls the child Pi's editor text
2
+ * and pushes `{type:"editor_state", empty}` over the control socket whenever the
3
+ * text changes, so the attach surface can gate ← on the authoritative state
4
+ * instead of render heuristics. Dependency-injected for unit testing. */
5
+
6
+ /** @typedef {{ write(jsonLine: string): void; on?(event: "close" | "error", fn: () => void): void }} SocketLike */
7
+
8
+ const defaultScheduler = {
9
+ interval(fn, ms) { const h = setInterval(fn, ms); h.unref?.(); return h; },
10
+ timeout(fn, ms) { const h = setTimeout(fn, ms); h.unref?.(); return h; },
11
+ clear(handle) { clearInterval(handle); clearTimeout(handle); },
12
+ };
13
+
14
+ export function createEditorStateReporter({ getEditorText, connect, intervalMs = 100, scheduler = defaultScheduler }) {
15
+ let started = false;
16
+ let stopped = false;
17
+ let socket = null;
18
+ let pollTimer = null;
19
+ let reconnectTimer = null;
20
+ let backoffMs = 1000;
21
+ let lastText = null;
22
+
23
+ function send(json) {
24
+ if (!socket) return;
25
+ try {
26
+ socket.write(JSON.stringify(json) + "\n");
27
+ } catch {
28
+ teardownSocket();
29
+ scheduleReconnect();
30
+ }
31
+ }
32
+
33
+ function teardownSocket() {
34
+ if (pollTimer !== null) { scheduler.clear(pollTimer); pollTimer = null; }
35
+ const s = socket;
36
+ socket = null;
37
+ try { s?.destroy?.(); } catch { /* already torn down */ }
38
+ try { s?.removeAllListeners?.(); } catch { /* already torn down */ }
39
+ }
40
+
41
+ function poll() {
42
+ if (stopped || !socket) return;
43
+ let text;
44
+ try {
45
+ text = getEditorText();
46
+ } catch {
47
+ return;
48
+ }
49
+ text = typeof text === "string" ? text : "";
50
+ if (text !== lastText) {
51
+ lastText = text;
52
+ send({ type: "editor_state", empty: text.length === 0 });
53
+ }
54
+ }
55
+
56
+ function startPolling() {
57
+ if (pollTimer !== null) return;
58
+ lastText = null; // force a first report after (re)connect
59
+ pollTimer = scheduler.interval(poll, intervalMs);
60
+ }
61
+
62
+ function scheduleReconnect() {
63
+ if (stopped) return;
64
+ teardownSocket();
65
+ reconnectTimer = scheduler.timeout(tryConnect, backoffMs);
66
+ backoffMs = Math.min(backoffMs * 2, 5000);
67
+ }
68
+
69
+ function tryConnect() {
70
+ reconnectTimer = null;
71
+ if (stopped) return;
72
+ let s;
73
+ try {
74
+ s = connect();
75
+ } catch {
76
+ scheduleReconnect();
77
+ return;
78
+ }
79
+ socket = s;
80
+ s?.resume?.();
81
+ s?.unref?.();
82
+ s?.on?.("data", () => {}); // consume and discard broadcast traffic
83
+ s?.on?.("connect", () => { if (socket === s) { backoffMs = 1000; startPolling(); } });
84
+ s?.on?.("close", () => { if (socket === s) scheduleReconnect(); });
85
+ s?.on?.("error", () => { if (socket === s) scheduleReconnect(); });
86
+ }
87
+
88
+ function start() {
89
+ if (started) return;
90
+ started = true;
91
+ stopped = false;
92
+ tryConnect();
93
+ }
94
+
95
+ function stop() {
96
+ stopped = true;
97
+ if (reconnectTimer !== null) { scheduler.clear(reconnectTimer); reconnectTimer = null; }
98
+ teardownSocket();
99
+ }
100
+
101
+ return { start, stop };
102
+ }
@@ -13,3 +13,35 @@ export function isProbablyEmptyPiInputLine(line) {
13
13
  const content = withoutRightPadding.replace(/^[\s\u00a0›>┃│|┆╎╏:]+/u, "");
14
14
  return content.length === 0;
15
15
  }
16
+
17
+ /** Glyphs Pi uses to render editor prompt / continuation lines (`>` main prompt,
18
+ * `›`/`┃`/`│` and variants in older releases). Must stay in sync with the
19
+ * trim charset of isProbablyEmptyPiInputLine below. */
20
+ const PROMPT_GLYPHS = "›>┃│|┆╎╏:";
21
+
22
+ /**
23
+ * Whether the given terminal line looks like a Pi editor input line: leading
24
+ * whitespace followed by a prompt/continuation glyph. The attach surface uses
25
+ * this to locate the editor line inside the buffer instead of trusting the
26
+ * terminal cursor, which wanders onto output/working lines while Pi streams
27
+ * (issue #66).
28
+ * @param {string} line
29
+ * @returns {boolean}
30
+ */
31
+ export function isProbablyPiInputLine(line) {
32
+ const withoutLeftPadding = String(line || "").replace(/^[\s\u00a0]+/u, "");
33
+ return withoutLeftPadding.length > 0 && PROMPT_GLYPHS.includes(withoutLeftPadding[0]);
34
+ }
35
+
36
+ /**
37
+ * Resolve the ← detach gate's emptiness signal: when the child Pi pushes its
38
+ * authoritative editor state (boolean), it wins; when it is unknown (null/
39
+ * undefined — child extension missing or socket never connected), fall back
40
+ * to the render heuristic.
41
+ * @param {boolean | null | undefined} editorEmpty
42
+ * @param {boolean} heuristic
43
+ * @returns {boolean}
44
+ */
45
+ export function resolveEditorEmpty(editorEmpty, heuristic) {
46
+ return editorEmpty === null || editorEmpty === undefined ? heuristic : editorEmpty;
47
+ }