@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.
@@ -99,7 +99,24 @@ export function registerTool(pi: ExtensionAPI): void {
99
99
  done,
100
100
  requestRender: () => tui.requestRender(),
101
101
  }),
102
- { overlay: true },
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.0",
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). That is the whole point of
9
- * the design: shared behavior by composition, not by patching pi internals.
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
- * v0.1 renders one question. The component is written against the session
13
- * rather than a single question so v0.2 can advance in place without a
14
- * rewrite.
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
- * Two-mode dialog: option selection, and (after choosing "Type something.")
36
- * free-text entry. Custom-answer entry is handled here rather than by mounting
37
- * pi-tui's `Input`, because `Input` owns its own key handling and would fight
38
- * the selector for Esc and Enter.
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. The note typed on the sentinel row is
75
- // carried across so a user who commented and then chose to type
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
- render(width: number): string[] {
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
- const session = this.session;
118
- return [
119
- session.title(),
120
- "",
121
- session.current?.question ?? "",
122
- "",
123
- ` ${this.customText}▌`,
124
- "",
125
- this.dim(" enter submit · esc back to options"),
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
- return this.selector.render(width);
173
+
174
+ lines.push(...this.selector.render(inner));
175
+ return lines;
129
176
  }
130
177
 
131
- private dim(text: string): string {
132
- return this.opts.theme ? this.opts.theme.fg("dim", text) : `\x1b[2m${text}\x1b[0m`;
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
- // Free-text mode. Esc unwinds to the option list rather than cancelling
141
- // the whole dialog one Esc should never discard more than one layer.
142
- if (keyData === "\x1b") {
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 === "\r" || keyData === "\n") {
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 === "\x7f" || keyData === "\b") {
222
+ if (isBackspaceKey(keyData)) {
158
223
  this.customText = this.customText.slice(0, -1);
159
224
  this.repaint();
160
225
  return;
161
226
  }
162
- if (isPrintableChunk(keyData)) {
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
- }