@zhushanwen/pi-ask-user 6.0.1 → 7.0.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.
package/ARCHITECTURE.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Internals reference for maintainers. For the usage contract (what the tool does, when an agent should call it), see [README.md](./README.md). This document covers how the code is structured, the state machine, the defensive execute flow, and where each design invariant is enforced — so a change does not silently break an invariant.
4
4
 
5
- Source: 6 files in `src/`, ~1320 lines total.
5
+ Source: 10 files in `src/`, ~1970 lines total.
6
6
 
7
7
  ## File dependency graph
8
8
 
@@ -10,33 +10,59 @@ Source: 6 files in `src/`, ~1320 lines total.
10
10
  ┌─────────────┐
11
11
  │ types.ts │ ← shared leaf; imports only typebox
12
12
  │ Schema + │ holds QuestionState / ThemeLike /
13
- │ shared │ createQuestionState here (NOT in
14
- │ state types │ component.ts) to break the cycle
15
- └──────▲──────┘
13
+ │ shared │ AnswerValue / createQuestionState
14
+ │ state types │ here (NOT in component.ts) to break
15
+ └──────▲──────┘ the cycle
16
16
  │ imported by all
17
- ┌──────────────────┼──────────────────┐
18
- │ │
19
- ┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐
20
- │ validate.ts │ │question-view│ │ submit-view
21
- │ pure check │ │ pure render │ │ pure render │
22
- └──────▲──────┘ └──────▲──────┘ └──────▲──────┘
23
- │ │
24
- │ ┌──────┴──────────────────┘
25
- │ │
26
- ┌──────┴───────────┴──┐
27
- │ component.ts │ ← state machine + input routing + race guards
28
- │ imports question-view + submit-view
29
- └──────────▲──────────┘
30
-
31
- ┌──────┴──────┐
32
- index.ts ← Tool factory + execute (6-step flow) + renderCall/renderResult
33
- └─────────────┘ imports component + validate + types
17
+ ┌──────────────────┼─────────────────────┬─────────────────┐
18
+ │ │
19
+ ┌──────┴──────┐ ┌──────┴──────┐ ┌─────────┴──────┐ ┌───────┴────────┐
20
+ │ validate.ts │ │question-view│ │ submit-view │ answer-codec │
21
+ │ pure check │ │ pure render │ │ pure render │ │ AnswerValue →
22
+ └──────▲──────┘ └──────▲──────┘ │ + buildResult │ │ proto entries │
23
+ │ │ └──────▲─────────┘ └───────▲────────┘
24
+ │ ┌──────┴──────────────────┘
25
+ │ │
26
+ ┌──────┴───────────┴──┐ ┌────────────────────┐ │
27
+ │ component.ts │◀───│ editor-ops.ts
28
+ state machine + │ │ editor pure ops │ │
29
+ │ input routing + │ │ (insert/delete/ │ │
30
+ race guards │ │ cursor/paste) │ │
31
+ └──────────▲──────────┘ └────────────────────┘ │
32
+
33
+ │ ┌────────────────────┐ │
34
+ ├────────────────▶│ channel-handler.ts │◀──────────────┘
35
+ │ (AskUserComp.) │ subagent passthru │
36
+ │ └─────────▲──────────┘
37
+ │ │
38
+ │ ┌─────────┴──────────┐
39
+ │ │channel-registry- │
40
+ │ │register.ts │
41
+ │ │globalThis Symbol │
42
+ │ │handshake │
43
+ │ └────────────────────┘
44
+ ┌──────┴───────────┐
45
+ │ index.ts │ ← Tool factory + execute (6-step flow) + renderCall/renderResult
46
+ └──────────────────┘ imports component + validate + types + submit-view +
47
+ channel-handler + channel-registry-register
34
48
  ```
35
49
 
36
50
  **No cycles.** All imports flow one direction; `types.ts` is the single leaf depended on by everyone.
37
51
 
38
52
  **Why `QuestionState` / `ThemeLike` live in `types.ts`, not `component.ts`** (see the comment in `types.ts`): `question-view.ts` and `submit-view.ts` are pure render functions that read/write `QuestionState` and need the `ThemeLike` interface. If those types lived in `component.ts`, the render views would import `component.ts`, and `component.ts` imports the render views — a cycle. Sinking the shared types to the dependency-free leaf keeps every arrow monotone. **Do not move these types back** without reintroducing the cycle.
39
53
 
54
+ ## Answer model & protocol boundary (D1)
55
+
56
+ `AnswerValue` (`types.ts`) is the single structured answer model — `{ selected: string[]; other: string | null }`. The old internal/proto double model is gone: proto `AskUserOption.value` equaled `label` (both were the same string), so the proto layer consumes the same single model (`Result.answers: Record<string, AnswerValue>`, key = question text).
57
+
58
+ Serialization happens **once, at the protocol boundary**: `encodeAnswer(value, { key, multiSelect })` in `answer-codec.ts` converts an `AnswerValue` into proto answers entries, byte-aligned with `@xyz-agent/extension-protocol` helpers' decode contract (`getAskUserAnswer` / `getAskUserOther`):
59
+
60
+ - 单选:`answers[key] = selected[0]`
61
+ - 多选:`answers[key] = JSON.stringify(selected)`
62
+ - Other:`answers[`${key}__other`] = other`(仅在 other 非空时写入)
63
+
64
+ Encoding is **one-way** — the old `parseAnswerParts` text reverse-parsing was deleted with the double model; there is no decode counterpart inside the extension. `channel-handler.ts` reads `AnswerValue` directly and calls `encodeAnswer` when forwarding to subagents.
65
+
40
66
  ## `execute` defensive flow (6 steps)
41
67
 
42
68
  `execute` in `src/index.ts` runs six ordered checks. Order is not arbitrary — each early step is cheaper than the next and some have side effects that must precede the rest.
@@ -54,33 +80,27 @@ Source: 6 files in `src/`, ~1320 lines total.
54
80
 
55
81
  ## `QuestionState` machine
56
82
 
57
- Each question has a `QuestionState` (`types.ts`). Its `mode` field is a three-state machine:
83
+ Each question has a `QuestionState` (`types.ts`). Its `mode` field is a two-state machine:
58
84
 
59
85
  ```
