pi-goal-x 0.26.1 → 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
@@ -108,54 +108,108 @@ export function findProposalPresentationSegments(lines: string[], tailStart: num
108
108
  * question. The protected head (top border + tabs + question line) is always
109
109
  * kept; the remaining budget is spent on the tail — options/footer/bottom
110
110
  * border — so long context blocks (proposal confirmations) are sliced from
111
- * their head exactly as the pre-fix tail-slice did (383ae52 surface). When the
112
- * options block starts immediately after the head (plain agent questions with
113
- * no context block), the TOP options are kept instead of the tail so the
114
- * recommended/first option stays visible; the footer hint and bottom border
115
- * 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.
116
114
  *
117
115
  * Proposal confirmations (proposal segments given) keep the head, the tasks
118
116
  * section (header + every task line), and the options/footer/bottom border in
119
117
  * frame; only the objective-box middle is sacrificed in-frame — the full
120
118
  * objective is always present in the scrollable transcript presentation
121
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.
122
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
+
123
138
  export function fitDialogLines(
124
139
  lines: string[],
125
140
  maxDialogLines: number,
126
141
  protectedHead: number,
127
- optionsImmediatelyAfterHead = false,
128
142
  proposal: ProposalPresentationSegments | null = null,
143
+ scroll: DialogScrollState | null = null,
144
+ dimStyle: (s: string) => string = (s) => s,
129
145
  ): string[] {
130
146
  if (maxDialogLines <= 0 || lines.length <= maxDialogLines) return lines;
131
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.
132
157
  const budget = maxDialogLines - keepHead;
133
158
  if (budget <= 0) return lines.slice(0, keepHead);
134
159
  const rest = lines.slice(protectedHead);
135
160
  if (rest.length <= budget) return [...lines.slice(0, keepHead), ...rest];
136
- if (proposal) {
137
- 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;
138
193
  }
139
- if (optionsImmediatelyAfterHead) {
140
- // Plain select-mode question: keep the footer hint + bottom border (as
141
- // many as fit), then spend the remaining room on the TOP options so the
142
- // recommended/first option stays visible; leading blank separators are
143
- // dropped first when room is short. Never exceeds maxDialogLines.
144
- const headLines = lines.slice(0, keepHead);
145
- const firstContent = rest.findIndex((l) => l.trim().length > 0);
146
- const lead = firstContent > 0 ? rest.slice(0, firstContent) : [];
147
- const contentStart = firstContent > 0 ? firstContent : 0;
148
- const tailBudget = Math.min(2, budget); // footer hint + bottom border
149
- const tail = rest.slice(-tailBudget);
150
- const room = budget - tailBudget;
151
- const keepContent = Math.max(0, Math.min(rest.length - contentStart, room));
152
- const keepLead = Math.max(0, Math.min(lead.length, room - keepContent));
153
- const content = rest.slice(contentStart, contentStart + keepContent);
154
- 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`);
155
201
  }
156
- // Context-heavy / input / submit dialogs: keep the head and the tail
157
- // (options, footer, bottom border) exactly as the pre-fix tail-slice did.
158
- 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;
159
213
  }
160
214
 
161
215
  /**
@@ -291,6 +345,10 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
291
345
  let inputQuestionId: string | null = null;
292
346
  let cachedLines: string[] | undefined;
293
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]> = [];
294
352
  let auditorEnabled = auditorToggleInit?.defaultEnabled ?? true;
295
353
  const answers = new Map<string, GoalQuestionnaireAnswer>();
296
354
  const drafts = new Map<string, string>();
@@ -340,6 +398,9 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
340
398
  function enterQuestion(q: GoalQuestionnaireQuestion) {
341
399
  const existing = answers.get(q.id);
342
400
  const draft = drafts.get(q.id);
401
+ // §options-scroll: start each question tab from the top.
402
+ scrollTop = 0;
403
+ needsFollow = false;
343
404
  if (q.options.length === 0) {
344
405
  inputMode = true;
345
406
  inputQuestionId = q.id;
@@ -354,7 +415,14 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
354
415
  }
355
416
  }
356
417
 
357
- 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() {
358
426
  if (!isMulti) {
359
427
  submit(false);
360
428
  return;
@@ -363,7 +431,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
363
431
  else currentTab = questions.length;
364
432
  const nextQ = currentQuestion();
365
433
  if (nextQ) enterQuestion(nextQ);
366
- else optionIndex = 0;
434
+ else enterSubmitTab();
367
435
  refresh();
368
436
  }
369
437
 
@@ -416,7 +484,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
416
484
  currentTab = matchesKey(data, Key.tab) ? (currentTab + 1) % totalTabs : (currentTab - 1 + totalTabs) % totalTabs;
417
485
  const nextQ = currentQuestion();
418
486
  if (nextQ) enterQuestion(nextQ);
419
- else optionIndex = 0;
487
+ else enterSubmitTab();
420
488
  refresh();
421
489
  return;
422
490
  }
@@ -433,7 +501,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
433
501
  currentTab = (currentTab + 1) % totalTabs;
434
502
  const nextQ = currentQuestion();
435
503
  if (nextQ) enterQuestion(nextQ);
436
- else optionIndex = 0;
504
+ else enterSubmitTab();
437
505
  refresh();
438
506
  return;
439
507
  }
@@ -441,12 +509,41 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
441
509
  currentTab = (currentTab - 1 + totalTabs) % totalTabs;
442
510
  const nextQ = currentQuestion();
443
511
  if (nextQ) enterQuestion(nextQ);
444
- else optionIndex = 0;
512
+ else enterSubmitTab();
445
513
  refresh();
446
514
  return;
447
515
  }
448
516
  }
449
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
+
450
547
  if (currentTab === questions.length) {
451
548
  if (matchesKey(data, Key.enter) && allAnswered()) submit(false);
452
549
  else if (matchesKey(data, Key.escape)) submit(true);
@@ -455,11 +552,13 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
455
552
 
456
553
  if (matchesKey(data, Key.up)) {
457
554
  optionIndex = Math.max(0, optionIndex - 1);
555
+ needsFollow = true; // §options-scroll: keep the selection visible
458
556
  refresh();
459
557
  return;
460
558
  }
461
559
  if (matchesKey(data, Key.down)) {
462
560
  optionIndex = Math.min(opts.length - 1, optionIndex + 1);
561
+ needsFollow = true;
463
562
  refresh();
464
563
  return;
465
564
  }
@@ -633,11 +732,13 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
633
732
  function renderOptions() {
634
733
  optionsStartIndex = lines.length;
635
734
  for (let i = 0; i < opts.length; i++) {
735
+ const start = lines.length;
636
736
  const opt = opts[i];
637
737
  const selected = i === optionIndex;
638
738
  const prefix = selected ? theme.fg("accent", "> ") : " ";
639
739
  const recTag = !opt.isCustom && q?.recommended === i ? theme.fg("success", " ★") : "";
640
740
  addWrapped(prefix + theme.fg(selected ? "accent" : "text", `${i + 1}. ${opt.label}`) + recTag);
741
+ optionRanges[i] = [start, lines.length - 1];
641
742
  }
642
743
  }
643
744
 
@@ -705,11 +806,21 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
705
806
  // objective-box middle is sacrificed there because the full objective
706
807
  // is always in the scrollable transcript presentation (renderCall).
707
808
  if (maxDialogLines !== undefined && lines.length > maxDialogLines) {
708
- const optionsImmediatelyAfterHead = !inputMode && currentTab !== questions.length && !!q && !q.context && opts.length > 0;
709
809
  const proposalSegments = !inputMode && currentTab !== questions.length && !!q && q.context
710
810
  ? findProposalPresentationSegments(lines, optionsStartIndex)
711
811
  : null;
712
- 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
+ }
713
824
  }
714
825
  cachedLines = lines;
715
826
  return lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.26.1",
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",