gentle-pi 2.3.0 → 2.5.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 +195 -11
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +21 -10
- package/assets/orchestrator.md +8 -12
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- package/docs/delegated-verification.md +25 -0
- package/docs/review-integration.md +1 -1
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +1421 -143
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agent-home.ts +8 -0
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +103 -12
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +92 -13
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/sdd-preflight.ts +2 -2
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +102 -11
- package/runtime/review-integration-v2.mjs +92 -13
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +18 -13
- package/skills/_shared/review-ledger-contract.md +9 -1
- package/skills/issue-creation/SKILL.md +53 -93
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +31 -14
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +33 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +71 -2
- package/tests/orchestrator-rdd-ownership.test.ts +10 -1
- package/tests/package-manifest.test.ts +134 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +142 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-routing.test.ts +229 -11
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/runtime-harness.mjs +1 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
2
|
+
import { createNativePointerScope, type NativePointerRegion } from "./native-pointer-region.ts";
|
|
3
|
+
import { isFinished, TASK_STATUS, THREAD_ITEM, type TaskRecord, type TaskStore, type TaskThread, type ThreadItem, type ToolItem } from "./agents-protocol.ts";
|
|
4
|
+
import { formatElapsed } from "./agents-widget.ts";
|
|
5
|
+
import { formatTokens } from "./shell-bar.ts";
|
|
6
|
+
|
|
7
|
+
// Gentle Agents overlay: tasks on the left, the selected task's thread on
|
|
8
|
+
// the right. Only the selected task is subscribed, thread items are rendered
|
|
9
|
+
// once each (they are immutable until replaced), and the viewport shows the
|
|
10
|
+
// tail unless the human scrolls up. The list opens on the active session's
|
|
11
|
+
// work (active tasks plus those finished in the last quarter hour); `a`
|
|
12
|
+
// widens it to every task of every session, including the stored history.
|
|
13
|
+
|
|
14
|
+
export interface AgentsViewTheme {
|
|
15
|
+
fg(color: string, text: string): string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const VIEW_SCOPE = {
|
|
19
|
+
SESSION: "session",
|
|
20
|
+
ALL: "all",
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type ViewScope = (typeof VIEW_SCOPE)[keyof typeof VIEW_SCOPE];
|
|
24
|
+
|
|
25
|
+
export interface AgentsViewDeps {
|
|
26
|
+
theme: AgentsViewTheme;
|
|
27
|
+
rows: number;
|
|
28
|
+
store: TaskStore;
|
|
29
|
+
// The active session; without it there is nothing to scope by and the
|
|
30
|
+
// list shows every task.
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
now(): number;
|
|
33
|
+
onCancel(task: TaskRecord): void;
|
|
34
|
+
canCancel?(task: TaskRecord): boolean;
|
|
35
|
+
onOpen(task: TaskRecord): void;
|
|
36
|
+
onClose(): void;
|
|
37
|
+
requestRender(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ROLE = {
|
|
41
|
+
FRAME: "border",
|
|
42
|
+
TITLE: "customMessageLabel",
|
|
43
|
+
SELECTED: "accent",
|
|
44
|
+
NAME: "text",
|
|
45
|
+
NAME_IDLE: "muted",
|
|
46
|
+
META: "dim",
|
|
47
|
+
TEXT: "text",
|
|
48
|
+
THINKING: "dim",
|
|
49
|
+
TOOL: "accent",
|
|
50
|
+
TOOL_ERROR: "error",
|
|
51
|
+
OUTPUT: "muted",
|
|
52
|
+
NOTE: "muted",
|
|
53
|
+
NOTE_ERROR: "warning",
|
|
54
|
+
KEY: "accent",
|
|
55
|
+
KEY_TEXT: "dim",
|
|
56
|
+
EMPTY: "dim",
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
const GLYPH: Record<string, string> = {
|
|
60
|
+
[TASK_STATUS.QUEUED]: "○",
|
|
61
|
+
[TASK_STATUS.RUNNING]: "◐",
|
|
62
|
+
[TASK_STATUS.WAITING]: "?",
|
|
63
|
+
[TASK_STATUS.COMPLETED]: "✓",
|
|
64
|
+
[TASK_STATUS.FAILED]: "✗",
|
|
65
|
+
[TASK_STATUS.CANCELLED]: "–",
|
|
66
|
+
[TASK_STATUS.TIMED_OUT]: "✗",
|
|
67
|
+
};
|
|
68
|
+
const GLYPH_ROLE: Record<string, string> = {
|
|
69
|
+
[TASK_STATUS.QUEUED]: "muted",
|
|
70
|
+
[TASK_STATUS.RUNNING]: "accent",
|
|
71
|
+
[TASK_STATUS.WAITING]: "warning",
|
|
72
|
+
[TASK_STATUS.COMPLETED]: "success",
|
|
73
|
+
[TASK_STATUS.FAILED]: "error",
|
|
74
|
+
[TASK_STATUS.CANCELLED]: "dim",
|
|
75
|
+
[TASK_STATUS.TIMED_OUT]: "error",
|
|
76
|
+
};
|
|
77
|
+
const LIST_MAX_WIDTH = 34;
|
|
78
|
+
const LIST_RATIO = 0.32;
|
|
79
|
+
const CHROME_ROWS = 3;
|
|
80
|
+
const MIN_BODY_ROWS = 1;
|
|
81
|
+
export const SESSION_FINISHED_TTL_MS = 15 * 60_000;
|
|
82
|
+
const SCOPE_LABEL: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "this session", [VIEW_SCOPE.ALL]: "all sessions" };
|
|
83
|
+
const SCOPE_KEY: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "all sessions", [VIEW_SCOPE.ALL]: "this session" };
|
|
84
|
+
const EMPTY_LIST = "no tasks yet";
|
|
85
|
+
const EMPTY_THREAD = "waiting for the first event";
|
|
86
|
+
const FOLLOW_BUTTON = "[ Follow ]";
|
|
87
|
+
const OPEN_BUTTON = "[ Open session ]";
|
|
88
|
+
const FOOTER_BUTTONS_WIDTH = FOLLOW_BUTTON.length + 1 + OPEN_BUTTON.length;
|
|
89
|
+
const KEYS = [
|
|
90
|
+
["j/k", "task"],
|
|
91
|
+
["ctrl+j/k", "scroll"],
|
|
92
|
+
["f", "follow"],
|
|
93
|
+
["o", "open session"],
|
|
94
|
+
["esc", "close"],
|
|
95
|
+
] as const;
|
|
96
|
+
|
|
97
|
+
const EMPTY_COMPONENT: Component = {
|
|
98
|
+
render: () => [],
|
|
99
|
+
invalidate() {},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
interface PointerButtonLayout {
|
|
103
|
+
x: number;
|
|
104
|
+
width: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface PointerLayout {
|
|
108
|
+
width: number;
|
|
109
|
+
height: number;
|
|
110
|
+
listX: number;
|
|
111
|
+
listWidth: number;
|
|
112
|
+
threadX: number;
|
|
113
|
+
threadWidth: number;
|
|
114
|
+
bodyRows: number;
|
|
115
|
+
footerY: number;
|
|
116
|
+
followButton?: PointerButtonLayout;
|
|
117
|
+
openButton?: PointerButtonLayout;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function rule(length: number): string {
|
|
121
|
+
return "─".repeat(Math.max(0, length));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fit(text: string, width: number): string {
|
|
125
|
+
const clipped = truncateToWidth(text, width, "…");
|
|
126
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function argsSummary(item: ToolItem): string {
|
|
130
|
+
const values = Object.values(item.args).filter((value) => typeof value === "string") as string[];
|
|
131
|
+
return (values[0] ?? "").replace(/\s+/g, " ").trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function toolLines(item: ToolItem, theme: AgentsViewTheme, width: number): string[] {
|
|
135
|
+
const role = item.isError ? ROLE.TOOL_ERROR : ROLE.TOOL;
|
|
136
|
+
const head = truncateToWidth(`▸ ${item.name} ${argsSummary(item)}`, width, "…");
|
|
137
|
+
const lines = [theme.fg(role, head)];
|
|
138
|
+
// The whole captured output, wrapped: the thread pane scrolls, so nothing
|
|
139
|
+
// is hidden here. The store already keeps only the last maxOutputChars of a
|
|
140
|
+
// tool's output and marks the cut with a leading ellipsis.
|
|
141
|
+
for (const line of item.output.split("\n").filter((line) => line.length > 0)) {
|
|
142
|
+
for (const wrapped of wrapTextWithAnsi(line, Math.max(1, width - 2))) lines.push(theme.fg(ROLE.OUTPUT, ` ${wrapped}`));
|
|
143
|
+
}
|
|
144
|
+
if (item.running) lines.push(theme.fg(ROLE.META, " …"));
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function thinkingLines(text: string, theme: AgentsViewTheme, width: number): string[] {
|
|
149
|
+
const lines: string[] = [];
|
|
150
|
+
for (const [index, line] of text.split("\n").filter((line) => line.length > 0).entries()) {
|
|
151
|
+
const prefix = index === 0 ? "∴ " : " ";
|
|
152
|
+
for (const wrapped of wrapTextWithAnsi(line, Math.max(1, width - 2))) lines.push(theme.fg(ROLE.THINKING, `${prefix}${wrapped}`));
|
|
153
|
+
}
|
|
154
|
+
return lines;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function itemLines(item: ThreadItem, theme: AgentsViewTheme, width: number): string[] {
|
|
158
|
+
switch (item.kind) {
|
|
159
|
+
case THREAD_ITEM.TEXT:
|
|
160
|
+
return wrapTextWithAnsi(item.text, width).map((line) => theme.fg(ROLE.TEXT, line));
|
|
161
|
+
case THREAD_ITEM.THINKING:
|
|
162
|
+
return thinkingLines(item.text, theme, width);
|
|
163
|
+
case THREAD_ITEM.TOOL:
|
|
164
|
+
return toolLines(item, theme, width);
|
|
165
|
+
case THREAD_ITEM.NOTE:
|
|
166
|
+
return [theme.fg(item.text.startsWith("error") ? ROLE.NOTE_ERROR : ROLE.NOTE, truncateToWidth(`· ${item.text}`, width, "…"))];
|
|
167
|
+
default:
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function taskHeader(task: TaskRecord, now: number): string {
|
|
173
|
+
const parts = [task.agent, task.status, task.model, task.tokens > 0 ? formatTokens(task.tokens) : "", task.cost > 0 ? `$${task.cost.toFixed(2)}` : "", task.startedAt === null ? "" : formatElapsed((task.endedAt ?? now) - task.startedAt)];
|
|
174
|
+
return parts.filter((part) => part.length > 0).join(" · ");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class AgentsView {
|
|
178
|
+
private readonly deps: AgentsViewDeps;
|
|
179
|
+
private tasks: TaskRecord[] = [];
|
|
180
|
+
private selected = 0;
|
|
181
|
+
private hovered: number | undefined;
|
|
182
|
+
private listScroll = 0;
|
|
183
|
+
private scope: ViewScope;
|
|
184
|
+
private scroll = 0;
|
|
185
|
+
private follow = true;
|
|
186
|
+
private readonly pointerScope = createNativePointerScope();
|
|
187
|
+
private readonly taskRegions = new Map<number, NativePointerRegion>();
|
|
188
|
+
private readonly listRegion: NativePointerRegion;
|
|
189
|
+
private readonly threadRegion: NativePointerRegion;
|
|
190
|
+
private readonly followRegion: NativePointerRegion;
|
|
191
|
+
private readonly openRegion: NativePointerRegion;
|
|
192
|
+
private hoveredControl: "follow" | "open" | undefined;
|
|
193
|
+
private pointerLayout: PointerLayout | undefined;
|
|
194
|
+
private unsubscribeTask: (() => void) | undefined;
|
|
195
|
+
private readonly unsubscribeSummary: () => void;
|
|
196
|
+
private cache = new WeakMap<ThreadItem, string[]>();
|
|
197
|
+
private cacheWidth = -1;
|
|
198
|
+
|
|
199
|
+
constructor(deps: AgentsViewDeps) {
|
|
200
|
+
this.deps = deps;
|
|
201
|
+
this.scope = deps.sessionId === undefined ? VIEW_SCOPE.ALL : VIEW_SCOPE.SESSION;
|
|
202
|
+
this.listRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
203
|
+
onWheel: (event) => this.wheelList(event),
|
|
204
|
+
});
|
|
205
|
+
this.threadRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
206
|
+
onWheel: (event) => this.wheelThread(event),
|
|
207
|
+
});
|
|
208
|
+
this.followRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
209
|
+
onHover: () => this.hoverControl("follow"),
|
|
210
|
+
onLeave: () => this.clearHoveredControl("follow"),
|
|
211
|
+
onClick: (event) => this.clickFollow(event),
|
|
212
|
+
});
|
|
213
|
+
this.openRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
214
|
+
onHover: () => this.hoverControl("open"),
|
|
215
|
+
onLeave: () => this.clearHoveredControl("open"),
|
|
216
|
+
onClick: (event) => this.clickOpen(event),
|
|
217
|
+
});
|
|
218
|
+
this.refreshTasks();
|
|
219
|
+
this.unsubscribeSummary = deps.store.subscribeSummary(() => {
|
|
220
|
+
this.clearFooterLayout();
|
|
221
|
+
this.refreshTasks();
|
|
222
|
+
this.deps.requestRender();
|
|
223
|
+
});
|
|
224
|
+
this.subscribeSelected();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
dispose(): void {
|
|
228
|
+
this.pointerLayout = undefined;
|
|
229
|
+
this.pointerScope.dispose();
|
|
230
|
+
this.unsubscribeTask?.();
|
|
231
|
+
this.unsubscribeSummary();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
mouseObserver(): ReturnType<typeof this.pointerScope.createMouseObserver> {
|
|
235
|
+
return this.pointerScope.createMouseObserver(() => this.deps.requestRender());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
selectedTask(): TaskRecord | undefined {
|
|
239
|
+
return this.tasks[this.selected];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
handleInput(data: string): void {
|
|
243
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
244
|
+
this.deps.onClose();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const task = this.selectedTask();
|
|
248
|
+
if (data === "j" || matchesKey(data, Key.down)) this.select(this.selected + 1);
|
|
249
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.select(this.selected - 1);
|
|
250
|
+
else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("j"))) this.scrollBy(this.pageRows());
|
|
251
|
+
else if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("k"))) this.scrollBy(-this.pageRows());
|
|
252
|
+
else if (data === "f") {
|
|
253
|
+
this.follow = true;
|
|
254
|
+
this.deps.requestRender();
|
|
255
|
+
} else if (data === "a" && this.deps.sessionId !== undefined) this.toggleScope();
|
|
256
|
+
else if ((data === "s" || data === "c") && task && this.canCancel(task)) this.deps.onCancel(task);
|
|
257
|
+
else if ((data === "o" || matchesKey(data, Key.enter)) && this.canOpen(task)) this.deps.onOpen(task!);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
261
|
+
const layout = this.pointerLayout;
|
|
262
|
+
if (!layout || event.width !== layout.width || event.height !== layout.height || event.x < 0 || event.y < 0 || event.x >= layout.width || event.y >= layout.height) return undefined;
|
|
263
|
+
if (event.y >= 1 && event.y < 1 + layout.bodyRows) {
|
|
264
|
+
const row = event.y - 1;
|
|
265
|
+
if (event.x >= layout.listX && event.x < layout.listX + layout.listWidth) {
|
|
266
|
+
if (event.type === "wheel") return this.listRegion.handleMouse(event);
|
|
267
|
+
const task = this.tasks[this.listScroll + row];
|
|
268
|
+
return task ? this.taskRegion(row).handleMouse(event) : undefined;
|
|
269
|
+
}
|
|
270
|
+
if (event.x >= layout.threadX && event.x < layout.threadX + layout.threadWidth && event.type === "wheel") {
|
|
271
|
+
return this.threadRegion.handleMouse(event);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (event.y === layout.footerY) {
|
|
275
|
+
if (this.isInButton(event.x, layout.followButton)) return this.followRegion.handleMouse(event);
|
|
276
|
+
if (this.isInButton(event.x, layout.openButton)) return this.openRegion.handleMouse(event);
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
render(width: number): string[] {
|
|
282
|
+
// Finished rows age out of the session scope while the overlay is open;
|
|
283
|
+
// the list is otherwise reordered only when a status changes.
|
|
284
|
+
const now = this.deps.now();
|
|
285
|
+
if (this.tasks.some((task) => !this.inScope(task, now))) this.refreshTasks();
|
|
286
|
+
if (this.pointerLayout && this.pointerLayout.width !== width) this.clearFooterLayout();
|
|
287
|
+
const theme = this.deps.theme;
|
|
288
|
+
const inner = width - 2;
|
|
289
|
+
const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
|
|
290
|
+
const threadWidth = inner - listWidth - 4;
|
|
291
|
+
const rows = this.bodyRows();
|
|
292
|
+
const footerY = rows + 1;
|
|
293
|
+
this.followSelection(rows);
|
|
294
|
+
this.pointerLayout = { width, height: rows + CHROME_ROWS, listX: 2, listWidth, threadX: listWidth + 5, threadWidth, bodyRows: rows, footerY };
|
|
295
|
+
this.listRegion.render(listWidth);
|
|
296
|
+
this.threadRegion.render(threadWidth);
|
|
297
|
+
const scope = this.deps.sessionId === undefined ? "" : `${SCOPE_LABEL[this.scope]} · `;
|
|
298
|
+
const title = `❀ Agents · ${scope}${this.counts()}`;
|
|
299
|
+
const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, title) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(title) - 3)}╮`);
|
|
300
|
+
const right = this.threadWindow(rows, threadWidth);
|
|
301
|
+
const body: string[] = [];
|
|
302
|
+
for (let row = 0; row < rows; row += 1) {
|
|
303
|
+
body.push(`${theme.fg(ROLE.FRAME, "│")} ${fit(this.taskLine(row), listWidth)} ${theme.fg(ROLE.FRAME, "│")} ${fit(right[row] ?? "", threadWidth)}${theme.fg(ROLE.FRAME, "│")}`);
|
|
304
|
+
}
|
|
305
|
+
const keys = this.keys().map(([key, label]) => `${theme.fg(ROLE.KEY, key)} ${theme.fg(ROLE.KEY_TEXT, label)}`).join(" ");
|
|
306
|
+
const footer = this.footer(keys, inner - 2);
|
|
307
|
+
const keysLine = `${theme.fg(ROLE.FRAME, "│")} ${fit(footer, inner - 2)} ${theme.fg(ROLE.FRAME, "│")}`;
|
|
308
|
+
return [top, ...body, keysLine, theme.fg(ROLE.FRAME, `╰${rule(inner)}╯`)];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
invalidate(): void {
|
|
312
|
+
this.clearFooterLayout();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private counts(): string {
|
|
316
|
+
const active = this.tasks.filter((task) => !isFinished(task.status)).length;
|
|
317
|
+
return `${active} active · ${this.tasks.length - active} finished`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private canCancel(task: TaskRecord): boolean {
|
|
321
|
+
return !isFinished(task.status) && (this.deps.canCancel?.(task) ?? true);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private canOpen(task: TaskRecord | undefined): boolean {
|
|
325
|
+
return Boolean(task?.sessionPath);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private keys(): ReadonlyArray<readonly [string, string]> {
|
|
329
|
+
const task = this.selectedTask();
|
|
330
|
+
const stop = task && this.canCancel(task) ? [["s", "Stop selected"]] as const : [];
|
|
331
|
+
const scope = this.deps.sessionId === undefined ? [] : [["a", SCOPE_KEY[this.scope]]] as const;
|
|
332
|
+
return [...KEYS.slice(0, 3), ...stop, ...scope, ...KEYS.slice(3)];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// A new scope reads from the top: selection, list window, and thread reset.
|
|
336
|
+
private toggleScope(): void {
|
|
337
|
+
this.clearFooterLayout();
|
|
338
|
+
this.scope = this.scope === VIEW_SCOPE.SESSION ? VIEW_SCOPE.ALL : VIEW_SCOPE.SESSION;
|
|
339
|
+
this.tasks = [];
|
|
340
|
+
this.selected = 0;
|
|
341
|
+
this.listScroll = 0;
|
|
342
|
+
this.hovered = undefined;
|
|
343
|
+
this.scroll = 0;
|
|
344
|
+
this.follow = true;
|
|
345
|
+
this.refreshTasks();
|
|
346
|
+
this.deps.requestRender();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The session scope: this session's active tasks plus those that finished
|
|
350
|
+
// within the last quarter hour. Everything else waits under "all sessions".
|
|
351
|
+
private inScope(task: TaskRecord, now: number): boolean {
|
|
352
|
+
if (this.scope === VIEW_SCOPE.ALL) return true;
|
|
353
|
+
if (task.parentSessionId !== this.deps.sessionId) return false;
|
|
354
|
+
return !isFinished(task.status) || task.endedAt === null || now - task.endedAt < SESSION_FINISHED_TTL_MS;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Keep the selected row inside the list window, moving the window by the
|
|
358
|
+
// least amount needed; the wheel moves the same window on its own.
|
|
359
|
+
private followSelection(rows: number): void {
|
|
360
|
+
if (this.selected < this.listScroll) this.listScroll = this.selected;
|
|
361
|
+
else if (this.selected >= this.listScroll + rows) this.listScroll = this.selected - rows + 1;
|
|
362
|
+
this.listScroll = Math.max(0, Math.min(this.listScroll, Math.max(0, this.tasks.length - rows)));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private bodyRows(): number {
|
|
366
|
+
return Math.max(MIN_BODY_ROWS, this.deps.rows - CHROME_ROWS);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private refreshTasks(): void {
|
|
370
|
+
const selectedId = this.tasks[this.selected]?.id;
|
|
371
|
+
const now = this.deps.now();
|
|
372
|
+
this.tasks = this.deps.store.list().filter((task) => this.inScope(task, now));
|
|
373
|
+
const index = this.tasks.findIndex((task) => task.id === selectedId);
|
|
374
|
+
this.selected = index === -1 ? Math.max(0, Math.min(this.selected, this.tasks.length - 1)) : index;
|
|
375
|
+
this.listScroll = Math.max(0, Math.min(this.listScroll, Math.max(0, this.tasks.length - this.bodyRows())));
|
|
376
|
+
if (this.hovered !== undefined && !this.tasks[this.hovered]) this.hovered = undefined;
|
|
377
|
+
if (index === -1) this.subscribeSelected();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private subscribeSelected(): void {
|
|
381
|
+
this.unsubscribeTask?.();
|
|
382
|
+
const task = this.selectedTask();
|
|
383
|
+
this.unsubscribeTask = task ? this.deps.store.subscribe(task.id, () => {
|
|
384
|
+
this.clearFooterLayout();
|
|
385
|
+
this.refreshTasks();
|
|
386
|
+
this.deps.requestRender();
|
|
387
|
+
}) : undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private taskLine(row: number): string {
|
|
391
|
+
if (this.tasks.length === 0) return row === 0 ? this.deps.theme.fg(ROLE.EMPTY, EMPTY_LIST) : "";
|
|
392
|
+
const index = this.listScroll + row;
|
|
393
|
+
const task = this.tasks[index];
|
|
394
|
+
if (!task) return "";
|
|
395
|
+
const theme = this.deps.theme;
|
|
396
|
+
this.taskRegion(row).render(this.pointerLayout?.listWidth ?? 1);
|
|
397
|
+
const marker = index === this.selected ? theme.fg(ROLE.SELECTED, "▸") : index === this.hovered ? theme.fg(ROLE.SELECTED, "▹") : " ";
|
|
398
|
+
const glyph = theme.fg(GLYPH_ROLE[task.status], GLYPH[task.status]);
|
|
399
|
+
const name = theme.fg(index === this.selected || index === this.hovered ? ROLE.NAME : ROLE.NAME_IDLE, task.agent);
|
|
400
|
+
const time = task.startedAt === null ? "" : theme.fg(ROLE.META, formatElapsed((task.endedAt ?? this.deps.now()) - task.startedAt));
|
|
401
|
+
return `${marker} ${glyph} ${name} ${time}`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private threadLines(thread: TaskThread, width: number): string[] {
|
|
405
|
+
if (this.cacheWidth !== width) {
|
|
406
|
+
this.cache = new WeakMap();
|
|
407
|
+
this.cacheWidth = width;
|
|
408
|
+
}
|
|
409
|
+
const lines: string[] = [];
|
|
410
|
+
if (thread.dropped > 0) lines.push(this.deps.theme.fg(ROLE.META, `… ${thread.dropped} earlier items not kept`));
|
|
411
|
+
for (const item of thread.items) {
|
|
412
|
+
let rendered = this.cache.get(item);
|
|
413
|
+
if (!rendered) {
|
|
414
|
+
rendered = itemLines(item, this.deps.theme, width);
|
|
415
|
+
this.cache.set(item, rendered);
|
|
416
|
+
}
|
|
417
|
+
lines.push(...rendered);
|
|
418
|
+
}
|
|
419
|
+
return lines;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private threadWindow(rows: number, width: number): string[] {
|
|
423
|
+
const task = this.selectedTask();
|
|
424
|
+
if (!task) return [];
|
|
425
|
+
const theme = this.deps.theme;
|
|
426
|
+
const header = theme.fg(ROLE.META, truncateToWidth(taskHeader(task, this.deps.now()), width, "…"));
|
|
427
|
+
const lines = this.threadLines(this.deps.store.thread(task.id), width);
|
|
428
|
+
if (lines.length === 0) return [header, theme.fg(ROLE.EMPTY, task.error ?? EMPTY_THREAD)];
|
|
429
|
+
const visible = rows - 1;
|
|
430
|
+
const maxScroll = Math.max(0, lines.length - visible);
|
|
431
|
+
this.scroll = this.follow ? maxScroll : Math.min(this.scroll, maxScroll);
|
|
432
|
+
return [header, ...lines.slice(this.scroll, this.scroll + visible)];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private select(index: number): void {
|
|
436
|
+
const next = Math.max(0, Math.min(this.tasks.length - 1, index));
|
|
437
|
+
if (next === this.selected) return;
|
|
438
|
+
this.selected = next;
|
|
439
|
+
this.scroll = 0;
|
|
440
|
+
this.follow = true;
|
|
441
|
+
this.subscribeSelected();
|
|
442
|
+
this.deps.requestRender();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// One page is the thread area: the body minus its header row.
|
|
446
|
+
private pageRows(): number {
|
|
447
|
+
return Math.max(1, this.bodyRows() - 1);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private scrollBy(delta: number): void {
|
|
451
|
+
this.follow = false;
|
|
452
|
+
this.scroll = Math.max(0, this.scroll + delta);
|
|
453
|
+
this.deps.requestRender();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private clearFooterLayout(): void {
|
|
457
|
+
this.pointerLayout = undefined;
|
|
458
|
+
this.hoveredControl = undefined;
|
|
459
|
+
this.pointerScope.invalidate();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private footer(keys: string, width: number): string {
|
|
463
|
+
const showButtons = visibleWidth(keys) + FOOTER_BUTTONS_WIDTH + 1 <= width;
|
|
464
|
+
const task = this.selectedTask();
|
|
465
|
+
this.followRegion.setDisabled(!showButtons || !task);
|
|
466
|
+
this.openRegion.setDisabled(!showButtons || !this.canOpen(task));
|
|
467
|
+
if (!showButtons) return keys;
|
|
468
|
+
this.followRegion.render(FOLLOW_BUTTON.length);
|
|
469
|
+
this.openRegion.render(OPEN_BUTTON.length);
|
|
470
|
+
const buttonX = 2 + width - FOOTER_BUTTONS_WIDTH;
|
|
471
|
+
if (this.pointerLayout) {
|
|
472
|
+
this.pointerLayout.followButton = { x: buttonX, width: FOLLOW_BUTTON.length };
|
|
473
|
+
this.pointerLayout.openButton = { x: buttonX + FOLLOW_BUTTON.length + 1, width: OPEN_BUTTON.length };
|
|
474
|
+
}
|
|
475
|
+
const followRole = task && this.hoveredControl === "follow" ? "warning" : task ? ROLE.KEY : ROLE.META;
|
|
476
|
+
const openRole = this.canOpen(task) && this.hoveredControl === "open" ? "warning" : this.canOpen(task) ? ROLE.KEY : ROLE.META;
|
|
477
|
+
return `${fit(keys, width - FOOTER_BUTTONS_WIDTH - 1)} ${this.deps.theme.fg(followRole, FOLLOW_BUTTON)} ${this.deps.theme.fg(openRole, OPEN_BUTTON)}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private isInButton(x: number, button: PointerButtonLayout | undefined): boolean {
|
|
481
|
+
return button !== undefined && x >= button.x && x < button.x + button.width;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private hoverControl(control: "follow" | "open"): TuiMouseEventResult {
|
|
485
|
+
if (this.hoveredControl === control) return { handled: true };
|
|
486
|
+
this.hoveredControl = control;
|
|
487
|
+
return { handled: true, render: true };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private clearHoveredControl(control: "follow" | "open"): void {
|
|
491
|
+
if (this.hoveredControl !== control) return;
|
|
492
|
+
this.hoveredControl = undefined;
|
|
493
|
+
this.deps.requestRender();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private clickFollow(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
497
|
+
if (event.button !== "left" || !this.selectedTask()) return undefined;
|
|
498
|
+
this.follow = true;
|
|
499
|
+
return { handled: true, render: true };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private clickOpen(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
503
|
+
if (event.button !== "left") return undefined;
|
|
504
|
+
const task = this.selectedTask();
|
|
505
|
+
if (!this.canOpen(task)) return undefined;
|
|
506
|
+
this.deps.onOpen(task!);
|
|
507
|
+
return { handled: true, render: true };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private taskRegion(row: number): NativePointerRegion {
|
|
511
|
+
let region = this.taskRegions.get(row);
|
|
512
|
+
if (!region) {
|
|
513
|
+
region = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
514
|
+
onHover: () => this.hoverTask(row),
|
|
515
|
+
onLeave: () => this.clearHoveredTask(row),
|
|
516
|
+
onClick: (event) => this.clickTask(row, event),
|
|
517
|
+
});
|
|
518
|
+
this.taskRegions.set(row, region);
|
|
519
|
+
}
|
|
520
|
+
return region;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private hoverTask(row: number): TuiMouseEventResult | undefined {
|
|
524
|
+
const index = this.listScroll + row;
|
|
525
|
+
if (!this.tasks[index] || this.hovered === index) return { handled: true };
|
|
526
|
+
this.hovered = index;
|
|
527
|
+
return { handled: true, render: true };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private clearHoveredTask(row: number): void {
|
|
531
|
+
if (this.hovered !== this.listScroll + row) return;
|
|
532
|
+
this.hovered = undefined;
|
|
533
|
+
this.deps.requestRender();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private clickTask(row: number, event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
537
|
+
if (event.button !== "left") return undefined;
|
|
538
|
+
this.select(this.listScroll + row);
|
|
539
|
+
return { handled: true, render: true };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private wheelList(event: TuiMouseEvent): TuiMouseEventResult {
|
|
543
|
+
const delta = event.wheelDelta ?? 0;
|
|
544
|
+
const next = Math.max(0, Math.min(Math.max(0, this.tasks.length - this.bodyRows()), this.listScroll + delta));
|
|
545
|
+
if (next === this.listScroll) return { handled: true, render: false };
|
|
546
|
+
this.listScroll = next;
|
|
547
|
+
this.hovered = undefined;
|
|
548
|
+
return { handled: true, render: true };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private wheelThread(event: TuiMouseEvent): TuiMouseEventResult {
|
|
552
|
+
const delta = event.wheelDelta ?? 0;
|
|
553
|
+
if (delta === 0) return { handled: true, render: false };
|
|
554
|
+
this.scrollBy(delta);
|
|
555
|
+
return { handled: true, render: true };
|
|
556
|
+
}
|
|
557
|
+
}
|