60
- Enter (on Other row)
61
- ┌────────────────────────────────────┐
62
-
63
- ┌─────────────┐ Enter (normal opt, ┌──────────────┐
64
- │ options │ allowComment=true) ────────▶│ comment
65
- │ (default) │ (note input)
66
- └─────┬───▲───┘◀────────────────── afterConfirm└──────┬───▲───┘
67
- │ │ Enter │ │ Esc
68
- Enter │ │ Esc (discard) (save note) │ │ (AC-17: skip,
69
- (Other) │ │ │ │ keep old value)
70
-
71
- ┌─────────────┐ Enter (text → save) ┌─────────────┐
72
- │ freeform │───────────────────────────────▶│ options
73
- (Other edit)│ │ (back to list)│
74
- └─────┬───▲───┘ └─────────────┘
75
- │ │
76
- ▼ │ Esc (discard)
77
- Enter (empty → clear freeTextValue)
78
-
79
-
80
- options
86
+ Enter (on Other row)
87
+ ┌────────────────────────────────────────┐
88
+
89
+ ┌─────────────┐ Enter (text → save + ┌──────────────┐
90
+ │ options │ afterConfirm + advance) ────▶ │ freeform
91
+ │ (default) │ ◀───────────────────────────────│ (editor)
92
+ └─────┬───▲───┘ Esc (save draft → back) / └──────┬───▲───┘
93
+ │ │ Enter empty (clear value → back) │ │
94
+ Enter │ │ Esc (back to previous tab; │ │
95
+ (normal on first tab → confirm-cancel overlay) │ │
96
+ option) │ │
97
+ ▼ │
98
+ afterConfirm → advance (next tab / Submit tab)
99
+
100
+ └─────────────────────────────────────────────────┘
81
101
  ```
82
102
 
83
- Transitions live in `component.ts`: `options → freeform` (Enter on Other), `freeform → options` (Enter saves / Enter empty clears / Esc discards), `options comment` (via `afterConfirm` when `allowComment`), `comment options` (Enter saves / Esc skips per AC-17).
103
+ Transitions live in `component.ts`: `options → freeform` (Enter on the Other row — the last option — prefills `draftText` from `freeTextValue ?? freeDraft`), `freeform → options` (Enter saves trimmed text into `freeTextValue` and calls `afterConfirm`; Enter on empty text clears `freeTextValue`; Esc saves the draft into `freeDraft` and returns to the list, restoring the saved options cursor). There is **no comment mode** — the freeform editor is the only text input; `QuestionMode = "options" | "freeform"`.
84
104
 
85
105
  ### `confirmed` invariant
86
106
 
@@ -95,14 +115,14 @@ Four assignment sites maintain it (`component.ts`):
95
115
  |------|------|----------------------|
96
116
  | `afterConfirm()` | `true` | Safe: caller has already set `selectedIndex` / `selectedIndices` / `freeTextValue`. |
97
117
  | `autoConfirmIfAnswered()` | `true` | Safe: guarded by `if (hasAnswer)` — never sets `true` without an answer. |
98
- | `toggleIndex()` when multi-select empties | `false` | Necessary: un-checking the last option must drop `confirmed` to preserve the contrapositive. |
99
- | `handleEditorInput` freeform empty-Enter | `false` | Necessary: clearing `freeTextValue` with no other answer must drop `confirmed`. |
118
+ | `toggleIndex()` when multi-select empties | `false` | Necessary: un-checking the last option (with no free text) must drop `confirmed` to preserve the contrapositive. |
119
+ | `handleEditorEnter` freeform empty-Enter | `false` | Necessary: clearing `freeTextValue` with no other answer must drop `confirmed`. |
100
120
 
101
121
  If you add a new path that changes the answer set, audit both directions of this invariant.
102
122
 
103
123
  ### `autoConfirmIfAnswered` trigger
104
124
 
105
- Called only from `gotoTab()` — when the user navigates between tabs via Tab/Shift+Tab without pressing Enter. It promotes an implicitly-answered tab (toggled but not confirmed) to `confirmed`. It deliberately **skips the comment input** (a Tab navigation intent should not force a comment prompt); only the Enter path enters comment mode via `afterConfirm`.
125
+ Called only from `gotoTab()` — when the user navigates between tabs via Tab/Shift+Tab without pressing Enter. It promotes an implicitly-answered tab (toggled but not confirmed) to `confirmed`. A Tab navigation intent should not force a confirm prompt only the Enter path confirms via `afterConfirm`.
106
126
 
107
127
  ## Race guards
108
128
 
@@ -151,13 +171,12 @@ Design spec: `.xyz-harness/2026-06-15-ask-user/spec.md` (FR = functional require
151
171
  | FR-2 (param schema/validation) | `types.ts` schema + `validate.ts` |
152
172
  | FR-3 (inline render, no overlay) | `execute` → `ctx.ui.custom` without `options` |
153
173
  | FR-4 (question view) | `question-view.ts` `renderQuestionView` |
154
- | FR-6 (input handling) | `component.ts` `handleInput` / `handleEditorInput` |
174
+ | FR-6 (input handling) | `component.ts` `handleInput` / `handleOptionsInput` / `handleEditorInput` |
155
175
  | FR-8 (headless disable) | `execute` step 2 |
156
176
  | FR-9 (custom render) | `renderCall` / `renderResult` |
157
177
  | FR-10 (signal abort) | `execute` step 3 + step 4 abort listener |
158
178
  | FR-12 (re-entry guard) | `_resolved` field + `cancel()` shared by abort listener |
159
179
  | FR-13 (error catch-all) | `execute` step 4 try/catch |
160
- | AC-17 (Esc in comment skips, keeps value) | `handleEditorInput` comment-mode Esc branch |
161
180
 
162
181
  When you change one of these behaviors, update both the code comment (which cites the FR/AC) and this table.
163
182
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-ask-user",
3
- "version": "6.0.1",
3
+ "version": "7.0.2",
4
4
  "description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview, inline editor and optional comments.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -26,19 +26,20 @@
26
26
  "ARCHITECTURE.md"
27
27
  ],
28
28
  "dependencies": {
29
- "@xyz-agent/extension-protocol": "^0.3.1"
29
+ "@xyz-agent/extension-protocol": "^0.4.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@earendil-works/pi-tui": "*",
33
- "typebox": "*",
34
33
  "@types/node": "^24.0.0",
34
+ "fast-check": "^4.9.0",
35
+ "typebox": "*",
35
36
  "vitest": "^4.1.8"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "@earendil-works/pi-coding-agent": "*",
39
40
  "@earendil-works/pi-tui": "*",
40
41
  "typebox": "*",
41
- "@zhushanwen/pi-subagent-workflow": "7.0.0"
42
+ "@zhushanwen/pi-subagent-workflow": "7.1.0"
42
43
  },
43
44
  "peerDependenciesMeta": {
44
45
  "@earendil-works/pi-tui": {
@@ -0,0 +1,102 @@
1
+ // src/__tests__/answer-codec.test.ts
2
+ // encodeAnswer 单向序列化 round-trip 测试(TC-09)。
3
+ // 反向验证用 @xyz-agent/extension-protocol 的解码 helper(getAskUserAnswer / getAskUserOther)
4
+ // ——该 helper 是 proto answers 格式的唯一解码 SSOT,encode 输出必须与其字节级对齐。
5
+ // m1 增量(TC-01):property-based describe 作为 5 个确定性用例的随机化超集补充。
6
+ import { getAskUserAnswer, getAskUserOther } from "@xyz-agent/extension-protocol";
7
+ import fc from "fast-check";
8
+ import { describe, expect, it } from "vitest";
9
+
10
+ import { encodeAnswer } from "../answer-codec";
11
+
12
+ describe("encodeAnswer", () => {
13
+ it("single-select: 主 key 写选中 label,other=null 不写 __other;getAskUserAnswer 解码得 string", () => {
14
+ const answers = encodeAnswer({ selected: ["A"], other: null }, { key: "db", multiSelect: false });
15
+ expect(answers).toEqual({ db: "A" });
16
+ expect(getAskUserAnswer(answers, { question: "db", options: [{ label: "A" }] })).toBe("A");
17
+ });
18
+
19
+ it("multi-select: 主 key 写 JSON.stringify(selected);getAskUserAnswer 解码得 string[]", () => {
20
+ const answers = encodeAnswer({ selected: ["A", "B"], other: null }, { key: "lang", multiSelect: true });
21
+ expect(answers).toEqual({ lang: '["A","B"]' });
22
+ expect(getAskUserAnswer(answers, { question: "lang", multiSelect: true })).toEqual(["A", "B"]);
23
+ });
24
+
25
+ it("selected 空 + other 有值: 只写 ${key}__other 不写主 key;getAskUserOther 解码得文本", () => {
26
+ const answers = encodeAnswer({ selected: [], other: "foo" }, { key: "db", multiSelect: false });
27
+ expect(answers).toEqual({ db__other: "foo" });
28
+ expect(getAskUserOther(answers, { question: "db" })).toBe("foo");
29
+ });
30
+
31
+ it("selected 空 + other null/空串: 返回 {}(未答,调用方不写入)", () => {
32
+ expect(encodeAnswer({ selected: [], other: null }, { key: "db", multiSelect: false })).toEqual({});
33
+ expect(encodeAnswer({ selected: [], other: "" }, { key: "db", multiSelect: true })).toEqual({});
34
+ });
35
+
36
+ it("selected 与 other 同时有值: 主 key + __other 都写;两个 helper 各自解码", () => {
37
+ const answers = encodeAnswer(
38
+ { selected: ["A"], other: "custom" },
39
+ { key: "db", multiSelect: false },
40
+ );
41
+ expect(answers).toEqual({ db: "A", db__other: "custom" });
42
+ expect(getAskUserAnswer(answers, { question: "db", options: [{ label: "A" }] })).toBe("A");
43
+ expect(getAskUserOther(answers, { question: "db" })).toBe("custom");
44
+ });
45
+ });
46
+
47
+ // ── m1 property-based round-trip(TC-01)─────────────────
48
+ // fast-check 随机生成合法 AnswerValue 组合 → encodeAnswer → 用协议解码 helper 反向验证
49
+ // 信息保持。六分支断言对应 encodeAnswer 序列化契约(C1)与解码契约(C2):
50
+ // (1) selected 非空 + multiSelect → 解码 deepEqual selected
51
+ // (2) selected 非空 + 单选 → 解码 === selected[0]
52
+ // (3) selected 空 → 主 key 不存在
53
+ // (4) other 非 null 非空串 → getAskUserOther 精确还原;否则 undefined
54
+ // (5) selected 空 + other null/空串 → 返回 {}(未答,调用方不写入)
55
+ // (6) 输出对象无多余键(主 key + __other 至多 2 个)
56
+ // key 生成器过滤 Object.prototype 属性名(toString/constructor/__proto__ 等):
57
+ // 这些名字读 answers[key] 会落到原型链(拿到 Function/原型对象而非 undefined),
58
+ // 是 plain-object answers 的既有协议局限(编码侧赋值本身安全),m1 只测不改 src/。
59
+ describe("encodeAnswer — property-based round-trip", () => {
60
+ it("任意 AnswerValue 组合序列化后经解码 helper 不丢信息", () => {
61
+ fc.assert(
62
+ fc.property(
63
+ fc.string().filter((k) => Object.prototype[k] === undefined),
64
+ fc.boolean(),
65
+ fc.array(fc.string(), { maxLength: 5 }),
66
+ fc.option(fc.string(), { nil: null }),
67
+ (key, multiSelect, selected, other) => {
68
+ const answers = encodeAnswer({ selected, other }, { key, multiSelect });
69
+
70
+ // (6) 输出对象无多余键
71
+ expect(Object.keys(answers).length).toBeLessThanOrEqual(2);
72
+
73
+ if (selected.length > 0) {
74
+ // (1)(2) 主 key 写入:多选 JSON.stringify(selected) / 单选 selected[0]
75
+ const decoded = getAskUserAnswer(answers, { question: key, multiSelect });
76
+ if (multiSelect) {
77
+ expect(decoded).toEqual(selected);
78
+ } else {
79
+ expect(decoded).toBe(selected[0]);
80
+ }
81
+ } else {
82
+ // (3) selected 空 → 主 key 不产生
83
+ expect(answers[key]).toBeUndefined();
84
+ expect(getAskUserAnswer(answers, { question: key, multiSelect })).toBeUndefined();
85
+ }
86
+
87
+ // (4) other:非 null 非空串才写 __other,解码精确还原
88
+ if (other !== null && other !== "") {
89
+ expect(getAskUserOther(answers, { question: key })).toBe(other);
90
+ } else {
91
+ expect(getAskUserOther(answers, { question: key })).toBeUndefined();
92
+ }
93
+
94
+ // (5) 全空 → 返回 {}(未答,调用方不写入)
95
+ if (selected.length === 0 && (other === null || other === "")) {
96
+ expect(answers).toEqual({});
97
+ }
98
+ },
99
+ ),
100
+ );
101
+ });
102
+ });
@@ -6,7 +6,8 @@
6
6
  // - RPC 路径(ctx.mode === 'rpc'):转发器——handler 内部调 askUserInteract(select 通道),
7
7
  // 把 proto answers JSON.stringify 成 {value} 返回,子进程 JSON.parse(value) 正确 decode。
8
8
  // - TUI 路径(ctx.mode === 'tui'):handler 走 ctx.ui.custom(mock 成返回预设 Result),
9
- // 验证内部 Result proto AskUserAnswers 重新编码(single/multi/Other/comment 四种答案形态)。
9
+ // 验证内部结构化 Result.answers(AnswerValue)→ proto AskUserAnswers 重新编码
10
+ // (single/multi/Other 三种答案形态,经 encodeAnswer 序列化)。
10
11
  // - 取消(askUserInteract/custom 返回 null 或 cancelled)→ {cancelled: true}
11
12
  // - 输入校验(channelPayload 缺失/无 questions)→ {cancelled: true}
12
13
  import type { AskUserQuestion } from "@xyz-agent/extension-protocol";
@@ -61,24 +62,14 @@ function makeCtx(opts: MockCtxOpts): {
61
62
  // ── 样例 proto questions(handler 收到的格式) ──────────
62
63
  const singleProto: AskUserQuestion = {
63
64
  question: "Which DB?",
64
- options: [{ label: "Postgres", value: "Postgres" }, { label: "SQLite", value: "SQLite" }],
65
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
65
66
  };
66
67
 
67
68
  const multiProto: AskUserQuestion = {
68
69
  question: "Which tools?",
69
70
  header: "Tools",
70
71
  multiSelect: true,
71
- options: [
72
- { label: "A", value: "A" },
73
- { label: "B", value: "B" },
74
- { label: "C", value: "C" },
75
- ],
76
- };
77
-
78
- const commentProto: AskUserQuestion = {
79
- question: "Which DB?",
80
- allowComment: true,
81
- options: [{ label: "Postgres", value: "Postgres" }, { label: "SQLite", value: "SQLite" }],
72
+ options: [{ label: "A" }, { label: "B" }, { label: "C" }],
82
73
  };
83
74
 
84
75
  // ── Tests ───────────────────────────────────────────────
@@ -103,16 +94,15 @@ describe("createAskUserChannelHandler", () => {
103
94
  expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
104
95
  });
105
96
 
106
- it("RPC: Other + comment proto answers → 透传", async () => {
97
+ it("RPC: Other proto answers → 透传", async () => {
107
98
  const protoAnswers = {
108
99
  "Which DB?": "Postgres",
109
100
  "Which DB?__other": "Custom DB",
110
- "Which DB?__comment": "prod constraint",
111
101
  };
112
102
  const handler = createAskUserChannelHandler(
113
103
  makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
114
104
  );
115
- const resp = await handler({ channelPayload: { questions: [commentProto] } });
105
+ const resp = await handler({ channelPayload: { questions: [singleProto] } });
116
106
  expect(resp).toEqual({ value: JSON.stringify(protoAnswers) });
117
107
  });
118
108
 
@@ -124,11 +114,11 @@ describe("createAskUserChannelHandler", () => {
124
114
  expect(resp).toEqual({ cancelled: true });
125
115
  });
126
116
 
127
- it("TUI: internal Result single-select → 重新编码为 proto answers", async () => {
128
- // 内部 Result.answers:key = question 全文,value = 选中 label
117
+ it("TUI: internal Result single-select AnswerValue → 重新编码为 proto answers", async () => {
118
+ // 内部 Result.answers:key = question 全文,value = 结构化 AnswerValue
129
119
  const internalResult: Result = {
130
120
  questions: [],
131
- answers: { "Which DB?": "Postgres" },
121
+ answers: { "Which DB?": { selected: ["Postgres"], other: null } },
132
122
  cancelled: false,
133
123
  };
134
124
  const handler = createAskUserChannelHandler(
@@ -142,7 +132,7 @@ describe("createAskUserChannelHandler", () => {
142
132
  it("TUI: multi-select internal Result → proto JSON array value", async () => {
143
133
  const internalResult: Result = {
144
134
  questions: [],
145
- answers: { "Which tools?": "A, C" },
135
+ answers: { "Which tools?": { selected: ["A", "C"], other: null } },
146
136
  cancelled: false,
147
137
  };
148
138
  const handler = createAskUserChannelHandler(
@@ -153,59 +143,11 @@ describe("createAskUserChannelHandler", () => {
153
143
  expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
154
144
  });
155
145
 
156
- it("TUI: value≠label single-select → encodeTuiResultToProto 回查 proto option value(PR #85 #8 回归守护)", async () => {
157
- // value≠label 是 #8 修复的核心场景:TUI 渲染用 label,但 proto 期望回传 option.value。
158
- // 若 #8 修复回归(直接 push label),此测试会失败:返回 "显示名A" 而非 "val_a"。
159
- const valueNeqLabelProto: AskUserQuestion = {
160
- question: "选哪个?",
161
- options: [
162
- { label: "显示名A", value: "val_a" },
163
- { label: "显示名B", value: "val_b" },
164
- ],
165
- };
166
- // 内部 Result.answers:用户在 TUI 选了"显示名A"(label)
167
- const internalResult: Result = {
168
- questions: [],
169
- answers: { "选哪个?": "显示名A" },
170
- cancelled: false,
171
- };
172
- const handler = createAskUserChannelHandler(
173
- makeCtx({ mode: "tui", customResult: internalResult }) as never,
174
- );
175
- const resp = await handler({ channelPayload: { questions: [valueNeqLabelProto] } });
176
- // 期望:proto answers 回查 value,返回 "val_a"(不是 label "显示名A")
177
- expect(resp).toEqual({ value: JSON.stringify({ "选哪个?": "val_a" }) });
178
- });
179
-
180
- it("TUI: value≠label multi-select → proto JSON 数组元素回查 value(PR #85 #8 回归守护)", async () => {
181
- // 多选路径同样依赖 #8 修复:selected.push(opt?.value ?? t),多选会 JSON.stringify 数组。
182
- const valueNeqLabelMultiProto: AskUserQuestion = {
183
- question: "选哪些?",
184
- header: "Opts",
185
- multiSelect: true,
186
- options: [
187
- { label: "显示名A", value: "val_a" },
188
- { label: "显示名B", value: "val_b" },
189
- ],
190
- };
191
- const internalResult: Result = {
192
- questions: [],
193
- answers: { "选哪些?": "显示名A, 显示名B" },
194
- cancelled: false,
195
- };
196
- const handler = createAskUserChannelHandler(
197
- makeCtx({ mode: "tui", customResult: internalResult }) as never,
198
- );
199
- const resp = await handler({ channelPayload: { questions: [valueNeqLabelMultiProto] } });
200
- // 期望:多选 JSON 数组,每个元素回查 value(["val_a","val_b"],不是 label)
201
- expect(resp).toEqual({ value: JSON.stringify({ Opts: JSON.stringify(["val_a", "val_b"]) }) });
202
- });
203
-
204
146
  it("TUI: Other free text → ${key}__other", async () => {
205
- // 内部 Result:selected label + Other 文本逗号拼接(与 getAnswerText 语义一致)
147
+ // 内部 Result:selected label + Other 文本分离存储(AnswerValue.other)
206
148
  const internalResult: Result = {
207
149
  questions: [],
208
- answers: { "Which DB?": "Postgres, Custom DB" },
150
+ answers: { "Which DB?": { selected: ["Postgres"], other: "Custom DB" } },
209
151
  cancelled: false,
210
152
  };
211
153
  const handler = createAskUserChannelHandler(
@@ -217,43 +159,18 @@ describe("createAskUserChannelHandler", () => {
217
159
  });
218
160
  });
219
161
 
220
- it("TUI: comment ${key}__comment", async () => {
221
- const internalResult: Result = {
222
- questions: [],
223
- answers: { "Which DB?": "Postgres — prod constraint" },
224
- cancelled: false,
225
- };
226
- const handler = createAskUserChannelHandler(
227
- makeCtx({ mode: "tui", customResult: internalResult }) as never,
228
- );
229
- const resp = await handler({ channelPayload: { questions: [commentProto] } });
230
- expect(resp).toEqual({
231
- value: JSON.stringify({ "Which DB?": "Postgres", "Which DB?__comment": "prod constraint" }),
232
- });
233
- });
234
-
235
- it("TUI: label containing ' — ' keeps full selection (MF-2)", async () => {
236
- // label 含 ANSWER_COMMENT_SEPARATOR("Postgres — prod")时,首分隔符切分
237
- // 会拦腰截断 label → 无法精确匹配 → 选中值静默丢失;修复后选中保留、comment 完整
238
- const sepLabelProto: AskUserQuestion = {
239
- question: "Which DB?",
240
- allowComment: true,
241
- options: [{ label: "Postgres — prod", value: "Postgres — prod" }, { label: "SQLite", value: "SQLite" }],
242
- };
162
+ it("TUI: Other-only answer(selected 空)→ 只写 ${key}__other 不写主 key", async () => {
243
163
  const internalResult: Result = {
244
164
  questions: [],
245
- answers: { "Which DB?": "Postgres prod constraint" },
165
+ answers: { "Which DB?": { selected: [], other: "custom text" } },
246
166
  cancelled: false,
247
167
  };
248
168
  const handler = createAskUserChannelHandler(
249
169
  makeCtx({ mode: "tui", customResult: internalResult }) as never,
250
170
  );
251
- const resp = await handler({ channelPayload: { questions: [sepLabelProto] } });
171
+ const resp = await handler({ channelPayload: { questions: [singleProto] } });
252
172
  expect(resp).toEqual({
253
- value: JSON.stringify({
254
- "Which DB?": "Postgres — prod",
255
- "Which DB?__comment": "constraint",
256
- }),
173
+ value: JSON.stringify({ "Which DB?__other": "custom text" }),
257
174
  });
258
175
  });
259
176
 
@@ -30,7 +30,6 @@ import {
30
30
  INSERT,
31
31
  LEFT,
32
32
  make,
33
- multiQWithComment,
34
33
  OSC_BEL,
35
34
  OSC_ST,
36
35
  PAGE_DOWN,
@@ -51,14 +50,6 @@ import {
51
50
  UP,
52
51
  } from "./fixtures";
53
52
 
54
- /** Helper: 打开 comment 编辑器并返回 component(多问题避免单问题 auto-submit) */
55
- function openComment(): AskUserComponent {
56
- const { c } = make(multiQWithComment);
57
- // Q1 (allowComment): select A → enters comment mode
58
- c.handleInput(ENTER);
59
- return c;
60
- }
61
-
62
53
  /** Helper: 打开 freeform 编辑器并返回 component(不渲染,避免缓存) */
63
54
  function openFreeform(q: Question[]): AskUserComponent {
64
55
  const { c } = make(q);
@@ -264,25 +255,6 @@ describe("AskUserComponent — key leak fix (C-ARROW / C-KEYMAP)", () => {
264
255
  expect(editorLine).not.toContain("abc");
265
256
  });
266
257
 
267
- // ── C-KEYMAP-COMMENT-UP: arrow key no-op in comment editor too ──
268
- it("C-KEYMAP-COMMENT-UP: arrow keys are no-op in comment editor", () => {
269
- const c = openComment();
270
- c.handleInput("a");
271
- c.handleInput(UP);
272
- c.handleInput(RIGHT);
273
- c.handleInput("b");
274
- // comment editor renders text and cursor on separate lines;
275
- // check the text line (before cursor) contains "ab"
276
- const lines = c.render(60);
277
- const textLine = lines.find((l) => l.includes("ab"));
278
- expect(textLine).toBeDefined();
279
- expect(textLine).toContain("ab");
280
- // Ensure no leaked bracket characters from arrow escape sequences
281
- const allText = lines.join("\n");
282
- expect(allText).not.toMatch(/\[A/); // no leaked UP sequence
283
- expect(allText).not.toMatch(/\[C/); // no leaked RIGHT sequence
284
- });
285
-
286
258
  // ── C-KEYMAP-MOD: modifier key combinations matrix (18 cases) ──
287
259
  const modifierCases: Array<{ name: string; seq: string }> = [
288
260
  { name: "ctrl+up", seq: CTRL_UP },
@@ -429,8 +401,8 @@ describe("AskUserComponent — unknown control sequence leak fix (C-CSI)", () =>
429
401
  expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
430
402
  });
431
403
 
432
- it("C-CSI-10: unknown CSI does not leak in comment editor", () => {
433
- const c = openComment();
404
+ it("C-CSI-10: unknown CSI does not leak in editor", () => {
405
+ const c = openFreeform([singleQ]);
434
406
  c.handleInput(UNKNOWN_CSI);
435
407
  c.handleInput("ab");
436
408
  const lines = c.render(60);