@hank-warren/pi-ask-user-question 0.2.1 → 0.3.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/README.md +15 -4
- package/ask-user-question.ts +21 -4
- package/package.json +2 -2
- package/questionnaire.ts +76 -16
- package/tool/schema.ts +6 -8
- package/tool/validate.ts +1 -4
- package/view/dialog.ts +151 -43
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
|
-
> [the spec](../../docs/specs/pi-ask-user-question.md) §14
|
|
7
|
+
> **v0.2** ships 1-4 questions per call as cycleable tabs, single-select, no
|
|
8
|
+
> preview pane. See [the spec](../../docs/specs/pi-ask-user-question.md) §14.
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -23,8 +23,19 @@ register a tool named `ask_user_question`.
|
|
|
23
23
|
| `1`–`9` | Select that option immediately |
|
|
24
24
|
| `↑` / `↓` | Move the highlight |
|
|
25
25
|
| `Enter` | Confirm the highlighted option |
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
26
|
+
| `n` | Attach a note to your choice, then `Enter` to send both |
|
|
27
|
+
| `Tab` / `→` | Next question |
|
|
28
|
+
| `Shift+Tab` / `←` | Previous question |
|
|
29
|
+
| `Esc` | Decline the questionnaire (or leave note/typed-answer mode) |
|
|
30
|
+
|
|
31
|
+
With several questions, each is a tab you can cycle through in any order;
|
|
32
|
+
answering one jumps to the next unanswered question, and the call returns once
|
|
33
|
+
every question has an answer. Cycling back and re-answering replaces that
|
|
34
|
+
question's answer rather than recording a second one.
|
|
35
|
+
|
|
36
|
+
**Why `n` and not `Tab` for notes?** Tab cycles questions here, matching
|
|
37
|
+
`@juicesharp/rpiv-ask-user-question`. `pi-auto-permissions` approval prompts
|
|
38
|
+
keep `Tab` for notes — they are single-question and have no tabs to cycle.
|
|
28
39
|
|
|
29
40
|
Every question gets an appended **`Type something.`** row for a free-text
|
|
30
41
|
answer. The model is not allowed to author that row itself — reserved labels
|
package/ask-user-question.ts
CHANGED
|
@@ -35,16 +35,16 @@ const ERROR_NO_UI = "Error: UI not available (running in non-interactive mode)";
|
|
|
35
35
|
const ERROR_NO_CUSTOM_UI =
|
|
36
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
37
|
|
|
38
|
-
const DESCRIPTION = `Ask the user
|
|
38
|
+
const DESCRIPTION = `Ask the user one or more structured questions during execution. Use when you need to:
|
|
39
39
|
1. Gather user preferences or requirements
|
|
40
40
|
2. Clarify ambiguous instructions
|
|
41
41
|
3. Get decisions on implementation choices as you work
|
|
42
42
|
4. Offer choices to the user about what direction to take
|
|
43
43
|
|
|
44
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
|
|
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 by pressing n, or press Esc to decline. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.
|
|
46
46
|
- If you recommend a specific option, make it the first option and add "(Recommended)" at the end of the label.
|
|
47
|
-
-
|
|
47
|
+
- 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.`;
|
|
48
48
|
|
|
49
49
|
function emitPrompt(pi: ExtensionAPI, params: AskUserParams): void {
|
|
50
50
|
const payload: AskUserPromptEventPayload = {
|
|
@@ -99,7 +99,24 @@ export function registerTool(pi: ExtensionAPI): void {
|
|
|
99
99
|
done,
|
|
100
100
|
requestRender: () => tui.requestRender(),
|
|
101
101
|
}),
|
|
102
|
-
{
|
|
102
|
+
{
|
|
103
|
+
overlay: true,
|
|
104
|
+
// Bottom-anchored and full width, so the questionnaire sits
|
|
105
|
+
// directly above the input dock instead of floating over the
|
|
106
|
+
// middle of the transcript. Mirrors the geometry
|
|
107
|
+
// @juicesharp/rpiv-ask-user-question used.
|
|
108
|
+
//
|
|
109
|
+
// width MUST stay "100%" in step with the dialog rendering full
|
|
110
|
+
// width: pi composites only the columns the component emits, so
|
|
111
|
+
// a narrower box inside a full-width overlay region would let
|
|
112
|
+
// the transcript show through beside it.
|
|
113
|
+
overlayOptions: {
|
|
114
|
+
anchor: "bottom-center",
|
|
115
|
+
width: "100%",
|
|
116
|
+
maxHeight: "100%",
|
|
117
|
+
margin: { left: 0, right: 0, bottom: 0 },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
103
120
|
);
|
|
104
121
|
|
|
105
122
|
// `custom()` resolving undefined means the host reported hasUI but
|
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.3.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": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"LICENSE"
|
|
46
46
|
],
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@hank-warren/pi-permission-selector": "^0.
|
|
48
|
+
"@hank-warren/pi-permission-selector": "^0.3.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"@earendil-works/pi-coding-agent": "*",
|
package/questionnaire.ts
CHANGED
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
* Questionnaire session state — a plain mutable object, no reducer.
|
|
3
3
|
*
|
|
4
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* reducer plus four selector modules for this. The state here is a cursor and
|
|
6
|
+
* a sparse answer array; a reducer would add indirection without adding a
|
|
7
|
+
* single testable guarantee.
|
|
8
|
+
*
|
|
9
|
+
* NAVIGATION MODEL. Questions are tabs, not a queue. The cursor moves freely
|
|
10
|
+
* (Tab / Shift+Tab, wrapping), answers are stored BY QUESTION INDEX so
|
|
11
|
+
* revisiting a question replaces its answer rather than appending a second
|
|
12
|
+
* one, and the questionnaire is complete only when every question has an
|
|
13
|
+
* answer. This mirrors the tab model of rpiv-ask-user-question.
|
|
8
14
|
*
|
|
9
15
|
* Pure: no pi imports, no rendering. The dialog drives it; tests drive it the
|
|
10
16
|
* same way.
|
|
@@ -27,10 +33,12 @@ export interface SelectableRow {
|
|
|
27
33
|
export class QuestionnaireSession {
|
|
28
34
|
private readonly params: AskUserParams;
|
|
29
35
|
private index = 0;
|
|
30
|
-
|
|
36
|
+
/** Sparse by design: `answers[i]` is undefined until question i is answered. */
|
|
37
|
+
private readonly answers: Array<QuestionAnswer | undefined>;
|
|
31
38
|
|
|
32
39
|
constructor(params: AskUserParams) {
|
|
33
40
|
this.params = params;
|
|
41
|
+
this.answers = new Array(params.questions.length).fill(undefined);
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
get questionIndex(): number {
|
|
@@ -45,16 +53,54 @@ export class QuestionnaireSession {
|
|
|
45
53
|
return this.params.questions[this.index];
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
/**
|
|
56
|
+
/** Move the cursor, wrapping at both ends. */
|
|
57
|
+
goTo(index: number): void {
|
|
58
|
+
if (this.total === 0) return;
|
|
59
|
+
this.index = ((index % this.total) + this.total) % this.total;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
next(): void {
|
|
63
|
+
this.goTo(this.index + 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
previous(): void {
|
|
67
|
+
this.goTo(this.index - 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True once every question has an answer. */
|
|
49
71
|
isComplete(): boolean {
|
|
50
|
-
return this.
|
|
72
|
+
return this.answers.every((answer) => answer !== undefined);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
isAnswered(index: number): boolean {
|
|
76
|
+
return this.answers[index] !== undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
answeredCount(): number {
|
|
80
|
+
return this.answers.filter((answer) => answer !== undefined).length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Move to the next question without an answer, searching forward from the
|
|
85
|
+
* cursor and wrapping. Returns false when everything is answered, which the
|
|
86
|
+
* dialog treats as "submit".
|
|
87
|
+
*/
|
|
88
|
+
advanceToUnanswered(): boolean {
|
|
89
|
+
for (let step = 1; step <= this.total; step++) {
|
|
90
|
+
const candidate = (this.index + step) % this.total;
|
|
91
|
+
if (!this.isAnswered(candidate)) {
|
|
92
|
+
this.index = candidate;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
51
97
|
}
|
|
52
98
|
|
|
53
99
|
/**
|
|
54
100
|
* Rows for the current question: the authored options plus the appended
|
|
55
101
|
* custom-answer sentinel. The sentinel is never an authored option — it is
|
|
56
|
-
* added here and stripped
|
|
57
|
-
*
|
|
102
|
+
* added here and stripped when recording, which is why validation rejects
|
|
103
|
+
* models that try to author it themselves.
|
|
58
104
|
*/
|
|
59
105
|
rows(): SelectableRow[] {
|
|
60
106
|
const question = this.current;
|
|
@@ -74,23 +120,33 @@ export class QuestionnaireSession {
|
|
|
74
120
|
}
|
|
75
121
|
|
|
76
122
|
/**
|
|
77
|
-
* Record
|
|
78
|
-
*
|
|
123
|
+
* Record (or replace) the answer for the current question. Replacement is
|
|
124
|
+
* the point: cycling back to a question and picking again must not leave
|
|
125
|
+
* two answers for one question in the envelope.
|
|
79
126
|
*/
|
|
80
127
|
recordAnswer(answer: string, opts: { custom?: boolean; notes?: string } = {}): void {
|
|
81
128
|
const question = this.current;
|
|
82
129
|
if (!question) return;
|
|
83
|
-
this.answers.
|
|
130
|
+
this.answers[this.index] = {
|
|
84
131
|
questionIndex: this.index,
|
|
85
132
|
question: question.question,
|
|
86
133
|
answer,
|
|
87
134
|
custom: opts.custom === true,
|
|
88
135
|
...(opts.notes ? { notes: opts.notes } : {}),
|
|
89
|
-
}
|
|
90
|
-
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The recorded answer for a question, if any. */
|
|
140
|
+
answerAt(index: number): QuestionAnswer | undefined {
|
|
141
|
+
return this.answers[index];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Header for question `index`, used by the tab strip. */
|
|
145
|
+
headerAt(index: number): string {
|
|
146
|
+
return this.params.questions[index]?.header ?? "";
|
|
91
147
|
}
|
|
92
148
|
|
|
93
|
-
/** Title line
|
|
149
|
+
/** Title line: `Header` alone, or `2 of 3 · Header` when there are tabs. */
|
|
94
150
|
title(): string {
|
|
95
151
|
const question = this.current;
|
|
96
152
|
if (!question) return "";
|
|
@@ -99,13 +155,17 @@ export class QuestionnaireSession {
|
|
|
99
155
|
: question.header;
|
|
100
156
|
}
|
|
101
157
|
|
|
158
|
+
private collected(): QuestionAnswer[] {
|
|
159
|
+
return this.answers.filter((answer): answer is QuestionAnswer => answer !== undefined);
|
|
160
|
+
}
|
|
161
|
+
|
|
102
162
|
/** Successful outcome. */
|
|
103
163
|
result(): QuestionnaireResult {
|
|
104
|
-
return { answers:
|
|
164
|
+
return { answers: this.collected(), cancelled: false };
|
|
105
165
|
}
|
|
106
166
|
|
|
107
167
|
/** Declined outcome, preserving any answers given before the cancel. */
|
|
108
168
|
cancelledResult(): QuestionnaireResult {
|
|
109
|
-
return { answers:
|
|
169
|
+
return { answers: this.collected(), cancelled: true };
|
|
110
170
|
}
|
|
111
171
|
}
|
package/tool/schema.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tool parameter schema and shared types for `ask_user_question`.
|
|
3
3
|
*
|
|
4
|
-
* v0.
|
|
5
|
-
* single-select, no preview.
|
|
6
|
-
*
|
|
7
|
-
* contract the model has already learned, and so session history recorded
|
|
8
|
-
* against the array shape keeps replaying.
|
|
4
|
+
* v0.2 scope (docs/specs/pi-ask-user-question.md §14): up to four questions,
|
|
5
|
+
* single-select, no preview. Questions render as tabs the user cycles with
|
|
6
|
+
* Tab / Shift+Tab.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import { Type } from "typebox";
|
|
@@ -16,8 +14,7 @@ export const TOOL_NAME = "ask_user_question";
|
|
|
16
14
|
export const MIN_OPTIONS = 2;
|
|
17
15
|
export const MAX_OPTIONS = 4;
|
|
18
16
|
export const MIN_QUESTIONS = 1;
|
|
19
|
-
|
|
20
|
-
export const MAX_QUESTIONS = 1;
|
|
17
|
+
export const MAX_QUESTIONS = 4;
|
|
21
18
|
|
|
22
19
|
export const MAX_HEADER_LENGTH = 16;
|
|
23
20
|
export const MAX_LABEL_LENGTH = 60;
|
|
@@ -63,7 +60,8 @@ export const QuestionSchema = Type.Object({
|
|
|
63
60
|
|
|
64
61
|
export const QuestionParamsSchema = Type.Object({
|
|
65
62
|
questions: Type.Array(QuestionSchema, {
|
|
66
|
-
description:
|
|
63
|
+
description:
|
|
64
|
+
"Questions to ask the user. 1-4 questions; the user cycles between them with Tab and answers each one.",
|
|
67
65
|
minItems: MIN_QUESTIONS,
|
|
68
66
|
maxItems: MAX_QUESTIONS,
|
|
69
67
|
}),
|
package/tool/validate.ts
CHANGED
|
@@ -49,10 +49,7 @@ export function validateParams(params: AskUserParams): ValidationError | undefin
|
|
|
49
49
|
if (!Array.isArray(questions) || questions.length < MIN_QUESTIONS || questions.length > MAX_QUESTIONS) {
|
|
50
50
|
return {
|
|
51
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}.`,
|
|
52
|
+
message: `questions must contain ${MIN_QUESTIONS}-${MAX_QUESTIONS} entries; received ${questions?.length ?? 0}. Group questions that belong to one decision; ask further questions in a follow-up call.`,
|
|
56
53
|
};
|
|
57
54
|
}
|
|
58
55
|
|
package/view/dialog.ts
CHANGED
|
@@ -2,13 +2,27 @@
|
|
|
2
2
|
* The questionnaire dialog: a pi-tui component driven by QuestionnaireSession
|
|
3
3
|
* and rendered through `ctx.ui.custom()`.
|
|
4
4
|
*
|
|
5
|
-
* NO MONKEY PATCHING. The numbered options, digit hotkeys and
|
|
5
|
+
* NO MONKEY PATCHING. The numbered options, digit hotkeys and the note editor
|
|
6
6
|
* come from `OptionSelector`, imported from the PUBLISHED sibling package
|
|
7
7
|
* `@hank-warren/pi-permission-selector` (plain `dependencies`, never
|
|
8
8
|
* `bundledDependencies` — AGENTS.md §Structure). See
|
|
9
9
|
* docs/specs/pi-ask-user-question.md §9.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* KEY MAP (matches @juicesharp/rpiv-ask-user-question):
|
|
12
|
+
*
|
|
13
|
+
* 1-9 select an option
|
|
14
|
+
* ↑ / ↓ move the highlight
|
|
15
|
+
* enter confirm the highlight
|
|
16
|
+
* n open the note editor for the highlighted option
|
|
17
|
+
* tab / → next question shift+tab / ← previous question
|
|
18
|
+
* esc decline the questionnaire
|
|
19
|
+
*
|
|
20
|
+
* Note that `n`, not Tab, opens notes here. Tab is reserved for cycling
|
|
21
|
+
* questions. pi-auto-permissions approval prompts keep Tab-to-comment, because
|
|
22
|
+
* they are single-question and have no tabs to cycle — `OptionSelector`
|
|
23
|
+
* defaults to Tab and this dialog overrides it.
|
|
24
|
+
*
|
|
25
|
+
* THREE RULES, all learned from shipping bugs:
|
|
12
26
|
*
|
|
13
27
|
* 1. NEVER compare key data with `===`. Every key check goes through the
|
|
14
28
|
* shared predicates in `.../keys.ts`. Under the Kitty keyboard protocol
|
|
@@ -16,18 +30,31 @@
|
|
|
16
30
|
* trapped the user in the custom-answer field with no way out.
|
|
17
31
|
* 2. ALWAYS pad rendered lines to the full overlay width. pi composites an
|
|
18
32
|
* overlay onto the chat line by line and only overwrites the columns the
|
|
19
|
-
* overlay actually emits; short lines let chat text show through
|
|
20
|
-
*
|
|
33
|
+
* overlay actually emits; short lines let chat text show through.
|
|
34
|
+
* 3. ALWAYS clamp lines to the inner width. A single over-long line breaks the
|
|
35
|
+
* right border and spills into the transcript, so `render` truncates as a
|
|
36
|
+
* last-resort invariant no matter what any content source produces.
|
|
21
37
|
*/
|
|
22
38
|
|
|
23
39
|
import {
|
|
24
40
|
isBackspaceKey,
|
|
41
|
+
isCharKey,
|
|
25
42
|
isEnterKey,
|
|
26
43
|
isEscapeKey,
|
|
44
|
+
isLeftKey,
|
|
27
45
|
isPrintable,
|
|
46
|
+
isRightKey,
|
|
47
|
+
isShiftTabKey,
|
|
48
|
+
isTabKey,
|
|
28
49
|
} from "@hank-warren/pi-permission-selector/keys.ts";
|
|
29
50
|
import { OptionSelector, type SelectorOption } from "@hank-warren/pi-permission-selector/selector.ts";
|
|
30
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
CURSOR_MARKER,
|
|
53
|
+
decodeKittyPrintable,
|
|
54
|
+
truncateToWidth,
|
|
55
|
+
visibleWidth,
|
|
56
|
+
wrapTextWithAnsi,
|
|
57
|
+
} from "@earendil-works/pi-tui";
|
|
31
58
|
import type { QuestionnaireSession } from "../questionnaire.ts";
|
|
32
59
|
import type { QuestionnaireResult } from "../tool/schema.ts";
|
|
33
60
|
|
|
@@ -42,72 +69,99 @@ export interface DialogOptions {
|
|
|
42
69
|
/** Called exactly once with the final outcome. */
|
|
43
70
|
done(result: QuestionnaireResult): void;
|
|
44
71
|
requestRender?(): void;
|
|
45
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Optional width cap. Unset means fill the overlay, which is what the
|
|
74
|
+
* bottom-anchored `width: "100%"` overlay wants: a narrower box would let
|
|
75
|
+
* the transcript show through to its right.
|
|
76
|
+
*/
|
|
46
77
|
maxWidth?: number;
|
|
47
78
|
}
|
|
48
79
|
|
|
49
|
-
const DEFAULT_MAX_WIDTH = 84;
|
|
50
80
|
/** Left/right border plus one space of padding on each side. */
|
|
51
81
|
const CHROME_COLUMNS = 4;
|
|
82
|
+
/** Indent for the custom-answer field, in columns. */
|
|
83
|
+
const FIELD_INDENT = " ";
|
|
84
|
+
/** Opens the note editor. Tab is taken by question cycling. */
|
|
85
|
+
const NOTE_KEY = "n";
|
|
86
|
+
const isNoteKey = isCharKey(NOTE_KEY);
|
|
87
|
+
|
|
88
|
+
/** Per-question UI state, preserved while cycling between tabs. */
|
|
89
|
+
interface TabState {
|
|
90
|
+
selector: OptionSelector;
|
|
91
|
+
customText?: string;
|
|
92
|
+
pendingNotes?: string;
|
|
93
|
+
}
|
|
52
94
|
|
|
53
95
|
export class QuestionnaireDialog {
|
|
54
96
|
/** Focusable — set by the TUI when focus changes. Drives CURSOR_MARKER. */
|
|
55
97
|
focused = false;
|
|
56
98
|
|
|
57
99
|
private readonly opts: DialogOptions;
|
|
58
|
-
private
|
|
59
|
-
private customText: string | undefined;
|
|
60
|
-
private pendingNotes: string | undefined;
|
|
100
|
+
private readonly tabs: TabState[] = [];
|
|
61
101
|
private finished = false;
|
|
62
102
|
|
|
63
103
|
constructor(opts: DialogOptions) {
|
|
64
104
|
this.opts = opts;
|
|
65
|
-
this.
|
|
105
|
+
for (let i = 0; i < this.session.total; i++) this.tabs.push({ selector: this.buildSelector(i) });
|
|
66
106
|
}
|
|
67
107
|
|
|
68
108
|
private get session() {
|
|
69
109
|
return this.opts.session;
|
|
70
110
|
}
|
|
71
111
|
|
|
112
|
+
private get tab(): TabState {
|
|
113
|
+
return this.tabs[this.session.questionIndex];
|
|
114
|
+
}
|
|
115
|
+
|
|
72
116
|
private repaint(): void {
|
|
73
117
|
this.opts.requestRender?.();
|
|
74
118
|
}
|
|
75
119
|
|
|
76
|
-
private buildSelector(): OptionSelector {
|
|
120
|
+
private buildSelector(index: number): OptionSelector {
|
|
77
121
|
const session = this.session;
|
|
122
|
+
const previous = session.goTo.bind(session);
|
|
123
|
+
// rows() reads the cursor, so snapshot this tab's rows at build time.
|
|
124
|
+
const restore = session.questionIndex;
|
|
125
|
+
previous(index);
|
|
78
126
|
const options: SelectorOption[] = session.rows().map((row) => ({
|
|
79
127
|
value: row.value,
|
|
80
128
|
label: row.label,
|
|
81
129
|
description: row.description,
|
|
82
130
|
}));
|
|
131
|
+
previous(restore);
|
|
83
132
|
|
|
84
133
|
return new OptionSelector({
|
|
85
134
|
options,
|
|
86
135
|
theme: this.opts.theme,
|
|
136
|
+
// `n` instead of Tab; Tab must stay free for question cycling. Tab is
|
|
137
|
+
// left unconsumed by the selector so this dialog can intercept it.
|
|
138
|
+
commentTrigger: isNoteKey,
|
|
139
|
+
commentKeyHint: NOTE_KEY,
|
|
87
140
|
onSelect: (option, comment) => {
|
|
141
|
+
session.goTo(index);
|
|
88
142
|
if (session.isCustomRow(option.value)) {
|
|
89
143
|
// Enter free-text mode. A note typed on the sentinel row is
|
|
90
144
|
// carried across so it is not silently lost.
|
|
91
|
-
this.pendingNotes = comment;
|
|
92
|
-
this.customText = "";
|
|
145
|
+
this.tabs[index].pendingNotes = comment;
|
|
146
|
+
this.tabs[index].customText = "";
|
|
93
147
|
this.repaint();
|
|
94
148
|
return;
|
|
95
149
|
}
|
|
96
150
|
session.recordAnswer(option.value, { notes: comment });
|
|
97
|
-
this.
|
|
151
|
+
this.afterAnswer();
|
|
98
152
|
},
|
|
99
153
|
onCancel: () => this.finish(session.cancelledResult()),
|
|
100
154
|
requestRender: () => this.repaint(),
|
|
101
155
|
});
|
|
102
156
|
}
|
|
103
157
|
|
|
104
|
-
|
|
158
|
+
/** Submit when everything is answered, else jump to the next gap. */
|
|
159
|
+
private afterAnswer(): void {
|
|
105
160
|
if (this.session.isComplete()) {
|
|
106
161
|
this.finish(this.session.result());
|
|
107
162
|
return;
|
|
108
163
|
}
|
|
109
|
-
|
|
110
|
-
this.selector = this.buildSelector();
|
|
164
|
+
this.session.advanceToUnanswered();
|
|
111
165
|
this.repaint();
|
|
112
166
|
}
|
|
113
167
|
|
|
@@ -119,11 +173,16 @@ export class QuestionnaireDialog {
|
|
|
119
173
|
|
|
120
174
|
/** True while the free-text custom-answer editor is open. */
|
|
121
175
|
isTypingCustom(): boolean {
|
|
122
|
-
return this.customText !== undefined;
|
|
176
|
+
return this.tab?.customText !== undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** True while any text-entry mode owns the keyboard. */
|
|
180
|
+
private isTyping(): boolean {
|
|
181
|
+
return this.isTypingCustom() || this.tab.selector.isCommenting();
|
|
123
182
|
}
|
|
124
183
|
|
|
125
184
|
invalidate(): void {
|
|
126
|
-
this.selector.invalidate();
|
|
185
|
+
for (const tab of this.tabs) tab.selector.invalidate();
|
|
127
186
|
}
|
|
128
187
|
|
|
129
188
|
private style(role: string, text: string): string {
|
|
@@ -131,37 +190,67 @@ export class QuestionnaireDialog {
|
|
|
131
190
|
return role === "dim" ? `\x1b[2m${text}\x1b[0m` : text;
|
|
132
191
|
}
|
|
133
192
|
|
|
193
|
+
/** `✓ Database ▸ Cache ○ Queue` — one line, only when tabs exist. */
|
|
194
|
+
private tabStrip(): string {
|
|
195
|
+
const parts: string[] = [];
|
|
196
|
+
for (let i = 0; i < this.session.total; i++) {
|
|
197
|
+
const current = i === this.session.questionIndex;
|
|
198
|
+
const mark = this.session.isAnswered(i) ? "✓" : current ? "▸" : "○";
|
|
199
|
+
const label = `${mark} ${this.session.headerAt(i)}`;
|
|
200
|
+
parts.push(current ? this.style("accent", label) : this.style("dim", label));
|
|
201
|
+
}
|
|
202
|
+
return parts.join(" ");
|
|
203
|
+
}
|
|
204
|
+
|
|
134
205
|
/** Inner content lines, before the box is drawn around them. */
|
|
135
206
|
private contentLines(inner: number): string[] {
|
|
136
207
|
const session = this.session;
|
|
137
|
-
const lines: string[] = [this.style("accent", session.title())
|
|
208
|
+
const lines: string[] = [this.style("accent", session.title())];
|
|
209
|
+
if (session.total > 1) lines.push(this.tabStrip());
|
|
210
|
+
lines.push("");
|
|
138
211
|
lines.push(...wrapTextWithAnsi(session.current?.question ?? "", inner));
|
|
139
212
|
lines.push("");
|
|
140
213
|
|
|
141
|
-
|
|
214
|
+
const tab = this.tab;
|
|
215
|
+
if (tab.customText !== undefined) {
|
|
216
|
+
// Wrap the typed answer. Without this a long answer ran past the right
|
|
217
|
+
// border and off the screen forever, because an input field renders as
|
|
218
|
+
// one line unless something breaks it up.
|
|
219
|
+
const avail = Math.max(1, inner - FIELD_INDENT.length - 1); // -1 reserves the caret cell
|
|
220
|
+
const wrapped = wrapTextWithAnsi(tab.customText, avail);
|
|
142
221
|
// CURSOR_MARKER is a zero-width APC sequence: the TUI strips it and
|
|
143
222
|
// parks the hardware cursor there, so the caret lands in the field
|
|
144
223
|
// instead of at the bottom of the screen.
|
|
145
|
-
const caret = this.focused ? CURSOR_MARKER : ""
|
|
146
|
-
|
|
224
|
+
const caret = `${this.focused ? CURSOR_MARKER : ""}▌`;
|
|
225
|
+
for (let i = 0; i < wrapped.length; i++) {
|
|
226
|
+
const last = i === wrapped.length - 1;
|
|
227
|
+
lines.push(`${FIELD_INDENT}${wrapped[i]}${last ? caret : ""}`);
|
|
228
|
+
}
|
|
147
229
|
lines.push("");
|
|
148
230
|
lines.push(this.style("dim", " enter submit · esc back to options"));
|
|
149
231
|
return lines;
|
|
150
232
|
}
|
|
151
233
|
|
|
152
|
-
lines.push(...
|
|
234
|
+
lines.push(...tab.selector.render(inner));
|
|
235
|
+
if (session.total > 1 && !tab.selector.isCommenting()) {
|
|
236
|
+
lines.push(this.style("dim", " tab next · shift+tab prev question"));
|
|
237
|
+
}
|
|
153
238
|
return lines;
|
|
154
239
|
}
|
|
155
240
|
|
|
156
241
|
render(width: number): string[] {
|
|
157
|
-
const
|
|
242
|
+
const cap = this.opts.maxWidth ?? width;
|
|
243
|
+
const outer = Math.max(20, Math.min(width, cap));
|
|
158
244
|
const inner = outer - CHROME_COLUMNS;
|
|
159
245
|
const border = (text: string) => this.style("dim", text);
|
|
160
246
|
|
|
161
247
|
const out: string[] = [border(`┌${"─".repeat(outer - 2)}┐`)];
|
|
162
|
-
for (const
|
|
163
|
-
//
|
|
164
|
-
//
|
|
248
|
+
for (const raw of this.contentLines(inner)) {
|
|
249
|
+
// Invariant (3): never let a line break the right border, whatever
|
|
250
|
+
// produced it.
|
|
251
|
+
const line = visibleWidth(raw) > inner ? truncateToWidth(raw, inner) : raw;
|
|
252
|
+
// Invariant (2): pad to the full inner width, or pi's overlay
|
|
253
|
+
// compositing leaves chat text visible to the right of each line.
|
|
165
254
|
const pad = Math.max(0, inner - visibleWidth(line));
|
|
166
255
|
out.push(`${border("│")} ${line}${" ".repeat(pad)} ${border("│")}`);
|
|
167
256
|
}
|
|
@@ -170,43 +259,62 @@ export class QuestionnaireDialog {
|
|
|
170
259
|
}
|
|
171
260
|
|
|
172
261
|
handleInput(keyData: string): void {
|
|
173
|
-
|
|
174
|
-
|
|
262
|
+
const tab = this.tab;
|
|
263
|
+
|
|
264
|
+
// Question cycling. Deliberately NOT active while typing: Tab inside the
|
|
265
|
+
// note editor means "back to options", and inside the custom-answer field
|
|
266
|
+
// a stray Tab must never teleport the user to another question and strand
|
|
267
|
+
// their half-typed text.
|
|
268
|
+
if (this.session.total > 1 && !this.isTyping()) {
|
|
269
|
+
if (isTabKey(keyData) || isRightKey(keyData)) {
|
|
270
|
+
this.session.next();
|
|
271
|
+
this.repaint();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (isShiftTabKey(keyData) || isLeftKey(keyData)) {
|
|
275
|
+
this.session.previous();
|
|
276
|
+
this.repaint();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (tab.customText === undefined) {
|
|
282
|
+
tab.selector.handleInput(keyData);
|
|
175
283
|
return;
|
|
176
284
|
}
|
|
177
285
|
|
|
178
286
|
// Free-text mode. Every check below MUST use a shared predicate — see
|
|
179
|
-
//
|
|
180
|
-
//
|
|
287
|
+
// rule 1. Esc unwinds to the option list rather than cancelling the
|
|
288
|
+
// dialog: one Esc never discards more than one layer.
|
|
181
289
|
if (isEscapeKey(keyData)) {
|
|
182
|
-
|
|
183
|
-
|
|
290
|
+
tab.customText = undefined;
|
|
291
|
+
tab.pendingNotes = undefined;
|
|
184
292
|
this.repaint();
|
|
185
293
|
return;
|
|
186
294
|
}
|
|
187
295
|
if (isEnterKey(keyData)) {
|
|
188
|
-
const text =
|
|
296
|
+
const text = tab.customText.trim();
|
|
189
297
|
if (text.length === 0) return; // Empty custom answers are not submittable.
|
|
190
|
-
this.session.recordAnswer(text, { custom: true, notes:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
this.
|
|
298
|
+
this.session.recordAnswer(text, { custom: true, notes: tab.pendingNotes });
|
|
299
|
+
tab.customText = undefined;
|
|
300
|
+
tab.pendingNotes = undefined;
|
|
301
|
+
this.afterAnswer();
|
|
194
302
|
return;
|
|
195
303
|
}
|
|
196
304
|
if (isBackspaceKey(keyData)) {
|
|
197
|
-
|
|
305
|
+
tab.customText = tab.customText.slice(0, -1);
|
|
198
306
|
this.repaint();
|
|
199
307
|
return;
|
|
200
308
|
}
|
|
201
309
|
if (isPrintable(keyData)) {
|
|
202
|
-
|
|
310
|
+
tab.customText += keyData;
|
|
203
311
|
this.repaint();
|
|
204
312
|
return;
|
|
205
313
|
}
|
|
206
314
|
// Kitty/modifyOtherKeys terminals encode plain printables as CSI-u.
|
|
207
315
|
const decoded = decodeKittyPrintable(keyData);
|
|
208
316
|
if (decoded !== undefined && isPrintable(decoded)) {
|
|
209
|
-
|
|
317
|
+
tab.customText += decoded;
|
|
210
318
|
this.repaint();
|
|
211
319
|
}
|
|
212
320
|
// Anything else (arrows, unhandled chords) is inert — never inserted
|