@fadhelhaidar/pi-ask-user 1.0.4 → 1.1.0

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.
@@ -1,22 +1,113 @@
1
1
  import {
2
2
  DEFAULT_COLLAPSE_KEY,
3
3
  COLLAPSE_KEY_OFF,
4
+ DEFAULT_NOTES_KEY,
5
+ KEY_PLACEHOLDER,
6
+ formatNotesKeyForDisplay,
4
7
  formatKeySpecForDisplay,
5
8
  ROW_INTENT_META,
6
9
  t,
7
10
  displayLabel,
8
- formatAnswerScalar
9
- } from "./index-x7fb685n.js";
11
+ formatAnswerScalar,
12
+ BORDER_VERTICAL_OVERHEAD,
13
+ BORDER_HORIZONTAL_OVERHEAD,
14
+ BORDER_INNER_PADDING_HORIZONTAL,
15
+ stripFenceMarkers,
16
+ renderBorderedBox,
17
+ computeBoxDimensions
18
+ } from "./index-n7wxhdmh.js";
10
19
 
11
20
  // view/dialog-builder.ts
12
21
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
13
- import { Container as Container2, Spacer as Spacer2 } from "@earendil-works/pi-tui";
22
+ import { Container as Container2, CURSOR_MARKER, Spacer as Spacer2 } from "@earendil-works/pi-tui";
23
+
24
+ // view/scroll-window.ts
25
+ var OVERFLOW_UP = "↑";
26
+ var OVERFLOW_DOWN = "↓";
27
+ var OVERFLOW_BOTH = "↕";
28
+ function computeScrollStart(bodyRange, rowsBeforeBody, availableMiddle, middleRows) {
29
+ if (!bodyRange)
30
+ return 0;
31
+ const focusedRowInMiddle = rowsBeforeBody + bodyRange[0];
32
+ const focusedHeight = bodyRange[1] - bodyRange[0];
33
+ const idealStart = focusedRowInMiddle - Math.floor(Math.max(0, availableMiddle - focusedHeight) / 2);
34
+ return Math.max(0, Math.min(idealStart, middleRows - availableMiddle));
35
+ }
36
+ function decorateOverflow(scrollableMiddle, hasUp, hasDown, theme, protectedRows) {
37
+ const drawable = (row) => !protectedRows || row < protectedRows[0] || row >= protectedRows[1];
38
+ if (hasUp && hasDown && scrollableMiddle.length === 1) {
39
+ if (drawable(0))
40
+ scrollableMiddle[0] = theme.fg("dim", OVERFLOW_BOTH);
41
+ return;
42
+ }
43
+ if (hasUp && scrollableMiddle.length > 0 && drawable(0)) {
44
+ scrollableMiddle[0] = theme.fg("dim", OVERFLOW_UP);
45
+ }
46
+ const last = scrollableMiddle.length - 1;
47
+ if (hasDown && last > 0 && drawable(last)) {
48
+ scrollableMiddle[last] = theme.fg("dim", OVERFLOW_DOWN);
49
+ }
50
+ }
14
51
 
15
52
  // view/tab-content-strategy.ts
