@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 +7 -7
- package/README.md +17 -20
- package/package.json +4 -4
- package/src/__tests__/component.test.ts +32 -0
- package/src/__tests__/e2e.test.ts +1 -1
- package/src/__tests__/index.test.ts +2 -11
- package/src/channel-handler.ts +9 -8
- package/src/channel-registry-register.ts +15 -13
- package/src/component.ts +54 -25
- package/src/editor-ops.ts +0 -2
- package/src/index.ts +7 -8
- package/src/question-view.ts +94 -53
- package/src/types.ts +6 -7
- package/src/validate.ts +63 -35
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 |
|
|
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` | `
|
|
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 —
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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) | `
|
|
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 | `
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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.
|
|
4
|
-
"description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview
|
|
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.
|
|
33
|
-
"@zhushanwen/pi-extension-logger": "0.
|
|
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
|
-
//
|
|
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
|
-
|
|
457
|
-
|
|
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", () => {
|
package/src/channel-handler.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
31
|
-
*((req: unknown) => Promise<unknown>)。本文件不静态 import
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// - subagent-workflow session_start 时往 slot 写
|
|
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-
|
|
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
|
-
* ⚠️ 必须与
|
|
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-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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-
|
|
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-
|
|
60
|
+
* - `registry`:canonical 实例,**仅 packages/subagent-core 创建**;缺失表示 registry 未就绪,
|
|
59
61
|
* ask-user 把 handler 入 pending 队列等待 flush
|
|
60
|
-
* - `pending`:未消费的注册请求(registry 就绪后由 subagent-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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-
|
|
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
|
|
221
|
-
// 3. slot.registry 未就绪 → handler 入 pending,等 subagent-
|
|
222
|
-
// ask-user 永不创建 registry 实例——canonical registry 仅 subagent-
|
|
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
|
-
|
|
355
|
-
|
|
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);
|
package/src/question-view.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
}
|