pi-better-harness 0.33.1 → 0.34.1
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/README.md +25 -9
- package/extensions/task-context/compact-task-header.ts +35 -0
- package/extensions/task-context/index.ts +55 -30
- package/extensions/task-context/plan-presentation.ts +6 -3
- package/extensions/task-context/plan-render.ts +3 -2
- package/extensions/task-context/plan-widget.ts +3 -6
- package/extensions/task-context/preferences.ts +24 -0
- package/extensions/task-context/workflow-plan.ts +3 -1
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +107 -10
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +6 -1
- package/node_modules/pi-better-goal/src/task-context.ts +12 -0
- package/node_modules/pi-better-subagents/README.md +6 -0
- package/node_modules/pi-better-subagents/catalog-ui.ts +89 -43
- package/node_modules/pi-better-subagents/index.ts +15 -13
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-navigator.ts +107 -10
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -13,15 +13,26 @@ Use `pi-better-harness` when you want the full working set for Pi. It manages:
|
|
|
13
13
|
- `pi-better-background-tasks` for durable shell tasks and watchers.
|
|
14
14
|
- `pi-better-ssh` for short remote commands over reusable SSH connections.
|
|
15
15
|
- `pi-better-goal` for explicit triggers, pause/resume, and background-aware continuation.
|
|
16
|
-
- Harness-owned Outcome & Todo for session context and checklist progress.
|
|
16
|
+
- Harness-owned Outcome & Todo for session context and checklist progress. Saved state displays by default; its hooks start off.
|
|
17
17
|
|
|
18
18
|
`pi-better-read-aloud` is intentionally not included yet.
|
|
19
19
|
|
|
20
20
|
## Outcome & Todo
|
|
21
21
|
|
|
22
|
-
In `/harness-settings`, **Outcome & Todo
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
In `/harness-settings`, **Outcome & Todo display** (`outcome-todo-display`)
|
|
23
|
+
defaults to **On**, and **Outcome & Todo hooks** (`outcome-todo`) defaults to
|
|
24
|
+
**Off**. Saved outcomes and checklists display even when hooks are off; missing
|
|
25
|
+
state creates no placeholder, state, or settings. Turning display off does not
|
|
26
|
+
disable hooks. Saved v1 `enabled` remains the hooks authority; the new optional
|
|
27
|
+
display preference defaults to true.
|
|
28
|
+
|
|
29
|
+
The compact widget has one Todo heading, with a caret after Todo and the completion
|
|
30
|
+
count on the left. A matching Goal's status and existing active-time clock appear
|
|
31
|
+
on the right. The outcome is one muted, truncated subtitle. The collapsed view
|
|
32
|
+
shows up to five relevant rows; the expanded view shows all rows. Click to toggle.
|
|
33
|
+
`/todo` shows the full objective, acceptance criteria, boundaries, and plan;
|
|
34
|
+
`/todo expand` and `/todo collapse` also control the compact checklist. Todo
|
|
35
|
+
commands remain available with hooks off for explicit user control.
|
|
25
36
|
|
|
26
37
|
An accepted outcome can be saved without a checklist. Completing or clearing its
|
|
27
38
|
Todo does not complete or remove the outcome. Write labels as short, plain actions,
|
|
@@ -32,15 +43,20 @@ existing saved labels remain unchanged until deliberately updated.
|
|
|
32
43
|
|
|
33
44
|
Use `newTask: true` with a new
|
|
34
45
|
outcome for unrelated work; a correction to the current task keeps its binding.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
Hooks enable `update_todo`, `get_todo`, capture, matching Goal lifecycle bridge
|
|
47
|
+
writes, reminders, and automated cleanup. While hooks are enabled and the outcome is active, Harness
|
|
48
|
+
includes a short reminder before each model call. Turning hooks off stops those
|
|
49
|
+
hooks without deleting saved state or starting a turn. `/goal` and skills never
|
|
50
|
+
enable them.
|
|
38
51
|
|
|
39
52
|
Existing Plan session histories restore as Todo. Workflow task-plan files and
|
|
40
53
|
revision checks stay unchanged; skills continue using their own records when the
|
|
41
54
|
optional interface is off. See the [usage guide](docs/usage.md#outcome--todo).
|
|
42
55
|
|
|
43
|
-
|
|
56
|
+
Subagent and background-task rows can be clicked to open their existing navigator
|
|
57
|
+
details. Enter and keyboard controls remain available, and editor input stays
|
|
58
|
+
passive. No permanent status bar is added; existing critical permission and
|
|
59
|
+
paused notices remain unchanged.
|
|
44
60
|
|
|
45
61
|
## Screenshots
|
|
46
62
|
|
|
@@ -71,7 +87,7 @@ pi install npm:pi-better-harness
|
|
|
71
87
|
After a normal bundle install or `pi update`, Harness masks any separately loaded
|
|
72
88
|
`pi-better-plan` package before Pi registers its tools, `/plan` command, widgets,
|
|
73
89
|
or prompt hooks. This applies in either load order, including when Outcome & Todo
|
|
74
|
-
|
|
90
|
+
hooks are off. The old registration and cached files may remain, but are inactive while
|
|
75
91
|
Harness is loaded. Settings and session history are not rewritten. The explicit
|
|
76
92
|
installer above removes the old registration in its selected scope instead.
|
|
77
93
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { PlanRenderTheme } from "./plan-presentation.js";
|
|
3
|
+
|
|
4
|
+
export interface CompactTaskHeader {
|
|
5
|
+
subtitle?: string;
|
|
6
|
+
disclosure?: string;
|
|
7
|
+
runtime?: { status: string; clock?: string };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function canRenderTaskRuntime(width: number, runtime: CompactTaskHeader["runtime"]): boolean {
|
|
11
|
+
if (!runtime || !Number.isFinite(width)) return false;
|
|
12
|
+
return visibleWidth(runtime.status + (runtime.clock ? ` ${runtime.clock}` : "")) + 6 <= Math.floor(width);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function renderCompactTaskHeader(summary: string | undefined, width: number, theme: PlanRenderTheme, header: CompactTaskHeader): string[] {
|
|
16
|
+
const size = Math.max(1, Math.floor(width));
|
|
17
|
+
const left = theme.fg("warning", "todo") + (header.disclosure ? theme.fg("accent", ` ${header.disclosure}`) : "") +
|
|
18
|
+
(summary ? theme.fg("dim", ` ${summary}`) : "");
|
|
19
|
+
const runtime = header.runtime;
|
|
20
|
+
const tone = runtime?.status === "complete" ? "success" : runtime?.status === "active" ? "accent" : "warning";
|
|
21
|
+
const right = runtime ? theme.fg(tone, runtime.status) + (runtime.clock ? theme.fg("dim", ` ${runtime.clock}`) : "") : "";
|
|
22
|
+
const rightWidth = visibleWidth(right);
|
|
23
|
+
const leftWidth = Math.max(4, size - rightWidth - 2);
|
|
24
|
+
const title = truncateToWidth(left, leftWidth);
|
|
25
|
+
const heading = right && canRenderTaskRuntime(size, runtime)
|
|
26
|
+
? title + " ".repeat(Math.max(2, size - visibleWidth(title) - rightWidth)) + right
|
|
27
|
+
: truncateToWidth(left, size);
|
|
28
|
+
const lines = [truncateToWidth(heading, size)];
|
|
29
|
+
if (header.subtitle) {
|
|
30
|
+
const indent = Math.min(2, size - 1);
|
|
31
|
+
const text = header.subtitle.replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " ").trim();
|
|
32
|
+
lines.push(" ".repeat(indent) + theme.fg("muted", truncateToWidth(text, size - indent)), "");
|
|
33
|
+
}
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
@@ -4,7 +4,11 @@ import { Text, matchesKey, type AutocompleteItem } from "@earendil-works/pi-tui"
|
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
import { readHarnessSetting, updateHarnessSetting } from "../shared-harness-settings.js";
|
|
6
6
|
import { reconstructGoalSnapshot } from "pi-better-goal/src/goal-state.ts";
|
|
7
|
+
import { formatClockDuration, goalTiming } from "pi-better-goal/src/goal-clock.ts";
|
|
7
8
|
import { OUTCOME_ENTRY, prepareOutcome, restoreOutcome, formatOutcome, type Outcome, type OutcomeInput } from "./outcome-state.js";
|
|
9
|
+
import { changeOutcomeTodoPreference, parseOutcomeTodoPreferences } from "./preferences.js";
|
|
10
|
+
import { canRenderTaskRuntime, renderCompactTaskHeader, type CompactTaskHeader } from "./compact-task-header.js";
|
|
11
|
+
import type { GoalSnapshot } from "pi-better-goal/src/types.ts";
|
|
8
12
|
|
|
9
13
|
import {
|
|
10
14
|
completedPlanClearDelay,
|
|
@@ -87,6 +91,7 @@ const UpdatePlanSchema = Type.Object({
|
|
|
87
91
|
type WorkflowPlanInput = Omit<RushPlanUpdate, "event"> & { path?: string; event?: string };
|
|
88
92
|
|
|
89
93
|
const LEGACY_PLAN_NAV_STATUS_KEY = "pi-better-plan-nav";
|
|
94
|
+
const goalHeaderRuntime = (goal: GoalSnapshot) => ({ status: goal.status, clock: formatClockDuration(goalTiming(goal).activeSeconds) });
|
|
90
95
|
const PLAN_ACTIONS: readonly AutocompleteItem[] = [
|
|
91
96
|
{ value: "clear", label: "clear", description: "Remove the current Todo without changing its outcome" },
|
|
92
97
|
{ value: "hide", label: "hide", description: "Hide the Todo widget" },
|
|
@@ -120,12 +125,9 @@ function workflowPlanOwner(ctx: ExtensionContext): string | null {
|
|
|
120
125
|
|
|
121
126
|
export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
122
127
|
let enabled = false;
|
|
128
|
+
let display = true;
|
|
129
|
+
let widgetContext: ExtensionContext | undefined;
|
|
123
130
|
let outcome: Outcome | null = null;
|
|
124
|
-
const readEnabled = (value: unknown): boolean => {
|
|
125
|
-
if (value === undefined) return false;
|
|
126
|
-
if (!value || typeof value !== "object" || Array.isArray(value) || (value as any).version !== 1 || typeof (value as any).enabled !== "boolean") throw new Error("Invalid outcomeTodo setting.");
|
|
127
|
-
return (value as { enabled: boolean }).enabled;
|
|
128
|
-
};
|
|
129
131
|
const syncTools = (): void => {
|
|
130
132
|
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
131
133
|
const active = pi.getActiveTools().filter(name => name !== "update_todo" && name !== "get_todo");
|
|
@@ -153,7 +155,7 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
153
155
|
};
|
|
154
156
|
const scheduleCancelledRowsClear = (): void => {
|
|
155
157
|
cancelCancelledRowsTimer();
|
|
156
|
-
if (!rushPlan) return;
|
|
158
|
+
if (!enabled || !rushPlan) return;
|
|
157
159
|
const delay = cancelledRowsClearDelay(rushPlan);
|
|
158
160
|
if (delay === null) return;
|
|
159
161
|
const timer = setTimeout(() => {
|
|
@@ -166,12 +168,13 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
166
168
|
timer.unref?.();
|
|
167
169
|
};
|
|
168
170
|
const observeCancellations = (plan: RushPlan, binding: WorkflowPlanBinding, ctx: ExtensionContext): void => {
|
|
171
|
+
if (!enabled) return;
|
|
169
172
|
observeCancelledRows(plan, binding, ctx.sessionManager.getBranch(), (type, data) => pi.appendEntry(type, data));
|
|
170
173
|
};
|
|
171
174
|
const unsubscribeWorkflow = pi.events.on("pi-better-workflow:changed", (owner) => {
|
|
172
175
|
displayedWorkflowOwner = owner && typeof owner === "object" && "name" in owner && typeof owner.name === "string"
|
|
173
176
|
? owner.name : null;
|
|
174
|
-
if (!enabled) return;
|
|
177
|
+
if (!enabled && !display) return;
|
|
175
178
|
if (displayedWorkflowOwner) {
|
|
176
179
|
cancelCancelledRowsTimer();
|
|
177
180
|
rushBinding = null;
|
|
@@ -283,7 +286,7 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
283
286
|
rushBinding = binding && (!displayedWorkflowOwner || binding.owner === displayedWorkflowOwner) ? binding : null;
|
|
284
287
|
rushPlan = null;
|
|
285
288
|
rushError = null;
|
|
286
|
-
if (enabled && rushBinding) loadRushPlan(ctx);
|
|
289
|
+
if ((enabled || display) && rushBinding) loadRushPlan(ctx);
|
|
287
290
|
displayMode = state.displayMode;
|
|
288
291
|
expanded = state.expanded;
|
|
289
292
|
if (ctx.hasUI) ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
|
|
@@ -313,24 +316,32 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
313
316
|
|
|
314
317
|
const installWidget = (ctx: ExtensionContext): void => {
|
|
315
318
|
if (!ctx.hasUI) return;
|
|
319
|
+
widgetContext = ctx;
|
|
316
320
|
ctx.ui.setWidget(
|
|
317
321
|
EXTENSION_NAME,
|
|
318
322
|
(tui, theme) => {
|
|
319
323
|
const localRefresh = (force = false): void => tui.requestRender(force);
|
|
320
324
|
refreshWidget = localRefresh;
|
|
321
325
|
const widget = createPlanWidget(
|
|
322
|
-
(width: number): string[] => {
|
|
323
|
-
if (!
|
|
324
|
-
const
|
|
326
|
+
(width: number, disclosure?: string): string[] => {
|
|
327
|
+
if (!display || displayMode === "hidden" || displayedWorkflowOwner !== workflowPlanOwner(ctx)) return [];
|
|
328
|
+
const goal = reconstructGoalSnapshot(ctx.sessionManager.getBranch());
|
|
329
|
+
const boundGoal = goal && outcome?.goalId === goal.goalId ? goal : null;
|
|
330
|
+
const header: CompactTaskHeader = {
|
|
331
|
+
subtitle: outcome?.objective, disclosure,
|
|
332
|
+
runtime: boundGoal ? goalHeaderRuntime(boundGoal)
|
|
333
|
+
: outcome ? { status: outcome.status } : undefined,
|
|
334
|
+
};
|
|
335
|
+
const heading = outcome ? renderCompactTaskHeader(undefined, width, theme as never, header) : [];
|
|
325
336
|
if (displayedWorkflowOwner || rushBinding) {
|
|
326
337
|
if (!rushBinding || (displayedWorkflowOwner && rushBinding.owner !== displayedWorkflowOwner) ||
|
|
327
338
|
!workflowBinding(ctx.sessionManager.getBranch())) return heading;
|
|
328
339
|
const fg = typeof theme?.fg === "function"
|
|
329
340
|
? (color: string, value: string) => theme.fg(color as never, value)
|
|
330
341
|
: undefined;
|
|
331
|
-
return
|
|
342
|
+
return rushPlan ? renderRushPlan(rushPlan, width, false, fg, rushBinding.owner, !displayedWorkflowOwner, expanded, header) : heading;
|
|
332
343
|
}
|
|
333
|
-
return
|
|
344
|
+
return currentPlan ? renderCompactPlan(currentPlan, width, theme as never, expanded, header) : heading;
|
|
334
345
|
},
|
|
335
346
|
() => (rushBinding && rushPlan ? visibleRushUnits(rushPlan).length : currentPlan?.steps.length ?? 0) > COMPACT_PLAN_ROWS,
|
|
336
347
|
() => expanded,
|
|
@@ -368,7 +379,7 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
368
379
|
? (color: string, value: string) => theme.fg(color as never, value)
|
|
369
380
|
: undefined;
|
|
370
381
|
const component = createRushPlanComponent(() => rushPlan ?? { ...plan, issues: [] }, () => done(), fg, planOwner, !owner);
|
|
371
|
-
return { render: (width) => [...(outcome ? new Text(formatOutcome(outcome
|
|
382
|
+
return { render: (width) => [...(outcome ? new Text(formatOutcome(outcome), 0, 0).render(width) : []), ...component.render(width)], handleInput(data) {
|
|
372
383
|
component.handleInput?.(data);
|
|
373
384
|
tui.requestRender();
|
|
374
385
|
}, invalidate: () => component.invalidate() };
|
|
@@ -396,7 +407,7 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
396
407
|
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
397
408
|
const component = createFullPlanComponent(snapshot, theme as never, () => done());
|
|
398
409
|
return {
|
|
399
|
-
render: (width) => [...(outcome ? new Text(formatOutcome(outcome
|
|
410
|
+
render: (width) => [...(outcome ? new Text(formatOutcome(outcome), 0, 0).render(width) : []), ...component.render(width)],
|
|
400
411
|
handleInput(data) {
|
|
401
412
|
component.handleInput?.(data);
|
|
402
413
|
tui.requestRender();
|
|
@@ -426,7 +437,7 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
426
437
|
],
|
|
427
438
|
parameters: UpdatePlanSchema,
|
|
428
439
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
429
|
-
if (!enabled) throw new Error("Outcome & Todo
|
|
440
|
+
if (!enabled) throw new Error("Outcome & Todo hooks are off. Enable them in /harness-settings.");
|
|
430
441
|
const owner = workflowPlanOwner(ctx);
|
|
431
442
|
const input = params as { explanation?: string; newTask?: boolean; todo?: PlanStepInput[]; outcome?: OutcomeInput; workflow?: WorkflowPlanInput };
|
|
432
443
|
if (!input.outcome && input.todo === undefined && !input.workflow) throw new Error("Send outcome, todo, or workflow.");
|
|
@@ -541,7 +552,7 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
541
552
|
promptSnippet: "Inspect the current outcome and Todo",
|
|
542
553
|
parameters: getTodoParameters,
|
|
543
554
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
544
|
-
if (!enabled) throw new Error("Outcome & Todo
|
|
555
|
+
if (!enabled) throw new Error("Outcome & Todo hooks are off. Enable them in /harness-settings.");
|
|
545
556
|
const owner = workflowPlanOwner(ctx);
|
|
546
557
|
const binding = workflowBinding(ctx.sessionManager.getBranch());
|
|
547
558
|
if (owner || binding) {
|
|
@@ -573,7 +584,6 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
573
584
|
description: "Inspect, show, hide, or clear the current Todo and inspect its outcome",
|
|
574
585
|
getArgumentCompletions: planArgumentCompletions,
|
|
575
586
|
handler: async (args, ctx) => {
|
|
576
|
-
if (!enabled) { ctx.ui.notify("Enable Outcome & Todo in /harness-settings.", "info"); return; }
|
|
577
587
|
const input = args.trim().toLowerCase();
|
|
578
588
|
if (!input) return showFullPlan(ctx);
|
|
579
589
|
if (input === "clear") {
|
|
@@ -608,25 +618,35 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
608
618
|
},
|
|
609
619
|
});
|
|
610
620
|
|
|
611
|
-
const setting = {
|
|
612
|
-
id:
|
|
613
|
-
|
|
621
|
+
const setting = (key: "enabled" | "display") => ({
|
|
622
|
+
id: key === "enabled" ? "outcome-todo" : "outcome-todo-display",
|
|
623
|
+
label: key === "enabled" ? "Outcome & Todo hooks" : "Outcome & Todo display", values: ["Off", "On"],
|
|
624
|
+
get: () => (key === "enabled" ? enabled : display) ? "On" : "Off",
|
|
614
625
|
async change(value: string, ctx: ExtensionContext) {
|
|
615
626
|
if (value !== "On" && value !== "Off") throw new Error("Invalid Outcome & Todo mode.");
|
|
616
|
-
updateHarnessSetting("outcomeTodo", current =>
|
|
617
|
-
enabled =
|
|
627
|
+
const preferences = updateHarnessSetting("outcomeTodo", current => changeOutcomeTodoPreference(current, key, value === "On"));
|
|
628
|
+
enabled = preferences.enabled;
|
|
629
|
+
display = preferences.display;
|
|
618
630
|
syncTools();
|
|
619
|
-
if (enabled) {
|
|
620
|
-
|
|
631
|
+
if (!enabled) { cancelCompletedPlanClear(); cancelCancelledRowsTimer(); }
|
|
632
|
+
installWidget(ctx);
|
|
633
|
+
restore(ctx);
|
|
621
634
|
},
|
|
622
|
-
};
|
|
623
|
-
const
|
|
635
|
+
});
|
|
636
|
+
const controls = [setting("display"), setting("enabled")];
|
|
637
|
+
const registerSettings = () => controls.forEach(control => pi.events.emit("harness-settings:register", control));
|
|
624
638
|
const unsubscribeSettings = pi.events.on("harness-settings:request", registerSettings);
|
|
625
639
|
registerSettings();
|
|
626
640
|
const unsubscribeTask = pi.events.on("pi-better-harness:task-context", (event: unknown) => {
|
|
627
641
|
if (!event || typeof event !== "object") return;
|
|
628
|
-
const request = event as { action: string; goalId?: string; objective?: string; enabled?: boolean };
|
|
642
|
+
const request = event as { action: string; goalId?: string; width?: number; objective?: string; enabled?: boolean; display?: boolean };
|
|
629
643
|
request.enabled = enabled;
|
|
644
|
+
if (request.action === "inspect") {
|
|
645
|
+
const goal = widgetContext ? reconstructGoalSnapshot(widgetContext.sessionManager.getBranch()) : null;
|
|
646
|
+
request.display = !!(typeof request.goalId === "string" && widgetContext && display && displayMode !== "hidden" &&
|
|
647
|
+
outcome && request.goalId === outcome.goalId && goal?.goalId === request.goalId && typeof request.width === "number" &&
|
|
648
|
+
canRenderTaskRuntime(request.width, goalHeaderRuntime(goal)) && displayedWorkflowOwner === workflowPlanOwner(widgetContext));
|
|
649
|
+
}
|
|
630
650
|
if (!enabled || request.action === "inspect") return;
|
|
631
651
|
if (request.action === "start" && request.goalId && request.objective?.trim()) {
|
|
632
652
|
if (outcome?.goalId === request.goalId) return;
|
|
@@ -642,9 +662,13 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
642
662
|
const onSessionStart = async (_event: unknown, ctx: ExtensionContext): Promise<void> => {
|
|
643
663
|
// Retire a separately installed pi-better-plan before installing our Todo widget.
|
|
644
664
|
await legacyPlanMask?.cleanup(ctx);
|
|
645
|
-
try {
|
|
665
|
+
try {
|
|
666
|
+
const preferences = parseOutcomeTodoPreferences(readHarnessSetting("outcomeTodo"));
|
|
667
|
+
enabled = preferences.enabled;
|
|
668
|
+
display = preferences.display;
|
|
669
|
+
} catch (error) { enabled = false; display = true; if (ctx.hasUI) ctx.ui.notify(String(error), "warning"); }
|
|
646
670
|
syncTools();
|
|
647
|
-
|
|
671
|
+
installWidget(ctx);
|
|
648
672
|
restore(ctx);
|
|
649
673
|
registerSettings();
|
|
650
674
|
};
|
|
@@ -669,6 +693,7 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
669
693
|
if (typeof unsubscribeSettings === "function") unsubscribeSettings();
|
|
670
694
|
if (typeof unsubscribeTask === "function") unsubscribeTask();
|
|
671
695
|
refreshWidget = undefined;
|
|
696
|
+
widgetContext = undefined;
|
|
672
697
|
try {
|
|
673
698
|
ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
|
|
674
699
|
ctx.ui.setWidget(EXTENSION_NAME, undefined);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Component } from "@earendil-works/pi-tui";
|
|
2
2
|
import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { renderCompactTaskHeader, type CompactTaskHeader } from "./compact-task-header.js";
|
|
3
4
|
|
|
4
5
|
export interface PlanRenderTheme {
|
|
5
6
|
fg(color: string, value: string): string;
|
|
@@ -64,6 +65,7 @@ export function planText(value: string): string {
|
|
|
64
65
|
export function renderPlanPresentation(
|
|
65
66
|
plan: PlanPresentation, width: number, theme: PlanRenderTheme,
|
|
66
67
|
full = false, selectedIndex = -1, expanded = false,
|
|
68
|
+
header?: CompactTaskHeader,
|
|
67
69
|
): string[] {
|
|
68
70
|
const size = Math.max(1, Math.floor(width));
|
|
69
71
|
const count = (status: DisplayStatus) => plan.rows.filter((row) => row.status === status).length;
|
|
@@ -77,13 +79,14 @@ export function renderPlanPresentation(
|
|
|
77
79
|
if (count(status)) summary.push(`${count(status)} ${label}`);
|
|
78
80
|
}
|
|
79
81
|
if (count("pending") && summary.length === 1) summary.push(`${count("pending")} pending`);
|
|
80
|
-
const lines =
|
|
82
|
+
const lines = !full && header ? renderCompactTaskHeader(summary[0], size, theme, header)
|
|
83
|
+
: [theme.fg("warning", "todo") + theme.fg("dim", ` ${summary.join(" · ")}`)];
|
|
81
84
|
for (const metadata of plan.metadata ?? []) {
|
|
82
85
|
const content = metadata.map((part) => theme.fg(part.color, planText(part.text))).join("");
|
|
83
86
|
for (const line of wrapTextWithAnsi(content, Math.max(1, size - 2))) lines.push(` ${line}`);
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
const labelWidth = Math.min(12, Math.max(
|
|
89
|
+
const labelWidth = Math.min(12, Math.max(1, ...plan.rows.map((row) => visibleWidth(planText(row.label)))));
|
|
87
90
|
const statusWidth = Math.min(24, Math.max(0, ...plan.rows.map((row) =>
|
|
88
91
|
visibleWidth(planText(row.statusLabel ?? statusStyle(row.status).label)))));
|
|
89
92
|
const indices = full || expanded ? plan.rows.map((_, index) => index) : compactRowIndices(plan.rows);
|
|
@@ -99,7 +102,7 @@ export function renderPlanPresentation(
|
|
|
99
102
|
const label = truncateToWidth(planText(row.label), labelWidth);
|
|
100
103
|
const prefix = (index === selectedIndex ? theme.fg("accent", "› ") : " ") +
|
|
101
104
|
theme.fg(style.color, style.glyph) + " " + theme.fg("dim", label) +
|
|
102
|
-
" ".repeat(Math.max(0, labelWidth - visibleWidth(label)) +
|
|
105
|
+
" ".repeat(Math.max(0, labelWidth - visibleWidth(label)) + 1);
|
|
103
106
|
const titleColor = row.status === "blocked" ? "warning" : row.status === "failed" ? "error" :
|
|
104
107
|
row.status === "in_progress" || row.status === "implemented" || row.status === "reviewed" ? "text" : "muted";
|
|
105
108
|
const badge = planText(row.statusLabel ?? style.label);
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type PlanPresentation, type PlanRenderTheme,
|
|
5
5
|
} from "./plan-presentation.js";
|
|
6
6
|
import type { PlanSnapshot } from "./types.js";
|
|
7
|
+
import type { CompactTaskHeader } from "./compact-task-header.js";
|
|
7
8
|
|
|
8
9
|
export type { PlanRenderTheme } from "./plan-presentation.js";
|
|
9
10
|
|
|
@@ -20,8 +21,8 @@ function presentPlan(plan: PlanSnapshot, showIds = false): PlanPresentation {
|
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
export function renderCompactPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme, expanded = false): string[] {
|
|
24
|
-
return renderPlanPresentation(presentPlan(plan), width, theme, false, -1, expanded);
|
|
24
|
+
export function renderCompactPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme, expanded = false, header?: CompactTaskHeader): string[] {
|
|
25
|
+
return renderPlanPresentation(presentPlan(plan), width, theme, false, -1, expanded, header);
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export function renderFullPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme, selectedIndex = -1): string[] {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Component } from "@earendil-works/pi-tui";
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
|
|
4
4
|
// Structural typing keeps keyboard-only Pi versions compatible with the fullscreen mouse API.
|
|
5
5
|
interface PlanMouseEvent {
|
|
@@ -14,7 +14,7 @@ interface PlanMouseEvent {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function createPlanWidget(
|
|
17
|
-
render: (width: number) => string[],
|
|
17
|
+
render: (width: number, disclosure?: string) => string[],
|
|
18
18
|
isFoldable: () => boolean,
|
|
19
19
|
isExpanded: () => boolean,
|
|
20
20
|
toggle: () => void,
|
|
@@ -22,10 +22,7 @@ export function createPlanWidget(
|
|
|
22
22
|
let lines: string[] = [];
|
|
23
23
|
return {
|
|
24
24
|
render(width) {
|
|
25
|
-
lines = render(width);
|
|
26
|
-
if (lines.length && isFoldable()) {
|
|
27
|
-
lines = [truncateToWidth(`${isExpanded() ? "▾" : "▸"} ${lines[0]}`, width), ...lines.slice(1)];
|
|
28
|
-
}
|
|
25
|
+
lines = render(width, isFoldable() ? isExpanded() ? "▾" : "▸" : undefined);
|
|
29
26
|
return lines.length ? ["", ...lines] : [];
|
|
30
27
|
},
|
|
31
28
|
handleMouse(event) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface OutcomeTodoPreferences {
|
|
2
|
+
version: 1;
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
display: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function parseOutcomeTodoPreferences(value: unknown): OutcomeTodoPreferences {
|
|
8
|
+
if (value === undefined) return { version: 1, enabled: false, display: true };
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid outcomeTodo setting.");
|
|
10
|
+
const record = value as Record<string, unknown>;
|
|
11
|
+
if (record.version !== 1) throw new Error("Invalid outcomeTodo setting.");
|
|
12
|
+
for (const key of ["enabled", "display"] as const) {
|
|
13
|
+
if (record[key] !== undefined && typeof record[key] !== "boolean") throw new Error(`Invalid outcomeTodo setting: ${key} must be boolean.`);
|
|
14
|
+
}
|
|
15
|
+
return { version: 1, enabled: record.enabled === undefined ? false : record.enabled as boolean,
|
|
16
|
+
display: record.display === undefined ? true : record.display as boolean };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function changeOutcomeTodoPreference(current: unknown, key: "enabled" | "display", value: boolean): OutcomeTodoPreferences {
|
|
20
|
+
const preferences = parseOutcomeTodoPreferences(current);
|
|
21
|
+
if (key !== "enabled" && key !== "display") throw new Error("Invalid outcomeTodo preference.");
|
|
22
|
+
if (typeof value !== "boolean") throw new Error("Invalid outcomeTodo preference value.");
|
|
23
|
+
return { ...preferences, [key]: value };
|
|
24
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
3
|
import type { Component } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { CompactTaskHeader } from "./compact-task-header.js";
|
|
4
5
|
import {
|
|
5
6
|
createPlanPresentationComponent, plainPlanTheme, planText, renderPlanPresentation, statusStyle,
|
|
6
7
|
type DisplayStatus, type PlanPresentation,
|
|
@@ -191,9 +192,10 @@ export function renderRushPlan(
|
|
|
191
192
|
owner = "rush-issues",
|
|
192
193
|
readOnly = false,
|
|
193
194
|
expanded = false,
|
|
195
|
+
header?: CompactTaskHeader,
|
|
194
196
|
): string[] {
|
|
195
197
|
const theme = fg ? { fg } : plainPlanTheme;
|
|
196
|
-
return renderPlanPresentation(presentRushPlan(plan, owner, readOnly), width, theme, full, -1, expanded);
|
|
198
|
+
return renderPlanPresentation(presentRushPlan(plan, owner, readOnly), width, theme, full, -1, expanded, header);
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
export function createRushPlanComponent(
|
|
@@ -328,28 +328,92 @@ function themeFgFromTheme(theme: unknown): (color: string, value: string) => str
|
|
|
328
328
|
return (color, value) => maybeTheme?.fg ? maybeTheme.fg(color, value) : value;
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
|
|
331
|
+
// Structural typing keeps keyboard-only Pi hosts (no mouse API) loading this widget unchanged.
|
|
332
|
+
type WidgetMouseEvent = {
|
|
333
|
+
type: string;
|
|
334
|
+
button: string;
|
|
335
|
+
/** Coordinates local to this component, zero-based. */
|
|
336
|
+
x: number;
|
|
337
|
+
y: number;
|
|
338
|
+
width: number;
|
|
339
|
+
shift?: boolean;
|
|
340
|
+
alt?: boolean;
|
|
341
|
+
ctrl?: boolean;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
type WidgetMouseResult = { handled: true; render: boolean };
|
|
345
|
+
|
|
346
|
+
type MainListWidget = Component & {
|
|
347
|
+
dispose?(): void;
|
|
348
|
+
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, deps: HostDeps): MainListWidget {
|
|
332
352
|
const requestRender = () => tui.requestRender?.();
|
|
333
353
|
state().mainListRequestRender = requestRender;
|
|
354
|
+
// navigatorId per rendered line, so a pointer row resolves by structure, never by row text.
|
|
355
|
+
let lineOwners: Array<string | undefined> = [];
|
|
356
|
+
let lineWidth = MAIN_LIST_FALLBACK_WIDTH;
|
|
357
|
+
// Pi synthesizes a click only for the component that handled the initial press.
|
|
358
|
+
let pressedRow: { navigatorId: string; y: number } | undefined;
|
|
359
|
+
const rowAt = (event: WidgetMouseEvent): string | undefined => {
|
|
360
|
+
const width = Number.isFinite(event.width) && event.width > 0 ? Math.floor(event.width) : lineWidth;
|
|
361
|
+
if (!Number.isFinite(event.x) || event.x < 0 || event.x >= width) return undefined;
|
|
362
|
+
if (!Number.isInteger(event.y) || event.y < 0 || event.y >= lineOwners.length) return undefined;
|
|
363
|
+
return lineOwners[event.y];
|
|
364
|
+
};
|
|
334
365
|
return {
|
|
335
366
|
render(width: number): string[] {
|
|
336
367
|
const rows = listRows();
|
|
337
368
|
syncMainListSelection(rows);
|
|
338
369
|
if (!rows.length) {
|
|
339
370
|
state().lastMainListLines = undefined;
|
|
371
|
+
lineOwners = [];
|
|
372
|
+
pressedRow = undefined;
|
|
340
373
|
return [];
|
|
341
374
|
}
|
|
342
|
-
const
|
|
375
|
+
const layout = buildMainListLayout(rows, renderWidth(width), deps.truncate, themeFgFromTheme(theme), {
|
|
343
376
|
selectedId: state().mainListFocused ? state().mainListSelectedId : undefined,
|
|
344
377
|
focused: state().mainListFocused === true,
|
|
345
|
-
})
|
|
378
|
+
});
|
|
379
|
+
lineWidth = renderWidth(width);
|
|
380
|
+
// The leading gap line belongs to the widget too, so its slot stays ownerless.
|
|
381
|
+
lineOwners = [undefined, ...layout.owners];
|
|
382
|
+
const lines = ["", ...layout.lines];
|
|
346
383
|
state().lastMainListLines = lines;
|
|
347
384
|
return lines;
|
|
348
385
|
},
|
|
349
|
-
|
|
386
|
+
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined {
|
|
387
|
+
// Only an unmodified left press is ours; every other gesture reaches the host untouched.
|
|
388
|
+
if (event.button !== "left" || event.shift === true || event.alt === true || event.ctrl === true) {
|
|
389
|
+
pressedRow = undefined;
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
if (event.type === "press") {
|
|
393
|
+
pressedRow = undefined;
|
|
394
|
+
const navigatorId = rowAt(event);
|
|
395
|
+
if (!navigatorId || !listRows().some((row) => row.navigatorId === navigatorId)) return undefined;
|
|
396
|
+
pressedRow = { navigatorId, y: event.y };
|
|
397
|
+
return { handled: true, render: false };
|
|
398
|
+
}
|
|
399
|
+
if (event.type !== "click") return undefined;
|
|
400
|
+
const pressed = pressedRow;
|
|
401
|
+
pressedRow = undefined;
|
|
402
|
+
// A click after another component's press, or after the layout moved, is not the row we claimed.
|
|
403
|
+
if (!pressed || pressed.y !== event.y || rowAt(event) !== pressed.navigatorId) return undefined;
|
|
404
|
+
if (!openNavigatorRow(pressed.navigatorId)) return undefined;
|
|
405
|
+
return { handled: true, render: true };
|
|
406
|
+
},
|
|
407
|
+
invalidate() {
|
|
408
|
+
state().lastMainListLines = undefined;
|
|
409
|
+
lineOwners = [];
|
|
410
|
+
pressedRow = undefined;
|
|
411
|
+
},
|
|
350
412
|
dispose() {
|
|
351
413
|
const s = state();
|
|
352
414
|
if (s.mainListRequestRender === requestRender) s.mainListRequestRender = undefined;
|
|
415
|
+
lineOwners = [];
|
|
416
|
+
pressedRow = undefined;
|
|
353
417
|
},
|
|
354
418
|
};
|
|
355
419
|
}
|
|
@@ -454,7 +518,26 @@ function buildMainListLines(
|
|
|
454
518
|
fg: (color: string, value: string) => string,
|
|
455
519
|
options: { selectedId?: string; focused?: boolean } = {},
|
|
456
520
|
): string[] {
|
|
521
|
+
return buildMainListLayout(rows, width, truncate, fg, options).lines;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The list's lines plus the navigatorId each line shows, so pointer input maps a rendered row to its
|
|
526
|
+
* stable row identity. Headers, separators and the shortcut line keep an undefined owner.
|
|
527
|
+
*/
|
|
528
|
+
function buildMainListLayout(
|
|
529
|
+
rows: InternalRow[],
|
|
530
|
+
width: number,
|
|
531
|
+
truncate: (s: string, width: number) => string,
|
|
532
|
+
fg: (color: string, value: string) => string,
|
|
533
|
+
options: { selectedId?: string; focused?: boolean } = {},
|
|
534
|
+
): { lines: string[]; owners: Array<string | undefined> } {
|
|
457
535
|
const lines: string[] = [];
|
|
536
|
+
const owners: Array<string | undefined> = [];
|
|
537
|
+
const push = (line: string, owner?: string) => {
|
|
538
|
+
lines.push(line);
|
|
539
|
+
owners.push(owner);
|
|
540
|
+
};
|
|
458
541
|
const grouped = new Map<string, InternalRow[]>();
|
|
459
542
|
for (const row of rows) {
|
|
460
543
|
const existing = grouped.get(row.providerLabel) ?? [];
|
|
@@ -466,16 +549,16 @@ function buildMainListLines(
|
|
|
466
549
|
for (let i = 0; i < orderedLabels.length; i += 1) {
|
|
467
550
|
const label = orderedLabels[i]!;
|
|
468
551
|
const group = grouped.get(label)!;
|
|
469
|
-
if (i > 0)
|
|
470
|
-
|
|
552
|
+
if (i > 0) push("");
|
|
553
|
+
push(providerGroupLabel(label, fg));
|
|
471
554
|
for (const row of group) {
|
|
472
555
|
const selected = options.focused && row.navigatorId === options.selectedId;
|
|
473
|
-
|
|
556
|
+
push(formatMainListRow(row, selected === true, fg, width), row.navigatorId);
|
|
474
557
|
}
|
|
475
558
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
559
|
+
push("");
|
|
560
|
+
push(shortcutsLine(options.focused === true, fg));
|
|
561
|
+
return { lines: lines.map((line) => safeTruncate(line, width, truncate)), owners };
|
|
479
562
|
}
|
|
480
563
|
|
|
481
564
|
function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
|
|
@@ -766,6 +849,20 @@ function handleMainListCloseKey(): void {
|
|
|
766
849
|
refreshMainListWidget();
|
|
767
850
|
}
|
|
768
851
|
|
|
852
|
+
/**
|
|
853
|
+
* Open the shared detail overlay on the row a pointer chose. Setting the selection first keeps the
|
|
854
|
+
* keyboard path identical: Enter, x and the overlay's own keys then act on the clicked row.
|
|
855
|
+
*/
|
|
856
|
+
function openNavigatorRow(navigatorId: string): boolean {
|
|
857
|
+
const rows = listRows();
|
|
858
|
+
if (!rows.some((row) => row.navigatorId === navigatorId)) return false;
|
|
859
|
+
clearMainListCloseArm();
|
|
860
|
+
state().mainListSelectedId = navigatorId;
|
|
861
|
+
refreshMainListWidget();
|
|
862
|
+
openNavigator();
|
|
863
|
+
return true;
|
|
864
|
+
}
|
|
865
|
+
|
|
769
866
|
function openNavigator(): void {
|
|
770
867
|
const s = state();
|
|
771
868
|
const ctx = s.uiCtx;
|
|
@@ -882,10 +882,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
882
882
|
}
|
|
883
883
|
const nextRenderDelay = goalClockRefreshDelayMs(goal);
|
|
884
884
|
if (nextRenderDelay !== null) scheduler.schedule(nextRenderDelay);
|
|
885
|
+
// Schedule before handing visual ownership to Harness so its header
|
|
886
|
+
// keeps ticking. An absent display response preserves standalone Goal.
|
|
887
|
+
if (requestTaskContext(pi, "inspect", goal, width).display === true) {
|
|
888
|
+
return [];
|
|
889
|
+
}
|
|
885
890
|
const fg = typeof theme?.fg === "function"
|
|
886
891
|
? (color: string, value: string) => theme.fg(color as never, value)
|
|
887
892
|
: undefined;
|
|
888
|
-
return [renderGoalClockLine(goal, width, undefined, fg)
|
|
893
|
+
return [renderGoalClockLine(goal, width, undefined, fg)];
|
|
889
894
|
},
|
|
890
895
|
};
|
|
891
896
|
},
|
|
@@ -14,9 +14,19 @@ export type TaskContextAction = "inspect" | "start" | "pause" | "resume" | "comp
|
|
|
14
14
|
export interface TaskContextRequest {
|
|
15
15
|
action: TaskContextAction;
|
|
16
16
|
goalId?: string;
|
|
17
|
+
/** Current widget width on a visual ownership inspection. */
|
|
18
|
+
width?: number;
|
|
17
19
|
/** The raw original goal trigger; only start requests carry it. */
|
|
18
20
|
objective?: string;
|
|
21
|
+
/** Hooks authority: a present Harness runtime sets this even when Outcome & Todo is off. */
|
|
19
22
|
enabled?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Visual ownership. A Harness runtime sets this on `inspect` responses when
|
|
25
|
+
* its combined Todo header is rendering the requested `goalId`. It is
|
|
26
|
+
* independent of `enabled` and stays undefined without Harness, so Goal keeps
|
|
27
|
+
* its standalone clock widget whenever that header does not own the goal.
|
|
28
|
+
*/
|
|
29
|
+
display?: boolean;
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
/**
|
|
@@ -27,8 +37,10 @@ export function requestTaskContext(
|
|
|
27
37
|
pi: Pick<ExtensionAPI, "events">,
|
|
28
38
|
action: TaskContextAction,
|
|
29
39
|
goal?: GoalSnapshot | null,
|
|
40
|
+
width?: number,
|
|
30
41
|
): TaskContextRequest {
|
|
31
42
|
const request: TaskContextRequest = { action };
|
|
43
|
+
if (width !== undefined) request.width = width;
|
|
32
44
|
if (goal) {
|
|
33
45
|
request.goalId = goal.goalId;
|
|
34
46
|
if (action === "start") request.objective = goal.objective;
|
|
@@ -19,6 +19,12 @@ Use `pi-better-subagents` when you want Pi to launch independent agent work with
|
|
|
19
19
|
- Live background-work navigator for active runs.
|
|
20
20
|
- Harness-owned run timing: every run gets a no-progress wake (10 min), while wall-clock soft deadlines and hard ceilings are opt-in so productive agents are not stopped solely for taking a long time. Configure per spawn with `deadline_minutes`, `grace_minutes`, `max_minutes`, `stuck_minutes`, or globally in `config.json` / `PI_SUBAGENT_*_MINUTES`. See [usage notes](docs/usage.md#run-timing-deadline-ceiling-stuck).
|
|
21
21
|
|
|
22
|
+
Every single or batch launch automatically joins one shared supervision watcher in the
|
|
23
|
+
foreground Pi process. The last active run's exit or stop tears it down; a later
|
|
24
|
+
launch starts it again. No separate background task or coordinator heartbeat is
|
|
25
|
+
needed. Completion delivery retries use the callback batcher's own timer. Lost-run
|
|
26
|
+
alerts retain supervision until their handoff is handled, so lost work is not silent.
|
|
27
|
+
|
|
22
28
|
## Install
|
|
23
29
|
|
|
24
30
|
```sh
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Input, SelectList, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import type {
|
|
1
|
+
import { Input, SelectList, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { OperationList, OperationView } from "./agent-inspection.ts";
|
|
3
3
|
import { EFFORT_LEVELS } from "./catalog-schema.ts";
|
|
4
4
|
import type { AgentSessionOverrides, AgentSettingKey } from "./agent-session-settings.ts";
|
|
5
5
|
|
|
@@ -19,9 +19,6 @@ function stateLabel(entry: OperationView): string {
|
|
|
19
19
|
return "unresolved";
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function fieldLine(label: string, field: OperationField): string {
|
|
23
|
-
return `${label.padEnd(8)} ${field.value ?? "(unset)"} ${field.source === "role-default" ? "inherited" : field.source === "agent-override" ? "override" : "absent"}`;
|
|
24
|
-
}
|
|
25
22
|
|
|
26
23
|
function modelLabel(entry: OperationView): string {
|
|
27
24
|
return entry.actualModel ?? entry.requestedModel ?? entry.fields?.model.value ?? "not resolved";
|
|
@@ -49,6 +46,7 @@ export function catalogColumns(entry: OperationView | null, width: number): stri
|
|
|
49
46
|
}
|
|
50
47
|
|
|
51
48
|
function wrapLine(text: string, width: number): string[] {
|
|
49
|
+
if (text.includes("\x1b")) return wrapTextWithAnsi(text, width);
|
|
52
50
|
const rows: string[] = [];
|
|
53
51
|
let row = "";
|
|
54
52
|
for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
|
|
@@ -62,31 +60,67 @@ function wrapLine(text: string, width: number): string[] {
|
|
|
62
60
|
return rows;
|
|
63
61
|
}
|
|
64
62
|
|
|
65
|
-
|
|
63
|
+
function settingLines(label: string, value: string, origin: string, width: number, marker = " ", theme?: Theme): string[] {
|
|
64
|
+
const title = (text: string) => theme?.fg(marker === "> " ? "accent" : "muted", text) ?? text;
|
|
65
|
+
const source = (text: string) => theme?.fg("dim", text) ?? text;
|
|
66
|
+
if (width < 54) return [title(`${marker}${label}`), ...wrapLine(`${value} ${source(origin)}`, Math.max(1, width - 4)).map((row) => ` ${row}`)];
|
|
67
|
+
const valueWidth = Math.max(1, width - 34);
|
|
68
|
+
return wrapLine(value, valueWidth).map((row, index) =>
|
|
69
|
+
`${index === 0 ? title(marker + label.padEnd(10)) : " ".repeat(12)}${cell(row, valueWidth)} ${index === 0 ? source(origin) : ""}`.trimEnd());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function catalogDetailLines(entry: OperationView, editable = false, width = 80, controls: string[] = [], theme?: Theme): string[] {
|
|
73
|
+
const label = (text: string) => theme?.fg("muted", text) ?? text;
|
|
74
|
+
const heading = (text: string) => label(theme?.bold?.(text) ?? text);
|
|
75
|
+
const contentWidth = Math.max(1, width - 4);
|
|
76
|
+
const columnWidth = width >= 90 ? Math.floor((contentWidth - 4) / 2) : contentWidth;
|
|
77
|
+
const fact = (title: string, value: string) => wrapLine(value, Math.max(1, columnWidth - 13))
|
|
78
|
+
.map((text, index) => `${index === 0 ? label(title.padEnd(13)) : " ".repeat(13)}${text}`);
|
|
79
|
+
const identity = [
|
|
80
|
+
...fact("ID", entry.id),
|
|
81
|
+
...fact("Kind", entry.identity.kind ?? "unknown"),
|
|
82
|
+
...(entry.role ? fact("Base role", `${entry.role.id ?? "(missing)"}${entry.role.found ? "" : " (missing)"}`) : []),
|
|
83
|
+
];
|
|
84
|
+
const state = [
|
|
85
|
+
...fact("Status", stateLabel(entry)),
|
|
86
|
+
...fact("Scope", entry.identity.scope ?? "unknown"),
|
|
87
|
+
...(entry.instructionMode ? fact("Instructions", entry.instructionMode) : []),
|
|
88
|
+
];
|
|
89
|
+
const overview = width >= 90
|
|
90
|
+
? Array.from({ length: Math.max(identity.length, state.length) }, (_, i) =>
|
|
91
|
+
`${cell(identity[i] ?? "", columnWidth)} ${state[i] ?? ""}`.trimEnd())
|
|
92
|
+
: [...identity, ...state];
|
|
66
93
|
const lines = [
|
|
67
|
-
`${entry.identity.name || entry.id} · ${entry.identity.kind ?? "unknown"}`,
|
|
68
|
-
entry.
|
|
94
|
+
theme?.fg("accent", `${entry.identity.name || entry.id} · ${entry.identity.kind ?? "unknown"}`) ?? `${entry.identity.name || entry.id} · ${entry.identity.kind ?? "unknown"}`,
|
|
95
|
+
...(entry.identity.description ? wrapLine(entry.identity.description, contentWidth) : []),
|
|
69
96
|
"",
|
|
70
|
-
|
|
71
|
-
`Source ${entry.identity.scope ?? "unknown"} · ${entry.identity.path ?? "(none)"}`,
|
|
72
|
-
...(entry.identity.description ? [`About ${entry.identity.description}`] : []),
|
|
73
|
-
...(entry.role ? [`Base role ${entry.role.id ?? "(missing)"}${entry.role.found ? "" : " · missing"}`] : []),
|
|
74
|
-
...(entry.instructionMode ? [`Instructions ${entry.instructionMode}`] : []),
|
|
97
|
+
...overview,
|
|
75
98
|
"",
|
|
76
|
-
"Configuration",
|
|
99
|
+
heading("Configuration"),
|
|
100
|
+
...(contentWidth >= 54 ? [label(settingLines("SETTING", "VALUE", "ORIGIN", contentWidth)[0])] : []),
|
|
101
|
+
...controls,
|
|
77
102
|
...(entry.fields ? [
|
|
78
|
-
...(!editable ? [
|
|
79
|
-
|
|
103
|
+
...(!editable ? ["model", "effort"].flatMap((key) => {
|
|
104
|
+
const field = entry.fields![key as "model" | "effort"];
|
|
105
|
+
return settingLines(key === "model" ? "Model" : "Effort", field.value ?? "(unset)", field.inherited || field.source === "role-default" ? "inherited" : field.source === "agent-override" ? "override" : "absent", contentWidth, " ", theme);
|
|
106
|
+
}) : []),
|
|
107
|
+
...settingLines("Tier", entry.fields.tier.value ?? "(unset)", entry.fields.tier.source === "role-default" ? "inherited" : entry.fields.tier.source === "agent-override" ? "override" : "absent", contentWidth, " ", theme),
|
|
80
108
|
] : []),
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
...(entry.
|
|
109
|
+
`${label("Actual")} ${entry.actualModel ?? "not resolved"}${entry.actualEffort ? ` · ${entry.actualEffort}` : ""}`,
|
|
110
|
+
"",
|
|
111
|
+
heading("Effective prompt"),
|
|
112
|
+
...(entry.instructions?.trim() ? entry.instructions.split(/\r\n|\r|\n/) : ["(No resolved instructions)"]),
|
|
85
113
|
"",
|
|
86
|
-
"
|
|
87
|
-
|
|
114
|
+
heading("Launch resolution"),
|
|
115
|
+
entry.launchabilityReason,
|
|
116
|
+
"",
|
|
117
|
+
heading("Definition source"),
|
|
118
|
+
entry.identity.path ?? "(none)",
|
|
119
|
+
...(entry.validation.length ? ["", heading("Diagnostics"), ...entry.validation.map((item) => `${item.blocking ? "!" : "·"} ${item.message}`)] : []),
|
|
120
|
+
...(entry.shadowed.length ? ["", heading("Shadowed sources"), ...entry.shadowed.map((source) => `${source.scope} ${source.path}`)] : []),
|
|
121
|
+
...(entry.restrictions.length ? ["", heading("Execution restrictions"), ...entry.restrictions.map((item) => `${item.honored ? "honored" : "not honored"} ${item.name}`)] : []),
|
|
88
122
|
];
|
|
89
|
-
return lines;
|
|
123
|
+
return lines.flatMap((text) => text ? wrapLine(text, contentWidth).map((row) => ` ${row}`) : [""]);
|
|
90
124
|
}
|
|
91
125
|
|
|
92
126
|
export function createCatalogComponent(
|
|
@@ -103,6 +137,8 @@ export function createCatalogComponent(
|
|
|
103
137
|
let query = "";
|
|
104
138
|
let scroll = 0;
|
|
105
139
|
let settingRow = 0;
|
|
140
|
+
let settingPositions: number[] = [];
|
|
141
|
+
let revealSetting = false;
|
|
106
142
|
let picker: SelectList | undefined;
|
|
107
143
|
let filterPicker: ((filter: string) => void) | undefined;
|
|
108
144
|
let pickerKey: AgentSettingKey | undefined;
|
|
@@ -159,11 +195,11 @@ export function createCatalogComponent(
|
|
|
159
195
|
[entry.id, entry.identity.name, entry.identity.description, entry.role?.id]
|
|
160
196
|
.some((value) => value?.toLowerCase().includes(query.toLowerCase())));
|
|
161
197
|
const selected = () => filtered().find((entry) => entry.id === selectedId);
|
|
162
|
-
const detailRows = (entry: OperationView, width: number) => (mode === "prompt"
|
|
198
|
+
const detailRows = (entry: OperationView, width: number, controls: string[] = []) => (mode === "prompt"
|
|
163
199
|
? [`${entry.identity.name || entry.id} · effective instructions`, "", ...(entry.instructions?.split("\n") ?? ["(none)"])]
|
|
164
|
-
: catalogDetailLines(entry, !!editor))
|
|
200
|
+
: catalogDetailLines(entry, !!editor, width, controls, theme))
|
|
165
201
|
.flatMap((text) => text ? wrapLine(text, Math.max(1, width - 2)) : [""]);
|
|
166
|
-
|
|
202
|
+
|
|
167
203
|
|
|
168
204
|
return {
|
|
169
205
|
get focused() { return focused; },
|
|
@@ -174,21 +210,20 @@ export function createCatalogComponent(
|
|
|
174
210
|
const footerRows = (editor && mode !== "prompt" ? 2 : 0) + (message ? 2 : 0);
|
|
175
211
|
const diagnosticRows = Math.min(catalog.diagnostics.length, 3);
|
|
176
212
|
const listSize = Math.max(1, height - 5 - footerRows - diagnosticRows - 1);
|
|
177
|
-
const
|
|
178
|
-
const detailSize = Math.max(1, height - 5 - footerRows - controlRows);
|
|
213
|
+
const detailSize = Math.max(1, height - 5 - footerRows);
|
|
179
214
|
const fit = (lines: string[]) => {
|
|
180
215
|
const result = lines.slice(0, height).map(line);
|
|
181
216
|
while (result.length < height) result.push("");
|
|
182
217
|
return result;
|
|
183
218
|
};
|
|
184
|
-
|
|
219
|
+
|
|
185
220
|
const line = (text: string) => truncateToWidth(text, safeWidth);
|
|
186
221
|
const rows = filtered();
|
|
187
222
|
const current = selected();
|
|
188
|
-
|
|
223
|
+
|
|
189
224
|
const selectedIndex = rows.findIndex((entry) => entry.id === selectedId);
|
|
190
225
|
const start = Math.max(0, Math.min(selectedIndex - Math.floor(listSize / 2), rows.length - listSize));
|
|
191
|
-
|
|
226
|
+
|
|
192
227
|
if (picker) return fit([
|
|
193
228
|
theme.fg("accent", `Agents / ${pickerKey}`), "",
|
|
194
229
|
...(pickerKey === "model" ? search.render(safeWidth) : []),
|
|
@@ -203,23 +238,29 @@ export function createCatalogComponent(
|
|
|
203
238
|
const ownSession = Object.hasOwn(own ?? {}, key);
|
|
204
239
|
const roleSession = field?.inherited && Object.hasOwn(editor?.settings()[current.role?.id ?? ""] ?? {}, key);
|
|
205
240
|
const origin = ownSession && own?.[key] === null ? "inherit / session" : ownSession ? "session" : roleSession ? "inherited / session" : field?.inherited ? "inherited" : "catalog";
|
|
206
|
-
|
|
207
|
-
const text = `${settingRow === index ? "> " : " "}${label.padEnd(18)} ${value}`;
|
|
208
|
-
if (safeWidth < 42) {
|
|
209
|
-
const title = `${settingRow === index ? "> " : " "}${label}`;
|
|
210
|
-
return [settingRow === index ? highlight(title, safeWidth) : title, ` ${truncateToWidth(value, Math.max(1, safeWidth - 2))}`];
|
|
211
|
-
}
|
|
212
|
-
return [settingRow === index ? highlight(text, safeWidth) : text];
|
|
241
|
+
return settingLines(label, field?.value ?? "(unset)", origin, Math.max(1, safeWidth - 4), settingRow === index ? "> " : " ", theme);
|
|
213
242
|
}),
|
|
214
|
-
"",
|
|
215
243
|
] : [];
|
|
244
|
+
const detailLines = current ? detailRows(current, safeWidth, controls) : [];
|
|
245
|
+
if (editor && mode === "detail") {
|
|
246
|
+
settingPositions = ["Model", "Effort"].map((label) => detailLines.findIndex((text) =>
|
|
247
|
+
new RegExp(`^ [> ] ${label}(?:\\s|$)`).test(text.replace(/\x1b\[[0-9;]*m/g, ""))));
|
|
248
|
+
if (revealSetting && settingPositions[settingRow] >= 0) {
|
|
249
|
+
const position = settingPositions[settingRow];
|
|
250
|
+
if (position < scroll) scroll = position;
|
|
251
|
+
else if (position >= scroll + detailSize) scroll = position - detailSize + 1;
|
|
252
|
+
}
|
|
253
|
+
revealSetting = false;
|
|
254
|
+
}
|
|
255
|
+
scroll = Math.max(0, Math.min(scroll, detailLines.length - detailSize));
|
|
216
256
|
const lines = mode !== "list" && current ? [
|
|
217
257
|
theme.fg("accent", mode === "prompt" ? "Agents / prompt" : editor ? `Agents / ${current.identity.name || current.id}` : "Agents / inspect"),
|
|
218
258
|
theme.fg("dim", mode === "prompt" ? "← details · ↑↓ scroll · Esc back" : `${editor ? "Enter change · " : ""}p prompt · ← list · ↑↓ ${editor ? "select / scroll" : "scroll"} · Esc back`),
|
|
219
259
|
"",
|
|
220
|
-
|
|
260
|
+
|
|
221
261
|
...detailLines.slice(scroll, scroll + detailSize).map((text, i) =>
|
|
222
|
-
i === 0 && scroll === 0 ? theme.fg("accent", text)
|
|
262
|
+
i === 0 && scroll === 0 ? theme.fg("accent", text)
|
|
263
|
+
: text),
|
|
223
264
|
"",
|
|
224
265
|
theme.fg("dim", `Showing ${scroll + 1}-${Math.min(scroll + detailSize, detailLines.length)} of ${detailLines.length} lines`),
|
|
225
266
|
] : [
|
|
@@ -261,8 +302,12 @@ export function createCatalogComponent(
|
|
|
261
302
|
else { close(); return; }
|
|
262
303
|
} else if (matchesKey(data, "up") || matchesKey(data, "down")) {
|
|
263
304
|
const delta = matchesKey(data, "down") ? 1 : -1;
|
|
264
|
-
if (mode === "detail" && editor && scroll
|
|
265
|
-
|
|
305
|
+
if (mode === "detail" && editor && scroll <= (settingPositions[1] ?? 0)
|
|
306
|
+
&& ((delta > 0 && settingRow === 0) || (delta < 0 && settingRow === 1))) {
|
|
307
|
+
settingRow += delta;
|
|
308
|
+
revealSetting = true;
|
|
309
|
+
}
|
|
310
|
+
else if (mode !== "list" && selected()) scroll = Math.max(0, scroll + delta);
|
|
266
311
|
else if (rows.length) selectedId = rows[Math.max(0, Math.min(rows.length - 1, rows.findIndex((entry) => entry.id === selectedId) + delta))]?.id;
|
|
267
312
|
} else if (matchesKey(data, "enter") && mode === "list" && selected()) {
|
|
268
313
|
mode = "detail";
|
|
@@ -274,6 +319,7 @@ export function createCatalogComponent(
|
|
|
274
319
|
} else if (editor && (data === "e" || data === "E") && mode === "detail" && selected()) {
|
|
275
320
|
settingRow = 0;
|
|
276
321
|
scroll = 0;
|
|
322
|
+
revealSetting = true;
|
|
277
323
|
} else if (editor && mode === "detail" && (matchesKey(data, "enter") || data === " ")) {
|
|
278
324
|
try { edit(); } catch (cause) { report(cause instanceof Error ? cause.message : String(cause), true); }
|
|
279
325
|
} else if (mode === "list" && matchesKey(data, "backspace")) {
|
|
@@ -879,7 +879,7 @@ function reconcileHealth(): void {
|
|
|
879
879
|
if (meta && meta.completionCallbackPendingAt !== undefined && meta.completionCallbackSentAt === undefined && meta.completionCallbackSuppressedAt === undefined) enqueueCompletionCallback(pi!, meta.id);
|
|
880
880
|
}
|
|
881
881
|
pruneProgressCache();
|
|
882
|
-
|
|
882
|
+
syncHealthTicker();
|
|
883
883
|
}
|
|
884
884
|
|
|
885
885
|
/** Release progress state for runs that are no longer live (lost, stopped, or finished by any path). */
|
|
@@ -948,10 +948,10 @@ function reconcileAbandonedRuns(now: number = Date.now()): number {
|
|
|
948
948
|
return adopted;
|
|
949
949
|
}
|
|
950
950
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
951
|
+
/** Completion batches own their retry timer; only supervision keeps this one alive. */
|
|
952
|
+
function syncHealthTicker(): void {
|
|
953
|
+
if (needsMonitoring(listMetasForParent(process.pid))) ensureHealthTicker();
|
|
954
|
+
else stopHealthTicker();
|
|
955
955
|
}
|
|
956
956
|
|
|
957
957
|
/** Start the reconciliation loop if it isn't already running. */
|
|
@@ -1099,6 +1099,7 @@ function navigatorCloseRun(id: string) {
|
|
|
1099
1099
|
healthLogCache.delete(id);
|
|
1100
1100
|
resetChildEventLogCursor(id);
|
|
1101
1101
|
resetParseRunCursor(id);
|
|
1102
|
+
syncHealthTicker();
|
|
1102
1103
|
return outcome;
|
|
1103
1104
|
}
|
|
1104
1105
|
|
|
@@ -1451,14 +1452,14 @@ function finalizeRun(pi: ExtensionAPI, ctx: ExtensionContext, id: string, code:
|
|
|
1451
1452
|
// Host-facing wrapper around first-party finalizer (finalization.ts).
|
|
1452
1453
|
// Coherent child-exit evidence may supersede provisional orphaned/lost
|
|
1453
1454
|
// reconciliation; finalization.ts enforces canExitFinalize + lifecycle authority.
|
|
1454
|
-
|
|
1455
|
+
finalizeRunCore(id, code, {
|
|
1455
1456
|
renderWidget,
|
|
1456
1457
|
notify: (message, level) => {
|
|
1457
1458
|
try { ctx.ui.notify(message, level); } catch { /* ignore */ }
|
|
1458
1459
|
},
|
|
1459
1460
|
sendMessage: () => enqueueCompletionCallback(pi, id),
|
|
1460
1461
|
});
|
|
1461
|
-
|
|
1462
|
+
syncHealthTicker();
|
|
1462
1463
|
}
|
|
1463
1464
|
|
|
1464
1465
|
function timingSchemaFields() {
|
|
@@ -1938,7 +1939,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1938
1939
|
"The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. Without an agent or role, pick a model with the model param (e.g. 'xai/grok-4.5@high'); providerless model patterns are resolved by Pi, while provider/model is deterministic and loads mapped provider extensions.",
|
|
1939
1940
|
...CATALOG_GUIDELINES,
|
|
1940
1941
|
"Subagent sandboxing defaults to off. Human settings in /sandbox control file, credential-file, command, and network permissions; sandbox:false cannot override an enabled human profile. Set sandbox:true to request kernel confinement, including without published settings. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
|
|
1941
|
-
"Every
|
|
1942
|
+
"Every launch automatically joins one shared harness-owned watcher, including batch launches; it stops when the last active run ends. Do not start a separate heartbeat or background watcher for subagents. No progress for stuck_minutes (default 10) wakes you once without stopping the run. Elapsed-time limits are opt-in: deadline_minutes steers the child to wrap up and later stops it after grace_minutes, while max_minutes stops it without grace. Do not set elapsed-time limits merely because a task may take a while; use them only when the work needs a real wall-clock bound.",
|
|
1942
1943
|
"Use git_clone_workspace:true when the subagent will mutate Git in a sandbox. The parent prepares a disposable, self-contained clone with a real .git/ directory inside the sandbox root, so linked-worktree metadata outside the sandbox cannot stall the child.",
|
|
1943
1944
|
],
|
|
1944
1945
|
parameters: Type.Object({
|
|
@@ -2263,7 +2264,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2263
2264
|
const acceptanceResultTool = subagentResultTool(Type, toolSession);
|
|
2264
2265
|
pi.registerTool(acceptanceResultTool);
|
|
2265
2266
|
publishAcceptanceHooks(acceptanceResultTool);
|
|
2266
|
-
const acceptanceStopTool = subagentStopTool(Type, { onStopped:
|
|
2267
|
+
const acceptanceStopTool = subagentStopTool(Type, { onStopped: () => {
|
|
2268
|
+
renderWidget();
|
|
2269
|
+
syncHealthTicker();
|
|
2270
|
+
} });
|
|
2267
2271
|
acceptanceStopToolRef = acceptanceStopTool;
|
|
2268
2272
|
pi.registerTool(acceptanceStopTool);
|
|
2269
2273
|
publishAcceptanceHooks();
|
|
@@ -2475,10 +2479,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2475
2479
|
// Clear the retired legacy widget so the shared navigator is the only
|
|
2476
2480
|
// list surface for running/orphaned subagents.
|
|
2477
2481
|
renderWidget();
|
|
2478
|
-
//
|
|
2479
|
-
|
|
2480
|
-
// (running/orphaned, or unmarked lost); it stops itself when idle.
|
|
2481
|
-
if (needsMonitoring(listMetasForParent(process.pid)) || hasPendingFailureCallbacks()) ensureHealthTicker();
|
|
2482
|
+
// Reattach one watcher after reload; ordinary completion retries belong to the batcher.
|
|
2483
|
+
syncHealthTicker();
|
|
2482
2484
|
});
|
|
2483
2485
|
|
|
2484
2486
|
pi.on("session_before_switch", () => {
|
|
@@ -328,28 +328,92 @@ function themeFgFromTheme(theme: unknown): (color: string, value: string) => str
|
|
|
328
328
|
return (color, value) => maybeTheme?.fg ? maybeTheme.fg(color, value) : value;
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
|
|
331
|
+
// Structural typing keeps keyboard-only Pi hosts (no mouse API) loading this widget unchanged.
|
|
332
|
+
type WidgetMouseEvent = {
|
|
333
|
+
type: string;
|
|
334
|
+
button: string;
|
|
335
|
+
/** Coordinates local to this component, zero-based. */
|
|
336
|
+
x: number;
|
|
337
|
+
y: number;
|
|
338
|
+
width: number;
|
|
339
|
+
shift?: boolean;
|
|
340
|
+
alt?: boolean;
|
|
341
|
+
ctrl?: boolean;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
type WidgetMouseResult = { handled: true; render: boolean };
|
|
345
|
+
|
|
346
|
+
type MainListWidget = Component & {
|
|
347
|
+
dispose?(): void;
|
|
348
|
+
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, deps: HostDeps): MainListWidget {
|
|
332
352
|
const requestRender = () => tui.requestRender?.();
|
|
333
353
|
state().mainListRequestRender = requestRender;
|
|
354
|
+
// navigatorId per rendered line, so a pointer row resolves by structure, never by row text.
|
|
355
|
+
let lineOwners: Array<string | undefined> = [];
|
|
356
|
+
let lineWidth = MAIN_LIST_FALLBACK_WIDTH;
|
|
357
|
+
// Pi synthesizes a click only for the component that handled the initial press.
|
|
358
|
+
let pressedRow: { navigatorId: string; y: number } | undefined;
|
|
359
|
+
const rowAt = (event: WidgetMouseEvent): string | undefined => {
|
|
360
|
+
const width = Number.isFinite(event.width) && event.width > 0 ? Math.floor(event.width) : lineWidth;
|
|
361
|
+
if (!Number.isFinite(event.x) || event.x < 0 || event.x >= width) return undefined;
|
|
362
|
+
if (!Number.isInteger(event.y) || event.y < 0 || event.y >= lineOwners.length) return undefined;
|
|
363
|
+
return lineOwners[event.y];
|
|
364
|
+
};
|
|
334
365
|
return {
|
|
335
366
|
render(width: number): string[] {
|
|
336
367
|
const rows = listRows();
|
|
337
368
|
syncMainListSelection(rows);
|
|
338
369
|
if (!rows.length) {
|
|
339
370
|
state().lastMainListLines = undefined;
|
|
371
|
+
lineOwners = [];
|
|
372
|
+
pressedRow = undefined;
|
|
340
373
|
return [];
|
|
341
374
|
}
|
|
342
|
-
const
|
|
375
|
+
const layout = buildMainListLayout(rows, renderWidth(width), deps.truncate, themeFgFromTheme(theme), {
|
|
343
376
|
selectedId: state().mainListFocused ? state().mainListSelectedId : undefined,
|
|
344
377
|
focused: state().mainListFocused === true,
|
|
345
|
-
})
|
|
378
|
+
});
|
|
379
|
+
lineWidth = renderWidth(width);
|
|
380
|
+
// The leading gap line belongs to the widget too, so its slot stays ownerless.
|
|
381
|
+
lineOwners = [undefined, ...layout.owners];
|
|
382
|
+
const lines = ["", ...layout.lines];
|
|
346
383
|
state().lastMainListLines = lines;
|
|
347
384
|
return lines;
|
|
348
385
|
},
|
|
349
|
-
|
|
386
|
+
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined {
|
|
387
|
+
// Only an unmodified left press is ours; every other gesture reaches the host untouched.
|
|
388
|
+
if (event.button !== "left" || event.shift === true || event.alt === true || event.ctrl === true) {
|
|
389
|
+
pressedRow = undefined;
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
if (event.type === "press") {
|
|
393
|
+
pressedRow = undefined;
|
|
394
|
+
const navigatorId = rowAt(event);
|
|
395
|
+
if (!navigatorId || !listRows().some((row) => row.navigatorId === navigatorId)) return undefined;
|
|
396
|
+
pressedRow = { navigatorId, y: event.y };
|
|
397
|
+
return { handled: true, render: false };
|
|
398
|
+
}
|
|
399
|
+
if (event.type !== "click") return undefined;
|
|
400
|
+
const pressed = pressedRow;
|
|
401
|
+
pressedRow = undefined;
|
|
402
|
+
// A click after another component's press, or after the layout moved, is not the row we claimed.
|
|
403
|
+
if (!pressed || pressed.y !== event.y || rowAt(event) !== pressed.navigatorId) return undefined;
|
|
404
|
+
if (!openNavigatorRow(pressed.navigatorId)) return undefined;
|
|
405
|
+
return { handled: true, render: true };
|
|
406
|
+
},
|
|
407
|
+
invalidate() {
|
|
408
|
+
state().lastMainListLines = undefined;
|
|
409
|
+
lineOwners = [];
|
|
410
|
+
pressedRow = undefined;
|
|
411
|
+
},
|
|
350
412
|
dispose() {
|
|
351
413
|
const s = state();
|
|
352
414
|
if (s.mainListRequestRender === requestRender) s.mainListRequestRender = undefined;
|
|
415
|
+
lineOwners = [];
|
|
416
|
+
pressedRow = undefined;
|
|
353
417
|
},
|
|
354
418
|
};
|
|
355
419
|
}
|
|
@@ -454,7 +518,26 @@ function buildMainListLines(
|
|
|
454
518
|
fg: (color: string, value: string) => string,
|
|
455
519
|
options: { selectedId?: string; focused?: boolean } = {},
|
|
456
520
|
): string[] {
|
|
521
|
+
return buildMainListLayout(rows, width, truncate, fg, options).lines;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The list's lines plus the navigatorId each line shows, so pointer input maps a rendered row to its
|
|
526
|
+
* stable row identity. Headers, separators and the shortcut line keep an undefined owner.
|
|
527
|
+
*/
|
|
528
|
+
function buildMainListLayout(
|
|
529
|
+
rows: InternalRow[],
|
|
530
|
+
width: number,
|
|
531
|
+
truncate: (s: string, width: number) => string,
|
|
532
|
+
fg: (color: string, value: string) => string,
|
|
533
|
+
options: { selectedId?: string; focused?: boolean } = {},
|
|
534
|
+
): { lines: string[]; owners: Array<string | undefined> } {
|
|
457
535
|
const lines: string[] = [];
|
|
536
|
+
const owners: Array<string | undefined> = [];
|
|
537
|
+
const push = (line: string, owner?: string) => {
|
|
538
|
+
lines.push(line);
|
|
539
|
+
owners.push(owner);
|
|
540
|
+
};
|
|
458
541
|
const grouped = new Map<string, InternalRow[]>();
|
|
459
542
|
for (const row of rows) {
|
|
460
543
|
const existing = grouped.get(row.providerLabel) ?? [];
|
|
@@ -466,16 +549,16 @@ function buildMainListLines(
|
|
|
466
549
|
for (let i = 0; i < orderedLabels.length; i += 1) {
|
|
467
550
|
const label = orderedLabels[i]!;
|
|
468
551
|
const group = grouped.get(label)!;
|
|
469
|
-
if (i > 0)
|
|
470
|
-
|
|
552
|
+
if (i > 0) push("");
|
|
553
|
+
push(providerGroupLabel(label, fg));
|
|
471
554
|
for (const row of group) {
|
|
472
555
|
const selected = options.focused && row.navigatorId === options.selectedId;
|
|
473
|
-
|
|
556
|
+
push(formatMainListRow(row, selected === true, fg, width), row.navigatorId);
|
|
474
557
|
}
|
|
475
558
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
559
|
+
push("");
|
|
560
|
+
push(shortcutsLine(options.focused === true, fg));
|
|
561
|
+
return { lines: lines.map((line) => safeTruncate(line, width, truncate)), owners };
|
|
479
562
|
}
|
|
480
563
|
|
|
481
564
|
function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
|
|
@@ -766,6 +849,20 @@ function handleMainListCloseKey(): void {
|
|
|
766
849
|
refreshMainListWidget();
|
|
767
850
|
}
|
|
768
851
|
|
|
852
|
+
/**
|
|
853
|
+
* Open the shared detail overlay on the row a pointer chose. Setting the selection first keeps the
|
|
854
|
+
* keyboard path identical: Enter, x and the overlay's own keys then act on the clicked row.
|
|
855
|
+
*/
|
|
856
|
+
function openNavigatorRow(navigatorId: string): boolean {
|
|
857
|
+
const rows = listRows();
|
|
858
|
+
if (!rows.some((row) => row.navigatorId === navigatorId)) return false;
|
|
859
|
+
clearMainListCloseArm();
|
|
860
|
+
state().mainListSelectedId = navigatorId;
|
|
861
|
+
refreshMainListWidget();
|
|
862
|
+
openNavigator();
|
|
863
|
+
return true;
|
|
864
|
+
}
|
|
865
|
+
|
|
769
866
|
function openNavigator(): void {
|
|
770
867
|
const s = state();
|
|
771
868
|
const ctx = s.uiCtx;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.1",
|
|
4
4
|
"description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and Outcome & Todo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -53,11 +53,11 @@
|
|
|
53
53
|
"test": "node --import tsx --import ../../scripts/isolate-registry.mjs --test test/*.test.mjs test/task-context/*.test.ts"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"pi-better-background-tasks": "0.
|
|
57
|
-
"pi-better-goal": "0.10.
|
|
56
|
+
"pi-better-background-tasks": "0.12.0",
|
|
57
|
+
"pi-better-goal": "0.10.3",
|
|
58
58
|
"pi-better-sandbox": "0.14.0",
|
|
59
59
|
"pi-better-ssh": "0.1.3",
|
|
60
|
-
"pi-better-subagents": "0.
|
|
60
|
+
"pi-better-subagents": "0.22.1",
|
|
61
61
|
"proper-lockfile": "^4.1.2",
|
|
62
62
|
"smol-toml": "1.9.0",
|
|
63
63
|
"yaml": "^2.9.1"
|