pi-goal-x 0.27.1 → 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.
@@ -134,6 +134,106 @@ function fit(value: string, width: number): string {
134
134
  return visibleWidth(value) > width ? truncateToWidth(value, width, "…") : value;
135
135
  }
136
136
 
137
+ // ── terminal-height bound (spec 2026-08-10-widget-height-bound-scrollback-fix) ──
138
+
139
+ /**
140
+ * Minimum rows reserved below/around the widget so pi's frame stays usable
141
+ * when the widget is at its cap: status line (1) + editor minimum (3) +
142
+ * footer (1) + a chat row (1). With the widget capped at
143
+ * `terminalRows − WIDGET_HEIGHT_RESERVE`, the widget never fills the terminal:
144
+ * the chat row stays in the frame, the widget's top lines stay within the
145
+ * viewport (no pi-tui full-render scrollback wipe), and the editor/footer
146
+ * stay visible — the equal-height scroll-up failure is impossible. Kept at
147
+ * the minimum that still guarantees the chat row: a larger reserve would
148
+ * slice dashboards that genuinely fit (e.g. the expanded 24-line dashboard
149
+ * on a 30-row terminal renders unchanged at 24 + 5 chrome + 1 chat = 30).
150
+ */
151
+ export const WIDGET_HEIGHT_RESERVE = 6;
152
+
153
+ /**
154
+ * Deterministic height guard: cap rendered lines to the terminal height minus
155
+ * the reserved chrome. Head slice, because the dashboard's content priority
156
+ * is top-down (identity → status → progress → tasks → details → hints); the
157
+ * goal identity/status and the interactive task list stay in frame. Pure
158
+ * index function — stable across renders, so the widget never shrinks on its
159
+ * own and can never trigger pi-tui's clearOnShrink full-render
160
+ * (`\x1b[2J\x1b[H\x1b[3J` scrollback wipe). No-op (unbounded) when
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.
167
+ */
168
+ export function boundWidgetRenderLines(lines: string[], terminalRows: number | undefined): string[] {
169
+ if (!terminalRows || terminalRows <= 0) return lines;
170
+ const cap = Math.max(1, terminalRows - WIDGET_HEIGHT_RESERVE);
171
+ if (lines.length <= cap) return lines;
172
+ return lines.slice(0, cap);
173
+ }
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
+
137
237
  function heading(theme: Theme, width: number, left: string, right = ""): string {
138
238
  if (!right) return fit(left, width);
139
239
  const rightPart = ` ${right}`;
@@ -190,18 +290,18 @@ export function renderAuditResultCardView(
190
290
  return renderAuditResultCard(deriveAuditResultCard(view.verdict, view.report), theme, width);
191
291
  }
192
292
 
193
- export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Theme, width: number, options: { openGoalCount?: number; auditorProgress?: AuditorWidgetProgress | null; disableTasks?: boolean; stalled?: boolean; ledgerEvents?: GoalLedgerEvent[]; expanded?: boolean; debug?: boolean; model?: GoalDashboardModel | null; compactScrollOffset?: number; expandedScrollOffset?: number; expandedTaskRows?: number; keybindings?: GoalDashboardKeybindings } = {}): string[] {
293
+ export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Theme, width: number, options: { openGoalCount?: number; auditorProgress?: AuditorWidgetProgress | null; disableTasks?: boolean; stalled?: boolean; ledgerEvents?: GoalLedgerEvent[]; expanded?: boolean; debug?: boolean; model?: GoalDashboardModel | null; compactScrollOffset?: number; expandedScrollOffset?: number; expandedTaskRows?: number; keybindings?: GoalDashboardKeybindings; /** Terminal-height bound (see boundWidgetRenderLines); undefined = unbounded. */ terminalRows?: number } = {}): string[] {
194
294
  // When auditor progress is active, show the structured audit dashboard
195
295
  // instead of the normal goal widget (§15.3).
196
296
  if (options.auditorProgress) {
197
- return renderAuditorWidgetLines(options.auditorProgress, theme, width, {
297
+ return boundWidgetRenderLines(renderAuditorWidgetLines(options.auditorProgress, theme, width, {
198
298
  showToolDetails: options.expanded === true || options.debug === true,
199
- });
299
+ }), options.terminalRows);
200
300
  }
201
301
  const openGoalCount = options.openGoalCount ?? 0;
202
302
  if (!goal) {
203
303
  // Unfocused panel when open goals exist; nothing when the pool is empty.
204
- return openGoalCount > 0 ? renderUnfocusedDashboard(openGoalCount, theme, width) : [];
304
+ return boundWidgetRenderLines(openGoalCount > 0 ? renderUnfocusedDashboard(openGoalCount, theme, width) : [], options.terminalRows);
205
305
  }
206
306
  const safeWidth = Math.max(1, width);
207
307
  const otherCount = Math.max(0, openGoalCount - 1);
@@ -215,7 +315,7 @@ export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Them
215
315
  const lines = options.expanded
216
316
  ? renderExpandedDashboard(model, theme, safeWidth, { scrollOffset: options.expandedScrollOffset, rows: options.expandedTaskRows, keybindings: options.keybindings })
217
317
  : renderCompactDashboard(model, theme, safeWidth, { scrollOffset: options.compactScrollOffset, keybindings: options.keybindings });
218
- return clampLinesToWidth(lines, width);
318
+ return boundWidgetRenderLines(clampLinesToWidth(lines, width), options.terminalRows);
219
319
  }
