@hank-warren/pi-ask-user-question 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/LICENSE +21 -0
- package/README.md +68 -0
- package/ask-user-question.ts +122 -0
- package/events.ts +49 -0
- package/index.ts +30 -0
- package/package.json +55 -0
- package/questionnaire.ts +111 -0
- package/reconcile.ts +47 -0
- package/tool/envelope.ts +59 -0
- package/tool/schema.ts +102 -0
- package/tool/validate.ts +110 -0
- package/view/dialog.ts +176 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hank Warren
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @hank-warren/pi-ask-user-question
|
|
2
|
+
|
|
3
|
+
A structured questionnaire the model can put to you when it would otherwise
|
|
4
|
+
guess. Instead of a free-form "which do you prefer?" in chat, you get a dialog
|
|
5
|
+
with numbered options, digit hotkeys, a typed-answer escape, and Tab-to-comment.
|
|
6
|
+
|
|
7
|
+
> **v0.1** ships one question per call, single-select, no preview pane. See
|
|
8
|
+
> [the spec](../../docs/specs/pi-ask-user-question.md) §14 for the road to v0.2.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pi install npm:@hank-warren/pi-ask-user-question
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
It must not be installed alongside `@juicesharp/rpiv-ask-user-question` — both
|
|
17
|
+
register a tool named `ask_user_question`.
|
|
18
|
+
|
|
19
|
+
## Keys
|
|
20
|
+
|
|
21
|
+
| Key | Action |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `1`–`9` | Select that option immediately |
|
|
24
|
+
| `↑` / `↓` | Move the highlight |
|
|
25
|
+
| `Enter` | Confirm the highlighted option |
|
|
26
|
+
| `Tab` | Attach a note to your choice, then `Enter` to send both |
|
|
27
|
+
| `Esc` | Decline the question (or leave note/typed-answer mode) |
|
|
28
|
+
|
|
29
|
+
Every question gets an appended **`Type something.`** row for a free-text
|
|
30
|
+
answer. The model is not allowed to author that row itself — reserved labels
|
|
31
|
+
are rejected at runtime.
|
|
32
|
+
|
|
33
|
+
## No monkey patching
|
|
34
|
+
|
|
35
|
+
Numbered options and Tab-to-comment come from `OptionSelector`, imported from
|
|
36
|
+
[`@hank-warren/pi-permission-selector`](../pi-permission-selector) and rendered
|
|
37
|
+
through `ctx.ui.custom()`. pi exposes no `setSelectorComponent` hook, so the
|
|
38
|
+
only alternative would be patching pi's internal `ExtensionSelectorComponent` —
|
|
39
|
+
which this package deliberately avoids. The trade-off: consistent behavior
|
|
40
|
+
across the dialogs we own, and nothing to break when pi changes its internals.
|
|
41
|
+
|
|
42
|
+
## Subagents cannot use this tool
|
|
43
|
+
|
|
44
|
+
Whenever `ctx.hasUI` is false — which is every headless subagent child — the
|
|
45
|
+
tool is removed from the active tool set before the agent starts. A background
|
|
46
|
+
run can therefore never block waiting on a human, and a child can never route a
|
|
47
|
+
question up to its supervisor. This is intentional, not a limitation; see
|
|
48
|
+
[`reconcile.ts`](./reconcile.ts).
|
|
49
|
+
|
|
50
|
+
## Events
|
|
51
|
+
|
|
52
|
+
Other extensions can observe the questionnaire without touching this one:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
pi.events.on("hank:ask-user:blocked", ({ active }) => {
|
|
56
|
+
// active === true while a human is being asked
|
|
57
|
+
});
|
|
58
|
+
pi.events.on("hank:ask-user:prompt", ({ questions }) => {
|
|
59
|
+
// questions[].question / .header / .options[].label
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Channel names are immutable and payloads are append-only — see
|
|
64
|
+
[`events.ts`](./events.ts) for the full stability policy.
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registration and execution for `ask_user_question`.
|
|
3
|
+
*
|
|
4
|
+
* The tool name is preserved verbatim from
|
|
5
|
+
* @juicesharp/rpiv-ask-user-question so session history recorded against that
|
|
6
|
+
* package replays cleanly. Only one of the two may be installed at a time.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
ASK_USER_BLOCKED_EVENT,
|
|
12
|
+
ASK_USER_PROMPT_EVENT,
|
|
13
|
+
type AskUserBlockedEventPayload,
|
|
14
|
+
type AskUserPromptEventPayload,
|
|
15
|
+
} from "./events.ts";
|
|
16
|
+
import { QuestionnaireSession } from "./questionnaire.ts";
|
|
17
|
+
import { buildResponse, buildToolResult } from "./tool/envelope.ts";
|
|
18
|
+
import {
|
|
19
|
+
type AskUserParams,
|
|
20
|
+
type QuestionnaireResult,
|
|
21
|
+
QuestionParamsSchema,
|
|
22
|
+
TOOL_NAME,
|
|
23
|
+
} from "./tool/schema.ts";
|
|
24
|
+
import { validateParams } from "./tool/validate.ts";
|
|
25
|
+
import { QuestionnaireDialog } from "./view/dialog.ts";
|
|
26
|
+
|
|
27
|
+
const ERROR_NO_UI = "Error: UI not available (running in non-interactive mode)";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wording matters here. Without the explicit "do NOT treat this as a decline",
|
|
31
|
+
* models read a host limitation as a refusal and change course as though the
|
|
32
|
+
* user had said no. Inherited from rpiv-ask-user-question, which learned it
|
|
33
|
+
* the hard way.
|
|
34
|
+
*/
|
|
35
|
+
const ERROR_NO_CUSTOM_UI =
|
|
36
|
+
"Error: this client cannot render the questionnaire (custom UI is unavailable). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, without using this tool.";
|
|
37
|
+
|
|
38
|
+
const DESCRIPTION = `Ask the user a structured question during execution. Use when you need to:
|
|
39
|
+
1. Gather user preferences or requirements
|
|
40
|
+
2. Clarify ambiguous instructions
|
|
41
|
+
3. Get decisions on implementation choices as you work
|
|
42
|
+
4. Offer choices to the user about what direction to take
|
|
43
|
+
|
|
44
|
+
Usage notes:
|
|
45
|
+
- The user can pick an option with the number keys, type a custom answer via the automatically appended "Type something." row, attach a note to their choice with Tab, or press Esc to decline. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.
|
|
46
|
+
- If you recommend a specific option, make it the first option and add "(Recommended)" at the end of the label.
|
|
47
|
+
- This version accepts exactly one question per call with 2-4 options.`;
|
|
48
|
+
|
|
49
|
+
function emitPrompt(pi: ExtensionAPI, params: AskUserParams): void {
|
|
50
|
+
const payload: AskUserPromptEventPayload = {
|
|
51
|
+
questions: params.questions.map((q) => ({
|
|
52
|
+
question: q.question,
|
|
53
|
+
header: q.header,
|
|
54
|
+
options: q.options.map((o) => ({ label: o.label, description: o.description })),
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
pi.events.emit(ASK_USER_PROMPT_EVENT, payload);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function emitBlocked(pi: ExtensionAPI, active: boolean): void {
|
|
61
|
+
const payload: AskUserBlockedEventPayload = { active };
|
|
62
|
+
pi.events.emit(ASK_USER_BLOCKED_EVENT, payload);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function registerTool(pi: ExtensionAPI): void {
|
|
66
|
+
pi.registerTool({
|
|
67
|
+
name: TOOL_NAME,
|
|
68
|
+
label: "Ask User Question",
|
|
69
|
+
description: DESCRIPTION,
|
|
70
|
+
parameters: QuestionParamsSchema,
|
|
71
|
+
|
|
72
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
73
|
+
const typed = params as unknown as AskUserParams;
|
|
74
|
+
|
|
75
|
+
// Backstop only — reconcile.ts should already have stripped the tool
|
|
76
|
+
// in headless runs (subagent children must never block on a human).
|
|
77
|
+
if (!ctx.hasUI) {
|
|
78
|
+
return buildToolResult(ERROR_NO_UI, { answers: [], cancelled: true, error: "no_ui" });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const invalid = validateParams(typed);
|
|
82
|
+
if (invalid) {
|
|
83
|
+
return buildToolResult(`Error: ${invalid.message}`, {
|
|
84
|
+
answers: [],
|
|
85
|
+
cancelled: true,
|
|
86
|
+
error: "invalid_params",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const session = new QuestionnaireSession(typed);
|
|
91
|
+
emitPrompt(pi, typed);
|
|
92
|
+
emitBlocked(pi, true);
|
|
93
|
+
try {
|
|
94
|
+
const result = await ctx.ui.custom<QuestionnaireResult | null>(
|
|
95
|
+
(tui, theme, _keybindings, done) =>
|
|
96
|
+
new QuestionnaireDialog({
|
|
97
|
+
session,
|
|
98
|
+
theme,
|
|
99
|
+
done,
|
|
100
|
+
requestRender: () => tui.requestRender(),
|
|
101
|
+
}),
|
|
102
|
+
{ overlay: true },
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// `custom()` resolving undefined means the host reported hasUI but
|
|
106
|
+
// cannot actually render a custom component. The user saw nothing.
|
|
107
|
+
if (result === undefined) {
|
|
108
|
+
return buildToolResult(ERROR_NO_CUSTOM_UI, {
|
|
109
|
+
answers: [],
|
|
110
|
+
cancelled: true,
|
|
111
|
+
error: "no_custom_ui",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return buildResponse(result, typed);
|
|
115
|
+
} finally {
|
|
116
|
+
// In `finally` so listeners are never left believing we are still
|
|
117
|
+
// blocked on a human after a throw.
|
|
118
|
+
emitBlocked(pi, false);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
package/events.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public event contract for @hank-warren/pi-ask-user-question.
|
|
3
|
+
*
|
|
4
|
+
* STABILITY POLICY — applies to every event in the `hank:*` namespace.
|
|
5
|
+
*
|
|
6
|
+
* 1. Channel names are immutable. Once shipped, never rename.
|
|
7
|
+
* 2. Payload changes are append-only. Listeners MUST tolerate unknown
|
|
8
|
+
* fields. New fields ship as optional (`?:`).
|
|
9
|
+
* 3. Breaking changes (rename, retype, remove a field; change emission
|
|
10
|
+
* semantics) require a NEW channel, e.g. `hank:ask-user:prompt.v2`,
|
|
11
|
+
* with dual-emit during a deprecation window.
|
|
12
|
+
* 4. No `version` field inside payloads. Version via channel name only.
|
|
13
|
+
* 5. Payloads must be JSON-safe: primitives, arrays, plain objects. No
|
|
14
|
+
* Set/Map/Date/class instances — payloads must survive JSON
|
|
15
|
+
* serialization when listeners forward them across process boundaries.
|
|
16
|
+
*
|
|
17
|
+
* Intended consumers: pi-statusline (blocked-on-human indicator) and
|
|
18
|
+
* pi-auto-permissions (suppress guardian nags while a human is being asked).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Emitted once when a questionnaire is presented. */
|
|
22
|
+
export const ASK_USER_PROMPT_EVENT = "hank:ask-user:prompt" as const;
|
|
23
|
+
|
|
24
|
+
export interface AskUserPromptOption {
|
|
25
|
+
label: string;
|
|
26
|
+
description: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AskUserPromptQuestion {
|
|
30
|
+
question: string;
|
|
31
|
+
header: string;
|
|
32
|
+
options: ReadonlyArray<AskUserPromptOption>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AskUserPromptEventPayload {
|
|
36
|
+
questions: ReadonlyArray<AskUserPromptQuestion>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Emitted while the questionnaire awaits input, and cleared with
|
|
41
|
+
* `{ active: false }` in a `finally` so listeners can always distinguish
|
|
42
|
+
* blocked-on-human from working — including when the dialog throws.
|
|
43
|
+
*/
|
|
44
|
+
export const ASK_USER_BLOCKED_EVENT = "hank:ask-user:blocked" as const;
|
|
45
|
+
|
|
46
|
+
export interface AskUserBlockedEventPayload {
|
|
47
|
+
/** True while input is awaited; false when the wait ends (answer, cancel, or error). */
|
|
48
|
+
active: boolean;
|
|
49
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-ask-user-question — a structured questionnaire the model can put to you
|
|
3
|
+
* when it would otherwise guess.
|
|
4
|
+
*
|
|
5
|
+
* Numbered options, digit hotkeys and Tab-to-comment come from the shared
|
|
6
|
+
* `OptionSelector` in @hank-warren/pi-permission-selector, composed through
|
|
7
|
+
* `ctx.ui.custom()`. No pi internals are patched.
|
|
8
|
+
*
|
|
9
|
+
* The tool is stripped from the active tool set whenever `ctx.hasUI` is false,
|
|
10
|
+
* so headless subagent children can never block waiting on a human. See
|
|
11
|
+
* reconcile.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { registerTool } from "./ask-user-question.ts";
|
|
16
|
+
import { registerReconciler } from "./reconcile.ts";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
ASK_USER_BLOCKED_EVENT,
|
|
20
|
+
ASK_USER_PROMPT_EVENT,
|
|
21
|
+
type AskUserBlockedEventPayload,
|
|
22
|
+
type AskUserPromptEventPayload,
|
|
23
|
+
type AskUserPromptOption,
|
|
24
|
+
type AskUserPromptQuestion,
|
|
25
|
+
} from "./events.ts";
|
|
26
|
+
|
|
27
|
+
export default function askUserQuestionExtension(pi: ExtensionAPI): void {
|
|
28
|
+
registerTool(pi);
|
|
29
|
+
registerReconciler(pi);
|
|
30
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hank-warren/pi-ask-user-question",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Structured questionnaire tool for Pi with numbered options, digit hotkeys and Tab-to-comment, composed from the shared permission-selector component.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi",
|
|
10
|
+
"questionnaire",
|
|
11
|
+
"prompt",
|
|
12
|
+
"tui",
|
|
13
|
+
"coding-agent"
|
|
14
|
+
],
|
|
15
|
+
"author": "Hank Warren",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/hank-warren/pi-extensions.git",
|
|
20
|
+
"directory": "packages/pi-ask-user-question"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/hank-warren/pi-extensions/issues"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-ask-user-question#readme",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./index.ts"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"index.ts",
|
|
36
|
+
"ask-user-question.ts",
|
|
37
|
+
"reconcile.ts",
|
|
38
|
+
"events.ts",
|
|
39
|
+
"questionnaire.ts",
|
|
40
|
+
"tool/schema.ts",
|
|
41
|
+
"tool/validate.ts",
|
|
42
|
+
"tool/envelope.ts",
|
|
43
|
+
"view/dialog.ts",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@hank-warren/pi-permission-selector": "^0.2.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
52
|
+
"@earendil-works/pi-tui": "*",
|
|
53
|
+
"typebox": "*"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/questionnaire.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Questionnaire session state — a plain mutable object, no reducer.
|
|
3
|
+
*
|
|
4
|
+
* @juicesharp/rpiv-ask-user-question spends ~1,500 lines on a Redux-style
|
|
5
|
+
* reducer plus four selector modules for this. The state here is one question
|
|
6
|
+
* index, one answer list, and a custom-answer buffer; a reducer would add
|
|
7
|
+
* indirection without adding a single testable guarantee.
|
|
8
|
+
*
|
|
9
|
+
* Pure: no pi imports, no rendering. The dialog drives it; tests drive it the
|
|
10
|
+
* same way.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type AskUserParams,
|
|
15
|
+
CUSTOM_ANSWER_LABEL,
|
|
16
|
+
CUSTOM_ANSWER_VALUE,
|
|
17
|
+
type QuestionAnswer,
|
|
18
|
+
type QuestionnaireResult,
|
|
19
|
+
} from "./tool/schema.ts";
|
|
20
|
+
|
|
21
|
+
export interface SelectableRow {
|
|
22
|
+
value: string;
|
|
23
|
+
label: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class QuestionnaireSession {
|
|
28
|
+
private readonly params: AskUserParams;
|
|
29
|
+
private index = 0;
|
|
30
|
+
private readonly answers: QuestionAnswer[] = [];
|
|
31
|
+
|
|
32
|
+
constructor(params: AskUserParams) {
|
|
33
|
+
this.params = params;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get questionIndex(): number {
|
|
37
|
+
return this.index;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get total(): number {
|
|
41
|
+
return this.params.questions.length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get current() {
|
|
45
|
+
return this.params.questions[this.index];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True once every question has been answered. */
|
|
49
|
+
isComplete(): boolean {
|
|
50
|
+
return this.index >= this.total;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Rows for the current question: the authored options plus the appended
|
|
55
|
+
* custom-answer sentinel. The sentinel is never an authored option — it is
|
|
56
|
+
* added here and stripped in `recordAnswer`, which is why validation
|
|
57
|
+
* rejects models that try to author it themselves.
|
|
58
|
+
*/
|
|
59
|
+
rows(): SelectableRow[] {
|
|
60
|
+
const question = this.current;
|
|
61
|
+
if (!question) return [];
|
|
62
|
+
const rows: SelectableRow[] = question.options.map((option) => ({
|
|
63
|
+
value: option.label,
|
|
64
|
+
label: option.label,
|
|
65
|
+
description: option.description,
|
|
66
|
+
}));
|
|
67
|
+
rows.push({ value: CUSTOM_ANSWER_VALUE, label: CUSTOM_ANSWER_LABEL });
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when `value` is the appended custom-answer row. */
|
|
72
|
+
isCustomRow(value: string): boolean {
|
|
73
|
+
return value === CUSTOM_ANSWER_VALUE;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Record an answer for the current question and advance. `custom` marks a
|
|
78
|
+
* typed free-text answer; `notes` is the Tab-to-comment note.
|
|
79
|
+
*/
|
|
80
|
+
recordAnswer(answer: string, opts: { custom?: boolean; notes?: string } = {}): void {
|
|
81
|
+
const question = this.current;
|
|
82
|
+
if (!question) return;
|
|
83
|
+
this.answers.push({
|
|
84
|
+
questionIndex: this.index,
|
|
85
|
+
question: question.question,
|
|
86
|
+
answer,
|
|
87
|
+
custom: opts.custom === true,
|
|
88
|
+
...(opts.notes ? { notes: opts.notes } : {}),
|
|
89
|
+
});
|
|
90
|
+
this.index += 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Title line for the dialog: `Header` or `2 of 3 · Header` when multiple. */
|
|
94
|
+
title(): string {
|
|
95
|
+
const question = this.current;
|
|
96
|
+
if (!question) return "";
|
|
97
|
+
return this.total > 1
|
|
98
|
+
? `${this.index + 1} of ${this.total} · ${question.header}`
|
|
99
|
+
: question.header;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Successful outcome. */
|
|
103
|
+
result(): QuestionnaireResult {
|
|
104
|
+
return { answers: [...this.answers], cancelled: false };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Declined outcome, preserving any answers given before the cancel. */
|
|
108
|
+
cancelledResult(): QuestionnaireResult {
|
|
109
|
+
return { answers: [...this.answers], cancelled: true };
|
|
110
|
+
}
|
|
111
|
+
}
|
package/reconcile.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mid-session lifecycle reconciliation for `ask_user_question`.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS — read before changing it.
|
|
5
|
+
*
|
|
6
|
+
* Subagent children run headless (`ctx.hasUI === false`). They must never be
|
|
7
|
+
* able to block waiting on a human, and they must never route a question up to
|
|
8
|
+
* the supervisor. Stripping the tool from the active set is the mechanism that
|
|
9
|
+
* enforces that: the LLM in a headless run never sees the tool, so it cannot
|
|
10
|
+
* call it, so it cannot stall a background run forever.
|
|
11
|
+
*
|
|
12
|
+
* Do NOT "improve" this into an escalation bridge. See
|
|
13
|
+
* docs/specs/pi-ask-user-question.md §2 and §7 — no-subagent-escalation is an
|
|
14
|
+
* explicit non-goal, not an oversight.
|
|
15
|
+
*
|
|
16
|
+
* Unlike @juicesharp/rpiv-ask-user-question there is no carve-out for
|
|
17
|
+
* `ctx.mode === "rpc"`, because this package ships no RPC dialog fallback:
|
|
18
|
+
* `hasUI` is the only signal and it is honest here.
|
|
19
|
+
*
|
|
20
|
+
* The in-handler `!ctx.hasUI` guard in ask-user-question.ts stays as a
|
|
21
|
+
* one-turn backstop in case a future pi release snapshots the tool list before
|
|
22
|
+
* `before_agent_start` runs.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { TOOL_NAME } from "./tool/schema.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Strip or restore the tool to match `ctx.hasUI`. Idempotent: when the tool is
|
|
30
|
+
* already in the right state, the active set (and every sibling tool in it) is
|
|
31
|
+
* left untouched.
|
|
32
|
+
*/
|
|
33
|
+
export function reconcileTool(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
34
|
+
const active = pi.getActiveTools();
|
|
35
|
+
const present = active.includes(TOOL_NAME);
|
|
36
|
+
if (!ctx.hasUI && present) {
|
|
37
|
+
pi.setActiveTools(active.filter((name) => name !== TOOL_NAME));
|
|
38
|
+
} else if (ctx.hasUI && !present) {
|
|
39
|
+
pi.setActiveTools([...active, TOOL_NAME]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerReconciler(pi: ExtensionAPI): void {
|
|
44
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
45
|
+
reconcileTool(pi, ctx);
|
|
46
|
+
});
|
|
47
|
+
}
|
package/tool/envelope.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-facing result envelope.
|
|
3
|
+
*
|
|
4
|
+
* The string shapes here are deliberately identical to
|
|
5
|
+
* @juicesharp/rpiv-ask-user-question's (`tool/response-envelope.ts`). Models
|
|
6
|
+
* have seen this format across prior sessions, and preserving it means session
|
|
7
|
+
* history replays without the assistant re-interpreting old tool results.
|
|
8
|
+
* Treat the exact wording as pinned by tests, not as incidental.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AskUserParams, QuestionAnswer, QuestionnaireResult } from "./schema.ts";
|
|
12
|
+
|
|
13
|
+
export const DECLINE_MESSAGE = "User declined to answer questions";
|
|
14
|
+
export const ENVELOPE_PREFIX = "User has answered your questions:";
|
|
15
|
+
export const ENVELOPE_SUFFIX = "You can now continue with the user's answers in mind.";
|
|
16
|
+
|
|
17
|
+
export interface ToolResult {
|
|
18
|
+
content: Array<{ type: "text"; text: string }>;
|
|
19
|
+
details: QuestionnaireResult;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildToolResult(text: string, details: QuestionnaireResult): ToolResult {
|
|
23
|
+
return { content: [{ type: "text" as const, text }], details };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Format a single answer as `"question"="answer"`, with an optional note. */
|
|
27
|
+
export function buildAnswerSegment(a: QuestionAnswer): string {
|
|
28
|
+
const parts: string[] = [`"${a.question}"="${a.answer}"`];
|
|
29
|
+
if (a.notes && a.notes.length > 0) parts.push(`user notes: ${a.notes}`);
|
|
30
|
+
return `${parts.join(". ")}.`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Map a questionnaire outcome to the tool envelope. Cancelled and
|
|
35
|
+
* zero-answers both collapse to DECLINE_MESSAGE so the model sees one
|
|
36
|
+
* canonical "didn't answer" signal regardless of cause.
|
|
37
|
+
*/
|
|
38
|
+
export function buildResponse(
|
|
39
|
+
result: QuestionnaireResult | null | undefined,
|
|
40
|
+
params: AskUserParams,
|
|
41
|
+
): ToolResult {
|
|
42
|
+
if (!result || result.cancelled) {
|
|
43
|
+
return buildToolResult(DECLINE_MESSAGE, {
|
|
44
|
+
answers: result?.answers ?? [],
|
|
45
|
+
cancelled: true,
|
|
46
|
+
...(result?.error ? { error: result.error } : {}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const segments: string[] = [];
|
|
51
|
+
for (let i = 0; i < params.questions.length; i++) {
|
|
52
|
+
const answer = result.answers.find((a) => a.questionIndex === i);
|
|
53
|
+
if (answer) segments.push(buildAnswerSegment(answer));
|
|
54
|
+
}
|
|
55
|
+
if (segments.length === 0) {
|
|
56
|
+
return buildToolResult(DECLINE_MESSAGE, { answers: result.answers, cancelled: true });
|
|
57
|
+
}
|
|
58
|
+
return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, result);
|
|
59
|
+
}
|
package/tool/schema.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool parameter schema and shared types for `ask_user_question`.
|
|
3
|
+
*
|
|
4
|
+
* v0.1 scope (docs/specs/pi-ask-user-question.md §14): a SINGLE question,
|
|
5
|
+
* single-select, no preview. The schema still accepts the `questions` array
|
|
6
|
+
* shape so that v0.2 can lift the count limit without changing the wire
|
|
7
|
+
* contract the model has already learned, and so session history recorded
|
|
8
|
+
* against the array shape keeps replaying.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
|
|
13
|
+
/** Canonical tool name — single source of truth, shared with reconcile.ts. */
|
|
14
|
+
export const TOOL_NAME = "ask_user_question";
|
|
15
|
+
|
|
16
|
+
export const MIN_OPTIONS = 2;
|
|
17
|
+
export const MAX_OPTIONS = 4;
|
|
18
|
+
export const MIN_QUESTIONS = 1;
|
|
19
|
+
/** v0.1 ships single-question only; v0.2 raises this to 4. */
|
|
20
|
+
export const MAX_QUESTIONS = 1;
|
|
21
|
+
|
|
22
|
+
export const MAX_HEADER_LENGTH = 16;
|
|
23
|
+
export const MAX_LABEL_LENGTH = 60;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Labels the model may not author, because the dialog appends its own
|
|
27
|
+
* custom-answer row. Compared case-insensitively after trimming.
|
|
28
|
+
*/
|
|
29
|
+
export const RESERVED_LABELS = ["other", "type something", "type something."] as const;
|
|
30
|
+
|
|
31
|
+
/** Label of the auto-appended custom-answer row. */
|
|
32
|
+
export const CUSTOM_ANSWER_LABEL = "Type something.";
|
|
33
|
+
/** Sentinel `value` for that row; never collides with a real option value. */
|
|
34
|
+
export const CUSTOM_ANSWER_VALUE = "\u0000custom-answer";
|
|
35
|
+
|
|
36
|
+
export const OptionSchema = Type.Object({
|
|
37
|
+
label: Type.String({
|
|
38
|
+
description:
|
|
39
|
+
"MAX 60 CHARACTERS — hard limit, requests over the limit are rejected. The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.",
|
|
40
|
+
}),
|
|
41
|
+
description: Type.String({
|
|
42
|
+
description:
|
|
43
|
+
"Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.",
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const QuestionSchema = Type.Object({
|
|
48
|
+
question: Type.String({
|
|
49
|
+
description:
|
|
50
|
+
"The complete question to ask the user. Should be clear, specific, and end with a question mark.",
|
|
51
|
+
}),
|
|
52
|
+
header: Type.String({
|
|
53
|
+
description:
|
|
54
|
+
'MAX 16 CHARACTERS — hard limit, requests over the limit are rejected. Very short chip/tag shown next to the question. Examples: "Auth method", "Library", "Approach".',
|
|
55
|
+
}),
|
|
56
|
+
options: Type.Array(OptionSchema, {
|
|
57
|
+
description:
|
|
58
|
+
"The available choices for this question. Must have 2-4 options, each a distinct, mutually exclusive choice. The 'Type something.' row is appended automatically — do NOT author it.",
|
|
59
|
+
minItems: MIN_OPTIONS,
|
|
60
|
+
maxItems: MAX_OPTIONS,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const QuestionParamsSchema = Type.Object({
|
|
65
|
+
questions: Type.Array(QuestionSchema, {
|
|
66
|
+
description: "Questions to ask the user. Exactly one question is supported in this version.",
|
|
67
|
+
minItems: MIN_QUESTIONS,
|
|
68
|
+
maxItems: MAX_QUESTIONS,
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export interface OptionParams {
|
|
73
|
+
label: string;
|
|
74
|
+
description: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface QuestionParams {
|
|
78
|
+
question: string;
|
|
79
|
+
header: string;
|
|
80
|
+
options: OptionParams[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AskUserParams {
|
|
84
|
+
questions: QuestionParams[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One answered question. `custom` marks a typed free-text answer. */
|
|
88
|
+
export interface QuestionAnswer {
|
|
89
|
+
questionIndex: number;
|
|
90
|
+
question: string;
|
|
91
|
+
answer: string;
|
|
92
|
+
custom: boolean;
|
|
93
|
+
/** Trimmed Tab-to-comment note, when the user attached one. */
|
|
94
|
+
notes?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface QuestionnaireResult {
|
|
98
|
+
answers: QuestionAnswer[];
|
|
99
|
+
cancelled: boolean;
|
|
100
|
+
/** Set only on infrastructure failures, never on a user decline. */
|
|
101
|
+
error?: "no_ui" | "no_custom_ui" | "invalid_params";
|
|
102
|
+
}
|
package/tool/validate.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parameter validation for `ask_user_question`. Pure — no pi imports, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* typebox enforces structure (array bounds, required fields, types). This
|
|
5
|
+
* module enforces the semantic rules typebox cannot express: length caps that
|
|
6
|
+
* the model routinely overshoots, reserved sentinel labels, and duplicates.
|
|
7
|
+
*
|
|
8
|
+
* Violations return a structured error that becomes a normal tool error the
|
|
9
|
+
* model can read and retry against — never a thrown exception, which would
|
|
10
|
+
* surface as an opaque crash.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type AskUserParams,
|
|
15
|
+
MAX_HEADER_LENGTH,
|
|
16
|
+
MAX_LABEL_LENGTH,
|
|
17
|
+
MAX_OPTIONS,
|
|
18
|
+
MAX_QUESTIONS,
|
|
19
|
+
MIN_OPTIONS,
|
|
20
|
+
MIN_QUESTIONS,
|
|
21
|
+
RESERVED_LABELS,
|
|
22
|
+
} from "./schema.ts";
|
|
23
|
+
|
|
24
|
+
export type ValidationCode =
|
|
25
|
+
| "bad_question_count"
|
|
26
|
+
| "bad_option_count"
|
|
27
|
+
| "header_too_long"
|
|
28
|
+
| "label_too_long"
|
|
29
|
+
| "reserved_label"
|
|
30
|
+
| "duplicate_label"
|
|
31
|
+
| "empty_question";
|
|
32
|
+
|
|
33
|
+
export interface ValidationError {
|
|
34
|
+
code: ValidationCode;
|
|
35
|
+
message: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isReserved = (label: string): boolean =>
|
|
39
|
+
(RESERVED_LABELS as readonly string[]).includes(label.trim().toLowerCase());
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate a params object. Returns the first violation, or undefined when the
|
|
43
|
+
* questionnaire is well-formed. First-violation-only is deliberate: the model
|
|
44
|
+
* fixes one thing per retry, and a wall of errors invites it to rewrite the
|
|
45
|
+
* whole call rather than patch the offending field.
|
|
46
|
+
*/
|
|
47
|
+
export function validateParams(params: AskUserParams): ValidationError | undefined {
|
|
48
|
+
const questions = params.questions;
|
|
49
|
+
if (!Array.isArray(questions) || questions.length < MIN_QUESTIONS || questions.length > MAX_QUESTIONS) {
|
|
50
|
+
return {
|
|
51
|
+
code: "bad_question_count",
|
|
52
|
+
message:
|
|
53
|
+
MAX_QUESTIONS === 1
|
|
54
|
+
? `This version supports exactly ${MAX_QUESTIONS} question per call; received ${questions?.length ?? 0}. Ask the most important question first, then follow up.`
|
|
55
|
+
: `questions must contain ${MIN_QUESTIONS}-${MAX_QUESTIONS} entries; received ${questions?.length ?? 0}.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < questions.length; i++) {
|
|
60
|
+
const q = questions[i];
|
|
61
|
+
const where = `questions[${i}]`;
|
|
62
|
+
|
|
63
|
+
if (!q.question?.trim()) {
|
|
64
|
+
return { code: "empty_question", message: `${where}.question must not be empty.` };
|
|
65
|
+
}
|
|
66
|
+
if (!q.header?.trim()) {
|
|
67
|
+
return { code: "empty_question", message: `${where}.header must not be empty.` };
|
|
68
|
+
}
|
|
69
|
+
if (q.header.length > MAX_HEADER_LENGTH) {
|
|
70
|
+
return {
|
|
71
|
+
code: "header_too_long",
|
|
72
|
+
message: `${where}.header is ${q.header.length} characters; the hard limit is ${MAX_HEADER_LENGTH}. Shorten it to a chip-sized tag.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(q.options) || q.options.length < MIN_OPTIONS || q.options.length > MAX_OPTIONS) {
|
|
76
|
+
return {
|
|
77
|
+
code: "bad_option_count",
|
|
78
|
+
message: `${where}.options must contain ${MIN_OPTIONS}-${MAX_OPTIONS} entries; received ${q.options?.length ?? 0}.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
for (let j = 0; j < q.options.length; j++) {
|
|
84
|
+
const option = q.options[j];
|
|
85
|
+
const at = `${where}.options[${j}]`;
|
|
86
|
+
if (option.label.length > MAX_LABEL_LENGTH) {
|
|
87
|
+
return {
|
|
88
|
+
code: "label_too_long",
|
|
89
|
+
message: `${at}.label is ${option.label.length} characters; the hard limit is ${MAX_LABEL_LENGTH}.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (isReserved(option.label)) {
|
|
93
|
+
return {
|
|
94
|
+
code: "reserved_label",
|
|
95
|
+
message: `${at}.label "${option.label}" is reserved. A "Type something." row is appended automatically — do not author it.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const key = option.label.trim().toLowerCase();
|
|
99
|
+
if (seen.has(key)) {
|
|
100
|
+
return {
|
|
101
|
+
code: "duplicate_label",
|
|
102
|
+
message: `${at}.label "${option.label}" duplicates an earlier option in the same question. Options must be distinct.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
seen.add(key);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
package/view/dialog.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The questionnaire dialog: a pi-tui component driven by QuestionnaireSession
|
|
3
|
+
* and rendered through `ctx.ui.custom()`.
|
|
4
|
+
*
|
|
5
|
+
* NO MONKEY PATCHING. The numbered options, digit hotkeys and Tab-to-comment
|
|
6
|
+
* come from `OptionSelector`, imported from the PUBLISHED sibling package
|
|
7
|
+
* `@hank-warren/pi-permission-selector` (plain `dependencies`, never
|
|
8
|
+
* `bundledDependencies` — AGENTS.md §Structure). That is the whole point of
|
|
9
|
+
* the design: shared behavior by composition, not by patching pi internals.
|
|
10
|
+
* See docs/specs/pi-ask-user-question.md §9.
|
|
11
|
+
*
|
|
12
|
+
* v0.1 renders one question. The component is written against the session
|
|
13
|
+
* rather than a single question so v0.2 can advance in place without a
|
|
14
|
+
* rewrite.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { OptionSelector, type SelectorOption } from "@hank-warren/pi-permission-selector/selector.ts";
|
|
18
|
+
import type { QuestionnaireSession } from "../questionnaire.ts";
|
|
19
|
+
import type { QuestionnaireResult } from "../tool/schema.ts";
|
|
20
|
+
|
|
21
|
+
/** Structural subset of pi's Theme used here; keeps the view unit-testable. */
|
|
22
|
+
export interface DialogTheme {
|
|
23
|
+
fg(role: string, text: string): string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DialogOptions {
|
|
27
|
+
session: QuestionnaireSession;
|
|
28
|
+
theme?: DialogTheme;
|
|
29
|
+
/** Called exactly once with the final outcome. */
|
|
30
|
+
done(result: QuestionnaireResult): void;
|
|
31
|
+
requestRender?(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Two-mode dialog: option selection, and (after choosing "Type something.")
|
|
36
|
+
* free-text entry. Custom-answer entry is handled here rather than by mounting
|
|
37
|
+
* pi-tui's `Input`, because `Input` owns its own key handling and would fight
|
|
38
|
+
* the selector for Esc and Enter.
|
|
39
|
+
*/
|
|
40
|
+
export class QuestionnaireDialog {
|
|
41
|
+
private readonly opts: DialogOptions;
|
|
42
|
+
private selector: OptionSelector;
|
|
43
|
+
private customText: string | undefined;
|
|
44
|
+
private pendingNotes: string | undefined;
|
|
45
|
+
private finished = false;
|
|
46
|
+
|
|
47
|
+
constructor(opts: DialogOptions) {
|
|
48
|
+
this.opts = opts;
|
|
49
|
+
this.selector = this.buildSelector();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private get session() {
|
|
53
|
+
return this.opts.session;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private repaint(): void {
|
|
57
|
+
this.opts.requestRender?.();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private buildSelector(): OptionSelector {
|
|
61
|
+
const session = this.session;
|
|
62
|
+
const options: SelectorOption[] = session.rows().map((row) => ({
|
|
63
|
+
value: row.value,
|
|
64
|
+
label: row.label,
|
|
65
|
+
description: row.description,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
return new OptionSelector({
|
|
69
|
+
title: `${session.title()}\n\n${session.current?.question ?? ""}`,
|
|
70
|
+
options,
|
|
71
|
+
theme: this.opts.theme,
|
|
72
|
+
onSelect: (option, comment) => {
|
|
73
|
+
if (session.isCustomRow(option.value)) {
|
|
74
|
+
// Enter free-text mode. The note typed on the sentinel row is
|
|
75
|
+
// carried across so a user who commented and then chose to type
|
|
76
|
+
// a custom answer does not silently lose it.
|
|
77
|
+
this.pendingNotes = comment;
|
|
78
|
+
this.customText = "";
|
|
79
|
+
this.repaint();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
session.recordAnswer(option.value, { notes: comment });
|
|
83
|
+
this.advance();
|
|
84
|
+
},
|
|
85
|
+
onCancel: () => this.finish(session.cancelledResult()),
|
|
86
|
+
requestRender: () => this.repaint(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private advance(): void {
|
|
91
|
+
if (this.session.isComplete()) {
|
|
92
|
+
this.finish(this.session.result());
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// v0.2: multi-question calls rebind the selector to the next question.
|
|
96
|
+
this.selector = this.buildSelector();
|
|
97
|
+
this.repaint();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private finish(result: QuestionnaireResult): void {
|
|
101
|
+
if (this.finished) return;
|
|
102
|
+
this.finished = true;
|
|
103
|
+
this.opts.done(result);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** True while the free-text custom-answer editor is open. */
|
|
107
|
+
isTypingCustom(): boolean {
|
|
108
|
+
return this.customText !== undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
invalidate(): void {
|
|
112
|
+
this.selector.invalidate();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
render(width: number): string[] {
|
|
116
|
+
if (this.customText !== undefined) {
|
|
117
|
+
const session = this.session;
|
|
118
|
+
return [
|
|
119
|
+
session.title(),
|
|
120
|
+
"",
|
|
121
|
+
session.current?.question ?? "",
|
|
122
|
+
"",
|
|
123
|
+
` ${this.customText}▌`,
|
|
124
|
+
"",
|
|
125
|
+
this.dim(" enter submit · esc back to options"),
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
return this.selector.render(width);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private dim(text: string): string {
|
|
132
|
+
return this.opts.theme ? this.opts.theme.fg("dim", text) : `\x1b[2m${text}\x1b[0m`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
handleInput(keyData: string): void {
|
|
136
|
+
if (this.customText === undefined) {
|
|
137
|
+
this.selector.handleInput(keyData);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Free-text mode. Esc unwinds to the option list rather than cancelling
|
|
141
|
+
// the whole dialog — one Esc should never discard more than one layer.
|
|
142
|
+
if (keyData === "\x1b") {
|
|
143
|
+
this.customText = undefined;
|
|
144
|
+
this.pendingNotes = undefined;
|
|
145
|
+
this.repaint();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (keyData === "\r" || keyData === "\n") {
|
|
149
|
+
const text = this.customText.trim();
|
|
150
|
+
if (text.length === 0) return; // Empty custom answers are not submittable.
|
|
151
|
+
this.session.recordAnswer(text, { custom: true, notes: this.pendingNotes });
|
|
152
|
+
this.customText = undefined;
|
|
153
|
+
this.pendingNotes = undefined;
|
|
154
|
+
this.advance();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (keyData === "\x7f" || keyData === "\b") {
|
|
158
|
+
this.customText = this.customText.slice(0, -1);
|
|
159
|
+
this.repaint();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (isPrintableChunk(keyData)) {
|
|
163
|
+
this.customText += keyData;
|
|
164
|
+
this.repaint();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isPrintableChunk(keyData: string): boolean {
|
|
170
|
+
if (keyData.length === 0) return false;
|
|
171
|
+
for (const char of keyData) {
|
|
172
|
+
const code = char.codePointAt(0) ?? 0;
|
|
173
|
+
if (code < 0x20 || code === 0x7f) return false;
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
}
|