@fitchmultz/pi-ask-question 0.6.5 → 0.6.7
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 +26 -0
- package/extensions/ask-question.ts +7 -1340
- package/extensions/lib/ask-dialogs.ts +155 -0
- package/extensions/lib/ask-keyboard.ts +55 -0
- package/extensions/lib/form-answers.ts +86 -0
- package/extensions/lib/form-page.ts +411 -0
- package/extensions/lib/form-scrolls.ts +250 -0
- package/extensions/lib/grill-me.ts +152 -0
- package/extensions/lib/normalize.ts +126 -0
- package/extensions/lib/prompt-channel.ts +205 -0
- package/extensions/lib/question-form.ts +545 -0
- package/extensions/lib/question-observer.ts +177 -101
- package/extensions/lib/remote-answers.ts +73 -0
- package/extensions/lib/schema.ts +49 -0
- package/extensions/lib/text.ts +32 -0
- package/extensions/lib/tool.ts +150 -0
- package/extensions/lib/types.ts +56 -0
- package/extensions/lib/validate.ts +16 -0
- package/package.json +2 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Dialog-mode ask flow for RPC UIs. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { clean, orderedAnswers, uniqueOptionLabel } from "./normalize.ts";
|
|
5
|
+
import {
|
|
6
|
+
CUSTOM_OPTION,
|
|
7
|
+
DONE_OPTION,
|
|
8
|
+
type Answer,
|
|
9
|
+
type NormalizedQuestion,
|
|
10
|
+
type QuestionResult,
|
|
11
|
+
type QuestionTimeout,
|
|
12
|
+
} from "./types.ts";
|
|
13
|
+
|
|
14
|
+
type DialogContext = {
|
|
15
|
+
ui: ExtensionContext["ui"];
|
|
16
|
+
timeout: QuestionTimeout;
|
|
17
|
+
signal: AbortSignal | undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type DialogMenu = {
|
|
21
|
+
options: string[];
|
|
22
|
+
choices: string[];
|
|
23
|
+
customOption: string;
|
|
24
|
+
doneOption: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type DialogAnswer =
|
|
28
|
+
| { status: "retry" }
|
|
29
|
+
| { status: "cancel" }
|
|
30
|
+
| { status: "picked"; answer: string; wasCustom: boolean };
|
|
31
|
+
|
|
32
|
+
/** Ask one question set through sequential select/input dialogs. */
|
|
33
|
+
export async function askWithDialogs(
|
|
34
|
+
questions: NormalizedQuestion[],
|
|
35
|
+
ui: ExtensionContext["ui"],
|
|
36
|
+
timeout: QuestionTimeout,
|
|
37
|
+
signal: AbortSignal | undefined,
|
|
38
|
+
): Promise<QuestionResult> {
|
|
39
|
+
const answers = new Map<string, Answer>();
|
|
40
|
+
for (const question of questions) {
|
|
41
|
+
if (signal?.aborted === true) {
|
|
42
|
+
return { answers: orderedAnswers(questions, answers), cancelled: true };
|
|
43
|
+
}
|
|
44
|
+
const cancelled = await askOneByDialog(question, answers, { ui, timeout, signal });
|
|
45
|
+
if (cancelled) {
|
|
46
|
+
return { answers: orderedAnswers(questions, answers), cancelled: true };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { answers: orderedAnswers(questions, answers), cancelled: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function askOneByDialog(
|
|
53
|
+
question: NormalizedQuestion,
|
|
54
|
+
answers: Map<string, Answer>,
|
|
55
|
+
context: DialogContext,
|
|
56
|
+
): Promise<boolean> {
|
|
57
|
+
const selected: string[] = [];
|
|
58
|
+
|
|
59
|
+
while (true) {
|
|
60
|
+
const menu = buildDialogMenu(question, selected);
|
|
61
|
+
const choice = await context.ui.select(
|
|
62
|
+
question.question,
|
|
63
|
+
menu.choices,
|
|
64
|
+
...(context.signal ? [{ signal: context.signal }] : []),
|
|
65
|
+
);
|
|
66
|
+
if (choice === undefined) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
context.timeout.stop();
|
|
70
|
+
if (choice === menu.doneOption) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const resolved = await resolveDialogAnswer(question, menu, choice, context);
|
|
75
|
+
if (resolved.status === "cancel") {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (resolved.status === "retry") {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
applyDialogSelection(question, answers, selected, resolved);
|
|
82
|
+
if (!question.multiSelect) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildDialogMenu(question: NormalizedQuestion, selected: string[]): DialogMenu {
|
|
89
|
+
const customOption = uniqueOptionLabel(CUSTOM_OPTION, question.options);
|
|
90
|
+
const doneOption = uniqueOptionLabel(DONE_OPTION, question.options);
|
|
91
|
+
const options = [
|
|
92
|
+
...question.options,
|
|
93
|
+
...selected.filter((answer) => !question.options.includes(answer)),
|
|
94
|
+
];
|
|
95
|
+
const labels = question.multiSelect
|
|
96
|
+
? options.map((answer) => `${selected.includes(answer) ? "☑" : "☐"} ${answer}`)
|
|
97
|
+
: options;
|
|
98
|
+
const choices = [...labels, customOption];
|
|
99
|
+
if (question.multiSelect && selected.length > 0) {
|
|
100
|
+
choices.push(doneOption);
|
|
101
|
+
}
|
|
102
|
+
return { options, choices, customOption, doneOption };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function resolveDialogAnswer(
|
|
106
|
+
question: NormalizedQuestion,
|
|
107
|
+
menu: DialogMenu,
|
|
108
|
+
choice: string,
|
|
109
|
+
context: DialogContext,
|
|
110
|
+
): Promise<DialogAnswer> {
|
|
111
|
+
if (choice !== menu.customOption) {
|
|
112
|
+
const answer = menu.options[menu.choices.indexOf(choice)] ?? choice;
|
|
113
|
+
return { status: "picked", answer, wasCustom: false };
|
|
114
|
+
}
|
|
115
|
+
const input = await context.ui.input(
|
|
116
|
+
question.question,
|
|
117
|
+
"Type your answer",
|
|
118
|
+
...(context.signal ? [{ signal: context.signal }] : []),
|
|
119
|
+
);
|
|
120
|
+
if (input === undefined) {
|
|
121
|
+
return { status: "cancel" };
|
|
122
|
+
}
|
|
123
|
+
const custom = clean(input);
|
|
124
|
+
if (custom === undefined) {
|
|
125
|
+
return { status: "retry" };
|
|
126
|
+
}
|
|
127
|
+
return { status: "picked", answer: custom, wasCustom: true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applyDialogSelection(
|
|
131
|
+
question: NormalizedQuestion,
|
|
132
|
+
answers: Map<string, Answer>,
|
|
133
|
+
selected: string[],
|
|
134
|
+
picked: { answer: string; wasCustom: boolean },
|
|
135
|
+
): void {
|
|
136
|
+
const index = selected.indexOf(picked.answer);
|
|
137
|
+
if (question.multiSelect && !picked.wasCustom && index !== -1) {
|
|
138
|
+
selected.splice(index, 1);
|
|
139
|
+
} else if (index === -1) {
|
|
140
|
+
selected.push(picked.answer);
|
|
141
|
+
}
|
|
142
|
+
if (selected.length === 0) {
|
|
143
|
+
answers.delete(question.id);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
answers.set(question.id, {
|
|
147
|
+
id: question.id,
|
|
148
|
+
question: question.question,
|
|
149
|
+
answer: question.multiSelect ? selected.join(", ") : picked.answer,
|
|
150
|
+
...(question.multiSelect ? { selectedOptions: [...selected] } : {}),
|
|
151
|
+
wasCustom: question.multiSelect
|
|
152
|
+
? selected.some((answer) => !question.options.includes(answer))
|
|
153
|
+
: picked.wasCustom,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Keyboard-mode ask flow: full-screen form plus cooperative-bus wiring. */
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { QuestionForm } from "./question-form.ts";
|
|
6
|
+
import type { QuestionObserver } from "./question-observer.ts";
|
|
7
|
+
import type { NormalizedQuestion, QuestionResult, QuestionTimeout } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Ask through the full-screen keyboard UI with cooperative-bus support. */
|
|
10
|
+
export async function askWithKeyboard(params: {
|
|
11
|
+
questions: NormalizedQuestion[];
|
|
12
|
+
ui: ExtensionContext["ui"];
|
|
13
|
+
pi: ExtensionAPI;
|
|
14
|
+
observer: QuestionObserver;
|
|
15
|
+
timeout: QuestionTimeout;
|
|
16
|
+
signal: AbortSignal | undefined;
|
|
17
|
+
}): Promise<QuestionResult> {
|
|
18
|
+
const signal = params.signal;
|
|
19
|
+
if (signal?.aborted === true) {
|
|
20
|
+
return { answers: [], cancelled: true };
|
|
21
|
+
}
|
|
22
|
+
const promptId = randomUUID();
|
|
23
|
+
let form: QuestionForm | undefined;
|
|
24
|
+
return params.ui
|
|
25
|
+
.custom<QuestionResult>(
|
|
26
|
+
(tui, theme, keys, done) => {
|
|
27
|
+
form = new QuestionForm(
|
|
28
|
+
{
|
|
29
|
+
questions: params.questions,
|
|
30
|
+
promptId,
|
|
31
|
+
ui: params.ui,
|
|
32
|
+
pi: params.pi,
|
|
33
|
+
observer: params.observer,
|
|
34
|
+
timeout: params.timeout,
|
|
35
|
+
signal,
|
|
36
|
+
onDone: done,
|
|
37
|
+
},
|
|
38
|
+
tui,
|
|
39
|
+
theme,
|
|
40
|
+
keys,
|
|
41
|
+
);
|
|
42
|
+
return form;
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
overlay: true,
|
|
46
|
+
overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" },
|
|
47
|
+
onHandle: (handle) => {
|
|
48
|
+
form?.attachOverlay(handle);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
.finally(() => {
|
|
53
|
+
form?.release();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Answer bookkeeping for the question form: single, multi-select, and custom answers. */
|
|
2
|
+
|
|
3
|
+
import { orderedAnswers } from "./normalize.ts";
|
|
4
|
+
import type { Answer, NormalizedQuestion } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/** The answer model for one prompt; the form orchestrates view updates around it. */
|
|
7
|
+
export class AnswerSheet {
|
|
8
|
+
readonly answers = new Map<string, Answer>();
|
|
9
|
+
private readonly multi = new Map<string, Set<string>>();
|
|
10
|
+
readonly custom = new Map<string, string>();
|
|
11
|
+
|
|
12
|
+
has(questionId: string): boolean {
|
|
13
|
+
return this.answers.has(questionId);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get(questionId: string): Answer | undefined {
|
|
17
|
+
return this.answers.get(questionId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
ordered(questions: NormalizedQuestion[]): Answer[] {
|
|
21
|
+
return orderedAnswers(questions, this.answers);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
selectedSet(question: NormalizedQuestion): Set<string> {
|
|
25
|
+
let selected = this.multi.get(question.id);
|
|
26
|
+
if (selected === undefined) {
|
|
27
|
+
selected = new Set<string>();
|
|
28
|
+
this.multi.set(question.id, selected);
|
|
29
|
+
}
|
|
30
|
+
return selected;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Write the joined multi-select answer; deletes it when nothing is selected. */
|
|
34
|
+
syncMultiAnswer(question: NormalizedQuestion): void {
|
|
35
|
+
const selected = [...this.selectedSet(question)];
|
|
36
|
+
if (selected.length === 0) {
|
|
37
|
+
this.answers.delete(question.id);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.answers.set(question.id, {
|
|
41
|
+
id: question.id,
|
|
42
|
+
question: question.question,
|
|
43
|
+
answer: selected.join(", "),
|
|
44
|
+
selectedOptions: selected,
|
|
45
|
+
wasCustom: selected.some((answer) => !question.options.includes(answer)),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
toggleMultiChoice(question: NormalizedQuestion, choice: string): void {
|
|
50
|
+
const selected = this.selectedSet(question);
|
|
51
|
+
if (selected.has(choice)) {
|
|
52
|
+
selected.delete(choice);
|
|
53
|
+
} else {
|
|
54
|
+
selected.add(choice);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setMultiCustom(question: NormalizedQuestion, answer: string): void {
|
|
59
|
+
const selected = this.selectedSet(question);
|
|
60
|
+
const previous = this.custom.get(question.id);
|
|
61
|
+
if (previous !== undefined) {
|
|
62
|
+
selected.delete(previous);
|
|
63
|
+
}
|
|
64
|
+
this.custom.set(question.id, answer);
|
|
65
|
+
selected.add(answer);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setSingle(question: NormalizedQuestion, answer: string, wasCustom: boolean): void {
|
|
69
|
+
this.answers.set(question.id, {
|
|
70
|
+
id: question.id,
|
|
71
|
+
question: question.question,
|
|
72
|
+
answer,
|
|
73
|
+
wasCustom,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Replace the multi-select choices and forget any custom answer. */
|
|
78
|
+
applyOptions(question: NormalizedQuestion, choices: string[]): void {
|
|
79
|
+
const selected = this.selectedSet(question);
|
|
80
|
+
selected.clear();
|
|
81
|
+
this.custom.delete(question.id);
|
|
82
|
+
for (const choice of choices) {
|
|
83
|
+
selected.add(choice);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/** Page layout for the question form: line accumulation and section bounds. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
CURSOR_MARKER,
|
|
6
|
+
Editor,
|
|
7
|
+
sliceByColumn,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
wrapTextWithAnsi,
|
|
11
|
+
type Keybinding,
|
|
12
|
+
type OverlayHandle,
|
|
13
|
+
type TUI,
|
|
14
|
+
} from "@earendil-works/pi-tui";
|
|
15
|
+
import { formatAnswer } from "./normalize.ts";
|
|
16
|
+
import { wrapByColumn } from "./text.ts";
|
|
17
|
+
import type { Answer, NormalizedQuestion } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
/** Read-only form surface the page builders render from. */
|
|
20
|
+
export type FormView = {
|
|
21
|
+
readonly questions: NormalizedQuestion[];
|
|
22
|
+
readonly answers: Map<string, Answer>;
|
|
23
|
+
readonly customAnswers: Map<string, string>;
|
|
24
|
+
readonly tab: number;
|
|
25
|
+
readonly option: number;
|
|
26
|
+
readonly editing: boolean;
|
|
27
|
+
readonly showTabs: boolean;
|
|
28
|
+
readonly submitTab: number;
|
|
29
|
+
readonly timeoutActive: boolean;
|
|
30
|
+
readonly theme: Theme;
|
|
31
|
+
keyText: (binding: Keybinding) => string;
|
|
32
|
+
current: () => NormalizedQuestion | undefined;
|
|
33
|
+
choices: () => string[];
|
|
34
|
+
customOption: () => string;
|
|
35
|
+
allAnswered: () => boolean;
|
|
36
|
+
selectedSet: (question: NormalizedQuestion) => Set<string>;
|
|
37
|
+
renderEditor: (width: number) => string[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Accumulates rendered lines while tracking page-section bounds. */
|
|
41
|
+
export class PageBuilder {
|
|
42
|
+
readonly width: number;
|
|
43
|
+
readonly lines: string[] = [];
|
|
44
|
+
readonly optionRows: Array<{ start: number; end: number }> = [];
|
|
45
|
+
questionStart = 0;
|
|
46
|
+
questionEnd = 0;
|
|
47
|
+
optionStart = 0;
|
|
48
|
+
optionEnd = 0;
|
|
49
|
+
reviewStart = 0;
|
|
50
|
+
reviewEnd = 0;
|
|
51
|
+
contextHint = "";
|
|
52
|
+
|
|
53
|
+
constructor(width: number) {
|
|
54
|
+
this.width = width;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get length(): number {
|
|
58
|
+
return this.lines.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
add(line = ""): void {
|
|
62
|
+
this.lines.push(
|
|
63
|
+
visibleWidth(line) > this.width ? sliceByColumn(line, 0, this.width, true) : line,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beginQuestion(): void {
|
|
68
|
+
this.questionStart = this.lines.length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
endQuestion(): void {
|
|
72
|
+
this.questionEnd = this.lines.length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beginOptions(): void {
|
|
76
|
+
this.optionStart = this.lines.length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
endOptions(): void {
|
|
80
|
+
this.optionEnd = this.lines.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
beginReview(): void {
|
|
84
|
+
this.reviewStart = this.lines.length;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
endReview(): void {
|
|
88
|
+
this.reviewEnd = this.lines.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
pushOptionRow(start: number): void {
|
|
92
|
+
this.optionRows.push({ start, end: this.lines.length - this.optionStart });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setLine(index: number, line: string): void {
|
|
96
|
+
this.lines[index] = line;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setContextHint(hint: string): void {
|
|
100
|
+
this.contextHint = hint;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Drop clipped rows and shift the option section up by the removed count. */
|
|
104
|
+
shiftOptions(removed: number): void {
|
|
105
|
+
this.optionStart -= removed;
|
|
106
|
+
this.optionEnd -= removed;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type WrapOptions = {
|
|
111
|
+
firstPrefix?: string;
|
|
112
|
+
restPrefix?: string;
|
|
113
|
+
style?: (text: string) => string;
|
|
114
|
+
prefixStyle?: (text: string) => string;
|
|
115
|
+
preserveWhitespace?: boolean;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
type ResolvedWrap = {
|
|
119
|
+
firstPrefix: string;
|
|
120
|
+
restPrefix: string;
|
|
121
|
+
style: (text: string) => string;
|
|
122
|
+
prefixStyle: (text: string) => string;
|
|
123
|
+
preserveWhitespace: boolean;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function resolveWrapOptions(options: WrapOptions): ResolvedWrap {
|
|
127
|
+
return {
|
|
128
|
+
firstPrefix: options.firstPrefix ?? "",
|
|
129
|
+
restPrefix: options.restPrefix ?? "",
|
|
130
|
+
style: options.style ?? ((value: string) => value),
|
|
131
|
+
prefixStyle: options.prefixStyle ?? ((value: string) => value),
|
|
132
|
+
preserveWhitespace: options.preserveWhitespace === true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Append wrapped text with optional prefixes and styling. */
|
|
137
|
+
export function addWrapped(
|
|
138
|
+
page: PageBuilder,
|
|
139
|
+
view: FormView,
|
|
140
|
+
text: string,
|
|
141
|
+
options: WrapOptions = {},
|
|
142
|
+
): void {
|
|
143
|
+
const resolved = resolveWrapOptions(options);
|
|
144
|
+
const wrap = resolved.preserveWhitespace ? wrapByColumn : wrapTextWithAnsi;
|
|
145
|
+
const prefixWidth = visibleWidth(resolved.firstPrefix);
|
|
146
|
+
if (prefixWidth >= page.width || (resolved.preserveWhitespace && page.width - prefixWidth < 2)) {
|
|
147
|
+
for (const line of wrap(
|
|
148
|
+
`${resolved.prefixStyle(resolved.firstPrefix)}${resolved.style(text)}`,
|
|
149
|
+
page.width,
|
|
150
|
+
)) {
|
|
151
|
+
page.add(line);
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const wrapped = wrap(resolved.style(text), page.width - prefixWidth);
|
|
156
|
+
page.add(`${resolved.prefixStyle(resolved.firstPrefix)}${wrapped[0] ?? ""}`);
|
|
157
|
+
for (let index = 1; index < wrapped.length; index += 1) {
|
|
158
|
+
page.add(`${resolved.prefixStyle(resolved.restPrefix)}${wrapped[index] ?? ""}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Build the full page for the current form state. */
|
|
163
|
+
export function buildFormPage(view: FormView, width: number): PageBuilder {
|
|
164
|
+
const page = new PageBuilder(width);
|
|
165
|
+
page.add(view.theme.fg("accent", "─".repeat(page.width)));
|
|
166
|
+
if (view.showTabs && !view.editing) {
|
|
167
|
+
addTabRow(page, view);
|
|
168
|
+
}
|
|
169
|
+
if (view.tab === view.submitTab) {
|
|
170
|
+
addReviewPage(page, view);
|
|
171
|
+
} else {
|
|
172
|
+
addQuestionPage(page, view);
|
|
173
|
+
}
|
|
174
|
+
addFooter(page, view);
|
|
175
|
+
return page;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The single-line hint shown while the user reads the conversation. */
|
|
179
|
+
export function contextHint(theme: Theme, width: number): { line: string; width: number } {
|
|
180
|
+
const hint = truncateToWidth("ctrl+o return to question • scroll conversation", width, "…");
|
|
181
|
+
return { line: theme.fg("dim", hint), width: visibleWidth(hint) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Tracks ctrl+o context-reading mode: overlay focus handoff and hint hit-testing. */
|
|
185
|
+
export class ContextReader {
|
|
186
|
+
private active = false;
|
|
187
|
+
private hintStart = 0;
|
|
188
|
+
private hintEnd = 0;
|
|
189
|
+
private hintWidth = 0;
|
|
190
|
+
|
|
191
|
+
get reading(): boolean {
|
|
192
|
+
return this.active;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
contains(event: { x: number; y: number }): boolean {
|
|
196
|
+
return event.y >= this.hintStart && event.y < this.hintEnd && event.x < this.hintWidth;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
toggle(overlay: OverlayHandle | undefined): void {
|
|
200
|
+
this.active = !this.active;
|
|
201
|
+
if (this.active) {
|
|
202
|
+
overlay?.unfocus({ target: null });
|
|
203
|
+
} else {
|
|
204
|
+
overlay?.focus();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
renderHint(theme: Theme, width: number): string[] {
|
|
209
|
+
const hint = contextHint(theme, width);
|
|
210
|
+
this.hintStart = 0;
|
|
211
|
+
this.hintEnd = 1;
|
|
212
|
+
this.hintWidth = hint.width;
|
|
213
|
+
return [hint.line];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
trackHint(page: PageBuilder): void {
|
|
217
|
+
this.hintStart = page.lines.length - 1;
|
|
218
|
+
this.hintEnd = page.lines.length;
|
|
219
|
+
this.hintWidth = visibleWidth(page.contextHint);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The inline editor for typed custom answers. */
|
|
224
|
+
export function createPromptEditor(tui: TUI, theme: Theme): Editor {
|
|
225
|
+
return new Editor(tui, {
|
|
226
|
+
borderColor: (text) => theme.fg("accent", text),
|
|
227
|
+
selectList: {
|
|
228
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
229
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
230
|
+
description: (text) => theme.fg("muted", text),
|
|
231
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
232
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Reserve terminal rows below the form overlay so the transcript does not shift. */
|
|
238
|
+
export function reserveWidgetRows(
|
|
239
|
+
ui: ExtensionContext["ui"],
|
|
240
|
+
widgetKey: string,
|
|
241
|
+
reservedRows: () => number,
|
|
242
|
+
): void {
|
|
243
|
+
ui.setWidget(widgetKey, () => ({
|
|
244
|
+
render: () => Array.from({ length: reservedRows() }, () => ""),
|
|
245
|
+
invalidate() {
|
|
246
|
+
// The spacer has no cached output to invalidate.
|
|
247
|
+
},
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function addTabRow(page: PageBuilder, view: FormView): void {
|
|
252
|
+
const labels = [
|
|
253
|
+
...view.questions.map((question, index) => {
|
|
254
|
+
const marker = view.answers.has(question.id) ? "■" : "□";
|
|
255
|
+
const label = ` ${marker} Q${index + 1} `;
|
|
256
|
+
return index === view.tab
|
|
257
|
+
? view.theme.bg("selectedBg", view.theme.fg("text", label))
|
|
258
|
+
: view.theme.fg("muted", label);
|
|
259
|
+
}),
|
|
260
|
+
view.tab === view.submitTab
|
|
261
|
+
? view.theme.bg("selectedBg", view.theme.fg("text", " Submit "))
|
|
262
|
+
: view.theme.fg(view.allAnswered() ? "success" : "dim", " Submit "),
|
|
263
|
+
].join(" ");
|
|
264
|
+
addWrapped(page, view, labels);
|
|
265
|
+
page.add();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function addReviewPage(page: PageBuilder, view: FormView): void {
|
|
269
|
+
addWrapped(page, view, view.theme.bold("Review answers"));
|
|
270
|
+
page.add();
|
|
271
|
+
page.beginReview();
|
|
272
|
+
for (const question of view.questions) {
|
|
273
|
+
const answer = view.answers.get(question.id);
|
|
274
|
+
addWrapped(page, view, formatAnswer(answer), {
|
|
275
|
+
firstPrefix: `${question.id}: `,
|
|
276
|
+
restPrefix: " ".repeat(visibleWidth(`${question.id}: `)),
|
|
277
|
+
...(answer ? {} : { style: (text: string) => view.theme.fg("warning", text) }),
|
|
278
|
+
preserveWhitespace: answer?.selectedOptions !== undefined,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
page.endReview();
|
|
282
|
+
page.add();
|
|
283
|
+
addWrapped(
|
|
284
|
+
page,
|
|
285
|
+
view,
|
|
286
|
+
view.allAnswered()
|
|
287
|
+
? `${view.keyText("tui.select.confirm")} to submit`
|
|
288
|
+
: "Answer all questions before submitting",
|
|
289
|
+
{
|
|
290
|
+
style: (text) => view.theme.fg(view.allAnswered() ? "success" : "warning", text),
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function addQuestionPage(page: PageBuilder, view: FormView): void {
|
|
296
|
+
const question = view.current();
|
|
297
|
+
if (question === undefined) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const answer = view.answers.get(question.id);
|
|
301
|
+
page.beginQuestion();
|
|
302
|
+
addWrapped(
|
|
303
|
+
page,
|
|
304
|
+
view,
|
|
305
|
+
question.multiSelect ? `${question.question} (select one or more)` : question.question,
|
|
306
|
+
);
|
|
307
|
+
page.endQuestion();
|
|
308
|
+
page.add();
|
|
309
|
+
page.beginOptions();
|
|
310
|
+
addOptionRows(page, view, question);
|
|
311
|
+
page.endOptions();
|
|
312
|
+
if (answer !== undefined && !view.editing) {
|
|
313
|
+
addAnswerPreview(page, view, question, answer);
|
|
314
|
+
}
|
|
315
|
+
if (view.editing) {
|
|
316
|
+
addEditorLines(page, view);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function addOptionRows(page: PageBuilder, view: FormView, question: NormalizedQuestion): void {
|
|
321
|
+
for (const [index, choice] of view.choices().entries()) {
|
|
322
|
+
if (view.editing && index !== view.option) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const start = page.length - page.optionStart;
|
|
326
|
+
const highlighted = index === view.option;
|
|
327
|
+
const checked = question.multiSelect ? view.selectedSet(question).has(choice) : false;
|
|
328
|
+
let marker: string;
|
|
329
|
+
if (!question.multiSelect) {
|
|
330
|
+
marker = `${index + 1}.`;
|
|
331
|
+
} else if (choice === view.customOption()) {
|
|
332
|
+
marker = "✎";
|
|
333
|
+
} else {
|
|
334
|
+
marker = checked ? "☑" : "☐";
|
|
335
|
+
}
|
|
336
|
+
const prefix = `${highlighted ? ">" : " "} ${marker} `;
|
|
337
|
+
addWrapped(page, view, choice, {
|
|
338
|
+
firstPrefix: prefix,
|
|
339
|
+
restPrefix: " ".repeat(prefix.length),
|
|
340
|
+
style: (text) => view.theme.fg(highlighted ? "accent" : "text", text),
|
|
341
|
+
prefixStyle: (text) =>
|
|
342
|
+
highlighted && text.includes(">") ? view.theme.fg("accent", text) : text,
|
|
343
|
+
});
|
|
344
|
+
page.pushOptionRow(start);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function addAnswerPreview(
|
|
349
|
+
page: PageBuilder,
|
|
350
|
+
view: FormView,
|
|
351
|
+
question: NormalizedQuestion,
|
|
352
|
+
answer: Answer,
|
|
353
|
+
): void {
|
|
354
|
+
const custom = question.multiSelect ? view.customAnswers.get(question.id) : undefined;
|
|
355
|
+
const selected = question.multiSelect ? view.selectedSet(question) : undefined;
|
|
356
|
+
let summary = answer.answer;
|
|
357
|
+
if (custom !== undefined && selected !== undefined && selected.has(custom)) {
|
|
358
|
+
summary = `${custom} (${selected.size} selected)`;
|
|
359
|
+
}
|
|
360
|
+
const preview = `Current answer: ${summary.replace(/\s+/g, " ")}`;
|
|
361
|
+
page.add(view.theme.fg("muted", truncateToWidth(preview, page.width, "…")));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function addEditorLines(page: PageBuilder, view: FormView): void {
|
|
365
|
+
addWrapped(page, view, view.theme.fg("muted", "Your answer:"));
|
|
366
|
+
const indent = page.width > 1 ? " " : "";
|
|
367
|
+
const editorWidth = Math.max(1, page.width - visibleWidth(indent));
|
|
368
|
+
for (const line of view.renderEditor(Math.max(3, editorWidth))) {
|
|
369
|
+
const cursorIndex = line.indexOf(CURSOR_MARKER);
|
|
370
|
+
const cursorColumn = cursorIndex === -1 ? 0 : visibleWidth(line.slice(0, cursorIndex));
|
|
371
|
+
const startColumn = Math.max(0, cursorColumn - editorWidth + 1);
|
|
372
|
+
page.add(`${indent}${sliceByColumn(line, startColumn, editorWidth, true)}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function footerHints(view: FormView): string {
|
|
377
|
+
if (view.editing) {
|
|
378
|
+
return `${view.keyText("tui.input.submit")} save answer • ${view.keyText("tui.select.cancel")} cancel edit`;
|
|
379
|
+
}
|
|
380
|
+
if (view.showTabs) {
|
|
381
|
+
return `←/→ or ${view.keyText("tui.input.tab")} questions • ${view.keyText("tui.select.up")}/${view.keyText("tui.select.down")} options • Space toggle multi-select • ${view.keyText("tui.select.confirm")} next/submit • ${view.keyText("tui.select.cancel")} cancel`;
|
|
382
|
+
}
|
|
383
|
+
return `${view.keyText("tui.select.up")}/${view.keyText("tui.select.down")} options • ${view.keyText("tui.select.confirm")} select • ${view.keyText("tui.select.cancel")} cancel`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function addFooter(page: PageBuilder, view: FormView): void {
|
|
387
|
+
if (!view.editing) {
|
|
388
|
+
page.add();
|
|
389
|
+
}
|
|
390
|
+
addWrapped(
|
|
391
|
+
page,
|
|
392
|
+
view,
|
|
393
|
+
view.timeoutActive
|
|
394
|
+
? "Auto-continues after 5 minutes without interaction."
|
|
395
|
+
: "No time limit while you answer.",
|
|
396
|
+
{
|
|
397
|
+
style: (text) => view.theme.fg("dim", text),
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
addWrapped(page, view, footerHints(view), {
|
|
401
|
+
style: (text) => view.theme.fg("dim", text),
|
|
402
|
+
});
|
|
403
|
+
const hint = truncateToWidth("ctrl+o read context", page.width, "…");
|
|
404
|
+
page.add(
|
|
405
|
+
`${view.theme.fg("dim", hint)}${view.theme.fg(
|
|
406
|
+
"accent",
|
|
407
|
+
"─".repeat(Math.max(0, page.width - visibleWidth(hint))),
|
|
408
|
+
)}`,
|
|
409
|
+
);
|
|
410
|
+
page.setContextHint(hint);
|
|
411
|
+
}
|