@zhushanwen/pi-ask-user 7.0.16 → 7.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -69,14 +69,14 @@ Encoding is **one-way** — the old `parseAnswerParts` text reverse-parsing was
69
69
 
70
70
  | Step | Check | Returns | Why this order |
71
71
  |------|-------|---------|----------------|
72
- | 1 | `validateInput(questions)` | `isError:true` + fix hint | Pure function, no side effects — cheapest gate. Reject before any UI/state work. |
73
- | 2 | `!ctx.hasUI` (headless) | `isError:true` + **`setActiveTools` removes ask_user** | Must run before the agent can retry. Physically removing the tool breaks a function-calling retry loop that plain `isError` cannot. |
72
+ | 1 | `validateInput(questions)` | `throw` (Pi marks `isError:true`, empty `details`) + fix hint | Pure function, no side effects — cheapest gate. Reject before any UI/state work. |
73
+ | 2 | `ctx.mode !== "tui" && ctx.mode !== "rpc"` (headless) | `throw` + **`setActiveTools` removes ask_user** | Must run before the agent can retry. Physically removing the tool breaks a function-calling retry loop that a thrown error cannot. |
74
74
  | 3 | `signal?.aborted` | `cancelled:true` | O(1) short-circuit before the expensive blocking `ctx.ui.custom` call. |
75
- | 4 | `try { ctx.ui.custom(...) } catch` | `isError:true` + `{ error }` | `ctx.ui.custom` is the only call that runs user interaction / editor construction / theme reads — the largest blast radius, so it is the only thing wrapped. |
75
+ | 4 | `try { ctx.ui.custom(...) } catch` | `throw` (Pi marks `isError:true`, empty `details`) | `ctx.ui.custom` is the only call that runs user interaction / editor construction / theme reads — the largest blast radius, so it is the only thing wrapped. |
76
76
  | 5 | `result === null \|\| result.cancelled` | `cancelled:true` | Component resolved to cancel. |
77
77
  | 6 | normal | `{ answers }` | Compose the summary. |
78
78
 
79
- **The order is load-bearing**: swapping 1↔2 wastes a UI check on invalid params; swapping 2↔3 lets an aborted agent enter a blocking UI; moving 4's try/catch wider catches nothing extra. The headless branch's `setActiveTools` is the key insight — returning `isError` alone does not stop an LLM from calling the tool again in the same turn, so the tool is removed from the session's active set and the error text says "do not retry".
79
+ **The order is load-bearing**: swapping 1↔2 wastes a UI check on invalid params; swapping 2↔3 lets an aborted agent enter a blocking UI; moving 4's try/catch wider catches nothing extra. Business outcomes (answers / cancellation) are returned as normal results — only validation failures and unexpected exceptions `throw` (Pi converts a throw into an `isError:true` tool result with empty `details`). The headless branch's `setActiveTools` is the key insight — even an `isError` tool result does not stop an LLM from calling the tool again in the same turn, so the tool is removed from the session's active set and the error text says "do not retry".
80
80
 
81
81
  ## `QuestionState` machine
82
82
 
@@ -122,7 +122,7 @@ If you add a new path that changes the answer set, audit both directions of this
122
122
 
123
123
  ### `autoConfirmIfAnswered` trigger
124
124
 
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`.
125
+ Called only from `gotoTab()` — when the user navigates between tabs via `←/→` (Tab is not a tab-navigation key: on the Submit tab it toggles Submit/Cancel focus, and `Shift+Tab` is deliberately unused because Pi's global `app.thinking.cycle` intercepts it). It promotes an implicitly-answered tab (toggled but not confirmed) to `confirmed`. A navigation intent should not force a confirm prompt — only the Enter path confirms via `afterConfirm`.
126
126
 
127
127
  ## Race guards
128
128
 
@@ -132,7 +132,7 @@ Three independent guards protect against three different races. They are dimensi
132
132
  |-------|------|----------|----------|-----------|
133
133
  | `_resolved` | `boolean` field | `component.ts` | **Double `done()`**: user already submitted/cancelled, then a signal-abort listener or a late keypress fires `done` again → Pi receives two resolves. | `submit()`/`cancel()` set `_resolved = true` before `done(...)`; **both `handleInput` and `cancel()` itself early-return if already set** — so a signal-abort firing after resolution (the listener calls `comp.cancel()`) is a no-op (see `execute` step 4). |
134
134
  | `pendingCancel` | `boolean` field | `component.ts` | **Accidental cancel losing answers**: Esc on the first question (or single question) cancelling outright would discard everything. | Two-step confirm: first Esc sets `pendingCancel = true` and shows an overlay; a second Esc truly cancels; any other key exits the overlay and keeps the form. The Submit-tab Cancel button bypasses this (already at the terminus). |
135
- | `autoConfirmIfAnswered` | **method** (not a field) | `component.ts` | **Zombie unanswered tab**: in multi-question mode, toggling an option then Tab-ing away leaves a tab "answered but not confirmed", so the Submit gate (`allConfirmed()`) stays false and the user cannot tell why Submit is blocked. | `gotoTab()` calls it before switching; if the current state has an answer but `!confirmed`, it sets `confirmed = true`. |
135
+ | `autoConfirmIfAnswered` | **method** (not a field) | `component.ts` | **Zombie unanswered tab**: in multi-question mode, toggling an option then navigating away (`←/→`) leaves a tab "answered but not confirmed", so the Submit gate (`allConfirmed()`) stays false and the user cannot tell why Submit is blocked. | `gotoTab()` calls it before switching; if the current state has an answer but `!confirmed`, it sets `confirmed = true`. |
136
136
 
137
137
  ## Three-layer rendering
138
138
 
@@ -164,7 +164,7 @@ Constants: `SPLIT_PANE_MIN_WIDTH = 84`, `SPLIT_PANE_LEFT_MIN = 32`, `SPLIT_PANE_
164
164
 
165
165
  ## Spec cross-reference
166
166
 
167
- Design spec: `.xyz-harness/2026-06-15-ask-user/spec.md` (FR = functional requirement, AC = acceptance criterion). Implementation anchors:
167
+ The original spec files (`.xyz-harness/2026-06-15-ask-user/`) are no longer in the repo — this table is self-contained (FR = functional requirement from the original spec). Implementation anchors:
168
168
 
169
169
  | Spec | Implemented in |
170
170
  |------|----------------|
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zhushanwen/pi-ask-user
2
2
 
3
- Inline adaptive `ask_user` tool for the Pi coding agent. Resolves ambiguity the agent cannot resolve itself — a single question (no tab bar) or 1-4 questions (tabbed view + submit), with split-pane option preview on wide terminals, an inline free-text editor, and optional comments.
3
+ Inline adaptive `ask_user` tool for the Pi coding agent. Resolves ambiguity the agent cannot resolve itself — a single question (no tab bar) or 1-4 questions (tabbed view + submit), with split-pane option preview on wide terminals and an inline free-text editor. There is no comment feature: the free-text "Other" editor is the only free-form input.
4
4
 
