pi-goal-x 0.26.0 → 0.26.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.
package/README.md CHANGED
@@ -129,6 +129,8 @@ Start a guided regular goal:
129
129
 
130
130
  The agent can ask questions and propose a complete objective and task plan. Confirm the proposal to create the goal and begin work.
131
131
 
132
+ The questionnaire dialog never truncates the question; when it is taller than the terminal it stays within the height and scrolls — `PgUp`/`PgDn` page and `Ctrl+↑/↓` line-scroll without moving the selection, `↑/↓` selection auto-follows into view, and a `▲`/`… +N more` edge indicator shows what is clipped.
133
+
132
134
  Start a guided Sisyphus goal:
133
135
 
134
136
  ```text
@@ -211,7 +211,10 @@ export function proposalText(draft: ActiveGoalDraft, objective: string, autoCont
211
211
  } else if (taskList && taskList.tasks.length > 0) {
212
212
  tasksText = "\n\nTasks proposed for confirmation:\n" + renderConfirmationTasks(taskList.tasks, 0).join("\n");
213
213
  } else {
214
- const derived = deriveTasksFromObjective(objective);
214
+ // §single-task-set: the derived preview must derive from the SAME
215
+ // objective text the apply path persists (extracted — the Verification
216
+ // contract line removed), so shown == persisted.
217
+ const derived = deriveTasksFromObjective(extractVerificationContract(objective).objective);
215
218
  if (derived && derived.length > 0) {
216
219
  tasksText = "\n\nTasks derived from the objective (confirm or ask the agent to adjust):\n" + renderConfirmationTasks(derived, 0).join("\n");
217
220
  }
@@ -465,12 +468,26 @@ export function registerDraftingTools(core: GoalCore): void {
465
468
  const objective = String(args?.objective ?? "");
466
469
  let text = theme.fg("toolTitle", "propose_goal_draft ") + theme.fg("muted", objective);
467
470
  const flatTasks = Array.isArray(args?.tasks) ? (args.tasks as FlatTaskInput[]) : [];
468
- const derived = flatTasks.length === 0 ? (deriveTasksFromObjective(objective) ?? []) : [];
469
- const taskBlock = flatTasks.length > 0
470
- ? { header: "Tasks proposed for confirmation:", lines: flatTasks.map((t) => `[ ] ${t.id}: ${t.title}`) }
471
- : derived.length > 0
472
- ? { header: "Tasks derived from the objective (confirm or ask the agent to adjust):", lines: renderConfirmationTasks(derived, 0) }
473
- : null;
471
+ // §single-task-set: a proposal shows exactly ONE task set — the set
472
+ // that will actually be persisted. Explicit tasks are that set; a
473
+ // tweak without explicit tasks retains the current list (mirror
474
+ // proposalText — never a derived-from-objective phantom); a new
475
+ // draft without explicit tasks shows the F2-derived preview derived
476
+ // from the SAME objective text the apply path persists (extracted).
477
+ let taskBlock: { header: string; lines: string[] } | null = null;
478
+ if (flatTasks.length > 0) {
479
+ taskBlock = { header: "Tasks proposed for confirmation:", lines: flatTasks.map((t) => `[ ] ${t.id}: ${t.title}`) };
480
+ } else if (activeDraft(core)?.mode === "tweak") {
481
+ const retained = core.state.goal?.taskList?.tasks;
482
+ if (retained && retained.length > 0) {
483
+ taskBlock = { header: "Current task list (retained unchanged):", lines: renderConfirmationTasks(retained, 0) };
484
+ }
485
+ } else {
486
+ const derived = deriveTasksFromObjective(extractVerificationContract(objective).objective) ?? [];
487
+ if (derived.length > 0) {
488
+ taskBlock = { header: "Tasks derived from the objective (confirm or ask the agent to adjust):", lines: renderConfirmationTasks(derived, 0) };
489
+ }
490
+ }
474
491
  if (taskBlock) {
475
492
  text += "\n\n" + theme.fg("toolTitle", theme.bold(taskBlock.header)) + "\n" + theme.fg("toolOutput", taskBlock.lines.join("\n"));
476
493
  }
@@ -54,10 +54,27 @@ export interface ProposalPresentationSegments {
54
54
  tailStart: number;
55
55
  }
56
56
 
57
+ /** Strip ANSI SGR sequences so structural scans see the plain text. */
58
+ function stripAnsiCodes(value: string): string {
59
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
60
+ }
61
+
57
62
  /**
58
63
  * Locate the proposal tasks segment and the options tail in a rendered dialog.
59
64
  * Returns null when the dialog is not a proposal-style confirmation (no
60
65
  * "Tasks proposed for confirmation:" header / "┌─ TASKS ─" box).
66
+ *
67
+ * The scan is ANSI-aware: every rendered line carries `theme.fg(...)` escape
68
+ * sequences in the real TUI (the mock theme does not), so plain-text regexes
69
+ * must be run against the stripped line or the tasks section collapses to its
70
+ * header and every styled task line is sliced out of the bounded frame. The
71
+ * box-drawn bottom border (`└───┘`) after the last task line is kept so the
72
+ * `┌─ TASKS ─┐` box renders complete.
73
+ *
74
+ * The interactive auditor toggle line ("press 'a' to toggle") renders between
75
+ * the context and the options; `tailStart` is pulled back to it so the bounded
76
+ * fit always keeps the auditor status visible (its status text is the only
77
+ * visible toggle feedback in the dialog).
61
78
  */
62
79
  export function findProposalPresentationSegments(lines: string[], tailStart: number): ProposalPresentationSegments | null {
63
80
  if (tailStart < 0) return null;
@@ -65,10 +82,25 @@ export function findProposalPresentationSegments(lines: string[], tailStart: num
65
82
  if (tasksStart < 0 || tasksStart >= tailStart) return null;
66
83
  let tasksEnd = tasksStart;
67
84
  for (let i = tasksStart + 1; i < tailStart; i++) {
68
- if (/^\s*\[[ x~]\]/.test(lines[i])) tasksEnd = i;
69
- else break;
85
+ const plain = stripAnsiCodes(lines[i]);
86
+ if (/^\s*\[[ x~]\]/.test(plain)) {
87
+ tasksEnd = i;
88
+ continue;
89
+ }
90
+ // Box-drawn TASKS section bottom border after the last task line.
91
+ if (tasksEnd > tasksStart && /^[└┐┤┘─]/.test(plain.trim())) tasksEnd = i;
92
+ break;
70
93
  }
71
- return { tasksStart, tasksEnd, tailStart };
94
+ // Protect the auditor toggle line: it renders between context and options,
95
+ // so pull the tail start back to it when present.
96
+ let effectiveTail = tailStart;
97
+ for (let i = tailStart - 1; i > tasksEnd; i--) {
98
+ if (lines[i].includes("press 'a' to toggle")) {
99
+ effectiveTail = i;
100
+ break;
101
+ }
102
+ }
103
+ return { tasksStart, tasksEnd, tailStart: effectiveTail };
72
104
  }
73
105
 
74
106
  /**
@@ -76,63 +108,118 @@ export function findProposalPresentationSegments(lines: string[], tailStart: num
76
108
  * question. The protected head (top border + tabs + question line) is always
77
109
  * kept; the remaining budget is spent on the tail — options/footer/bottom
78
110
  * border — so long context blocks (proposal confirmations) are sliced from
79
- * their head exactly as the pre-fix tail-slice did (383ae52 surface). When the
80
- * options block starts immediately after the head (plain agent questions with
81
- * no context block), the TOP options are kept instead of the tail so the
82
- * recommended/first option stays visible; the footer hint and bottom border
83
- * are always the last rendered lines. Never returns more than maxDialogLines.
111
+ * their head exactly as the pre-fix tail-slice did (383ae52 surface). The
112
+ * footer hint and bottom border are always the last rendered lines. Never
113
+ * returns more than maxDialogLines.
84
114
  *
85
115
  * Proposal confirmations (proposal segments given) keep the head, the tasks
86
116
  * section (header + every task line), and the options/footer/bottom border in
87
117
  * frame; only the objective-box middle is sacrificed in-frame — the full
88
118
  * objective is always present in the scrollable transcript presentation
89
119
  * (propose_goal_draft renderCall), so nothing of the goal is ever omitted.
120
+ *
121
+ * §options-scroll: when a `scroll` state is given (select-mode question tabs
122
+ * and the submit summary), the dialog becomes a `less`-style viewport over the
123
+ * FULL content — nothing is truncated, nothing is sliced away — and the
124
+ * options are always reachable by scrolling (PageUp/PageDown, Ctrl+↑/↓, and
125
+ * ↑/↓ selection auto-follow). See fitDialogViewport.
90
126
  */
127
+ export interface DialogScrollState {
128
+ /** Current viewport offset over the full content lines. */
129
+ scrollTop: number;
130
+ /** When true, the fitter nudges scrollTop so the selection range is visible. */
131
+ needsFollow: boolean;
132
+ /** Line ranges [start, end] of each selectable option in the full content. */
133
+ optionRanges: Array<[number, number]>;
134
+ /** Index of the current selection in optionRanges. */
135
+ followIndex: number;
136
+ }
137
+
91
138
  export function fitDialogLines(
92
139
  lines: string[],
93
140
  maxDialogLines: number,
94
141
  protectedHead: number,
95
- optionsImmediatelyAfterHead = false,
96
142
  proposal: ProposalPresentationSegments | null = null,
143
+ scroll: DialogScrollState | null = null,
144
+ dimStyle: (s: string) => string = (s) => s,
97
145
  ): string[] {
98
146
  if (maxDialogLines <= 0 || lines.length <= maxDialogLines) return lines;
99
147
  const keepHead = Math.min(protectedHead, maxDialogLines);
148
+ if (proposal) {
149
+ return fitProposalPresentation(lines, maxDialogLines, keepHead, proposal);
150
+ }
151
+ if (scroll) {
152
+ return fitDialogViewport(lines, maxDialogLines, scroll, dimStyle);
153
+ }
154
+ // Context-heavy / input / submit dialogs without a scroll state: keep the
155
+ // head and the tail (options/footer/bottom border) exactly as the pre-fix
156
+ // tail-slice did.
100
157
  const budget = maxDialogLines - keepHead;
101
158
  if (budget <= 0) return lines.slice(0, keepHead);
102
159
  const rest = lines.slice(protectedHead);
103
160
  if (rest.length <= budget) return [...lines.slice(0, keepHead), ...rest];
104
- if (proposal) {
105
- return fitProposalPresentation(lines, maxDialogLines, keepHead, proposal);
161
+ return [...lines.slice(0, keepHead), ...rest.slice(rest.length - budget)];
162
+ }
163
+
164
+ /**
165
+ * §options-scroll viewport fit: never truncate or drop content — window the
166
+ * full dialog lines with a scrollTop offset so every line (question, context,
167
+ * options, footer, border) stays reachable. The last viewport row is reserved
168
+ * as the bottom edge: the bottom border when the end is reached, otherwise a
169
+ * themed `… +N more · PgUp/PgDn scroll` indicator (mirrors the dashboard's
170
+ * `… +N more task` rows). A themed `▲ N more` indicator replaces the first
171
+ * content row when scrolled down. Selection auto-follow (needsFollow) nudges
172
+ * the window so the selected option's line range is inside it. Output length
173
+ * is exactly maxDialogLines when clipped, never more (churn-guard invariant).
174
+ */
175
+ function fitDialogViewport(
176
+ lines: string[],
177
+ maxDialogLines: number,
178
+ scroll: DialogScrollState,
179
+ dimStyle: (s: string) => string,
180
+ ): string[] {
181
+ const contentWindow = Math.max(0, maxDialogLines - 1);
182
+ const contentEnd = Math.max(0, lines.length - 1);
183
+ // Selection auto-follow: nudge scrollTop so the selected option's range is
184
+ // inside the content window (minimal scroll; clamp below).
185
+ if (scroll.needsFollow && scroll.optionRanges[scroll.followIndex]) {
186
+ const [os, oe] = scroll.optionRanges[scroll.followIndex];
187
+ if (os < scroll.scrollTop || oe >= scroll.scrollTop + contentWindow) {
188
+ scroll.scrollTop = oe >= scroll.scrollTop + contentWindow
189
+ ? Math.min(os, Math.max(0, oe - contentWindow + 1))
190
+ : os;
191
+ }
192
+ scroll.needsFollow = false;
106
193
  }
107
- if (optionsImmediatelyAfterHead) {
108
- // Plain select-mode question: keep the footer hint + bottom border (as
109
- // many as fit), then spend the remaining room on the TOP options so the
110
- // recommended/first option stays visible; leading blank separators are
111
- // dropped first when room is short. Never exceeds maxDialogLines.
112
- const headLines = lines.slice(0, keepHead);
113
- const firstContent = rest.findIndex((l) => l.trim().length > 0);
114
- const lead = firstContent > 0 ? rest.slice(0, firstContent) : [];
115
- const contentStart = firstContent > 0 ? firstContent : 0;
116
- const tailBudget = Math.min(2, budget); // footer hint + bottom border
117
- const tail = rest.slice(-tailBudget);
118
- const room = budget - tailBudget;
119
- const keepContent = Math.max(0, Math.min(rest.length - contentStart, room));
120
- const keepLead = Math.max(0, Math.min(lead.length, room - keepContent));
121
- const content = rest.slice(contentStart, contentStart + keepContent);
122
- return [...headLines, ...lead.slice(0, keepLead), ...content, ...tail];
194
+ scroll.scrollTop = Math.max(0, Math.min(scroll.scrollTop, Math.max(0, contentEnd - contentWindow)));
195
+ const s = scroll.scrollTop;
196
+ const hiddenAbove = s;
197
+ const hiddenBelow = contentEnd - (s + contentWindow);
198
+ const viewport = lines.slice(s, s + contentWindow);
199
+ if (hiddenAbove > 0 && viewport.length > 0) {
200
+ viewport[0] = dimStyle(`▲ ${hiddenAbove} more`);
123
201
  }
124
- // Context-heavy / input / submit dialogs: keep the head and the tail
125
- // (options, footer, bottom border) exactly as the pre-fix tail-slice did.
126
- return [...lines.slice(0, keepHead), ...rest.slice(rest.length - budget)];
202
+ if (hiddenBelow > 0) {
203
+ // The reserved bottom edge row advertises the scroll affordance — it
204
+ // replaces the border while content is clipped below (mirrors the
205
+ // dashboard's `… +N more task` rows) and gives way to the border at the
206
+ // end. The footer hint sits directly above the border, so it is only
207
+ // visible when the end is reached (never while clipped — geometry).
208
+ viewport.push(dimStyle(`… +${hiddenBelow} more · PgUp/PgDn scroll`));
209
+ } else {
210
+ viewport.push(lines[lines.length - 1]);
211
+ }
212
+ return viewport;
127
213
  }
128
214
 
129
215
  /**
130
- * Proposal confirmation fit: keep the protected head, the tasks section, and
131
- * the options/footer/bottom border; the objective-box middle is sacrificed
132
- * in-frame (it stays fully readable in the scrollback presentation). Interior
133
- * blank spacing lines are dropped first when room is short; task lines are
134
- * only dropped after that, from the end, when the bound is exhausted (those
135
- * lines remain in the scrollback presentation). Never exceeds maxDialogLines.
216
+ * Proposal confirmation fit: keep the protected head, the tasks section, the
217
+ * interactive auditor toggle line (when present), and the options/footer/
218
+ * bottom border; the objective-box middle is sacrificed in-frame (it stays
219
+ * fully readable in the scrollback presentation). Interior blank spacing lines
220
+ * are dropped first when room is short; task lines are only dropped after
221
+ * that, from the end, when the bound is exhausted (those lines remain in the
222
+ * scrollback presentation). Never exceeds maxDialogLines.
136
223
  */
137
224
  function fitProposalPresentation(
138
225
  lines: string[],
@@ -150,15 +237,18 @@ function fitProposalPresentation(
150
237
  // the options and the footer hint) before touching any content line.
151
238
  const stripped = candidate.filter((l, i) => i < keepHead || l.trim() !== "");
152
239
  if (stripped.length <= maxDialogLines) return stripped;
153
- // Bound still exhausted: keep the head, then spend the room on the tail
154
- // first (options/footer/bottom border — the actionable decision surface —
155
- // kept from its end so the border and footer never drop), then on the
156
- // tasks from the start. The dropped task lines remain fully readable in
240
+ // Bound still exhausted: keep the head, then the auditor toggle line (the
241
+ // actionable status control — its feedback must stay visible), then spend
242
+ // the room on the tail (options/footer/bottom border — the decision
243
+ // surface — kept from its end so the border and footer never drop), then on
244
+ // the tasks from the start. The dropped task lines remain fully readable in
157
245
  // the scrollback presentation. Never exceeds maxDialogLines.
158
246
  const tailNoBlanks = tail.filter((l) => l.trim() !== "");
159
- const keepTail = Math.min(tailNoBlanks.length, Math.max(0, maxDialogLines - keepHead));
160
- const tailKept = tailNoBlanks.slice(tailNoBlanks.length - keepTail);
161
- const keepTasks = Math.min(tasks.length, Math.max(0, maxDialogLines - keepHead - keepTail));
247
+ const hasAuditor = tail.length > 0 && tail[0].includes("press 'a' to toggle");
248
+ const restTail = hasAuditor ? tailNoBlanks.slice(1) : tailNoBlanks;
249
+ const keepRest = Math.min(restTail.length, Math.max(0, maxDialogLines - keepHead - (hasAuditor ? 1 : 0)));
250
+ const tailKept = [...(hasAuditor ? [tail[0]] : []), ...restTail.slice(restTail.length - keepRest)];
251
+ const keepTasks = Math.min(tasks.length, Math.max(0, maxDialogLines - keepHead - tailKept.length));
162
252
  return [...head, ...tasks.slice(0, keepTasks), ...tailKept];
163
253
  }
164
254
 
@@ -255,6 +345,10 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
255
345
  let inputQuestionId: string | null = null;
256
346
  let cachedLines: string[] | undefined;
257
347
  let optionsStartIndex = -1;
348
+ // §options-scroll viewport state (select-mode + submit tabs only).
349
+ let scrollTop = 0;
350
+ let needsFollow = false;
351
+ let optionRanges: Array<[number, number]> = [];
258
352
  let auditorEnabled = auditorToggleInit?.defaultEnabled ?? true;
259
353
  const answers = new Map<string, GoalQuestionnaireAnswer>();
260
354
  const drafts = new Map<string, string>();
@@ -304,6 +398,9 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
304
398
  function enterQuestion(q: GoalQuestionnaireQuestion) {
305
399
  const existing = answers.get(q.id);
306
400
  const draft = drafts.get(q.id);
401
+ // §options-scroll: start each question tab from the top.
402
+ scrollTop = 0;
403
+ needsFollow = false;
307
404
  if (q.options.length === 0) {
308
405
  inputMode = true;
309
406
  inputQuestionId = q.id;
@@ -318,7 +415,14 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
318
415
  }
319
416
  }
320
417
 
321
- function advanceAfterAnswer() {
418
+ function enterSubmitTab() {
419
+ // §options-scroll: the submit summary also opens from the top.
420
+ optionIndex = 0;
421
+ scrollTop = 0;
422
+ needsFollow = false;
423
+ }
424
+
425
+ function advanceAfterAnswer() {
322
426
  if (!isMulti) {
323
427
  submit(false);
324
428
  return;
@@ -327,7 +431,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
327
431
  else currentTab = questions.length;
328
432
  const nextQ = currentQuestion();
329
433
  if (nextQ) enterQuestion(nextQ);
330
- else optionIndex = 0;
434
+ else enterSubmitTab();
331
435
  refresh();
332
436
  }
333
437
 
@@ -380,7 +484,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
380
484
  currentTab = matchesKey(data, Key.tab) ? (currentTab + 1) % totalTabs : (currentTab - 1 + totalTabs) % totalTabs;
381
485
  const nextQ = currentQuestion();
382
486
  if (nextQ) enterQuestion(nextQ);
383
- else optionIndex = 0;
487
+ else enterSubmitTab();
384
488
  refresh();
385
489
  return;
386
490
  }
@@ -397,7 +501,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
397
501
  currentTab = (currentTab + 1) % totalTabs;
398
502
  const nextQ = currentQuestion();
399
503
  if (nextQ) enterQuestion(nextQ);
400
- else optionIndex = 0;
504
+ else enterSubmitTab();
401
505
  refresh();
402
506
  return;
403
507
  }
@@ -405,12 +509,41 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
405
509
  currentTab = (currentTab - 1 + totalTabs) % totalTabs;
406
510
  const nextQ = currentQuestion();
407
511
  if (nextQ) enterQuestion(nextQ);
408
- else optionIndex = 0;
512
+ else enterSubmitTab();
409
513
  refresh();
410
514
  return;
411
515
  }
412
516
  }
413
517
 
518
+ // §options-scroll viewport keys (select-mode question tabs AND the
519
+ // submit summary): page and line scroll without moving the selection.
520
+ // Handled before the submit-tab early return so the summary scrolls
521
+ // too. ↑/↓ still select and auto-follow into view on the next render.
522
+ if (matchesKey(data, Key.pageUp)) {
523
+ scrollTop -= Math.max(1, (maxDialogLines ?? 10) - 1);
524
+ needsFollow = false;
525
+ refresh();
526
+ return;
527
+ }
528
+ if (matchesKey(data, Key.pageDown)) {
529
+ scrollTop += Math.max(1, (maxDialogLines ?? 10) - 1);
530
+ needsFollow = false;
531
+ refresh();
532
+ return;
533
+ }
534
+ if (matchesKey(data, Key.ctrl("up"))) {
535
+ scrollTop -= 1;
536
+ needsFollow = false;
537
+ refresh();
538
+ return;
539
+ }
540
+ if (matchesKey(data, Key.ctrl("down"))) {
541
+ scrollTop += 1;
542
+ needsFollow = false;
543
+ refresh();
544
+ return;
545
+ }
546
+
414
547
  if (currentTab === questions.length) {
415
548
  if (matchesKey(data, Key.enter) && allAnswered()) submit(false);
416
549
  else if (matchesKey(data, Key.escape)) submit(true);
@@ -419,11 +552,13 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
419
552
 
420
553
  if (matchesKey(data, Key.up)) {
421
554
  optionIndex = Math.max(0, optionIndex - 1);
555
+ needsFollow = true; // §options-scroll: keep the selection visible
422
556
  refresh();
423
557
  return;
424
558
  }
425
559
  if (matchesKey(data, Key.down)) {
426
560
  optionIndex = Math.min(opts.length - 1, optionIndex + 1);
561
+ needsFollow = true;
427
562
  refresh();
428
563
  return;
429
564
  }
@@ -597,11 +732,13 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
597
732
  function renderOptions() {
598
733
  optionsStartIndex = lines.length;
599
734
  for (let i = 0; i < opts.length; i++) {
735
+ const start = lines.length;
600
736
  const opt = opts[i];
601
737
  const selected = i === optionIndex;
602
738
  const prefix = selected ? theme.fg("accent", "> ") : " ";
603
739
  const recTag = !opt.isCustom && q?.recommended === i ? theme.fg("success", " ★") : "";
604
740
  addWrapped(prefix + theme.fg(selected ? "accent" : "text", `${i + 1}. ${opt.label}`) + recTag);
741
+ optionRanges[i] = [start, lines.length - 1];
605
742
  }
606
743
  }
607
744
 
@@ -669,11 +806,21 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
669
806
  // objective-box middle is sacrificed there because the full objective
670
807
  // is always in the scrollable transcript presentation (renderCall).
671
808
  if (maxDialogLines !== undefined && lines.length > maxDialogLines) {
672
- const optionsImmediatelyAfterHead = !inputMode && currentTab !== questions.length && !!q && !q.context && opts.length > 0;
673
809
  const proposalSegments = !inputMode && currentTab !== questions.length && !!q && q.context
674
810
  ? findProposalPresentationSegments(lines, optionsStartIndex)
675
811
  : null;
676
- lines = fitDialogLines(lines, maxDialogLines, protectedCount, optionsImmediatelyAfterHead, proposalSegments);
812
+ // §options-scroll: select-mode question tabs and the submit summary
813
+ // are a viewport over the full content (never truncated); input
814
+ // mode keeps the legacy tail-keep so the editor stays the priority.
815
+ let scrollState: DialogScrollState | null = null;
816
+ if (!inputMode) {
817
+ scrollState = { scrollTop, needsFollow, optionRanges, followIndex: optionIndex };
818
+ }
819
+ lines = fitDialogLines(lines, maxDialogLines, protectedCount, proposalSegments, scrollState, (s) => theme.fg("dim", s));
820
+ if (scrollState) {
821
+ scrollTop = scrollState.scrollTop;
822
+ needsFollow = scrollState.needsFollow;
823
+ }
677
824
  }
678
825
  cachedLines = lines;
679
826
  return lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "description": "Goal mode extension for pi: durable long-running objectives, five model tools, structured tasks, independent completion audit, Sisyphus mode, auto-continue, and a status overlay.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",