pi-goal-x 0.27.1 → 0.27.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.
@@ -134,6 +134,39 @@ 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
+ export function boundWidgetRenderLines(lines: string[], terminalRows: number | undefined): string[] {
164
+ if (!terminalRows || terminalRows <= 0) return lines;
165
+ const cap = Math.max(1, terminalRows - WIDGET_HEIGHT_RESERVE);
166
+ if (lines.length <= cap) return lines;
167
+ return lines.slice(0, cap);
168
+ }
169
+
137
170
  function heading(theme: Theme, width: number, left: string, right = ""): string {
138
171
  if (!right) return fit(left, width);
139
172
  const rightPart = ` ${right}`;
@@ -190,18 +223,18 @@ export function renderAuditResultCardView(
190
223
  return renderAuditResultCard(deriveAuditResultCard(view.verdict, view.report), theme, width);
191
224
  }
192
225
 
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[] {
226
+ 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
227
  // When auditor progress is active, show the structured audit dashboard
195
228
  // instead of the normal goal widget (§15.3).
196
229
  if (options.auditorProgress) {
197
- return renderAuditorWidgetLines(options.auditorProgress, theme, width, {
230
+ return boundWidgetRenderLines(renderAuditorWidgetLines(options.auditorProgress, theme, width, {
198
231
  showToolDetails: options.expanded === true || options.debug === true,
199
- });
232
+ }), options.terminalRows);
200
233
  }
201
234
  const openGoalCount = options.openGoalCount ?? 0;
202
235
  if (!goal) {
203
236
  // Unfocused panel when open goals exist; nothing when the pool is empty.
204
- return openGoalCount > 0 ? renderUnfocusedDashboard(openGoalCount, theme, width) : [];
237
+ return boundWidgetRenderLines(openGoalCount > 0 ? renderUnfocusedDashboard(openGoalCount, theme, width) : [], options.terminalRows);
205
238
  }
206
239
  const safeWidth = Math.max(1, width);
207
240
  const otherCount = Math.max(0, openGoalCount - 1);
@@ -215,7 +248,7 @@ export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Them
215
248
  const lines = options.expanded
216
249
  ? renderExpandedDashboard(model, theme, safeWidth, { scrollOffset: options.expandedScrollOffset, rows: options.expandedTaskRows, keybindings: options.keybindings })
217
250
  : renderCompactDashboard(model, theme, safeWidth, { scrollOffset: options.compactScrollOffset, keybindings: options.keybindings });
218
- return clampLinesToWidth(lines, width);
251
+ return boundWidgetRenderLines(clampLinesToWidth(lines, width), options.terminalRows);
219
252
  }
220
253
 
221
254
  export class GoalWidgetComponent implements Component {
@@ -318,10 +351,17 @@ export class GoalWidgetComponent implements Component {
318
351
  render(width: number): string[] {
319
352
  const settings = this.getSettings();
320
353
  this.lastRenderWidth = Math.max(1, width);
354
+ // Terminal-height bound (spec 2026-08-10): read pi's terminal rows at
355
+ // render time so the widget never fills the terminal — the chat/editor
356
+ // stay visible and scrollable and the widget can never trigger pi-tui's
357
+ // shrink full-render (\x1b[2J\x1b[H\x1b[3J) scrollback wipe. Same cast
358
+ // pattern as the questionnaire churn guard; mock TUIs without a
359
+ // `terminal` render unbounded.
360
+ const terminalRows = (this.tui as unknown as { terminal?: { rows?: number } }).terminal?.rows;
321
361
  // §15.4: a finished audit shows its result card until cleared.
322
362
  const auditResult = this.getAuditResult();
323
363
  if (auditResult) {
324
- return clampLinesToWidth(renderAuditResultCardView(auditResult, this.theme, width), width);
364
+ return boundWidgetRenderLines(clampLinesToWidth(renderAuditResultCardView(auditResult, this.theme, width), width), terminalRows);
325
365
  }
326
366
  const goal = this.getGoal();
327
367
  const otherCount = Math.max(0, this.getOpenGoalCount() - 1);
@@ -345,11 +385,12 @@ export class GoalWidgetComponent implements Component {
345
385
  expandedScrollOffset: this.expandedScrollOffset,
346
386
  expandedTaskRows: expandedTaskViewportRows(this.lastRenderWidth),
347
387
  keybindings: settings.keybindings?.dashboard,
388
+ terminalRows,
348
389
  });
349
390
  if (this.getDebugMode()) {
350
391
  lines.push(...this.renderDebugPanel(width));
351
392
  }
352
- return clampLinesToWidth(lines, width);
393
+ return boundWidgetRenderLines(clampLinesToWidth(lines, width), terminalRows);
353
394
  }
354
395
 
355
396
  /**
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.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",