5
5
  The tool's primary caller is the LLM. This README covers both **how an agent should use it** (top sections) and **how a maintainer reads the code** (File structure → Design notes).
6
6
 
@@ -46,7 +46,6 @@ If you recommend an option, prefix its label with `(Recommended)` and list it fi
46
46
  description?: string; // short rationale shown under the label and in the preview pane
47
47
  }>;
48
48
  multiSelect?: boolean; // default false; true only when several options can validly apply
49
- allowComment?: boolean; // default false; lets the user append a free-text note after selecting
50
49
  }>
51
50
  } // questions: 1-4 entries
52
51
  ```
@@ -61,7 +60,7 @@ If you recommend an option, prefix its label with `(Recommended)` and list it fi
61
60
  | `header` | ≤12 chars; required when `questions.length > 1` | `validate.ts` (length + non-empty) |
62
61
  | `options[].label` | non-empty, unique within the question | `validate.ts` |
63
62
 
64
- Validation errors are returned as `isError: true` with a message that names the violation and tells you how to fix it — correct the parameters and retry.
63
+ Validation failures make the tool `throw` with a message that names the violation and tells you how to fix it — correct the parameters and retry. (Pi converts a thrown error into an `isError: true` tool result; business outcomes like answers and cancellation are returned normally.)
65
64
 
66
65
  ## Result format
67
66
 
@@ -69,8 +68,7 @@ On success the tool returns the answers joined as `"question" = "answer"` lines.
69
68
 
70
69
  - **Single-select**: the chosen `label`.
71
70
  - **Multi-select**: selected labels joined with `, ` (e.g. `A, B`).
72
- - **Free-text (Other)**: whatever the user typed.
73
- - **Comment**: if `allowComment` was set, the user's note is appended after ` — ` (e.g. `Postgres — needs TLS`).
71
+ - **Free-text (Other)**: whatever the user typed (appended to the selection with `, ` when both exist).
74
72
 
75
73
  A question with no answer reports as `(no answer)`.
76
74
 
@@ -78,11 +76,13 @@ A question with no answer reports as `(no answer)`.
78
76
 
79
77
  | Situation | Return | What the agent should do |
80
78
  |-----------|--------|--------------------------|
81
- | Parameter validation fails | `isError: true` + fix hint | Correct params and retry |
82
- | No interactive UI (headless) | `isError: true`, tool **disabled for the session** | Proceed with a defensible decision stated in text, or wait for the user — **do not retry** |
79
+ | Parameter validation fails | `throw` (Pi shows it as `isError: true`) + fix hint | Correct params and retry |
80
+ | No interactive UI (headless) | `throw`, tool **disabled for the session** | Proceed with a defensible decision stated in text, or wait for the user — **do not retry** |
83
81
  | Agent aborted (goal cancelled / context compacted) | `cancelled: true` | The text identifies it as an agent abort, not a user cancel. Do not assume an answer; do not retry ask_user — propagate the abort, or wait for new instructions if the decision is still required. |
84
82
  | User cancels (Esc → confirm, or Cancel button) | `cancelled: true` | Wait for new instructions, or re-ask with refined options if the decision is still required |
85
- | Unexpected error | `isError: true` + `{ error }` | Retry once with corrected parameters, or proceed with a defensible decision |
83
+ | Unexpected error during interaction | `throw` (Pi shows it as `isError: true`) | Retry once with corrected parameters, or proceed with a defensible decision |
84
+
85
+ Business outcomes (answers / cancellation) are returned as normal results; only validation failures and unexpected exceptions `throw` — Pi marks a thrown error `isError: true` with empty `details`.
86
86
 
87
87
  The headless branch physically removes the tool from the session (`setActiveTools`) — this is deliberate, so a function-calling loop cannot keep retrying `ask_user` in a non-interactive context.
88
88
 
@@ -90,11 +90,10 @@ The headless branch physically removes the tool from the session (`setActiveTool
90
90
 
91
91
  - **Adaptive layout**: single question → no tab bar; 1-4 questions → tabbed view + Submit tab.
92
92
  - **Split-pane preview** (≥84 cols): option list left, selected option detail right. The right pane is **plain-text** option detail (label + description), not a Markdown renderer.
93
- - **Inline free-text editor**: select "Other" → Enter → type a custom answer. Multi-line aware, soft-wrapped.
94
- - **Optional comments**: `allowComment: true` → after selecting, the user may append a short note.
93
+ - **Inline free-text editor**: select "Other" → Enter → type a custom answer. Multi-line aware, soft-wrapped. No comment mode — this editor is the only free-form input.
95
94
  - **Multi-select**: `multiSelect: true` → toggle checkboxes with Space, Enter to confirm.
96
95
  - **Esc confirm-to-cancel**: Esc on the first question opens a confirm overlay (a second Esc cancels; any other key stays).
97
- - **Headless-safe**: disables the tool and returns `isError` when no UI is available.
96
+ - **Headless-safe**: disables the tool and throws when no UI is available.
98
97
 
99
98
  ## File structure
100
99
 
@@ -111,7 +110,11 @@ extensions/universal/ask-user/
111
110
  ├── validate.ts # pure parameter validation; error messages aimed at LLM fixability
112
111
  ├── component.ts # AskUserComponent: state machine, input routing, race guards
113
112
  ├── question-view.ts # pure render: option list, split-pane, inline editor
