pi-goal-x 0.27.3 → 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.
@@ -161,9 +161,9 @@ export const WIDGET_HEIGHT_RESERVE = 6;
161
161
  * terminalRows is missing (mock TUI, headless contexts, /goal-status text).
162
162
  *
163
163
  * This pure head-slice is the terminal bound for direct callers. The live
164
- * component additionally applies the sticky-cap bound
164
+ * component additionally applies the stable-height bound
165
165
  * (spec 2026-08-11-stable-widget-height) so the rendered height stays
166
- * constant once the widget reaches the cap — see applyStableHeightBound.
166
+ * constant within each regime — see applyStableHeightBound.
167
167
  */
168
168
  export function boundWidgetRenderLines(lines: string[], terminalRows: number | undefined): string[] {
169
169
  if (!terminalRows || terminalRows <= 0) return lines;
@@ -173,26 +173,26 @@ export function boundWidgetRenderLines(lines: string[], terminalRows: number | u
173
173
  }
174
174
 
175
175
  /**
176
- * Sticky-cap rendered height (spec 2026-08-11-stable-widget-height).
176
+ * Stable rendered height per regime (spec 2026-08-11-stable-widget-height).
177
177
  *
178
178
  * The widget's natural (unbounded) height varies with goal state — activity
179
179
  * feed growth, current-task contract/evidence wrapping, verification text,
180
- * budget, task growth (measured 4..31 lines on a 24-row terminal). When the
181
- * widget is taller than the terminal, every natural-height change alters the
182
- * dock height → the terminal buffer's line count changes → the terminal
183
- * scrolls to the bottom and the user cannot hold a scroll position to read
184
- * the chat. The sticky cap makes the rendered height invariant to goal-state
185
- * changes while the widget is at the cap:
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):
186
185
  *
187
- * - once natural > cap in the current regime, render exactly `cap` lines on
188
- * every later render of that regime (head slice when natural > cap, blank
189
- * padding when natural dips below — the height never changes);
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
190
  * - resizing the terminal clears the latch and re-evaluates
191
- * min(natural, newCap): growing the terminal reveals more of the widget;
192
- * once it fits, everything renders (the fits case stays byte-identical);
191
+ * min(natural, newCap): growing the terminal reveals more of the widget,
192
+ * shrinking re-caps;
193
193
  * - a regime change (goal id/status, widget state kind, compact↔expanded,
194
- * debug mode, tasks disabled) clears the latch so the new mode starts from
195
- * its own natural height.
194
+ * debug mode, tasks disabled, first task appearing) clears the latch so the
195
+ * new mode starts from its own natural height.
196
196
  *
197
197
  * Pure given the persisted latch state: deterministic, no timers, no
198
198
  * randomness — the same goal state on the same terminal renders the same
@@ -201,28 +201,43 @@ export function boundWidgetRenderLines(lines: string[], terminalRows: number | u
201
201
  export function applyStableHeightBound(
202
202
  lines: string[],
203
203
  terminalRows: number | undefined,
204
- state: { stickyCap: number | undefined; stickyRegime: string | undefined; stickyTerminalRows: number | undefined },
204
+ state: { stickyCap: number | undefined; stickyRegime: string | undefined; stickyTerminalRows: number | undefined; stickyReserve?: number },
205
205
  regime: string,
206
+ reserve: number = WIDGET_HEIGHT_RESERVE,
206
207
  ): string[] {
207
208
  if (!terminalRows || terminalRows <= 0) return lines;
208
- const cap = Math.max(1, terminalRows - WIDGET_HEIGHT_RESERVE);
209
+ const cap = Math.max(1, terminalRows - reserve);
209
210
  if (state.stickyTerminalRows !== terminalRows) {
210
211
  // Terminal resized: adapt to the new height (grow/shrink to fit).
211
212
  state.stickyCap = undefined;
212
213
  state.stickyTerminalRows = terminalRows;
213
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
+ }
214
224
  if (state.stickyRegime !== regime) {
215
225
  // Mode/state changed: the latch belongs to one regime.
216
226
  state.stickyCap = undefined;
217
227
  state.stickyRegime = regime;
218
228
  }
219
- if (lines.length > cap) {
220
- // At the cap: latch the rendered height and head-slice (the dashboard's
221
- // content priority is top-down).
222
- state.stickyCap = cap;
223
- return lines.slice(0, cap);
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);
224
239
  }
225
- if (state.stickyCap !== undefined && lines.length < state.stickyCap) {
240
+ if (lines.length < state.stickyCap) {
226
241
  // Natural dipped below the committed height: pad deterministically so
227
242
  // the buffer line count never changes. Blank rows after the box footer
228
243
  // — honest filler (the …/↑ N more markers are only for hidden content).
@@ -230,7 +245,7 @@ export function applyStableHeightBound(
230
245
  while (padded.length < state.stickyCap) padded.push("");
231
246
  return padded;
232
247
  }
233
- // Fits case (natural ≤ cap, no latch): byte-identical to today.
248
+ // Exactly the committed height: unchanged.
234
249
  return lines;
235
250
  }
236
251
 
@@ -340,14 +355,17 @@ export class GoalWidgetComponent implements Component {
340
355
  private lastSeenGoalId: string | undefined;
341
356
  private lastSeenLatestCompletedAt: string | undefined;
342
357
 
343
- // Sticky-cap latch (spec 2026-08-11-stable-widget-height): once the widget
344
- // reaches the terminal cap in a regime, its rendered height stays constant
345
- // (cap lines) until the regime or the terminal size changes, so the buffer
346
- // line count never changes and the terminal stops jumping to the bottom.
347
- private stableHeightState: { stickyCap: number | undefined; stickyRegime: string | undefined; stickyTerminalRows: number | undefined } = {
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 } = {
348
365
  stickyCap: undefined,
349
366
  stickyRegime: undefined,
350
367
  stickyTerminalRows: undefined,
368
+ stickyReserve: undefined,
351
369
  };
352
370
 
353
371
  constructor(options: GoalWidgetOptions) {
@@ -436,12 +454,81 @@ export class GoalWidgetComponent implements Component {
436
454
  // `terminal` render unbounded.
437
455
  const terminalRows = (this.tui as unknown as { terminal?: { rows?: number } }).terminal?.rows;
438
456
  // Render the current branch unbounded (natural), then apply the
439
- // stable-height bound (spec 2026-08-11): the rendered height is constant
440
- // while the widget is at the cap, so the dock height / buffer line count
441
- // stop changing and the terminal stops jumping to the bottom.
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.
442
461
  const natural = this.renderNatural(width);
443
462
  const regime = this.stableHeightRegime();
444
- return applyStableHeightBound(natural, terminalRows, this.stableHeightState, regime);
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;
445
532
  }
446
533
 
447
534
  /** The current widget branch rendered unbounded (no terminal-height bound). */
@@ -483,10 +570,13 @@ export class GoalWidgetComponent implements Component {
483
570
  }
484
571
 
485
572
  /**
486
- * Sticky-cap regime key: the latch belongs to one goal, goal status,
573
+ * Stable-height regime key: the latch belongs to one goal, goal status,
487
574
  * widget state kind (focused / audit / result / unfocused / none),
488
- * expansion mode, debug mode, and task setting — a change clears the latch
489
- * so the new mode starts from its own natural height.
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.
490
580
  */
491
581
  private stableHeightRegime(): string {
492
582
  const settings = this.getSettings();
@@ -496,7 +586,8 @@ export class GoalWidgetComponent implements Component {
496
586
  else if (this.getAuditorProgress()) stateKind = "audit";
497
587
  else if (goal) stateKind = "focused";
498
588
  else stateKind = this.getOpenGoalCount() > 0 ? "unfocused" : "none";
499
- return [goal?.id ?? "∅", goal?.status ?? "∅", stateKind, String(this.getExpanded()), String(this.getDebugMode()), String(settings.disableTasks === true)].join("|");
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("|");
500
591
  }
501
592
 
502
593
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.27.3",
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",