220
320
 
221
321
  export class GoalWidgetComponent implements Component {
@@ -240,6 +340,16 @@ export class GoalWidgetComponent implements Component {
240
340
  private lastSeenGoalId: string | undefined;
241
341
  private lastSeenLatestCompletedAt: string | undefined;
242
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
+
243
353
  constructor(options: GoalWidgetOptions) {
244
354
  this.theme = options.theme;
245
355
  this.tui = options.tui;
@@ -318,6 +428,26 @@ export class GoalWidgetComponent implements Component {
318
428
  render(width: number): string[] {
319
429
  const settings = this.getSettings();
320
430
  this.lastRenderWidth = Math.max(1, width);
431
+ // Terminal-height bound (spec 2026-08-10): read pi's terminal rows at
432
+ // render time so the widget never fills the terminal — the chat/editor
433
+ // stay visible and scrollable and the widget can never trigger pi-tui's
434
+ // shrink full-render (\x1b[2J\x1b[H\x1b[3J) scrollback wipe. Same cast
435
+ // pattern as the questionnaire churn guard; mock TUIs without a
436
+ // `terminal` render unbounded.
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);
321
451
  // §15.4: a finished audit shows its result card until cleared.
322
452
  const auditResult = this.getAuditResult();
323
453
  if (auditResult) {
@@ -332,7 +462,7 @@ export class GoalWidgetComponent implements Component {
332
462
  tasksDisabled: settings.disableTasks === true,
333
463
  }) : null;
334
464
  this.maybeReanchor(model);
335
- let lines = renderGoalWidgetLines(this.getGoal(), this.theme, width, {
465
+ const lines = renderGoalWidgetLines(this.getGoal(), this.theme, safeWidth, {
336
466
  openGoalCount: this.getOpenGoalCount(),
337
467
  auditorProgress: this.getAuditorProgress(),
338
468
  disableTasks: settings.disableTasks,
@@ -352,6 +482,23 @@ export class GoalWidgetComponent implements Component {
352
482
  return clampLinesToWidth(lines, width);
353
483
  }
354
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("|");
500
+ }
501
+
355
502
  /**
356
503
  * §9.6 re-anchor rule: when the goal changes or a new task completes (the
357
504
  * latest completedAt moves), reset both viewports to the anchored defaults
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.27.1",
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",