@astralyn/pi 0.87.4 → 0.87.6

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 (185) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/bundle/chunks/{chunk-CUDKRZ2V.js → chunk-4W4Q2H6W.js} +58 -57
  3. package/dist/bundle/chunks/{chunk-ANT4TSUE.js → chunk-IORR2FW4.js} +1 -1
  4. package/dist/bundle/chunks/{virtual-modules-Y2YZRMU3.js → virtual-modules-KUEIEAZN.js} +1 -1
  5. package/dist/bundle/cli.js +1 -1
  6. package/dist/bundle/index.js +1 -1
  7. package/dist/bundle/rpc-entry.js +1 -1
  8. package/dist/core/agent-session.d.ts +8 -24
  9. package/dist/core/agent-session.d.ts.map +1 -1
  10. package/dist/core/agent-session.js +54 -109
  11. package/dist/core/agent-session.js.map +1 -1
  12. package/dist/core/background/session.d.ts +11 -7
  13. package/dist/core/background/session.d.ts.map +1 -1
  14. package/dist/core/background/session.js +42 -16
  15. package/dist/core/background/session.js.map +1 -1
  16. package/dist/core/context-snapshot.d.ts +17 -4
  17. package/dist/core/context-snapshot.d.ts.map +1 -1
  18. package/dist/core/context-snapshot.js +48 -50
  19. package/dist/core/context-snapshot.js.map +1 -1
  20. package/dist/core/extensions/distribution-api.d.ts +0 -7
  21. package/dist/core/extensions/distribution-api.d.ts.map +1 -1
  22. package/dist/core/extensions/distribution-api.js +6 -6
  23. package/dist/core/extensions/distribution-api.js.map +1 -1
  24. package/dist/core/extensions/index.d.ts +1 -1
  25. package/dist/core/extensions/index.d.ts.map +1 -1
  26. package/dist/core/extensions/index.js +1 -1
  27. package/dist/core/extensions/index.js.map +1 -1
  28. package/dist/core/extensions/loader.d.ts +1 -1
  29. package/dist/core/extensions/loader.d.ts.map +1 -1
  30. package/dist/core/extensions/loader.js +3 -2
  31. package/dist/core/extensions/loader.js.map +1 -1
  32. package/dist/core/extensions/types.d.ts +1 -5
  33. package/dist/core/extensions/types.d.ts.map +1 -1
  34. package/dist/core/extensions/types.js +1 -1
  35. package/dist/core/extensions/types.js.map +1 -1
  36. package/dist/core/keybindings.d.ts +10 -0
  37. package/dist/core/keybindings.d.ts.map +1 -1
  38. package/dist/core/keybindings.js +3 -0
  39. package/dist/core/keybindings.js.map +1 -1
  40. package/dist/core/sdk.d.ts.map +1 -1
  41. package/dist/core/sdk.js +59 -7
  42. package/dist/core/sdk.js.map +1 -1
  43. package/dist/core/tools/bash.d.ts +1 -3
  44. package/dist/core/tools/bash.d.ts.map +1 -1
  45. package/dist/core/tools/bash.js.map +1 -1
  46. package/dist/core/tools/find.d.ts.map +1 -1
  47. package/dist/core/tools/find.js +0 -1
  48. package/dist/core/tools/find.js.map +1 -1
  49. package/dist/core/tools/grep.d.ts.map +1 -1
  50. package/dist/core/tools/grep.js +0 -1
  51. package/dist/core/tools/grep.js.map +1 -1
  52. package/dist/core/tools/ls.d.ts.map +1 -1
  53. package/dist/core/tools/ls.js +0 -1
  54. package/dist/core/tools/ls.js.map +1 -1
  55. package/dist/core/tools/read.d.ts.map +1 -1
  56. package/dist/core/tools/read.js +0 -1
  57. package/dist/core/tools/read.js.map +1 -1
  58. package/dist/core/tools/render-utils.d.ts +0 -7
  59. package/dist/core/tools/render-utils.d.ts.map +1 -1
  60. package/dist/core/tools/render-utils.js +0 -11
  61. package/dist/core/tools/render-utils.js.map +1 -1
  62. package/dist/core/tools/renderers/bash.d.ts.map +1 -1
  63. package/dist/core/tools/renderers/bash.js +59 -5
  64. package/dist/core/tools/renderers/bash.js.map +1 -1
  65. package/dist/core/tools/renderers/edit.d.ts.map +1 -1
  66. package/dist/core/tools/renderers/edit.js +4 -22
  67. package/dist/core/tools/renderers/edit.js.map +1 -1
  68. package/dist/core/tools/renderers/find.d.ts.map +1 -1
  69. package/dist/core/tools/renderers/find.js +7 -6
  70. package/dist/core/tools/renderers/find.js.map +1 -1
  71. package/dist/core/tools/renderers/grep.d.ts.map +1 -1
  72. package/dist/core/tools/renderers/grep.js +8 -7
  73. package/dist/core/tools/renderers/grep.js.map +1 -1
  74. package/dist/core/tools/renderers/ls.d.ts.map +1 -1
  75. package/dist/core/tools/renderers/ls.js +3 -2
  76. package/dist/core/tools/renderers/ls.js.map +1 -1
  77. package/dist/core/tools/renderers/read.d.ts.map +1 -1
  78. package/dist/core/tools/renderers/read.js +7 -12
  79. package/dist/core/tools/renderers/read.js.map +1 -1
  80. package/dist/core/tools/renderers/write.d.ts.map +1 -1
  81. package/dist/core/tools/renderers/write.js +3 -2
  82. package/dist/core/tools/renderers/write.js.map +1 -1
  83. package/dist/core/tools/tool-definition-wrapper.d.ts +2 -6
  84. package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -1
  85. package/dist/core/tools/tool-definition-wrapper.js +5 -4
  86. package/dist/core/tools/tool-definition-wrapper.js.map +1 -1
  87. package/dist/extensions/background/completion-render.d.ts.map +1 -1
  88. package/dist/extensions/background/completion-render.js +3 -4
  89. package/dist/extensions/background/completion-render.js.map +1 -1
  90. package/dist/extensions/background/constants.d.ts +2 -0
  91. package/dist/extensions/background/constants.d.ts.map +1 -1
  92. package/dist/extensions/background/constants.js +2 -0
  93. package/dist/extensions/background/constants.js.map +1 -1
  94. package/dist/extensions/background/index.d.ts.map +1 -1
  95. package/dist/extensions/background/index.js +31 -5
  96. package/dist/extensions/background/index.js.map +1 -1
  97. package/dist/extensions/question/detail-pane.d.ts +12 -0
  98. package/dist/extensions/question/detail-pane.d.ts.map +1 -0
  99. package/dist/extensions/question/detail-pane.js +42 -0
  100. package/dist/extensions/question/detail-pane.js.map +1 -0
  101. package/dist/extensions/question/dialog.d.ts.map +1 -1
  102. package/dist/extensions/question/dialog.js +175 -105
  103. package/dist/extensions/question/dialog.js.map +1 -1
  104. package/dist/extensions/question/option-window.d.ts +26 -0
  105. package/dist/extensions/question/option-window.d.ts.map +1 -0
  106. package/dist/extensions/question/option-window.js +53 -0
  107. package/dist/extensions/question/option-window.js.map +1 -0
  108. package/dist/extensions/question/render-cache.d.ts +4 -4
  109. package/dist/extensions/question/render-cache.d.ts.map +1 -1
  110. package/dist/extensions/question/render-cache.js +10 -11
  111. package/dist/extensions/question/render-cache.js.map +1 -1
  112. package/dist/extensions/todo/constants.d.ts +1 -1
  113. package/dist/extensions/todo/constants.d.ts.map +1 -1
  114. package/dist/extensions/todo/constants.js +2 -2
  115. package/dist/extensions/todo/constants.js.map +1 -1
  116. package/dist/extensions/todo/index.d.ts.map +1 -1
  117. package/dist/extensions/todo/index.js +12 -8
  118. package/dist/extensions/todo/index.js.map +1 -1
  119. package/dist/extensions/todo/schema.d.ts +1 -0
  120. package/dist/extensions/todo/schema.d.ts.map +1 -1
  121. package/dist/extensions/todo/schema.js +1 -1
  122. package/dist/extensions/todo/schema.js.map +1 -1
  123. package/dist/extensions/todo/state.d.ts.map +1 -1
  124. package/dist/extensions/todo/state.js +15 -5
  125. package/dist/extensions/todo/state.js.map +1 -1
  126. package/dist/extensions/todo/view.d.ts +4 -4
  127. package/dist/extensions/todo/view.d.ts.map +1 -1
  128. package/dist/extensions/todo/view.js +11 -30
  129. package/dist/extensions/todo/view.js.map +1 -1
  130. package/dist/modes/interactive/components/index.d.ts +1 -1
  131. package/dist/modes/interactive/components/index.d.ts.map +1 -1
  132. package/dist/modes/interactive/components/index.js +1 -1
  133. package/dist/modes/interactive/components/index.js.map +1 -1
  134. package/dist/modes/interactive/components/status-marker.d.ts +2 -2
  135. package/dist/modes/interactive/components/status-marker.js +2 -2
  136. package/dist/modes/interactive/components/status-marker.js.map +1 -1
  137. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  138. package/dist/modes/interactive/interactive-mode.js +7 -10
  139. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  140. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  141. package/dist/modes/interactive/theme/theme.js +2 -3
  142. package/dist/modes/interactive/theme/theme.js.map +1 -1
  143. package/dist/modes/interactive/tool-view/chat.d.ts +20 -0
  144. package/dist/modes/interactive/tool-view/chat.d.ts.map +1 -0
  145. package/dist/modes/interactive/tool-view/chat.js +46 -0
  146. package/dist/modes/interactive/tool-view/chat.js.map +1 -0
  147. package/dist/modes/interactive/tool-view/fallback.d.ts +14 -0
  148. package/dist/modes/interactive/tool-view/fallback.d.ts.map +1 -0
  149. package/dist/modes/interactive/tool-view/fallback.js +52 -0
  150. package/dist/modes/interactive/tool-view/fallback.js.map +1 -0
  151. package/dist/modes/interactive/tool-view/style.d.ts +63 -0
  152. package/dist/modes/interactive/tool-view/style.d.ts.map +1 -0
  153. package/dist/modes/interactive/tool-view/style.js +83 -0
  154. package/dist/modes/interactive/tool-view/style.js.map +1 -0
  155. package/dist/modes/interactive/{components → tool-view}/tool-execution.d.ts +17 -24
  156. package/dist/modes/interactive/tool-view/tool-execution.d.ts.map +1 -0
  157. package/dist/modes/interactive/{components → tool-view}/tool-execution.js +42 -116
  158. package/dist/modes/interactive/tool-view/tool-execution.js.map +1 -0
  159. package/docs/bundled/README.md +1 -1
  160. package/docs/bundled/extensions/background.md +1 -1
  161. package/docs/bundled/extensions/question.md +6 -5
  162. package/docs/bundled/extensions/todo.md +5 -5
  163. package/docs/bundled/tool-presentation.md +41 -31
  164. package/docs/extensions.md +1 -4
  165. package/docs/keybindings.md +11 -0
  166. package/npm-shrinkwrap.json +2 -2
  167. package/package.json +1 -1
  168. package/dist/core/model-stream-options.d.ts +0 -6
  169. package/dist/core/model-stream-options.d.ts.map +0 -1
  170. package/dist/core/model-stream-options.js +0 -20
  171. package/dist/core/model-stream-options.js.map +0 -1
  172. package/dist/modes/interactive/components/tool-chrome.d.ts +0 -28
  173. package/dist/modes/interactive/components/tool-chrome.d.ts.map +0 -1
  174. package/dist/modes/interactive/components/tool-chrome.js +0 -84
  175. package/dist/modes/interactive/components/tool-chrome.js.map +0 -1
  176. package/dist/modes/interactive/components/tool-execution.d.ts.map +0 -1
  177. package/dist/modes/interactive/components/tool-execution.js.map +0 -1
  178. package/dist/modes/interactive/components/tool-group.d.ts +0 -17
  179. package/dist/modes/interactive/components/tool-group.d.ts.map +0 -1
  180. package/dist/modes/interactive/components/tool-group.js +0 -78
  181. package/dist/modes/interactive/components/tool-group.js.map +0 -1
  182. package/dist/modes/interactive/tool-chat.d.ts +0 -21
  183. package/dist/modes/interactive/tool-chat.d.ts.map +0 -1
  184. package/dist/modes/interactive/tool-chat.js +0 -58
  185. package/dist/modes/interactive/tool-chat.js.map +0 -1
