gentle-pi 2.5.0 → 2.6.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 +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +21 -3
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +799 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +283 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +283 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1460 -33
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-review-capability-contract.test.ts +12 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
|
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
4
|
import { emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { renderThreadItem } from "../lib/agents-thread-view.ts";
|
|
6
|
+
import { AgentsView, taskHeader } from "../lib/agents-view.ts";
|
|
6
7
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
7
8
|
|
|
8
9
|
// Gentle Agents overlay: list left, selected thread right, tail-following,
|
|
@@ -14,7 +15,7 @@ function task(id: string, overrides: Partial<TaskRecord> = {}): TaskRecord {
|
|
|
14
15
|
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
|
}
|
|
16
17
|
|
|
17
|
-
function harness(rows = 8, sessionId?:
|
|
18
|
+
function harness(rows: number | (() => number) = 8, sessionId = "s", canCancel?: (task: TaskRecord) => boolean) {
|
|
18
19
|
const store = new TaskStore();
|
|
19
20
|
const events: string[] = [];
|
|
20
21
|
let renders = 0;
|
|
@@ -24,6 +25,7 @@ function harness(rows = 8, sessionId?: string) {
|
|
|
24
25
|
store,
|
|
25
26
|
sessionId,
|
|
26
27
|
now: () => 61_000,
|
|
28
|
+
canCancel,
|
|
27
29
|
onCancel: (entry) => events.push(`cancel:${entry.id}`),
|
|
28
30
|
onOpen: (entry) => events.push(`open:${entry.id}`),
|
|
29
31
|
onClose: () => events.push("close"),
|
|
@@ -32,20 +34,214 @@ function harness(rows = 8, sessionId?: string) {
|
|
|
32
34
|
return { store, view, events, renders: () => renders };
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
function clickLabel(view: AgentsView, label: string, width = 60): void {
|
|
38
|
+
const frame = view.render(width).map(stripAnsi);
|
|
39
|
+
const y = frame.findIndex((line) => line.includes(label));
|
|
40
|
+
assert.ok(y >= 0, `${label} must be mouse accessible at ${width} columns`);
|
|
41
|
+
const x = visibleWidth(frame[y].slice(0, frame[y].indexOf(label)));
|
|
42
|
+
assert.equal(view.handleMouse(mouse(x, y, width, frame.length, "click"))?.handled, true);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("narrow navigation enters a direct child, retains manual thread position, and Escape backs out", () => {
|
|
46
|
+
const { store, view, events } = harness(8);
|
|
47
|
+
store.add(task("a"));
|
|
48
|
+
store.add(task("other", { parentSessionId: "other", agent: "foreign" }));
|
|
49
|
+
for (let i = 0; i < 30; i++) store.apply("a", { type: TASK_EVENT.TEXT, text: `line ${i}\n` }, 2000);
|
|
50
|
+
assert.match(view.render(40).join("\n"), /Subagent explore/);
|
|
51
|
+
assert.deepEqual(events, [], "rendering the direct list never activates a child");
|
|
52
|
+
assert.doesNotMatch(view.render(40).join("\n"), /foreign/);
|
|
53
|
+
clickLabel(view, "Subagent explore", 40);
|
|
54
|
+
assert.match(view.render(40).join("\n"), /line 29/);
|
|
55
|
+
view.handleInput("\x1b[5~");
|
|
56
|
+
const manual = view.render(40).slice(1, 6);
|
|
57
|
+
view.handleInput("\x1b");
|
|
58
|
+
assert.deepEqual(events, []);
|
|
59
|
+
assert.match(view.render(40).join("\n"), /Subagent explore/);
|
|
60
|
+
clickLabel(view, "Subagent explore", 40);
|
|
61
|
+
assert.deepEqual(view.render(40).slice(1, 6), manual);
|
|
62
|
+
view.render(80);
|
|
63
|
+
assert.equal(view.selectedTask()?.id, "a");
|
|
64
|
+
assert.deepEqual(view.render(40).slice(1, 6), manual);
|
|
65
|
+
view.handleInput("\x1b");
|
|
66
|
+
assert.match(view.render(40).join("\n"), /Subagent explore/);
|
|
67
|
+
view.handleInput("\x1b");
|
|
68
|
+
assert.deepEqual(events, ["close"]);
|
|
69
|
+
view.dispose();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("desktop fullscreen and every task action have mouse controls at the split boundary", () => {
|
|
73
|
+
const { store, view, events } = harness(8, "s");
|
|
74
|
+
store.add(task("a"));
|
|
75
|
+
for (let i = 0; i < 30; i++) store.apply("a", { type: TASK_EVENT.TEXT, text: `line ${i}\n` }, 2000);
|
|
76
|
+
view.render(60);
|
|
77
|
+
view.handleInput("F");
|
|
78
|
+
assert.doesNotMatch(view.render(60).join("\n"), /Subagent explore/);
|
|
79
|
+
view.handleInput("\x1b[5~");
|
|
80
|
+
const manual = view.render(60).slice(1, 6);
|
|
81
|
+
clickLabel(view, "Back");
|
|
82
|
+
clickLabel(view, "Fullscreen");
|
|
83
|
+
assert.deepEqual(view.render(60).slice(1, 6), manual);
|
|
84
|
+
clickLabel(view, "Follow");
|
|
85
|
+
assert.match(view.render(60).join("\n"), /line 29/);
|
|
86
|
+
clickLabel(view, "Open");
|
|
87
|
+
clickLabel(view, "Stop");
|
|
88
|
+
clickLabel(view, "Scope");
|
|
89
|
+
assert.deepEqual(events, ["open:a", "cancel:a"]);
|
|
90
|
+
clickLabel(view, "Close");
|
|
91
|
+
assert.deepEqual(events, ["open:a", "cancel:a", "close"]);
|
|
92
|
+
view.dispose();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("tiny frames stay bounded and Close remains clickable whenever a cell exists", () => {
|
|
96
|
+
for (const rows of [0, 1, 2, 3, 8]) for (const width of [0, 1, 2, 11, 12, 23, 24, 59, 60]) {
|
|
97
|
+
const { store, view, events } = harness(rows);
|
|
98
|
+
store.add(task("a"));
|
|
99
|
+
const frame = view.render(width);
|
|
100
|
+
assert.ok(frame.length <= rows);
|
|
101
|
+
for (const line of frame) assert.ok(visibleWidth(line) <= width);
|
|
102
|
+
if (rows && width) clickLabel(view, "×", width);
|
|
103
|
+
assert.deepEqual(events, rows && width ? ["close"] : []);
|
|
104
|
+
view.dispose();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("compact footer cycles mouse actions without clipped hit regions", () => {
|
|
109
|
+
const { store, view, events } = harness(8, "s");
|
|
110
|
+
store.add(task("a"));
|
|
111
|
+
view.render(12);
|
|
112
|
+
view.handleInput("\r");
|
|
113
|
+
clickLabel(view, "Follow", 12);
|
|
114
|
+
clickLabel(view, ">", 12);
|
|
115
|
+
clickLabel(view, "Open", 12);
|
|
116
|
+
clickLabel(view, ">", 12);
|
|
117
|
+
clickLabel(view, "Stop", 12);
|
|
118
|
+
clickLabel(view, ">", 12);
|
|
119
|
+
clickLabel(view, "Scope", 12);
|
|
120
|
+
assert.deepEqual(events, ["open:a", "cancel:a"]);
|
|
121
|
+
view.dispose();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("manual list wheels remain independent of selection and survive Back and resize", () => {
|
|
125
|
+
const { store, view } = harness(6);
|
|
126
|
+
for (let i = 0; i < 10; i++) store.add(task(`t${i}`, { agent: `agent${i}`, createdAt: 1000 - i }));
|
|
127
|
+
view.render(40);
|
|
128
|
+
view.handleMouse(mouse(4, 2, 40, 6, "wheel", 5));
|
|
129
|
+
const viewport = () => view.render(40).slice(1, 4).map((line) => stripAnsi(line).replace("▸", " "));
|
|
130
|
+
const manual = viewport();
|
|
131
|
+
assert.match(manual[0], /agent5/);
|
|
132
|
+
assert.equal(view.selectedTask()?.id, "t0", "wheel scrolling does not change selection");
|
|
133
|
+
clickLabel(view, "Subagent agent5", 40);
|
|
134
|
+
view.handleInput("\x1b");
|
|
135
|
+
assert.match(stripAnsi(view.render(40)[1]), /▸.*Subagent agent5/, "clicking selects the visible child");
|
|
136
|
+
assert.deepEqual(viewport(), manual, "Back preserves viewport content apart from the intentional selection marker");
|
|
137
|
+
view.render(80);
|
|
138
|
+
assert.deepEqual(viewport(), manual);
|
|
139
|
+
assert.equal(view.selectedTask()?.id, "t5");
|
|
140
|
+
view.dispose();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("activating a visible child preserves the manual list offset through Back and height resize", () => {
|
|
144
|
+
for (const target of [4, 5]) {
|
|
145
|
+
let rows = 6;
|
|
146
|
+
const { store, view } = harness(() => rows);
|
|
147
|
+
for (let i = 0; i < 10; i++) store.add(task(`t${i}`, { agent: `agent${i}`, createdAt: 1000 - i }));
|
|
148
|
+
view.render(40);
|
|
149
|
+
for (let i = 0; i < 4; i++) view.handleInput("j");
|
|
150
|
+
view.render(40);
|
|
151
|
+
view.handleMouse(mouse(4, 2, 40, rows, "wheel", 1));
|
|
152
|
+
assert.match(view.render(40)[1], /agent3/);
|
|
153
|
+
clickLabel(view, `Subagent agent${target}`, 40);
|
|
154
|
+
assert.equal(view.selectedTask()?.id, `t${target}`);
|
|
155
|
+
view.handleInput("\x1b");
|
|
156
|
+
assert.match(view.render(40)[1], /agent3/, "Back restores the manual offset, not selection-following");
|
|
157
|
+
rows = 20;
|
|
158
|
+
view.render(40);
|
|
159
|
+
rows = 6;
|
|
160
|
+
assert.match(view.render(40)[1], /agent3/, "temporary height clamping must not replace the saved offset");
|
|
161
|
+
assert.equal(view.selectedTask()?.id, `t${target}`);
|
|
162
|
+
view.dispose();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("resizing a narrow child list to desktop keeps Fullscreen local and completion returns to the list", () => {
|
|
167
|
+
const { store, view, events } = harness(8);
|
|
168
|
+
store.add(task("a"));
|
|
169
|
+
view.render(40);
|
|
170
|
+
clickLabel(view, "Fullscreen", 80);
|
|
171
|
+
assert.deepEqual(events, []);
|
|
172
|
+
store.update("a", { status: TASK_STATUS.COMPLETED, endedAt: 5000 });
|
|
173
|
+
assert.equal(view.selectedTask(), undefined);
|
|
174
|
+
assert.doesNotMatch(view.render(80).join("\n"), /Back|Subagent explore/);
|
|
175
|
+
assert.deepEqual(events, []);
|
|
176
|
+
view.dispose();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("fullscreen retains every semantic block and hides Stop for unowned active tasks", () => {
|
|
180
|
+
const { store, view, events } = harness(8, "s", () => false);
|
|
181
|
+
const thread = { ...emptyThread(), items: [
|
|
182
|
+
{ kind: "thinking" as const, text: "reasoning retained" },
|
|
183
|
+
{ kind: "text" as const, text: "answer retained" },
|
|
184
|
+
{ kind: "tool" as const, callId: "call", name: "bash", args: {}, output: Array.from({ length: 40 }, (_, i) => `output ${i}`).join("\n"), running: false, isError: false },
|
|
185
|
+
{ kind: "note" as const, text: "final retained note" },
|
|
186
|
+
] };
|
|
187
|
+
store.restore(task("a"), thread);
|
|
188
|
+
view.render(60);
|
|
189
|
+
view.handleInput("F");
|
|
190
|
+
for (let i = 0; i < 12; i++) view.handleInput("\x1b[5~");
|
|
191
|
+
const seen = new Set<string>();
|
|
192
|
+
for (let i = 0; i < 50; i++) {
|
|
193
|
+
const frame = view.render(60).map(stripAnsi);
|
|
194
|
+
assert.doesNotMatch(frame.at(-2) ?? "", /\[Stop\]/);
|
|
195
|
+
for (const line of frame.slice(1, 6)) seen.add(line);
|
|
196
|
+
view.handleMouse(mouse(4, 2, 60, 8, "wheel", 1));
|
|
197
|
+
}
|
|
198
|
+
assert.match([...seen].join("\n"), /Thinking[\s\S]*reasoning retained[\s\S]*Text[\s\S]*answer retained[\s\S]*Tool[\s\S]*output 39[\s\S]*Note[\s\S]*final retained note/);
|
|
199
|
+
view.handleInput("s");
|
|
200
|
+
assert.deepEqual(events, []);
|
|
201
|
+
assert.deepEqual(store.thread("a"), thread, "presentation does not mutate retention");
|
|
202
|
+
view.dispose();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("narrow unknown origins stay isolated rather than sharing an inferred orchestrator", () => {
|
|
206
|
+
const { store, view } = harness(8);
|
|
207
|
+
store.add(task("a", { parentSessionId: "", agent: "alpha" }));
|
|
208
|
+
store.add(task("b", { parentSessionId: " ", agent: "beta" }));
|
|
209
|
+
view.handleInput("a");
|
|
210
|
+
assert.doesNotMatch(view.render(40).join("\n"), /Unknown session|alpha|beta/);
|
|
211
|
+
assert.equal(view.selectedTask(), undefined);
|
|
212
|
+
assert.equal(store.list().length, 2, "unknown retained origins remain isolated from presence");
|
|
213
|
+
view.dispose();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("presentation changes invalidate pointer geometry before another frame", () => {
|
|
217
|
+
const { store, view, events } = harness(8);
|
|
218
|
+
store.add(task("a"));
|
|
219
|
+
const frame = view.render(80);
|
|
220
|
+
view.handleInput("F");
|
|
221
|
+
assert.equal(view.handleMouse(mouse(4, 2, 80, frame.length, "click")), undefined);
|
|
222
|
+
view.render(80);
|
|
223
|
+
view.handleInput("\x1b");
|
|
224
|
+
assert.equal(view.handleMouse(mouse(40, 2, 80, frame.length, "wheel", 1)), undefined);
|
|
225
|
+
view.handleInput("F");
|
|
226
|
+
view.handleInput("q");
|
|
227
|
+
assert.deepEqual(events, ["close"]);
|
|
228
|
+
view.dispose();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("renderThreadItem renders labeled text, thinking, tool-output, and note blocks", () => {
|
|
232
|
+
assert.deepEqual(renderThreadItem({ kind: "text", text: "one two three four" }, plainTheme, 9), ["Text", " one two", " three", " four"]);
|
|
233
|
+
assert.deepEqual(renderThreadItem({ kind: "thinking", text: "deep\nthoughts" }, plainTheme, 20), ["Thinking", " deep", " thoughts"]);
|
|
38
234
|
const output = Array.from({ length: 10 }, (_, index) => `line ${index}`).join("\n");
|
|
39
|
-
const tool =
|
|
40
|
-
assert.equal(tool[0], "
|
|
41
|
-
assert.equal(tool.length, 12, "head, every output line
|
|
42
|
-
assert.equal(tool[1], "
|
|
43
|
-
assert.equal(tool[
|
|
44
|
-
assert.equal(tool[11], "
|
|
235
|
+
const tool = renderThreadItem({ kind: "tool", callId: "c", name: "bash", args: { command: "ls -la" }, output, running: true, isError: false }, plainTheme, 30);
|
|
236
|
+
assert.equal(tool[0], "Tool · bash · Running");
|
|
237
|
+
assert.equal(tool.length, 12, "head, output label, and every retained output line");
|
|
238
|
+
assert.equal(tool[1], " Output");
|
|
239
|
+
assert.equal(tool[2], " line 0");
|
|
240
|
+
assert.equal(tool[11], " line 9");
|
|
45
241
|
// A long output line wraps instead of being clipped, so nothing is hidden.
|
|
46
|
-
const wide =
|
|
47
|
-
assert.deepEqual(wide.slice(
|
|
48
|
-
assert.deepEqual(
|
|
242
|
+
const wide = renderThreadItem({ kind: "tool", callId: "c", name: "bash", args: {}, output: "alpha beta gamma delta", running: false, isError: false }, plainTheme, 12);
|
|
243
|
+
assert.deepEqual(wide.slice(2), [" alpha", " beta", " gamma", " delta"]);
|
|
244
|
+
assert.deepEqual(renderThreadItem({ kind: "note", text: "error: boom" }, plainTheme, 30), ["Note", " error: boom"]);
|
|
49
245
|
assert.equal(taskHeader(task("a"), 61_000), "explore · running · gpt-5.6-terra · 34k · $0.27 · 1m00s");
|
|
50
246
|
});
|
|
51
247
|
|
|
@@ -58,37 +254,41 @@ test("AgentsView renders the frame with the task list and the selected thread's
|
|
|
58
254
|
for (const line of lines) assert.equal(visibleWidth(line), 90, `"${stripAnsi(line)}" is not 90 wide`);
|
|
59
255
|
const plain = lines.map(stripAnsi);
|
|
60
256
|
assert.equal(plain.length, 8);
|
|
61
|
-
assert.match(plain[0], /^╭─ ❀ Agents ·
|
|
62
|
-
assert.match(plain[1],
|
|
63
|
-
assert.match(plain[2],
|
|
257
|
+
assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active ─+ \[F Fullscreen\] \[× Close\]╮$/);
|
|
258
|
+
assert.match(plain[1], /▸ └ ◐ Subagent explore.*explore · running · gpt-5\.6-terra · 34k · \$0\.27 · 1m00s/);
|
|
259
|
+
assert.match(plain[2], /line 3/, "the thread window follows the tail");
|
|
260
|
+
assert.match(plain[3], /line 4/);
|
|
261
|
+
assert.doesNotMatch(plain.join("\n"), /Subagent worker|orchestrator/i);
|
|
64
262
|
assert.match(plain[4], /line 5/);
|
|
65
|
-
assert.match(plain[6],
|
|
263
|
+
assert.match(plain[6], /\[ Follow \].*\[ Open session \].*\[Stop\]/);
|
|
66
264
|
assert.match(plain[7], /^╰─+╯$/);
|
|
67
265
|
});
|
|
68
266
|
|
|
69
267
|
test("AgentsView keys move the selection, scroll, follow, cancel, open, and close", () => {
|
|
70
268
|
const { store, view, events } = harness(8);
|
|
71
269
|
store.add(task("a"));
|
|
72
|
-
store.add(task("b", { createdAt: 900, lastActivityAt: 900, status: TASK_STATUS.
|
|
270
|
+
store.add(task("b", { createdAt: 900, lastActivityAt: 900, status: TASK_STATUS.WAITING }));
|
|
73
271
|
assert.equal(view.selectedTask()?.id, "a");
|
|
74
272
|
view.handleInput("j");
|
|
75
273
|
assert.equal(view.selectedTask()?.id, "b");
|
|
76
|
-
view.handleInput("c");
|
|
77
|
-
assert.deepEqual(events, [], "finished tasks cannot be cancelled");
|
|
78
274
|
view.handleInput("k");
|
|
275
|
+
assert.deepEqual(events, [], "navigation alone does not cancel");
|
|
79
276
|
view.handleInput("c");
|
|
80
277
|
view.handleInput("o");
|
|
81
|
-
|
|
82
|
-
assert.deepEqual(events, ["cancel:a", "open:a", "close"]);
|
|
278
|
+
assert.deepEqual(events, ["cancel:a", "open:a"]);
|
|
83
279
|
for (let index = 0; index < 12; index += 1) store.apply("a", { type: TASK_EVENT.TEXT, text: `l${index}\n` }, 2000);
|
|
84
280
|
view.handleInput("\x1b[5~");
|
|
85
|
-
assert.match(stripAnsi(view.render(80)[2]), /│
|
|
281
|
+
assert.match(stripAnsi(view.render(80)[2]), /│ Text +│$/, "page up leaves follow mode and shows the top block label");
|
|
282
|
+
assert.match(stripAnsi(view.render(80)[3]), /│ l0 +│$/, "page up leaves follow mode and shows the top block body");
|
|
86
283
|
view.handleInput("\x0a");
|
|
87
|
-
assert.match(stripAnsi(view.render(80)[2]), /│
|
|
284
|
+
assert.match(stripAnsi(view.render(80)[2]), /│ l3 +│$/, "ctrl+j scrolls one page down across the labeled block");
|
|
88
285
|
view.handleInput("\x0b");
|
|
89
|
-
assert.match(stripAnsi(view.render(80)[2]), /│
|
|
286
|
+
assert.match(stripAnsi(view.render(80)[2]), /│ Text +│$/, "ctrl+k scrolls one page up to the labeled block");
|
|
287
|
+
assert.match(stripAnsi(view.render(80)[3]), /│ l0 +│$/, "ctrl+k restores the top block body");
|
|
90
288
|
view.handleInput("f");
|
|
91
|
-
assert.match(stripAnsi(view.render(80)[2]), /│
|
|
289
|
+
assert.match(stripAnsi(view.render(80)[2]), /│ l9 +│$/, "f follows the tail again");
|
|
290
|
+
view.handleInput("\x1b");
|
|
291
|
+
assert.deepEqual(events, ["cancel:a", "open:a", "close"]);
|
|
92
292
|
});
|
|
93
293
|
|
|
94
294
|
test("AgentsView subscribes only to the selected task and survives an empty store", () => {
|
|
@@ -106,6 +306,54 @@ test("AgentsView subscribes only to the selected task and survives an empty stor
|
|
|
106
306
|
assert.equal(renders(), before + 1, "disposed views stay quiet");
|
|
107
307
|
});
|
|
108
308
|
|
|
309
|
+
test("live-only current panel exposes active children directly at desktop and narrow widths", () => {
|
|
310
|
+
for (const width of [40, 80]) {
|
|
311
|
+
const { store, view, events } = harness(12, "s");
|
|
312
|
+
try {
|
|
313
|
+
for (const status of [TASK_STATUS.RUNNING, TASK_STATUS.QUEUED, TASK_STATUS.WAITING]) {
|
|
314
|
+
store.add(task(status, { agent: status, status }));
|
|
315
|
+
}
|
|
316
|
+
for (const status of [TASK_STATUS.COMPLETED, TASK_STATUS.FAILED, TASK_STATUS.CANCELLED, TASK_STATUS.TIMED_OUT]) {
|
|
317
|
+
store.add(task(status, { agent: `history_${status}`, status, endedAt: 60_999 }));
|
|
318
|
+
}
|
|
319
|
+
store.add(task("foreign", { agent: "foreign", parentSessionId: "other" }));
|
|
320
|
+
const frame = view.render(width).map(stripAnsi).join("\n");
|
|
321
|
+
assert.doesNotMatch(frame, /orchestrator|history_|foreign|finished/i);
|
|
322
|
+
for (const status of ["running", "queued", "waiting"]) assert.match(frame, new RegExp(`Subagent ${status}`));
|
|
323
|
+
assert.match(stripAnsi(view.render(width)[1]), /Subagent queued/, "equal creation times sort by ID, with no parent wrapper");
|
|
324
|
+
assert.equal(view.selectedTask()?.id, "running", "adding earlier-sorted children preserves the existing selection");
|
|
325
|
+
view.handleInput("k");
|
|
326
|
+
assert.equal(view.selectedTask()?.id, "queued", "Up selects the preceding live child");
|
|
327
|
+
const selected = view.selectedTask()!;
|
|
328
|
+
view.handleInput("k");
|
|
329
|
+
assert.equal(view.selectedTask()?.id, selected.id, "Up at the first child cannot select a hidden parent wrapper");
|
|
330
|
+
assert.match(stripAnsi(view.render(width)[1]), /▸.*Subagent queued/);
|
|
331
|
+
if (width < 60) clickLabel(view, `Subagent ${selected.agent}`, width);
|
|
332
|
+
view.handleInput("s");
|
|
333
|
+
assert.deepEqual(events, [`cancel:${selected.id}`]);
|
|
334
|
+
store.update(selected.id, { status: TASK_STATUS.COMPLETED, endedAt: 61_000 });
|
|
335
|
+
assert.notEqual(view.selectedTask()?.id, selected.id, "completion removes the selected live row immediately");
|
|
336
|
+
} finally {
|
|
337
|
+
view.dispose();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("live-only current panel never promotes retained foreign or unknown tasks into open sessions", () => {
|
|
343
|
+
const { store, view } = harness(12, "s");
|
|
344
|
+
try {
|
|
345
|
+
store.add(task("foreign", { agent: "foreign", parentSessionId: "closed" }));
|
|
346
|
+
store.add(task("unknown", { agent: "unknown", parentSessionId: "" }));
|
|
347
|
+
store.add(task("finished", { agent: "finished", status: TASK_STATUS.COMPLETED, endedAt: 61_000 }));
|
|
348
|
+
assert.equal(view.selectedTask(), undefined);
|
|
349
|
+
view.handleInput("a");
|
|
350
|
+
assert.doesNotMatch(view.render(80).map(stripAnsi).join("\n"), /Subagent (foreign|unknown|finished)|Orchestrator closed|Unknown session/);
|
|
351
|
+
assert.equal(store.list().length, 3, "panel filtering never deletes retained history");
|
|
352
|
+
} finally {
|
|
353
|
+
view.dispose();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
109
357
|
function mouse(x: number, y: number, width: number, height: number, type: TuiMouseEvent["type"] = "move", wheelDelta?: number): TuiMouseEvent {
|
|
110
358
|
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
359
|
}
|
|
@@ -131,6 +379,68 @@ test("AgentsView pointer regions hover and select task rows without activating,
|
|
|
131
379
|
assert.equal(view.handleMouse(mouse(4, 2, 80, lines.length)), undefined, "late pointer events are inert after disposal");
|
|
132
380
|
});
|
|
133
381
|
|
|
382
|
+
test("AgentsView retains narrow keyboard mode after selection and store invalidation", () => {
|
|
383
|
+
const { store, view, events } = harness(6);
|
|
384
|
+
store.add(task("a"));
|
|
385
|
+
store.add(task("b", { agent: "b" }));
|
|
386
|
+
for (let index = 0; index < 3; index += 1) store.apply("b", { type: TASK_EVENT.TEXT, text: `thread ${index}\n` }, 2000);
|
|
387
|
+
view.render(40);
|
|
388
|
+
view.handleInput("j");
|
|
389
|
+
view.handleInput("\t");
|
|
390
|
+
assert.match(view.render(40).map(stripAnsi).join("\n"), /thread 2/, "Tab still enters Details before a pointer-invalidating selection rerenders");
|
|
391
|
+
view.handleInput("\t");
|
|
392
|
+
view.handleInput("\t");
|
|
393
|
+
assert.match(view.render(40).map(stripAnsi).join("\n"), /thread 2/, "repeated Tab toggles semantically before a frame");
|
|
394
|
+
view.handleInput("\t");
|
|
395
|
+
store.apply("b", { type: TASK_EVENT.TEXT, text: "after update\n" }, 2000);
|
|
396
|
+
view.handleInput("\t");
|
|
397
|
+
assert.match(view.render(40).map(stripAnsi).join("\n"), /after update/, "a selected-task update does not discard narrow keyboard knowledge");
|
|
398
|
+
view.handleInput("\x1b");
|
|
399
|
+
view.handleInput("\t");
|
|
400
|
+
assert.equal(view.selectedTask()?.id, "b", "returning through the list retains its selected child");
|
|
401
|
+
assert.deepEqual(events, [], "Tab never opens the editor");
|
|
402
|
+
view.dispose();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("AgentsView narrow drilldown has explicit Back, independent wheel and Open, and global close", () => {
|
|
406
|
+
const { store, view, events } = harness(6);
|
|
407
|
+
store.add(task("a"));
|
|
408
|
+
store.add(task("b", { agent: "b" }));
|
|
409
|
+
for (let index = 0; index < 8; index += 1) store.apply("b", { type: TASK_EVENT.TEXT, text: `thread ${index}\n` }, 2000);
|
|
410
|
+
let frame = view.render(40);
|
|
411
|
+
let top = stripAnsi(frame[0] ?? "");
|
|
412
|
+
assert.match(top, /\[×\]/, "root has a compact global Close");
|
|
413
|
+
clickLabel(view, "Subagent b", 40);
|
|
414
|
+
assert.equal(view.selectedTask()?.id, "b");
|
|
415
|
+
assert.deepEqual(events, [], "drilldown never opens or stops a task");
|
|
416
|
+
assert.equal(view.handleMouse(mouse(4, 2, 40, frame.length, "click")), undefined, "a mode change invalidates old pointer bounds before render");
|
|
417
|
+
frame = view.render(40);
|
|
418
|
+
top = stripAnsi(frame[0] ?? "");
|
|
419
|
+
assert.match(top, /\[← Back\]/);
|
|
420
|
+
assert.match(frame.map(stripAnsi).join("\n"), /thread 7/, "Details is the local thread viewport");
|
|
421
|
+
assert.equal(view.handleMouse(mouse(4, 2, 40, frame.length, "wheel", -1))?.handled, true, "the detail viewport owns its wheel");
|
|
422
|
+
const backX = visibleWidth(top.slice(0, top.indexOf("[← Back]")));
|
|
423
|
+
assert.equal(view.handleMouse(mouse(backX, 0, 40, frame.length, "click"))?.handled, true, "Back is an explicit pointer control");
|
|
424
|
+
assert.match(view.render(40).map(stripAnsi).join("\n"), /Subagent b/, "Back restores the local List viewport");
|
|
425
|
+
view.handleInput("\t");
|
|
426
|
+
assert.match(view.render(40).map(stripAnsi).join("\n"), /thread 7/, "Tab enters Details only for a selected task");
|
|
427
|
+
view.handleInput("\t");
|
|
428
|
+
assert.match(view.render(40).map(stripAnsi).join("\n"), /Subagent b/, "Tab returns to List");
|
|
429
|
+
view.handleInput("\t");
|
|
430
|
+
view.handleInput("o");
|
|
431
|
+
view.handleInput("\x1b");
|
|
432
|
+
view.handleInput("k");
|
|
433
|
+
view.handleInput("\x0d");
|
|
434
|
+
view.handleInput("o");
|
|
435
|
+
assert.deepEqual(events, ["open:b", "open:a"], "Enter inspects locally; o remains the editor action");
|
|
436
|
+
assert.equal(view.render(11).length, 1, "width below 12 uses the bounded Close-only fallback");
|
|
437
|
+
assert.equal(view.handleMouse(mouse(0, 0, 11, 1))?.handled, true, "the tiny Close cell remains hoverable");
|
|
438
|
+
view.handleInput("\x1b");
|
|
439
|
+
view.handleInput("q");
|
|
440
|
+
assert.deepEqual(events, ["open:b", "open:a", "close"], "q closes once from any level without cancelling");
|
|
441
|
+
view.dispose();
|
|
442
|
+
});
|
|
443
|
+
|
|
134
444
|
test("AgentsView clears hover on leave, list scrolling, resize, updates, empty lists, and disposal", () => {
|
|
135
445
|
const { store, view } = harness(6);
|
|
136
446
|
store.add(task("a"));
|
|
@@ -147,21 +457,21 @@ test("AgentsView clears hover on leave, list scrolling, resize, updates, empty l
|
|
|
147
457
|
observer.afterMouse(event);
|
|
148
458
|
}
|
|
149
459
|
};
|
|
150
|
-
dispatch(mouse(4,
|
|
151
|
-
assert.match(stripAnsi(view.render(80)[
|
|
152
|
-
dispatch(mouse(4,
|
|
153
|
-
assert.match(stripAnsi(view.render(80)[
|
|
154
|
-
dispatch(mouse(4,
|
|
460
|
+
dispatch(mouse(4, 3, 80, lines.length));
|
|
461
|
+
assert.match(stripAnsi(view.render(80)[3]), /▹/, "the hovered row is styled without changing selection");
|
|
462
|
+
dispatch(mouse(4, 3, 80, lines.length, "wheel", -1));
|
|
463
|
+
assert.match(stripAnsi(view.render(80)[3]), /▹/, "a list wheel event at its boundary preserves hover");
|
|
464
|
+
dispatch(mouse(4, 3, 80, lines.length, "wheel", 1));
|
|
155
465
|
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,
|
|
157
|
-
dispatch(mouse(50,
|
|
158
|
-
assert.doesNotMatch(stripAnsi(view.render(80)[
|
|
159
|
-
dispatch(mouse(4,
|
|
466
|
+
dispatch(mouse(4, 3, 80, lines.length));
|
|
467
|
+
dispatch(mouse(50, 3, 80, lines.length));
|
|
468
|
+
assert.doesNotMatch(stripAnsi(view.render(80)[3]), /▹/, "the root observer clears hover outside a child region");
|
|
469
|
+
dispatch(mouse(4, 3, 80, lines.length));
|
|
160
470
|
view.render(81);
|
|
161
|
-
assert.doesNotMatch(stripAnsi(view.render(81)[
|
|
162
|
-
dispatch(mouse(4,
|
|
471
|
+
assert.doesNotMatch(stripAnsi(view.render(81)[3]), /▹/, "resize clears hover before the next frame");
|
|
472
|
+
dispatch(mouse(4, 3, 81, lines.length));
|
|
163
473
|
store.apply("a", { type: TASK_EVENT.TEXT, text: "update" }, 2000);
|
|
164
|
-
assert.doesNotMatch(stripAnsi(view.render(81)[
|
|
474
|
+
assert.doesNotMatch(stripAnsi(view.render(81)[3]), /▹/, "task updates clear the actual hovered task row");
|
|
165
475
|
view.dispose();
|
|
166
476
|
const { view: empty } = harness(6);
|
|
167
477
|
empty.render(80);
|
|
@@ -172,64 +482,98 @@ test("AgentsView clears hover on leave, list scrolling, resize, updates, empty l
|
|
|
172
482
|
test("AgentsView advertises s to stop an active selection, retains c as an alias, and hides stopping for finished tasks", () => {
|
|
173
483
|
const { store, view, events } = harness(8);
|
|
174
484
|
store.add(task("active"));
|
|
175
|
-
assert.match(stripAnsi(view.render(80).at(-2) ?? ""),
|
|
485
|
+
assert.match(stripAnsi(view.render(80).at(-2) ?? ""), /\[Stop\]/);
|
|
176
486
|
view.handleInput("s");
|
|
177
487
|
view.handleInput("c");
|
|
178
488
|
assert.deepEqual(events, ["cancel:active", "cancel:active"]);
|
|
179
489
|
store.update("active", { status: TASK_STATUS.CANCELLED, endedAt: 2000 });
|
|
180
|
-
assert.doesNotMatch(stripAnsi(view.render(80).at(-2) ?? ""),
|
|
490
|
+
assert.doesNotMatch(stripAnsi(view.render(80).at(-2) ?? ""), /\[Stop\]/);
|
|
181
491
|
view.handleInput("s");
|
|
182
492
|
assert.deepEqual(events, ["cancel:active", "cancel:active"], "finished tasks never stop");
|
|
183
493
|
});
|
|
184
494
|
|
|
185
|
-
test("AgentsView Escape and q
|
|
495
|
+
test("AgentsView close control, Escape, and q share rendered bounds and one idempotent close", () => {
|
|
186
496
|
const { store, view, events } = harness(8);
|
|
187
497
|
store.add(task("active"));
|
|
498
|
+
const wide = 80;
|
|
499
|
+
let lines = view.render(wide);
|
|
500
|
+
let top = stripAnsi(lines[0]);
|
|
501
|
+
assert.equal(visibleWidth(top), wide, "the full close header fits its rendered width");
|
|
502
|
+
assert.match(top, /\[× Close\]/, "wide headers show the labelled close control");
|
|
503
|
+
let closeX = visibleWidth(top.slice(0, top.indexOf("[× Close]")));
|
|
504
|
+
assert.equal(view.handleMouse(mouse(closeX, 0, wide, lines.length))?.render, true, "hover targets only the rendered close bounds");
|
|
505
|
+
assert.equal(view.selectedTask()?.id, "active", "close hover never changes task selection");
|
|
506
|
+
assert.equal(view.handleMouse(mouse(closeX, 0, wide, lines.length, "press")), undefined, "press is inert");
|
|
507
|
+
assert.equal(view.handleMouse({ ...mouse(closeX, 0, wide, lines.length, "click"), button: "right" }), undefined, "right click is inert");
|
|
508
|
+
assert.equal(view.handleMouse({ ...mouse(closeX, 0, wide, lines.length, "click"), button: "middle" }), undefined, "middle click is inert");
|
|
509
|
+
assert.equal(view.handleMouse(mouse(closeX, 0, wide + 1, lines.length, "click")), undefined, "stale width is inert");
|
|
510
|
+
assert.equal(view.handleMouse(mouse(closeX, 0, wide, lines.length + 1, "click")), undefined, "stale height is inert");
|
|
511
|
+
|
|
512
|
+
lines = view.render(59);
|
|
513
|
+
top = stripAnsi(lines[0]);
|
|
514
|
+
assert.equal(visibleWidth(top), 59, "the narrow header fits its rendered width");
|
|
515
|
+
assert.match(top, /\[×\]/, "width 59 uses its narrow root instead of the fallback");
|
|
516
|
+
assert.doesNotMatch(top, /Follow|Open session/, "narrow mode keeps footer controls out when they do not fit");
|
|
517
|
+
|
|
518
|
+
lines = view.render(wide);
|
|
519
|
+
top = stripAnsi(lines[0]);
|
|
520
|
+
closeX = visibleWidth(top.slice(0, top.indexOf("[× Close]")));
|
|
521
|
+
assert.equal(view.handleMouse(mouse(closeX, 0, wide, lines.length, "click"))?.handled, true);
|
|
188
522
|
view.handleInput("\x1b");
|
|
189
523
|
view.handleInput("q");
|
|
190
|
-
assert.deepEqual(events, ["close", "
|
|
524
|
+
assert.deepEqual(events, ["close"], "pointer and close keys share one idempotent close action without cancelling");
|
|
525
|
+
assert.equal(view.handleMouse(mouse(closeX, 0, wide, lines.length, "click")), undefined, "late pointer events are inert after close");
|
|
526
|
+
const { store: keyStore, view: keyView, events: keyEvents } = harness(8);
|
|
527
|
+
keyStore.add(task("key-close"));
|
|
528
|
+
keyView.handleInput("\x1b");
|
|
529
|
+
keyView.handleInput("q");
|
|
530
|
+
assert.deepEqual(keyEvents, ["close"], "Escape and q themselves close only once");
|
|
531
|
+
keyView.dispose();
|
|
532
|
+
view.dispose();
|
|
533
|
+
assert.equal(view.handleMouse(mouse(closeX, 0, wide, lines.length)), undefined, "disposed close controls stay inert");
|
|
191
534
|
});
|
|
192
535
|
|
|
193
536
|
test("AgentsView scrolls the task list so the selection stays visible when there are more tasks than rows", () => {
|
|
194
537
|
const { store, view } = harness(6);
|
|
195
538
|
for (let index = 0; index < 6; index += 1) store.add(task(`t${index}`, { agent: `agent${index}`, createdAt: 1000 - index, lastActivityAt: 1000 - index }));
|
|
196
539
|
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()[
|
|
540
|
+
assert.match(listed()[0], /▸ └ ◐ Subagent agent0/);
|
|
541
|
+
assert.match(listed()[1], /Subagent agent1/);
|
|
542
|
+
assert.match(listed()[2], /Subagent agent2/);
|
|
199
543
|
for (let index = 0; index < 4; index += 1) view.handleInput("j");
|
|
200
544
|
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/);
|
|
545
|
+
assert.match(listed()[2], /▸ └ ◐ Subagent agent4/, "the list scrolls down until the selection is the last visible row");
|
|
546
|
+
assert.match(listed()[0], /Subagent agent2/);
|
|
203
547
|
view.handleInput("j");
|
|
204
548
|
view.handleInput("j");
|
|
205
549
|
assert.equal(view.selectedTask()?.id, "t5", "the selection stops at the last task");
|
|
206
|
-
assert.match(listed()[2], /▸ ◐ agent5/);
|
|
550
|
+
assert.match(listed()[2], /▸ └ ◐ Subagent agent5/);
|
|
207
551
|
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/);
|
|
552
|
+
assert.match(listed()[0], /▸ └ ◐ Subagent agent1/, "moving up scrolls the list back");
|
|
553
|
+
assert.match(listed()[2], /Subagent agent3/);
|
|
210
554
|
});
|
|
211
555
|
|
|
212
|
-
test("AgentsView
|
|
213
|
-
const { store, view } = harness(
|
|
556
|
+
test("AgentsView scopes to live children and toggles only the open-session directory", () => {
|
|
557
|
+
const { store, view } = harness(12, "s");
|
|
214
558
|
store.add(task("mine", { agent: "mine" }));
|
|
215
559
|
store.add(task("theirs", { agent: "theirs", parentSessionId: "other", createdAt: 900, lastActivityAt: 900 }));
|
|
216
560
|
store.add(task("fresh", { agent: "fresh", status: TASK_STATUS.COMPLETED, endedAt: 61_000 - 60_000, createdAt: 850, lastActivityAt: 850 }));
|
|
217
561
|
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]);
|
|
562
|
+
const names = () => view.render(80).map(stripAnsi).filter((line) => /[◐✓] Subagent /.test(line)).map((line) => line.match(/[◐✓] Subagent (\w+)/)?.[1]);
|
|
219
563
|
let plain = view.render(80).map(stripAnsi);
|
|
220
|
-
assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active
|
|
221
|
-
assert.deepEqual(names(), ["mine"
|
|
222
|
-
assert.match(plain.at(-2) ?? "", /
|
|
564
|
+
assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active ─+ \[F Fullscreen\] \[× Close\]╮$/);
|
|
565
|
+
assert.deepEqual(names(), ["mine"], "all terminal tasks and foreign retained tasks stay out");
|
|
566
|
+
assert.match(plain.at(-2) ?? "", /\[Scope\]/);
|
|
223
567
|
view.handleInput("a");
|
|
224
568
|
plain = view.render(80).map(stripAnsi);
|
|
225
|
-
assert.match(plain[0], /^╭─ ❀ Agents · all sessions ·
|
|
226
|
-
assert.deepEqual(names(), ["mine", "
|
|
227
|
-
assert.match(plain.at(-2) ?? "", /
|
|
569
|
+
assert.match(plain[0], /^╭─ ❀ Agents · all sessions · 1 active ─+ \[F Fullscreen\] \[× Close\]╮$/);
|
|
570
|
+
assert.deepEqual(names(), ["mine"], "retained foreign tasks cannot manufacture open orchestrators");
|
|
571
|
+
assert.match(plain.at(-2) ?? "", /\[Scope\]/);
|
|
228
572
|
view.handleInput("j");
|
|
229
|
-
assert.equal(view.selectedTask()?.id, "
|
|
573
|
+
assert.equal(view.selectedTask()?.id, "mine", "navigation stops at the only live child");
|
|
230
574
|
view.handleInput("a");
|
|
231
575
|
assert.equal(view.selectedTask()?.id, "mine", "a new scope reads from the top");
|
|
232
|
-
assert.deepEqual(names(), ["mine"
|
|
576
|
+
assert.deepEqual(names(), ["mine"]);
|
|
233
577
|
});
|
|
234
578
|
|
|
235
579
|
test("AgentsView footer buttons follow, open only a session-backed selection, and clear stale geometry", () => {
|
|
@@ -239,8 +583,8 @@ test("AgentsView footer buttons follow, open only a session-backed selection, an
|
|
|
239
583
|
const width = 160;
|
|
240
584
|
const lines = view.render(width);
|
|
241
585
|
const footerY = lines.length - 2;
|
|
242
|
-
const followX =
|
|
243
|
-
const openX =
|
|
586
|
+
const followX = 2;
|
|
587
|
+
const openX = 13;
|
|
244
588
|
assert.match(stripAnsi(lines.at(-2) ?? ""), /\[ Follow \].*\[ Open session \]/, "buttons render only after the full key-hint row fits");
|
|
245
589
|
|
|
246
590
|
view.handleInput("\x1b[5~");
|
|
@@ -260,8 +604,8 @@ test("AgentsView footer buttons follow, open only a session-backed selection, an
|
|
|
260
604
|
assert.equal(view.handleMouse(mouse(openX, footerY, width, lines.length, "click")), undefined, "Open is disabled when the selected task loses its session path");
|
|
261
605
|
assert.deepEqual(events, ["open:a"]);
|
|
262
606
|
view.render(200);
|
|
263
|
-
assert.equal(view.handleMouse(mouse(followX, footerY,
|
|
264
|
-
assert.
|
|
607
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length, "click")), undefined, "resize discards the old footer geometry before routing clicks");
|
|
608
|
+
assert.match(stripAnsi(view.render(44).at(-2) ?? ""), /Follow|Open/, "controls take priority over keyboard hints");
|
|
265
609
|
view.handleInput("a");
|
|
266
610
|
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length, "click")), undefined, "a scope change clears old footer geometry until the next frame");
|
|
267
611
|
view.dispose();
|
|
@@ -272,3 +616,70 @@ test("AgentsView footer buttons follow, open only a session-backed selection, an
|
|
|
272
616
|
assert.equal(empty.handleMouse(mouse(openX, footerY, width, emptyLines.length, "click")), undefined, "Open is disabled with no selected session");
|
|
273
617
|
empty.dispose();
|
|
274
618
|
});
|
|
619
|
+
|
|
620
|
+
test("AgentsView reads live rows, preserves manual scroll and selection, and invalidates stale pointer bounds", () => {
|
|
621
|
+
const store = new TaskStore();
|
|
622
|
+
let rows = 8;
|
|
623
|
+
const view = new AgentsView({
|
|
624
|
+
theme: plainTheme,
|
|
625
|
+
rows: () => rows,
|
|
626
|
+
store,
|
|
627
|
+
sessionId: "s",
|
|
628
|
+
now: () => 61_000,
|
|
629
|
+
onCancel() {},
|
|
630
|
+
onOpen() {},
|
|
631
|
+
onClose() {},
|
|
632
|
+
requestRender() {},
|
|
633
|
+
});
|
|
634
|
+
for (let index = 0; index < 6; index += 1) store.add(task(`t${index}`, { agent: `agent${index}`, createdAt: 1000 - index, lastActivityAt: 1000 - index }));
|
|
635
|
+
for (let index = 0; index < 10; index += 1) store.apply("t4", { type: TASK_EVENT.TEXT, text: `line ${index}\n` }, 2000);
|
|
636
|
+
for (let index = 0; index < 4; index += 1) view.handleInput("j");
|
|
637
|
+
view.render(80);
|
|
638
|
+
view.handleInput("\x1b[5~");
|
|
639
|
+
rows = 6;
|
|
640
|
+
const resized = view.render(80).map(stripAnsi);
|
|
641
|
+
assert.equal(resized.length, 6, "the frame uses the live terminal height budget");
|
|
642
|
+
assert.equal(view.selectedTask()?.id, "t4", "resize never changes the selected task");
|
|
643
|
+
assert.match(resized[3] ?? "", /▸ └ ◐ Subagent agent4/, "the list scroll clamps while retaining the selected row");
|
|
644
|
+
assert.doesNotMatch(resized.join("\n"), /line 9/, "manual thread scroll survives resize instead of returning to the tail");
|
|
645
|
+
assert.equal(view.handleMouse(mouse(4, 2, 80, 8, "click")), undefined, "old-height pointer input is stale after resize");
|
|
646
|
+
view.render(40);
|
|
647
|
+
view.handleInput("\t");
|
|
648
|
+
const narrow = view.render(40).map(stripAnsi);
|
|
649
|
+
rows = 5;
|
|
650
|
+
const narrower = view.render(40).map(stripAnsi);
|
|
651
|
+
assert.equal(view.selectedTask()?.id, "t4", "a narrow resize retains the selection");
|
|
652
|
+
assert.doesNotMatch(narrow.join("\n"), /line 9/);
|
|
653
|
+
assert.doesNotMatch(narrower.join("\n"), /line 9/, "a narrow resize preserves manual detail scrolling");
|
|
654
|
+
rows = 2;
|
|
655
|
+
assert.equal(view.render(80).length, 1, "a tiny terminal gets one bounded fallback line, never forced chrome");
|
|
656
|
+
view.dispose();
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test("AgentsView rejects cached-height pointer input before the live resize renders", () => {
|
|
660
|
+
const store = new TaskStore();
|
|
661
|
+
let rows = 8;
|
|
662
|
+
const view = new AgentsView({
|
|
663
|
+
theme: plainTheme,
|
|
664
|
+
rows: () => rows,
|
|
665
|
+
store,
|
|
666
|
+
sessionId: "s",
|
|
667
|
+
now: () => 61_000,
|
|
668
|
+
onCancel() {},
|
|
669
|
+
onOpen() {},
|
|
670
|
+
onClose() {},
|
|
671
|
+
requestRender() {},
|
|
672
|
+
});
|
|
673
|
+
store.add(task("a"));
|
|
674
|
+
for (let index = 0; index < 12; index += 1) store.apply("a", { type: TASK_EVENT.TEXT, text: `l${index}\n` }, 2000);
|
|
675
|
+
view.handleInput("\x1b[5~");
|
|
676
|
+
const oldFrame = view.render(80);
|
|
677
|
+
assert.match(stripAnsi(oldFrame[3] ?? ""), /l0/, "manual scrolling establishes a stable pre-resize position");
|
|
678
|
+
rows = 6;
|
|
679
|
+
assert.equal(view.handleMouse(mouse(50, 2, 80, oldFrame.length, "wheel", 1)), undefined, "old-height wheel input is inert before the resize frame");
|
|
680
|
+
const resized = view.render(80);
|
|
681
|
+
assert.match(stripAnsi(resized[3] ?? ""), /l0/, "the stale wheel leaves manual thread scroll unchanged");
|
|
682
|
+
assert.equal(view.handleMouse(mouse(50, 2, 80, resized.length, "wheel", 1))?.handled, true, "the new-height wheel reaches the rendered thread region");
|
|
683
|
+
assert.match(stripAnsi(view.render(80)[2] ?? ""), /l0/, "the live wheel scrolls past the semantic block label after the new frame");
|
|
684
|
+
view.dispose();
|
|
685
|
+
});
|