@hank-warren/pi-ask-user-question 0.2.0 → 0.2.2
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/ask-user-question.ts +18 -1
- package/package.json +1 -1
- package/view/dialog.ts +110 -45
package/ask-user-question.ts
CHANGED
|
@@ -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.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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": [
|
package/view/dialog.ts
CHANGED
|
@@ -5,16 +5,38 @@
|
|
|
5
5
|
* NO MONKEY PATCHING. The numbered options, digit hotkeys and Tab-to-comment
|
|
6
6
|
* come from `OptionSelector`, imported from the PUBLISHED sibling package
|
|
7
7
|
* `@hank-warren/pi-permission-selector` (plain `dependencies`, never
|
|
8
|
-
* `bundledDependencies` — AGENTS.md §Structure).
|
|
9
|
-
*
|
|
10
|
-
* See docs/specs/pi-ask-user-question.md §9.
|
|
8
|
+
* `bundledDependencies` — AGENTS.md §Structure). See
|
|
9
|
+
* docs/specs/pi-ask-user-question.md §9.
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* TWO RULES, both learned from v0.2.0 shipping broken:
|
|
12
|
+
*
|
|
13
|
+
* 1. NEVER compare key data with `===`. Every key check goes through the
|
|
14
|
+
* shared predicates in `.../keys.ts`. Under the Kitty keyboard protocol
|
|
15
|
+
* (Ghostty's default) Esc is `\x1b[27u`, not `\x1b`, so raw comparisons
|
|
16
|
+
* trapped the user in the custom-answer field with no way out.
|
|
17
|
+
* 2. ALWAYS pad rendered lines to the full overlay width. pi composites an
|
|
18
|
+
* overlay onto the chat line by line and only overwrites the columns the
|
|
19
|
+
* overlay actually emits; short lines let chat text show through and the
|
|
20
|
+
* dialog renders as garbage interleaved with the transcript.
|
|
21
|
+
* 3. ALWAYS clamp lines to the inner width. A single over-long line breaks the
|
|
22
|
+
* right border and spills into the transcript, so `render` truncates as a
|
|
23
|
+
* last-resort invariant no matter what any content source produces.
|
|
15
24
|
*/
|
|
16
25
|
|
|
26
|
+
import {
|
|
27
|
+
isBackspaceKey,
|
|
28
|
+
isEnterKey,
|
|
29
|
+
isEscapeKey,
|
|
30
|
+
isPrintable,
|
|
31
|
+
} from "@hank-warren/pi-permission-selector/keys.ts";
|
|
17
32
|
import { OptionSelector, type SelectorOption } from "@hank-warren/pi-permission-selector/selector.ts";
|
|
33
|
+
import {
|
|
34
|
+
CURSOR_MARKER,
|
|
35
|
+
decodeKittyPrintable,
|
|
36
|
+
truncateToWidth,
|
|
37
|
+
visibleWidth,
|
|
38
|
+
wrapTextWithAnsi,
|
|
39
|
+
} from "@earendil-works/pi-tui";
|
|
18
40
|
import type { QuestionnaireSession } from "../questionnaire.ts";
|
|
19
41
|
import type { QuestionnaireResult } from "../tool/schema.ts";
|
|
20
42
|
|
|
@@ -29,15 +51,23 @@ export interface DialogOptions {
|
|
|
29
51
|
/** Called exactly once with the final outcome. */
|
|
30
52
|
done(result: QuestionnaireResult): void;
|
|
31
53
|
requestRender?(): void;
|
|
54
|
+
/**
|
|
55
|
+
* Optional width cap. Unset means fill the overlay, which is what the
|
|
56
|
+
* bottom-anchored `width: "100%"` overlay wants: a narrower box would let
|
|
57
|
+
* the transcript show through to its right.
|
|
58
|
+
*/
|
|
59
|
+
maxWidth?: number;
|
|
32
60
|
}
|
|
33
61
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*/
|
|
62
|
+
/** Left/right border plus one space of padding on each side. */
|
|
63
|
+
const CHROME_COLUMNS = 4;
|
|
64
|
+
/** Indent for the custom-answer field, in columns. */
|
|
65
|
+
const FIELD_INDENT = " ";
|
|
66
|
+
|
|
40
67
|
export class QuestionnaireDialog {
|
|
68
|
+
/** Focusable — set by the TUI when focus changes. Drives CURSOR_MARKER. */
|
|
69
|
+
focused = false;
|
|
70
|
+
|
|
41
71
|
private readonly opts: DialogOptions;
|
|
42
72
|
private selector: OptionSelector;
|
|
43
73
|
private customText: string | undefined;
|
|
@@ -66,14 +96,12 @@ export class QuestionnaireDialog {
|
|
|
66
96
|
}));
|
|
67
97
|
|
|
68
98
|
return new OptionSelector({
|
|
69
|
-
title: `${session.title()}\n\n${session.current?.question ?? ""}`,
|
|
70
99
|
options,
|
|
71
100
|
theme: this.opts.theme,
|
|
72
101
|
onSelect: (option, comment) => {
|
|
73
102
|
if (session.isCustomRow(option.value)) {
|
|
74
|
-
// Enter free-text mode.
|
|
75
|
-
// carried across so
|
|
76
|
-
// a custom answer does not silently lose it.
|
|
103
|
+
// Enter free-text mode. A note typed on the sentinel row is
|
|
104
|
+
// carried across so it is not silently lost.
|
|
77
105
|
this.pendingNotes = comment;
|
|
78
106
|
this.customText = "";
|
|
79
107
|
this.repaint();
|
|
@@ -112,24 +140,59 @@ export class QuestionnaireDialog {
|
|
|
112
140
|
this.selector.invalidate();
|
|
113
141
|
}
|
|
114
142
|
|
|
115
|
-
|
|
143
|
+
private style(role: string, text: string): string {
|
|
144
|
+
if (this.opts.theme) return this.opts.theme.fg(role, text);
|
|
145
|
+
return role === "dim" ? `\x1b[2m${text}\x1b[0m` : text;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Inner content lines, before the box is drawn around them. */
|
|
149
|
+
private contentLines(inner: number): string[] {
|
|
150
|
+
const session = this.session;
|
|
151
|
+
const lines: string[] = [this.style("accent", session.title()), ""];
|
|
152
|
+
lines.push(...wrapTextWithAnsi(session.current?.question ?? "", inner));
|
|
153
|
+
lines.push("");
|
|
154
|
+
|
|
116
155
|
if (this.customText !== undefined) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
156
|
+
// Wrap the typed answer. Without this a long answer ran past the right
|
|
157
|
+
// border and off the screen forever, because an input field renders as
|
|
158
|
+
// one line unless something breaks it up.
|
|
159
|
+
const avail = Math.max(1, inner - FIELD_INDENT.length - 1); // -1 reserves the caret cell
|
|
160
|
+
const wrapped = wrapTextWithAnsi(this.customText, avail);
|
|
161
|
+
// CURSOR_MARKER is a zero-width APC sequence: the TUI strips it and
|
|
162
|
+
// parks the hardware cursor there, so the caret lands in the field
|
|
163
|
+
// instead of at the bottom of the screen.
|
|
164
|
+
const caret = `${this.focused ? CURSOR_MARKER : ""}▌`;
|
|
165
|
+
for (let i = 0; i < wrapped.length; i++) {
|
|
166
|
+
const last = i === wrapped.length - 1;
|
|
167
|
+
lines.push(`${FIELD_INDENT}${wrapped[i]}${last ? caret : ""}`);
|
|
168
|
+
}
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push(this.style("dim", " enter submit · esc back to options"));
|
|
171
|
+
return lines;
|
|
127
172
|
}
|
|
128
|
-
|
|
173
|
+
|
|
174
|
+
lines.push(...this.selector.render(inner));
|
|
175
|
+
return lines;
|
|
129
176
|
}
|
|
130
177
|
|
|
131
|
-
|
|
132
|
-
|
|
178
|
+
render(width: number): string[] {
|
|
179
|
+
const cap = this.opts.maxWidth ?? width;
|
|
180
|
+
const outer = Math.max(20, Math.min(width, cap));
|
|
181
|
+
const inner = outer - CHROME_COLUMNS;
|
|
182
|
+
const border = (text: string) => this.style("dim", text);
|
|
183
|
+
|
|
184
|
+
const out: string[] = [border(`┌${"─".repeat(outer - 2)}┐`)];
|
|
185
|
+
for (const raw of this.contentLines(inner)) {
|
|
186
|
+
// Invariant (3): never let a line break the right border, whatever
|
|
187
|
+
// produced it.
|
|
188
|
+
const line = visibleWidth(raw) > inner ? truncateToWidth(raw, inner) : raw;
|
|
189
|
+
// Invariant (2): pad to the full inner width, or pi's overlay
|
|
190
|
+
// compositing leaves chat text visible to the right of each line.
|
|
191
|
+
const pad = Math.max(0, inner - visibleWidth(line));
|
|
192
|
+
out.push(`${border("│")} ${line}${" ".repeat(pad)} ${border("│")}`);
|
|
193
|
+
}
|
|
194
|
+
out.push(border(`└${"─".repeat(outer - 2)}┘`));
|
|
195
|
+
return out;
|
|
133
196
|
}
|
|
134
197
|
|
|
135
198
|
handleInput(keyData: string): void {
|
|
@@ -137,15 +200,17 @@ export class QuestionnaireDialog {
|
|
|
137
200
|
this.selector.handleInput(keyData);
|
|
138
201
|
return;
|
|
139
202
|
}
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
203
|
+
|
|
204
|
+
// Free-text mode. Every check below MUST use a shared predicate — see
|
|
205
|
+
// the header comment. Esc unwinds to the option list rather than
|
|
206
|
+
// cancelling the dialog: one Esc never discards more than one layer.
|
|
207
|
+
if (isEscapeKey(keyData)) {
|
|
143
208
|
this.customText = undefined;
|
|
144
209
|
this.pendingNotes = undefined;
|
|
145
210
|
this.repaint();
|
|
146
211
|
return;
|
|
147
212
|
}
|
|
148
|
-
if (keyData
|
|
213
|
+
if (isEnterKey(keyData)) {
|
|
149
214
|
const text = this.customText.trim();
|
|
150
215
|
if (text.length === 0) return; // Empty custom answers are not submittable.
|
|
151
216
|
this.session.recordAnswer(text, { custom: true, notes: this.pendingNotes });
|
|
@@ -154,23 +219,23 @@ export class QuestionnaireDialog {
|
|
|
154
219
|
this.advance();
|
|
155
220
|
return;
|
|
156
221
|
}
|
|
157
|
-
if (keyData
|
|
222
|
+
if (isBackspaceKey(keyData)) {
|
|
158
223
|
this.customText = this.customText.slice(0, -1);
|
|
159
224
|
this.repaint();
|
|
160
225
|
return;
|
|
161
226
|
}
|
|
162
|
-
if (
|
|
227
|
+
if (isPrintable(keyData)) {
|
|
163
228
|
this.customText += keyData;
|
|
164
229
|
this.repaint();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Kitty/modifyOtherKeys terminals encode plain printables as CSI-u.
|
|
233
|
+
const decoded = decodeKittyPrintable(keyData);
|
|
234
|
+
if (decoded !== undefined && isPrintable(decoded)) {
|
|
235
|
+
this.customText += decoded;
|
|
236
|
+
this.repaint();
|
|
165
237
|
}
|
|
238
|
+
// Anything else (arrows, unhandled chords) is inert — never inserted
|
|
239
|
+
// into the field as literal escape-sequence garbage.
|
|
166
240
|
}
|
|
167
241
|
}
|
|
168
|
-
|
|
169
|
-
function isPrintableChunk(keyData: string): boolean {
|
|
170
|
-
if (keyData.length === 0) return false;
|
|
171
|
-
for (const char of keyData) {
|
|
172
|
-
const code = char.codePointAt(0) ?? 0;
|
|
173
|
-
if (code < 0x20 || code === 0x7f) return false;
|
|
174
|
-
}
|
|
175
|
-
return true;
|
|
176
|
-
}
|