pi-goal-x 0.27.2 → 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.
|
@@ -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 stable-height bound
|
|
165
|
+
* (spec 2026-08-11-stable-widget-height) so the rendered height stays
|
|
166
|
+
* constant within each regime — 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,83 @@ export function boundWidgetRenderLines(lines: string[], terminalRows: number | u
|
|
|
167
172
|
return lines.slice(0, cap);
|
|
168
173
|
}
|
|
169
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Stable rendered height per regime (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). 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):
|
|
185
|
+
*
|
|
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
|
+
* - resizing the terminal clears the latch and re-evaluates
|
|
191
|
+
* min(natural, newCap): growing the terminal reveals more of the widget,
|
|
192
|
+
* shrinking re-caps;
|
|
193
|
+
* - a regime change (goal id/status, widget state kind, compact↔expanded,
|
|
194
|
+
* debug mode, tasks disabled, first task appearing) clears the latch so the
|
|
195
|
+
* new mode starts from 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; stickyReserve?: number },
|
|
205
|
+
regime: string,
|
|
206
|
+
reserve: number = WIDGET_HEIGHT_RESERVE,
|
|
207
|
+
): string[] {
|
|
208
|
+
if (!terminalRows || terminalRows <= 0) return lines;
|
|
209
|
+
const cap = Math.max(1, terminalRows - reserve);
|
|
210
|
+
if (state.stickyTerminalRows !== terminalRows) {
|
|
211
|
+
// Terminal resized: adapt to the new height (grow/shrink to fit).
|
|
212
|
+
state.stickyCap = undefined;
|
|
213
|
+
state.stickyTerminalRows = terminalRows;
|
|
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
|
+
}
|
|
224
|
+
if (state.stickyRegime !== regime) {
|
|
225
|
+
// Mode/state changed: the latch belongs to one regime.
|
|
226
|
+
state.stickyCap = undefined;
|
|
227
|
+
state.stickyRegime = regime;
|
|
228
|
+
}
|
|
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);
|
|
239
|
+
}
|
|
240
|
+
if (lines.length < state.stickyCap) {
|
|
241
|
+
// Natural dipped below the committed height: pad deterministically so
|
|
242
|
+
// the buffer line count never changes. Blank rows after the box footer
|
|
243
|
+
// — honest filler (the …/↑ N more markers are only for hidden content).
|
|
244
|
+
const padded = lines.slice();
|
|
245
|
+
while (padded.length < state.stickyCap) padded.push("");
|
|
246
|
+
return padded;
|
|
247
|
+
}
|
|
248
|
+
// Exactly the committed height: unchanged.
|
|
249
|
+
return lines;
|
|
250
|
+
}
|
|
251
|
+
|
|
170
252
|
function heading(theme: Theme, width: number, left: string, right = ""): string {
|
|
171
253
|
if (!right) return fit(left, width);
|
|
172
254
|
const rightPart = ` ${right}`;
|
|
@@ -273,6 +355,19 @@ export class GoalWidgetComponent implements Component {
|
|
|
273
355
|
private lastSeenGoalId: string | undefined;
|
|
274
356
|
private lastSeenLatestCompletedAt: string | undefined;
|
|
275
357
|
|
|
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 } = {
|
|
365
|
+
stickyCap: undefined,
|
|
366
|
+
stickyRegime: undefined,
|
|
367
|
+
stickyTerminalRows: undefined,
|
|
368
|
+
stickyReserve: undefined,
|
|
369
|
+
};
|
|
370
|
+
|
|
276
371
|
constructor(options: GoalWidgetOptions) {
|
|
277
372
|
this.theme = options.theme;
|
|
278
373
|
this.tui = options.tui;
|
|
@@ -358,10 +453,92 @@ export class GoalWidgetComponent implements Component {
|
|
|
358
453
|
// pattern as the questionnaire churn guard; mock TUIs without a
|
|
359
454
|
// `terminal` render unbounded.
|
|
360
455
|
const terminalRows = (this.tui as unknown as { terminal?: { rows?: number } }).terminal?.rows;
|
|
456
|
+
// Render the current branch unbounded (natural), then apply the
|
|
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.
|
|
461
|
+
const natural = this.renderNatural(width);
|
|
462
|
+
const regime = this.stableHeightRegime();
|
|
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;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The current widget branch rendered unbounded (no terminal-height bound). */
|
|
535
|
+
private renderNatural(width: number): string[] {
|
|
536
|
+
const settings = this.getSettings();
|
|
537
|
+
const safeWidth = Math.max(1, width);
|
|
361
538
|
// §15.4: a finished audit shows its result card until cleared.
|
|
362
539
|
const auditResult = this.getAuditResult();
|
|
363
540
|
if (auditResult) {
|
|
364
|
-
return
|
|
541
|
+
return clampLinesToWidth(renderAuditResultCardView(auditResult, this.theme, width), width);
|
|
365
542
|
}
|
|
366
543
|
const goal = this.getGoal();
|
|
367
544
|
const otherCount = Math.max(0, this.getOpenGoalCount() - 1);
|
|
@@ -372,7 +549,7 @@ export class GoalWidgetComponent implements Component {
|
|
|
372
549
|
tasksDisabled: settings.disableTasks === true,
|
|
373
550
|
}) : null;
|
|
374
551
|
this.maybeReanchor(model);
|
|
375
|
-
|
|
552
|
+
const lines = renderGoalWidgetLines(this.getGoal(), this.theme, safeWidth, {
|
|
376
553
|
openGoalCount: this.getOpenGoalCount(),
|
|
377
554
|
auditorProgress: this.getAuditorProgress(),
|
|
378
555
|
disableTasks: settings.disableTasks,
|
|
@@ -385,12 +562,32 @@ export class GoalWidgetComponent implements Component {
|
|
|
385
562
|
expandedScrollOffset: this.expandedScrollOffset,
|
|
386
563
|
expandedTaskRows: expandedTaskViewportRows(this.lastRenderWidth),
|
|
387
564
|
keybindings: settings.keybindings?.dashboard,
|
|
388
|
-
terminalRows,
|
|
389
565
|
});
|
|
390
566
|
if (this.getDebugMode()) {
|
|
391
567
|
lines.push(...this.renderDebugPanel(width));
|
|
392
568
|
}
|
|
393
|
-
return
|
|
569
|
+
return clampLinesToWidth(lines, width);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Stable-height regime key: the latch belongs to one goal, goal status,
|
|
574
|
+
* widget state kind (focused / audit / result / unfocused / none),
|
|
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.
|
|
580
|
+
*/
|
|
581
|
+
private stableHeightRegime(): string {
|
|
582
|
+
const settings = this.getSettings();
|
|
583
|
+
const goal = this.getGoal();
|
|
584
|
+
let stateKind: string;
|
|
585
|
+
if (this.getAuditResult()) stateKind = "result";
|
|
586
|
+
else if (this.getAuditorProgress()) stateKind = "audit";
|
|
587
|
+
else if (goal) stateKind = "focused";
|
|
588
|
+
else stateKind = this.getOpenGoalCount() > 0 ? "unfocused" : "none";
|
|
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("|");
|
|
394
591
|
}
|
|
395
592
|
|
|
396
593
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-x",
|
|
3
|
-
"version": "0.27.
|
|
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",
|