pi-goal-x 0.27.3 → 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.
- package/extensions/widgets/goal-widget.ts +129 -38
- package/package.json +2 -1
|
@@ -161,9 +161,9 @@ export const WIDGET_HEIGHT_RESERVE = 6;
|
|
|
161
161
|
* terminalRows is missing (mock TUI, headless contexts, /goal-status text).
|
|
162
162
|
*
|
|
163
163
|
* This pure head-slice is the terminal bound for direct callers. The live
|
|
164
|
-
* component additionally applies the
|
|
164
|
+
* component additionally applies the stable-height bound
|
|
165
165
|
* (spec 2026-08-11-stable-widget-height) so the rendered height stays
|
|
166
|
-
* constant
|
|
166
|
+
* constant within each regime — see applyStableHeightBound.
|
|
167
167
|
*/
|
|
168
168
|
export function boundWidgetRenderLines(lines: string[], terminalRows: number | undefined): string[] {
|
|
169
169
|
if (!terminalRows || terminalRows <= 0) return lines;
|
|
@@ -173,26 +173,26 @@ export function boundWidgetRenderLines(lines: string[], terminalRows: number | u
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
/**
|
|
176
|
-
*
|
|
176
|
+
* Stable rendered height per regime (spec 2026-08-11-stable-widget-height).
|
|
177
177
|
*
|
|
178
178
|
* The widget's natural (unbounded) height varies with goal state — activity
|
|
179
179
|
* feed growth, current-task contract/evidence wrapping, verification text,
|
|
180
|
-
* budget, task growth (measured 4..31 lines on a 24-row terminal).
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* changes while the widget is at the cap:
|
|
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):
|
|
186
185
|
*
|
|
187
|
-
* -
|
|
188
|
-
* every later render of that regime
|
|
189
|
-
*
|
|
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
190
|
* - resizing the terminal clears the latch and re-evaluates
|
|
191
|
-
* min(natural, newCap): growing the terminal reveals more of the widget
|
|
192
|
-
*
|
|
191
|
+
* min(natural, newCap): growing the terminal reveals more of the widget,
|
|
192
|
+
* shrinking re-caps;
|
|
193
193
|
* - a regime change (goal id/status, widget state kind, compact↔expanded,
|
|
194
|
-
* debug mode, tasks disabled) clears the latch so the
|
|
195
|
-
* its own natural height.
|
|
194
|
+
* debug mode, tasks disabled, first task appearing) clears the latch so the
|
|
195
|
+
* new mode starts from its own natural height.
|
|
196
196
|
*
|
|
197
197
|
* Pure given the persisted latch state: deterministic, no timers, no
|
|
198
198
|
* randomness — the same goal state on the same terminal renders the same
|
|
@@ -201,28 +201,43 @@ export function boundWidgetRenderLines(lines: string[], terminalRows: number | u
|
|
|
201
201
|
export function applyStableHeightBound(
|
|
202
202
|
lines: string[],
|
|
203
203
|
terminalRows: number | undefined,
|
|
204
|
-
state: { stickyCap: number | undefined; stickyRegime: string | undefined; stickyTerminalRows: number | undefined },
|
|
204
|
+
state: { stickyCap: number | undefined; stickyRegime: string | undefined; stickyTerminalRows: number | undefined; stickyReserve?: number },
|
|
205
205
|
regime: string,
|
|
206
|
+
reserve: number = WIDGET_HEIGHT_RESERVE,
|
|
206
207
|
): string[] {
|
|
207
208
|
if (!terminalRows || terminalRows <= 0) return lines;
|
|
208
|
-
const cap = Math.max(1, terminalRows -
|
|
209
|
+
const cap = Math.max(1, terminalRows - reserve);
|
|
209
210
|
if (state.stickyTerminalRows !== terminalRows) {
|
|
210
211
|
// Terminal resized: adapt to the new height (grow/shrink to fit).
|
|
211
212
|
state.stickyCap = undefined;
|
|
212
213
|
state.stickyTerminalRows = terminalRows;
|
|
213
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
|
+
}
|
|
214
224
|
if (state.stickyRegime !== regime) {
|
|
215
225
|
// Mode/state changed: the latch belongs to one regime.
|
|
216
226
|
state.stickyCap = undefined;
|
|
217
227
|
state.stickyRegime = regime;
|
|
218
228
|
}
|
|
219
|
-
if (
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
state.stickyCap = cap;
|
|
223
|
-
|
|
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);
|
|
224
239
|
}
|
|
225
|
-
if (
|
|
240
|
+
if (lines.length < state.stickyCap) {
|
|
226
241
|
// Natural dipped below the committed height: pad deterministically so
|
|
227
242
|
// the buffer line count never changes. Blank rows after the box footer
|
|
228
243
|
// — honest filler (the …/↑ N more markers are only for hidden content).
|
|
@@ -230,7 +245,7 @@ export function applyStableHeightBound(
|
|
|
230
245
|
while (padded.length < state.stickyCap) padded.push("");
|
|
231
246
|
return padded;
|
|
232
247
|
}
|
|
233
|
-
//
|
|
248
|
+
// Exactly the committed height: unchanged.
|
|
234
249
|
return lines;
|
|
235
250
|
}
|
|
236
251
|
|
|
@@ -340,14 +355,17 @@ export class GoalWidgetComponent implements Component {
|
|
|
340
355
|
private lastSeenGoalId: string | undefined;
|
|
341
356
|
private lastSeenLatestCompletedAt: string | undefined;
|
|
342
357
|
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
|
|
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 } = {
|
|
348
365
|
stickyCap: undefined,
|
|
349
366
|
stickyRegime: undefined,
|
|
350
367
|
stickyTerminalRows: undefined,
|
|
368
|
+
stickyReserve: undefined,
|
|
351
369
|
};
|
|
352
370
|
|
|
353
371
|
constructor(options: GoalWidgetOptions) {
|
|
@@ -436,12 +454,81 @@ export class GoalWidgetComponent implements Component {
|
|
|
436
454
|
// `terminal` render unbounded.
|
|
437
455
|
const terminalRows = (this.tui as unknown as { terminal?: { rows?: number } }).terminal?.rows;
|
|
438
456
|
// Render the current branch unbounded (natural), then apply the
|
|
439
|
-
// stable-height bound (spec 2026-08-11): the rendered height
|
|
440
|
-
//
|
|
441
|
-
//
|
|
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.
|
|
442
461
|
const natural = this.renderNatural(width);
|
|
443
462
|
const regime = this.stableHeightRegime();
|
|
444
|
-
|
|
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;
|
|
445
532
|
}
|
|
446
533
|
|
|
447
534
|
/** The current widget branch rendered unbounded (no terminal-height bound). */
|
|
@@ -483,10 +570,13 @@ export class GoalWidgetComponent implements Component {
|
|
|
483
570
|
}
|
|
484
571
|
|
|
485
572
|
/**
|
|
486
|
-
*
|
|
573
|
+
* Stable-height regime key: the latch belongs to one goal, goal status,
|
|
487
574
|
* widget state kind (focused / audit / result / unfocused / none),
|
|
488
|
-
* expansion mode, debug mode, and task
|
|
489
|
-
* so the new mode starts from its own natural height.
|
|
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.
|
|
490
580
|
*/
|
|
491
581
|
private stableHeightRegime(): string {
|
|
492
582
|
const settings = this.getSettings();
|
|
@@ -496,7 +586,8 @@ export class GoalWidgetComponent implements Component {
|
|
|
496
586
|
else if (this.getAuditorProgress()) stateKind = "audit";
|
|
497
587
|
else if (goal) stateKind = "focused";
|
|
498
588
|
else stateKind = this.getOpenGoalCount() > 0 ? "unfocused" : "none";
|
|
499
|
-
|
|
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("|");
|
|
500
591
|
}
|
|
501
592
|
|
|
502
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",
|