@hank-warren/pi-ask-user-question 0.4.2 → 0.5.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/CHANGELOG.md +47 -0
- package/README.md +44 -5
- package/ask-user-question.ts +16 -19
- package/events.ts +6 -0
- package/package.json +2 -2
- package/questionnaire.ts +30 -0
- package/tool/schema.ts +31 -4
- package/tool/validate.ts +7 -3
- package/view/dialog.ts +49 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# @hank-warren/pi-ask-user-question
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 385212b: add `multiSelect` questions
|
|
8
|
+
|
|
9
|
+
A question with `multiSelect: true` renders as checkboxes for the cases where
|
|
10
|
+
several answers hold at once ("which of these packages should change", "which
|
|
11
|
+
checks to run before merging"). Space or a digit toggles a row, Enter submits
|
|
12
|
+
the checked options, and the answer comes back as the chosen labels joined with
|
|
13
|
+
`", "` — `answer` stays a plain string, so the envelope and every consumer of
|
|
14
|
+
`details.answers` are unchanged. The parts are also available as
|
|
15
|
+
`answers[].selected`.
|
|
16
|
+
|
|
17
|
+
Multi-select questions may carry 2-6 options instead of 2-4; the
|
|
18
|
+
`bad_option_count` message now names the mode and its range. Previews work on
|
|
19
|
+
multi-select options (the pane keys off the highlighted row), though
|
|
20
|
+
multi-select answers carry no `preview` into the envelope.
|
|
21
|
+
|
|
22
|
+
Checking the appended `Type something.` row opens the free-text field with the
|
|
23
|
+
other ticks preserved, and the typed value is **appended** to them rather than
|
|
24
|
+
replacing them, so `pi-stats, pi-plan-mode, and also the docs site` is one
|
|
25
|
+
answer. Such an answer records `custom: true`.
|
|
26
|
+
|
|
27
|
+
`hank:ask-user:prompt` payloads gain an optional `multiSelect` field, emitted
|
|
28
|
+
only when true — append-only, no new channel.
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- e05dfb5: render the questionnaire in the editor area instead of as an overlay
|
|
33
|
+
|
|
34
|
+
An overlay is composited over the bottom rows of the viewport, so the transcript
|
|
35
|
+
underneath the dialog was unreachable: you are already scrolled to the bottom of
|
|
36
|
+
the session and there is nothing left to scroll. Several lines of chat sat
|
|
37
|
+
behind the box with no way to read them while answering.
|
|
38
|
+
|
|
39
|
+
The dialog now mounts in the editor area — where `ctx.ui.select` renders pi's
|
|
40
|
+
own selectors, and where `pi-auto-permissions` already puts its approval prompt.
|
|
41
|
+
In the normal document flow the transcript is pushed up rather than covered, so
|
|
42
|
+
every line stays readable in the terminal's own scrollback.
|
|
43
|
+
|
|
44
|
+
Nothing else changes: same box, same keys, same width behavior. The input dock
|
|
45
|
+
is replaced for the duration of the questionnaire and restored when it closes.
|
|
46
|
+
|
|
47
|
+
- Updated dependencies [385212b]
|
|
48
|
+
- @hank-warren/pi-permission-selector@1.1.0
|
|
49
|
+
|
|
3
50
|
## 0.4.2
|
|
4
51
|
|
|
5
52
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ A structured questionnaire the model can put to you when it would otherwise
|
|
|
4
4
|
guess. Instead of a free-form "which do you prefer?" in chat, you get a dialog
|
|
5
5
|
with numbered options, digit hotkeys, a typed-answer escape, and Tab-to-comment.
|
|
6
6
|
|
|
7
|
-
> **v0.
|
|
8
|
-
> an optional preview pane. See
|
|
7
|
+
> **v0.5** ships 1-4 questions per call as cycleable tabs, single- or
|
|
8
|
+
> multi-select, with an optional preview pane. See
|
|
9
9
|
> [the spec](../../docs/specs/pi-ask-user-question.md) §14.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
@@ -21,9 +21,10 @@ register a tool named `ask_user_question`.
|
|
|
21
21
|
|
|
22
22
|
| Key | Action |
|
|
23
23
|
|---|---|
|
|
24
|
-
| `1`–`9` | Select that option immediately |
|
|
24
|
+
| `1`–`9` | Select that option immediately (toggle it, on a multi-select question) |
|
|
25
|
+
| `Space` | Toggle the highlighted option (multi-select only) |
|
|
25
26
|
| `↑` / `↓` | Move the highlight |
|
|
26
|
-
| `Enter` | Confirm the highlighted option |
|
|
27
|
+
| `Enter` | Confirm the highlighted option, or submit the checked ones |
|
|
27
28
|
| `n` | Attach a note to your choice, then `Enter` to send both |
|
|
28
29
|
| `Tab` / `→` | Next question |
|
|
29
30
|
| `Shift+Tab` / `←` | Previous question |
|
|
@@ -34,6 +35,33 @@ answering one jumps to the next unanswered question, and the call returns once
|
|
|
34
35
|
every question has an answer. Cycling back and re-answering replaces that
|
|
35
36
|
question's answer rather than recording a second one.
|
|
36
37
|
|
|
38
|
+
## Multi-select
|
|
39
|
+
|
|
40
|
+
A question with `multiSelect: true` renders as checkboxes, for the cases where
|
|
41
|
+
several answers hold at once — "which of these packages should change", "which
|
|
42
|
+
checks to run before merging":
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
→ [x] 1. pi-stats
|
|
46
|
+
[ ] 2. pi-statusline
|
|
47
|
+
[x] 3. pi-plan-mode
|
|
48
|
+
[ ] 4. Type something.
|
|
49
|
+
space/1-9 toggle · ↑↓ move · enter confirm (2) · n add note · esc cancel
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- Space and the digit hotkeys both toggle; a digit no longer commits, so one
|
|
53
|
+
keystroke cannot end the question early.
|
|
54
|
+
- Enter submits the checked options in list order and is inert until at least
|
|
55
|
+
one is checked — the count in the hint is the tell.
|
|
56
|
+
- The answer comes back as the chosen labels joined with `, `.
|
|
57
|
+
- Multi-select questions may carry 2-6 options; single-select stays at 2-4.
|
|
58
|
+
- Checking **`Type something.`** alongside other options opens the free-text
|
|
59
|
+
field with those ticks preserved, and the typed value is **appended** to them
|
|
60
|
+
rather than replacing them, so `pi-stats, pi-plan-mode, and also the docs
|
|
61
|
+
site` is a single answer.
|
|
62
|
+
|
|
63
|
+
Mutually exclusive choices stay single-select; that is still the default.
|
|
64
|
+
|
|
37
65
|
## Previews
|
|
38
66
|
|
|
39
67
|
An option may carry a `preview` field — markdown shown in a pane below the
|
|
@@ -51,6 +79,17 @@ Every question gets an appended **`Type something.`** row for a free-text
|
|
|
51
79
|
answer. The model is not allowed to author that row itself — reserved labels
|
|
52
80
|
are rejected at runtime.
|
|
53
81
|
|
|
82
|
+
## Where the dialog renders
|
|
83
|
+
|
|
84
|
+
The questionnaire renders **in the editor area**, exactly where `ctx.ui.select`
|
|
85
|
+
puts pi's own selectors — not as an overlay floating over the transcript.
|
|
86
|
+
|
|
87
|
+
An overlay is composited over the bottom rows of the viewport, so the chat lines
|
|
88
|
+
underneath it are unreachable: you are already scrolled to the bottom and there
|
|
89
|
+
is nothing left to scroll. Rendering in the document flow pushes the transcript
|
|
90
|
+
up instead of covering it, so every line stays readable in the terminal's own
|
|
91
|
+
scrollback while you answer.
|
|
92
|
+
|
|
54
93
|
## No monkey patching
|
|
55
94
|
|
|
56
95
|
Numbered options and Tab-to-comment come from `OptionSelector`, imported from
|
|
@@ -77,7 +116,7 @@ pi.events.on("hank:ask-user:blocked", ({ active }) => {
|
|
|
77
116
|
// active === true while a human is being asked
|
|
78
117
|
});
|
|
79
118
|
pi.events.on("hank:ask-user:prompt", ({ questions }) => {
|
|
80
|
-
// questions[].question / .header / .options[].label
|
|
119
|
+
// questions[].question / .header / .multiSelect / .options[].label
|
|
81
120
|
});
|
|
82
121
|
```
|
|
83
122
|
|
package/ask-user-question.ts
CHANGED
|
@@ -44,16 +44,21 @@ const DESCRIPTION = `Ask the user one or more structured questions during execut
|
|
|
44
44
|
Preview feature:
|
|
45
45
|
Use the optional \`preview\` field on an option when presenting a concrete artifact the user needs to compare visually: an ASCII mockup, a code snippet, a diagram or configuration variation. It renders as markdown in a pane below the options while that option is highlighted. Do not use it for simple preference questions where the label and description already say enough.
|
|
46
46
|
|
|
47
|
+
Multi-select:
|
|
48
|
+
Set \`multiSelect: true\` on a question when several answers can hold at once — "which of these packages should change", "which checks to run before merging". Keep mutually exclusive choices single-select; that is still the default and the common case. A multi-select question may have 2-6 options instead of 2-4, renders as checkboxes, and the user toggles rows with Space or a digit and confirms with Enter. The answer comes back as the chosen labels joined with ", ", and the user may add a typed value to the checked ones through the "Type something." row. \`preview\` works on multi-select options too.
|
|
49
|
+
|
|
47
50
|
Usage notes:
|
|
48
51
|
- 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 by pressing n, or press Esc to decline. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.
|
|
49
52
|
- If you recommend a specific option, make it the first option and add "(Recommended)" at the end of the label.
|
|
50
|
-
- Ask 1-4 questions per call, each with 2-4 options. Multiple questions render as tabs the user cycles with Tab; every question must be answered before the call returns. Group questions that belong to one decision rather than asking them in separate calls, but do not pad a single decision into several questions.`;
|
|
53
|
+
- Ask 1-4 questions per call, each with 2-4 options (2-6 when \`multiSelect\` is true). Multiple questions render as tabs the user cycles with Tab; every question must be answered before the call returns. Group questions that belong to one decision rather than asking them in separate calls, but do not pad a single decision into several questions.`;
|
|
51
54
|
|
|
52
55
|
function emitPrompt(pi: ExtensionAPI, params: AskUserParams): void {
|
|
53
56
|
const payload: AskUserPromptEventPayload = {
|
|
54
57
|
questions: params.questions.map((q) => ({
|
|
55
58
|
question: q.question,
|
|
56
59
|
header: q.header,
|
|
60
|
+
// Emitted only when true — append-only payload policy (events.ts).
|
|
61
|
+
...(q.multiSelect ? { multiSelect: true } : {}),
|
|
57
62
|
options: q.options.map((o) => ({ label: o.label, description: o.description })),
|
|
58
63
|
})),
|
|
59
64
|
};
|
|
@@ -94,6 +99,16 @@ export function registerTool(pi: ExtensionAPI): void {
|
|
|
94
99
|
emitPrompt(pi, typed);
|
|
95
100
|
emitBlocked(pi, true);
|
|
96
101
|
try {
|
|
102
|
+
// NOT an overlay. An overlay is composited over the bottom rows of the
|
|
103
|
+
// viewport, so the transcript underneath it is unreachable: the user is
|
|
104
|
+
// already scrolled to the bottom and there is nothing left to scroll.
|
|
105
|
+
// Rendering in the editor area instead puts the dialog in the normal
|
|
106
|
+
// document flow — the transcript is pushed up rather than covered, and
|
|
107
|
+
// every line of it stays reachable in the terminal's own scrollback.
|
|
108
|
+
//
|
|
109
|
+
// This is also where `ctx.ui.select` renders, and where
|
|
110
|
+
// pi-auto-permissions' approval prompt already puts `OptionSelector`.
|
|
111
|
+
// pi restores the input editor when `done` fires.
|
|
97
112
|
const result = await ctx.ui.custom<QuestionnaireResult | null>(
|
|
98
113
|
(tui, theme, _keybindings, done) =>
|
|
99
114
|
new QuestionnaireDialog({
|
|
@@ -105,24 +120,6 @@ export function registerTool(pi: ExtensionAPI): void {
|
|
|
105
120
|
done,
|
|
106
121
|
requestRender: () => tui.requestRender(),
|
|
107
122
|
}),
|
|
108
|
-
{
|
|
109
|
-
overlay: true,
|
|
110
|
-
// Bottom-anchored and full width, so the questionnaire sits
|
|
111
|
-
// directly above the input dock instead of floating over the
|
|
112
|
-
// middle of the transcript. Mirrors the geometry
|
|
113
|
-
// @juicesharp/rpiv-ask-user-question used.
|
|
114
|
-
//
|
|
115
|
-
// width MUST stay "100%" in step with the dialog rendering full
|
|
116
|
-
// width: pi composites only the columns the component emits, so
|
|
117
|
-
// a narrower box inside a full-width overlay region would let
|
|
118
|
-
// the transcript show through beside it.
|
|
119
|
-
overlayOptions: {
|
|
120
|
-
anchor: "bottom-center",
|
|
121
|
-
width: "100%",
|
|
122
|
-
maxHeight: "100%",
|
|
123
|
-
margin: { left: 0, right: 0, bottom: 0 },
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
123
|
);
|
|
127
124
|
|
|
128
125
|
// `custom()` resolving undefined means the host reported hasUI but
|
package/events.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface AskUserPromptOption {
|
|
|
29
29
|
export interface AskUserPromptQuestion {
|
|
30
30
|
question: string;
|
|
31
31
|
header: string;
|
|
32
|
+
/**
|
|
33
|
+
* True when the question renders as checkboxes. Emitted only when true, so
|
|
34
|
+
* existing listeners see the payload they already knew (policy rule 2:
|
|
35
|
+
* append-only, new fields optional).
|
|
36
|
+
*/
|
|
37
|
+
multiSelect?: boolean;
|
|
32
38
|
options: ReadonlyArray<AskUserPromptOption>;
|
|
33
39
|
}
|
|
34
40
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-ask-user-question",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Structured questionnaire tool for Pi with numbered options, digit hotkeys and Tab-to-comment, composed from the shared permission-selector component.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"CHANGELOG.md"
|
|
47
47
|
],
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@hank-warren/pi-permission-selector": "^1.0
|
|
49
|
+
"@hank-warren/pi-permission-selector": "^1.1.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"@earendil-works/pi-coding-agent": "*",
|
package/questionnaire.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type AskUserParams,
|
|
21
21
|
CUSTOM_ANSWER_LABEL,
|
|
22
22
|
CUSTOM_ANSWER_VALUE,
|
|
23
|
+
MULTI_SELECT_JOIN,
|
|
23
24
|
type QuestionAnswer,
|
|
24
25
|
type QuestionnaireResult,
|
|
25
26
|
} from "./tool/schema.ts";
|
|
@@ -122,6 +123,35 @@ export class QuestionnaireSession {
|
|
|
122
123
|
return value === CUSTOM_ANSWER_VALUE;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
/** True when the current question renders as checkboxes. */
|
|
127
|
+
isMultiSelect(): boolean {
|
|
128
|
+
return this.current?.multiSelect === true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Record (or replace) a multi-select answer.
|
|
133
|
+
*
|
|
134
|
+
* `answer` is the labels joined with `", "` — spec §5.4 — so `tool/envelope.ts`
|
|
135
|
+
* and every consumer of `details.answers` stay unchanged; `selected` carries
|
|
136
|
+
* the parts for anyone who wants them structured. `custom` means "a typed
|
|
137
|
+
* value is among the parts", not "the whole answer is free text".
|
|
138
|
+
*
|
|
139
|
+
* No preview is attached even when checked options declare one: concatenating
|
|
140
|
+
* several previews into one answer string is noise the model authored itself.
|
|
141
|
+
*/
|
|
142
|
+
recordMultiAnswer(labels: string[], opts: { custom?: boolean; notes?: string } = {}): void {
|
|
143
|
+
const question = this.current;
|
|
144
|
+
if (!question || labels.length === 0) return;
|
|
145
|
+
this.answers[this.index] = {
|
|
146
|
+
questionIndex: this.index,
|
|
147
|
+
question: question.question,
|
|
148
|
+
answer: labels.join(MULTI_SELECT_JOIN),
|
|
149
|
+
custom: opts.custom === true,
|
|
150
|
+
selected: [...labels],
|
|
151
|
+
...(opts.notes ? { notes: opts.notes } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
125
155
|
/**
|
|
126
156
|
* Record (or replace) the answer for the current question. Replacement is
|
|
127
157
|
* the point: cycling back to a question and picking again must not leave
|
package/tool/schema.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Tool parameter schema and shared types for `ask_user_question`.
|
|
3
3
|
*
|
|
4
4
|
* v0.2 scope (docs/specs/pi-ask-user-question.md §14): up to four questions,
|
|
5
|
-
* single-select,
|
|
6
|
-
* Tab / Shift+Tab.
|
|
5
|
+
* single- or multi-select, optional per-option preview. Questions render as
|
|
6
|
+
* tabs the user cycles with Tab / Shift+Tab.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { Type } from "typebox";
|
|
@@ -13,9 +13,19 @@ export const TOOL_NAME = "ask_user_question";
|
|
|
13
13
|
|
|
14
14
|
export const MIN_OPTIONS = 2;
|
|
15
15
|
export const MAX_OPTIONS = 4;
|
|
16
|
+
/**
|
|
17
|
+
* Multi-select questions get a larger cap. Checkboxes are a shortlist UI, not a
|
|
18
|
+
* pick-one UI, so six is where a list stops fitting comfortably above the input
|
|
19
|
+
* dock — not where it stops being a decision.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_MULTI_OPTIONS = 6;
|
|
16
22
|
export const MIN_QUESTIONS = 1;
|
|
17
23
|
export const MAX_QUESTIONS = 4;
|
|
18
24
|
|
|
25
|
+
/** Upper bound on authored options for a question, by mode. */
|
|
26
|
+
export const maxOptionsFor = (multiSelect?: boolean): number =>
|
|
27
|
+
multiSelect ? MAX_MULTI_OPTIONS : MAX_OPTIONS;
|
|
28
|
+
|
|
19
29
|
export const MAX_HEADER_LENGTH = 16;
|
|
20
30
|
export const MAX_LABEL_LENGTH = 60;
|
|
21
31
|
|
|
@@ -30,6 +40,9 @@ export const CUSTOM_ANSWER_LABEL = "Type something.";
|
|
|
30
40
|
/** Sentinel `value` for that row; never collides with a real option value. */
|
|
31
41
|
export const CUSTOM_ANSWER_VALUE = "\u0000custom-answer";
|
|
32
42
|
|
|
43
|
+
/** Separator joining a multi-select answer's labels into `answer` (spec §5.4). */
|
|
44
|
+
export const MULTI_SELECT_JOIN = ", ";
|
|
45
|
+
|
|
33
46
|
export const OptionSchema = Type.Object({
|
|
34
47
|
label: Type.String({
|
|
35
48
|
description:
|
|
@@ -56,11 +69,17 @@ export const QuestionSchema = Type.Object({
|
|
|
56
69
|
description:
|
|
57
70
|
'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".',
|
|
58
71
|
}),
|
|
72
|
+
multiSelect: Type.Optional(
|
|
73
|
+
Type.Boolean({
|
|
74
|
+
description:
|
|
75
|
+
"Render this question as checkboxes so the user can pick more than one option. Use it when several answers can hold at once (\"which of these should change\", \"which checks to run before merging\"); keep mutually exclusive choices single-select. Multi-select questions may have 2-6 options instead of 2-4. The user toggles rows with Space or a digit and confirms with Enter, and the answer comes back as the chosen labels joined with \", \".",
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
59
78
|
options: Type.Array(OptionSchema, {
|
|
60
79
|
description:
|
|
61
|
-
"The available choices for this question. Must have 2-4 options, each a distinct
|
|
80
|
+
"The available choices for this question. Must have 2-4 options (2-6 when multiSelect is true), each a distinct choice; without multiSelect they must also be mutually exclusive. The 'Type something.' row is appended automatically — do NOT author it.",
|
|
62
81
|
minItems: MIN_OPTIONS,
|
|
63
|
-
maxItems:
|
|
82
|
+
maxItems: MAX_MULTI_OPTIONS,
|
|
64
83
|
}),
|
|
65
84
|
});
|
|
66
85
|
|
|
@@ -82,6 +101,8 @@ export interface OptionParams {
|
|
|
82
101
|
export interface QuestionParams {
|
|
83
102
|
question: string;
|
|
84
103
|
header: string;
|
|
104
|
+
/** Checkbox mode: the user may check several options. Default false. */
|
|
105
|
+
multiSelect?: boolean;
|
|
85
106
|
options: OptionParams[];
|
|
86
107
|
}
|
|
87
108
|
|
|
@@ -99,6 +120,12 @@ export interface QuestionAnswer {
|
|
|
99
120
|
notes?: string;
|
|
100
121
|
/** Preview text of the chosen option, when it carried one. */
|
|
101
122
|
preview?: string;
|
|
123
|
+
/**
|
|
124
|
+
* The individual chosen labels, present only on multi-select answers.
|
|
125
|
+
* `answer` holds the same list joined with `", "`, so every existing
|
|
126
|
+
* consumer of `answer` — including the envelope — is unchanged.
|
|
127
|
+
*/
|
|
128
|
+
selected?: string[];
|
|
102
129
|
}
|
|
103
130
|
|
|
104
131
|
export interface QuestionnaireResult {
|
package/tool/validate.ts
CHANGED
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
type AskUserParams,
|
|
15
15
|
MAX_HEADER_LENGTH,
|
|
16
16
|
MAX_LABEL_LENGTH,
|
|
17
|
-
MAX_OPTIONS,
|
|
18
17
|
MAX_QUESTIONS,
|
|
18
|
+
maxOptionsFor,
|
|
19
19
|
MIN_OPTIONS,
|
|
20
20
|
MIN_QUESTIONS,
|
|
21
21
|
RESERVED_LABELS,
|
|
@@ -69,10 +69,14 @@ export function validateParams(params: AskUserParams): ValidationError | undefin
|
|
|
69
69
|
message: `${where}.header is ${q.header.length} characters; the hard limit is ${MAX_HEADER_LENGTH}. Shorten it to a chip-sized tag.`,
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
// The cap is mode-aware: checkboxes are a shortlist UI and get six, a
|
|
73
|
+
// pick-one question still gets four.
|
|
74
|
+
const maxOptions = maxOptionsFor(q.multiSelect);
|
|
75
|
+
if (!Array.isArray(q.options) || q.options.length < MIN_OPTIONS || q.options.length > maxOptions) {
|
|
76
|
+
const mode = q.multiSelect ? "multi-select" : "single-select";
|
|
73
77
|
return {
|
|
74
78
|
code: "bad_option_count",
|
|
75
|
-
message: `${where}.options must contain ${MIN_OPTIONS}-${
|
|
79
|
+
message: `${where}.options must contain ${MIN_OPTIONS}-${maxOptions} entries for a ${mode} question; received ${q.options?.length ?? 0}.`,
|
|
76
80
|
};
|
|
77
81
|
}
|
|
78
82
|
|
package/view/dialog.ts
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
*
|
|
11
11
|
* KEY MAP (matches @juicesharp/rpiv-ask-user-question):
|
|
12
12
|
*
|
|
13
|
-
* 1-9 select an option
|
|
13
|
+
* 1-9 select an option (toggle it, on a multiSelect question)
|
|
14
|
+
* space toggle the highlighted option (multiSelect only)
|
|
14
15
|
* ↑ / ↓ move the highlight
|
|
15
|
-
* enter confirm the highlight
|
|
16
|
+
* enter confirm the highlight, or submit the checked options
|
|
16
17
|
* n open the note editor for the highlighted option
|
|
17
18
|
* tab / → next question shift+tab / ← previous question
|
|
18
19
|
* esc decline the questionnaire
|
|
@@ -28,9 +29,10 @@
|
|
|
28
29
|
* shared predicates in `.../keys.ts`. Under the Kitty keyboard protocol
|
|
29
30
|
* (Ghostty's default) Esc is `\x1b[27u`, not `\x1b`, so raw comparisons
|
|
30
31
|
* trapped the user in the custom-answer field with no way out.
|
|
31
|
-
* 2. ALWAYS pad rendered lines to the full
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* 2. ALWAYS pad rendered lines to the full width. The dialog is a rectangle,
|
|
33
|
+
* and a short line leaves whatever the renderer last drew in those columns
|
|
34
|
+
* visible inside the box — which is exactly how it looked when this was an
|
|
35
|
+
* overlay composited onto the chat.
|
|
34
36
|
* 3. ALWAYS clamp lines to the inner width. A single over-long line breaks the
|
|
35
37
|
* right border and spills into the transcript, so `render` truncates as a
|
|
36
38
|
* last-resort invariant no matter what any content source produces.
|
|
@@ -71,9 +73,8 @@ export interface DialogOptions {
|
|
|
71
73
|
done(result: QuestionnaireResult): void;
|
|
72
74
|
requestRender?(): void;
|
|
73
75
|
/**
|
|
74
|
-
* Optional width cap. Unset means fill the
|
|
75
|
-
*
|
|
76
|
-
* the transcript show through to its right.
|
|
76
|
+
* Optional width cap. Unset means fill the width the host gives us, which is
|
|
77
|
+
* what the full-width editor area wants.
|
|
77
78
|
*/
|
|
78
79
|
maxWidth?: number;
|
|
79
80
|
/**
|
|
@@ -99,6 +100,13 @@ interface TabState {
|
|
|
99
100
|
selector: OptionSelector;
|
|
100
101
|
customText?: string;
|
|
101
102
|
pendingNotes?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Labels checked alongside the sentinel row on a multi-select question, held
|
|
105
|
+
* while the free-text field is open. Committed text is APPENDED to these
|
|
106
|
+
* rather than replacing them (amends spec §5.3), so "A, B, and also …" is
|
|
107
|
+
* one answer. Undefined means the field belongs to a single-select question.
|
|
108
|
+
*/
|
|
109
|
+
pendingSelected?: string[];
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
export class QuestionnaireDialog {
|
|
@@ -137,6 +145,8 @@ export class QuestionnaireDialog {
|
|
|
137
145
|
label: row.label,
|
|
138
146
|
description: row.description,
|
|
139
147
|
}));
|
|
148
|
+
// Read the mode while the cursor is still parked on this tab's question.
|
|
149
|
+
const multiSelect = session.isMultiSelect();
|
|
140
150
|
previous(restore);
|
|
141
151
|
|
|
142
152
|
return new OptionSelector({
|
|
@@ -146,6 +156,21 @@ export class QuestionnaireDialog {
|
|
|
146
156
|
// left unconsumed by the selector so this dialog can intercept it.
|
|
147
157
|
commentTrigger: isNoteKey,
|
|
148
158
|
commentKeyHint: NOTE_KEY,
|
|
159
|
+
multiSelect,
|
|
160
|
+
onSubmit: (checked, comment) => {
|
|
161
|
+
session.goTo(index);
|
|
162
|
+
const labels = checked.filter((o) => !session.isCustomRow(o.value)).map((o) => o.label);
|
|
163
|
+
if (checked.some((o) => session.isCustomRow(o.value))) {
|
|
164
|
+
// Free-text mode with the other ticks held; Esc restores them.
|
|
165
|
+
this.tabs[index].pendingNotes = comment;
|
|
166
|
+
this.tabs[index].pendingSelected = labels;
|
|
167
|
+
this.tabs[index].customText = "";
|
|
168
|
+
this.repaint();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
session.recordMultiAnswer(labels, { notes: comment });
|
|
172
|
+
this.afterAnswer();
|
|
173
|
+
},
|
|
149
174
|
onSelect: (option, comment) => {
|
|
150
175
|
session.goTo(index);
|
|
151
176
|
if (session.isCustomRow(option.value)) {
|
|
@@ -309,8 +334,8 @@ export class QuestionnaireDialog {
|
|
|
309
334
|
// Invariant (3): never let a line break the right border, whatever
|
|
310
335
|
// produced it.
|
|
311
336
|
const line = visibleWidth(raw) > inner ? truncateToWidth(raw, inner) : raw;
|
|
312
|
-
// Invariant (2): pad to the full inner width,
|
|
313
|
-
//
|
|
337
|
+
// Invariant (2): pad to the full inner width, so every row of the box is
|
|
338
|
+
// the same rectangle and nothing shows through beside a short line.
|
|
314
339
|
const pad = Math.max(0, inner - visibleWidth(line));
|
|
315
340
|
out.push(`${border("│")} ${line}${" ".repeat(pad)} ${border("│")}`);
|
|
316
341
|
}
|
|
@@ -347,17 +372,30 @@ export class QuestionnaireDialog {
|
|
|
347
372
|
// rule 1. Esc unwinds to the option list rather than cancelling the
|
|
348
373
|
// dialog: one Esc never discards more than one layer.
|
|
349
374
|
if (isEscapeKey(keyData)) {
|
|
375
|
+
// Back to the option list. The selector still holds every tick, so a
|
|
376
|
+
// multi-select question is exactly as the user left it.
|
|
350
377
|
tab.customText = undefined;
|
|
351
378
|
tab.pendingNotes = undefined;
|
|
379
|
+
tab.pendingSelected = undefined;
|
|
352
380
|
this.repaint();
|
|
353
381
|
return;
|
|
354
382
|
}
|
|
355
383
|
if (isEnterKey(keyData)) {
|
|
356
384
|
const text = tab.customText.trim();
|
|
357
385
|
if (text.length === 0) return; // Empty custom answers are not submittable.
|
|
358
|
-
|
|
386
|
+
if (tab.pendingSelected !== undefined) {
|
|
387
|
+
// Append, never replace: the ticks the user left standing are part of
|
|
388
|
+
// the answer, and the typed value goes last.
|
|
389
|
+
this.session.recordMultiAnswer([...tab.pendingSelected, text], {
|
|
390
|
+
custom: true,
|
|
391
|
+
notes: tab.pendingNotes,
|
|
392
|
+
});
|
|
393
|
+
} else {
|
|
394
|
+
this.session.recordAnswer(text, { custom: true, notes: tab.pendingNotes });
|
|
395
|
+
}
|
|
359
396
|
tab.customText = undefined;
|
|
360
397
|
tab.pendingNotes = undefined;
|
|
398
|
+
tab.pendingSelected = undefined;
|
|
361
399
|
this.afterAnswer();
|
|
362
400
|
return;
|
|
363
401
|
}
|