@zhuxixi/pi-agent-board 0.10.0 → 0.10.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,125 @@
1
+ # Spec: #167 — middle-click paste in attach views under pi ≥1.0 fullscreen TUI
2
+
3
+ Status: DRAFT (awaiting user approval — github-issue-driven step 4 gate)
4
+ Issue: https://github.com/zhuxixi/pi-agent-board/issues/167
5
+ Date: 2026-10-09
6
+
7
+ ## Problem (root cause, verified)
8
+
9
+ pi ≥1.0 defaults to fullscreen TUI. `TuiAltScreen` parses and consumes all SGR mouse
10
+ sequences itself; overlays receive mouse only through the normalized `handleMouse(event)`
11
+ API. `PtyAttachComponent` implements only the legacy `handleInput(data)` path, so its
12
+ middle-click paste (`pastePrimarySelection()`, commit a1c5f08) never runs in fullscreen.
13
+ Regular mode (`pi --tui-mode regular`) is unaffected. Evidence: pi-tui 1.1.0
14
+ `tui-alt-screen.js:520-526`, `tui.js:507-532`; research notes in
15
+ `~/.claude/github-issue-driven/zhuxixi/pi-agent-board/issue-167/research/`.
16
+
17
+ ## Goal / non-goals
18
+
19
+ **Goal**: middle-click inside the attach view pastes the X11 PRIMARY selection into the
20
+ hosted session in fullscreen mode, with the same semantics and escape hatch as regular
21
+ mode; regular mode behavior unchanged.
22
+
23
+ **Non-goals** (explicitly deferred):
24
+ - X11 PRIMARY *mirroring* of selections made in fullscreen (pi's own fullscreen selection
25
+ writes CLIPBOARD, not PRIMARY). Taking over left-button events would displace pi's
26
+ fullscreen selection — needs its own design (follow-up).
27
+ - #169 (attach teardown disabling outer-terminal mouse reporting) — separate issue/PR.
28
+ - Any wheel behavior change (today's defer-to-overlay fallback keeps working; we return
29
+ `undefined` for wheel).
30
+
31
+ ## Design
32
+
33
+ ### 1. Pure decision helper (new, `src/core/pty-scroll.mjs` — same module that owns
34
+ `parseMouseInputChunk`)
35
+
36
+ ```js
37
+ // Returns the attach-surface action for a normalized pi-tui mouse event.
38
+ // "paste-primary": read X11 PRIMARY and forward as input (middle-click paste).
39
+ // null: not ours — let the event fall through untouched (today's behavior).
40
+ resolveAttachMouseAction(event, { nativePasteEnabled }) → "paste-primary" | null
41
+ ```
42
+
43
+ Decision table (`event.type`, `event.button`, `nativePasteEnabled`):
44
+
45
+ | type | button | nativePasteEnabled | result |
46
+ | --- | --- | --- | --- |
47
+ | press | middle | true | `"paste-primary"` |
48
+ | press | middle | false | `null` (kill switch = today's silent drop) |
49
+ | press/release/click | left/right/none | * | `null` (pi fullscreen selection & default behavior untouched) |
50
+ | wheel | any | * | `null` (existing defer-to-`handleInput` path preserved) |
51
+ | move/drag | any | * | `null` |
52
+
53
+ `nativePasteEnabled` is computed once per call site from
54
+ `process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE !== "0"` (same rule as `pastePrimarySelection`).
55
+
56
+ ### 2. Component wiring (`src/ui/pty-attach.ts`)
57
+
58
+ ```ts
59
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
60
+ const action = resolveAttachMouseAction(event, {
61
+ nativePasteEnabled: process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE !== "0",
62
+ });
63
+ if (action === "paste-primary") {
64
+ this.clearPendingClick();
65
+ this.clearSelection();
66
+ this.pastePrimarySelection();
67
+ return { handled: true, render: false };
68
+ }
69
+ return undefined;
70
+ }
71
+ ```
72
+
73
+ - `{ handled: true }` stops propagation and suppresses alt-screen fallback for that
74
+ press; `render: false` because a successful paste repaints via child output (async)
75
+ and a failed one (no xclip) needs no repaint.
76
+ - Legacy `handleInput` raw path stays exactly as is — regular mode and wheel keep working.
77
+ - No changes to outer-terminal mouse-mode writes (`MOUSE_ENABLE`/`MOUSE_DISABLE`) — #169.
78
+
79
+ ### 3. devDependencies bump (enabling task)
80
+
81
+ Bump `@earendil-works/pi-tui` and `@earendil-works/pi-coding-agent` devDeps 0.79.8 → 1.1.0
82
+ (match the runtime the extension actually runs under). Gate: `npm run typecheck` +
83
+ `npm run verify` green. If unrelated type breakage from 0.79.8→1.1.0 turns out to be large,
84
+ fallback: keep 0.79.8 and declare local structural `TuiMouseEvent`/result types in the
85
+ component — decision point recorded in the plan; the bump is the preferred path.
86
+
87
+ ## Acceptance matrix
88
+
89
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
90
+ |----|--------|----------|----------|----------|
91
+ | A1 | 决策表:哪些归一化鼠标事件触发中键粘贴 | 自动化验证(unit) | `node --test test/pty-scroll.test.mjs`(新增用例直接调 `resolveAttachMouseAction`,覆盖决策表全部行 + `AGENT_BOARD_ATTACH_NATIVE_PASTE=0`) | 新增用例全绿,且现有点击/滚轮用例不回归 |
92
+ | A2 | 组件接线:`PtyAttachComponent.handleMouse` 中键 press → 读 PRIMARY → 作为 input 发往 attach socket;kill switch 时返回 undefined | 自动化验证(integration,组件级场景) | 新增 `test-support/pty-attach-mouse-scenario.ts`(仿 `pty-attach-protocol-scenario.ts`:fake tui + stub-`xclip` 目录 prepend PATH 打印 fixture 文本),由 `test/pty-attach-mouse.test.mjs` 以 `node --experimental-transform-types` 运行并断言 JSON 输出 | ① 中键 press 返回 `{handled:true}` 且 socket 收到 `{type:"input",data:fixture}`;② 同场景 legacy `handleInput` 注入 SGR 中键序列行为一致(regular 路径回归);③ `AGENT_BOARD_ATTACH_NATIVE_PASTE=0` 时 `handleMouse` 返回 undefined 且无 input 发出 |
93
+ | A3 | 类型与构建在 pi-tui 1.1.0 下成立 | 自动化验证(static/build) | `npm run typecheck`、`npm run verify`(含 pack:dry) | 全部通过,无 0.79.8→1.1.0 引入的未处理类型错误 |
94
+ | A4 | 全部既有行为不回归 | 自动化验证(unit+integration,既有套件) | `npm test` / `npm run test:coverage` | 既有测试全绿,覆盖阈值不降 |
95
+ | U1 | 真实终端里 fullscreen 中键粘贴生效 | 用户实测 | ① pi 1.1.0 fullscreen → `/agent-board` → attach;② 在别的 X11 应用选中一段文字;③ 在 attach 视图内中键 | 子 session 收到粘贴文本并有反应;同机 `pi --tui-mode regular` 行为不变;`AGENT_BOARD_ATTACH_NATIVE_PASTE=0` 后中键回到无反应 |
96
+
97
+ ## Testability split design (hard constraint for implementation)
98
+
99
+ - **Pure core**: `resolveAttachMouseAction(event, {nativePasteEnabled})` — plain function,
100
+ no I/O, no env reads inside (env read at call site). Unit-tested decision table (A1).
101
+ Test boundary: `src/core/pty-scroll.mjs` exports; tests import only this.
102
+ - **I/O shell**: `PtyAttachComponent.handleMouse` maps action → `pastePrimarySelection()`
103
+ (existing xclip spawn) + result object. Tested at component level (A2) through public
104
+ seams only: `handleMouse`/`handleInput` in, fake tui terminal writes + attach-socket
105
+ messages out, `xclip` stubbed via PATH. No test-only constructor options.
106
+ - The pure function must not be re-coupled into the component (no inline decision logic).
107
+
108
+ ## Risks / notes
109
+
110
+ - devDep bump may expose type breakage elsewhere (0.79.8 typings vs 1.1.0). Contained by
111
+ A3 as its own task; fallback documented above.
112
+ - `AGENT_BOARD_ATTACH_MOUSE=0` does not disable the new path (it governs outer-terminal
113
+ mode writes only); in fullscreen the alt screen owns reporting regardless. Documented
114
+ here as intended.
115
+ - Behavior when xclip is absent: unchanged silent no-op (existing contract), but the
116
+ event is still consumed (`handled:true`) — same as regular mode today.
117
+
118
+ ## Deliverables
119
+
120
+ - `src/core/pty-scroll.mjs`: + `resolveAttachMouseAction`
121
+ - `src/ui/pty-attach.ts`: + `handleMouse`
122
+ - `package.json`: devDeps bump (pi-tui, pi-coding-agent → 1.1.0)
123
+ - `test/pty-scroll.test.mjs`: decision-table cases
124
+ - `test-support/pty-attach-mouse-scenario.ts` + `test/pty-attach-mouse.test.mjs`
125
+ - CHANGELOG entry via release tooling at release time (not in this PR)
@@ -0,0 +1,130 @@
1
+ Drafted: zai-coding-cn/glm-5.3 (selected) · zai-coding-cn/glm-5.3 (physical) @ 2026-10-09T12:49:41Z
2
+
3
+ # Spec: #169 — detaching from a PTY attach view kills outer-terminal mouse reporting for the rest of the fullscreen session
4
+
5
+ Status: DRAFT (awaiting spec-cross-review, then user approval — github-issue-driven step 4 gates)
6
+ Issue: https://github.com/zhuxixi/pi-agent-board/issues/169
7
+ Repo state baseline: main @ `fee345c` (post-#171)
8
+
9
+ ## Problem (root cause, verified from source)
10
+
11
+ In fullscreen mode (pi ≥1.0 default) `TuiAltScreen` owns outer-terminal mouse reporting:
12
+ it writes the enable set `?1000h?1002h?1003h?1004h?1006h` exactly once in
13
+ `beforeTerminalStart` (pi-tui 1.1.0 `tui-alt-screen.js:194`) and disables it only at
14
+ `beforeTerminalStop` (:196-206); there is no re-assert path. `PtyAttachComponent` nonetheless
15
+ manages the outer terminal's mouse mode itself — ctor writes
16
+ `MOUSE_DISABLE`+`MOUSE_ENABLE` (downgrading `?1003h`/`?1004h`), and `close()` writes
17
+ `MOUSE_DISABLE` unconditionally (src/ui/pty-attach.ts:278-280, :1524 via
18
+ `disableMouseScroll` :882 — which, unlike `enableMouseScroll`, has no env guard). After
19
+ the first detach, pi-tui still believes mouse is on but the terminal stopped sending
20
+ events: fullscreen selection / copy-on-select / wheel / click regions are dead until pi
21
+ restarts. In regular mode the same writes are correct (board is the sole mouse owner).
22
+
23
+ Full evidence: `research/2026-10-09-fullscreen-mouse-ownership.md`,
24
+ `research/2026-10-09-repo-state-and-testability.md` in this directory.
25
+
26
+ ## Goal / non-goals
27
+
28
+ **Goal**: in fullscreen mode the attach component performs **zero** outer-terminal
29
+ mouse-mode writes for its entire lifecycle (ctor, refresh timers, connect, resize,
30
+ close) — `TuiAltScreen` owns the mode and its full `?1003h/?1004h` set survives
31
+ attach AND detach. Regular-mode behavior (including the
32
+ disable→enable→refresh pairing and the kill-switch semantics) is unchanged. Old pi
33
+ runtimes without `tui.mode` keep today's (regular) behavior.
34
+
35
+ **Non-goals**:
36
+ - X11 PRIMARY mirroring of the fullscreen-native selection (deferred with #167).
37
+ - Any pi-tui change or new re-assert API (unnecessary).
38
+ - Wheel/scroll/selection behavior inside attach views (unchanged by design: deferred
39
+ wheel still reaches `handleInput`; clicks via `handleMouse` from #171).
40
+ - #170 (reply guard), #168 (session resume) — unrelated paths.
41
+
42
+ ## Design
43
+
44
+ ### 1. Pure decision helper (new, `src/core/pty-scroll.mjs` — mouse helpers live here)
45
+
46
+ ```js
47
+ /**
48
+ * Issue #169: whether the attach surface should manage the OUTER terminal's mouse
49
+ * mode. Only in regular TUI mode — in fullscreen, TuiAltScreen owns mouse reporting
50
+ * (asserted once at startup, never re-asserted), so any write from here can only
51
+ * downgrade or kill it. Old runtimes without tui.mode behave as regular.
52
+ * Pure: mode and the env-derived flag are resolved at the call site.
53
+ */
54
+ shouldOwnOuterMouseMode(mode, mouseEnabled) → boolean // mode !== "fullscreen" && mouseEnabled
55
+ ```
56
+
57
+ ### 2. Component gating (`src/ui/pty-attach.ts`)
58
+
59
+ All outer-terminal mouse writes funnel through exactly two private methods —
60
+ `enableMouseScroll()` (:852) and `disableMouseScroll()` (:882). Gate both:
61
+
62
+ ```ts
63
+ private ownsOuterMouseMode(): boolean {
64
+ return shouldOwnOuterMouseMode(this.tui.mode, this.mouseScrollEnabled());
65
+ }
66
+ ```
67
+
68
+ - `enableMouseScroll()`: early-return `if (!this.ownsOuterMouseMode())` (replaces the
69
+ current `mouseScrollEnabled()` check; also gates the `XTSHIFTESCAPE_SELECT` write).
70
+ - `disableMouseScroll()`: early-return `if (!this.ownsOuterMouseMode())` (new guard —
71
+ fixes both the fullscreen teardown kill and the flagged unguarded disable under
72
+ `AGENT_BOARD_ATTACH_MOUSE=0`).
73
+ - `refreshMouseScrollMode()` keeps its `mouseScrollEnabled()` early-return (cheap) and
74
+ its timers call the now-gated `enableMouseScroll` — no other call-site changes
75
+ (ctor :278-280, connect :487, resize :1203, close :1524 all route through the two
76
+ choke points).
77
+ - Comment on `ownsOuterMouseMode()` noting `tui.mode` is typed non-optional but may be
78
+ absent on old runtimes — strict inequality handles it.
79
+
80
+ ### Resulting behavior matrix
81
+
82
+ | tui.mode | AGENT_BOARD_ATTACH_MOUSE | ctor/connect/resize writes | close() write | fullscreen session after detach |
83
+ | --- | --- | --- | --- | --- |
84
+ | fullscreen | any | none | none | mouse alive (BUG FIXED) |
85
+ | regular / undefined (old runtime) | on (default) | today's pairing | MOUSE_DISABLE | n/a (unchanged) |
86
+ | regular / undefined | off | none | none | n/a (also fixes unguarded-disable defect) |
87
+ | fullscreen (host built with TuiAltScreenOptions.mouse: false) | n/a | none | none | consistent: host explicitly disabled mouse; nothing owns it — attach-view mouse features inert by host choice |
88
+
89
+ ## Acceptance matrix
90
+
91
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
92
+ |----|--------|----------|----------|----------|
93
+ | A1 | 鼠标模式所有权决策表(mode × env 开关) | 自动化验证(unit) | `node --test test/pty-scroll.test.mjs`(新增用例直接调 `shouldOwnOuterMouseMode`,覆盖 6 格:fullscreen×on/off、regular×on/off、undefined×on/off) | 新增用例全绿,现有点击/滚轮/中键用例不回归 |
94
+ | A2 | fullscreen 全生命周期零外层鼠标写;regular 配对保留;kill switch 零写 | 自动化验证(integration,组件级场景) | 新增 `test-support/pty-attach-mouse-mode-scenario.ts`(fake tui 的 `terminal.write` 采集字节 + `mode` 可控 + 不可达 socketPath),由 `test/pty-attach-mouse-mode.test.mjs` 以 `node --experimental-transform-types` 运行断言 JSON 输出 | ① `mode:"fullscreen"`:ctor+250ms 刷新窗+close 全程 0 个 `?1000/?1002/?1003/?1004/?1006/XTSHIFTESCAPE` 写;② `mode:"regular"`:ctor 有 `MOUSE_ENABLE`、close 有 `MOUSE_DISABLE`;③ regular+`AGENT_BOARD_ATTACH_MOUSE=0`:全程 0 写(含 close);④ 无 `mode` 字段的 fake tui 行为同 regular |
95
+ | A3 | 类型与构建 | 自动化验证(static/build) | `npm run typecheck`、`npm run verify`(含 pack:dry) | 全部通过 |
96
+ | A4 | 既有行为不回归(含 #171 中键粘贴场景) | 自动化验证(unit+integration,既有套件) | `npm test` / `npm run test:coverage` | 既有测试全绿,覆盖阈值不降 |
97
+ | U1 | 真机:detach 后外层鼠标存活 | 用户实测 | ① pi ≥1.0 fullscreen → `/agent-board` → attach → detach(ctrl+\);② dashboard 里拖选文字 → 应高亮且 copy-on-select 生效;③ 滚轮/点击区域正常;④ attach 中 pi 自身全屏选中/复制正常(`?1003h/?1004h` 不再被降级);⑤ `pi --tui-mode regular` 下 attach→detach 行为与从前一致 | ①–⑤ 全部符合;修复前 ①③④ 在 detach 后失效 |
98
+
99
+ ## Testability split design (hard constraint for implementation)
100
+
101
+ - **Pure core**: `shouldOwnOuterMouseMode(mode, mouseEnabled)` — plain function, no env
102
+ reads inside (both args resolved at the call site). Unit-tested decision table (A1).
103
+ Test boundary: `src/core/pty-scroll.mjs` exports; tests import only this.
104
+ - **I/O shell**: `PtyAttachComponent.ownsOuterMouseMode()` reads `this.tui.mode` +
105
+ `this.mouseScrollEnabled()` and delegates to the pure helper; the two write methods
106
+ keep ALL terminal writes. Component-level tests (A2) drive only public seams
107
+ (constructor, time, `dispose()`) and observe `terminal.write` bytes of a fake tui —
108
+ no test-only constructor options, no reading private state.
109
+ - The pure helper must not be re-inlined into the component; the two choke-point methods
110
+ must remain the only places that write mouse-mode/XTSHIFTESCAPE sequences.
111
+
112
+ ## Risks / notes
113
+
114
+ - A2 uses an unreachable socketPath to keep the scenario runner-free; if the ctor's
115
+ connect-error path proves non-deterministic for the write-spy assertions, fall back to
116
+ the #171-style real-runner scenario asserting the same facts around a real
117
+ attach/detach (decision at plan time; record whichever is used).
118
+ - Existing scenarios' fake tuis have no `mode` field → they stay on the regular path and
119
+ don't assert terminal bytes → unaffected by the gating.
120
+ - `AGENT_BOARD_ATTACH_MOUSE` semantics in fullscreen become moot (no writes either way);
121
+ document in the behavior matrix rather than redefining the switch.
122
+
123
+ ## Deliverables
124
+
125
+ - `src/core/pty-scroll.mjs`: + `shouldOwnOuterMouseMode`
126
+ - `src/ui/pty-attach.ts`: + `ownsOuterMouseMode()` private method; guards in
127
+ `enableMouseScroll` / `disableMouseScroll`
128
+ - `test/pty-scroll.test.mjs`: decision-table cases
129
+ - `test-support/pty-attach-mouse-mode-scenario.ts` + `test/pty-attach-mouse-mode.test.mjs`
130
+ - CHANGELOG entry via release tooling at release time (not in this PR)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.10.0",
3
+ "version": "0.10.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",
@@ -73,8 +73,8 @@
73
73
  }
74
74
  },
75
75
  "devDependencies": {
76
- "@earendil-works/pi-coding-agent": "0.79.8",
77
- "@earendil-works/pi-tui": "0.79.8",
76
+ "@earendil-works/pi-coding-agent": "1.1.0",
77
+ "@earendil-works/pi-tui": "1.1.0",
78
78
  "@types/node": "^25.9.1",
79
79
  "c8": "^12.0.0",
80
80
  "typescript": "^5.9.3"
@@ -11,6 +11,7 @@ import { requestDashboardRender } from "../core/dashboard-render.mjs";
11
11
  import { createService } from "../runtime/service.mjs";
12
12
  import { planAttachPrelude, planAttachResolved } from "./attach-decision.mjs";
13
13
  import { screenLogPath } from "../core/paths.mjs";
14
+ import { appendDiagnostic } from "../core/diagnostics.mjs";
14
15
  import { DashboardComponent, type DashboardResult } from "../ui/dashboard.js";
15
16
  import { PtyAttachComponent, type PtyAttachResult } from "../ui/pty-attach.js";
16
17
 
@@ -241,6 +242,16 @@ async function openPtyAttach(
241
242
  socketPath,
242
243
  screenLogPath: root ? screenLogPath(root, viewId) : undefined,
243
244
  title: name,
245
+ onDiagnostic: root
246
+ ? (event) => {
247
+ // Best effort, like every other diagnostics writer.
248
+ try {
249
+ appendDiagnostic(root, viewId, event as never);
250
+ } catch {
251
+ /* diagnostics must never break attach */
252
+ }
253
+ }
254
+ : undefined,
244
255
  }),
245
256
  {
246
257
  overlay: true,
@@ -13,6 +13,7 @@ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
13
13
  import { createService } from "../runtime/service.mjs";
14
14
  import { planAttachPrelude, planAttachResolved } from "./attach-decision.mjs";
15
15
  import { screenLogPath } from "../core/paths.mjs";
16
+ import { appendDiagnostic } from "../core/diagnostics.mjs";
16
17
  import { PtyAttachComponent, type PtyAttachResult } from "../ui/pty-attach.js";
17
18
  import type { DashboardResult } from "../ui/dashboard.js";
18
19
 
@@ -39,6 +40,16 @@ export async function openPtyAttach(
39
40
  socketPath,
40
41
  screenLogPath: root ? screenLogPath(root, viewId) : undefined,
41
42
  title: name,
43
+ onDiagnostic: root
44
+ ? (event) => {
45
+ // Best effort, like every other diagnostics writer.
46
+ try {
47
+ appendDiagnostic(root, viewId, event as never);
48
+ } catch {
49
+ /* diagnostics must never break attach */
50
+ }
51
+ }
52
+ : undefined,
42
53
  }),
43
54
  {
44
55
  overlay: true,
@@ -94,6 +94,31 @@ export function parseMouseInputChunk(data) {
94
94
  return events;
95
95
  }
96
96
 
97
+ /**
98
+ * Issue #167: map a normalized pi-tui mouse event on the attach surface to an
99
+ * action. "paste-primary" = middle-click paste (read X11 PRIMARY, forward as
100
+ * input); null = not ours, the event falls through untouched (pi's fullscreen
101
+ * selection, the wheel defer path, and everything else keep working). Pure by
102
+ * contract: the env flag is resolved at the call site, never read here.
103
+ */
104
+ export function resolveAttachMouseAction(event, { nativePasteEnabled }) {
105
+ if (!nativePasteEnabled) return null;
106
+ if (event.type === "press" && event.button === "middle") return "paste-primary";
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Issue #169: whether the attach surface should manage the OUTER terminal's
112
+ * mouse mode. Only in regular TUI mode — in fullscreen, TuiAltScreen owns
113
+ * mouse reporting (asserted once at startup, never re-asserted), so any write
114
+ * from the attach surface can only downgrade or kill it. Old runtimes without
115
+ * tui.mode behave as regular. Pure: both arguments are resolved at the call
116
+ * site (mode from the TUI, the flag from the env-derived helper).
117
+ */
118
+ export function shouldOwnOuterMouseMode(mode, mouseEnabled) {
119
+ return mode !== "fullscreen" && !!mouseEnabled;
120
+ }
121
+
97
122
  /**
98
123
  * Return +1 for wheel-up (scroll back), -1 for wheel-down, 0 for non-wheel input.
99
124
  * Supports standard/passive SGR and X10/normal mouse encodings.
@@ -156,3 +156,84 @@ function toLinearChannel(channel) {
156
156
  const value = channel / 255;
157
157
  return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
158
158
  }
159
+
160
+ // --- Attach settle scheme probe (issue #161) -------------------------------
161
+ // pi-tui >= 0.99 renamed queryTerminalBackgroundColor -> queryTerminalColors.
162
+ // This repo compiles against pi-tui 0.79.8 but the extension runs against the
163
+ // host pi's pi-tui (peerDependencies "*"), so the probe feature-detects at
164
+ // runtime. Local structural typedefs only — TerminalColors does not exist on
165
+ // the 0.79.8 compile face and must not be imported.
166
+
167
+ /** @typedef {{ r: number, g: number, b: number }} SettleRgb */
168
+ /** @typedef {{ background?: SettleRgb, foreground?: SettleRgb, palette?: SettleRgb[] }} TerminalColorsLike */
169
+ /** @typedef {{ queryTerminalColors?: (opts: { timeoutMs: number, onLateReply?: (colors: TerminalColorsLike) => void }) => Promise<TerminalColorsLike>, queryTerminalBackgroundColor?: (opts: { timeoutMs: number }) => Promise<SettleRgb|undefined> }} SettleProbeSurface */
170
+ /** @typedef {{ rgb?: SettleRgb, anyColors: boolean }} SettleProbeResult */
171
+
172
+ /**
173
+ * Issue #161: detect which terminal color query API a TUI surface exposes.
174
+ * Detection is pure; the returned adapter only calls the passed-in surface.
175
+ * @param {SettleProbeSurface} tuiLike
176
+ * @returns {{ api: "colors"|"background"|"none", invoke: (tui: SettleProbeSurface, timeoutMs: number, onLateReply?: (colors: TerminalColorsLike) => void) => Promise<SettleProbeResult> }}
177
+ */
178
+ export function resolveProbeApi(tuiLike) {
179
+ if (tuiLike && typeof tuiLike.queryTerminalColors === "function") {
180
+ return {
181
+ api: "colors",
182
+ invoke: async (tui, timeoutMs, onLateReply) => {
183
+ const colors = await tui.queryTerminalColors({ timeoutMs, onLateReply });
184
+ return { rgb: backgroundRgbFromTerminalColors(colors), anyColors: hasAnyColor(colors) };
185
+ },
186
+ };
187
+ }
188
+ if (tuiLike && typeof tuiLike.queryTerminalBackgroundColor === "function") {
189
+ return {
190
+ api: "background",
191
+ invoke: async (tui, timeoutMs) => {
192
+ const rgb = await tui.queryTerminalBackgroundColor({ timeoutMs });
193
+ return { rgb, anyColors: rgb !== undefined };
194
+ },
195
+ };
196
+ }
197
+ return { api: "none", invoke: async () => ({ rgb: undefined, anyColors: false }) };
198
+ }
199
+
200
+ /** @param {TerminalColorsLike} colors @returns {SettleRgb|undefined} */
201
+ export function backgroundRgbFromTerminalColors(colors) {
202
+ return colors && typeof colors === "object" ? colors.background : undefined;
203
+ }
204
+
205
+ /** @param {TerminalColorsLike} colors @returns {boolean} */
206
+ function hasAnyColor(colors) {
207
+ if (!colors || typeof colors !== "object") return false;
208
+ if (colors.background !== undefined || colors.foreground !== undefined) return true;
209
+ return Array.isArray(colors.palette) && colors.palette.some((c) => c !== undefined);
210
+ }
211
+
212
+ const SETTLE_SCHEME_WARN_OUTCOMES = new Set(["timeout", "error", "no_background", "no_probe_api", "dropped_disconnected"]);
213
+
214
+ /**
215
+ * Issue #161: build the DiagnosticEvent-compatible patch for the settle scheme
216
+ * flow. Field names MUST stay inside normalizeDiagnostic's whitelist
217
+ * (level/code/runId/source/message/details) — anything else is silently
218
+ * dropped. Warn only when delivery was expected but failed; routine outcomes
219
+ * (dedup, quick detach, kill switch) stay info so warningCount stays meaningful.
220
+ * @param {{ probeApi?: "colors"|"background"|"none", outcome: "reported"|"timeout"|"error"|"no_background"|"no_probe_api"|"dropped_closed"|"dropped_disconnected"|"duplicate_skipped"|"suppressed", report?: string, late?: boolean }} details
221
+ * @returns {{ source: string, level: "info"|"warn", code: string, message: string, details: { probeApi?: string, outcome: string, report?: string, late?: boolean } }}
222
+ */
223
+ export function buildSettleSchemePatch(details) {
224
+ const parts = [details.outcome];
225
+ if (details.probeApi) parts.push(`via ${details.probeApi}`);
226
+ if (details.report) parts.push(details.report);
227
+ if (details.late) parts.push("late");
228
+ const patch = {
229
+ source: "attach",
230
+ level: SETTLE_SCHEME_WARN_OUTCOMES.has(details.outcome) ? "warn" : "info",
231
+ code: "attach_settle_scheme",
232
+ message: `attach settle scheme: ${parts.join(" ")}`,
233
+ details: { outcome: details.outcome },
234
+ };
235
+ if (details.probeApi !== undefined) patch.details.probeApi = details.probeApi;
236
+ if (details.report !== undefined) patch.details.report = details.report;
237
+ if (details.late !== undefined) patch.details.late = details.late;
238
+ return patch;
239
+ }