pi-goal-x 0.27.2 → 0.27.3

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 sticky-cap bound
165
+ * (spec 2026-08-11-stable-widget-height) so the rendered height stays
166
+ * constant once the widget reaches the cap — 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,68 @@ export function boundWidgetRenderLines(lines: string[], terminalRows: number | u
167
172
  return lines.slice(0, cap);
168
173
  }
169
174
 
175
+ /**
176
+ * Sticky-cap rendered height (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). 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:
186
+ *
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);
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);
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.
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 },
205
+ regime: string,
206
+ ): string[] {
207
+ if (!terminalRows || terminalRows <= 0) return lines;
208
+ const cap = Math.max(1, terminalRows - WIDGET_HEIGHT_RESERVE);
209
+ if (state.stickyTerminalRows !== terminalRows) {
210
+ // Terminal resized: adapt to the new height (grow/shrink to fit).
211
+ state.stickyCap = undefined;
212
+ state.stickyTerminalRows = terminalRows;
213
+ }
214
+ if (state.stickyRegime !== regime) {
215
+ // Mode/state changed: the latch belongs to one regime.
216
+ state.stickyCap = undefined;
217
+ state.stickyRegime = regime;
218
+ }
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);
224
+ }
225
+ if (state.stickyCap !== undefined && lines.length < state.stickyCap) {
226
+ // Natural dipped below the committed height: pad deterministically so
227
+ // the buffer line count never changes. Blank rows after the box footer
228
+ // — honest filler (the …/↑ N more markers are only for hidden content).
229
+ const padded = lines.slice();
230
+ while (padded.length < state.stickyCap) padded.push("");
231
+ return padded;
232
+ }
233
+ // Fits case (natural ≤ cap, no latch): byte-identical to today.
234
+ return lines;
235
+ }
236
+
170
237
  function heading(theme: Theme, width: number, left: string, right = ""): string {
171
238
  if (!right) return fit(left, width);
172
239
  const rightPart = ` ${right}`;
@@ -273,6 +340,16 @@ export class GoalWidgetComponent implements Component {
273
340
  private lastSeenGoalId: string | undefined;
274
341
  private lastSeenLatestCompletedAt: string | undefined;
275
342
 
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 } = {
348
+ stickyCap: undefined,
349
+ stickyRegime: undefined,
350
+ stickyTerminalRows: undefined,
351
+ };
352
+
276
353
  constructor(options: GoalWidgetOptions) {
277
354
  this.theme = options.theme;
278
355
  this.tui = options.tui;
@@ -358,10 +435,23 @@ export class GoalWidgetComponent implements Component {
358
435
  // pattern as the questionnaire churn guard; mock TUIs without a
359
436
  // `terminal` render unbounded.
360
437
  const terminalRows = (this.tui as unknown as { terminal?: { rows?: number } }).terminal?.rows;
438
+ // 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.
442
+ const natural = this.renderNatural(width);
443
+ const regime = this.stableHeightRegime();
444
+ return applyStableHeightBound(natural, terminalRows, this.stableHeightState, regime);
445
+ }
446
+
447
+ /** The current widget branch rendered unbounded (no terminal-height bound). */
448
+ private renderNatural(width: number): string[] {
449
+ const settings = this.getSettings();
450
+ const safeWidth = Math.max(1, width);
361
451
  // §15.4: a finished audit shows its result card until cleared.
362
452
  const auditResult = this.getAuditResult();
363
453
  if (auditResult) {
364
- return boundWidgetRenderLines(clampLinesToWidth(renderAuditResultCardView(auditResult, this.theme, width), width), terminalRows);
454
+ return clampLinesToWidth(renderAuditResultCardView(auditResult, this.theme, width), width);
365
455
  }
366
456
  const goal = this.getGoal();
367
457
  const otherCount = Math.max(0, this.getOpenGoalCount() - 1);
@@ -372,7 +462,7 @@ export class GoalWidgetComponent implements Component {
372
462
  tasksDisabled: settings.disableTasks === true,
373
463
  }) : null;
374
464
  this.maybeReanchor(model);
375
- let lines = renderGoalWidgetLines(this.getGoal(), this.theme, width, {
465
+ const lines = renderGoalWidgetLines(this.getGoal(), this.theme, safeWidth, {
376
466
  openGoalCount: this.getOpenGoalCount(),
377
467
  auditorProgress: this.getAuditorProgress(),
378
468
  disableTasks: settings.disableTasks,
@@ -385,12 +475,28 @@ export class GoalWidgetComponent implements Component {
385
475
  expandedScrollOffset: this.expandedScrollOffset,
386
476
  expandedTaskRows: expandedTaskViewportRows(this.lastRenderWidth),
387
477
  keybindings: settings.keybindings?.dashboard,
388
- terminalRows,
389
478
  });
390
479
  if (this.getDebugMode()) {
391
480
  lines.push(...this.renderDebugPanel(width));
392
481
  }
393
- return boundWidgetRenderLines(clampLinesToWidth(lines, width), terminalRows);
482
+ return clampLinesToWidth(lines, width);
483
+ }
484
+
485
+ /**
486
+ * Sticky-cap regime key: the latch belongs to one goal, goal status,
487
+ * 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.
490
+ */
491
+ private stableHeightRegime(): string {
492
+ const settings = this.getSettings();
493
+ const goal = this.getGoal();
494
+ let stateKind: string;
495
+ if (this.getAuditResult()) stateKind = "result";
496
+ else if (this.getAuditorProgress()) stateKind = "audit";
497
+ else if (goal) stateKind = "focused";
498
+ 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("|");
394
500
  }
395
501
 
396
502
  /**
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.3",
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",