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
|
-
|
|
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.
|
|
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",
|