114
- └── submit-view.ts # pure render: Submit tab, answer summary, buildResult
113
+ ├── submit-view.ts # pure render: Submit tab, answer summary, buildResult
114
+ ├── answer-codec.ts # pure encode: AnswerValue → proto answers entries (protocol boundary)
115
+ ├── channel-handler.ts # subagent channel passthru: RPC forward / TUI re-render → proto answers
116
+ ├── channel-registry-register.ts # globalThis Symbol slot handshake (handler registration)
117
+ └── editor-ops.ts # pure editor ops: insert/delete/cursor/paste (UTF-16 surrogate aware)
115
118
  ```
116
119
 
117
120
  `types.ts` is intentionally the shared dependency leaf — it holds `QuestionState`/`ThemeLike` (not `component.ts`) so the two pure-render views depend only on the leaf, breaking a would-be `component → view → component` cycle. See ARCHITECTURE.md for the full graph.
@@ -130,18 +133,12 @@ All three are consistent and point the same direction. If you tune behavior, edi
130
133
 
131
134
  - **Why inline, not overlay** (`execute` → `ctx.ui.custom` without `options`): the question belongs in the conversation flow, not a modal that obscures context.
132
135
  - **Why `Other` is auto-appended, not in the schema**: free-text input is the user's escape hatch and must not be something the LLM can omit or mislabel. Keeping it out of `options` guarantees it is always present and always last.
133
- - **Why `←/→` does not switch tabs**: left/right is reserved for the Submit tab's Submit/Cancel focus toggle, so it does not yank focus away while navigating an option list.
136
+ - **Why `←/→` switches tabs but `Tab` does not**: arrow keys move between question tabs (and wrap on the Submit tab), so tab navigation never conflicts with the option-list cursor (`↑/↓`) or text editing. On the Submit tab, `Tab` alone toggles focus between Submit and Cancel a deliberate single-key bidirectional toggle; `Shift+Tab` is intentionally unused because Pi's global `app.thinking.cycle` intercepts it.
134
137
  - **Why validation messages are verbose**: every message names the violation and gives a fix path, because the reader is an LLM that will retry.
135
138
 
136
139
  ## Spec reference
137
140
 
138
- The original design spec, acceptance criteria (FR-x / AC-x), and E2E test cases live under `.xyz-harness/2026-06-15-ask-user/`:
139
-
140
- - `spec.md` — requirements + functional/acceptance criteria
141
- - `e2e-test-cases.md` — end-to-end scenarios
142
- - `clarification.md` / `plan.md` — design rationale
143
-
144
- Cross-references between these and the implementation are in ARCHITECTURE.md.
141
+ The original design spec (requirements, functional requirements FR-x, acceptance criteria AC-x, E2E test cases) predates this repository — the original spec files are no longer in the repo. The FR cross-reference table in ARCHITECTURE.md is self-contained: each entry names the behavior and where it is implemented in this codebase.
145
142
 
146
143
  ## License
147
144
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-ask-user",
3
- "version": "7.0.16",
4
- "description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview, inline editor and optional comments.",
3
+ "version": "7.1.1",
4
+ "description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview and an inline free-text editor.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "xyz-agent": {
@@ -29,8 +29,8 @@
29
29
  "ARCHITECTURE.md"
30
30
  ],
31
31
  "dependencies": {
32
- "@xyz-agent/extension-protocol": "0.7.0",
33
- "@zhushanwen/pi-extension-logger": "0.3.1"
32
+ "@xyz-agent/extension-protocol": "0.8.1",
33
+ "@zhushanwen/pi-extension-logger": "0.4.1"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@earendil-works/pi-tui": "^0.84.4",
@@ -820,3 +820,35 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
820
820
  expect(lines.some((l: string) => l.includes("B"))).toBe(true);
821
821
  });
822
822
  });
823
+
824
+ // ── options 输入路由 no-op 落空语义(←/→ 与 Space 的守卫分支)──
825
+ describe("AskUserComponent — options input no-op edges", () => {
826
+ it("C-NAV-1: single question ignores LEFT/RIGHT in options mode (no tab nav, no cancel overlay)", () => {
827
+ const { c, result } = make([singleQ]);
828
+ // 单问题无 tab:←/→ 应为 no-op(isSingle 短路),既不切 tab 也不进入确认取消覆盖层
829
+ c.handleInput(LEFT);
830
+ c.handleInput(RIGHT);
831
+ expect(result.val).toBeUndefined();
832
+ // 未进入 Esc 确认取消覆盖层(覆盖层特征文案不出现)
833
+ expect(c.render(60).some((l) => l.includes("Cancel all"))).toBe(false);
834
+ // 光标仍在第一项,表单完好
835
+ const lines = c.render(60);
836
+ expect(lines.some((l) => l.includes(">") && l.includes("Postgres"))).toBe(true);
837
+ });
838
+
839
+ it("C-NAV-2: multi-select Space on Other row is a no-op (does not toggle, does not enter editor)", () => {
840
+ const { c, result } = make([singleQMulti]);
841
+ // 下移到 Other 行(Auth, Search, Other → 光标 index 2)
842
+ c.handleInput(DOWN);
843
+ c.handleInput(DOWN);
844
+ // Other 行上 Space:multiSelect && !onOther 为 false → 落空
845
+ c.handleInput(" ");
846
+ // 未确认、未提交
847
+ expect(result.val).toBeUndefined();
848
+ const lines = c.render(60);
849
+ // Other 行未被 toggle(无任何 [✓] 勾选框出现)
850
+ expect(lines.some((l) => l.includes("[✓]"))).toBe(false);
851
+ // 仍在 options 模式(未进 freeform 编辑器——反色光标不出现)
852
+ expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
853
+ });
854
+ });
@@ -1,7 +1,7 @@
1
1
  // src/__tests__/e2e.test.ts
2
2
  // E2E test cases for ask_user. Drives tool.execute() → real AskUserComponent
3
3
  // → simulated keypresses → asserts on final execute() result contract.
4
- // Spec: .xyz-harness/2026-06-15-ask-user/e2e-test-cases.md
4
+ // (原始 spec 文件已不在仓内——用例自包含,编号 E2E-x 沿用原始 spec 命名)
5
5
 
6
6
  import { describe, expect, it } from "vitest";
7
7
 
@@ -453,17 +453,8 @@ describe("renderCall / renderResult (FR-9)", () => {
453
453
  expect(renderText(node)).toContain("Cancelled");
454
454
  });
455
455
 
456
- it("I-19: renderResult error shows <error>", () => {
457
- const tool = getTool();
458
- const node = tool.renderResult(
459
- { details: { error: "something broke" } },
460
- { expanded: false },
461
- stubTheme,
462
- ) as unknown as { render(width: number): string[] };
463
- const text = renderText(node);
464
- expect(text).toContain("✗");
465
- expect(text).toContain("something broke");
466
- });
456
+ // (I-19 已随 ErrorDetails 死类型删除:错误路径全部 throw(W4),pi isError
457
+ // details 为空对象,renderResult 不存在 {error} details 分支可渲染)
467
458
 
468
459
  // S-3: options.expanded 展开 —— 显示全部选项 + ●/○ 选中标记(spec FR-9)
469
460
  it("I-20: renderResult expanded shows all options with ●/○ marks", () => {
@@ -13,7 +13,8 @@
13
13
  // (key=header/question,单选=string,多选=JSON 数组,Other→__other),让子进程 decode 一致。
14
14
  //
15
15
  // handler 收到的 req.channelPayload = {questions: AskUserQuestion[], allowCancel}(proto 格式,
16
- // 由子进程 askUserInteract 编码、subagent-workflow parseChannel 解析 options[0] JSON 得到)。
16
+ // 由子进程 askUserInteract 编码、packages/subagent-core parseChannel(execution/ui-channels.ts)
17
+ // 解析 options[0] JSON 得到)。
17
18
 
18
19
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
19
20
  import {
@@ -27,20 +28,20 @@ import { encodeAnswer } from "./answer-codec";
27
28
  import { type AnswerValue, type Option, type Question, type Result, type ThemeLike } from "./types";
28
29
 
29
30
  /**
30
- * channel handler 签名——与 subagent-workflow 的 UiChannelRegistry.ChannelHandler 一致
31
- *((req: unknown) => Promise<unknown>)。本文件不静态 import subagent-workflow(它是可选
32
- * peerDep,未安装时静态 import 会导致整个 ask-user 加载失败);注册时通过动态 import 拿
33
- * registry,handler 签名用本地等价类型,运行时结构兼容。
31
+ * channel handler 签名——与 packages/subagent-core 的 UiChannelRegistry.ChannelHandler 一致
32
+ *(execution/ui-channels.ts 定义,(req: unknown) => Promise<unknown>)。本文件不静态 import
33
+ * packages/subagent-core(host 侧包,非本包依赖——两侧扩展经 globalThis slot 握手协作,
34
+ * 见 index.ts 工厂注释);handler 签名用本地等价类型,运行时结构兼容。
34
35
  */
35
36
  export type ChannelHandler = (req: unknown) => Promise<unknown>;
36
37
 
37
- /** handler 返回给 subagent-workflow 的 UiResponse 形状(dialog-queue.ts 定义)。
38
+ /** handler 返回给 packages/subagent-core 的 UiResponse 形状(execution/dialog-queue.ts 定义)。
38
39
  * - {value}: select 的回传值(子进程 JSON.parse(value) 得 answers)
39
40
  * - {cancelled}: 用户取消 / 子进程 close / handler 抛错 */
40
41
  type ChannelResponse = { value: string } | { cancelled: true };
41
42
 
42
43
  /** handler 收到的 req 形状收窄(ChannelHandler 签名是 unknown,按形状 as 收窄)。
43
- * channelPayload 由 subagent-workflow parseChannel 填充。 */
44
+ * channelPayload 由 packages/subagent-core parseChannel 填充。 */
44
45
  interface ChannelRequest {
45
46
  channelPayload?: { questions?: AskUserQuestion[]; allowCancel?: boolean };
46
47
  }
@@ -138,7 +139,7 @@ async function runTuiProtoInteraction(
138
139
  */
139
140
  export function createAskUserChannelHandler(ctx: ExtensionContext): ChannelHandler {
140
141
  return async (req: unknown): Promise<unknown> => {
141
- // req 正常是 subagent-workflow 构造的 UiRequest 对象;防御性收窄 null/undefined/
142
+ // req 正常是 packages/subagent-core 构造的 UiRequest 对象;防御性收窄 null/undefined/
142
143
  // 非 object(handler 抛错会被 dialog-queue 兜底为 {cancelled:true},但这里直接返回更干净)
143
144
  if (req === null || typeof req !== "object") {
144
145
  return { cancelled: true } satisfies ChannelResponse;
@@ -3,14 +3,16 @@
3
3
  // ask-user 侧的 channel handler 握手注册纯函数。
4
4
  //
5
5
  // 设计动机(PR #85 #M4 修复):原实现 ask-user 先 session_start 时会自建简化 Map-based
6
- // registry 占据 canonical 槽位,劫持 subagent-workflow 后续 getOrCreateChannelRegistry 拿到的
7
- // 实例(subagent-workflow 的 createUiChannelRegistry 才是 canonical——它带排队、dialog 队列
8
- // 等完整能力)。修复后的握手协议改为「带 version 的 slot」:
9
- // - subagent-workflow session_start 时往 slot 写 {version, registry, pending:[]}
6
+ // registry 占据 canonical 槽位,劫持后续 getOrCreateChannelRegistry 拿到的实例
7
+ // (packages/subagent-core 的 createUiChannelRegistry(execution/ui-channels.ts)才是
8
+ // canonical——它带排队、dialog 队列等完整能力)。修复后的握手协议改为「带 version 的 slot」:
9
+ // - 承载 subagent-core 的 subagent-workflow 扩展 session_start 时往 slot 写
10
+ // {version, registry, pending:[]}(registry 由 subagent-core 创建)
10
11
  // - ask-user session_start 时只读 slot,registry 就绪则调 registry.register,未就绪则 push pending
11
12
  // - ask-user **永不**创建 registry 实例,**永不**写 slot.registry
12
13
  //
13
- // 这是 ask-user 侧的注册入口;canonical registry 由 subagent-workflow 创建。本模块永不创建
14
+ // 这是 ask-user 侧的注册入口;canonical registry 由 packages/subagent-core 创建(经
15
+ // channel-registry-access.ts 的 getOrCreateChannelRegistry + flush)。本模块永不创建
14
16
  // registry 实例——仅往 slot 写 pending 或调 slot.registry.register。
15
17
 
16
18
  import type { ChannelHandler } from "./channel-handler";
@@ -21,7 +23,7 @@ const logger = getLogger("ask-user");
21
23
  /**
22
24
  * 进程级 channel registry 握手的 globalThis key(Symbol.for 跨模块共享)。
23
25
  *
24
- * ⚠️ 必须与 extensions/universal/subagent-workflow/src/execution/channel-registry-access.ts 的字面量
26
+ * ⚠️ 必须与 packages/subagent-core/src/execution/channel-registry-access.ts 的字面量
25
27
  * 完全一致——两边用同一字符串确保拿到同一 slot 实例。改名必须两侧同步。
26
28
  */
27
29
  export const CHANNEL_HANDSHAKE_KEY = Symbol.for(
@@ -35,9 +37,9 @@ const HANDSHAKE_VERSION = 1;
35
37
  const ASK_USER_CHANNEL = "ask_user";
36
38
 
37
39
  /**
38
- * channel registry 的本地等价接口(与 subagent-workflow UiChannelRegistry 形状一致)。
39
- * 本模块不静态 import subagent-workflow(它是可选 peerDep,未安装时静态 import 会致整个
40
- * ask-user 加载失败);运行时结构兼容即可。
40
+ * channel registry 的本地等价接口(与 packages/subagent-core UiChannelRegistry 形状一致,
41
+ * execution/ui-channels.ts 定义)。本模块不静态 import packages/subagent-core(host 侧包,
42
+ * 非本包依赖——两侧经 globalThis slot 握手协作);运行时结构兼容即可。
41
43
  */
42
44
  interface ChannelRegistry {
43
45
  register(channel: string, handler: ChannelHandler): void;
@@ -45,7 +47,7 @@ interface ChannelRegistry {
45
47
  list(): string[];
46
48
  }
47
49
 
48
- /** pending 队列元素:channel + handler。subagent-workflow flush 时遍历调用 registry.register。 */
50
+ /** pending 队列元素:channel + handler。packages/subagent-core flush(channel-registry-access.ts)时遍历调用 registry.register。 */
49
51
  interface PendingEntry {
50
52
  channel: string;
51
53
  handler: ChannelHandler;
@@ -55,9 +57,9 @@ interface PendingEntry {
55
57
  * globalThis slot 的形状。
56
58
  *
57
59
  * - `version`:握手协议版本(运行时校验,不兼容则丢弃重建)
58
- * - `registry`:canonical 实例,**仅 subagent-workflow 创建**;缺失表示 registry 未就绪,
60
+ * - `registry`:canonical 实例,**仅 packages/subagent-core 创建**;缺失表示 registry 未就绪,
59
61
  * ask-user 把 handler 入 pending 队列等待 flush
60
- * - `pending`:未消费的注册请求(registry 就绪后由 subagent-workflow 一次性 flush)
62
+ * - `pending`:未消费的注册请求(registry 就绪后由 packages/subagent-core 一次性 flush)
61
63
  */
62
64
  export interface ChannelRegistryHandshake {
63
65
  version: 1;
@@ -98,7 +100,7 @@ function ensureSlot(): ChannelRegistryHandshake {
98
100
  * 3. slot 存在且 registry 就绪 → 直接调 registry.register("ask_user", handler)
99
101
  *
100
102
  * 多次调用幂等:registry 就绪时 register 同名覆盖;未就绪时 pending.length 增长
101
- * (subagent-workflow flush 时一次性消费所有 pending)。
103
+ * (packages/subagent-core flush 时一次性消费所有 pending)。
102
104
  *
103
105
  * @param handler ask_user channel handler(createAskUserChannelHandler 产出)
104
106
  */
package/src/component.ts CHANGED
@@ -214,6 +214,7 @@ export class AskUserComponent implements Component {
214
214
  /**
215
215
  * options 模式的输入处理(从 handleInput 拆出)。
216
216
  * 含:Esc 回退/确认取消、←/→ 切 tab、↑/↓ 移光标、Enter 确认、Space toggle。
217
+ * 主函数只留编排:Esc → tab 导航 → 光标导航 → 确认键,各阶段命中即终止。
217
218
  */
218
219
  private handleOptionsInput(data: string, state: QuestionState, q: Question): void {
219
220
  // Esc → 回退到上一个问题;在首个问题时进入确认取消覆盖层
@@ -221,59 +222,87 @@ export class AskUserComponent implements Component {
221
222
  this.escBackOrConfirm();
222
223
  return;
223
224
  }
225
+ if (this.handleTabNavKeys(data)) return;
226
+ if (this.handleCursorNavKeys(data, state, q)) return;
227
+ this.handleOptionConfirmKeys(data, state, q);
228
+ }
224
229
 
225
- // ← / → 切换问题 tab(多问题,options 模式)
230
+ /** ← / → 切换问题 tab(多问题,options 模式)。返回 true 表示已消费该按键。 */
231
+ private handleTabNavKeys(data: string): boolean {
226
232
  if (!this.isSingle && matchesKey(data, "right")) {
227
233
  this.gotoTab(Math.min(this.activeTab + 1, this.questions.length));
228
- return;
234
+ return true;
229
235
  }
230
236
  if (!this.isSingle && matchesKey(data, "left")) {
231
237
  this.gotoTab(Math.max(this.activeTab - 1, 0));
232
- return;
238
+ return true;
233
239
  }
240
+ return false;
241
+ }
234
242
 
243
+ /** ↑/↓ 移动选项光标(↓ 受 Other 行下界约束)。返回 true 表示已消费该按键。 */
244
+ private handleCursorNavKeys(data: string, state: QuestionState, q: Question): boolean {
235
245
  if (matchesKey(data, "up")) {
236
246
  state.cursorIndex = Math.max(0, state.cursorIndex - 1);
237
247
  this.rerender();
238
- return;
248
+ return true;
239
249
  }
240
250
  if (matchesKey(data, "down")) {
241
251
  const max = allOptions(q).length - 1;
242
252
  state.cursorIndex = Math.min(max, state.cursorIndex + 1);
243
253
  this.rerender();
244
- return;
254
+ return true;
245
255
  }
256
+ return false;
257
+ }
246
258
 
259
+ /** Other 行 Enter 进编辑器 / 多选 Space toggle + Enter 确认 / 单选 Enter 确认。 */
260
+ private handleOptionConfirmKeys(data: string, state: QuestionState, q: Question): void {
247
261
  const opts = allOptions(q);
248
262
  const onOther = state.cursorIndex === opts.length - 1;
249
263
 
250
264
  // Other row → Enter opens freeform editor
251
265
  if (onOther && matchesKey(data, "enter")) {
252
- state.savedOptionsCursorIndex = state.cursorIndex;
253
- state.mode = "freeform";
254
- state.draftText = state.freeTextValue ?? state.freeDraft ?? "";
255
- state.cursorIndex = state.draftText.length;
256
- this.rerender();
266
+ this.openOtherEditor(state);
257
267
  return;
258
268
  }
259
269
 
260
270
  if (q.multiSelect && !onOther) {
261
- if (matchesKey(data, "space")) {
262
- this.toggleIndex(state, state.cursorIndex);
263
- return;
264
- }
265
- if (matchesKey(data, "enter")) {
266
- state.selectedIndices.add(state.cursorIndex);
267
- this.afterConfirm(state);
268
- return;
269
- }
271
+ this.handleMultiSelectConfirmKeys(data, state);
270
272
  } else if (!q.multiSelect && !onOther) {
271
- if (matchesKey(data, "enter")) {
272
- state.selectedIndex = state.cursorIndex;
273
- state.freeTextValue = null;
274
- this.afterConfirm(state);
275
- return;
276
- }
273
+ this.handleSingleSelectConfirmKeys(data, state);
274
+ }
275
+ }
276
+
277
+ /** Other 行 Enter:进 freeform 编辑器,预填已提交文本或上一次草稿。 */
278
+ private openOtherEditor(state: QuestionState): void {
279
+ state.savedOptionsCursorIndex = state.cursorIndex;
280
+ state.mode = "freeform";
281
+ state.draftText = state.freeTextValue ?? state.freeDraft ?? "";
282
+ state.cursorIndex = state.draftText.length;
283
+ this.rerender();
284
+ }
285
+
286
+ /** 多选(非 Other 行):Space toggle;Enter 选中光标项并确认前进。 */
287
+ private handleMultiSelectConfirmKeys(data: string, state: QuestionState): void {
288
+ if (matchesKey(data, "space")) {
289
+ this.toggleIndex(state, state.cursorIndex);
290
+ return;
291
+ }
292
+ if (matchesKey(data, "enter")) {
293
+ state.selectedIndices.add(state.cursorIndex);
294
+ this.afterConfirm(state);
295
+ return;
296
+ }
297
+ }
298
+
299
+ /** 单选(非 Other 行):Enter 选中光标项(清 Other 文本)并确认前进。 */
300
+ private handleSingleSelectConfirmKeys(data: string, state: QuestionState): void {
301
+ if (matchesKey(data, "enter")) {
302
+ state.selectedIndex = state.cursorIndex;
303
+ state.freeTextValue = null;
304
+ this.afterConfirm(state);
305
+ return;
277
306
  }
278
307
  }
279
308
 
package/src/editor-ops.ts CHANGED
@@ -10,8 +10,6 @@ import { isHighSurrogate, SURROGATE_PAIR_LEN, type QuestionState } from "./types
10
10
 
11
11
  // ── 编辑器纯操作 ──
12
12
 
13
- // ── 编辑器纯操作 ──
14
-
15
13
  /** 在光标处插入文本,光标前移 text.length。 */
16
14
  export function insertAtCursor(state: QuestionState, text: string): void {
17
15
  state.draftText = state.draftText.slice(0, state.cursorIndex) + text + state.draftText.slice(state.cursorIndex);
package/src/index.ts CHANGED
@@ -211,15 +211,16 @@ export default function (pi: ExtensionAPI): void {
211
211
  // 注册 ask_user channel handler:把 subagent 子进程的 ask_user 请求透传到主进程 UI。
212
212
  //
213
213
  // 跨扩展握手协议(PR #85 #M4):通过 globalThis Symbol.for 约定 slot 形状
214
- //(CHANNEL_HANDSHAKE_KEY,与 subagent-workflow/src/execution/channel-registry-access.ts
214
+ //(CHANNEL_HANDSHAKE_KEY,与 packages/subagent-core/src/execution/channel-registry-access.ts
215
215
  // 用同一字符串 key),不依赖 dynamic import npm 包名(两个扩展都通过
216
216
  // ~/.pi/agent/extensions/ symlink 加载,互相之间无法用 npm 包名 import)。
217
217
  //
218
218
  // 握手流程(registerAskUserChannelHandler 内部完成):
219
219
  // 1. 读 slot;不存在或 version 不兼容 → 建 slot(仅 pending,**永不建 registry**)
220
- // 2. slot.registry 就绪(subagent-workflow 先到)→ 直接调 registry.register
221
- // 3. slot.registry 未就绪 → handler 入 pending,等 subagent-workflow flush
222
- // ask-user 永不创建 registry 实例——canonical registry 仅 subagent-workflow 创建。
220
+ // 2. slot.registry 就绪(承载 packages/subagent-core 的 subagent-workflow 扩展先到)→ 直接调 registry.register
221
+ // 3. slot.registry 未就绪 → handler 入 pending,等 subagent-core flush
222
+ // ask-user 永不创建 registry 实例——canonical registry 仅 packages/subagent-core 创建
223
+ //(execution/ui-channels.ts 的 createUiChannelRegistry)。
223
224
  pi.on("session_start", (_event, ctx) => {
224
225
  registerAskUserChannelHandler(createAskUserChannelHandler(ctx));
225
226
  });
@@ -351,10 +352,8 @@ Don't:
351
352
  theme: ThemeLike,
352
353
  ) {
353
354
  const details = result.details;
354
- if (details && "error" in details && details.error) {
355
- return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
356
- }
357
- // details 现已排除 ErrorDetails 分支,收窄为 Result | undefined
355
+ // 错误路径已全部改 throw(W4):pi isError:true details 为空对象,
356
+ // 不存在 ErrorDetails 形态——这里 details 要么 undefined 要么 Result
358
357
  const d = details as Result | undefined;
359
358
  if (!d || d.cancelled) {
360
359
  return new Text(theme.fg("warning", "Cancelled"), 0, 0);
@@ -124,6 +124,7 @@ const EDITOR_HINT = " ←/→ Home/End move · Backspace deletes · Enter submit
124
124
  /**
125
125
  * 构建选项列表行(不含分屏预览)。hideDescriptions 用于分屏模式左列。
126
126
  * freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选)。
127
+ * 主函数只留逐行编排:Other / 多选 / 单选三类行各自提取为独立 append helper。
127
128
  */
128
129
  function buildOptionLines(
129
130
  ctx: RenderContext,
@@ -141,68 +142,108 @@ function buildOptionLines(
141
142
  // 编辑器模式下用 savedOptionsCursorIndex 判断选项高亮,cursorIndex 此时是文本光标
142
143
  const activeOptionCursor = state.mode === "freeform" ? state.savedOptionsCursorIndex : state.cursorIndex;
143
144
  const isSelected = i === activeOptionCursor;
144
- const isOther = opt.isOther === true;
145
145
  const prefix = isSelected ? t.fg("accent", ">") : " ";
146
146
 
147
- if (isOther) {
148
- // 标记位宽度必须与普通选项一致,否则编号列错位:
149
- // 单选 check = 1 列,多选 box = 3 列。
150
- // 此前单选 freeform 占位用 " "(2列)、多选非 freeform 用 check(1列),
151
- // 两种情况下 Other 编号都与普通选项错位。
152
- if (state.mode === "freeform") {
153
- const marker = q.multiSelect ? t.fg("dim", "[ ]") : " ";
154
- const num = i + 1;
155
- const lead = `${prefix} ${marker} `;
156
- const avail = Math.max(1, width - visibleWidth(lead));
157
- // 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
158
- const cursorText = renderCursorText(state.draftText, state.cursorIndex);
159
- const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
160
- addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
161
- } else {
162
- const hasFreeText = state.freeTextValue !== null;
163
- const marker = q.multiSelect
164
- ? (hasFreeText ? t.fg("success", "[✓]") : t.fg("dim", "[ ]"))
165
- : (hasFreeText ? t.fg("success", "✓") : " ");
166
- const labelColor = isSelected ? "accent" : "text";
167
- const num = i + 1;
168
- add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
169
- if (hasFreeText) {
170
- // 预览缩进对齐到 label 起始列:prefix + sp + marker + sp + "N." + sp。
171
- // 随 num 位数与单/多选 marker 宽度动态变化,硬编码会错位。
172
- const numStr = `${num}.`;
173
- const lead = " ".repeat(
174
- visibleWidth(prefix) + 1 + visibleWidth(marker) + 1 + numStr.length + 1,
175
- );
176
- const avail = Math.max(1, width - visibleWidth(lead));
177
- const styled = t.fg("dim", `"${state.freeTextValue ?? ""}"`);
178
- addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
179
- }
180
- }
147
+ if (opt.isOther === true) {
148
+ appendOtherRow(ctx, opt, i, isSelected, prefix, add);
181
149
  } else if (q.multiSelect) {
182
- const checked = state.selectedIndices.has(i);
183
- const box = checked ? t.fg("accent", "[✓]") : t.fg("dim", "[ ]");
184
- const labelColor = isSelected ? "accent" : "text";
185
- const num = i + 1;
186
- add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
187
- if (opt.description && !hideDescriptions) {
188
- const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_MULTI);
189
- for (const line of wrapped) add(` ${line}`);
190
- }
150
+ appendMultiSelectRow(ctx, opt, i, isSelected, prefix, hideDescriptions, add);
191
151
  } else {
192
- const isConfirmed = state.selectedIndex === i;
193
- const check = isConfirmed ? t.fg("success", "✓") : " ";
194
- const labelColor = isSelected ? "accent" : "text";
195
- const num = i + 1;
196
- add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
197
- if (opt.description && !hideDescriptions) {
198
- const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_SINGLE);
199
- for (const line of wrapped) add(` ${line}`);
200
- }
152
+ appendSingleSelectRow(ctx, opt, i, isSelected, prefix, hideDescriptions, add);
201
153
  }
202
154
  }
203
155
  return lines;
204
156
  }
205
157
 
158
+ /** Other 行:freeform 原地编辑器(反色光标)/ 非 freeform 的 ✓ 标记 + 已保存文本预览。 */
159
+ function appendOtherRow(
160
+ ctx: RenderContext,
161
+ opt: DisplayOption,
162
+ index: number,
163
+ isSelected: boolean,
164
+ prefix: string,
165
+ add: (s: string) => void,
166
+ ): void {
167
+ const { question: q, state, theme: t, width } = ctx;
168
+ const num = index + 1;
169
+
170
+ if (state.mode === "freeform") {
171
+ // 标记位宽度必须与普通选项一致,否则编号列错位:
172
+ // 单选 check = 1 列,多选 box = 3 列。
173
+ // 此前单选 freeform 占位用 " "(2列)、多选非 freeform 用 check(1列),
174
+ // 两种情况下 Other 编号都与普通选项错位。
175
+ const marker = q.multiSelect ? t.fg("dim", "[ ]") : " ";
176
+ const lead = `${prefix} ${marker} `;
177
+ const avail = Math.max(1, width - visibleWidth(lead));
178
+ // 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
179
+ const cursorText = renderCursorText(state.draftText, state.cursorIndex);
180
+ const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
181
+ addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
182
+ } else {
183
+ const hasFreeText = state.freeTextValue !== null;
184
+ const marker = q.multiSelect
185
+ ? (hasFreeText ? t.fg("success", "[✓]") : t.fg("dim", "[ ]"))
186
+ : (hasFreeText ? t.fg("success", "✓") : " ");
187
+ const labelColor = isSelected ? "accent" : "text";
188
+ add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
189
+ if (hasFreeText) {
190
+ // 预览缩进对齐到 label 起始列:prefix + sp + marker + sp + "N." + sp。
191
+ // 随 num 位数与单/多选 marker 宽度动态变化,硬编码会错位。
192
+ const numStr = `${num}.`;
193
+ const lead = " ".repeat(
194
+ visibleWidth(prefix) + 1 + visibleWidth(marker) + 1 + numStr.length + 1,
195
+ );
196
+ const avail = Math.max(1, width - visibleWidth(lead));
197
+ const styled = t.fg("dim", `"${state.freeTextValue ?? ""}"`);
198
+ addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
199
+ }
200
+ }
201
+ }
202
+
203
+ /** 多选普通选项行:勾选框 + 编号 label + 缩进描述(hideDescriptions 时省略描述)。 */
204
+ function appendMultiSelectRow(
205
+ ctx: RenderContext,
206
+ opt: DisplayOption,
207
+ index: number,
208
+ isSelected: boolean,
209
+ prefix: string,
210
+ hideDescriptions: boolean,
211
+ add: (s: string) => void,
212
+ ): void {
213
+ const { state, theme: t, width } = ctx;
214
+ const checked = state.selectedIndices.has(index);
215
+ const box = checked ? t.fg("accent", "[✓]") : t.fg("dim", "[ ]");
216
+ const labelColor = isSelected ? "accent" : "text";
217
+ const num = index + 1;
218
+ add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
219
+ if (opt.description && !hideDescriptions) {
220
+ const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_MULTI);
221
+ for (const line of wrapped) add(` ${line}`);
222
+ }
223
+ }
224
+
225
+ /** 单选普通选项行:✓ 确认标记 + 编号 label + 缩进描述(hideDescriptions 时省略描述)。 */
226
+ function appendSingleSelectRow(
227
+ ctx: RenderContext,
228
+ opt: DisplayOption,
229
+ index: number,
230
+ isSelected: boolean,
231
+ prefix: string,
232
+ hideDescriptions: boolean,
233
+ add: (s: string) => void,
234
+ ): void {
235
+ const { state, theme: t, width } = ctx;
236
+ const isConfirmed = state.selectedIndex === index;
237
+ const check = isConfirmed ? t.fg("success", "✓") : " ";
238
+ const labelColor = isSelected ? "accent" : "text";
239
+ const num = index + 1;
240
+ add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
241
+ if (opt.description && !hideDescriptions) {
242
+ const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_SINGLE);
243
+ for (const line of wrapped) add(` ${line}`);
244
+ }
245
+ }
246
+
206
247
  /** 构建分屏右侧 Markdown 详情预览。 */
207
248
  function buildPreviewLines(
208
249
  ctx: RenderContext,
package/src/types.ts CHANGED
@@ -109,13 +109,12 @@ export const ResultSchema = Type.Object({
109
109
 
110
110
  export type Result = Static<typeof ResultSchema>;
111
111
 
112
- /** execute 意外异常时返回的错误 details(区别于 Result.cancelled 的业务取消) */
113
- export interface ErrorDetails {
114
- error: string;
115
- }
116
-
117
- /** execute 返回的 details 联合:正常/取消/校验失败走 Result,意外异常走 ErrorDetails */
118
- export type AskUserDetails = Result | ErrorDetails;
112
+ /**
113
+ * execute 返回的 details 形状:正常/取消/校验失败都复用 Result。
114
+ * 错误路径已全部改为 throw(W4)——pi 对 throw 生成 isError:true 且 details 为空对象,
115
+ * 不存在「错误 details」形态;曾有的 ErrorDetails 死类型随不可达分支一并删除。
116
+ */
117
+ export type AskUserDetails = Result;
119
118
 
120
119
  // ── 跨模块共享的交互状态类型 ─────────────────────────
121
120
  // 放这里(而非 component.ts)是为了让 question-view.ts / submit-view.ts
package/src/validate.ts CHANGED
@@ -22,48 +22,74 @@ const ERROR_PREVIEW_CHARS = 20;
22
22
  * - 多问题(questions.length > 1)时每个 question 必须有非空 header
23
23
  *
24
24
  * 错误消息面向 LLM:除描述违规外,附带一句修复指引(如何改),对结构误用附 Correct 正例。
25
+ *
26
+ * 主函数只留编排:逐 question 依序跑「文本 → 唯一性 → option labels」,再跑
27
+ * 多问题 header 校验与 header 长度校验。各阶段提取为独立 helper(?? 链按序短路求值,
28
+ * 与原 early-return 顺序一致)。校验顺序即错误优先级,不可调换。
25
29
  */
26
30
  export function validateInput(questions: InputQuestion[]): string | null {
27
31
  const seenQuestions = new Set<string>();
28
32
 
29
33
  for (const q of questions) {
30
34
  const qt = q.question;
35
+ const perQuestionError =
36
+ checkQuestionText(qt) ??
37
+ checkDuplicateQuestion(qt, seenQuestions) ??
38
+ checkOptionLabels(qt, q.options);
39
+ if (perQuestionError) return perQuestionError;
40
+ }
31
41
 
32
- // 1a. question 文本长度上限(key 有界)
33
- if (qt.length > QUESTION_MAX_CHARS) {
34
- return `Question text exceeds ${QUESTION_MAX_CHARS} chars: "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Shorten it to a single concise decision; move extra context into the context field.`;
35
- }
36
- // 1b. question 文本无控制字符(key 可预测,不影响下游渲染/解析)
37
- if (CONTROL_CHAR_RE.test(qt)) {
38
- return `Question text must not contain control characters (incl. newlines): "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Use plain single-line text; split multi-part questions into separate entries.`;
39
- }
42
+ return checkMultiQuestionHeaders(questions) ?? checkHeaderLengths(questions);
43
+ }
40
44
 