@@ -1,22 +1,27 @@
1
1
  import "./keybindings.js";
2
- import { Editor, Markdown, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { CURSOR_MARKER, Editor, Markdown, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
3
3
  import { keyLabel as configuredKeyLabel } from "../../modes/interactive/components/keybinding-hints.js";
4
4
  import { getMarkdownTheme } from "../../modes/interactive/theme/theme.js";
5
+ import { DetailPane } from "./detail-pane.js";
5
6
  import { ruleBorder, wrapWithPrefix } from "./dialog-primitives.js";
6
7
  import { QUESTION_LIMITS } from "./limits.js";
8
+ import { moreOptionsHint, windowItems } from "./option-window.js";
7
9
  import { PreviewLinesCache, WidthCachedRender } from "./render-cache.js";
8
10
  import { displayOptions, firstUnanswered, hasAnswer, hasMultiAnswer, newQuestionState, orderedAnswers, } from "./state.js";
9
- const PREVIEW_MAX_LINES = 16;
10
11
  /**
11
- * Estimated non-preview rows in the stacked (narrow) layout: top/bottom rules,
12
- * tab strip + blank, question line + blank, up to 5 option rows with
13
- * descriptions, the "Chat about this" row, warning slot, and the hint line.
14
- * Reserved so the preview clamps short of the terminal height.
12
+ * The dialog replaces the editor in the bottom dock, and the dock grows with its
13
+ * content. Capping the dialog at half the terminal keeps the transcript (often the
14
+ * assistant reply the question refers to) readable while the dialog is open.
15
15
  */
16
- const PREVIEW_CHROME_ROWS = 18;
16
+ const DIALOG_MAX_FRACTION = 0.5;
17
+ const DIALOG_MIN_ROWS = 16;
17
18
  const VIEWPORT_SCROLL_STEP = 4;
18
- function applyVerticalViewport(lines, width, terminalRows, theme, options = {}) {
19
- const maxRows = Math.max(1, Math.floor(terminalRows));
19
+ function dialogRowBudget(terminalRows) {
20
+ const rows = Math.max(1, Math.floor(terminalRows));
21
+ return Math.min(rows, Math.max(DIALOG_MIN_ROWS, Math.floor(rows * DIALOG_MAX_FRACTION)));
22
+ }
23
+ /** Clamp `lines` to `maxRows` between the first and last line, scrolling around the anchor. */
24
+ function applyVerticalViewport(lines, width, maxRows, theme, options = {}) {
20
25
  if (lines.length <= maxRows)
21
26
  return { lines, start: 0, maxStart: 0 };
22
27
  if (maxRows < 5) {
@@ -60,6 +65,7 @@ export function createQuestionDialog(questions, signal) {
60
65
  let finished = false;
61
66
  const cache = new WidthCachedRender();
62
67
  const previewCache = new PreviewLinesCache();
68
+ const details = new DetailPane();
63
69
  const states = questions.map(() => newQuestionState());
64
70
  const optionsByQuestion = questions.map((question) => displayOptions(question));
65
71
  const editorTheme = {
@@ -111,6 +117,7 @@ export function createQuestionDialog(questions, signal) {
111
117
  editor.focused = dialogFocused && inputMode !== undefined;
112
118
  }
113
119
  function setCurrentIdx(index) {
120
+ details.reset();
114
121
  currentIdx = Math.max(0, Math.min(questions.length - 1, index));
115
122
  view = "question";
116
123
  inputMode = undefined;
@@ -160,6 +167,7 @@ export function createQuestionDialog(questions, signal) {
160
167
  setCurrentIdx(missing);
161
168
  }
162
169
  function beginCustomInput() {
170
+ details.reset();
163
171
  const state = currentState();
164
172
  inputMode = "custom";
165
173
  noteTarget = undefined;
@@ -182,6 +190,7 @@ export function createQuestionDialog(questions, signal) {
182
190
  state.warning = undefined;
183
191
  }
184
192
  function beginNotesInput() {
193
+ details.reset();
185
194
  const question = currentQuestion();
186
195
  const state = currentState();
187
196
  const option = focusedOption();
@@ -357,6 +366,13 @@ export function createQuestionDialog(questions, signal) {
357
366
  setCurrentIdx(currentIdx - 1);
358
367
  return;
359
368
  }
369
+ if (keyMatches(data, "app.question.pageUp") || keyMatches(data, "app.question.pageDown")) {
370
+ // Resolve the current pane's height even if another key arrived before a redraw.
371
+ render(tui.terminal.columns);
372
+ details.page(keyMatches(data, "app.question.pageUp") ? -1 : 1);
373
+ refresh();
374
+ return;
375
+ }
360
376
  if (keyMatches(data, "tui.editor.cursorRight")) {
361
377
  if (currentIdx < questions.length - 1)
362
378
  setCurrentIdx(currentIdx + 1);
@@ -473,20 +489,9 @@ export function createQuestionDialog(questions, signal) {
473
489
  : theme.fg(firstUnanswered(states) === undefined ? "success" : "dim", " ✓ Submit "),
474
490
  ].join(" ");
475
491
  wrapWithPrefix(" ", tabs, renderWidth, lines);
476
- lines.push("");
477
492
  }
478
- function previewLines(previewText, width, terminalRows) {
479
- const maxLines = Math.max(4, Math.min(PREVIEW_MAX_LINES, terminalRows - PREVIEW_CHROME_ROWS));
480
- return previewCache.get(previewText, width, maxLines, () => {
481
- const markdown = new Markdown(previewText, 1, 0, getMarkdownTheme());
482
- const rendered = markdown.render(Math.max(1, width));
483
- if (rendered.length <= maxLines)
484
- return rendered;
485
- return [
486
- ...rendered.slice(0, maxLines),
487
- theme.fg("dim", truncateToWidth(`… ${rendered.length - maxLines} preview lines hidden`, Math.max(1, width))),
488
- ];
489
- });
493
+ function previewLines(previewText, width) {
494
+ return previewCache.get(previewText, width, () => new Markdown(previewText, 1, 0, getMarkdownTheme()).render(Math.max(1, width)));
490
495
  }
491
496
  /** Multi-select checkbox marker; the other row shows no box until a custom answer exists. */
492
497
  function multiMarker(option, checked, hasCustomAnswer) {
@@ -510,15 +515,90 @@ export function createQuestionDialog(questions, signal) {
510
515
  }
511
516
  wrapWithPrefix(" ", theme.fg("dim", joinHints(keyAction("tui.select.confirm", "submit"), keyAction("tui.select.cancel", "edit last question"), keyGroupAction(["tui.select.up", "tui.select.down"], "scroll"))), renderWidth, lines);
512
517
  }
518
+ /** Chat row, warning, and key hints: always visible below the scrolling option area. */
519
+ function renderQuestionFooter(renderWidth, digitRange) {
520
+ const state = currentState();
521
+ const isMulti = currentQuestion().multiSelect === true;
522
+ const lines = [];
523
+ const chatPrefix = footerFocused ? theme.fg("accent", "→") : " ";
524
+ wrapWithPrefix(`${chatPrefix} `, theme.fg(footerFocused ? "accent" : "text", "Chat about this"), renderWidth, lines);
525
+ if (state.warning) {
526
+ lines.push("");
527
+ wrapWithPrefix(" ", theme.fg("warning", state.warning), renderWidth, lines);
528
+ }
529
+ let hints;
530
+ if (inputMode === "notes") {
531
+ hints = joinHints(editorSubmitAction("save notes"), keyAction("tui.select.cancel", "back"));
532
+ }
533
+ else if (inputMode === "custom") {
534
+ hints = joinHints(editorSubmitAction(isMulti ? "save custom answer" : "continue"), keyAction("tui.select.cancel", "back"));
535
+ }
536
+ else if (footerFocused) {
537
+ hints = joinHints(keyAction("tui.select.confirm", "discuss"), keyAction("tui.select.up", "return to options"), keyAction("tui.select.cancel", "cancel"));
538
+ }
539
+ else if (isMulti) {
540
+ hints = joinHints(keyGroupAction(["tui.select.up", "tui.select.down"], "navigate"), `${digitRange} toggle`, keyAction("app.list.toggle", "toggle focused"), keyAction("tui.input.tab", "notes/custom"), keyAction("tui.select.confirm", "continue"), questions.length > 1
541
+ ? keyGroupAction(["tui.editor.cursorLeft", "tui.editor.cursorRight"], "questions")
542
+ : "", keyAction("tui.select.cancel", "cancel"));
543
+ }
544
+ else {
545
+ hints = joinHints(keyGroupAction(["tui.select.up", "tui.select.down"], "navigate"), `${digitRange} select`, keyAction("tui.input.tab", "notes/custom"), keyAction("tui.select.confirm", "select"), questions.length > 1
546
+ ? keyGroupAction(["tui.editor.cursorLeft", "tui.editor.cursorRight"], "questions")
547
+ : "", keyAction("tui.select.cancel", "cancel"));
548
+ }
549
+ // Wrap between actions, so a key stays beside the action it performs.
550
+ let hintLine = "";
551
+ for (const hint of hints.split(" • ")) {
552
+ const joined = hintLine ? `${hintLine} • ${hint}` : hint;
553
+ if (hintLine && visibleWidth(joined) > renderWidth - 1) {
554
+ wrapWithPrefix(" ", theme.fg("dim", hintLine), renderWidth, lines);
555
+ hintLine = hint;
556
+ }
557
+ else
558
+ hintLine = joined;
559
+ }
560
+ if (hintLine)
561
+ wrapWithPrefix(" ", theme.fg("dim", hintLine), renderWidth, lines);
562
+ lines.push(ruleBorder(theme, renderWidth));
563
+ return lines;
564
+ }
565
+ /**
566
+ * Show whole items from `optionLines` in `rows` rows around `focus`, with
567
+ * "more options" hints on the sides that have hidden items. `focusRow` is the
568
+ * row of the focused item's first line.
569
+ */
570
+ function windowOptionLines(optionLines, items, focus, rows, width) {
571
+ const window = windowItems(items, focus, rows);
572
+ const rowsBefore = (count) => items.slice(0, count).reduce((sum, item) => sum + item.height, 0);
573
+ const from = rowsBefore(window.first);
574
+ const block = optionLines.slice(from, rowsBefore(window.last + 1));
575
+ const visible = applyVerticalViewport(block, width, window.contentRows, theme, {
576
+ anchorLine: Math.max(0, block.findIndex((line) => line.includes(CURSOR_MARKER))),
577
+ }).lines;
578
+ if (!window.showAbove && !window.showBelow)
579
+ return { rows: visible, focusRow: rowsBefore(focus) - from };
580
+ const hint = (direction, count) => theme.fg("dim", ` ${truncateToWidth(moreOptionsHint(direction, count), Math.max(1, width - 1))}`);
581
+ const spare = Array.from({ length: Math.max(0, window.contentRows - visible.length) }, () => "");
582
+ return {
583
+ rows: [
584
+ ...(window.showAbove ? [hint("up", window.hiddenAbove)] : []),
585
+ ...visible,
586
+ ...(window.showBelow ? [hint("down", window.hiddenBelow)] : []),
587
+ ...spare,
588
+ ],
589
+ focusRow: (window.showAbove ? 1 : 0) + rowsBefore(focus) - from,
590
+ };
591
+ }
513
592
  function compute(width, terminalRows) {
514
593
  const lines = [];
515
594
  const renderWidth = Math.max(1, width);
595
+ const maxRows = dialogRowBudget(terminalRows);
516
596
  lines.push(ruleBorder(theme, renderWidth));
517
597
  renderTabs(renderWidth, lines);
518
598
  if (view === "review") {
519
599
  renderReview(renderWidth, lines);
520
600
  lines.push(ruleBorder(theme, renderWidth));
521
- const viewport = applyVerticalViewport(lines, renderWidth, terminalRows, theme, {
601
+ const viewport = applyVerticalViewport(lines, renderWidth, maxRows, theme, {
522
602
  preferredStart: reviewScrollOffset,
523
603
  });
524
604
  reviewMaxScrollOffset = viewport.maxStart;
@@ -529,16 +609,20 @@ export function createQuestionDialog(questions, signal) {
529
609
  const state = currentState();
530
610
  const options = currentOptions();
531
611
  const isMulti = question.multiSelect === true;
532
- const digitRange = `1-${Math.min(9, options.length)}`;
533
- wrapWithPrefix(" ", `${theme.fg("accent", question.header)} ${theme.fg("text", question.question)}`, renderWidth, lines);
534
- lines.push("");
612
+ wrapWithPrefix(" ", theme.fg("text", question.question), renderWidth, lines);
613
+ // The pinned head and footer leave one bounded area for choices and reading.
614
+ const footerLines = renderQuestionFooter(renderWidth, `1-${Math.min(9, options.length)}`);
615
+ const areaRows = Math.max(1, maxRows - lines.length - footerLines.length);
616
+ const sideBySide = !inputMode && renderWidth >= 72;
617
+ // Measure every authored label, reserving the six-column prefix and a checkmark.
618
+ // Saved answers and focus changes must not move the divider or reflow the preview.
619
+ const listWidth = sideBySide
620
+ ? Math.min(Math.floor((renderWidth - 3) / 2), Math.max(24, ...options.map((option, index) => 6 + visibleWidth(`${index + 1}. ${option.label.replace(/\s+/gu, " ")}`) + 2)))
621
+ : renderWidth;
622
+ const detailWidth = sideBySide ? renderWidth - listWidth - 3 : renderWidth;
535
623
  const optionLines = [];
536
- let focusedOptionStart = 0;
537
- let focusedOptionEnd = 0;
538
- let inputAnchorOffset;
539
- const hasPreview = inputMode === undefined && !isMulti && options.some((option) => option.kind === "option" && option.preview);
540
- const showPreviewSideBySide = hasPreview && renderWidth >= 60;
541
- const listWidth = showPreviewSideBySide ? Math.max(20, Math.floor(renderWidth * 0.4)) : renderWidth;
624
+ const items = [];
625
+ let focusItem = footerFocused ? options.length - 1 : state.optionIndex;
542
626
  for (let index = 0; index < options.length; index++) {
543
627
  const option = options[index];
544
628
  const focused = !footerFocused && index === state.optionIndex;
@@ -556,91 +640,77 @@ export function createQuestionDialog(questions, signal) {
556
640
  ? (state.customAnswer?.text ??
557
641
  (state.singleAnswer?.kind === "custom" ? state.singleAnswer.answer : undefined))
558
642
  : undefined;
559
- const labelText = customText ? `${option.label} ✎ ${customText}` : option.label;
643
+ const labelText = (customText ? `${option.label} ✎ ${customText}` : option.label).replace(/\s+/gu, " ");
560
644
  const label = `${index + 1}. ${labelText}${selectedSingle ? " ✓" : ""}${note}`;
561
645
  const color = focused ? "accent" : selectedSingle || checked ? "success" : "text";
562
- if (focused)
563
- focusedOptionStart = optionLines.length;
564
- wrapWithPrefix(`${focusArrow} ${marker} `, theme.fg(color, label), listWidth, optionLines);
565
- if (option.kind === "option" && option.description) {
566
- wrapWithPrefix(" ", theme.fg("muted", option.description), listWidth, optionLines);
567
- }
568
- if (focused)
569
- focusedOptionEnd = optionLines.length;
646
+ const row = truncateToWidth(`${focusArrow} ${marker} ${theme.fg(color, label)}`, listWidth);
647
+ optionLines.push(focused ? theme.bg("selectedBg", row + " ".repeat(Math.max(0, listWidth - visibleWidth(row)))) : row);
648
+ items.push({ height: 1, countsAsOption: true });
570
649
  }
571
650
  if (inputMode) {
572
- optionLines.push("");
651
+ const start = optionLines.length;
573
652
  const label = inputMode === "notes" ? `Notes for ${noteTarget ?? "option"}:` : "Your answer:";
574
- wrapWithPrefix(" ", theme.fg("muted", label), listWidth, optionLines);
653
+ optionLines.push(theme.fg("muted", truncateToWidth(` ${label}`, listWidth)));
575
654
  for (const line of editor.render(Math.max(1, listWidth - 2)))
576
655
  optionLines.push(` ${line}`);
577
- inputAnchorOffset = Math.max(0, optionLines.length - 1);
578
- }
579
- const optionListStartLine = lines.length;
580
- if (showPreviewSideBySide) {
581
- const gap = 2;
582
- const rightWidth = renderWidth - listWidth - gap;
583
- const focused = options[state.optionIndex];
584
- const previewText = focused?.kind === "option" && focused.preview ? focused.preview : "";
585
- const preview = previewText
586
- ? previewLines(previewText, rightWidth, terminalRows)
587
- : [theme.fg("dim", "(no preview)")];
588
- const rightLines = [...Array.from({ length: focusedOptionStart }, () => ""), ...preview];
589
- const maxRows = Math.max(optionLines.length, rightLines.length);
590
- const pad = " ".repeat(gap);
591
- for (let row = 0; row < maxRows; row++) {
592
- const left = row < optionLines.length ? optionLines[row] : "";
593
- const leftPadded = left + " ".repeat(Math.max(0, listWidth - visibleWidth(left)));
594
- const right = row < rightLines.length ? rightLines[row] : "";
595
- lines.push(`${leftPadded}${pad}${right}`);
596
- }
656
+ items.push({ height: optionLines.length - start, countsAsOption: false });
657
+ focusItem = items.length - 1;
658
+ }
659
+ const regionStart = lines.length;
660
+ let focusRow;
661
+ if (inputMode) {
662
+ const windowed = windowOptionLines(optionLines, items, focusItem, areaRows, renderWidth);
663
+ focusRow = windowed.focusRow;
664
+ lines.push(...windowed.rows);
597
665
  }
598
666
  else {
599
- if (hasPreview) {
600
- const focused = options[state.optionIndex];
601
- const previewText = focused?.kind === "option" && focused.preview ? focused.preview : "";
602
- if (previewText) {
603
- optionLines.splice(focusedOptionEnd, 0, "", ruleBorder(theme, renderWidth, "dim"), ...previewLines(previewText, renderWidth, terminalRows));
667
+ const option = options[state.optionIndex];
668
+ const content = [];
669
+ const hasPreview = !footerFocused && !isMulti && option.kind === "option" && Boolean(option.preview);
670
+ const title = footerFocused ? "Chat about this" : `${hasPreview ? "Preview" : "Details"} · ${option.label}`;
671
+ // The compact list may elide a long label; its full text stays readable here.
672
+ if (!footerFocused &&
673
+ visibleWidth(`${state.optionIndex + 1}. ${option.label.replace(/\s+/gu, " ")}`) > listWidth - 6) {
674
+ wrapWithPrefix(" ", theme.fg("text", option.label), detailWidth, content);
675
+ }
676
+ if (footerFocused) {
677
+ wrapWithPrefix(" ", theme.fg("muted", "Discuss this question before choosing an answer."), detailWidth, content);
678
+ }
679
+ else if (option.kind === "option") {
680
+ wrapWithPrefix(" ", theme.fg("muted", option.description), detailWidth, content);
681
+ const note = state.notesByOption.get(option.label);
682
+ if (note)
683
+ wrapWithPrefix(" ", theme.fg("success", `Note: ${note}`), detailWidth, content);
684
+ if (hasPreview && option.preview)
685
+ content.push("", ...previewLines(option.preview, detailWidth));
686
+ }
687
+ else {
688
+ const custom = state.customAnswer?.text ??
689
+ (state.singleAnswer?.kind === "custom" ? state.singleAnswer.answer : undefined);
690
+ wrapWithPrefix(" ", theme.fg("muted", custom || "Write your own answer instead of choosing an option."), detailWidth, content);
691
+ }
692
+ // On narrow screens reserve reading space first, then show as many compact
693
+ // choices as fit. The focused choice always remains in the list window.
694
+ const optionRows = sideBySide ? areaRows : Math.max(1, Math.min(options.length, areaRows - 4));
695
+ const detailRows = sideBySide ? areaRows : Math.max(1, areaRows - optionRows);
696
+ const left = windowOptionLines(optionLines, items, focusItem, optionRows, listWidth);
697
+ const right = details.render(`${currentIdx}:${state.optionIndex}:${footerFocused}`, title, content, detailWidth, detailRows, keyGroupAction(["app.question.pageUp", "app.question.pageDown"], "scroll"), theme);
698
+ focusRow = left.focusRow;
699
+ if (sideBySide) {
700
+ for (let row = 0; row < Math.max(left.rows.length, right.length); row++) {
701
+ const leftText = left.rows[row] ?? "";
702
+ const leftPadded = leftText + " ".repeat(Math.max(0, listWidth - visibleWidth(leftText)));
703
+ lines.push(`${leftPadded}${theme.fg("dim", " │ ")}${right[row] ?? ""}`);
604
704
  }
605
705
  }
606
- lines.push(...optionLines);
607
- }
608
- lines.push("");
609
- const chatLine = lines.length;
610
- const chatPrefix = footerFocused ? theme.fg("accent", "→") : " ";
611
- wrapWithPrefix(`${chatPrefix} `, theme.fg(footerFocused ? "accent" : "text", "Chat about this"), renderWidth, lines);
612
- let warningLine;
613
- if (state.warning) {
614
- lines.push("");
615
- warningLine = lines.length;
616
- wrapWithPrefix(" ", theme.fg("warning", state.warning), renderWidth, lines);
617
- }
618
- lines.push("");
619
- if (inputMode === "notes") {
620
- wrapWithPrefix(" ", theme.fg("dim", joinHints(editorSubmitAction("save notes"), keyAction("tui.select.cancel", "back"))), renderWidth, lines);
621
- }
622
- else if (inputMode === "custom") {
623
- const hint = isMulti ? "save custom answer" : "continue";
624
- wrapWithPrefix(" ", theme.fg("dim", joinHints(editorSubmitAction(hint), keyAction("tui.select.cancel", "back"))), renderWidth, lines);
625
- }
626
- else if (footerFocused) {
627
- wrapWithPrefix(" ", theme.fg("dim", joinHints(keyAction("tui.select.confirm", "discuss"), keyAction("tui.select.up", "return to options"), keyAction("tui.select.cancel", "cancel"))), renderWidth, lines);
628
- }
629
- else if (isMulti) {
630
- wrapWithPrefix(" ", theme.fg("dim", joinHints(`${digitRange} toggle`, keyAction("app.list.toggle", "toggle focused"), keyAction("tui.input.tab", "notes/custom"), keyAction("tui.select.confirm", "continue"), keyGroupAction(["tui.editor.cursorLeft", "tui.editor.cursorRight"], "questions"), keyAction("tui.select.cancel", "cancel"))), renderWidth, lines);
631
- }
632
- else {
633
- wrapWithPrefix(" ", theme.fg("dim", joinHints(`${digitRange} select`, keyAction("tui.input.tab", "notes/custom"), keyAction("tui.select.confirm", "select"), keyGroupAction(["tui.editor.cursorLeft", "tui.editor.cursorRight"], "questions"), keyAction("tui.select.cancel", "cancel"))), renderWidth, lines);
706
+ else {
707
+ lines.push(...left.rows, ...right);
708
+ }
634
709
  }
635
- lines.push(ruleBorder(theme, renderWidth));
636
- let anchorLine = optionListStartLine + focusedOptionStart;
637
- if (inputAnchorOffset !== undefined)
638
- anchorLine = optionListStartLine + inputAnchorOffset;
639
- else if (warningLine !== undefined)
640
- anchorLine = warningLine;
641
- else if (footerFocused)
642
- anchorLine = chatLine;
643
- return applyVerticalViewport(lines, renderWidth, terminalRows, theme, { anchorLine }).lines;
710
+ const anchorLine = regionStart + focusRow;
711
+ lines.push(...footerLines);
712
+ // Only a terminal too short for the pinned rows has lines left to drop here.
713
+ return applyVerticalViewport(lines, renderWidth, maxRows, theme, { anchorLine }).lines;
644
714
  }
645
715
  return {
646
716
  get focused() {