@zhushanwen/pi-ask-user 0.0.4 → 0.2.0
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 +171 -0
- package/README.md +135 -9
- package/package.json +6 -2
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/component-keymap.test.ts +522 -0
- package/src/__tests__/component.test.ts +50 -12
- package/src/__tests__/e2e-harness.ts +1 -0
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +329 -25
- package/src/__tests__/question-view.test.ts +71 -53
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +19 -4
- package/src/__tests__/w2-draft-hint.test.ts +157 -0
- package/src/__tests__/w3-regression.test.ts +128 -0
- package/src/answer-format.ts +51 -0
- package/src/component.ts +132 -129
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +198 -72
- package/src/question-view.ts +81 -65
- package/src/submit-view.ts +19 -11
- package/src/types.ts +24 -2
- package/src/validate.ts +20 -1
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# ask-user — Architecture
|
|
2
|
+
|
|
3
|
+
Internals reference for maintainers. For the usage contract (what the tool does, when an agent should call it), see [README.md](./README.md). This document covers how the code is structured, the state machine, the defensive execute flow, and where each design invariant is enforced — so a change does not silently break an invariant.
|
|
4
|
+
|
|
5
|
+
Source: 6 files in `src/`, ~1320 lines total.
|
|
6
|
+
|
|
7
|
+
## File dependency graph
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
┌─────────────┐
|
|
11
|
+
│ types.ts │ ← shared leaf; imports only typebox
|
|
12
|
+
│ Schema + │ holds QuestionState / ThemeLike /
|
|
13
|
+
│ shared │ createQuestionState here (NOT in
|
|
14
|
+
│ state types │ component.ts) to break the cycle
|
|
15
|
+
└──────▲──────┘
|
|
16
|
+
│ imported by all
|
|
17
|
+
┌──────────────────┼──────────────────┐
|
|
18
|
+
│ │ │
|
|
19
|
+
┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐
|
|
20
|
+
│ validate.ts │ │question-view│ │ submit-view │
|
|
21
|
+
│ pure check │ │ pure render │ │ pure render │
|
|
22
|
+
└──────▲──────┘ └──────▲──────┘ └──────▲──────┘
|
|
23
|
+
│ │ │
|
|
24
|
+
│ ┌──────┴──────────────────┘
|
|
25
|
+
│ │
|
|
26
|
+
┌──────┴───────────┴──┐
|
|
27
|
+
│ component.ts │ ← state machine + input routing + race guards
|
|
28
|
+
│ │ imports question-view + submit-view
|
|
29
|
+
└──────────▲──────────┘
|
|
30
|
+
│
|
|
31
|
+
┌──────┴──────┐
|
|
32
|
+
│ index.ts │ ← Tool factory + execute (6-step flow) + renderCall/renderResult
|
|
33
|
+
└─────────────┘ imports component + validate + types
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**No cycles.** All imports flow one direction; `types.ts` is the single leaf depended on by everyone.
|
|
37
|
+
|
|
38
|
+
**Why `QuestionState` / `ThemeLike` live in `types.ts`, not `component.ts`** (see the comment in `types.ts`): `question-view.ts` and `submit-view.ts` are pure render functions that read/write `QuestionState` and need the `ThemeLike` interface. If those types lived in `component.ts`, the render views would import `component.ts`, and `component.ts` imports the render views — a cycle. Sinking the shared types to the dependency-free leaf keeps every arrow monotone. **Do not move these types back** without reintroducing the cycle.
|
|
39
|
+
|
|
40
|
+
## `execute` defensive flow (6 steps)
|
|
41
|
+
|
|
42
|
+
`execute` in `src/index.ts` runs six ordered checks. Order is not arbitrary — each early step is cheaper than the next and some have side effects that must precede the rest.
|
|
43
|
+
|
|
44
|
+
| Step | Check | Returns | Why this order |
|
|
45
|
+
|------|-------|---------|----------------|
|
|
46
|
+
| 1 | `validateInput(questions)` | `isError:true` + fix hint | Pure function, no side effects — cheapest gate. Reject before any UI/state work. |
|
|
47
|
+
| 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. |
|
|
48
|
+
| 3 | `signal?.aborted` | `cancelled:true` | O(1) short-circuit before the expensive blocking `ctx.ui.custom` call. |
|
|
49
|
+
| 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. |
|
|
50
|
+
| 5 | `result === null \|\| result.cancelled` | `cancelled:true` | Component resolved to cancel. |
|
|
51
|
+
| 6 | normal | `{ answers }` | Compose the summary. |
|
|
52
|
+
|
|
53
|
+
**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".
|
|
54
|
+
|
|
55
|
+
## `QuestionState` machine
|
|
56
|
+
|
|
57
|
+
Each question has a `QuestionState` (`types.ts`). Its `mode` field is a three-state machine:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
Enter (on Other row)
|
|
61
|
+
┌────────────────────────────────────┐
|
|
62
|
+
▼ │
|
|
63
|
+
┌─────────────┐ Enter (normal opt, ┌──────────────┐
|
|
64
|
+
│ options │ allowComment=true) ────────▶│ comment │
|
|
65
|
+
│ (default) │ │ (note input) │
|
|
66
|
+
└─────┬───▲───┘◀────────────────── afterConfirm└──────┬───▲───┘
|
|
67
|
+
│ │ Enter │ │ Esc
|
|
68
|
+
Enter │ │ Esc (discard) (save note) │ │ (AC-17: skip,
|
|
69
|
+
(Other) │ │ │ │ keep old value)
|
|
70
|
+
▼ │ ▼ │
|
|
71
|
+
┌─────────────┐ Enter (text → save) ┌─────────────┐
|
|
72
|
+
│ freeform │───────────────────────────────▶│ options │
|
|
73
|
+
│ (Other edit)│ │ (back to list)│
|
|
74
|
+
└─────┬───▲───┘ └─────────────┘
|
|
75
|
+
│ │
|
|
76
|
+
▼ │ Esc (discard)
|
|
77
|
+
Enter (empty → clear freeTextValue)
|
|
78
|
+
│
|
|
79
|
+
▼
|
|
80
|
+
options
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Transitions live in `component.ts`: `options → freeform` (Enter on Other), `freeform → options` (Enter saves / Enter empty clears / Esc discards), `options → comment` (via `afterConfirm` when `allowComment`), `comment → options` (Enter saves / Esc skips per AC-17).
|
|
84
|
+
|
|
85
|
+
### `confirmed` invariant
|
|
86
|
+
|
|
87
|
+
> **`confirmed === true` ⟹ the question has at least one answer.**
|
|
88
|
+
> I.e. `multiSelect ? selectedIndices.size > 0 : selectedIndex !== null`, **or** `freeTextValue !== null`.
|
|
89
|
+
|
|
90
|
+
This invariant is what makes the Submit gate (`allConfirmed()`) sound — if it ever fails, Submit lets through a question whose answer is missing, and the LLM receives `(no answer)`.
|
|
91
|
+
|
|
92
|
+
Four assignment sites maintain it (`component.ts`):
|
|
93
|
+
|
|
94
|
+
| Site | Sets | Why safe / necessary |
|
|
95
|
+
|------|------|----------------------|
|
|
96
|
+
| `afterConfirm()` | `true` | Safe: caller has already set `selectedIndex` / `selectedIndices` / `freeTextValue`. |
|
|
97
|
+
| `autoConfirmIfAnswered()` | `true` | Safe: guarded by `if (hasAnswer)` — never sets `true` without an answer. |
|
|
98
|
+
| `toggleIndex()` when multi-select empties | `false` | Necessary: un-checking the last option must drop `confirmed` to preserve the contrapositive. |
|
|
99
|
+
| `handleEditorInput` freeform empty-Enter | `false` | Necessary: clearing `freeTextValue` with no other answer must drop `confirmed`. |
|
|
100
|
+
|
|
101
|
+
If you add a new path that changes the answer set, audit both directions of this invariant.
|
|
102
|
+
|
|
103
|
+
### `autoConfirmIfAnswered` trigger
|
|
104
|
+
|
|
105
|
+
Called only from `gotoTab()` — when the user navigates between tabs via Tab/Shift+Tab without pressing Enter. It promotes an implicitly-answered tab (toggled but not confirmed) to `confirmed`. It deliberately **skips the comment input** (a Tab navigation intent should not force a comment prompt); only the Enter path enters comment mode via `afterConfirm`.
|
|
106
|
+
|
|
107
|
+
## Race guards
|
|
108
|
+
|
|
109
|
+
Three independent guards protect against three different races. They are dimensionally orthogonal but easy to confuse — keep them distinct.
|
|
110
|
+
|
|
111
|
+
| Guard | Kind | Location | Prevents | Mechanism |
|
|
112
|
+
|-------|------|----------|----------|-----------|
|
|
113
|
+
| `_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). |
|
|
114
|
+
| `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). |
|
|
115
|
+
| `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`. |
|
|
116
|
+
|
|
117
|
+
## Three-layer rendering
|
|
118
|
+
|
|
119
|
+
Three independent render paths with non-overlapping jobs. Changing one layer never affects the others (unless you change the `details` schema, which feeds `renderResult`).
|
|
120
|
+
|
|
121
|
+
| Layer | When | Location | Job | Returns |
|
|
122
|
+
|-------|------|----------|-----|---------|
|
|
123
|
+
| `renderCall` | tool invoked, while `execute` is running (during interaction) | `index.ts` | Compact title: `ask_user <headers>` — tells the user what the agent is asking. | one `TruncatedText` |
|
|
124
|
+
| inline render (execute) | after `ctx.ui.custom` returns the component, the runtime loops `comp.render(width)` | factory in `index.ts`, render in `component.ts` | The live interactive TUI: option list / editor / tab bar / button bar / cancel overlay. | `string[]` (one per line) |
|
|
125
|
+
| `renderResult` | after `execute` returns | `index.ts` | Final result display. Compact: `✓ header: answer`; when `options.expanded`: all options with `●`/`○` selection marks. | `Box` of `TruncatedText`s |
|
|
126
|
+
|
|
127
|
+
## Split-pane adaptive layout
|
|
128
|
+
|
|
129
|
+
`getSplitPaneWidths(width)` in `question-view.ts` is a pure function with three-level degradation:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
width < 84 → null (single column)
|
|
133
|
+
available = width - len(" │ ") // separator overhead
|
|
134
|
+
available < 32 + 28 (= 60) → null (too narrow)
|
|
135
|
+
preferredLeft = floor(available * 0.42)
|
|
136
|
+
left = clamp(preferredLeft, 32, available - 28)
|
|
137
|
+
right = available - left
|
|
138
|
+
right < 28 → null (fallback)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
So the **left column (option list) takes 42%, the right column (option detail) takes 58%**, with floors of 32 and 28 respectively, and a total split threshold of 84 columns. `buildSplitPane` pads the shorter column to `max(leftLines, rightLines, 8)` so the two stay aligned row-for-row.
|
|
142
|
+
|
|
143
|
+
Constants: `SPLIT_PANE_MIN_WIDTH = 84`, `SPLIT_PANE_LEFT_MIN = 32`, `SPLIT_PANE_RIGHT_MIN = 28`, `SPLIT_PANE_SEPARATOR = " │ "` (all in `types.ts`).
|
|
144
|
+
|
|
145
|
+
## Spec cross-reference
|
|
146
|
+
|
|
147
|
+
Design spec: `.xyz-harness/2026-06-15-ask-user/spec.md` (FR = functional requirement, AC = acceptance criterion). Implementation anchors:
|
|
148
|
+
|
|
149
|
+
| Spec | Implemented in |
|
|
150
|
+
|------|----------------|
|
|
151
|
+
| FR-2 (param schema/validation) | `types.ts` schema + `validate.ts` |
|
|
152
|
+
| FR-3 (inline render, no overlay) | `execute` → `ctx.ui.custom` without `options` |
|
|
153
|
+
| FR-4 (question view) | `question-view.ts` `renderQuestionView` |
|
|
154
|
+
| FR-6 (input handling) | `component.ts` `handleInput` / `handleEditorInput` |
|
|
155
|
+
| FR-8 (headless disable) | `execute` step 2 |
|
|
156
|
+
| FR-9 (custom render) | `renderCall` / `renderResult` |
|
|
157
|
+
| FR-10 (signal abort) | `execute` step 3 + step 4 abort listener |
|
|
158
|
+
| FR-12 (re-entry guard) | `_resolved` field + `cancel()` shared by abort listener |
|
|
159
|
+
| FR-13 (error catch-all) | `execute` step 4 try/catch |
|
|
160
|
+
| AC-17 (Esc in comment skips, keeps value) | `handleEditorInput` comment-mode Esc branch |
|
|
161
|
+
|
|
162
|
+
When you change one of these behaviors, update both the code comment (which cites the FR/AC) and this table.
|
|
163
|
+
|
|
164
|
+
## Resolved gaps
|
|
165
|
+
|
|
166
|
+
Previously surfaced by review, now fixed (kept as a maintenance trail):
|
|
167
|
+
|
|
168
|
+
- **Abort-vs-cancel text collision** (FR-10) — resolved: `execute` step 3 now returns `"Agent aborted..."`, distinct from step 5's user-cancel text. — `src/index.ts`.
|
|
169
|
+
- **`question` length limit not in schema** — resolved: the `QuestionSchema` description now states "≤1000 chars". — `src/types.ts`.
|
|
170
|
+
- **`header` >12 chars silently truncated** — resolved: `validate.ts` now rejects headers over `HEADER_MAX_CHARS` instead of silently truncating in the UI. — `src/validate.ts`.
|
|
171
|
+
- **`cancel()` re-entry race** (FR-12) — resolved: `cancel()` now guards with `_resolved`; a signal abort firing after submit/cancel no longer calls `done` twice. — `src/component.ts`.
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @zhushanwen/pi-ask-user
|
|
2
2
|
|
|
3
|
-
Inline adaptive `ask_user` tool for Pi coding agent.
|
|
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.
|
|
4
|
+
|
|
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).
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -8,15 +10,139 @@ Inline adaptive `ask_user` tool for Pi coding agent. Single question (no tabs) o
|
|
|
8
10
|
pi install npm:@zhushanwen/pi-ask-user
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
> **Dev-only symlink**: during local development you may symlink this package into `~/.pi/agent/extensions/` for debugging, but **never use the symlinked copy for daily work**. Local directory discovery has an `index.ts` fallback that masks a missing `pi` manifest field — npm-installed copies then silently fail to load. See the repo root CLAUDE.md "扩展安装红线".
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
Call `ask_user` **only when all three hold**:
|
|
18
|
+
|
|
19
|
+
1. The request has ≥2 reasonable approaches.
|
|
20
|
+
2. You have already gathered context (read/grep) and the answer is still genuinely ambiguous.
|
|
21
|
+
3. Picking wrong means redoing real work.
|
|
22
|
+
|
|
23
|
+
If you can form a defensible recommendation from the codebase, **proceed and state your choice** — do not ask. Models over-ask because asking feels safer than deciding; resist this.
|
|
24
|
+
|
|
25
|
+
## When NOT to use
|
|
26
|
+
|
|
27
|
+
- **Trivia answerable by reading code/docs** — plain text suffices.
|
|
28
|
+
- **Simple confirmations** ("I'll delete X") — plain text suffices.
|
|
29
|
+
- **Outsourcing judgment you should make** — if context makes the answer clear, decide.
|
|
30
|
+
- **Free-form requirements / long-form feedback** — this tool returns short selections only.
|
|
31
|
+
- **High-frequency grilling** — do not chain `ask_user` calls as a default fallback when stuck. If you have no context to pass, you are not ready to ask — read code first.
|
|
32
|
+
- **Reversible decisions** — if a wrong guess is cheap to roll back, just decide.
|
|
33
|
+
|
|
34
|
+
If you recommend an option, prefix its label with `(Recommended)` and list it first.
|
|
35
|
+
|
|
36
|
+
## Parameters
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
{
|
|
40
|
+
questions: Array<{
|
|
41
|
+
question: string; // one self-contained decision; ≤1000 chars; no control chars (incl. \n)
|
|
42
|
+
header?: string; // tab label ≤12 chars; REQUIRED (non-empty) when questions.length > 1
|
|
43
|
+
context?: string; // 1-3 sentences of what you learned; shown above the question
|
|
44
|
+
options: Array<{ // 2-4 mutually exclusive options; do NOT add an 'Other' — it is automatic
|
|
45
|
+
label: string; // ≤ ~40 chars (longer overflows the split-pane UI); also the answer value
|
|
46
|
+
description?: string; // short rationale shown under the label and in the preview pane
|
|
47
|
+
}>;
|
|
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
|
+
}>
|
|
51
|
+
} // questions: 1-4 entries
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Constraints at a glance**
|
|
55
|
+
|
|
56
|
+
| Field | Constraint | Enforced by |
|
|
57
|
+
|-------|-----------|-------------|
|
|
58
|
+
| `questions` | 1-4 entries | schema (`minItems`/`maxItems`) |
|
|
59
|
+
| `options` | 2-4 entries | schema |
|
|
60
|
+
| `question` | ≤1000 chars, no control chars (incl. `\n`), unique within the call | schema description + `validate.ts` |
|
|
61
|
+
| `header` | ≤12 chars; required when `questions.length > 1` | `validate.ts` (length + non-empty) |
|
|
62
|
+
| `options[].label` | non-empty, unique within the question | `validate.ts` |
|
|
63
|
+
|
|
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.
|
|
65
|
+
|
|
66
|
+
## Result format
|
|
67
|
+
|
|
68
|
+
On success the tool returns the answers joined as `"question" = "answer"` lines. Answer composition rules:
|
|
69
|
+
|
|
70
|
+
- **Single-select**: the chosen `label`.
|
|
71
|
+
- **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`).
|
|
74
|
+
|
|
75
|
+
A question with no answer reports as `(no answer)`.
|
|
76
|
+
|
|
77
|
+
## Behavior on failure / cancellation
|
|
12
78
|
|
|
13
|
-
|
|
79
|
+
| Situation | Return | What the agent should do |
|
|
80
|
+
|-----------|--------|--------------------------|
|
|
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** |
|
|
83
|
+
| 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
|
+
| 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 |
|
|
86
|
+
|
|
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.
|
|
14
88
|
|
|
15
89
|
## Features
|
|
16
90
|
|
|
17
|
-
- **Adaptive layout**: single question → no tab bar; 1-4 questions → tabbed view + Submit tab
|
|
18
|
-
- **Split-pane preview** (≥84 cols): option list left, selected option
|
|
19
|
-
- **Inline free-text editor**: select "Other" → Enter → type custom answer
|
|
20
|
-
- **Optional comments**: `allowComment: true` → after
|
|
21
|
-
- **Multi-select**: `multiSelect: true` → toggle checkboxes, Enter to confirm
|
|
22
|
-
- **
|
|
91
|
+
- **Adaptive layout**: single question → no tab bar; 1-4 questions → tabbed view + Submit tab.
|
|
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.
|
|
95
|
+
- **Multi-select**: `multiSelect: true` → toggle checkboxes with Space, Enter to confirm.
|
|
96
|
+
- **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.
|
|
98
|
+
|
|
99
|
+
## File structure
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
extensions/ask-user/
|
|
103
|
+
├── index.ts # re-export entry (Pi loads via package.json pi.extensions)
|
|
104
|
+
├── package.json
|
|
105
|
+
├── README.md # this file — usage contract for LLM callers + overview
|
|
106
|
+
├── ARCHITECTURE.md # internals: dependency graph, state machine, defensive flow
|
|
107
|
+
├── vitest.config.ts
|
|
108
|
+
└── src/
|
|
109
|
+
├── index.ts # Tool factory: registerTool + execute (6-step defensive flow) + renderCall/renderResult
|
|
110
|
+
├── types.ts # Input schema, Result schema, shared state types (QuestionState/ThemeLike) — dependency leaf
|
|
111
|
+
├── validate.ts # pure parameter validation; error messages aimed at LLM fixability
|
|
112
|
+
├── component.ts # AskUserComponent: state machine, input routing, race guards
|
|
113
|
+
├── question-view.ts # pure render: option list, split-pane, inline editor
|
|
114
|
+
└── submit-view.ts # pure render: Submit tab, answer summary, buildResult
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`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.
|
|
118
|
+
|
|
119
|
+
## Steer mechanism
|
|
120
|
+
|
|
121
|
+
The tool registers three steering channels to discourage over-asking:
|
|
122
|
+
|
|
123
|
+
- **`description`** — the long tool description shown in the agent's tool catalog (the three preconditions + negative cases).
|
|
124
|
+
- **`promptSnippet`** — one-line summary injected into the system prompt.
|
|
125
|
+
- **`promptGuidelines`** — six focused rules reinforcing: gather context first, one decision per question, no trivia, don't outsource judgment, don't add an `Other` option.
|
|
126
|
+
|
|
127
|
+
All three are consistent and point the same direction. If you tune behavior, edit all three together in `src/index.ts` to avoid drift.
|
|
128
|
+
|
|
129
|
+
## Design notes
|
|
130
|
+
|
|
131
|
+
- **Why inline, not overlay** (`execute` → `ctx.ui.custom` without `options`): the question belongs in the conversation flow, not a modal that obscures context.
|
|
132
|
+
- **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.
|
|
134
|
+
- **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
|
+
|
|
136
|
+
## Spec reference
|
|
137
|
+
|
|
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.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview, inline editor, and optional comments.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -22,8 +22,12 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"index.ts",
|
|
24
24
|
"src/",
|
|
25
|
-
"README.md"
|
|
25
|
+
"README.md",
|
|
26
|
+
"ARCHITECTURE.md"
|
|
26
27
|
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@xyz-agent/extension-protocol": "^0.2.0"
|
|
30
|
+
},
|
|
27
31
|
"devDependencies": {
|
|
28
32
|
"@earendil-works/pi-tui": "*",
|
|
29
33
|
"@sinclair/typebox": "*",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/__tests__/answer-format.test.ts
|
|
2
|
+
//
|
|
3
|
+
// answer-format.ts 独立单元测试。
|
|
4
|
+
// 覆盖审查 S5 发现的覆盖盲区:
|
|
5
|
+
// - parseAnswerParts 子串误匹配("A" 不应命中 "AB")
|
|
6
|
+
// - formatAnswer 空 parts → null
|
|
7
|
+
// - comment 分隔符边界
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { formatAnswer, parseAnswerParts } from "../answer-format.js";
|
|
12
|
+
import { ANSWER_COMMENT_SEPARATOR } from "../types.js";
|
|
13
|
+
|
|
14
|
+
describe("formatAnswer", () => {
|
|
15
|
+
it("returns null for empty parts (unanswered)", () => {
|
|
16
|
+
expect(formatAnswer([])).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns null for empty parts even with comment", () => {
|
|
20
|
+
// parts 空 = 没有选中选项,即使有 comment 也不应产出有效答案行
|
|
21
|
+
expect(formatAnswer([], "some comment")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("joins single part without separator", () => {
|
|
25
|
+
expect(formatAnswer(["yes"])).toBe("yes");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("joins multiple parts with ', '", () => {
|
|
29
|
+
expect(formatAnswer(["A", "B", "C"])).toBe("A, B, C");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("appends comment with ANSWER_COMMENT_SEPARATOR", () => {
|
|
33
|
+
const result = formatAnswer(["A", "B"], "my comment");
|
|
34
|
+
expect(result).toBe(`A, B${ANSWER_COMMENT_SEPARATOR}my comment`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("handles null comment (no separator appended)", () => {
|
|
38
|
+
expect(formatAnswer(["A"], null)).toBe("A");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("parseAnswerParts", () => {
|
|
43
|
+
it("extracts selected labels by exact match", () => {
|
|
44
|
+
const labels = ["yes", "no", "maybe"];
|
|
45
|
+
const result = parseAnswerParts("yes, no", labels);
|
|
46
|
+
expect(result.selected).toEqual(["yes", "no"]);
|
|
47
|
+
expect(result.comment).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// S5 核心:防子串误匹配——"A" 不应命中 label "AB"
|
|
51
|
+
it("does NOT match substring labels (A vs AB)", () => {
|
|
52
|
+
const labels = ["A", "AB", "ABC"];
|
|
53
|
+
// 答案 "A, AB" 应精确匹配两个 label,而非 "A" 匹配三次
|
|
54
|
+
const result = parseAnswerParts("A, AB", labels);
|
|
55
|
+
expect(result.selected).toEqual(["A", "AB"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does NOT match 'A' when only 'AB' is in answer", () => {
|
|
59
|
+
const labels = ["A", "AB"];
|
|
60
|
+
// 答案 "AB" 只应命中 "AB",不应命中 "A"
|
|
61
|
+
const result = parseAnswerParts("AB", labels);
|
|
62
|
+
expect(result.selected).toEqual(["AB"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("preserves order of appearance in answer (not label order)", () => {
|
|
66
|
+
const labels = ["A", "B", "C"];
|
|
67
|
+
// 用户选择顺序可能与 options 定义顺序不同
|
|
68
|
+
const result = parseAnswerParts("C, A", labels);
|
|
69
|
+
expect(result.selected).toEqual(["C", "A"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("extracts comment after ANSWER_COMMENT_SEPARATOR", () => {
|
|
73
|
+
const labels = ["yes"];
|
|
74
|
+
const answer = `yes${ANSWER_COMMENT_SEPARATOR}because reasons`;
|
|
75
|
+
const result = parseAnswerParts(answer, labels);
|
|
76
|
+
expect(result.selected).toEqual(["yes"]);
|
|
77
|
+
expect(result.comment).toBe("because reasons");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles full-width comma (,) as separator", () => {
|
|
81
|
+
const labels = ["A", "B"];
|
|
82
|
+
const result = parseAnswerParts("A,B", labels);
|
|
83
|
+
expect(result.selected).toEqual(["A", "B"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns non-matching tokens as neither selected nor comment (Other free text)", () => {
|
|
87
|
+
const labels = ["yes", "no"];
|
|
88
|
+
// "custom text" 不匹配任何 label → 是 Other 自由文本
|
|
89
|
+
const result = parseAnswerParts("custom text", labels);
|
|
90
|
+
expect(result.selected).toEqual([]);
|
|
91
|
+
expect(result.comment).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("handles empty answer string", () => {
|
|
95
|
+
const result = parseAnswerParts("", ["A", "B"]);
|
|
96
|
+
expect(result.selected).toEqual([]);
|
|
97
|
+
expect(result.comment).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("handles answer with only comment (no selected labels)", () => {
|
|
101
|
+
const labels = ["A"];
|
|
102
|
+
const answer = `${ANSWER_COMMENT_SEPARATOR}just a comment`;
|
|
103
|
+
const result = parseAnswerParts(answer, labels);
|
|
104
|
+
expect(result.selected).toEqual([]);
|
|
105
|
+
expect(result.comment).toBe("just a comment");
|
|
106
|
+
});
|
|
107
|
+
});
|