gentle-pi 2.4.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 +170 -12
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/orchestrator-delegation.md +19 -9
- package/assets/orchestrator.md +5 -5
- package/docs/delegated-verification.md +25 -0
- 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 +898 -79
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- 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 +88 -12
- 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-integration-v2.ts +58 -8
- 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/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 +87 -11
- package/runtime/review-integration-v2.mjs +58 -8
- 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 +6 -2
- package/skills/_shared/review-ledger-contract.md +3 -1
- 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/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 +28 -12
- 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/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 +16 -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 +69 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +17 -6
- 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-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +3 -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 +25 -0
- package/tests/review-host-relay-routing.test.ts +20 -4
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- 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/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
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
|
+
import { emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
5
|
+
import { AgentsView, itemLines, taskHeader } from "../lib/agents-view.ts";
|
|
6
|
+
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
7
|
+
|
|
8
|
+
// Gentle Agents overlay: list left, selected thread right, tail-following,
|
|
9
|
+
// and only the selected task subscribed.
|
|
10
|
+
|
|
11
|
+
const plainTheme = { fg: (_color: string, text: string) => text };
|
|
12
|
+
|
|
13
|
+
function task(id: string, overrides: Partial<TaskRecord> = {}): TaskRecord {
|
|
14
|
+
return { id, agent: "explore", mode: "task", prompt: "p", label: "p", cwd: "/r", parentSessionId: "s", status: TASK_STATUS.RUNNING, createdAt: 1000, startedAt: 1000, endedAt: null, model: "gpt-5.6-terra", thinking: undefined, sessionPath: "/sessions/x.jsonl", error: null, result: null, lastStep: "grep", lastActivityAt: 1000, turns: 0, toolCalls: 0, tokens: 34_000, cost: 0.27, ...overrides };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function harness(rows = 8, sessionId?: string) {
|
|
18
|
+
const store = new TaskStore();
|
|
19
|
+
const events: string[] = [];
|
|
20
|
+
let renders = 0;
|
|
21
|
+
const view = new AgentsView({
|
|
22
|
+
theme: plainTheme,
|
|
23
|
+
rows,
|
|
24
|
+
store,
|
|
25
|
+
sessionId,
|
|
26
|
+
now: () => 61_000,
|
|
27
|
+
onCancel: (entry) => events.push(`cancel:${entry.id}`),
|
|
28
|
+
onOpen: (entry) => events.push(`open:${entry.id}`),
|
|
29
|
+
onClose: () => events.push("close"),
|
|
30
|
+
requestRender: () => (renders += 1),
|
|
31
|
+
});
|
|
32
|
+
return { store, view, events, renders: () => renders };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("itemLines renders text, whole thinking blocks, whole tool output, and notes", () => {
|
|
36
|
+
assert.deepEqual(itemLines({ kind: "text", text: "one two three four" }, plainTheme, 9), ["one two", "three", "four"]);
|
|
37
|
+
assert.deepEqual(itemLines({ kind: "thinking", text: "deep\nthoughts" }, plainTheme, 20), ["∴ deep", " thoughts"]);
|
|
38
|
+
const output = Array.from({ length: 10 }, (_, index) => `line ${index}`).join("\n");
|
|
39
|
+
const tool = itemLines({ kind: "tool", callId: "c", name: "bash", args: { command: "ls -la" }, output, running: true, isError: false }, plainTheme, 30);
|
|
40
|
+
assert.equal(tool[0], "▸ bash ls -la");
|
|
41
|
+
assert.equal(tool.length, 12, "head, every output line, running marker");
|
|
42
|
+
assert.equal(tool[1], " line 0");
|
|
43
|
+
assert.equal(tool[10], " line 9");
|
|
44
|
+
assert.equal(tool[11], " …");
|
|
45
|
+
// A long output line wraps instead of being clipped, so nothing is hidden.
|
|
46
|
+
const wide = itemLines({ kind: "tool", callId: "c", name: "bash", args: {}, output: "alpha beta gamma delta", running: false, isError: false }, plainTheme, 12);
|
|
47
|
+
assert.deepEqual(wide.slice(1), [" alpha beta", " gamma", " delta"]);
|
|
48
|
+
assert.deepEqual(itemLines({ kind: "note", text: "error: boom" }, plainTheme, 30), ["· error: boom"]);
|
|
49
|
+
assert.equal(taskHeader(task("a"), 61_000), "explore · running · gpt-5.6-terra · 34k · $0.27 · 1m00s");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("AgentsView renders the frame with the task list and the selected thread's tail, at width", () => {
|
|
53
|
+
const { store, view } = harness(8);
|
|
54
|
+
store.add(task("a"));
|
|
55
|
+
store.add(task("b", { agent: "worker", status: TASK_STATUS.COMPLETED, endedAt: 5000, createdAt: 900, lastActivityAt: 900 }));
|
|
56
|
+
for (let index = 0; index < 6; index += 1) store.apply("a", { type: TASK_EVENT.TEXT, text: `line ${index}\n` }, 2000);
|
|
57
|
+
const lines = view.render(90);
|
|
58
|
+
for (const line of lines) assert.equal(visibleWidth(line), 90, `"${stripAnsi(line)}" is not 90 wide`);
|
|
59
|
+
const plain = lines.map(stripAnsi);
|
|
60
|
+
assert.equal(plain.length, 8);
|
|
61
|
+
assert.match(plain[0], /^╭─ ❀ Agents · 1 active · 1 finished ─+╮$/);
|
|
62
|
+
assert.match(plain[1], /^│ ▸ ◐ explore 1m00s +│ explore · running · gpt-5\.6-terra · 34k · \$0\.27 · 1m00s +│$/);
|
|
63
|
+
assert.match(plain[2], /^│ ✓ worker 4s +│ line 3 +│$/, "the thread window follows the tail");
|
|
64
|
+
assert.match(plain[4], /line 5/);
|
|
65
|
+
assert.match(plain[6], /j\/k task .* esc close/);
|
|
66
|
+
assert.match(plain[7], /^╰─+╯$/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("AgentsView keys move the selection, scroll, follow, cancel, open, and close", () => {
|
|
70
|
+
const { store, view, events } = harness(8);
|
|
71
|
+
store.add(task("a"));
|
|
72
|
+
store.add(task("b", { createdAt: 900, lastActivityAt: 900, status: TASK_STATUS.COMPLETED, endedAt: 5000 }));
|
|
73
|
+
assert.equal(view.selectedTask()?.id, "a");
|
|
74
|
+
view.handleInput("j");
|
|
75
|
+
assert.equal(view.selectedTask()?.id, "b");
|
|
76
|
+
view.handleInput("c");
|
|
77
|
+
assert.deepEqual(events, [], "finished tasks cannot be cancelled");
|
|
78
|
+
view.handleInput("k");
|
|
79
|
+
view.handleInput("c");
|
|
80
|
+
view.handleInput("o");
|
|
81
|
+
view.handleInput("\x1b");
|
|
82
|
+
assert.deepEqual(events, ["cancel:a", "open:a", "close"]);
|
|
83
|
+
for (let index = 0; index < 12; index += 1) store.apply("a", { type: TASK_EVENT.TEXT, text: `l${index}\n` }, 2000);
|
|
84
|
+
view.handleInput("\x1b[5~");
|
|
85
|
+
assert.match(stripAnsi(view.render(80)[2]), /│ l0 +│$/, "page up leaves follow mode and shows the top");
|
|
86
|
+
view.handleInput("\x0a");
|
|
87
|
+
assert.match(stripAnsi(view.render(80)[2]), /│ l4 +│$/, "ctrl+j scrolls one page down");
|
|
88
|
+
view.handleInput("\x0b");
|
|
89
|
+
assert.match(stripAnsi(view.render(80)[2]), /│ l0 +│$/, "ctrl+k scrolls one page up");
|
|
90
|
+
view.handleInput("f");
|
|
91
|
+
assert.match(stripAnsi(view.render(80)[2]), /│ l9 +│$/, "f follows the tail again");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("AgentsView subscribes only to the selected task and survives an empty store", () => {
|
|
95
|
+
const { store, view, renders } = harness(6);
|
|
96
|
+
assert.match(stripAnsi(view.render(60)[1]), /no tasks yet/);
|
|
97
|
+
store.add(task("a"));
|
|
98
|
+
store.add(task("b", { createdAt: 900, lastActivityAt: 900 }));
|
|
99
|
+
const before = renders();
|
|
100
|
+
store.apply("b", { type: TASK_EVENT.TEXT, text: "quiet" }, 2000);
|
|
101
|
+
assert.equal(renders(), before, "an event on the unselected task renders nothing");
|
|
102
|
+
store.apply("a", { type: TASK_EVENT.TEXT, text: "loud" }, 2000);
|
|
103
|
+
assert.equal(renders(), before + 1);
|
|
104
|
+
view.dispose();
|
|
105
|
+
store.apply("a", { type: TASK_EVENT.TEXT, text: "after" }, 2000);
|
|
106
|
+
assert.equal(renders(), before + 1, "disposed views stay quiet");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
function mouse(x: number, y: number, width: number, height: number, type: TuiMouseEvent["type"] = "move", wheelDelta?: number): TuiMouseEvent {
|
|
110
|
+
return { type, button: type === "move" || type === "wheel" ? "none" : "left", x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false, wheelDelta };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
test("AgentsView pointer regions hover and select task rows without activating, and keep list and thread wheels independent", () => {
|
|
114
|
+
const { store, view, events } = harness(6);
|
|
115
|
+
for (const id of ["a", "b", "c", "d", "e"]) store.add(task(id, { agent: id }));
|
|
116
|
+
for (let index = 0; index < 8; index += 1) store.apply("b", { type: TASK_EVENT.TEXT, text: `thread ${index}\n` }, 2000);
|
|
117
|
+
const lines = view.render(80);
|
|
118
|
+
|
|
119
|
+
assert.equal(view.handleMouse(mouse(4, 2, 80, lines.length))?.handled, true, "hover consumes only the task row");
|
|
120
|
+
assert.equal(view.selectedTask()?.id, "a", "hover never changes keyboard selection or the displayed thread");
|
|
121
|
+
assert.equal(view.handleMouse(mouse(4, 2, 80, lines.length, "click"))?.handled, true);
|
|
122
|
+
assert.equal(view.selectedTask()?.id, "b", "click selects the task and displays its thread");
|
|
123
|
+
assert.deepEqual(events, [], "click selects the task only; it never opens or cancels");
|
|
124
|
+
view.render(80);
|
|
125
|
+
assert.equal(view.handleMouse(mouse(50, 2, 80, lines.length, "wheel", -1))?.handled, true, "thread wheel is handled by its viewport");
|
|
126
|
+
assert.match(stripAnsi(view.render(80)[2]), /thread 6/, "thread wheel moves only the selected thread");
|
|
127
|
+
assert.equal(view.handleMouse(mouse(4, 2, 80, lines.length, "wheel", 1))?.handled, true, "list wheel is handled by its viewport");
|
|
128
|
+
assert.match(stripAnsi(view.render(80)[1]), / b /, "list wheel changes only the task-list viewport");
|
|
129
|
+
assert.equal(view.handleMouse(mouse(50, 2, 80, lines.length, "click")), undefined, "thread clicks are inert");
|
|
130
|
+
view.dispose();
|
|
131
|
+
assert.equal(view.handleMouse(mouse(4, 2, 80, lines.length)), undefined, "late pointer events are inert after disposal");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("AgentsView clears hover on leave, list scrolling, resize, updates, empty lists, and disposal", () => {
|
|
135
|
+
const { store, view } = harness(6);
|
|
136
|
+
store.add(task("a"));
|
|
137
|
+
store.add(task("b", { agent: "b" }));
|
|
138
|
+
store.add(task("c", { agent: "c" }));
|
|
139
|
+
store.add(task("d", { agent: "d" }));
|
|
140
|
+
const lines = view.render(80);
|
|
141
|
+
const observer = view.mouseObserver();
|
|
142
|
+
const dispatch = (event: TuiMouseEvent) => {
|
|
143
|
+
observer.beforeMouse(event);
|
|
144
|
+
try {
|
|
145
|
+
return view.handleMouse(event);
|
|
146
|
+
} finally {
|
|
147
|
+
observer.afterMouse(event);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
dispatch(mouse(4, 2, 80, lines.length));
|
|
151
|
+
assert.match(stripAnsi(view.render(80)[2]), /▹/, "the hovered row is styled without changing selection");
|
|
152
|
+
dispatch(mouse(4, 2, 80, lines.length, "wheel", -1));
|
|
153
|
+
assert.match(stripAnsi(view.render(80)[2]), /▹/, "a list wheel event at its boundary preserves hover");
|
|
154
|
+
dispatch(mouse(4, 2, 80, lines.length, "wheel", 1));
|
|
155
|
+
assert.doesNotMatch(stripAnsi(view.render(80).join("\n")), /▹/, "list scrolling clears hover so it cannot remain on the task formerly under the pointer");
|
|
156
|
+
dispatch(mouse(4, 2, 80, lines.length));
|
|
157
|
+
dispatch(mouse(50, 2, 80, lines.length));
|
|
158
|
+
assert.doesNotMatch(stripAnsi(view.render(80)[2]), /▹/, "the root observer clears hover outside a child region");
|
|
159
|
+
dispatch(mouse(4, 2, 80, lines.length));
|
|
160
|
+
view.render(81);
|
|
161
|
+
assert.doesNotMatch(stripAnsi(view.render(81)[2]), /▹/, "resize clears hover before the next frame");
|
|
162
|
+
dispatch(mouse(4, 2, 81, lines.length));
|
|
163
|
+
store.apply("a", { type: TASK_EVENT.TEXT, text: "update" }, 2000);
|
|
164
|
+
assert.doesNotMatch(stripAnsi(view.render(81)[2]), /▹/, "task updates clear hover");
|
|
165
|
+
view.dispose();
|
|
166
|
+
const { view: empty } = harness(6);
|
|
167
|
+
empty.render(80);
|
|
168
|
+
assert.equal(empty.handleMouse(mouse(4, 1, 80, 6)), undefined, "empty task rows are inert");
|
|
169
|
+
empty.dispose();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("AgentsView advertises s to stop an active selection, retains c as an alias, and hides stopping for finished tasks", () => {
|
|
173
|
+
const { store, view, events } = harness(8);
|
|
174
|
+
store.add(task("active"));
|
|
175
|
+
assert.match(stripAnsi(view.render(80).at(-2) ?? ""), /s Stop selected/);
|
|
176
|
+
view.handleInput("s");
|
|
177
|
+
view.handleInput("c");
|
|
178
|
+
assert.deepEqual(events, ["cancel:active", "cancel:active"]);
|
|
179
|
+
store.update("active", { status: TASK_STATUS.CANCELLED, endedAt: 2000 });
|
|
180
|
+
assert.doesNotMatch(stripAnsi(view.render(80).at(-2) ?? ""), /Stop selected/);
|
|
181
|
+
view.handleInput("s");
|
|
182
|
+
assert.deepEqual(events, ["cancel:active", "cancel:active"], "finished tasks never stop");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("AgentsView Escape and q close an active selection without cancelling it", () => {
|
|
186
|
+
const { store, view, events } = harness(8);
|
|
187
|
+
store.add(task("active"));
|
|
188
|
+
view.handleInput("\x1b");
|
|
189
|
+
view.handleInput("q");
|
|
190
|
+
assert.deepEqual(events, ["close", "close"], "close keys never invoke selected-task cancellation");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("AgentsView scrolls the task list so the selection stays visible when there are more tasks than rows", () => {
|
|
194
|
+
const { store, view } = harness(6);
|
|
195
|
+
for (let index = 0; index < 6; index += 1) store.add(task(`t${index}`, { agent: `agent${index}`, createdAt: 1000 - index, lastActivityAt: 1000 - index }));
|
|
196
|
+
const listed = () => view.render(80).slice(1, 4).map((line) => stripAnsi(line).slice(0, 24));
|
|
197
|
+
assert.match(listed()[0], /▸ ◐ agent0/);
|
|
198
|
+
assert.match(listed()[2], /agent2/);
|
|
199
|
+
for (let index = 0; index < 4; index += 1) view.handleInput("j");
|
|
200
|
+
assert.equal(view.selectedTask()?.id, "t4");
|
|
201
|
+
assert.match(listed()[2], /▸ ◐ agent4/, "the list scrolls down until the selection is the last visible row");
|
|
202
|
+
assert.match(listed()[0], /agent2/);
|
|
203
|
+
view.handleInput("j");
|
|
204
|
+
view.handleInput("j");
|
|
205
|
+
assert.equal(view.selectedTask()?.id, "t5", "the selection stops at the last task");
|
|
206
|
+
assert.match(listed()[2], /▸ ◐ agent5/);
|
|
207
|
+
for (let index = 0; index < 4; index += 1) view.handleInput("k");
|
|
208
|
+
assert.match(listed()[0], /▸ ◐ agent1/, "moving up scrolls the list back");
|
|
209
|
+
assert.match(listed()[2], /agent3/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("AgentsView lists the active session's recent tasks by default and a toggles every session", () => {
|
|
213
|
+
const { store, view } = harness(8, "s");
|
|
214
|
+
store.add(task("mine", { agent: "mine" }));
|
|
215
|
+
store.add(task("theirs", { agent: "theirs", parentSessionId: "other", createdAt: 900, lastActivityAt: 900 }));
|
|
216
|
+
store.add(task("fresh", { agent: "fresh", status: TASK_STATUS.COMPLETED, endedAt: 61_000 - 60_000, createdAt: 850, lastActivityAt: 850 }));
|
|
217
|
+
store.add(task("stale", { agent: "stale", status: TASK_STATUS.COMPLETED, endedAt: 61_000 - 16 * 60_000, createdAt: 800, lastActivityAt: 800 }));
|
|
218
|
+
const names = () => view.render(80).map(stripAnsi).filter((line) => /[◐✓] /.test(line)).map((line) => line.match(/[◐✓] (\w+)/)?.[1]);
|
|
219
|
+
let plain = view.render(80).map(stripAnsi);
|
|
220
|
+
assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active · 1 finished ─+╮$/);
|
|
221
|
+
assert.deepEqual(names(), ["mine", "fresh"], "another session's task and one finished over fifteen minutes ago stay out");
|
|
222
|
+
assert.match(plain.at(-2) ?? "", /a all sessions/);
|
|
223
|
+
view.handleInput("a");
|
|
224
|
+
plain = view.render(80).map(stripAnsi);
|
|
225
|
+
assert.match(plain[0], /^╭─ ❀ Agents · all sessions · 2 active · 2 finished ─+╮$/);
|
|
226
|
+
assert.deepEqual(names(), ["mine", "theirs", "fresh", "stale"]);
|
|
227
|
+
assert.match(plain.at(-2) ?? "", /a this session/);
|
|
228
|
+
view.handleInput("j");
|
|
229
|
+
assert.equal(view.selectedTask()?.id, "theirs");
|
|
230
|
+
view.handleInput("a");
|
|
231
|
+
assert.equal(view.selectedTask()?.id, "mine", "a new scope reads from the top");
|
|
232
|
+
assert.deepEqual(names(), ["mine", "fresh"]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("AgentsView footer buttons follow, open only a session-backed selection, and clear stale geometry", () => {
|
|
236
|
+
const { store, view, events } = harness(8, "s");
|
|
237
|
+
store.add(task("a", { sessionPath: "/sessions/a.jsonl" }));
|
|
238
|
+
for (let index = 0; index < 12; index += 1) store.apply("a", { type: TASK_EVENT.TEXT, text: `line ${index}\n` }, 2000);
|
|
239
|
+
const width = 160;
|
|
240
|
+
const lines = view.render(width);
|
|
241
|
+
const footerY = lines.length - 2;
|
|
242
|
+
const followX = 131;
|
|
243
|
+
const openX = 142;
|
|
244
|
+
assert.match(stripAnsi(lines.at(-2) ?? ""), /\[ Follow \].*\[ Open session \]/, "buttons render only after the full key-hint row fits");
|
|
245
|
+
|
|
246
|
+
view.handleInput("\x1b[5~");
|
|
247
|
+
assert.match(stripAnsi(view.render(width)[2]), /line 5/, "page up leaves follow mode before the button restores it");
|
|
248
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length))?.render, true, "button hover requests a distinct frame");
|
|
249
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length, "press")), undefined, "press is inert");
|
|
250
|
+
assert.equal(view.handleMouse({ ...mouse(followX, footerY, width, lines.length, "click"), button: "right" }), undefined, "right click is inert");
|
|
251
|
+
assert.equal(view.handleMouse({ ...mouse(followX, footerY, width, lines.length, "click"), button: "middle" }), undefined, "middle click is inert");
|
|
252
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length, "click"))?.handled, true);
|
|
253
|
+
assert.match(stripAnsi(view.render(width)[2]), /line 9/, "Follow returns the selected thread to its tail");
|
|
254
|
+
assert.equal(view.handleMouse(mouse(openX, footerY, width, lines.length, "click"))?.handled, true);
|
|
255
|
+
assert.deepEqual(events, ["open:a"], "Open delegates the selected task to the existing callback");
|
|
256
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width + 1, lines.length, "click")), undefined, "a pre-render resize makes the prior footer geometry inert");
|
|
257
|
+
|
|
258
|
+
store.update("a", { sessionPath: null });
|
|
259
|
+
view.render(width);
|
|
260
|
+
assert.equal(view.handleMouse(mouse(openX, footerY, width, lines.length, "click")), undefined, "Open is disabled when the selected task loses its session path");
|
|
261
|
+
assert.deepEqual(events, ["open:a"]);
|
|
262
|
+
view.render(200);
|
|
263
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, 200, lines.length, "click")), undefined, "resize discards the old footer geometry before routing clicks");
|
|
264
|
+
assert.doesNotMatch(stripAnsi(view.render(44).at(-2) ?? ""), /\[ Follow \]|\[ Open session \]/, "buttons hide instead of truncating when the rendered key hints do not fit");
|
|
265
|
+
view.handleInput("a");
|
|
266
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length, "click")), undefined, "a scope change clears old footer geometry until the next frame");
|
|
267
|
+
view.dispose();
|
|
268
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length, "click")), undefined, "disposed footer controls stay inert");
|
|
269
|
+
const { view: empty } = harness(8, "s");
|
|
270
|
+
const emptyLines = empty.render(width);
|
|
271
|
+
assert.equal(empty.handleMouse(mouse(followX, footerY, width, emptyLines.length, "click")), undefined, "Follow is disabled with no selected task");
|
|
272
|
+
assert.equal(empty.handleMouse(mouse(openX, footerY, width, emptyLines.length, "click")), undefined, "Open is disabled with no selected session");
|
|
273
|
+
empty.dispose();
|
|
274
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { TASK_STATUS, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
5
|
+
import { formatElapsed, renderAgentsCard, widgetExpiryMs, widgetRows, widgetTasks } from "../lib/agents-widget.ts";
|
|
6
|
+
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
7
|
+
|
|
8
|
+
// Gentle Agents widget: the card above the editor that shows what the
|
|
9
|
+
// subagents are doing, drawn from task records only (never from threads).
|
|
10
|
+
// Layout: glyph, agent, task summary (wrapped), then model · tokens · cost · time.
|
|
11
|
+
|
|
12
|
+
const plainTheme = { fg: (_color: string, text: string) => text };
|
|
13
|
+
|
|
14
|
+
function task(overrides: Partial<TaskRecord>): TaskRecord {
|
|
15
|
+
return { id: "t", agent: "sdd-explore", mode: "task", prompt: "map footer data sources", label: "map footer data sources", cwd: "/r", parentSessionId: "s", status: TASK_STATUS.RUNNING, createdAt: 1000, startedAt: 1000, endedAt: null, model: "anthropic/claude-sonnet-5", thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "grep", lastActivityAt: 1000, turns: 0, toolCalls: 0, tokens: 34_000, cost: 0.27, ...overrides };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("formatElapsed renders seconds, minutes, and hours compactly", () => {
|
|
19
|
+
assert.equal(formatElapsed(4_000), "4s");
|
|
20
|
+
assert.equal(formatElapsed(65_000), "1m05s");
|
|
21
|
+
assert.equal(formatElapsed(3_720_000), "1h02m");
|
|
22
|
+
assert.equal(formatElapsed(-5), "0s");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("widgetTasks keeps active tasks in start order and only recently finished ones", () => {
|
|
26
|
+
const tasks = [
|
|
27
|
+
task({ id: "old-done", status: TASK_STATUS.COMPLETED, endedAt: 10_000 }),
|
|
28
|
+
task({ id: "new-done", status: TASK_STATUS.FAILED, endedAt: 95_000 }),
|
|
29
|
+
task({ id: "queued", status: TASK_STATUS.QUEUED, createdAt: 3000, startedAt: null }),
|
|
30
|
+
task({ id: "running", createdAt: 2000, startedAt: 2000 }),
|
|
31
|
+
];
|
|
32
|
+
assert.deepEqual(widgetTasks(tasks, 100_000).map((entry) => entry.id), ["new-done", "running", "queued"]);
|
|
33
|
+
assert.deepEqual(widgetTasks([], 100_000), []);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("widgetExpiryMs says how long until the next finished row leaves the card", () => {
|
|
37
|
+
const running = task({ id: "running", createdAt: 2000, startedAt: 2000 });
|
|
38
|
+
const done = task({ id: "done", status: TASK_STATUS.COMPLETED, endedAt: 95_000 });
|
|
39
|
+
const later = task({ id: "later", status: TASK_STATUS.COMPLETED, endedAt: 99_000 });
|
|
40
|
+
assert.equal(widgetExpiryMs([running, done, later], 100_000), 55_000, "the oldest shown row expires first");
|
|
41
|
+
assert.equal(widgetExpiryMs([done], 155_000), undefined, "a row past its minute is already gone");
|
|
42
|
+
assert.equal(widgetExpiryMs([running], 100_000), undefined, "active rows never expire");
|
|
43
|
+
assert.equal(widgetExpiryMs([], 100_000), undefined);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("renderAgentsCard draws columns for agent, task, and model · tokens · cost · time, with the batch time in the rule", () => {
|
|
47
|
+
const tasks = [
|
|
48
|
+
task({ id: "a", status: TASK_STATUS.COMPLETED, startedAt: 1000, endedAt: 26_000 }),
|
|
49
|
+
task({ id: "b", agent: "sdd-apply", label: "write gentle-shell footer", startedAt: 44_000, tokens: 12_000, cost: 0.09 }),
|
|
50
|
+
];
|
|
51
|
+
const lines = renderAgentsCard(tasks, plainTheme, 84, 85_000, { collapsed: false });
|
|
52
|
+
for (const line of lines) assert.equal(visibleWidth(line), 84, `"${stripAnsi(line)}" is not 84 wide`);
|
|
53
|
+
const plain = lines.map(stripAnsi);
|
|
54
|
+
assert.match(plain[0], /^╭─ ❀ Agents · 1 active · 1 done ─+ 1m24s ╮$/);
|
|
55
|
+
assert.match(plain[1], /^│ ✓ sdd-explore map footer data sources +claude-sonnet-5 · 34k · \$0\.27 · 25s │$/);
|
|
56
|
+
assert.match(plain[2], /^│ ◐ sdd-apply write gentle-shell footer +claude-sonnet-5 · 12k · \$0\.09 · 41s │$/);
|
|
57
|
+
assert.match(plain[3], /^╰─+╯$/);
|
|
58
|
+
assert.deepEqual(renderAgentsCard([], plainTheme, 60, 0, { collapsed: false }), []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("renderAgentsCard keeps every task on one line, clipping long labels, and drops the task column when the card is narrow", () => {
|
|
62
|
+
const tasks = [task({ id: "a", label: "write the gentle shell footer and all of its tests before lunch" })];
|
|
63
|
+
const wide = renderAgentsCard(tasks, plainTheme, 84, 5_000, { collapsed: false }).map(stripAnsi);
|
|
64
|
+
assert.equal(wide.length, 3);
|
|
65
|
+
assert.match(wide[1], /^│ ◐ sdd-explore write the gentle shell foot… +claude-sonnet-5 · 34k · \$0\.27 · 4s │$/);
|
|
66
|
+
const narrow = renderAgentsCard(tasks, plainTheme, 44, 5_000, { collapsed: false }).map(stripAnsi);
|
|
67
|
+
assert.equal(narrow.length, 3);
|
|
68
|
+
assert.match(narrow[1], /^│ ◐ sdd-explore +34k · \$0\.27 · 4s │$/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("renderAgentsCard shows questions and failures in place of the task, and collapses to the first row", () => {
|
|
72
|
+
const tasks = [
|
|
73
|
+
task({ id: "a", status: TASK_STATUS.WAITING, lastStep: "asked: Delete?", tokens: 0, cost: 0 }),
|
|
74
|
+
task({ id: "b", status: TASK_STATUS.FAILED, endedAt: 2000, error: "pi exited with code 1", lastStep: "pi exited with code 1" }),
|
|
75
|
+
task({ id: "c", status: TASK_STATUS.QUEUED, createdAt: 1500, startedAt: null, tokens: 0, cost: 0 }),
|
|
76
|
+
];
|
|
77
|
+
const plain = renderAgentsCard(tasks, plainTheme, 80, 3000, { collapsed: false }).map(stripAnsi);
|
|
78
|
+
assert.match(plain[0], /^╭─ ❀ Agents · 1 waiting · 1 queued · 1 failed ─+ 2s ╮$/);
|
|
79
|
+
assert.match(plain[1], /^│ \? sdd-explore asked: Delete\? +claude-sonnet-5 · 2s │$/);
|
|
80
|
+
assert.match(plain[2], /^│ ✗ sdd-explore pi exited with code 1 +claude-sonnet-5 · 34k · \$0\.27 · 1s │$/);
|
|
81
|
+
assert.match(plain[3], /^│ ○ sdd-explore map footer data sources +queued │$/);
|
|
82
|
+
const collapsed = renderAgentsCard(tasks, plainTheme, 80, 3000, { collapsed: true, collapseKey: "ctrl+shift+a" }).map(stripAnsi);
|
|
83
|
+
assert.equal(collapsed.length, 3);
|
|
84
|
+
assert.match(collapsed[0], /ctrl\+shift\+a expand ╮$/);
|
|
85
|
+
assert.match(collapsed[1], /^│ \? sdd-explore asked: Delete\?/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("widgetRows caps the card at a quarter of the terminal, between three and eight rows", () => {
|
|
89
|
+
assert.equal(widgetRows(40), 8, "tall terminals still stop at eight rows");
|
|
90
|
+
assert.equal(widgetRows(24), 6);
|
|
91
|
+
assert.equal(widgetRows(10), 3, "short terminals keep three rows");
|
|
92
|
+
assert.equal(widgetRows(undefined), 8, "without a terminal the widest default applies");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("renderAgentsCard caps the rows at maxRows, keeps active tasks ahead of finished ones, and says how many are hidden", () => {
|
|
96
|
+
const tasks = [
|
|
97
|
+
task({ id: "done", status: TASK_STATUS.COMPLETED, startedAt: 500, endedAt: 2000 }),
|
|
98
|
+
...Array.from({ length: 6 }, (_, index) => task({ id: `run${index}`, label: `job ${index}`, createdAt: 1000 + index, startedAt: 1000 + index })),
|
|
99
|
+
];
|
|
100
|
+
const plain = renderAgentsCard(tasks, plainTheme, 80, 5000, { collapsed: false, maxRows: 4, viewKey: "alt+a" }).map(stripAnsi);
|
|
101
|
+
assert.equal(plain.length, 6, "frame plus four rows");
|
|
102
|
+
assert.match(plain[0], /6 active · 1 done/, "the title still counts every shown task");
|
|
103
|
+
assert.match(plain[1], /◐ sdd-explore job 0/);
|
|
104
|
+
assert.match(plain[3], /◐ sdd-explore job 2/);
|
|
105
|
+
assert.match(plain[4], /^│ … 4 more · alt\+a to view +│$/, "the finished row gives way to running ones");
|
|
106
|
+
assert.equal(renderAgentsCard(tasks, plainTheme, 80, 5000, { collapsed: false }).length, 9, "without a cap every row shows");
|
|
107
|
+
assert.equal(renderAgentsCard(tasks, plainTheme, 80, 5000, { collapsed: false, maxRows: 7 }).length, 9, "at the cap no row is hidden");
|
|
108
|
+
assert.match(renderAgentsCard(tasks, plainTheme, 80, 5000, { collapsed: false, maxRows: 4 }).map(stripAnsi)[4], /^│ … 4 more +│$/, "no view key, no hint");
|
|
109
|
+
const waiting = [...tasks, task({ id: "ask", status: TASK_STATUS.WAITING, lastStep: "asked: Delete?", createdAt: 4000, startedAt: 4000 })];
|
|
110
|
+
assert.match(renderAgentsCard(waiting, plainTheme, 80, 5000, { collapsed: false, maxRows: 2 }).map(stripAnsi)[1], /^│ \? sdd-explore asked: Delete\?/, "a question is never hidden");
|
|
111
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
3
4
|
import askUserChoice from "../extensions/ask-user-choice.ts";
|
|
4
5
|
|
|
5
6
|
interface ChoiceResult {
|
|
@@ -35,6 +36,35 @@ interface ChoiceLifecycleEvent {
|
|
|
35
36
|
data: { active: boolean };
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
interface NativeChoiceComponent {
|
|
40
|
+
render(width: number): string[];
|
|
41
|
+
handleInput(data: string): void;
|
|
42
|
+
handleMouse?: (event: Record<string, unknown>) => NativeChoiceMouseResult | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface NativeChoiceMouseResult {
|
|
46
|
+
handled?: boolean;
|
|
47
|
+
focus?: boolean;
|
|
48
|
+
render?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ChoiceTui {
|
|
52
|
+
requestRender(): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ChoiceTheme {
|
|
56
|
+
fg(color: string, text: string): string;
|
|
57
|
+
bg(color: string, text: string): string;
|
|
58
|
+
bold(text: string): string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ChoiceCustomFactory = (
|
|
62
|
+
tui: ChoiceTui,
|
|
63
|
+
theme: ChoiceTheme,
|
|
64
|
+
keybindings: unknown,
|
|
65
|
+
done: (value: unknown) => void,
|
|
66
|
+
) => NativeChoiceComponent;
|
|
67
|
+
|
|
38
68
|
type BeforeAgentStart = (event: unknown, ctx: { mode: string }) => void | Promise<void>;
|
|
39
69
|
|
|
40
70
|
function registerChoiceTool(
|
|
@@ -103,11 +133,11 @@ function tuiContext(inputs: readonly string[], rendered: { value: string }) {
|
|
|
103
133
|
return {
|
|
104
134
|
mode: "tui",
|
|
105
135
|
ui: {
|
|
106
|
-
custom: async (factory:
|
|
136
|
+
custom: async (factory: ChoiceCustomFactory) => {
|
|
107
137
|
let result: unknown;
|
|
108
138
|
const component = factory(
|
|
109
139
|
{ requestRender() {} },
|
|
110
|
-
{ fg: (_color, text) => text, bold: (text) => text },
|
|
140
|
+
{ fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
|
|
111
141
|
{},
|
|
112
142
|
(value) => {
|
|
113
143
|
result = value;
|
|
@@ -143,6 +173,128 @@ test("ask_user_choice exposes a strict closed single-select schema", () => {
|
|
|
143
173
|
assert.deepEqual(Object.keys(optionSchema?.properties ?? {}).sort(), ["description", "label", "value"]);
|
|
144
174
|
});
|
|
145
175
|
|
|
176
|
+
test("ask_user_choice hover preserves the keyboard-selected opaque value", async () => {
|
|
177
|
+
const { tool } = registerChoiceTool();
|
|
178
|
+
let completionCalls = 0;
|
|
179
|
+
const result = await tool.execute("call", { question: "Proceed?", options }, new AbortController().signal, undefined, {
|
|
180
|
+
mode: "tui",
|
|
181
|
+
ui: { custom: async (factory: ChoiceCustomFactory) => {
|
|
182
|
+
let completed: unknown;
|
|
183
|
+
const component = factory({ requestRender() {} }, {
|
|
184
|
+
fg: (_color, text) => `\u001b[38;5;39m${text}\u001b[39m`,
|
|
185
|
+
bg: (_color, text) => `\u001b[48;5;236m${text}\u001b[49m`, bold: (text) => text,
|
|
186
|
+
}, {}, (value) => { completionCalls++; completed = value; });
|
|
187
|
+
const lines = component.render(80);
|
|
188
|
+
const first = lines.findIndex((line) => stripTerminalSequences(line).includes(options[0]!.label));
|
|
189
|
+
const second = lines.findIndex((line) => stripTerminalSequences(line).includes(options[1]!.label));
|
|
190
|
+
assert.ok(first >= 0 && second >= 0);
|
|
191
|
+
const hover = {
|
|
192
|
+
type: "move", button: "none", x: 0, y: second,
|
|
193
|
+
screenX: 0, screenY: second, width: 80, height: lines.length,
|
|
194
|
+
shift: false, alt: false, ctrl: false,
|
|
195
|
+
};
|
|
196
|
+
assert.equal(component.handleMouse?.(hover)?.render, true);
|
|
197
|
+
assert.equal(completed, undefined, "hover never submits");
|
|
198
|
+
const hovered = component.render(80);
|
|
199
|
+
assert.ok(hovered[second]?.includes("\u001b[48;5;236m"), "only the second option is hovered");
|
|
200
|
+
assert.ok(
|
|
201
|
+
hovered[first]?.includes("\u001b[38;5;39m") && !hovered[first]?.includes("\u001b[48;5;236m"),
|
|
202
|
+
"hover does not change keyboard selection",
|
|
203
|
+
);
|
|
204
|
+
component.handleInput("\r");
|
|
205
|
+
component.handleInput("\r");
|
|
206
|
+
return completed;
|
|
207
|
+
} },
|
|
208
|
+
});
|
|
209
|
+
assert.deepEqual(result.details.selection, { value: options[0]!.value, label: options[0]!.label, index: 1 });
|
|
210
|
+
assert.equal(completionCalls, 1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("ask_user_choice retains native rendered hit testing for mouse selection", async () => {
|
|
214
|
+
const longOptions = [
|
|
215
|
+
{
|
|
216
|
+
label: "Authorize the observed baseline hash after independent verification",
|
|
217
|
+
description: "Accept the observed baseline after a long description that verifies layout offsets.",
|
|
218
|
+
value: "authorize_observed_hash",
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
label: "Preserve the originally requested hash without changing the envelope",
|
|
222
|
+
description: "Keep the original opaque answer token after the user confirms the requested value.",
|
|
223
|
+
value: "preserve_requested_hash",
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
const { tool } = registerChoiceTool();
|
|
227
|
+
let completionCalls = 0;
|
|
228
|
+
const result = await tool.execute(
|
|
229
|
+
"call",
|
|
230
|
+
{
|
|
231
|
+
question: "A deliberately long question verifies that the rendered header does not shift native option hit bounds.",
|
|
232
|
+
options: longOptions,
|
|
233
|
+
},
|
|
234
|
+
new AbortController().signal,
|
|
235
|
+
undefined,
|
|
236
|
+
{
|
|
237
|
+
mode: "tui",
|
|
238
|
+
ui: {
|
|
239
|
+
custom: async (factory: ChoiceCustomFactory) => {
|
|
240
|
+
let completed: unknown;
|
|
241
|
+
const component = factory(
|
|
242
|
+
{ requestRender() {} },
|
|
243
|
+
{ fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
|
|
244
|
+
{},
|
|
245
|
+
(value) => {
|
|
246
|
+
completionCalls++;
|
|
247
|
+
completed = value;
|
|
248
|
+
},
|
|
249
|
+
) as NativeChoiceComponent;
|
|
250
|
+
const narrow = component.render(24);
|
|
251
|
+
assert.ok(narrow.length > 0, "the real component renders before a narrow-to-wide resize");
|
|
252
|
+
const wide = component.render(100);
|
|
253
|
+
const secondRow = wide.findIndex((line) => line.includes(longOptions[1]!.label.slice(0, 24)));
|
|
254
|
+
assert.ok(secondRow >= 0, "the test locates the actual rendered option row");
|
|
255
|
+
const event = (type: string, button: string, y: number, wheelDelta?: number) => ({
|
|
256
|
+
type,
|
|
257
|
+
button,
|
|
258
|
+
x: 1,
|
|
259
|
+
y,
|
|
260
|
+
screenX: 1,
|
|
261
|
+
screenY: y,
|
|
262
|
+
width: 100,
|
|
263
|
+
height: wide.length,
|
|
264
|
+
shift: false,
|
|
265
|
+
alt: false,
|
|
266
|
+
ctrl: false,
|
|
267
|
+
wheelDelta,
|
|
268
|
+
});
|
|
269
|
+
assert.equal(typeof component.handleMouse, "function", "native mouse dispatch must survive the custom UI adapter");
|
|
270
|
+
const wheel = component.handleMouse?.(event("wheel", "none", secondRow, 1));
|
|
271
|
+
assert.equal(wheel?.handled, true);
|
|
272
|
+
assert.equal(wheel?.render, true);
|
|
273
|
+
assert.equal(completed, undefined, "wheel changes focus without answering");
|
|
274
|
+
assert.equal(component.handleMouse?.(event("release", "left", secondRow)), undefined);
|
|
275
|
+
assert.equal(component.handleMouse?.(event("click", "right", secondRow)), undefined);
|
|
276
|
+
assert.equal(component.handleMouse?.(event("click", "left", wide.length)), undefined);
|
|
277
|
+
assert.equal(component.handleMouse?.({ type: "click" }), undefined);
|
|
278
|
+
const press = component.handleMouse?.(event("press", "left", secondRow));
|
|
279
|
+
assert.equal(press?.handled, true);
|
|
280
|
+
assert.equal(press?.focus, true);
|
|
281
|
+
assert.equal(completed, undefined, "press focuses and selects but never answers");
|
|
282
|
+
component.handleMouse?.(event("click", "left", secondRow));
|
|
283
|
+
component.handleMouse?.(event("click", "left", secondRow));
|
|
284
|
+
component.handleInput("\r");
|
|
285
|
+
return completed;
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
assert.deepEqual(result.details.selection, {
|
|
291
|
+
value: "preserve_requested_hash",
|
|
292
|
+
label: longOptions[1]!.label,
|
|
293
|
+
index: 2,
|
|
294
|
+
});
|
|
295
|
+
assert.equal(completionCalls, 1, "late keyboard input cannot complete the choice twice");
|
|
296
|
+
});
|
|
297
|
+
|
|
146
298
|
test("ask_user_choice handles a closed Kilo hash decision with an opaque envelope value", async () => {
|
|
147
299
|
const { tool } = registerChoiceTool(["read"]);
|
|
148
300
|
const rendered = { value: "" };
|
|
@@ -185,7 +337,9 @@ test("ask_user_choice emits a private balanced lifecycle around selection and ca
|
|
|
185
337
|
},
|
|
186
338
|
},
|
|
187
339
|
);
|
|
188
|
-
|
|
340
|
+
const selection = selected.details.selection;
|
|
341
|
+
assert.ok(selection !== null && typeof selection === "object" && "value" in selection);
|
|
342
|
+
assert.equal(selection.value, "preserve_requested_hash");
|
|
189
343
|
assert.deepEqual(sequence, ["active", "custom", "inactive"]);
|
|
190
344
|
assert.deepEqual(selectedRegistration.emittedEvents(), [
|
|
191
345
|
{ channel: "gentle-pi:ask-user-choice:blocked", data: { active: true } },
|