gentle-pi 2.4.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 +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -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 +39 -11
- package/assets/orchestrator.md +5 -5
- 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/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- 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 +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- 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 +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -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 +240 -0
- 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 +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- 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 +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -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 +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- 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 +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- 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 +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- 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-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 +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- 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 +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- 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 +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -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 +656 -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 +156 -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-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- 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 +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -0,0 +1,685 @@
|
|
|
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 { renderThreadItem } from "../lib/agents-thread-view.ts";
|
|
6
|
+
import { AgentsView, taskHeader } from "../lib/agents-view.ts";
|
|
7
|
+
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
8
|
+
|
|
9
|
+
// Gentle Agents overlay: list left, selected thread right, tail-following,
|
|
10
|
+
// and only the selected task subscribed.
|
|
11
|
+
|
|
12
|
+
const plainTheme = { fg: (_color: string, text: string) => text };
|
|
13
|
+
|
|
14
|
+
function task(id: string, overrides: Partial<TaskRecord> = {}): TaskRecord {
|
|
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 };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function harness(rows: number | (() => number) = 8, sessionId = "s", canCancel?: (task: TaskRecord) => boolean) {
|
|
19
|
+
const store = new TaskStore();
|
|
20
|
+
const events: string[] = [];
|
|
21
|
+
let renders = 0;
|
|
22
|
+
const view = new AgentsView({
|
|
23
|
+
theme: plainTheme,
|
|
24
|
+
rows,
|
|
25
|
+
store,
|
|
26
|
+
sessionId,
|
|
27
|
+
now: () => 61_000,
|
|
28
|
+
canCancel,
|
|
29
|
+
onCancel: (entry) => events.push(`cancel:${entry.id}`),
|
|
30
|
+
onOpen: (entry) => events.push(`open:${entry.id}`),
|
|
31
|
+
onClose: () => events.push("close"),
|
|
32
|
+
requestRender: () => (renders += 1),
|
|
33
|
+
});
|
|
34
|
+
return { store, view, events, renders: () => renders };
|
|
35
|
+
}
|
|
36
|
+
|
|
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"]);
|
|
234
|
+
const output = Array.from({ length: 10 }, (_, index) => `line ${index}`).join("\n");
|
|
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");
|
|
241
|
+
// A long output line wraps instead of being clipped, so nothing is hidden.
|
|
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"]);
|
|
245
|
+
assert.equal(taskHeader(task("a"), 61_000), "explore · running · gpt-5.6-terra · 34k · $0.27 · 1m00s");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("AgentsView renders the frame with the task list and the selected thread's tail, at width", () => {
|
|
249
|
+
const { store, view } = harness(8);
|
|
250
|
+
store.add(task("a"));
|
|
251
|
+
store.add(task("b", { agent: "worker", status: TASK_STATUS.COMPLETED, endedAt: 5000, createdAt: 900, lastActivityAt: 900 }));
|
|
252
|
+
for (let index = 0; index < 6; index += 1) store.apply("a", { type: TASK_EVENT.TEXT, text: `line ${index}\n` }, 2000);
|
|
253
|
+
const lines = view.render(90);
|
|
254
|
+
for (const line of lines) assert.equal(visibleWidth(line), 90, `"${stripAnsi(line)}" is not 90 wide`);
|
|
255
|
+
const plain = lines.map(stripAnsi);
|
|
256
|
+
assert.equal(plain.length, 8);
|
|
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);
|
|
262
|
+
assert.match(plain[4], /line 5/);
|
|
263
|
+
assert.match(plain[6], /\[ Follow \].*\[ Open session \].*\[Stop\]/);
|
|
264
|
+
assert.match(plain[7], /^╰─+╯$/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("AgentsView keys move the selection, scroll, follow, cancel, open, and close", () => {
|
|
268
|
+
const { store, view, events } = harness(8);
|
|
269
|
+
store.add(task("a"));
|
|
270
|
+
store.add(task("b", { createdAt: 900, lastActivityAt: 900, status: TASK_STATUS.WAITING }));
|
|
271
|
+
assert.equal(view.selectedTask()?.id, "a");
|
|
272
|
+
view.handleInput("j");
|
|
273
|
+
assert.equal(view.selectedTask()?.id, "b");
|
|
274
|
+
view.handleInput("k");
|
|
275
|
+
assert.deepEqual(events, [], "navigation alone does not cancel");
|
|
276
|
+
view.handleInput("c");
|
|
277
|
+
view.handleInput("o");
|
|
278
|
+
assert.deepEqual(events, ["cancel:a", "open:a"]);
|
|
279
|
+
for (let index = 0; index < 12; index += 1) store.apply("a", { type: TASK_EVENT.TEXT, text: `l${index}\n` }, 2000);
|
|
280
|
+
view.handleInput("\x1b[5~");
|
|
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");
|
|
283
|
+
view.handleInput("\x0a");
|
|
284
|
+
assert.match(stripAnsi(view.render(80)[2]), /│ l3 +│$/, "ctrl+j scrolls one page down across the labeled block");
|
|
285
|
+
view.handleInput("\x0b");
|
|
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");
|
|
288
|
+
view.handleInput("f");
|
|
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"]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("AgentsView subscribes only to the selected task and survives an empty store", () => {
|
|
295
|
+
const { store, view, renders } = harness(6);
|
|
296
|
+
assert.match(stripAnsi(view.render(60)[1]), /no tasks yet/);
|
|
297
|
+
store.add(task("a"));
|
|
298
|
+
store.add(task("b", { createdAt: 900, lastActivityAt: 900 }));
|
|
299
|
+
const before = renders();
|
|
300
|
+
store.apply("b", { type: TASK_EVENT.TEXT, text: "quiet" }, 2000);
|
|
301
|
+
assert.equal(renders(), before, "an event on the unselected task renders nothing");
|
|
302
|
+
store.apply("a", { type: TASK_EVENT.TEXT, text: "loud" }, 2000);
|
|
303
|
+
assert.equal(renders(), before + 1);
|
|
304
|
+
view.dispose();
|
|
305
|
+
store.apply("a", { type: TASK_EVENT.TEXT, text: "after" }, 2000);
|
|
306
|
+
assert.equal(renders(), before + 1, "disposed views stay quiet");
|
|
307
|
+
});
|
|
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
|
+
|
|
357
|
+
function mouse(x: number, y: number, width: number, height: number, type: TuiMouseEvent["type"] = "move", wheelDelta?: number): TuiMouseEvent {
|
|
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 };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
test("AgentsView pointer regions hover and select task rows without activating, and keep list and thread wheels independent", () => {
|
|
362
|
+
const { store, view, events } = harness(6);
|
|
363
|
+
for (const id of ["a", "b", "c", "d", "e"]) store.add(task(id, { agent: id }));
|
|
364
|
+
for (let index = 0; index < 8; index += 1) store.apply("b", { type: TASK_EVENT.TEXT, text: `thread ${index}\n` }, 2000);
|
|
365
|
+
const lines = view.render(80);
|
|
366
|
+
|
|
367
|
+
assert.equal(view.handleMouse(mouse(4, 2, 80, lines.length))?.handled, true, "hover consumes only the task row");
|
|
368
|
+
assert.equal(view.selectedTask()?.id, "a", "hover never changes keyboard selection or the displayed thread");
|
|
369
|
+
assert.equal(view.handleMouse(mouse(4, 2, 80, lines.length, "click"))?.handled, true);
|
|
370
|
+
assert.equal(view.selectedTask()?.id, "b", "click selects the task and displays its thread");
|
|
371
|
+
assert.deepEqual(events, [], "click selects the task only; it never opens or cancels");
|
|
372
|
+
view.render(80);
|
|
373
|
+
assert.equal(view.handleMouse(mouse(50, 2, 80, lines.length, "wheel", -1))?.handled, true, "thread wheel is handled by its viewport");
|
|
374
|
+
assert.match(stripAnsi(view.render(80)[2]), /thread 6/, "thread wheel moves only the selected thread");
|
|
375
|
+
assert.equal(view.handleMouse(mouse(4, 2, 80, lines.length, "wheel", 1))?.handled, true, "list wheel is handled by its viewport");
|
|
376
|
+
assert.match(stripAnsi(view.render(80)[1]), / b /, "list wheel changes only the task-list viewport");
|
|
377
|
+
assert.equal(view.handleMouse(mouse(50, 2, 80, lines.length, "click")), undefined, "thread clicks are inert");
|
|
378
|
+
view.dispose();
|
|
379
|
+
assert.equal(view.handleMouse(mouse(4, 2, 80, lines.length)), undefined, "late pointer events are inert after disposal");
|
|
380
|
+
});
|
|
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
|
+
|
|
444
|
+
test("AgentsView clears hover on leave, list scrolling, resize, updates, empty lists, and disposal", () => {
|
|
445
|
+
const { store, view } = harness(6);
|
|
446
|
+
store.add(task("a"));
|
|
447
|
+
store.add(task("b", { agent: "b" }));
|
|
448
|
+
store.add(task("c", { agent: "c" }));
|
|
449
|
+
store.add(task("d", { agent: "d" }));
|
|
450
|
+
const lines = view.render(80);
|
|
451
|
+
const observer = view.mouseObserver();
|
|
452
|
+
const dispatch = (event: TuiMouseEvent) => {
|
|
453
|
+
observer.beforeMouse(event);
|
|
454
|
+
try {
|
|
455
|
+
return view.handleMouse(event);
|
|
456
|
+
} finally {
|
|
457
|
+
observer.afterMouse(event);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
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));
|
|
465
|
+
assert.doesNotMatch(stripAnsi(view.render(80).join("\n")), /▹/, "list scrolling clears hover so it cannot remain on the task formerly under the pointer");
|
|
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));
|
|
470
|
+
view.render(81);
|
|
471
|
+
assert.doesNotMatch(stripAnsi(view.render(81)[3]), /▹/, "resize clears hover before the next frame");
|
|
472
|
+
dispatch(mouse(4, 3, 81, lines.length));
|
|
473
|
+
store.apply("a", { type: TASK_EVENT.TEXT, text: "update" }, 2000);
|
|
474
|
+
assert.doesNotMatch(stripAnsi(view.render(81)[3]), /▹/, "task updates clear the actual hovered task row");
|
|
475
|
+
view.dispose();
|
|
476
|
+
const { view: empty } = harness(6);
|
|
477
|
+
empty.render(80);
|
|
478
|
+
assert.equal(empty.handleMouse(mouse(4, 1, 80, 6)), undefined, "empty task rows are inert");
|
|
479
|
+
empty.dispose();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("AgentsView advertises s to stop an active selection, retains c as an alias, and hides stopping for finished tasks", () => {
|
|
483
|
+
const { store, view, events } = harness(8);
|
|
484
|
+
store.add(task("active"));
|
|
485
|
+
assert.match(stripAnsi(view.render(80).at(-2) ?? ""), /\[Stop\]/);
|
|
486
|
+
view.handleInput("s");
|
|
487
|
+
view.handleInput("c");
|
|
488
|
+
assert.deepEqual(events, ["cancel:active", "cancel:active"]);
|
|
489
|
+
store.update("active", { status: TASK_STATUS.CANCELLED, endedAt: 2000 });
|
|
490
|
+
assert.doesNotMatch(stripAnsi(view.render(80).at(-2) ?? ""), /\[Stop\]/);
|
|
491
|
+
view.handleInput("s");
|
|
492
|
+
assert.deepEqual(events, ["cancel:active", "cancel:active"], "finished tasks never stop");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("AgentsView close control, Escape, and q share rendered bounds and one idempotent close", () => {
|
|
496
|
+
const { store, view, events } = harness(8);
|
|
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);
|
|
522
|
+
view.handleInput("\x1b");
|
|
523
|
+
view.handleInput("q");
|
|
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");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("AgentsView scrolls the task list so the selection stays visible when there are more tasks than rows", () => {
|
|
537
|
+
const { store, view } = harness(6);
|
|
538
|
+
for (let index = 0; index < 6; index += 1) store.add(task(`t${index}`, { agent: `agent${index}`, createdAt: 1000 - index, lastActivityAt: 1000 - index }));
|
|
539
|
+
const listed = () => view.render(80).slice(1, 4).map((line) => stripAnsi(line).slice(0, 24));
|
|
540
|
+
assert.match(listed()[0], /▸ └ ◐ Subagent agent0/);
|
|
541
|
+
assert.match(listed()[1], /Subagent agent1/);
|
|
542
|
+
assert.match(listed()[2], /Subagent agent2/);
|
|
543
|
+
for (let index = 0; index < 4; index += 1) view.handleInput("j");
|
|
544
|
+
assert.equal(view.selectedTask()?.id, "t4");
|
|
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/);
|
|
547
|
+
view.handleInput("j");
|
|
548
|
+
view.handleInput("j");
|
|
549
|
+
assert.equal(view.selectedTask()?.id, "t5", "the selection stops at the last task");
|
|
550
|
+
assert.match(listed()[2], /▸ └ ◐ Subagent agent5/);
|
|
551
|
+
for (let index = 0; index < 4; index += 1) view.handleInput("k");
|
|
552
|
+
assert.match(listed()[0], /▸ └ ◐ Subagent agent1/, "moving up scrolls the list back");
|
|
553
|
+
assert.match(listed()[2], /Subagent agent3/);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("AgentsView scopes to live children and toggles only the open-session directory", () => {
|
|
557
|
+
const { store, view } = harness(12, "s");
|
|
558
|
+
store.add(task("mine", { agent: "mine" }));
|
|
559
|
+
store.add(task("theirs", { agent: "theirs", parentSessionId: "other", createdAt: 900, lastActivityAt: 900 }));
|
|
560
|
+
store.add(task("fresh", { agent: "fresh", status: TASK_STATUS.COMPLETED, endedAt: 61_000 - 60_000, createdAt: 850, lastActivityAt: 850 }));
|
|
561
|
+
store.add(task("stale", { agent: "stale", status: TASK_STATUS.COMPLETED, endedAt: 61_000 - 16 * 60_000, createdAt: 800, lastActivityAt: 800 }));
|
|
562
|
+
const names = () => view.render(80).map(stripAnsi).filter((line) => /[◐✓] Subagent /.test(line)).map((line) => line.match(/[◐✓] Subagent (\w+)/)?.[1]);
|
|
563
|
+
let plain = view.render(80).map(stripAnsi);
|
|
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\]/);
|
|
567
|
+
view.handleInput("a");
|
|
568
|
+
plain = view.render(80).map(stripAnsi);
|
|
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\]/);
|
|
572
|
+
view.handleInput("j");
|
|
573
|
+
assert.equal(view.selectedTask()?.id, "mine", "navigation stops at the only live child");
|
|
574
|
+
view.handleInput("a");
|
|
575
|
+
assert.equal(view.selectedTask()?.id, "mine", "a new scope reads from the top");
|
|
576
|
+
assert.deepEqual(names(), ["mine"]);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("AgentsView footer buttons follow, open only a session-backed selection, and clear stale geometry", () => {
|
|
580
|
+
const { store, view, events } = harness(8, "s");
|
|
581
|
+
store.add(task("a", { sessionPath: "/sessions/a.jsonl" }));
|
|
582
|
+
for (let index = 0; index < 12; index += 1) store.apply("a", { type: TASK_EVENT.TEXT, text: `line ${index}\n` }, 2000);
|
|
583
|
+
const width = 160;
|
|
584
|
+
const lines = view.render(width);
|
|
585
|
+
const footerY = lines.length - 2;
|
|
586
|
+
const followX = 2;
|
|
587
|
+
const openX = 13;
|
|
588
|
+
assert.match(stripAnsi(lines.at(-2) ?? ""), /\[ Follow \].*\[ Open session \]/, "buttons render only after the full key-hint row fits");
|
|
589
|
+
|
|
590
|
+
view.handleInput("\x1b[5~");
|
|
591
|
+
assert.match(stripAnsi(view.render(width)[2]), /line 5/, "page up leaves follow mode before the button restores it");
|
|
592
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length))?.render, true, "button hover requests a distinct frame");
|
|
593
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length, "press")), undefined, "press is inert");
|
|
594
|
+
assert.equal(view.handleMouse({ ...mouse(followX, footerY, width, lines.length, "click"), button: "right" }), undefined, "right click is inert");
|
|
595
|
+
assert.equal(view.handleMouse({ ...mouse(followX, footerY, width, lines.length, "click"), button: "middle" }), undefined, "middle click is inert");
|
|
596
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length, "click"))?.handled, true);
|
|
597
|
+
assert.match(stripAnsi(view.render(width)[2]), /line 9/, "Follow returns the selected thread to its tail");
|
|
598
|
+
assert.equal(view.handleMouse(mouse(openX, footerY, width, lines.length, "click"))?.handled, true);
|
|
599
|
+
assert.deepEqual(events, ["open:a"], "Open delegates the selected task to the existing callback");
|
|
600
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width + 1, lines.length, "click")), undefined, "a pre-render resize makes the prior footer geometry inert");
|
|
601
|
+
|
|
602
|
+
store.update("a", { sessionPath: null });
|
|
603
|
+
view.render(width);
|
|
604
|
+
assert.equal(view.handleMouse(mouse(openX, footerY, width, lines.length, "click")), undefined, "Open is disabled when the selected task loses its session path");
|
|
605
|
+
assert.deepEqual(events, ["open:a"]);
|
|
606
|
+
view.render(200);
|
|
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");
|
|
609
|
+
view.handleInput("a");
|
|
610
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length, "click")), undefined, "a scope change clears old footer geometry until the next frame");
|
|
611
|
+
view.dispose();
|
|
612
|
+
assert.equal(view.handleMouse(mouse(followX, footerY, width, lines.length, "click")), undefined, "disposed footer controls stay inert");
|
|
613
|
+
const { view: empty } = harness(8, "s");
|
|
614
|
+
const emptyLines = empty.render(width);
|
|
615
|
+
assert.equal(empty.handleMouse(mouse(followX, footerY, width, emptyLines.length, "click")), undefined, "Follow is disabled with no selected task");
|
|
616
|
+
assert.equal(empty.handleMouse(mouse(openX, footerY, width, emptyLines.length, "click")), undefined, "Open is disabled with no selected session");
|
|
617
|
+
empty.dispose();
|
|
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
|
+
});
|