gentle-pi 3.2.1 → 3.4.0
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 +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-shell.md +52 -15
- package/docs/readme-reference.md +67 -7
- package/docs/review-integration.md +22 -17
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +93 -18
- package/extensions/gentle-ai.ts +180 -37
- package/extensions/gentle-shell.ts +476 -39
- package/extensions/gentle-todo.ts +19 -1
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +297 -0
- package/lib/native-review-cli.ts +50 -10
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +256 -171
- package/lib/review-integration-v2.ts +114 -27
- package/lib/shell-bar.ts +163 -75
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +118 -16
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +103 -12
- package/lib/shell-usage.ts +120 -6
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +49 -9
- package/runtime/review-integration-v2.mjs +114 -27
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/verify-package-files.mjs +3 -4
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +10 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +599 -7
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +125 -9
- package/tests/gentle-shell.test.ts +1149 -24
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +460 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +34 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +297 -299
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +146 -1
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +129 -26
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +200 -124
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +301 -8
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +120 -1
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/lib/opaque-pi-reviewer-adapter.ts +0 -404
- package/skills/release/SKILL.md +0 -137
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
|
@@ -104,19 +104,37 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
|
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
const todoCard = (current: TodoSession, theme: Parameters<typeof renderTodoCard>[1], scrollable: boolean, spacer: boolean): Component & { dispose(): void } => {
|
|
107
|
+
let hovered = false;
|
|
107
108
|
const card: Component = {
|
|
108
109
|
render(width: number) {
|
|
109
110
|
const lines = renderTodoCard(current.state, theme, width, {
|
|
110
111
|
collapsed: current.collapsed,
|
|
111
112
|
staleTurns: staleTurns(current.state, current.turn),
|
|
112
113
|
collapseKey,
|
|
114
|
+
hovered,
|
|
113
115
|
...(scrollable ? { scrollable: true } : {}),
|
|
114
116
|
});
|
|
115
117
|
return spacer && lines.length > 0 ? [...lines, ""] : lines;
|
|
116
118
|
},
|
|
117
|
-
invalidate() {
|
|
119
|
+
invalidate() {
|
|
120
|
+
hovered = false;
|
|
121
|
+
},
|
|
118
122
|
};
|
|
119
123
|
const region = new NativePointerRegion(card, {
|
|
124
|
+
onHover(event) {
|
|
125
|
+
// The region spans the whole card, but only the header row (y===0)
|
|
126
|
+
// is the clickable control, so a move elsewhere in the card clears
|
|
127
|
+
// hover exactly like leaving the region entirely would.
|
|
128
|
+
const next = event.y === 0;
|
|
129
|
+
if (next === hovered) return { handled: true };
|
|
130
|
+
hovered = next;
|
|
131
|
+
return { handled: true, render: true };
|
|
132
|
+
},
|
|
133
|
+
onLeave() {
|
|
134
|
+
if (!hovered) return;
|
|
135
|
+
hovered = false;
|
|
136
|
+
current.host?.requestRender();
|
|
137
|
+
},
|
|
120
138
|
onClick(event) {
|
|
121
139
|
if (event.button !== "left" || event.y !== 0) return undefined;
|
|
122
140
|
toggle(current);
|
|
@@ -535,18 +535,39 @@ interface BoundedRowSection {
|
|
|
535
535
|
tail?: boolean;
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
+
// Cache the rendered preview slice per stable tool-result object: pi re-renders
|
|
539
|
+
// every visible card each frame, so re-tokenizing the full output text per pass is
|
|
540
|
+
// pure waste. Only the returned preview slice is retained, never the full wrapped
|
|
541
|
+
// text, so an arbitrarily large output cannot pin every wrapped line for the
|
|
542
|
+
// result object's lifetime.
|
|
543
|
+
const boundedRowsLineCache = new WeakMap<object, Array<{ text: string; width: number; rows: number; tail: boolean; lines: string[] } | undefined>>();
|
|
544
|
+
|
|
538
545
|
class BoundedRows implements Component {
|
|
539
546
|
private readonly sections: readonly BoundedRowSection[];
|
|
547
|
+
private readonly cacheKey: object | undefined;
|
|
540
548
|
|
|
541
|
-
constructor(sections: readonly BoundedRowSection[]) {
|
|
549
|
+
constructor(sections: readonly BoundedRowSection[], cacheKey?: object) {
|
|
542
550
|
this.sections = sections;
|
|
551
|
+
this.cacheKey = cacheKey;
|
|
543
552
|
}
|
|
544
553
|
|
|
554
|
+
/** Renders each section through the wrapped-line cache, sliced to the section row budget; cache hits skip re-tokenizing and re-wrapping the section text. */
|
|
545
555
|
render(width: number): string[] {
|
|
546
|
-
|
|
556
|
+
const cache = this.cacheKey ? boundedRowsLineCache.get(this.cacheKey) : undefined;
|
|
557
|
+
return this.sections.flatMap(({ text, rows, tail = false }, index) => {
|
|
547
558
|
if (rows <= 0) return [];
|
|
559
|
+
const hit = cache?.[index];
|
|
560
|
+
if (hit && hit.text === text && hit.width === width && hit.rows === rows && hit.tail === tail) {
|
|
561
|
+
return [...hit.lines];
|
|
562
|
+
}
|
|
548
563
|
const rendered = new Text(text, 0, 0).render(width);
|
|
549
|
-
|
|
564
|
+
const sliced = tail ? rendered.slice(-rows) : rendered.slice(0, rows);
|
|
565
|
+
if (this.cacheKey) {
|
|
566
|
+
const slot = boundedRowsLineCache.get(this.cacheKey) ?? [];
|
|
567
|
+
slot[index] = { text, width, rows, tail, lines: sliced };
|
|
568
|
+
boundedRowsLineCache.set(this.cacheKey, slot);
|
|
569
|
+
}
|
|
570
|
+
return [...sliced];
|
|
550
571
|
});
|
|
551
572
|
}
|
|
552
573
|
|
|
@@ -641,8 +662,10 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
|
|
|
641
662
|
}
|
|
642
663
|
return new Text(formatToolCall(toolName, callArgs, theme), 0, 0);
|
|
643
664
|
},
|
|
665
|
+
/** Builds the card component for this render pass; collapsed cards delegate to the wrapped-line cache keyed by the tool result object. */
|
|
644
666
|
renderResult(result, options, theme, context) {
|
|
645
667
|
const renderContext = context as ToolRenderContextLike | undefined;
|
|
668
|
+
const cacheKey = typeof result === "object" && result !== null ? result : undefined;
|
|
646
669
|
const safeResult = sanitizedResult(result);
|
|
647
670
|
const text = safeText(extractTextContent(safeResult));
|
|
648
671
|
const isError = renderContext?.isError ?? options.isError ?? false;
|
|
@@ -661,7 +684,7 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
|
|
|
661
684
|
return new BoundedRows([
|
|
662
685
|
{ text: theme.fg("warning", partialLabel(toolName, text)), rows: 1 },
|
|
663
686
|
...(visible ? [{ text: theme.fg("muted", visible), rows: PREVIEW_LINE_LIMIT, tail: true }] : []),
|
|
664
|
-
]);
|
|
687
|
+
], cacheKey);
|
|
665
688
|
}
|
|
666
689
|
if (options.expanded && toolName === "read" && hasImageContent(safeResult) && officialRenderResult) {
|
|
667
690
|
return officialRenderResult(
|
|
@@ -686,7 +709,7 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
|
|
|
686
709
|
return new BoundedRows([
|
|
687
710
|
{ text: theme.fg(color, output.replace(/^\n/, "")), rows: PREVIEW_LINE_LIMIT, tail },
|
|
688
711
|
...(hint ? [{ text: theme.fg(color, hint.slice(1)), rows: 1 }] : []),
|
|
689
|
-
]);
|
|
712
|
+
], cacheKey);
|
|
690
713
|
}
|
|
691
714
|
return new Text(hint ? theme.fg(color, hint.slice(1)) : "", 0, 0);
|
|
692
715
|
},
|
|
@@ -5,6 +5,7 @@ import * as os from "node:os";
|
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
6
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
|
|
8
9
|
|
|
9
10
|
const PI_AGENT_DIR = join(os.homedir(), ".pi", "agent");
|
|
10
11
|
const PI_NPM_DIR = join(PI_AGENT_DIR, "npm", "node_modules");
|
|
@@ -691,6 +692,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
691
692
|
|
|
692
693
|
let tick = 0;
|
|
693
694
|
let refreshStats = () => {};
|
|
695
|
+
let headerCache: { key: string; out: string[] } | null = null;
|
|
694
696
|
const state = {
|
|
695
697
|
timer: null as NodeJS.Timeout | null,
|
|
696
698
|
mode: currentIntroMode() as IntroMode,
|
|
@@ -718,18 +720,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
718
720
|
setTimeout(() => {
|
|
719
721
|
ctx.ui.setHeader((tui, theme) => {
|
|
720
722
|
if (state.timer) clearInterval(state.timer);
|
|
723
|
+
headerCache = null;
|
|
721
724
|
|
|
722
725
|
refreshStats = () => tui.requestRender();
|
|
726
|
+
// Capture once: a command changes the live prompt, not this intro.
|
|
727
|
+
const animationPolicy = resolveAnimationPolicy().policy;
|
|
723
728
|
const animStart = Date.now();
|
|
724
|
-
|
|
725
|
-
tick
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
729
|
+
if (animationPolicy === "potato") {
|
|
730
|
+
tick = Number.MAX_SAFE_INTEGER;
|
|
731
|
+
state.timer = null;
|
|
732
|
+
} else {
|
|
733
|
+
const performance = animationPolicy === "performance";
|
|
734
|
+
state.timer = setInterval(() => {
|
|
735
|
+
tick += performance ? 10 : 1;
|
|
736
|
+
const finished = allStrokesReady() && tick > WRITING_END_TICK + 22;
|
|
737
|
+
if (finished || Date.now() - animStart > 5000) {
|
|
738
|
+
clearInterval(state.timer!);
|
|
739
|
+
state.timer = null;
|
|
740
|
+
}
|
|
741
|
+
try { tui.requestRender(); } catch { cleanup(); }
|
|
742
|
+
}, performance ? 250 : 25);
|
|
743
|
+
}
|
|
733
744
|
|
|
734
745
|
// Grace period: pi-tui emite resizes transitorios mientras compone su layout inicial.
|
|
735
746
|
const bootStart = Date.now();
|
|
@@ -753,8 +764,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
753
764
|
process.stdout.on("resize", resizeHandler);
|
|
754
765
|
|
|
755
766
|
return {
|
|
767
|
+
/** Renders the persistent header grid; memoized per width, tick, mode and stats so static passes reuse the built lines. */
|
|
756
768
|
render(width: number): string[] {
|
|
757
769
|
if (state.mode === "skip") return [];
|
|
770
|
+
const headerKey = `${width}|${tick}|${state.mode}|${gitBranch}|${mcpServersCount}|${extensionsCount}|${packagesCount}|${sddAgentsCount}|${ctx.cwd}|${skills.length}|${customTools.length}`;
|
|
771
|
+
if (headerCache?.key === headerKey) return headerCache.out;
|
|
758
772
|
|
|
759
773
|
const flashStartTick = 10;
|
|
760
774
|
const roseOpacity = Math.min(1, tick / 10);
|
|
@@ -1050,9 +1064,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1050
1064
|
out.push(truncateToWidth(line, Math.max(1, width), ""));
|
|
1051
1065
|
}
|
|
1052
1066
|
|
|
1067
|
+
headerCache = { key: headerKey, out };
|
|
1053
1068
|
return out;
|
|
1054
1069
|
},
|
|
1055
|
-
invalidate() {},
|
|
1070
|
+
invalidate() { headerCache = null; },
|
|
1056
1071
|
dispose() {
|
|
1057
1072
|
cleanup();
|
|
1058
1073
|
},
|
package/lib/agents-view.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
2
2
|
import { measureAgentsViewLayout, type AgentsViewLayout } from "./agents-view-layout.ts";
|
|
3
|
+
import { HOVER_ROLE, paintHoverable } from "./shell-hover.ts";
|
|
3
4
|
import { emptyThread, isFinished, TASK_STATUS, type TaskRecord, type TaskStore, type TaskThread, type ThreadItem } from "./agents-protocol.ts";
|
|
4
5
|
import { renderThreadItem, type AgentsThreadTheme } from "./agents-thread-view.ts";
|
|
5
6
|
import { formatElapsed } from "./agents-widget.ts";
|
|
@@ -42,7 +43,7 @@ const ROLE = {
|
|
|
42
43
|
FRAME: "border",
|
|
43
44
|
TITLE: "customMessageLabel",
|
|
44
45
|
SELECTED: "accent",
|
|
45
|
-
HOVER:
|
|
46
|
+
HOVER: HOVER_ROLE,
|
|
46
47
|
NAME: "text",
|
|
47
48
|
NAME_IDLE: "muted",
|
|
48
49
|
META: "dim",
|
|
@@ -69,7 +70,7 @@ const GLYPH_ROLE: Record<string, string> = {
|
|
|
69
70
|
[TASK_STATUS.CANCELLED]: "dim",
|
|
70
71
|
[TASK_STATUS.TIMED_OUT]: "error",
|
|
71
72
|
};
|
|
72
|
-
|
|
73
|
+
const MAX_FINISHED_PER_SESSION = 200;
|
|
73
74
|
const SCOPE_LABEL: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "this session", [VIEW_SCOPE.ALL]: "all sessions" };
|
|
74
75
|
const SCOPE_KEY: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "all sessions", [VIEW_SCOPE.ALL]: "this session" };
|
|
75
76
|
const EMPTY_LIST = "no tasks yet";
|
|
@@ -428,7 +429,8 @@ export class AgentsView {
|
|
|
428
429
|
|
|
429
430
|
private counts(): string {
|
|
430
431
|
const active = this.tasks.filter((task) => !isFinished(task.status)).length;
|
|
431
|
-
|
|
432
|
+
const finished = this.tasks.length - active;
|
|
433
|
+
return `${active} active · ${finished} finished`;
|
|
432
434
|
}
|
|
433
435
|
|
|
434
436
|
private actionableTask(): TaskRecord | undefined {
|
|
@@ -470,8 +472,12 @@ export class AgentsView {
|
|
|
470
472
|
}
|
|
471
473
|
|
|
472
474
|
private inScope(task: TaskRecord, _now: number): boolean {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
+
// A local task (this session's own, active or finished history) always
|
|
476
|
+
// belongs; refreshTasks() is the single authority on which finished ones
|
|
477
|
+
// survive its own retention cap. A remote (presence) task never keeps
|
|
478
|
+
// history -- it drops out of scope the moment it finishes.
|
|
479
|
+
if (task.parentSessionId === this.deps.sessionId) return true;
|
|
480
|
+
return !isFinished(task.status) && this.scope === VIEW_SCOPE.ALL && this.remoteThreads.has(task.id);
|
|
475
481
|
}
|
|
476
482
|
|
|
477
483
|
private directoryRoot(): boolean {
|
|
@@ -534,8 +540,19 @@ export class AgentsView {
|
|
|
534
540
|
}
|
|
535
541
|
|
|
536
542
|
private refreshTasks(): void {
|
|
537
|
-
|
|
538
|
-
|
|
543
|
+
// Finished history bypasses isLocalTask: that dep exists to keep an
|
|
544
|
+
// unrelated task resolveTask pulled in on demand (e.g. `subagent_result`
|
|
545
|
+
// for an id from another session) out of the live panel, never to hide
|
|
546
|
+
// this session's own finished subagents -- those belong here whether
|
|
547
|
+
// they were restored on resume or just finished live.
|
|
548
|
+
const local = this.deps.store.list().filter((task) => task.parentSessionId === this.deps.sessionId
|
|
549
|
+
&& (isFinished(task.status) || (this.deps.isLocalTask?.(task) ?? true)));
|
|
550
|
+
const active = local.filter((task) => !isFinished(task.status));
|
|
551
|
+
const finished = local
|
|
552
|
+
.filter((task) => isFinished(task.status))
|
|
553
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
|
|
554
|
+
.slice(0, MAX_FINISHED_PER_SESSION);
|
|
555
|
+
this.tasks = [...active, ...finished];
|
|
539
556
|
if (this.scope === VIEW_SCOPE.ALL) this.tasks.push(...this.peers.flatMap((group) => group.tasks));
|
|
540
557
|
const groups = this.sessionGroups();
|
|
541
558
|
const present = new Set(groups.map((group) => group.id));
|
|
@@ -564,7 +581,15 @@ export class AgentsView {
|
|
|
564
581
|
group.tasks.push(task);
|
|
565
582
|
}
|
|
566
583
|
for (const group of groups.values()) {
|
|
567
|
-
|
|
584
|
+
// Active tasks first (newest created), then finished ones as history
|
|
585
|
+
// (newest ended first) -- a task never reorders within its own group
|
|
586
|
+
// just because it finished.
|
|
587
|
+
group.tasks.sort((a, b) => {
|
|
588
|
+
const finishedRank = Number(isFinished(a.status)) - Number(isFinished(b.status));
|
|
589
|
+
if (finishedRank !== 0) return finishedRank;
|
|
590
|
+
if (isFinished(a.status)) return (b.endedAt ?? 0) - (a.endedAt ?? 0);
|
|
591
|
+
return b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
592
|
+
});
|
|
568
593
|
}
|
|
569
594
|
if (this.scope === VIEW_SCOPE.ALL) {
|
|
570
595
|
const localId = `session:${this.deps.sessionId}`;
|
|
@@ -637,16 +662,18 @@ export class AgentsView {
|
|
|
637
662
|
const theme = this.deps.theme;
|
|
638
663
|
this.taskRegion(row).render(this.pointerLayout?.listWidth ?? 1);
|
|
639
664
|
const selected = entry.id === this.selectedRow()?.id;
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const
|
|
665
|
+
// Selection always outranks hover; the shared hover painter only
|
|
666
|
+
// applies once selection is ruled out.
|
|
667
|
+
const hovered = !selected && entry.id === this.hoveredId;
|
|
668
|
+
const markerRole = selected ? ROLE.SELECTED : hovered ? ROLE.HOVER : undefined;
|
|
669
|
+
const marker = markerRole ? theme.fg(markerRole, selected ? "▸" : "▹") : " ";
|
|
643
670
|
if (entry.kind === "heading") {
|
|
644
671
|
const state = this.isExpanded(entry.group) ? "▾" : "▸";
|
|
645
|
-
return `${marker}${theme
|
|
672
|
+
return `${marker}${paintHoverable(theme, state, hovered, ROLE.SELECTED)} ${paintHoverable(theme, this.groupHeading(entry.group), hovered, selected ? ROLE.SELECTED : ROLE.NAME_IDLE)}`;
|
|
646
673
|
}
|
|
647
674
|
const task = entry.task;
|
|
648
675
|
const glyph = theme.fg(GLYPH_ROLE[task.status] ?? ROLE.META, GLYPH[task.status] ?? "?");
|
|
649
|
-
const name = theme
|
|
676
|
+
const name = paintHoverable(theme, `Subagent ${task.agent}`, hovered, selected ? ROLE.SELECTED : ROLE.NAME_IDLE);
|
|
650
677
|
const time = task.startedAt === null ? "" : theme.fg(ROLE.META, formatElapsed((task.endedAt ?? this.deps.now()) - task.startedAt));
|
|
651
678
|
return `${marker} ${theme.fg(ROLE.META, "└")} ${glyph} ${name} ${time}`;
|
|
652
679
|
}
|
|
@@ -756,7 +783,7 @@ export class AgentsView {
|
|
|
756
783
|
actions.push({ x: 2 + visibleWidth(text), width: label.length, region: control.region });
|
|
757
784
|
control.region.render(label.length);
|
|
758
785
|
const hovered = (control.region === this.followRegion && this.hoveredControl === "follow") || (control.region === this.openRegion && this.hoveredControl === "open");
|
|
759
|
-
text +=
|
|
786
|
+
text += control.enabled ? paintHoverable(this.deps.theme, label, hovered, ROLE.KEY) : this.deps.theme.fg(ROLE.META, label);
|
|
760
787
|
}
|
|
761
788
|
if (required > width) {
|
|
762
789
|
const more = this.actionRegion("more", () => {
|
package/lib/agents-widget.ts
CHANGED
|
@@ -29,7 +29,28 @@ interface Columns {
|
|
|
29
29
|
name: number;
|
|
30
30
|
task: number;
|
|
31
31
|
meta: number;
|
|
32
|
-
|
|
32
|
+
// Columnar rows give each surviving metadata field its own fixed,
|
|
33
|
+
// right-aligned column sized to the widest value among shown tasks, so
|
|
34
|
+
// model·effort, tokens, cost, and elapsed line up vertically across rows.
|
|
35
|
+
// A narrow card may have dropped some of those columns and still be
|
|
36
|
+
// columnar; false means the clipped single-string fallback.
|
|
37
|
+
columnar: boolean;
|
|
38
|
+
// Populated only when columnar is true.
|
|
39
|
+
metaWidths?: MetaColumnWidths;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface MetaFields {
|
|
43
|
+
exec: string;
|
|
44
|
+
tokens: string;
|
|
45
|
+
cost: string;
|
|
46
|
+
elapsed: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface MetaColumnWidths {
|
|
50
|
+
exec: number;
|
|
51
|
+
tokens: number;
|
|
52
|
+
cost: number;
|
|
53
|
+
elapsed: number;
|
|
33
54
|
}
|
|
34
55
|
|
|
35
56
|
const LOOK: Record<TaskStatus, StatusLook> = {
|
|
@@ -150,11 +171,48 @@ function executionLabel(task: TaskRecord, width = Infinity): string {
|
|
|
150
171
|
return clip(model, width - visibleWidth(suffix)) + suffix;
|
|
151
172
|
}
|
|
152
173
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
174
|
+
// Narrow (non-columnar) rows keep the single-string contract: a queued
|
|
175
|
+
// task shows the bare word, everything else shows its exec label.
|
|
176
|
+
function narrowMetaText(task: TaskRecord): string {
|
|
177
|
+
return task.status === TASK_STATUS.QUEUED ? "queued" : executionLabel(task);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Queued rows carry no model, token, or cost data yet, so every field but
|
|
181
|
+
// elapsed stays blank — and elapsed itself becomes the literal word "queued"
|
|
182
|
+
// rather than the empty string startedAt === null would otherwise produce.
|
|
183
|
+
function metaFields(task: TaskRecord, now: number): MetaFields {
|
|
184
|
+
if (task.status === TASK_STATUS.QUEUED) return { exec: "", tokens: "", cost: "", elapsed: "queued" };
|
|
185
|
+
return {
|
|
186
|
+
exec: executionLabel(task),
|
|
187
|
+
tokens: task.tokens > 0 ? formatTokens(task.tokens) : "",
|
|
188
|
+
cost: task.cost > 0 ? `$${task.cost.toFixed(2)}` : "",
|
|
189
|
+
elapsed: elapsed(task, now),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function metaColumnWidths(tasks: readonly TaskRecord[], now: number): MetaColumnWidths {
|
|
194
|
+
const fields = tasks.map((task) => metaFields(task, now));
|
|
195
|
+
const widest = (pick: (field: MetaFields) => string) => Math.max(0, ...fields.map((field) => visibleWidth(pick(field))));
|
|
196
|
+
return { exec: widest((field) => field.exec), tokens: widest((field) => field.tokens), cost: widest((field) => field.cost), elapsed: widest((field) => field.elapsed) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// A column whose widest value is empty across every shown task (e.g. no task
|
|
200
|
+
// carries a cost yet) is dropped entirely, together with its separator —
|
|
201
|
+
// exactly like the old joined string dropped an empty field, but decided once
|
|
202
|
+
// for the whole card rather than per row, so the remaining columns still align.
|
|
203
|
+
function metaTotalWidth(widths: MetaColumnWidths): number {
|
|
204
|
+
const active = [widths.exec, widths.tokens, widths.cost, widths.elapsed].filter((width) => width > 0);
|
|
205
|
+
return active.reduce((sum, width) => sum + width, 0) + Math.max(0, active.length - 1) * visibleWidth(" · ");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function metaRowText(task: TaskRecord, now: number, widths: MetaColumnWidths): string {
|
|
209
|
+
const fields = metaFields(task, now);
|
|
210
|
+
const parts: string[] = [];
|
|
211
|
+
if (widths.exec > 0) parts.push(fields.exec.padStart(widths.exec));
|
|
212
|
+
if (widths.tokens > 0) parts.push(fields.tokens.padStart(widths.tokens));
|
|
213
|
+
if (widths.cost > 0) parts.push(fields.cost.padStart(widths.cost));
|
|
214
|
+
if (widths.elapsed > 0) parts.push(fields.elapsed.padStart(widths.elapsed));
|
|
215
|
+
return parts.join(" · ");
|
|
158
216
|
}
|
|
159
217
|
|
|
160
218
|
function taskText(task: TaskRecord): string {
|
|
@@ -163,27 +221,40 @@ function taskText(task: TaskRecord): string {
|
|
|
163
221
|
return task.label;
|
|
164
222
|
}
|
|
165
223
|
|
|
166
|
-
// Narrow cards
|
|
224
|
+
// Narrow cards degrade per column, decided once for the whole card so the
|
|
225
|
+
// surviving columns keep lining up: the task text goes first, then the
|
|
226
|
+
// model·effort label, then tokens, then cost. Elapsed is the one value the
|
|
227
|
+
// reader cannot rebuild from anything else on screen, so it is the last to go
|
|
228
|
+
// (gentle-shell#1143). Only when even elapsed alone does not fit does the row
|
|
229
|
+
// fall back to the clipped single-string label.
|
|
167
230
|
function columns(tasks: readonly TaskRecord[], inner: number, now: number): Columns {
|
|
168
231
|
const name = Math.max(0, Math.min(NAME_MAX, inner - 3, Math.max(...tasks.map((task) => visibleWidth(task.agent)))));
|
|
169
232
|
const fixed = 1 + GLYPH_GAP.length + name + COLUMN_GAP.length;
|
|
170
|
-
const
|
|
171
|
-
const full =
|
|
233
|
+
const metaWidths = metaColumnWidths(tasks, now);
|
|
234
|
+
const full = metaTotalWidth(metaWidths);
|
|
172
235
|
const task = inner - fixed - full - COLUMN_GAP.length;
|
|
173
|
-
if (task >= TASK_MIN) return { inner, name, meta: full, task,
|
|
174
|
-
|
|
236
|
+
if (task >= TASK_MIN) return { inner, name, meta: full, task, columnar: true, metaWidths };
|
|
237
|
+
let widths = metaWidths;
|
|
238
|
+
for (const drop of ["exec", "tokens", "cost"] as const) {
|
|
239
|
+
if (metaTotalWidth(widths) <= inner - fixed) break;
|
|
240
|
+
widths = { ...widths, [drop]: 0 };
|
|
241
|
+
}
|
|
242
|
+
const meta = metaTotalWidth(widths);
|
|
243
|
+
if (meta > 0 && meta <= inner - fixed) return { inner, name, meta, task: 0, columnar: true, metaWidths: widths };
|
|
244
|
+
const narrow = Math.max(0, ...tasks.map((task) => visibleWidth(narrowMetaText(task))));
|
|
245
|
+
return { inner, name, meta: Math.max(0, Math.min(inner - fixed, narrow)), task: 0, columnar: false };
|
|
175
246
|
}
|
|
176
247
|
|
|
177
248
|
function row(task: TaskRecord, theme: CardTheme, cols: Columns, now: number, allowMetadataRow: boolean): string[] {
|
|
178
249
|
const look = LOOK[task.status];
|
|
179
250
|
const name = clip(task.agent, cols.name);
|
|
180
251
|
const head = `${theme.fg(look.role, look.glyph)}${GLYPH_GAP}${theme.fg(NAME_ROLE, name)}${" ".repeat(cols.name - visibleWidth(name))}`;
|
|
181
|
-
const metadata = cols.
|
|
252
|
+
const metadata = cols.columnar && cols.metaWidths ? metaRowText(task, now, cols.metaWidths) : task.status === TASK_STATUS.QUEUED ? clip("queued", cols.meta) : executionLabel(task, cols.meta);
|
|
182
253
|
const tail = theme.fg(META_ROLE, " ".repeat(Math.max(0, cols.meta - visibleWidth(metadata))) + metadata);
|
|
183
254
|
if (cols.inner < 3) return [theme.fg(look.role, clip(look.glyph, cols.inner))];
|
|
184
255
|
// The scrollable sidebar can preserve identity and execution metadata on
|
|
185
256
|
// separate rows. The height-capped above-editor widget keeps its row budget.
|
|
186
|
-
if (allowMetadataRow && cols.task === 0 && task.status !== TASK_STATUS.QUEUED && visibleWidth(executionLabel(task)) > cols.meta) {
|
|
257
|
+
if (allowMetadataRow && !cols.columnar && cols.task === 0 && task.status !== TASK_STATUS.QUEUED && visibleWidth(executionLabel(task)) > cols.meta) {
|
|
187
258
|
return [head, theme.fg(META_ROLE, executionLabel(task, cols.inner))];
|
|
188
259
|
}
|
|
189
260
|
if (cols.task === 0) return [`${head}${" ".repeat(Math.max(0, cols.inner - visibleWidth(head) - visibleWidth(tail)))}${tail}`];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
5
|
+
|
|
6
|
+
export const ANIMATION_POLICY = { QUALITY: "quality", PERFORMANCE: "performance", POTATO: "potato" } as const;
|
|
7
|
+
export type AnimationPolicy = (typeof ANIMATION_POLICY)[keyof typeof ANIMATION_POLICY];
|
|
8
|
+
const SOURCE = { GLOBAL: "global_file", DEFAULT: "default" } as const;
|
|
9
|
+
export const ANIMATION_SCHEMA = "gentle-pi.animations/v1";
|
|
10
|
+
|
|
11
|
+
interface AnimationOptions { gentlePiConfigHome?: string }
|
|
12
|
+
export interface AnimationResolution {
|
|
13
|
+
policy: AnimationPolicy;
|
|
14
|
+
source: (typeof SOURCE)[keyof typeof SOURCE];
|
|
15
|
+
malformed: boolean;
|
|
16
|
+
globalFile: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseAnimationPolicyFile(raw: string): AnimationPolicy | undefined {
|
|
20
|
+
try {
|
|
21
|
+
const value: unknown = JSON.parse(raw);
|
|
22
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
23
|
+
if (!("schema" in value) || value.schema !== ANIMATION_SCHEMA || !("policy" in value) || Object.keys(value).length !== 2) return undefined;
|
|
24
|
+
return value.policy === "quality" || value.policy === "performance" || value.policy === "potato" ? value.policy : undefined;
|
|
25
|
+
} catch { return undefined; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveAnimationPolicy(options: AnimationOptions = {}): AnimationResolution {
|
|
29
|
+
const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "animations.json");
|
|
30
|
+
try {
|
|
31
|
+
const policy = parseAnimationPolicyFile(readFileSync(globalFile, "utf8"));
|
|
32
|
+
return { policy: policy ?? "quality", source: SOURCE.GLOBAL, malformed: policy === undefined, globalFile };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const missing = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
35
|
+
return { policy: "quality", source: missing ? SOURCE.DEFAULT : SOURCE.GLOBAL, malformed: !missing, globalFile };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Same-directory rename prevents readers from seeing a partially written policy. */
|
|
40
|
+
export function writeAnimationPolicy(policy: AnimationPolicy, options: AnimationOptions = {}): string {
|
|
41
|
+
const home = options.gentlePiConfigHome ?? gentlePiConfigHome();
|
|
42
|
+
const path = join(home, "animations.json");
|
|
43
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
44
|
+
mkdirSync(home, { recursive: true });
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(temporary, `${JSON.stringify({ schema: ANIMATION_SCHEMA, policy })}\n`, { flag: "wx", mode: 0o600 });
|
|
47
|
+
renameSync(temporary, path);
|
|
48
|
+
} finally {
|
|
49
|
+
try { unlinkSync(temporary); } catch { /* Rename already consumed the temporary file. */ }
|
|
50
|
+
}
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { TaskRecord } from "./agents-protocol.ts";
|
|
2
|
+
|
|
3
|
+
const ACTION = { WARM: "warm", STOP: "stop" } as const;
|
|
4
|
+
type WarmingAction = (typeof ACTION)[keyof typeof ACTION];
|
|
5
|
+
interface WarmingDecision {
|
|
6
|
+
warmCost: number;
|
|
7
|
+
missCost: number;
|
|
8
|
+
continuationProbability: number;
|
|
9
|
+
action: WarmingAction;
|
|
10
|
+
}
|
|
11
|
+
interface WarmingOverride { action: WarmingAction }
|
|
12
|
+
interface WarmingEvents {
|
|
13
|
+
on(event: "cache_warming_decision", handler: (event: WarmingDecision) => WarmingOverride | undefined): unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface WarmingState {
|
|
16
|
+
sessionId: string | undefined;
|
|
17
|
+
ownedTaskIds: ReadonlySet<string>;
|
|
18
|
+
tasks: Array<Pick<TaskRecord, "id" | "parentSessionId" | "mode" | "status">>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pi 0.86.1's native minimum expected savings, in dollars. Ownership raises
|
|
22
|
+
// continuation probability to 1, not permission to spend without a benefit.
|
|
23
|
+
const MINIMUM_EXPECTED_SAVINGS = 0.05;
|
|
24
|
+
|
|
25
|
+
export function installBackgroundCacheWarming(pi: WarmingEvents, state: () => WarmingState): void {
|
|
26
|
+
pi.on("cache_warming_decision", (event) => {
|
|
27
|
+
const { sessionId, ownedTaskIds, tasks } = state();
|
|
28
|
+
if (!sessionId || !tasks.some(task => task.parentSessionId === sessionId &&
|
|
29
|
+
ownedTaskIds.has(task.id) && task.mode === "background" &&
|
|
30
|
+
(task.status === "queued" || task.status === "running"))) return;
|
|
31
|
+
// Only native candidates arrive here: Pi owns opt-out, replay safety,
|
|
32
|
+
// provider TTLs, fixed horizons, scheduling and usage outside context.
|
|
33
|
+
const economic = Number.isFinite(event.warmCost) && event.warmCost >= 0 &&
|
|
34
|
+
Number.isFinite(event.missCost) && event.missCost > 0 &&
|
|
35
|
+
event.missCost - event.warmCost >= MINIMUM_EXPECTED_SAVINGS;
|
|
36
|
+
return { action: economic ? ACTION.WARM : ACTION.STOP };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -25,6 +25,8 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
|
|
|
25
25
|
{ command: "gentle:persona", label: "Switch persona" },
|
|
26
26
|
{ command: "gentle:review-mode", label: "Review mode (receipt-driven development)" },
|
|
27
27
|
{ command: "gentle:background-subagents", label: "Background subagents" },
|
|
28
|
+
{ command: "gentle:double-esc-cancel", label: "Require double Esc to cancel" },
|
|
29
|
+
{ command: "gentle:animations", label: "Animation mode" },
|
|
28
30
|
{ command: "gentle:telemetry", label: "Telemetry" },
|
|
29
31
|
{ command: "gentle:banner", label: "Startup banner" },
|
|
30
32
|
{ command: "gentle:banner-color", label: "Banner color" },
|