@hank-warren/pi-ask-user-question 0.2.0 → 0.2.1
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/package.json +1 -1
- package/view/dialog.ts +84 -45
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.1",
|
|
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,29 @@
|
|
|
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.
|
|
15
21
|
*/
|
|
16
22
|
|
|
23
|
+
import {
|
|
24
|
+
isBackspaceKey,
|
|
25
|
+
isEnterKey,
|
|
26
|
+
isEscapeKey,
|
|
27
|
+
isPrintable,
|
|
28
|
+
} from "@hank-warren/pi-permission-selector/keys.ts";
|
|
17
29
|
import { OptionSelector, type SelectorOption } from "@hank-warren/pi-permission-selector/selector.ts";
|
|
30
|
+
import { CURSOR_MARKER, decodeKittyPrintable, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
18
31
|
import type { QuestionnaireSession } from "../questionnaire.ts";
|
|
19
32
|
import type { QuestionnaireResult } from "../tool/schema.ts";
|
|
20
33
|
|
|
@@ -29,15 +42,18 @@ export interface DialogOptions {
|
|
|
29
42
|
/** Called exactly once with the final outcome. */
|
|
30
43
|
done(result: QuestionnaireResult): void;
|
|
31
44
|
requestRender?(): void;
|
|
45
|
+
/** Overlay width cap. Defaults to 84 columns. */
|
|
46
|
+
maxWidth?: number;
|
|
32
47
|
}
|
|
33
48
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* the selector for Esc and Enter.
|
|
39
|
-
*/
|
|
49
|
+
const DEFAULT_MAX_WIDTH = 84;
|
|
50
|
+
/** Left/right border plus one space of padding on each side. */
|
|
51
|
+
const CHROME_COLUMNS = 4;
|
|
52
|
+
|
|
40
53
|
export class QuestionnaireDialog {
|
|
54
|
+
/** Focusable — set by the TUI when focus changes. Drives CURSOR_MARKER. */
|
|
55
|
+
focused = false;
|
|
56
|
+
|
|
41
57
|
private readonly opts: DialogOptions;
|
|
42
58
|
private selector: OptionSelector;
|
|
43
59
|
private customText: string | undefined;
|
|
@@ -66,14 +82,12 @@ export class QuestionnaireDialog {
|
|
|
66
82
|
}));
|
|
67
83
|
|
|
68
84
|
return new OptionSelector({
|
|
69
|
-
title: `${session.title()}\n\n${session.current?.question ?? ""}`,
|
|
70
85
|
options,
|
|
71
86
|
theme: this.opts.theme,
|
|
72
87
|
onSelect: (option, comment) => {
|
|
73
88
|
if (session.isCustomRow(option.value)) {
|
|
74
|
-
// Enter free-text mode.
|
|
75
|
-
// carried across so
|
|
76
|
-
// a custom answer does not silently lose it.
|
|
89
|
+
// Enter free-text mode. A note typed on the sentinel row is
|
|
90
|
+
// carried across so it is not silently lost.
|
|
77
91
|
this.pendingNotes = comment;
|
|
78
92
|
this.customText = "";
|
|
79
93
|
this.repaint();
|
|
@@ -112,24 +126,47 @@ export class QuestionnaireDialog {
|
|
|
112
126
|
this.selector.invalidate();
|
|
113
127
|
}
|
|
114
128
|
|
|
115
|
-
|
|
129
|
+
private style(role: string, text: string): string {
|
|
130
|
+
if (this.opts.theme) return this.opts.theme.fg(role, text);
|
|
131
|
+
return role === "dim" ? `\x1b[2m${text}\x1b[0m` : text;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Inner content lines, before the box is drawn around them. */
|
|
135
|
+
private contentLines(inner: number): string[] {
|
|
136
|
+
const session = this.session;
|
|
137
|
+
const lines: string[] = [this.style("accent", session.title()), ""];
|
|
138
|
+
lines.push(...wrapTextWithAnsi(session.current?.question ?? "", inner));
|
|
139
|
+
lines.push("");
|
|
140
|
+
|
|
116
141
|
if (this.customText !== undefined) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
this.dim(" enter submit · esc back to options"),
|
|
126
|
-
];
|
|
142
|
+
// CURSOR_MARKER is a zero-width APC sequence: the TUI strips it and
|
|
143
|
+
// parks the hardware cursor there, so the caret lands in the field
|
|
144
|
+
// instead of at the bottom of the screen.
|
|
145
|
+
const caret = this.focused ? CURSOR_MARKER : "";
|
|
146
|
+
lines.push(` ${this.customText}${caret}▌`);
|
|
147
|
+
lines.push("");
|
|
148
|
+
lines.push(this.style("dim", " enter submit · esc back to options"));
|
|
149
|
+
return lines;
|
|
127
150
|
}
|
|
128
|
-
|
|
151
|
+
|
|
152
|
+
lines.push(...this.selector.render(inner));
|
|
153
|
+
return lines;
|
|
129
154
|
}
|
|
130
155
|
|
|
131
|
-
|
|
132
|
-
|
|
156
|
+
render(width: number): string[] {
|
|
157
|
+
const outer = Math.max(20, Math.min(width, this.opts.maxWidth ?? DEFAULT_MAX_WIDTH));
|
|
158
|
+
const inner = outer - CHROME_COLUMNS;
|
|
159
|
+
const border = (text: string) => this.style("dim", text);
|
|
160
|
+
|
|
161
|
+
const out: string[] = [border(`┌${"─".repeat(outer - 2)}┐`)];
|
|
162
|
+
for (const line of this.contentLines(inner)) {
|
|
163
|
+
// Pad to the full inner width. Without this, pi's overlay compositing
|
|
164
|
+
// leaves the underlying chat text visible to the right of each line.
|
|
165
|
+
const pad = Math.max(0, inner - visibleWidth(line));
|
|
166
|
+
out.push(`${border("│")} ${line}${" ".repeat(pad)} ${border("│")}`);
|
|
167
|
+
}
|
|
168
|
+
out.push(border(`└${"─".repeat(outer - 2)}┘`));
|
|
169
|
+
return out;
|
|
133
170
|
}
|
|
134
171
|
|
|
135
172
|
handleInput(keyData: string): void {
|
|
@@ -137,15 +174,17 @@ export class QuestionnaireDialog {
|
|
|
137
174
|
this.selector.handleInput(keyData);
|
|
138
175
|
return;
|
|
139
176
|
}
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
177
|
+
|
|
178
|
+
// Free-text mode. Every check below MUST use a shared predicate — see
|
|
179
|
+
// the header comment. Esc unwinds to the option list rather than
|
|
180
|
+
// cancelling the dialog: one Esc never discards more than one layer.
|
|
181
|
+
if (isEscapeKey(keyData)) {
|
|
143
182
|
this.customText = undefined;
|
|
144
183
|
this.pendingNotes = undefined;
|
|
145
184
|
this.repaint();
|
|
146
185
|
return;
|
|
147
186
|
}
|
|
148
|
-
if (keyData
|
|
187
|
+
if (isEnterKey(keyData)) {
|
|
149
188
|
const text = this.customText.trim();
|
|
150
189
|
if (text.length === 0) return; // Empty custom answers are not submittable.
|
|
151
190
|
this.session.recordAnswer(text, { custom: true, notes: this.pendingNotes });
|
|
@@ -154,23 +193,23 @@ export class QuestionnaireDialog {
|
|
|
154
193
|
this.advance();
|
|
155
194
|
return;
|
|
156
195
|
}
|
|
157
|
-
if (keyData
|
|
196
|
+
if (isBackspaceKey(keyData)) {
|
|
158
197
|
this.customText = this.customText.slice(0, -1);
|
|
159
198
|
this.repaint();
|
|
160
199
|
return;
|
|
161
200
|
}
|
|
162
|
-
if (
|
|
201
|
+
if (isPrintable(keyData)) {
|
|
163
202
|
this.customText += keyData;
|
|
164
203
|
this.repaint();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Kitty/modifyOtherKeys terminals encode plain printables as CSI-u.
|
|
207
|
+
const decoded = decodeKittyPrintable(keyData);
|
|
208
|
+
if (decoded !== undefined && isPrintable(decoded)) {
|
|
209
|
+
this.customText += decoded;
|
|
210
|
+
this.repaint();
|
|
165
211
|
}
|
|
212
|
+
// Anything else (arrows, unhandled chords) is inert — never inserted
|
|
213
|
+
// into the field as literal escape-sequence garbage.
|
|
166
214
|
}
|
|
167
215
|
}
|
|
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
|
-
}
|