@hank-warren/pi-ask-user-question 0.2.2 → 0.4.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 +25 -4
- package/ask-user-question.ts +10 -4
- package/package.json +2 -2
- package/questionnaire.ts +84 -16
- package/tool/envelope.ts +6 -1
- package/tool/schema.ts +16 -9
- package/tool/validate.ts +1 -4
- package/view/dialog.ts +177 -35
package/README.md
CHANGED
|
@@ -4,8 +4,9 @@ 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
|
-
>
|
|
7
|
+
> **v0.3** ships 1-4 questions per call as cycleable tabs, single-select, with
|
|
8
|
+
> an optional preview pane. See
|
|
9
|
+
> [the spec](../../docs/specs/pi-ask-user-question.md) §14.
|
|
9
10
|
|
|
10
11
|
## Install
|
|
11
12
|
|
|
@@ -23,8 +24,28 @@ register a tool named `ask_user_question`.
|
|
|
23
24
|
| `1`–`9` | Select that option immediately |
|
|
24
25
|
| `↑` / `↓` | Move the highlight |
|
|
25
26
|
| `Enter` | Confirm the highlighted option |
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
27
|
+
| `n` | Attach a note to your choice, then `Enter` to send both |
|
|
28
|
+
| `Tab` / `→` | Next question |
|
|
29
|
+
| `Shift+Tab` / `←` | Previous question |
|
|
30
|
+
| `Esc` | Decline the questionnaire (or leave note/typed-answer mode) |
|
|
31
|
+
|
|
32
|
+
With several questions, each is a tab you can cycle through in any order;
|
|
33
|
+
answering one jumps to the next unanswered question, and the call returns once
|
|
34
|
+
every question has an answer. Cycling back and re-answering replaces that
|
|
35
|
+
question's answer rather than recording a second one.
|
|
36
|
+
|
|
37
|
+
## Previews
|
|
38
|
+
|
|
39
|
+
An option may carry a `preview` field — markdown shown in a pane below the
|
|
40
|
+
options while that option is highlighted. Use it for concrete artifacts worth
|
|
41
|
+
comparing (ASCII mockups, code snippets, configuration variations), not for
|
|
42
|
+
simple preference questions. The pane is stacked rather than side-by-side, and
|
|
43
|
+
long previews are clipped with a `… N more lines` marker so the options always
|
|
44
|
+
stay visible.
|
|
45
|
+
|
|
46
|
+
**Why `n` and not `Tab` for notes?** Tab cycles questions here, matching
|
|
47
|
+
`@juicesharp/rpiv-ask-user-question`. `pi-auto-permissions` approval prompts
|
|
48
|
+
keep `Tab` for notes — they are single-question and have no tabs to cycle.
|
|
28
49
|
|
|
29
50
|
Every question gets an appended **`Type something.`** row for a free-text
|
|
30
51
|
answer. The model is not allowed to author that row itself — reserved labels
|
package/ask-user-question.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* package replays cleanly. Only one of the two may be installed at a time.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type
|
|
9
|
+
import { type ExtensionAPI, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import {
|
|
11
11
|
ASK_USER_BLOCKED_EVENT,
|
|
12
12
|
ASK_USER_PROMPT_EVENT,
|
|
@@ -35,16 +35,19 @@ 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
|
+
Preview feature:
|
|
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
|
+
|
|
44
47
|
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
|
|
48
|
+
- 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
49
|
- If you recommend a specific option, make it the first option and add "(Recommended)" at the end of the label.
|
|
47
|
-
-
|
|
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.`;
|
|
48
51
|
|
|
49
52
|
function emitPrompt(pi: ExtensionAPI, params: AskUserParams): void {
|
|
50
53
|
const payload: AskUserPromptEventPayload = {
|
|
@@ -96,6 +99,9 @@ export function registerTool(pi: ExtensionAPI): void {
|
|
|
96
99
|
new QuestionnaireDialog({
|
|
97
100
|
session,
|
|
98
101
|
theme,
|
|
102
|
+
// Reuse pi's own markdown theme so previews match the
|
|
103
|
+
// transcript rather than inventing a second code style.
|
|
104
|
+
markdownTheme: getMarkdownTheme(),
|
|
99
105
|
done,
|
|
100
106
|
requestRender: () => tui.requestRender(),
|
|
101
107
|
}),
|
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.4.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.2
|
|
48
|
+
"@hank-warren/pi-permission-selector": "^0.3.2"
|
|
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.
|
|
@@ -22,15 +28,19 @@ export interface SelectableRow {
|
|
|
22
28
|
value: string;
|
|
23
29
|
label: string;
|
|
24
30
|
description?: string;
|
|
31
|
+
/** Markdown shown in the preview pane while this row is highlighted. */
|
|
32
|
+
preview?: string;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
export class QuestionnaireSession {
|
|
28
36
|
private readonly params: AskUserParams;
|
|
29
37
|
private index = 0;
|
|
30
|
-
|
|
38
|
+
/** Sparse by design: `answers[i]` is undefined until question i is answered. */
|
|
39
|
+
private readonly answers: Array<QuestionAnswer | undefined>;
|
|
31
40
|
|
|
32
41
|
constructor(params: AskUserParams) {
|
|
33
42
|
this.params = params;
|
|
43
|
+
this.answers = new Array(params.questions.length).fill(undefined);
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
get questionIndex(): number {
|
|
@@ -45,16 +55,54 @@ export class QuestionnaireSession {
|
|
|
45
55
|
return this.params.questions[this.index];
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
/**
|
|
58
|
+
/** Move the cursor, wrapping at both ends. */
|
|
59
|
+
goTo(index: number): void {
|
|
60
|
+
if (this.total === 0) return;
|
|
61
|
+
this.index = ((index % this.total) + this.total) % this.total;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
next(): void {
|
|
65
|
+
this.goTo(this.index + 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
previous(): void {
|
|
69
|
+
this.goTo(this.index - 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True once every question has an answer. */
|
|
49
73
|
isComplete(): boolean {
|
|
50
|
-
return this.
|
|
74
|
+
return this.answers.every((answer) => answer !== undefined);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
isAnswered(index: number): boolean {
|
|
78
|
+
return this.answers[index] !== undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
answeredCount(): number {
|
|
82
|
+
return this.answers.filter((answer) => answer !== undefined).length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Move to the next question without an answer, searching forward from the
|
|
87
|
+
* cursor and wrapping. Returns false when everything is answered, which the
|
|
88
|
+
* dialog treats as "submit".
|
|
89
|
+
*/
|
|
90
|
+
advanceToUnanswered(): boolean {
|
|
91
|
+
for (let step = 1; step <= this.total; step++) {
|
|
92
|
+
const candidate = (this.index + step) % this.total;
|
|
93
|
+
if (!this.isAnswered(candidate)) {
|
|
94
|
+
this.index = candidate;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
51
99
|
}
|
|
52
100
|
|
|
53
101
|
/**
|
|
54
102
|
* Rows for the current question: the authored options plus the appended
|
|
55
103
|
* custom-answer sentinel. The sentinel is never an authored option — it is
|
|
56
|
-
* added here and stripped
|
|
57
|
-
*
|
|
104
|
+
* added here and stripped when recording, which is why validation rejects
|
|
105
|
+
* models that try to author it themselves.
|
|
58
106
|
*/
|
|
59
107
|
rows(): SelectableRow[] {
|
|
60
108
|
const question = this.current;
|
|
@@ -63,6 +111,7 @@ export class QuestionnaireSession {
|
|
|
63
111
|
value: option.label,
|
|
64
112
|
label: option.label,
|
|
65
113
|
description: option.description,
|
|
114
|
+
...(option.preview ? { preview: option.preview } : {}),
|
|
66
115
|
}));
|
|
67
116
|
rows.push({ value: CUSTOM_ANSWER_VALUE, label: CUSTOM_ANSWER_LABEL });
|
|
68
117
|
return rows;
|
|
@@ -74,23 +123,38 @@ export class QuestionnaireSession {
|
|
|
74
123
|
}
|
|
75
124
|
|
|
76
125
|
/**
|
|
77
|
-
* Record
|
|
78
|
-
*
|
|
126
|
+
* Record (or replace) the answer for the current question. Replacement is
|
|
127
|
+
* the point: cycling back to a question and picking again must not leave
|
|
128
|
+
* two answers for one question in the envelope.
|
|
79
129
|
*/
|
|
80
130
|
recordAnswer(answer: string, opts: { custom?: boolean; notes?: string } = {}): void {
|
|
81
131
|
const question = this.current;
|
|
82
132
|
if (!question) return;
|
|
83
|
-
|
|
133
|
+
// A custom typed answer never carries a preview; only a chosen option can.
|
|
134
|
+
const preview = opts.custom
|
|
135
|
+
? undefined
|
|
136
|
+
: question.options.find((option) => option.label === answer)?.preview;
|
|
137
|
+
this.answers[this.index] = {
|
|
84
138
|
questionIndex: this.index,
|
|
85
139
|
question: question.question,
|
|
86
140
|
answer,
|
|
87
141
|
custom: opts.custom === true,
|
|
88
142
|
...(opts.notes ? { notes: opts.notes } : {}),
|
|
89
|
-
|
|
90
|
-
|
|
143
|
+
...(preview ? { preview } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The recorded answer for a question, if any. */
|
|
148
|
+
answerAt(index: number): QuestionAnswer | undefined {
|
|
149
|
+
return this.answers[index];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Header for question `index`, used by the tab strip. */
|
|
153
|
+
headerAt(index: number): string {
|
|
154
|
+
return this.params.questions[index]?.header ?? "";
|
|
91
155
|
}
|
|
92
156
|
|
|
93
|
-
/** Title line
|
|
157
|
+
/** Title line: `Header` alone, or `2 of 3 · Header` when there are tabs. */
|
|
94
158
|
title(): string {
|
|
95
159
|
const question = this.current;
|
|
96
160
|
if (!question) return "";
|
|
@@ -99,13 +163,17 @@ export class QuestionnaireSession {
|
|
|
99
163
|
: question.header;
|
|
100
164
|
}
|
|
101
165
|
|
|
166
|
+
private collected(): QuestionAnswer[] {
|
|
167
|
+
return this.answers.filter((answer): answer is QuestionAnswer => answer !== undefined);
|
|
168
|
+
}
|
|
169
|
+
|
|
102
170
|
/** Successful outcome. */
|
|
103
171
|
result(): QuestionnaireResult {
|
|
104
|
-
return { answers:
|
|
172
|
+
return { answers: this.collected(), cancelled: false };
|
|
105
173
|
}
|
|
106
174
|
|
|
107
175
|
/** Declined outcome, preserving any answers given before the cancel. */
|
|
108
176
|
cancelledResult(): QuestionnaireResult {
|
|
109
|
-
return { answers:
|
|
177
|
+
return { answers: this.collected(), cancelled: true };
|
|
110
178
|
}
|
|
111
179
|
}
|
package/tool/envelope.ts
CHANGED
|
@@ -23,9 +23,14 @@ export function buildToolResult(text: string, details: QuestionnaireResult): Too
|
|
|
23
23
|
return { content: [{ type: "text" as const, text }], details };
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Format a single answer as `"question"="answer"`, with the chosen option's
|
|
28
|
+
* preview and the user's note appended when present. Order and wording match
|
|
29
|
+
* rpiv's envelope and are pinned by tests.
|
|
30
|
+
*/
|
|
27
31
|
export function buildAnswerSegment(a: QuestionAnswer): string {
|
|
28
32
|
const parts: string[] = [`"${a.question}"="${a.answer}"`];
|
|
33
|
+
if (a.preview && a.preview.length > 0) parts.push(`selected preview: ${a.preview}`);
|
|
29
34
|
if (a.notes && a.notes.length > 0) parts.push(`user notes: ${a.notes}`);
|
|
30
35
|
return `${parts.join(". ")}.`;
|
|
31
36
|
}
|
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;
|
|
@@ -42,6 +39,12 @@ export const OptionSchema = Type.Object({
|
|
|
42
39
|
description:
|
|
43
40
|
"Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.",
|
|
44
41
|
}),
|
|
42
|
+
preview: Type.Optional(
|
|
43
|
+
Type.String({
|
|
44
|
+
description:
|
|
45
|
+
"Optional markdown shown in a pane below the options while this option is highlighted. Use for concrete artifacts the user needs to compare: ASCII mockups, code snippets, diagram or configuration variations. Do NOT use it for simple preference questions where the label and description already say enough.",
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
45
48
|
});
|
|
46
49
|
|
|
47
50
|
export const QuestionSchema = Type.Object({
|
|
@@ -63,7 +66,8 @@ export const QuestionSchema = Type.Object({
|
|
|
63
66
|
|
|
64
67
|
export const QuestionParamsSchema = Type.Object({
|
|
65
68
|
questions: Type.Array(QuestionSchema, {
|
|
66
|
-
description:
|
|
69
|
+
description:
|
|
70
|
+
"Questions to ask the user. 1-4 questions; the user cycles between them with Tab and answers each one.",
|
|
67
71
|
minItems: MIN_QUESTIONS,
|
|
68
72
|
maxItems: MAX_QUESTIONS,
|
|
69
73
|
}),
|
|
@@ -72,6 +76,7 @@ export const QuestionParamsSchema = Type.Object({
|
|
|
72
76
|
export interface OptionParams {
|
|
73
77
|
label: string;
|
|
74
78
|
description: string;
|
|
79
|
+
preview?: string;
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
export interface QuestionParams {
|
|
@@ -90,8 +95,10 @@ export interface QuestionAnswer {
|
|
|
90
95
|
question: string;
|
|
91
96
|
answer: string;
|
|
92
97
|
custom: boolean;
|
|
93
|
-
/** Trimmed
|
|
98
|
+
/** Trimmed note, when the user attached one. */
|
|
94
99
|
notes?: string;
|
|
100
|
+
/** Preview text of the chosen option, when it carried one. */
|
|
101
|
+
preview?: string;
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
export interface QuestionnaireResult {
|
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,8 +30,7 @@
|
|
|
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
|
-
* dialog renders as garbage interleaved with the transcript.
|
|
33
|
+
* overlay actually emits; short lines let chat text show through.
|
|
21
34
|
* 3. ALWAYS clamp lines to the inner width. A single over-long line breaks the
|
|
22
35
|
* right border and spills into the transcript, so `render` truncates as a
|
|
23
36
|
* last-resort invariant no matter what any content source produces.
|
|
@@ -25,9 +38,14 @@
|
|
|
25
38
|
|
|
26
39
|
import {
|
|
27
40
|
isBackspaceKey,
|
|
41
|
+
isCharKey,
|
|
28
42
|
isEnterKey,
|
|
29
43
|
isEscapeKey,
|
|
44
|
+
isLeftKey,
|
|
30
45
|
isPrintable,
|
|
46
|
+
isRightKey,
|
|
47
|
+
isShiftTabKey,
|
|
48
|
+
isTabKey,
|
|
31
49
|
} from "@hank-warren/pi-permission-selector/keys.ts";
|
|
32
50
|
import { OptionSelector, type SelectorOption } from "@hank-warren/pi-permission-selector/selector.ts";
|
|
33
51
|
import {
|
|
@@ -37,6 +55,7 @@ import {
|
|
|
37
55
|
visibleWidth,
|
|
38
56
|
wrapTextWithAnsi,
|
|
39
57
|
} from "@earendil-works/pi-tui";
|
|
58
|
+
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
40
59
|
import type { QuestionnaireSession } from "../questionnaire.ts";
|
|
41
60
|
import type { QuestionnaireResult } from "../tool/schema.ts";
|
|
42
61
|
|
|
@@ -57,71 +76,101 @@ export interface DialogOptions {
|
|
|
57
76
|
* the transcript show through to its right.
|
|
58
77
|
*/
|
|
59
78
|
maxWidth?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Markdown theme for the preview pane. Hosts pass pi's `getMarkdownTheme()`;
|
|
81
|
+
* when omitted the preview renders as plain wrapped text, which keeps this
|
|
82
|
+
* component unit-testable without constructing a 14-function theme.
|
|
83
|
+
*/
|
|
84
|
+
markdownTheme?: MarkdownTheme;
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
/** Left/right border plus one space of padding on each side. */
|
|
63
88
|
const CHROME_COLUMNS = 4;
|
|
64
89
|
/** Indent for the custom-answer field, in columns. */
|
|
65
90
|
const FIELD_INDENT = " ";
|
|
91
|
+
/** Opens the note editor. Tab is taken by question cycling. */
|
|
92
|
+
const NOTE_KEY = "n";
|
|
93
|
+
const isNoteKey = isCharKey(NOTE_KEY);
|
|
94
|
+
/** Maximum rows the preview pane may occupy before it is clipped. */
|
|
95
|
+
const PREVIEW_MAX_ROWS = 12;
|
|
96
|
+
|
|
97
|
+
/** Per-question UI state, preserved while cycling between tabs. */
|
|
98
|
+
interface TabState {
|
|
99
|
+
selector: OptionSelector;
|
|
100
|
+
customText?: string;
|
|
101
|
+
pendingNotes?: string;
|
|
102
|
+
}
|
|
66
103
|
|
|
67
104
|
export class QuestionnaireDialog {
|
|
68
105
|
/** Focusable — set by the TUI when focus changes. Drives CURSOR_MARKER. */
|
|
69
106
|
focused = false;
|
|
70
107
|
|
|
71
108
|
private readonly opts: DialogOptions;
|
|
72
|
-
private
|
|
73
|
-
private customText: string | undefined;
|
|
74
|
-
private pendingNotes: string | undefined;
|
|
109
|
+
private readonly tabs: TabState[] = [];
|
|
75
110
|
private finished = false;
|
|
76
111
|
|
|
77
112
|
constructor(opts: DialogOptions) {
|
|
78
113
|
this.opts = opts;
|
|
79
|
-
this.
|
|
114
|
+
for (let i = 0; i < this.session.total; i++) this.tabs.push({ selector: this.buildSelector(i) });
|
|
80
115
|
}
|
|
81
116
|
|
|
82
117
|
private get session() {
|
|
83
118
|
return this.opts.session;
|
|
84
119
|
}
|
|
85
120
|
|
|
121
|
+
private get tab(): TabState {
|
|
122
|
+
return this.tabs[this.session.questionIndex];
|
|
123
|
+
}
|
|
124
|
+
|
|
86
125
|
private repaint(): void {
|
|
87
126
|
this.opts.requestRender?.();
|
|
88
127
|
}
|
|
89
128
|
|
|
90
|
-
private buildSelector(): OptionSelector {
|
|
129
|
+
private buildSelector(index: number): OptionSelector {
|
|
91
130
|
const session = this.session;
|
|
131
|
+
const previous = session.goTo.bind(session);
|
|
132
|
+
// rows() reads the cursor, so snapshot this tab's rows at build time.
|
|
133
|
+
const restore = session.questionIndex;
|
|
134
|
+
previous(index);
|
|
92
135
|
const options: SelectorOption[] = session.rows().map((row) => ({
|
|
93
136
|
value: row.value,
|
|
94
137
|
label: row.label,
|
|
95
138
|
description: row.description,
|
|
96
139
|
}));
|
|
140
|
+
previous(restore);
|
|
97
141
|
|
|
98
142
|
return new OptionSelector({
|
|
99
143
|
options,
|
|
100
144
|
theme: this.opts.theme,
|
|
145
|
+
// `n` instead of Tab; Tab must stay free for question cycling. Tab is
|
|
146
|
+
// left unconsumed by the selector so this dialog can intercept it.
|
|
147
|
+
commentTrigger: isNoteKey,
|
|
148
|
+
commentKeyHint: NOTE_KEY,
|
|
101
149
|
onSelect: (option, comment) => {
|
|
150
|
+
session.goTo(index);
|
|
102
151
|
if (session.isCustomRow(option.value)) {
|
|
103
152
|
// Enter free-text mode. A note typed on the sentinel row is
|
|
104
153
|
// carried across so it is not silently lost.
|
|
105
|
-
this.pendingNotes = comment;
|
|
106
|
-
this.customText = "";
|
|
154
|
+
this.tabs[index].pendingNotes = comment;
|
|
155
|
+
this.tabs[index].customText = "";
|
|
107
156
|
this.repaint();
|
|
108
157
|
return;
|
|
109
158
|
}
|
|
110
159
|
session.recordAnswer(option.value, { notes: comment });
|
|
111
|
-
this.
|
|
160
|
+
this.afterAnswer();
|
|
112
161
|
},
|
|
113
162
|
onCancel: () => this.finish(session.cancelledResult()),
|
|
114
163
|
requestRender: () => this.repaint(),
|
|
115
164
|
});
|
|
116
165
|
}
|
|
117
166
|
|
|
118
|
-
|
|
167
|
+
/** Submit when everything is answered, else jump to the next gap. */
|
|
168
|
+
private afterAnswer(): void {
|
|
119
169
|
if (this.session.isComplete()) {
|
|
120
170
|
this.finish(this.session.result());
|
|
121
171
|
return;
|
|
122
172
|
}
|
|
123
|
-
|
|
124
|
-
this.selector = this.buildSelector();
|
|
173
|
+
this.session.advanceToUnanswered();
|
|
125
174
|
this.repaint();
|
|
126
175
|
}
|
|
127
176
|
|
|
@@ -133,11 +182,16 @@ export class QuestionnaireDialog {
|
|
|
133
182
|
|
|
134
183
|
/** True while the free-text custom-answer editor is open. */
|
|
135
184
|
isTypingCustom(): boolean {
|
|
136
|
-
return this.customText !== undefined;
|
|
185
|
+
return this.tab?.customText !== undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** True while any text-entry mode owns the keyboard. */
|
|
189
|
+
private isTyping(): boolean {
|
|
190
|
+
return this.isTypingCustom() || this.tab.selector.isCommenting();
|
|
137
191
|
}
|
|
138
192
|
|
|
139
193
|
invalidate(): void {
|
|
140
|
-
this.selector.invalidate();
|
|
194
|
+
for (const tab of this.tabs) tab.selector.invalidate();
|
|
141
195
|
}
|
|
142
196
|
|
|
143
197
|
private style(role: string, text: string): string {
|
|
@@ -145,19 +199,34 @@ export class QuestionnaireDialog {
|
|
|
145
199
|
return role === "dim" ? `\x1b[2m${text}\x1b[0m` : text;
|
|
146
200
|
}
|
|
147
201
|
|
|
202
|
+
/** `✓ Database ▸ Cache ○ Queue` — one line, only when tabs exist. */
|
|
203
|
+
private tabStrip(): string {
|
|
204
|
+
const parts: string[] = [];
|
|
205
|
+
for (let i = 0; i < this.session.total; i++) {
|
|
206
|
+
const current = i === this.session.questionIndex;
|
|
207
|
+
const mark = this.session.isAnswered(i) ? "✓" : current ? "▸" : "○";
|
|
208
|
+
const label = `${mark} ${this.session.headerAt(i)}`;
|
|
209
|
+
parts.push(current ? this.style("accent", label) : this.style("dim", label));
|
|
210
|
+
}
|
|
211
|
+
return parts.join(" ");
|
|
212
|
+
}
|
|
213
|
+
|
|
148
214
|
/** Inner content lines, before the box is drawn around them. */
|
|
149
215
|
private contentLines(inner: number): string[] {
|
|
150
216
|
const session = this.session;
|
|
151
|
-
const lines: string[] = [this.style("accent", session.title())
|
|
217
|
+
const lines: string[] = [this.style("accent", session.title())];
|
|
218
|
+
if (session.total > 1) lines.push(this.tabStrip());
|
|
219
|
+
lines.push("");
|
|
152
220
|
lines.push(...wrapTextWithAnsi(session.current?.question ?? "", inner));
|
|
153
221
|
lines.push("");
|
|
154
222
|
|
|
155
|
-
|
|
223
|
+
const tab = this.tab;
|
|
224
|
+
if (tab.customText !== undefined) {
|
|
156
225
|
// Wrap the typed answer. Without this a long answer ran past the right
|
|
157
226
|
// border and off the screen forever, because an input field renders as
|
|
158
227
|
// one line unless something breaks it up.
|
|
159
228
|
const avail = Math.max(1, inner - FIELD_INDENT.length - 1); // -1 reserves the caret cell
|
|
160
|
-
const wrapped = wrapTextWithAnsi(
|
|
229
|
+
const wrapped = wrapTextWithAnsi(tab.customText, avail);
|
|
161
230
|
// CURSOR_MARKER is a zero-width APC sequence: the TUI strips it and
|
|
162
231
|
// parks the hardware cursor there, so the caret lands in the field
|
|
163
232
|
// instead of at the bottom of the screen.
|
|
@@ -171,10 +240,64 @@ export class QuestionnaireDialog {
|
|
|
171
240
|
return lines;
|
|
172
241
|
}
|
|
173
242
|
|
|
174
|
-
lines.push(...
|
|
243
|
+
lines.push(...tab.selector.render(inner));
|
|
244
|
+
lines.push(...this.previewLines(inner));
|
|
245
|
+
if (session.total > 1 && !tab.selector.isCommenting()) {
|
|
246
|
+
lines.push(this.style("dim", " tab next · shift+tab prev question"));
|
|
247
|
+
}
|
|
175
248
|
return lines;
|
|
176
249
|
}
|
|
177
250
|
|
|
251
|
+
/** Preview markdown for the highlighted row, if it carries any. */
|
|
252
|
+
private currentPreview(): string | undefined {
|
|
253
|
+
const selected = this.tab.selector.getSelected();
|
|
254
|
+
if (!selected) return undefined;
|
|
255
|
+
return this.session.rows().find((row) => row.value === selected.value)?.preview;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The preview pane: markdown between two rules, below the options.
|
|
260
|
+
*
|
|
261
|
+
* Stacked, not side-by-side. rpiv spent ~712 lines largely on making a
|
|
262
|
+
* two-column layout behave at narrow widths; stacking needs none of it
|
|
263
|
+
* (docs/specs/pi-ask-user-question.md §6.2).
|
|
264
|
+
*/
|
|
265
|
+
private previewLines(inner: number): string[] {
|
|
266
|
+
const preview = this.currentPreview();
|
|
267
|
+
if (!preview) return [];
|
|
268
|
+
|
|
269
|
+
const rendered = this.renderMarkdown(preview, inner);
|
|
270
|
+
|
|
271
|
+
// Cap the pane so a long preview cannot push the options off screen.
|
|
272
|
+
const clipped = rendered.slice(0, PREVIEW_MAX_ROWS);
|
|
273
|
+
if (rendered.length > PREVIEW_MAX_ROWS) {
|
|
274
|
+
clipped.push(this.style("dim", `… ${rendered.length - PREVIEW_MAX_ROWS} more lines`));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const rule = this.style("dim", "─".repeat(Math.max(1, inner)));
|
|
278
|
+
return ["", rule, ...clipped, rule];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Render preview markdown, falling back to plain wrapped text.
|
|
283
|
+
*
|
|
284
|
+
* The fallback is not just for hosts that pass no theme. pi's
|
|
285
|
+
* `getMarkdownTheme()` returns lazily-bound functions that throw
|
|
286
|
+
* "Theme not initialized" until `initTheme()` has run, and a throw inside
|
|
287
|
+
* `render()` would take down the entire dialog rather than one pane. A
|
|
288
|
+
* preview is a nicety; it must never be able to do that.
|
|
289
|
+
*/
|
|
290
|
+
private renderMarkdown(preview: string, inner: number): string[] {
|
|
291
|
+
if (this.opts.markdownTheme) {
|
|
292
|
+
try {
|
|
293
|
+
return new Markdown(preview, 0, 0, this.opts.markdownTheme).render(inner);
|
|
294
|
+
} catch {
|
|
295
|
+
// fall through to plain text
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return preview.split("\n").flatMap((line) => wrapTextWithAnsi(line, inner));
|
|
299
|
+
}
|
|
300
|
+
|
|
178
301
|
render(width: number): string[] {
|
|
179
302
|
const cap = this.opts.maxWidth ?? width;
|
|
180
303
|
const outer = Math.max(20, Math.min(width, cap));
|
|
@@ -196,43 +319,62 @@ export class QuestionnaireDialog {
|
|
|
196
319
|
}
|
|
197
320
|
|
|
198
321
|
handleInput(keyData: string): void {
|
|
199
|
-
|
|
200
|
-
|
|
322
|
+
const tab = this.tab;
|
|
323
|
+
|
|
324
|
+
// Question cycling. Deliberately NOT active while typing: Tab inside the
|
|
325
|
+
// note editor means "back to options", and inside the custom-answer field
|
|
326
|
+
// a stray Tab must never teleport the user to another question and strand
|
|
327
|
+
// their half-typed text.
|
|
328
|
+
if (this.session.total > 1 && !this.isTyping()) {
|
|
329
|
+
if (isTabKey(keyData) || isRightKey(keyData)) {
|
|
330
|
+
this.session.next();
|
|
331
|
+
this.repaint();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (isShiftTabKey(keyData) || isLeftKey(keyData)) {
|
|
335
|
+
this.session.previous();
|
|
336
|
+
this.repaint();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (tab.customText === undefined) {
|
|
342
|
+
tab.selector.handleInput(keyData);
|
|
201
343
|
return;
|
|
202
344
|
}
|
|
203
345
|
|
|
204
346
|
// Free-text mode. Every check below MUST use a shared predicate — see
|
|
205
|
-
//
|
|
206
|
-
//
|
|
347
|
+
// rule 1. Esc unwinds to the option list rather than cancelling the
|
|
348
|
+
// dialog: one Esc never discards more than one layer.
|
|
207
349
|
if (isEscapeKey(keyData)) {
|
|
208
|
-
|
|
209
|
-
|
|
350
|
+
tab.customText = undefined;
|
|
351
|
+
tab.pendingNotes = undefined;
|
|
210
352
|
this.repaint();
|
|
211
353
|
return;
|
|
212
354
|
}
|
|
213
355
|
if (isEnterKey(keyData)) {
|
|
214
|
-
const text =
|
|
356
|
+
const text = tab.customText.trim();
|
|
215
357
|
if (text.length === 0) return; // Empty custom answers are not submittable.
|
|
216
|
-
this.session.recordAnswer(text, { custom: true, notes:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
this.
|
|
358
|
+
this.session.recordAnswer(text, { custom: true, notes: tab.pendingNotes });
|
|
359
|
+
tab.customText = undefined;
|
|
360
|
+
tab.pendingNotes = undefined;
|
|
361
|
+
this.afterAnswer();
|
|
220
362
|
return;
|
|
221
363
|
}
|
|
222
364
|
if (isBackspaceKey(keyData)) {
|
|
223
|
-
|
|
365
|
+
tab.customText = tab.customText.slice(0, -1);
|
|
224
366
|
this.repaint();
|
|
225
367
|
return;
|
|
226
368
|
}
|
|
227
369
|
if (isPrintable(keyData)) {
|
|
228
|
-
|
|
370
|
+
tab.customText += keyData;
|
|
229
371
|
this.repaint();
|
|
230
372
|
return;
|
|
231
373
|
}
|
|
232
374
|
// Kitty/modifyOtherKeys terminals encode plain printables as CSI-u.
|
|
233
375
|
const decoded = decodeKittyPrintable(keyData);
|
|
234
376
|
if (decoded !== undefined && isPrintable(decoded)) {
|
|
235
|
-
|
|
377
|
+
tab.customText += decoded;
|
|
236
378
|
this.repaint();
|
|
237
379
|
}
|
|
238
380
|
// Anything else (arrows, unhandled chords) is inert — never inserted
|