pi-goal-x 0.27.2 → 0.27.4

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.
@@ -159,6 +159,11 @@ export const WIDGET_HEIGHT_RESERVE = 6;
159
159
  * own and can never trigger pi-tui's clearOnShrink full-render
160
160
  * (`\x1b[2J\x1b[H\x1b[3J` scrollback wipe). No-op (unbounded) when
161
161
  * terminalRows is missing (mock TUI, headless contexts, /goal-status text).
162
+ *
163
+ * This pure head-slice is the terminal bound for direct callers. The live
164
+ * component additionally applies the stable-height bound
165
+ * (spec 2026-08-11-stable-widget-height) so the rendered height stays
166
+ * constant within each regime — see applyStableHeightBound.
162
167
  */
163
168
  export function boundWidgetRenderLines(lines: string[], terminalRows: number | undefined): string[] {
164
169
  if (!terminalRows || terminalRows <= 0) return lines;
@@ -167,6 +172,83 @@ export function boundWidgetRenderLines(lines: string[], terminalRows: number | u
167
172
  return lines.slice(0, cap);
168
173
  }
169
174
 
175
+ /**
176
+ * Stable rendered height per regime (spec 2026-08-11-stable-widget-height).
177
+ *
178
+ * The widget's natural (unbounded) height varies with goal state — activity
179
+ * feed growth, current-task contract/evidence wrapping, verification text,
180
+ * budget, task growth (measured 4..31 lines on a 24-row terminal). Every
181
+ * natural-height change alters the dock height → the terminal buffer's line
182
+ * count changes → the terminal scrolls to the bottom and the user cannot hold
183
+ * a scroll position to read the chat. The latch makes the rendered height
184
+ * invariant to goal-state changes in EVERY case (fits and capped):
185
+ *
186
+ * - the first render of each regime commits the rendered height
187
+ * (min(natural, cap)) and every later render of that regime renders exactly
188
+ * that many lines — growth is head-sliced, shrink is blank-padded, the
189
+ * height never changes;
190
+ * - resizing the terminal clears the latch and re-evaluates
191
+ * min(natural, newCap): growing the terminal reveals more of the widget,
192
+ * shrinking re-caps;
193
+ * - a regime change (goal id/status, widget state kind, compact↔expanded,
194
+ * debug mode, tasks disabled, first task appearing) clears the latch so the
195
+ * new mode starts from its own natural height.
196
+ *
197
+ * Pure given the persisted latch state: deterministic, no timers, no
198
+ * randomness — the same goal state on the same terminal renders the same
199
+ * lines on every render.
200
+ */
201
+ export function applyStableHeightBound(
202
+ lines: string[],
203
+ terminalRows: number | undefined,
204
+ state: { stickyCap: number | undefined; stickyRegime: string | undefined; stickyTerminalRows: number | undefined; stickyReserve?: number },
205
+ regime: string,
206
+ reserve: number = WIDGET_HEIGHT_RESERVE,
207
+ ): string[] {
208
+ if (!terminalRows || terminalRows <= 0) return lines;
209
+ const cap = Math.max(1, terminalRows - reserve);
210
+ if (state.stickyTerminalRows !== terminalRows) {
211
+ // Terminal resized: adapt to the new height (grow/shrink to fit).
212
+ state.stickyCap = undefined;
213
+ state.stickyTerminalRows = terminalRows;
214
+ }
215
+ if (state.stickyReserve !== reserve) {
216
+ // The chrome below the widget changed (editor grew/shrunk, status,
217
+ // footer, pending): re-evaluate so the widget's block plus the chrome
218
+ // never exceeds the terminal — otherwise the chat's appended lines
219
+ // and the status line land above pi-tui's viewport top and every
220
+ // agent write triggers a 2J+3J scrollback wipe (spec 2026-08-11).
221
+ state.stickyCap = undefined;
222
+ state.stickyReserve = reserve;
223
+ }
224
+ if (state.stickyRegime !== regime) {
225
+ // Mode/state changed: the latch belongs to one regime.
226
+ state.stickyCap = undefined;
227
+ state.stickyRegime = regime;
228
+ }
229
+ if (state.stickyCap === undefined) {
230
+ // First render of the regime: latch the height — the natural height
231
+ // when it fits, else the cap. Constant from here on, fits AND capped.
232
+ state.stickyCap = Math.min(lines.length, cap);
233
+ }
234
+ if (lines.length > state.stickyCap) {
235
+ // Growth past the committed height: head-slice (the dashboard's
236
+ // content priority is top-down), so the buffer line count never
237
+ // changes.
238
+ return lines.slice(0, state.stickyCap);
239
+ }
240
+ if (lines.length < state.stickyCap) {
241
+ // Natural dipped below the committed height: pad deterministically so
242
+ // the buffer line count never changes. Blank rows after the box footer
243
+ // — honest filler (the …/↑ N more markers are only for hidden content).
244
+ const padded = lines.slice();
245
+ while (padded.length < state.stickyCap) padded.push("");
246
+ return padded;
247
+ }
248
+ // Exactly the committed height: unchanged.
249
+ return lines;
250
+ }
251
+
170
252
  function heading(theme: Theme, width: number, left: string, right = ""): string {
171
253
  if (!right) return fit(left, width);
172
254
  const rightPart = ` ${right}`;
@@ -273,6 +355,19 @@ export class GoalWidgetComponent implements Component {
273
355
  private lastSeenGoalId: string | undefined;
274
356
  private lastSeenLatestCompletedAt: string | undefined;
275
357
 
358
+ // Stable-height latch (spec 2026-08-11-stable-widget-height): the first
359
+ // render of each regime commits the rendered height (natural when it fits,
360
+ // else the cap); the height stays constant (head-slice growth, pad shrink)
361
+ // until the regime, the terminal size, or the measured dock chrome changes,
362
+ // so the buffer line count never changes and the terminal stops jumping to
363
+ // the bottom.
364
+ private stableHeightState: { stickyCap: number | undefined; stickyRegime: string | undefined; stickyTerminalRows: number | undefined; stickyReserve?: number } = {
365
+ stickyCap: undefined,
366
+ stickyRegime: undefined,
367
+ stickyTerminalRows: undefined,
368
+ stickyReserve: undefined,
369
+ };
370
+
276
371
  constructor(options: GoalWidgetOptions) {
277
372
  this.theme = options.theme;
278
373
  this.tui = options.tui;
@@ -358,10 +453,92 @@ export class GoalWidgetComponent implements Component {
358
453
  // pattern as the questionnaire churn guard; mock TUIs without a
359
454
  // `terminal` render unbounded.
360
455
  const terminalRows = (this.tui as unknown as { terminal?: { rows?: number } }).terminal?.rows;
456
+ // Render the current branch unbounded (natural), then apply the
457
+ // stable-height bound (spec 2026-08-11): the rendered height latches at
458
+ // the first render of each regime and stays constant (fits and capped),
459
+ // so the dock height / buffer line count stop changing and the
460
+ // terminal stops jumping to the bottom.
461
+ const natural = this.renderNatural(width);
462
+ const regime = this.stableHeightRegime();
463
+ // Reserve adapted to the actual dock chrome (status + editor + footer +
464
+ // pending): the widget's block plus the chrome must never exceed the
465
+ // terminal, or the chat's appended lines and the status line land above
466
+ // pi-tui's viewport top and every agent write wipes the scrollback.
467
+ const reserve = this.measureDockReserve(width) ?? WIDGET_HEIGHT_RESERVE;
468
+ return applyStableHeightBound(natural, terminalRows, this.stableHeightState, regime, reserve);
469
+ }
470
+
471
+ /**
472
+ * Measure the rendered height of the dock chrome around the widget
473
+ * (pending + status + editor + widgetBelow + footer) at the current width,
474
+ * so the widget can size itself so its block plus the chrome never exceeds
475
+ * the terminal (spec 2026-08-11): pi-tui full-renders (2J+3J, scrollback
476
+ * wipe) whenever the first changed line is above its tracked viewport top
477
+ * (`previousBufferLength − terminalRows`); when the widget's block pushes
478
+ * the chat's append point and the status line above that, every agent
479
+ * write wipes the terminal scrollback and the user is forced to the
480
+ * bottom. Returns undefined (→ the static WIDGET_HEIGHT_RESERVE fallback)
481
+ * when the TUI exposes no measurable dock (mock TUIs, headless).
482
+ */
483
+ private measureDockReserve(width: number): number | undefined {
484
+ type DockChild = { children?: DockChild[]; render?: (w: number) => string[] };
485
+ const tui = this.tui as unknown as { children?: DockChild[] };
486
+ const top = tui.children;
487
+ if (!Array.isArray(top) || top.length < 2) return undefined;
488
+ // Locate the dock list and the widget's own container:
489
+ // regular mode — top-level [document, pending, status, widgetAbove,
490
+ // editor, widgetBelow, footer] with the widget directly inside
491
+ // widgetContainerAbove;
492
+ // fullscreen mode — top-level [transcript, dock] with the widget
493
+ // inside widgetContainerAbove inside the dock VStack.
494
+ let dockList: typeof top | undefined;
495
+ let widgetContainer: (typeof top)[number] | undefined;
496
+ for (const c of top) {
497
+ const kids = Array.isArray(c.children) ? c.children : undefined;
498
+ if (kids?.includes(this)) {
499
+ dockList = top;
500
+ widgetContainer = c;
501
+ break;
502
+ }
503
+ const nested = kids?.find((cc) => Array.isArray(cc.children) && cc.children!.includes(this)) as (typeof top)[number] | undefined;
504
+ if (nested) {
505
+ dockList = kids!;
506
+ widgetContainer = nested;
507
+ break;
508
+ }
509
+ }
510
+ if (!dockList || !widgetContainer) return undefined;
511
+ let total = 0;
512
+ for (let i = 0; i < dockList.length; i++) {
513
+ const c = dockList[i]!;
514
+ // Skip the document container / transcript (its height cancels out
515
+ // of the viewport math: it sits above both the chat's append point
516
+ // and the viewport top) and the widget's own container.
517
+ if (dockList === top && i === 0) continue;
518
+ if (c === widgetContainer) continue;
519
+ if (typeof c?.render !== "function") continue;
520
+ try {
521
+ const lines = c.render(width);
522
+ if (Array.isArray(lines)) total += lines.length;
523
+ } catch {
524
+ // A sibling render failure must never break the widget; fall
525
+ // back to the static reserve.
526
+ return undefined;
527
+ }
528
+ }
529
+ // +1 slack so the strictest safety condition (chat append point below
530
+ // the viewport top) holds even at the boundary.
531
+ return total + 1;
532
+ }
533
+
534
+ /** The current widget branch rendered unbounded (no terminal-height bound). */
535
+ private renderNatural(width: number): string[] {
536
+ const settings = this.getSettings();
537
+ const safeWidth = Math.max(1, width);
361
538
  // §15.4: a finished audit shows its result card until cleared.
362
539
  const auditResult = this.getAuditResult();
363
540
  if (auditResult) {
364
- return boundWidgetRenderLines(clampLinesToWidth(renderAuditResultCardView(auditResult, this.theme, width), width), terminalRows);
541
+ return clampLinesToWidth(renderAuditResultCardView(auditResult, this.theme, width), width);
365
542
  }
366
543
  const goal = this.getGoal();
367
544
  const otherCount = Math.max(0, this.getOpenGoalCount() - 1);
@@ -372,7 +549,7 @@ export class GoalWidgetComponent implements Component {
372
549
  tasksDisabled: settings.disableTasks === true,
373
550
  }) : null;
374
551
  this.maybeReanchor(model);
375
- let lines = renderGoalWidgetLines(this.getGoal(), this.theme, width, {
552
+ const lines = renderGoalWidgetLines(this.getGoal(), this.theme, safeWidth, {
376
553
  openGoalCount: this.getOpenGoalCount(),
377
554
  auditorProgress: this.getAuditorProgress(),
378
555
  disableTasks: settings.disableTasks,
@@ -385,12 +562,32 @@ export class GoalWidgetComponent implements Component {
385
562
  expandedScrollOffset: this.expandedScrollOffset,
386
563
  expandedTaskRows: expandedTaskViewportRows(this.lastRenderWidth),
387
564
  keybindings: settings.keybindings?.dashboard,
388
- terminalRows,
389
565
  });
390
566
  if (this.getDebugMode()) {
391
567
  lines.push(...this.renderDebugPanel(width));
392
568
  }
393
- return boundWidgetRenderLines(clampLinesToWidth(lines, width), terminalRows);
569
+ return clampLinesToWidth(lines, width);
570
+ }
571
+
572
+ /**
573
+ * Stable-height regime key: the latch belongs to one goal, goal status,
574
+ * widget state kind (focused / audit / result / unfocused / none),
575
+ * expansion mode, debug mode, task setting, and task presence — a change
576
+ * clears the latch so the new mode starts from its own natural height.
577
+ * Task presence re-latches a structurally empty goal (0 tasks → tiny
578
+ * dashboard) when its first task appears — a rare structural jump, not
579
+ * steady-state churn.
580
+ */
581
+ private stableHeightRegime(): string {
582
+ const settings = this.getSettings();
583
+ const goal = this.getGoal();
584
+ let stateKind: string;
585
+ if (this.getAuditResult()) stateKind = "result";
586
+ else if (this.getAuditorProgress()) stateKind = "audit";
587
+ else if (goal) stateKind = "focused";
588
+ else stateKind = this.getOpenGoalCount() > 0 ? "unfocused" : "none";
589
+ const hasTasks = (goal?.taskList?.tasks?.length ?? 0) > 0;
590
+ return [goal?.id ?? "∅", goal?.status ?? "∅", stateKind, String(this.getExpanded()), String(this.getDebugMode()), String(settings.disableTasks === true), String(hasTasks)].join("|");
394
591
  }
395
592
 
396
593
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.27.2",
3
+ "version": "0.27.4",
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",
@@ -66,6 +66,7 @@
66
66
  "@earendil-works/pi-coding-agent": "^0.84.1",
67
67
  "@earendil-works/pi-tui": "^0.84.1",
68
68
  "@eslint/js": "^9.39.5",
69
+ "@xterm/headless": "^6.0.0",
69
70
  "eslint": "^9.39.5",
70
71
  "globals": "^15.15.0",
71
72
  "typebox": "^1.3.11",