16
53
  import { Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
54
+
55
+ // state/selectors/derivations.ts
56
+ function selectConfirmedIndicator(questions, currentTab, answers, items) {
57
+ const q = questions[currentTab];
58
+ if (!q || q.multiSelect === true)
59
+ return;
60
+ const prior = answers.get(currentTab);
61
+ if (!prior)
62
+ return;
63
+ if (prior.kind === "custom") {
64
+ const otherIndex = items.findIndex((it) => it.kind === "other");
65
+ if (otherIndex < 0)
66
+ return;
67
+ return { index: otherIndex, labelOverride: prior.answer ?? "" };
68
+ }
69
+ if (prior.kind !== "option" || typeof prior.answer !== "string")
70
+ return;
71
+ const index = items.findIndex((it) => it.kind === "option" && it.label === prior.answer);
72
+ if (index < 0)
73
+ return;
74
+ return { index };
75
+ }
76
+ function selectSavedNote(notesByTab, answers, tab) {
77
+ const note = notesByTab.get(tab) ?? answers.get(tab)?.notes;
78
+ return note && note.length > 0 ? note : undefined;
79
+ }
80
+ function selectActivePreviewPaneIndex(currentTab, totalQuestions) {
81
+ if (totalQuestions <= 0)
82
+ return 0;
83
+ return Math.min(currentTab, totalQuestions - 1);
84
+ }
85
+
86
+ // view/dialog-copy.ts
87
+ var HINT_PART_ENTER = "Enter to select";
88
+ var HINT_PART_NAV = "↑/↓ to navigate";
89
+ var HINT_PART_NEW_LINE = "Shift+Enter for newline";
90
+ var HINT_PART_CLEAR = "Ctrl+U to clear";
91
+ var HINT_PART_TOGGLE = "Enter to toggle";
92
+ var HINT_PART_TAB = "Tab to switch questions";
93
+ var HINT_PART_CANCEL = "Esc to cancel";
94
+ var HINT_PART_NOTES_TEMPLATE = `${KEY_PLACEHOLDER} to add notes`;
95
+ var HINT_PART_NOTES = HINT_PART_NOTES_TEMPLATE.replace(KEY_PLACEHOLDER, formatNotesKeyForDisplay(DEFAULT_NOTES_KEY));
96
+ var HINT_PART_COLLAPSE_TEMPLATE = `${KEY_PLACEHOLDER} to collapse`;
97
+ var HINT_PART_EXPAND_TEMPLATE = `${KEY_PLACEHOLDER} to expand`;
98
+ var HINT_PART_COLLAPSE = HINT_PART_COLLAPSE_TEMPLATE.replace(KEY_PLACEHOLDER, formatKeySpecForDisplay(DEFAULT_COLLAPSE_KEY));
99
+ var HINT_SINGLE = [HINT_PART_ENTER, HINT_PART_NAV, HINT_PART_NOTES, HINT_PART_CANCEL].join(" · ");
100
+ var HINT_MULTI = [HINT_PART_ENTER, HINT_PART_NAV, HINT_PART_NOTES, HINT_PART_TAB, HINT_PART_CANCEL].join(" · ");
101
+ var COLLAPSED_HINT_TEMPLATE = [HINT_PART_EXPAND_TEMPLATE, HINT_PART_CANCEL].join(" · ");
102
+ var REVIEW_HEADING = "Review your answers";
103
+ var READY_PROMPT = "Ready to submit your answers?";
104
+ var INCOMPLETE_WARNING_PREFIX = "⚠ Answer remaining questions before submitting:";
105
+ var SAVED_NOTE_LABEL = "Note for this answer:";
106
+
107
+ // view/tab-content-strategy.ts
17
108
  var NOTES_HEADER = "Notes:";
18
109
  var GLOBAL_NOTES_HEADER = "Global note:";
19
- var REVIEW_GLOBAL_HINT = "n to add a note";
110
+ var REVIEW_GLOBAL_HINT_TEMPLATE = `${KEY_PLACEHOLDER} to add a note`;
20
111
  var REVIEW_NOTE_LABEL = "Note";
21
112
 
22
113
  class OneLineClippedText {
@@ -65,19 +156,26 @@ class QuestionTabStrategy {
65
156
  return this.config.getCurrentBodyHeight(width);
66
157
  }
67
158
  midRows(state) {
68
- if (!state.notesVisible)
159
+ if (state.notesVisible) {
160
+ return [
161
+ new Text(this.config.theme.fg("muted", t("notes.header", NOTES_HEADER)), 1, 0),
162
+ this.config.notesInput,
163
+ new Spacer(1)
164
+ ];
165
+ }
166
+ const note = selectSavedNote(state.notesByTab, state.answers, state.currentTab);
167
+ if (note === undefined)
69
168
  return [];
70
- return [
71
- new Text(this.config.theme.fg("muted", t("notes.header", NOTES_HEADER)), 1, 0),
72
- this.config.notesInput,
73
- new Spacer(1)
74
- ];
169
+ const [firstLine = "", ...rest] = note.split(`
170
+ `);
171
+ const text = `${t("notes.saved_label", SAVED_NOTE_LABEL)} ${firstLine}${rest.length > 0 ? " …" : ""}`;
172
+ return [new OneLineClippedText(this.config.theme.fg("dim", text), 1)];
75
173
  }
76
174
  footerRows(state) {
77
175
  const question = this.config.questions[state.currentTab];
78
176
  return [
79
177
  new Spacer(1),
80
- new OneLineClippedText(this.config.theme.fg("dim", buildHintText(question, this.config.isMulti, state, this.config.collapseKey)), 1)
178
+ new OneLineClippedText(this.config.theme.fg("dim", buildHintText(question, this.config.isMulti, state, this.config.collapseKey, this.config.notesKey)), 1)
81
179
  ];
82
180
  }
83
181
  focusedItemRowRange(width, state) {
@@ -151,18 +249,19 @@ class SubmitTabStrategy {
151
249
  out.push(new Spacer(1));
152
250
  out.push(new Spacer(1));
153
251
  }
154
- out.push(new OneLineClippedText(this.config.theme.fg("dim", buildSubmitHintText(state)), 1));
252
+ out.push(new OneLineClippedText(this.config.theme.fg("dim", buildSubmitHintText(state, this.config.notesKey)), 1));
155
253
  return out;
156
254
  }
157
255
  focusedItemRowRange(_width, _state) {
158
256
  return;
159
257
  }
160
258
  }
161
- function buildHintText(question, isMulti, state, collapseKey) {
259
+ function buildHintText(question, isMulti, state, collapseKey, notesKey) {
162
260
  const enterHint = question?.multiSelect === true && !state.notesVisible ? t("hint.enter_toggle", HINT_PART_TOGGLE) : t("hint.enter", HINT_PART_ENTER);
163
261
  const parts = [enterHint, t("hint.navigate", HINT_PART_NAV)];
164
- if (question && !state.notesVisible && !state.inputMode)
165
- parts.push(t("hint.notes", HINT_PART_NOTES));
262
+ if (question && !state.notesVisible && !state.inputMode) {
263
+ parts.push(t("hint.notes", HINT_PART_NOTES_TEMPLATE).replace(KEY_PLACEHOLDER, formatNotesKeyForDisplay(notesKey)));
264
+ }
166
265
  if (isMulti)
167
266
  parts.push(t("hint.tab", HINT_PART_TAB));
168
267
  parts.push(t("hint.cancel", HINT_PART_CANCEL));
@@ -175,10 +274,11 @@ function buildHintText(question, isMulti, state, collapseKey) {
175
274
  parts.push(t("hint.clear", HINT_PART_CLEAR));
176
275
  return parts.join(" · ");
177
276
  }
178
- function buildSubmitHintText(state) {
277
+ function buildSubmitHintText(state, notesKey) {
179
278
  const parts = [t("hint.enter", HINT_PART_ENTER), t("hint.navigate", HINT_PART_NAV)];
180
- if (!state.notesVisible)
181
- parts.push(t("review.global_hint", REVIEW_GLOBAL_HINT));
279
+ if (!state.notesVisible) {
280
+ parts.push(t("review.global_hint", REVIEW_GLOBAL_HINT_TEMPLATE).replace(KEY_PLACEHOLDER, formatNotesKeyForDisplay(notesKey)));
281
+ }
182
282
  parts.push(t("hint.cancel", HINT_PART_CANCEL));
183
283
  if (state.notesVisible)
184
284
  parts.push(t("hint.newline", HINT_PART_NEW_LINE));
@@ -186,27 +286,6 @@ function buildSubmitHintText(state) {
186
286
  }
187
287
 
188
288
  // view/dialog-builder.ts
189
- var HINT_PART_ENTER = "Enter to select";
190
- var HINT_PART_NAV = "↑/↓ to navigate";
191
- var HINT_PART_NEW_LINE = "Shift+Enter for newline";
192
- var HINT_PART_CLEAR = "Ctrl+U to clear";
193
- var HINT_PART_TOGGLE = "Enter to toggle";
194
- var HINT_PART_NOTES = "n to add notes";
195
- var HINT_PART_TAB = "Tab to switch questions";
196
- var HINT_PART_CANCEL = "Esc to cancel";
197
- var KEY_PLACEHOLDER = "{key}";
198
- var HINT_PART_COLLAPSE_TEMPLATE = `${KEY_PLACEHOLDER} to collapse`;
199
- var HINT_PART_EXPAND_TEMPLATE = `${KEY_PLACEHOLDER} to expand`;
200
- var HINT_PART_COLLAPSE = HINT_PART_COLLAPSE_TEMPLATE.replace(KEY_PLACEHOLDER, formatKeySpecForDisplay(DEFAULT_COLLAPSE_KEY));
201
- var HINT_SINGLE = [HINT_PART_ENTER, HINT_PART_NAV, HINT_PART_NOTES, HINT_PART_CANCEL].join(" · ");
202
- var HINT_MULTI = [HINT_PART_ENTER, HINT_PART_NAV, HINT_PART_NOTES, HINT_PART_TAB, HINT_PART_CANCEL].join(" · ");
203
- var COLLAPSED_HINT_TEMPLATE = [HINT_PART_EXPAND_TEMPLATE, HINT_PART_CANCEL].join(" · ");
204
- var REVIEW_HEADING = "Review your answers";
205
- var READY_PROMPT = "Ready to submit your answers?";
206
- var INCOMPLETE_WARNING_PREFIX = "⚠ Answer remaining questions before submitting:";
207
- var OVERFLOW_UP = "↑";
208
- var OVERFLOW_DOWN = "↓";
209
- var OVERFLOW_BOTH = "↕";
210
289
  function renderFitsTerminal(natural, spacerRows) {
211
290
  return spacerRows > 0 ? [...natural, ...Array(spacerRows).fill("")] : natural;
212
291
  }
@@ -214,26 +293,7 @@ function renderChromeOnly(natural, topFixed, bottomFixed, termRows) {
214
293
  const chromeOnly = [...natural.slice(0, topFixed), ...natural.slice(natural.length - bottomFixed)];
215
294
  return chromeOnly.length > termRows ? chromeOnly.slice(0, termRows) : chromeOnly;
216
295
  }
217
- function computeScrollStart(bodyRange, headingCount, availableMiddle, middleRows) {
218
- if (!bodyRange)
219
- return 0;
220
- const focusedRowInMiddle = headingCount + bodyRange[0];
221
- const focusedHeight = bodyRange[1] - bodyRange[0];
222
- const idealStart = focusedRowInMiddle - Math.floor(Math.max(0, availableMiddle - focusedHeight) / 2);
223
- return Math.max(0, Math.min(idealStart, middleRows - availableMiddle));
224
- }
225
- function decorateOverflow(scrollableMiddle, hasUp, hasDown, theme) {
226
- if (hasUp && hasDown && scrollableMiddle.length === 1) {
227
- scrollableMiddle[0] = theme.fg("dim", OVERFLOW_BOTH);
228
- return;
229
- }
230
- if (hasUp && scrollableMiddle.length > 0) {
231
- scrollableMiddle[0] = theme.fg("dim", OVERFLOW_UP);
232
- }
233
- if (hasDown && scrollableMiddle.length > 0) {
234
- scrollableMiddle[scrollableMiddle.length - 1] = theme.fg("dim", OVERFLOW_DOWN);
235
- }
236
- }
296
+ var MIN_SCROLL_ROWS_WITH_PINNED_HEADING = 3;
237
297
 
238
298
  class DialogView {
239
299
  liveProps;
@@ -252,13 +312,15 @@ class DialogView {
252
312
  notesInput: config.notesInput,
253
313
  isMulti: config.isMulti,
254
314
  getCurrentBodyHeight: config.getCurrentBodyHeight,
255
- collapseKey: config.collapseKey
315
+ collapseKey: config.collapseKey,
316
+ notesKey: config.notesKey
256
317
  });
257
318
  this.submitStrategy = config.isMulti ? new SubmitTabStrategy({
258
319
  theme: config.theme,
259
320
  questions: config.questions,
260
321
  submitPicker: config.submitPicker,
261
- notesInput: config.notesInput
322
+ notesInput: config.notesInput,
323
+ notesKey: config.notesKey
262
324
  }) : undefined;
263
325
  this.maxFooterRowCount = Math.max(this.questionStrategy.footerRowCount, this.submitStrategy?.footerRowCount ?? 0);
264
326
  }
@@ -272,31 +334,46 @@ class DialogView {
272
334
  const onSubmit = this.config.isMulti && state.currentTab === this.config.questions.length;
273
335
  const strategy = onSubmit && this.submitStrategy ? this.submitStrategy : this.questionStrategy;
274
336
  const headingRowCache = strategy.headingRows(state);
275
- const headingCount = headingRowCache.length;
276
- const natural = this.buildContainerFromStrategy(strategy, headingRowCache).render(width);
337
+ const headingHeight = headingRowCache.reduce((rows, c) => rows + c.render(width).length, 0);
338
+ const midRows = strategy.midRows(state);
339
+ const natural = this.buildContainerFromStrategy(strategy, headingRowCache, midRows).render(width);
277
340
  const topFixed = 1 + (this.config.isMulti && this.config.tabBar ? 2 : 0) + 1;
278
341
  const bottomFixed = 1 + strategy.footerRowCount;
279
- const middleRows = natural.length - topFixed - bottomFixed;
280
- const spacerRows = Math.max(0, this.config.getBodyHeight(width) + this.maxFooterRowCount - strategy.bodyHeight(width, state) - strategy.footerRowCount);
342
+ const spacerRows = Math.max(0, this.config.getBodyHeight(width) + this.maxFooterRowCount - strategy.bodyHeight(width, state) - strategy.footerRowCount - (state.notesVisible ? 0 : midRows.length));
281
343
  const termRows = this.config.getTerminalRows();
282
- if (natural.length + spacerRows <= termRows) {
283
- return renderFitsTerminal(natural, spacerRows);
344
+ if (natural.length <= termRows) {
345
+ return renderFitsTerminal(natural, Math.min(spacerRows, termRows - natural.length));
284
346
  }
285
- const availableMiddle = Math.max(0, termRows - topFixed - bottomFixed);
347
+ const pinHeading = termRows - topFixed - headingHeight - bottomFixed >= MIN_SCROLL_ROWS_WITH_PINNED_HEADING;
348
+ const top = pinHeading ? topFixed + headingHeight : topFixed;
349
+ const rowsBeforeBody = pinHeading ? 0 : headingHeight;
350
+ const middleRows = natural.length - top - bottomFixed;
351
+ const availableMiddle = Math.max(0, termRows - top - bottomFixed);
286
352
  if (availableMiddle === 0) {
287
- return renderChromeOnly(natural, topFixed, bottomFixed, termRows);
353
+ return renderChromeOnly(natural, top, bottomFixed, termRows);
354
+ }
355
+ const windowRows = Math.min(availableMiddle, middleRows);
356
+ const optionFocus = strategy.focusedItemRowRange(width, state);
357
+ const notesRows = midRows.reduce((rows, component) => rows + component.render(width).length, 0);
358
+ const notesStart = Math.max(0, middleRows - notesRows);
359
+ let focusInMiddle;
360
+ if (state.notesVisible && notesRows > 0) {
361
+ const cursorRow = notesRows <= windowRows ? -1 : natural.slice(top, top + middleRows).findIndex((line, i) => i >= notesStart && line.includes(CURSOR_MARKER));
362
+ focusInMiddle = cursorRow === -1 ? [notesStart, middleRows] : [cursorRow, cursorRow + 1];
363
+ } else if (optionFocus) {
364
+ focusInMiddle = [rowsBeforeBody + optionFocus[0], rowsBeforeBody + optionFocus[1]];
288
365
  }
289
- const scrollStart = computeScrollStart(strategy.focusedItemRowRange(width, state), headingCount, availableMiddle, middleRows);
290
- const scrollableMiddle = natural.slice(topFixed + scrollStart, topFixed + scrollStart + availableMiddle);
291
- decorateOverflow(scrollableMiddle, scrollStart > 0, scrollStart + availableMiddle < middleRows, this.config.theme);
292
- const result = [
293
- ...natural.slice(0, topFixed),
294
- ...scrollableMiddle,
295
- ...natural.slice(natural.length - bottomFixed)
366
+ const scrollStart = computeScrollStart(focusInMiddle, 0, windowRows, middleRows);
367
+ const scrollableMiddle = natural.slice(top + scrollStart, top + scrollStart + windowRows);
368
+ const focusInWindow = focusInMiddle && [
369
+ focusInMiddle[0] - scrollStart,
370
+ focusInMiddle[1] - scrollStart
296
371
  ];
372
+ decorateOverflow(scrollableMiddle, scrollStart > 0, scrollStart + windowRows < middleRows, this.config.theme, focusInWindow);
373
+ const result = [...natural.slice(0, top), ...scrollableMiddle, ...natural.slice(natural.length - bottomFixed)];
297
374
  return result.length > termRows ? result.slice(0, termRows) : result;
298
375
  }
299
- buildContainerFromStrategy(strategy, headingRowCache) {
376
+ buildContainerFromStrategy(strategy, headingRowCache, midRows) {
300
377
  const { theme, isMulti, tabBar } = this.config;
301
378
  const state = this.liveProps.state;
302
379
  const container = new Container2;
@@ -309,7 +386,7 @@ class DialogView {
309
386
  container.addChild(c);
310
387
  container.addChild(strategy.bodyComponent(state));
311
388
  container.addChild(new Spacer2(1));
312
- for (const c of strategy.midRows(state))
389
+ for (const c of midRows)
313
390
  container.addChild(c);
314
391
  container.addChild(border());
315
392
  for (const c of strategy.footerRows(state))
@@ -343,7 +420,7 @@ function perTabBinding(spec) {
343
420
  import { truncateToWidth as truncateToWidth2, visibleWidth, wrapTextWithAnsi as wrapTextWithAnsi2 } from "@earendil-works/pi-tui";
344
421
 
345
422
  // view/components/inline-input.ts
346
- import { CURSOR_MARKER, renderFakeCursor, wrapTextWithAnsi } from "@earendil-works/pi-tui";
423
+ import { CURSOR_MARKER as CURSOR_MARKER2, renderFakeCursor, wrapTextWithAnsi } from "@earendil-works/pi-tui";
347
424
  var graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
348
425
  function resolveCursorOffset(buffer, requested) {
349
426
  if (requested !== undefined && requested >= 0 && requested <= buffer.length)
@@ -358,7 +435,7 @@ function buildCursorRaw(buffer, offset) {
358
435
  `;
359
436
  const atCursor = rawAt === "" || rawAt === " " || cursorAtLineEnd ? " " : rawAt;
360
437
  const after = buffer.slice(offset + (cursorAtLineEnd ? 0 : rawAt.length));
361
- return `${before}${CURSOR_MARKER}${renderFakeCursor(atCursor)}${after}`;
438
+ return `${before}${CURSOR_MARKER2}${renderFakeCursor(atCursor)}${after}`;
362
439
  }
363
440
  function renderInlineInputRow(opts) {
364
441
  const { buffer, cursorOffset, rowPrefix, continuationPrefix, contentWidth, selectedText } = opts;
@@ -676,57 +753,7 @@ class OptionListView {
676
753
  }
677
754
 
678
755
  // view/components/preview/markdown-content-cache.ts
679
- import { Markdown, visibleWidth as visibleWidth4 } from "@earendil-works/pi-tui";
680
-
681
- // view/components/preview/preview-box-renderer.ts
682
- import { truncateToWidth as truncateToWidth3, visibleWidth as visibleWidth3 } from "@earendil-works/pi-tui";
683
- var ANSI_SGR_RE = /\x1b\[[0-9;]*m/g;
684
- var ANSI_OSC8_RE = /\x1b\]8;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
685
- var FENCE_MARKER_RE = /^`{3}/;
686
- var BORDER_VERTICAL_OVERHEAD = 2;
687
- var BORDER_HORIZONTAL_OVERHEAD = 2;
688
- var BORDER_INNER_PADDING_HORIZONTAL = 1;
689
- var BOX_MIN_CONTENT_WIDTH = 40;
690
- function stripFenceMarkers(lines) {
691
- return lines.filter((line) => {
692
- const clean = line.replace(ANSI_SGR_RE, "").replace(ANSI_OSC8_RE, "");
693
- return !FENCE_MARKER_RE.test(clean);
694
- });
695
- }
696
- function renderBorderedBox(lines, width, colorFn, hidden = 0) {
697
- const dashSpan = Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD);
698
- const contentInner = Math.max(1, dashSpan - 2 * BORDER_INNER_PADDING_HORIZONTAL);
699
- const pad = " ".repeat(BORDER_INNER_PADDING_HORIZONTAL);
700
- const top = colorFn(`┌${"─".repeat(dashSpan)}┐`);
701
- const out = [top];
702
- for (const line of lines) {
703
- const padded = truncateToWidth3(line, contentInner, "", true);
704
- out.push(`${colorFn("│")}${pad}${padded}${pad}${colorFn("│")}`);
705
- }
706
- if (hidden > 0) {
707
- const indicator = ` ✂ ── ${hidden} lines hidden ── `;
708
- const space = dashSpan - indicator.length;
709
- const leftFill = "─".repeat(Math.max(0, Math.floor(space / 2)));
710
- const rightFill = "─".repeat(Math.max(0, dashSpan - leftFill.length - indicator.length));
711
- out.push(colorFn(`└${leftFill}${indicator}${rightFill}┘`));
712
- } else {
713
- out.push(colorFn(`└${"─".repeat(dashSpan)}┘`));
714
- }
715
- return out;
716
- }
717
- function computeBoxDimensions(contentLines, maxInnerWidth) {
718
- let widest = Math.min(BOX_MIN_CONTENT_WIDTH, maxInnerWidth);
719
- for (const line of contentLines) {
720
- const w = visibleWidth3(line.replace(/\s+$/, ""));
721
- if (w > widest)
722
- widest = w;
723
- }
724
- const innerWidth = Math.min(widest, maxInnerWidth);
725
- const boxWidth = innerWidth + BORDER_HORIZONTAL_OVERHEAD + 2 * BORDER_INNER_PADDING_HORIZONTAL;
726
- return { innerWidth, boxWidth };
727
- }
728
-
729
- // view/components/preview/markdown-content-cache.ts
756
+ import { Markdown, visibleWidth as visibleWidth3 } from "@earendil-works/pi-tui";
730
757
  var MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE = 20;
731
758
  var MAX_PREVIEW_HEIGHT_STACKED = 15;
732
759
  var NO_PREVIEW_TEXT = "No preview available";
@@ -764,7 +791,7 @@ class MarkdownContentCache {
764
791
  const text = this.previewTexts.get(optionIndex);
765
792
  if (!text) {
766
793
  const placeholder = this.theme.fg("dim", t("preview.no_preview", NO_PREVIEW_TEXT));
767
- const pad = Math.max(0, innerWidth - visibleWidth4(placeholder));
794
+ const pad = Math.max(0, innerWidth - visibleWidth3(placeholder));
768
795
  return [placeholder + " ".repeat(pad)];
769
796
  }
770
797
  let md = this.markdownCache.get(optionIndex);
@@ -782,7 +809,8 @@ class MarkdownContentCache {
782
809
  }
783
810
 
784
811
  // view/components/preview/preview-block-renderer.ts
785
- var NOTES_AFFORDANCE_TEXT = "Notes: press n to add notes";
812
+ var NOTES_AFFORDANCE_TEXT = `Notes: press ${formatNotesKeyForDisplay(DEFAULT_NOTES_KEY)} to add notes`;
813
+ var NOTES_EDIT_AFFORDANCE_TEXT = `Note saved: press ${formatNotesKeyForDisplay(DEFAULT_NOTES_KEY)} to edit`;
786
814
  function contentBudgetFor(mode) {
787
815
  const cap = mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
788
816
  return Math.max(1, cap - BORDER_VERTICAL_OVERHEAD - NOTES_AFFORDANCE_OVERHEAD);
@@ -794,8 +822,10 @@ function innerWidthFor(width) {
794
822
  class PreviewBlockRenderer {
795
823
  theme;
796
824
  cache;
825
+ notesKey;
797
826
  constructor(config) {
798
827
  this.theme = config.theme;
828
+ this.notesKey = config.notesKey;
799
829
  this.cache = new MarkdownContentCache(config.question, config.theme, config.markdownTheme);
800
830
  }
801
831
  hasAnyPreview() {
@@ -814,7 +844,7 @@ class PreviewBlockRenderer {
814
844
  const contentRows = Math.min(rawRows, contentBudget);
815
845
  return BORDER_VERTICAL_OVERHEAD + contentRows + NOTES_AFFORDANCE_OVERHEAD;
816
846
  }
817
- renderBlock(width, optionIndex, mode, focused, notesVisible) {
847
+ renderBlock(width, optionIndex, mode, focused, notesVisible, hasNote = false) {
818
848
  const contentBudget = contentBudgetFor(mode);
819
849
  const maxInnerWidth = innerWidthFor(width);
820
850
  const raw = this.cache.bodyFor(optionIndex, maxInnerWidth);
@@ -825,13 +855,14 @@ class PreviewBlockRenderer {
825
855
  const colorFn = (s) => this.theme.fg("accent", s);
826
856
  const boxedLines = renderBorderedBox(contentLines, boxWidth, colorFn, hidden);
827
857
  const showAffordance = focused && !notesVisible && this.cache.has(optionIndex);
828
- const affordance = showAffordance ? this.theme.fg("muted", t("preview.notes_affordance", NOTES_AFFORDANCE_TEXT)) : "";
858
+ const [affordanceKey, affordanceFallback] = hasNote ? ["preview.notes_edit_affordance", NOTES_EDIT_AFFORDANCE_TEXT] : ["preview.notes_affordance", NOTES_AFFORDANCE_TEXT];
859
+ const affordance = showAffordance ? this.theme.fg("muted", t(affordanceKey, affordanceFallback).replace(KEY_PLACEHOLDER, formatNotesKeyForDisplay(this.notesKey))) : "";
829
860
  return [...boxedLines, "", affordance];
830
861
  }
831
862
  }
832
863
 
833
864
  // view/components/preview/preview-layout-decider.ts
834
- import { visibleWidth as visibleWidth5 } from "@earendil-works/pi-tui";
865
+ import { visibleWidth as visibleWidth4 } from "@earendil-works/pi-tui";
835
866
  var PREVIEW_MIN_WIDTH = 100;
836
867
  var PREVIEW_COLUMN_GAP = 2;
837
868
  var PREVIEW_PADDING_LEFT = 1;
@@ -848,7 +879,7 @@ function adaptiveLeftWidth(items, totalForNumbering, paneWidth) {
848
879
  const confirmedOverhead = CONFIRMED_OVERHEAD;
849
880
  let maxLabel = 0;
850
881
  for (const item of items) {
851
- const w = visibleWidth5(item.label);
882
+ const w = visibleWidth4(item.label);
852
883
  if (w > maxLabel)
853
884
  maxLabel = w;
854
885
  }
@@ -876,7 +907,7 @@ function previewSourceWidth(question) {
876
907
  continue;
877
908
  for (const line of text.split(`
878
909
  `)) {
879
- const w = visibleWidth5(line);
910
+ const w = visibleWidth4(line);
880
911
  if (w > max)
881
912
  max = w;
882
913
  }
@@ -917,7 +948,7 @@ function bodyWidths(paneWidth, mode, adaptiveLeft) {
917
948
  }
918
949
 
919
950
  // view/components/preview/preview-pane.ts
920
- import { truncateToWidth as truncateToWidth4, visibleWidth as visibleWidth6 } from "@earendil-works/pi-tui";
951
+ import { truncateToWidth as truncateToWidth3, visibleWidth as visibleWidth5 } from "@earendil-works/pi-tui";
921
952
  class PreviewPane {
922
953
  question;
923
954
  getTerminalWidth;
@@ -932,7 +963,7 @@ class PreviewPane {
932
963
  this.getTerminalWidth = config.getTerminalWidth;
933
964
  this.optionListView = config.optionListView;
934
965
  this.previewBlock = config.previewBlock;
935
- this.props = { notesVisible: false, selectedIndex: 0, focused: false, inputMode: false };
966
+ this.props = { notesVisible: false, hasNote: false, selectedIndex: 0, focused: false, inputMode: false };
936
967
  }
937
968
  setGlobalLeftWidth(getter) {
938
969
  this.globalLeftWidth = getter;
@@ -961,7 +992,7 @@ class PreviewPane {
961
992
  return [
962
993
  ...this.optionListView.render(width),
963
994
  ...Array(STACKED_GAP_ROWS).fill(""),
964
- ...this.previewBlock.renderBlock(width, this.props.selectedIndex, mode, this.props.focused, this.props.notesVisible)
995
+ ...this.previewBlock.renderBlock(width, this.props.selectedIndex, mode, this.props.focused, this.props.notesVisible, this.props.hasNote)
965
996
  ];
966
997
  }
967
998
  focusedItemRowRange(width) {
@@ -1026,29 +1057,29 @@ class PreviewPane {
1026
1057
  for (let i = 0;i < rows; i++) {
1027
1058
  const leftRaw = leftLines[i] ?? "";
1028
1059
  const rightRaw = rightLines[i] ?? "";
1029
- const leftClamped = truncateToWidth4(leftRaw, leftWidth, "");
1030
- const leftPad = " ".repeat(Math.max(0, leftWidth - visibleWidth6(leftClamped)));
1060
+ const leftClamped = truncateToWidth3(leftRaw, leftWidth, "");
1061
+ const leftPad = " ".repeat(Math.max(0, leftWidth - visibleWidth5(leftClamped)));
1031
1062
  const joined = `${leftClamped}${leftPad}${gapStr}${rightRaw}`;
1032
- out.push(truncateToWidth4(joined, width, ""));
1063
+ out.push(truncateToWidth3(joined, width, ""));
1033
1064
  }
1034
1065
  return out;
1035
1066
  }
1036
1067
  renderPaddedPreviewLines(colWidth, mode) {
1037
1068
  const inner = Math.max(1, colWidth - PREVIEW_PADDING_LEFT);
1038
- const contentLines = this.previewBlock.renderBlock(inner, this.props.selectedIndex, mode, this.props.focused, this.props.notesVisible);
1039
- const boxWidth = Math.max(1, visibleWidth6(contentLines[0] ?? ""));
1069
+ const contentLines = this.previewBlock.renderBlock(inner, this.props.selectedIndex, mode, this.props.focused, this.props.notesVisible, this.props.hasNote);
1070
+ const boxWidth = Math.max(1, visibleWidth5(contentLines[0] ?? ""));
1040
1071
  const boxAlignedPad = Math.max(PREVIEW_PADDING_LEFT, colWidth - boxWidth);
1041
1072
  return contentLines.map((line) => {
1042
1073
  if (line === "")
1043
1074
  return "";
1044
- const pad = Math.max(PREVIEW_PADDING_LEFT, Math.min(boxAlignedPad, colWidth - visibleWidth6(line)));
1045
- return `${" ".repeat(pad)}${truncateToWidth4(line, colWidth - pad, "")}`;
1075
+ const pad = Math.max(PREVIEW_PADDING_LEFT, Math.min(boxAlignedPad, colWidth - visibleWidth5(line)));
1076
+ return `${" ".repeat(pad)}${truncateToWidth3(line, colWidth - pad, "")}`;
1046
1077
  });
1047
1078
  }
1048
1079
  }
1049
1080
 
1050
1081
  // view/components/submit-picker.ts
1051
- import { truncateToWidth as truncateToWidth5 } from "@earendil-works/pi-tui";
1082
+ import { truncateToWidth as truncateToWidth4 } from "@earendil-works/pi-tui";
1052
1083
  var ACTIVE_POINTER2 = "❯ ";
1053
1084
  var INACTIVE_POINTER2 = " ";
1054
1085
  var NUMBER_SEPARATOR2 = ". ";
@@ -1078,14 +1109,14 @@ class SubmitPicker {
1078
1109
  const pointer = active ? ACTIVE_POINTER2 : INACTIVE_POINTER2;
1079
1110
  const number = `${i + 1}${NUMBER_SEPARATOR2}`;
1080
1111
  const label = active ? this.theme.fg("accent", this.theme.bold(text)) : this.theme.fg("text", text);
1081
- lines.push(truncateToWidth5(`${pointer}${number}${label}`, width, ""));
1112
+ lines.push(truncateToWidth4(`${pointer}${number}${label}`, width, ""));
1082
1113
  }
1083
1114
  return lines;
1084
1115
  }
1085
1116
  }
1086
1117
 
1087
1118
  // view/components/tab-bar.ts
1088
- import { truncateToWidth as truncateToWidth6 } from "@earendil-works/pi-tui";
1119
+ import { truncateToWidth as truncateToWidth5 } from "@earendil-works/pi-tui";
1089
1120
 
1090
1121
  class TabBar {
1091
1122
  theme;
@@ -1112,38 +1143,11 @@ class TabBar {
1112
1143
  const submitStyled = this.props.submit.active ? this.theme.bg("selectedBg", this.theme.fg("text", submitText)) : this.theme.fg(this.props.submit.allAnswered ? "success" : "dim", submitText);
1113
1144
  pieces.push(submitStyled);
1114
1145
  pieces.push(" →");
1115
- const tabLine = truncateToWidth6(pieces.join(""), width, "");
1146
+ const tabLine = truncateToWidth5(pieces.join(""), width, "");
1116
1147
  return [tabLine, ""];
1117
1148
  }
1118
1149
  }
1119
1150
 
1120
- // state/selectors/derivations.ts
1121
- function selectConfirmedIndicator(questions, currentTab, answers, items) {
1122
- const q = questions[currentTab];
1123
- if (!q || q.multiSelect === true)
1124
- return;
1125
- const prior = answers.get(currentTab);
1126
- if (!prior)
1127
- return;
1128
- if (prior.kind === "custom") {
1129
- const otherIndex = items.findIndex((it) => it.kind === "other");
1130
- if (otherIndex < 0)
1131
- return;
1132
- return { index: otherIndex, labelOverride: prior.answer ?? "" };
1133
- }
1134
- if (prior.kind !== "option" || typeof prior.answer !== "string")
1135
- return;
1136
- const index = items.findIndex((it) => it.kind === "option" && it.label === prior.answer);
1137
- if (index < 0)
1138
- return;
1139
- return { index };
1140
- }
1141
- function selectActivePreviewPaneIndex(currentTab, totalQuestions) {
1142
- if (totalQuestions <= 0)
1143
- return 0;
1144
- return Math.min(currentTab, totalQuestions - 1);
1145
- }
1146
-
1147
1151
  // state/selectors/focus.ts
1148
1152
  function selectActiveView(state, totalQuestions) {
1149
1153
  if (state.notesVisible)
@@ -1290,6 +1294,7 @@ var selectSubmitPickerProps = (state, ctx) => {
1290
1294
  };
1291
1295
  var selectPreviewPaneProps = (state, ctx) => ({
1292
1296
  notesVisible: state.notesVisible,
1297
+ hasNote: selectSavedNote(state.notesByTab, state.answers, ctx.i) !== undefined,
1293
1298
  selectedIndex: state.optionIndex,
1294
1299
  focused: ctx.activeView === "options",
1295
1300
  inputMode: state.inputMode
@@ -1352,12 +1357,13 @@ class QuestionnaireBuilder {
1352
1357
  initialState;
1353
1358
  getCurrentTab;
1354
1359
  collapseKey;
1360
+ notesKey;
1355
1361
  selectTheme;
1356
1362
  markdownTheme = getMarkdownTheme();
1357
1363
  notesInput;
1358
1364
  inlineInput;
1359
1365
  getTerminalWidth = () => this.tui.terminal.columns;
1360
- getTerminalRows = () => this.tui.terminal.rows;
1366
+ rowBudget;
1361
1367
  constructor(config) {
1362
1368
  this.tui = config.tui;
1363
1369
  this.theme = config.theme;
@@ -1367,6 +1373,8 @@ class QuestionnaireBuilder {
1367
1373
  this.initialState = config.initialState;
1368
1374
  this.getCurrentTab = config.getCurrentTab;
1369
1375
  this.collapseKey = config.collapseKey;
1376
+ this.notesKey = config.notesKey;
1377
+ this.rowBudget = config.rowBudget ?? ((terminalRows) => terminalRows);
1370
1378
  this.selectTheme = this.makeSelectTheme();
1371
1379
  const textEditorTheme = editorTheme(this.theme);
1372
1380
  this.notesInput = new Editor(this.tui, textEditorTheme);
@@ -1405,7 +1413,8 @@ class QuestionnaireBuilder {
1405
1413
  const previewBlock = new PreviewBlockRenderer({
1406
1414
  question,
1407
1415
  theme: this.theme,
1408
- markdownTheme: this.markdownTheme
1416
+ markdownTheme: this.markdownTheme,
1417
+ notesKey: this.notesKey
1409
1418
  });
1410
1419
  const preview = new PreviewPane({
1411
1420
  question,
@@ -1466,8 +1475,9 @@ class QuestionnaireBuilder {
1466
1475
  submitPicker,
1467
1476
  getBodyHeight: heights.global,
1468
1477
  getCurrentBodyHeight: heights.current,
1469
- getTerminalRows: this.getTerminalRows,
1470
- collapseKey: this.collapseKey
1478
+ getTerminalRows: () => this.rowBudget(this.tui.terminal.rows),
1479
+ collapseKey: this.collapseKey,
1480
+ notesKey: this.notesKey
1471
1481
  }, { state: this.initialState, activePreviewPane: this.pickInitialActivePreview(tabs) });
1472
1482
  }
1473
1483
  buildGlobalBindings(dialog, submitPicker, tabBar) {
@@ -1531,6 +1541,10 @@ var KEYBIND_EDITOR_DOWN = "tui.editor.cursorDown";
1531
1541
  var KEYBIND_CLEAR = "tui.editor.deleteToLineStart";
1532
1542
  var KEYBIND_EXTERNAL_EDITOR = "app.editor.external";
1533
1543
  var NOTES_ACTIVATE_KEY = "n";
1544
+ function matchesNotesKey(data, notesKey) {
1545
+ const key = typeof notesKey === "string" ? notesKey : NOTES_ACTIVATE_KEY;
1546
+ return data === key || matchesKey(data, key);
1547
+ }
1534
1548
  function isConfirm(kb, data) {
1535
1549
  return kb.matches(data, KEYBIND_CONFIRM) || kb.matches(data, KEYBIND_SUBMIT);
1536
1550
  }
@@ -1674,11 +1688,19 @@ function routeSubmitTab(kb, data, state, runtime) {
1674
1688
  if (isConfirm(kb, data)) {
1675
1689
  return state.submitChoiceIndex === 1 ? { kind: "cancel" } : { kind: "submit" };
1676
1690
  }
1677
- if (data === NOTES_ACTIVATE_KEY) {
1691
+ if (matchesNotesKey(data, runtime.notesKey)) {
1678
1692
  return { kind: "notes_enter" };
1679
1693
  }
1680
1694
  return { kind: "ignore" };
1681
1695
  }
1696
+ var DIGIT_KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
1697
+ function digitRow(data, runtime) {
1698
+ const index = DIGIT_KEYS.findIndex((digit) => matchesKey(data, digit));
1699
+ const item = runtime.items[index];
1700
+ if (!item || !ROW_INTENT_META[item.kind].numbered)
1701
+ return;
1702
+ return { index, activatesInputMode: ROW_INTENT_META[item.kind].activatesInputMode };
1703
+ }
1682
1704
  function routeMultiSelectTab(kb, data, state, runtime) {
1683
1705
  const focusedKind = runtime.currentItem?.kind;
1684
1706
  const focusedMeta = focusedKind ? ROW_INTENT_META[focusedKind] : undefined;
@@ -1696,7 +1718,12 @@ function routeMultiSelectTab(kb, data, state, runtime) {
1696
1718
  }
1697
1719
  if (kb.matches(data, KEYBIND_CANCEL))
1698
1720
  return { kind: "cancel" };
1699
- return { kind: "ignore" };
1721
+ const row = digitRow(data, runtime);
1722
+ if (!row)
1723
+ return { kind: "ignore" };
1724
+ if (row.activatesInputMode)
1725
+ return { kind: "nav", nextIndex: row.index, inputValue: runtime.inputBuffer };
1726
+ return { kind: "toggle", index: row.index };
1700
1727
  }
1701
1728
  function routeSingleSelectTab(kb, data, state, runtime) {
1702
1729
  if (isConfirm(kb, data)) {
@@ -1707,7 +1734,15 @@ function routeSingleSelectTab(kb, data, state, runtime) {
1707
1734
  }
1708
1735
  if (kb.matches(data, KEYBIND_CANCEL))
1709
1736
  return { kind: "cancel" };
1710
- return { kind: "ignore" };
1737
+ const row = digitRow(data, runtime);
1738
+ if (!row)
1739
+ return { kind: "ignore" };
1740
+ if (row.activatesInputMode)
1741
+ return { kind: "nav", nextIndex: row.index, inputValue: runtime.inputBuffer };
1742
+ const answer = buildSingleSelectAnswer({ ...state, optionIndex: row.index }, { ...runtime, currentItem: runtime.items[row.index] });
1743
+ if (!answer)
1744
+ return { kind: "ignore" };
1745
+ return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
1711
1746
  }
1712
1747
  function routeKey(data, state, runtime) {
1713
1748
  const kb = runtime.keybindings;
@@ -1729,7 +1764,7 @@ function routeKey(data, state, runtime) {
1729
1764
  const q = runtime.questions[state.currentTab];
1730
1765
  if (!q)
1731
1766
  return { kind: "ignore" };
1732
- if (data === NOTES_ACTIVATE_KEY) {
1767
+ if (matchesNotesKey(data, runtime.notesKey)) {
1733
1768
  return { kind: "notes_enter" };
1734
1769
  }
1735
1770
  if (kb.matches(data, KEYBIND_UP)) {
@@ -1816,12 +1851,20 @@ function withoutCustomDraft(state, tab) {
1816
1851
  drafts.delete(tab);
1817
1852
  return drafts;
1818
1853
  }
1854
+ function answeredRowIndex(answer, items) {
1855
+ let index = -1;
1856
+ if (answer?.kind === "option")
1857
+ index = items.findIndex((i) => i.kind === "option" && i.label === answer.answer);
1858
+ if (answer?.kind === "multi")
1859
+ index = items.findIndex((i) => i.kind === "next");
1860
+ return Math.max(0, index);
1861
+ }
1819
1862
  function switchTabResult(state, nextTab, ctx) {
1820
1863
  const notesValue = notesValueFor(state, nextTab);
1821
1864
  const transitioned = {
1822
1865
  ...state,
1823
1866
  currentTab: nextTab,
1824
- optionIndex: 0,
1867
+ optionIndex: answeredRowIndex(state.answers.get(nextTab), ctx.itemsByTab[nextTab] ?? []),
1825
1868
  inputMode: false,
1826
1869
  notesVisible: false,
1827
1870
  submitChoiceIndex: 0,
@@ -2038,6 +2081,7 @@ class QuestionnaireSession {
2038
2081
  keybindings;
2039
2082
  editInput;
2040
2083
  collapseKey;
2084
+ notesKey;
2041
2085
  canReopenWhileHidden;
2042
2086
  inputEditorOpen = false;
2043
2087
  overlayHandle;
@@ -2053,6 +2097,7 @@ class QuestionnaireSession {
2053
2097
  this.keybindings = config.keybindings;
2054
2098
  this.editInput = config.editInput;
2055
2099
  this.collapseKey = config.collapseKey;
2100
+ this.notesKey = config.notesKey;
2056
2101
  this.canReopenWhileHidden = config.canReopenWhileHidden;
2057
2102
  const built = buildQuestionnaire({
2058
2103
  tui: this.tui,
@@ -2062,7 +2107,9 @@ class QuestionnaireSession {
2062
2107
  isMulti: this.isMulti,
2063
2108
  initialState: this.state,
2064
2109
  getCurrentTab: () => this.state.currentTab,
2065
- collapseKey: this.collapseKey
2110
+ collapseKey: this.collapseKey,
2111
+ notesKey: this.notesKey,
2112
+ rowBudget: config.rowBudget
2066
2113
  });
2067
2114
  this.notesInput = built.notesInput;
2068
2115
  this.inlineInput = built.inlineInput;
@@ -2164,7 +2211,8 @@ class QuestionnaireSession {
2164
2211
  isMulti: this.isMulti,
2165
2212
  currentItem: this.currentItem(),
2166
2213
  items: this.itemsByTab[this.state.currentTab] ?? [],
2167
- collapseKey: this.collapseKey
2214
+ collapseKey: this.collapseKey,
2215
+ notesKey: this.notesKey
2168
2216
  };
2169
2217
  }
2170
2218
  applyContext() {