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.
Files changed (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. 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 { AgentsView, itemLines, taskHeader } from "../lib/agents-view.ts";
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?: string) {
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
- test("itemLines renders text, whole thinking blocks, whole tool output, and notes", () => {
36
- assert.deepEqual(itemLines({ kind: "text", text: "one two three four" }, plainTheme, 9), ["one two", "three", "four"]);
37
- assert.deepEqual(itemLines({ kind: "thinking", text: "deep\nthoughts" }, plainTheme, 20), ["∴ deep", " thoughts"]);
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 = itemLines({ kind: "tool", callId: "c", name: "bash", args: { command: "ls -la" }, output, running: true, isError: false }, plainTheme, 30);
40
- assert.equal(tool[0], "▸ bash ls -la");
41
- assert.equal(tool.length, 12, "head, every output line, running marker");
42
- assert.equal(tool[1], " line 0");
43
- assert.equal(tool[10], " line 9");
44
- assert.equal(tool[11], " …");
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 = itemLines({ kind: "tool", callId: "c", name: "bash", args: {}, output: "alpha beta gamma delta", running: false, isError: false }, plainTheme, 12);
47
- assert.deepEqual(wide.slice(1), [" alpha beta", " gamma", " delta"]);
48
- assert.deepEqual(itemLines({ kind: "note", text: "error: boom" }, plainTheme, 30), ["· error: boom"]);
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 · 1 active · 1 finished ─+╮$/);
62
- assert.match(plain[1], /^│ ▸ ◐ explore 1m00s +│ explore · running · gpt-5\.6-terra · 34k · \$0\.27 · 1m00s +│$/);
63
- assert.match(plain[2], /^│ ✓ worker 4s +│ line 3 +│$/, "the thread window follows the tail");
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], /j\/k task .* esc close/);
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.COMPLETED, endedAt: 5000 }));
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
- view.handleInput("\x1b");
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]), /│ l0 +│$/, "page up leaves follow mode and shows the top");
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]), /│ l4 +│$/, "ctrl+j scrolls one page down");
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]), /│ l0 +│$/, "ctrl+k scrolls one page up");
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]), /│ l9 +│$/, "f follows the tail again");
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, 2, 80, lines.length));
151
- assert.match(stripAnsi(view.render(80)[2]), /▹/, "the hovered row is styled without changing selection");
152
- dispatch(mouse(4, 2, 80, lines.length, "wheel", -1));
153
- assert.match(stripAnsi(view.render(80)[2]), /▹/, "a list wheel event at its boundary preserves hover");
154
- dispatch(mouse(4, 2, 80, lines.length, "wheel", 1));
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, 2, 80, lines.length));
157
- dispatch(mouse(50, 2, 80, lines.length));
158
- assert.doesNotMatch(stripAnsi(view.render(80)[2]), /▹/, "the root observer clears hover outside a child region");
159
- dispatch(mouse(4, 2, 80, lines.length));
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)[2]), /▹/, "resize clears hover before the next frame");
162
- dispatch(mouse(4, 2, 81, lines.length));
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)[2]), /▹/, "task updates clear hover");
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) ?? ""), /s Stop selected/);
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) ?? ""), /Stop selected/);
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 close an active selection without cancelling it", () => {
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", "close"], "close keys never invoke selected-task cancellation");
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()[2], /agent2/);
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 lists the active session's recent tasks by default and a toggles every session", () => {
213
- const { store, view } = harness(8, "s");
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 · 1 finished ─+╮$/);
221
- assert.deepEqual(names(), ["mine", "fresh"], "another session's task and one finished over fifteen minutes ago stay out");
222
- assert.match(plain.at(-2) ?? "", /a all sessions/);
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 · 2 active · 2 finished ─+╮$/);
226
- assert.deepEqual(names(), ["mine", "theirs", "fresh", "stale"]);
227
- assert.match(plain.at(-2) ?? "", /a this session/);
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, "theirs");
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", "fresh"]);
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 = 131;
243
- const openX = 142;
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, 200, lines.length, "click")), undefined, "resize discards the old footer geometry before routing clicks");
264
- assert.doesNotMatch(stripAnsi(view.render(44).at(-2) ?? ""), /\[ Follow \]|\[ Open session \]/, "buttons hide instead of truncating when the rendered key hints do not fit");
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
+ });