41
- // 1c. question 文本唯一
42
- if (seenQuestions.has(qt)) {
43
- return `Duplicate question: "${qt}". Each question text must be unique; merge duplicates or rephrase one to differ.`;
44
- }
45
- seenQuestions.add(qt);
45
+ /** 1a. question 文本长度上限(key 有界);1b. 无控制字符(key 可预测,不影响下游渲染/解析)。 */
46
+ function checkQuestionText(qt: string): string | null {
47
+ if (qt.length > QUESTION_MAX_CHARS) {
48
+ return `Question text exceeds ${QUESTION_MAX_CHARS} chars: "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Shorten it to a single concise decision; move extra context into the context field.`;
49
+ }
50
+ if (CONTROL_CHAR_RE.test(qt)) {
51
+ return `Question text must not contain control characters (incl. newlines): "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Use plain single-line text; split multi-part questions into separate entries.`;
52
+ }
53
+ return null;
54
+ }
46
55
 
47
- // 2. option 元素必须是 {label, description} 对象,不能是 string。
48
- // 弱模型最高频误用:"options":["A","B"]。schema 层已放宽让 string 进来,这里友好拦截
49
- // (InputQuestion.options (Option | string)[],typeof 收窄后 opt 为 Option)。
50
- const seenLabels = new Set<string>();
51
- for (const opt of q.options) {
52
- if (typeof opt === "string") {
53
- return `Options for question "${qt}" must be an array of {label, description} objects, not strings. Correct: "options":[{"label":"A","description":"..."},{"label":"B","description":"..."}]`;
54
- }
55
- // opt 已收窄为 Option
56
- if (opt.label.trim() === "") {
57
- return `Option label must not be empty in question "${qt}". Give every option a distinct, descriptive label.`;
58
- }
59
- if (seenLabels.has(opt.label)) {
60
- return `Duplicate option label "${opt.label}" in question "${qt}". Options must be mutually exclusive — reword one so each label maps to a distinct choice.`;
61
- }
62
- seenLabels.add(opt.label);
56
+ /** 1c. question 文本唯一。未重复时登记到 seenQuestions(调用方依赖此副作用)。 */
57
+ function checkDuplicateQuestion(qt: string, seenQuestions: Set<string>): string | null {
58
+ if (seenQuestions.has(qt)) {
59
+ return `Duplicate question: "${qt}". Each question text must be unique; merge duplicates or rephrase one to differ.`;
60
+ }
61
+ seenQuestions.add(qt);
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * 2. option 元素必须是 {label, description} 对象,不能是 string。
67
+ * 弱模型最高频误用:"options":["A","B"]。schema 层已放宽让 string 进来,这里友好拦截
68
+ * (InputQuestion.options 是 (Option | string)[],typeof 收窄后 opt 为 Option)。
69
+ */
70
+ function checkOptionLabels(qt: string, options: InputQuestion["options"]): string | null {
71
+ const seenLabels = new Set<string>();
72
+ for (const opt of options) {
73
+ if (typeof opt === "string") {
74
+ return `Options for question "${qt}" must be an array of {label, description} objects, not strings. Correct: "options":[{"label":"A","description":"..."},{"label":"B","description":"..."}]`;
63
75
  }
76
+ // opt 已收窄为 Option
77
+ if (opt.label.trim() === "") {
78
+ return `Option label must not be empty in question "${qt}". Give every option a distinct, descriptive label.`;
79
+ }
80
+ if (seenLabels.has(opt.label)) {
81
+ return `Duplicate option label "${opt.label}" in question "${qt}". Options must be mutually exclusive — reword one so each label maps to a distinct choice.`;
82
+ }
83
+ seenLabels.add(opt.label);
64
84
  }
85
+ return null;
86
+ }
65
87
 
66
- // 3. 多问题时 header 必填且非空
88
+ /**
89
+ * 3. 多问题时 header 必填且非空;S3: header 唯一——重复 header 会导致 askUserKey 碰撞,
90
+ * 后一个 question 的 __other 覆盖前一个(协议 helper 用 header 作 answers 读取 key)。
91
+ */
92
+ function checkMultiQuestionHeaders(questions: InputQuestion[]): string | null {
67
93
  if (questions.length > 1) {
68
94
  for (const q of questions) {
69
95
  if (!q.header || q.header.trim() === "") {
@@ -71,8 +97,6 @@ export function validateInput(questions: InputQuestion[]): string | null {
71
97
  }
72
98
  }
73
99
 
74
- // S3: 多问题时 header 唯一——重复 header 会导致 askUserKey 碰撞,
75
- // 后一个 question 的 __other 覆盖前一个(协议 helper 用 header 作 answers 读取 key)。
76
100
  const seenHeaders = new Set<string>();
77
101
  for (const q of questions) {
78
102
  const h = q.header!.trim();
@@ -82,14 +106,18 @@ export function validateInput(questions: InputQuestion[]): string | null {
82
106
  seenHeaders.add(h);
83
107
  }
84
108
  }
109
+ return null;
110
+ }
85
111
 
86
- // 4. header 长度上限(若提供)。单/多问题均校验:超出会在 tab 栏被静默截断,
87
- // 这里提前拒绝,让 LLM 拿到可修复错误而非残缺 UI(兑现 schema description 的 ≤12 契约)。
112
+ /**
113
+ * 4. header 长度上限(若提供)。单/多问题均校验:超出会在 tab 栏被静默截断,
114
+ * 这里提前拒绝,让 LLM 拿到可修复错误而非残缺 UI(兑现 schema description 的 ≤12 契约)。
115
+ */
116
+ function checkHeaderLengths(questions: InputQuestion[]): string | null {
88
117
  for (const q of questions) {
89
118
  if (q.header !== undefined && q.header.length > HEADER_MAX_CHARS) {
90
119
  return `Header exceeds ${HEADER_MAX_CHARS} chars: "${q.header.slice(0, ERROR_PREVIEW_CHARS)}..." in question "${q.question}". Shorten it; longer headers are truncated in the tab bar.`;
91
120
  }
92
121
  }
93
-
94
122
  return null;
95
123
  }