@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.0",
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). 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.
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
- * 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
- */
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. 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.
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
- render(width: number): string[] {
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
- 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
- ];
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
- return this.selector.render(width);
151
+
152
+ lines.push(...this.selector.render(inner));
153
+ return lines;
129
154
  }
130
155
 
131
- private dim(text: string): string {
132
- return this.opts.theme ? this.opts.theme.fg("dim", text) : `\x1b[2m${text}\x1b[0m`;
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
- // 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") {
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 === "\r" || keyData === "\n") {
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 === "\x7f" || keyData === "\b") {
196
+ if (isBackspaceKey(keyData)) {
158
197
  this.customText = this.customText.slice(0, -1);
159
198
  this.repaint();
160
199
  return;
161
200
  }
162
- if (isPrintableChunk(keyData)) {
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
- }