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
@@ -1,19 +1,20 @@
1
- import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
- import { createNativePointerScope, type NativePointerRegion } from "./native-pointer-region.ts";
3
- import { isFinished, TASK_STATUS, THREAD_ITEM, type TaskRecord, type TaskStore, type TaskThread, type ThreadItem, type ToolItem } from "./agents-protocol.ts";
1
+ import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
+ import { measureAgentsViewLayout, type AgentsViewLayout } from "./agents-view-layout.ts";
3
+ import { emptyThread, isFinished, TASK_STATUS, type TaskRecord, type TaskStore, type TaskThread, type ThreadItem } from "./agents-protocol.ts";
4
+ import { renderThreadItem, type AgentsThreadTheme } from "./agents-thread-view.ts";
4
5
  import { formatElapsed } from "./agents-widget.ts";
6
+ import { createNativePointerScope, type NativePointerRegion } from "./native-pointer-region.ts";
5
7
  import { formatTokens } from "./shell-bar.ts";
8
+ import { PresenceCursor, readActivity, type Header, type Target } from "./orchestrator-presence.ts";
6
9
 
7
10
  // Gentle Agents overlay: tasks on the left, the selected task's thread on
8
11
  // the right. Only the selected task is subscribed, thread items are rendered
9
12
  // once each (they are immutable until replaced), and the viewport shows the
10
- // tail unless the human scrolls up. The list opens on the active session's
11
- // work (active tasks plus those finished in the last quarter hour); `a`
12
- // widens it to every task of every session, including the stored history.
13
+ // tail unless the human scrolls up. Current scope contains direct live children;
14
+ // all sessions is a presence directory, not a history browser. Remote activity
15
+ // is presentation-only and never enters the local TaskStore.
13
16
 
14
- export interface AgentsViewTheme {
15
- fg(color: string, text: string): string;
16
- }
17
+ export interface AgentsViewTheme extends AgentsThreadTheme {}
17
18
 
18
19
  export const VIEW_SCOPE = {
19
20
  SESSION: "session",
@@ -24,14 +25,14 @@ export type ViewScope = (typeof VIEW_SCOPE)[keyof typeof VIEW_SCOPE];
24
25
 
25
26
  export interface AgentsViewDeps {
26
27
  theme: AgentsViewTheme;
27
- rows: number;
28
+ rows: number | (() => number);
28
29
  store: TaskStore;
29
- // The active session; without it there is nothing to scope by and the
30
- // list shows every task.
31
30
  sessionId?: string;
31
+ presence?: { profile: string; target?: Readonly<Target> };
32
32
  now(): number;
33
33
  onCancel(task: TaskRecord): void;
34
34
  canCancel?(task: TaskRecord): boolean;
35
+ isLocalTask?(task: TaskRecord): boolean;
35
36
  onOpen(task: TaskRecord): void;
36
37
  onClose(): void;
37
38
  requestRender(): void;
@@ -41,16 +42,10 @@ const ROLE = {
41
42
  FRAME: "border",
42
43
  TITLE: "customMessageLabel",
43
44
  SELECTED: "accent",
45
+ HOVER: "warning",
44
46
  NAME: "text",
45
47
  NAME_IDLE: "muted",
46
48
  META: "dim",
47
- TEXT: "text",
48
- THINKING: "dim",
49
- TOOL: "accent",
50
- TOOL_ERROR: "error",
51
- OUTPUT: "muted",
52
- NOTE: "muted",
53
- NOTE_ERROR: "warning",
54
49
  KEY: "accent",
55
50
  KEY_TEXT: "dim",
56
51
  EMPTY: "dim",
@@ -74,10 +69,6 @@ const GLYPH_ROLE: Record<string, string> = {
74
69
  [TASK_STATUS.CANCELLED]: "dim",
75
70
  [TASK_STATUS.TIMED_OUT]: "error",
76
71
  };
77
- const LIST_MAX_WIDTH = 34;
78
- const LIST_RATIO = 0.32;
79
- const CHROME_ROWS = 3;
80
- const MIN_BODY_ROWS = 1;
81
72
  export const SESSION_FINISHED_TTL_MS = 15 * 60_000;
82
73
  const SCOPE_LABEL: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "this session", [VIEW_SCOPE.ALL]: "all sessions" };
83
74
  const SCOPE_KEY: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "all sessions", [VIEW_SCOPE.ALL]: "this session" };
@@ -85,13 +76,17 @@ const EMPTY_LIST = "no tasks yet";
85
76
  const EMPTY_THREAD = "waiting for the first event";
86
77
  const FOLLOW_BUTTON = "[ Follow ]";
87
78
  const OPEN_BUTTON = "[ Open session ]";
88
- const FOOTER_BUTTONS_WIDTH = FOLLOW_BUTTON.length + 1 + OPEN_BUTTON.length;
79
+ const CLOSE_BUTTON = "[× Close]";
80
+ // At the 60-cell split boundary these four controls fit before any hints.
81
+ const STOP_BUTTON = "[Stop]";
82
+ const SCOPE_BUTTON = "[Scope]";
89
83
  const KEYS = [
90
84
  ["j/k", "task"],
91
85
  ["ctrl+j/k", "scroll"],
92
86
  ["f", "follow"],
93
87
  ["o", "open session"],
94
- ["esc", "close"],
88
+ ["esc", "back"],
89
+ ["q", "close"],
95
90
  ] as const;
96
91
 
97
92
  const EMPTY_COMPONENT: Component = {
@@ -104,19 +99,25 @@ interface PointerButtonLayout {
104
99
  width: number;
105
100
  }
106
101
 
107
- interface PointerLayout {
108
- width: number;
109
- height: number;
110
- listX: number;
111
- listWidth: number;
112
- threadX: number;
113
- threadWidth: number;
114
- bodyRows: number;
115
- footerY: number;
116
- followButton?: PointerButtonLayout;
117
- openButton?: PointerButtonLayout;
102
+ interface PointerLayout extends AgentsViewLayout {
103
+ narrowView: "list" | "details";
104
+ sourceHeight?: number;
105
+ closeButton?: PointerButtonLayout;
106
+ modeButton?: PointerButtonLayout;
107
+ actions?: Array<PointerButtonLayout & { region: NativePointerRegion }>;
118
108
  }
119
109
 
110
+ interface SessionGroup {
111
+ id: string;
112
+ sessionId: string | undefined;
113
+ label?: string;
114
+ tasks: TaskRecord[];
115
+ }
116
+
117
+ type VisibleRow =
118
+ | { id: string; kind: "heading"; group: SessionGroup }
119
+ | { id: string; kind: "task"; group: SessionGroup; task: TaskRecord };
120
+
120
121
  function rule(length: number): string {
121
122
  return "─".repeat(Math.max(0, length));
122
123
  }
@@ -126,49 +127,6 @@ function fit(text: string, width: number): string {
126
127
  return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
127
128
  }
128
129
 
129
- function argsSummary(item: ToolItem): string {
130
- const values = Object.values(item.args).filter((value) => typeof value === "string") as string[];
131
- return (values[0] ?? "").replace(/\s+/g, " ").trim();
132
- }
133
-
134
- function toolLines(item: ToolItem, theme: AgentsViewTheme, width: number): string[] {
135
- const role = item.isError ? ROLE.TOOL_ERROR : ROLE.TOOL;
136
- const head = truncateToWidth(`▸ ${item.name} ${argsSummary(item)}`, width, "…");
137
- const lines = [theme.fg(role, head)];
138
- // The whole captured output, wrapped: the thread pane scrolls, so nothing
139
- // is hidden here. The store already keeps only the last maxOutputChars of a
140
- // tool's output and marks the cut with a leading ellipsis.
141
- for (const line of item.output.split("\n").filter((line) => line.length > 0)) {
142
- for (const wrapped of wrapTextWithAnsi(line, Math.max(1, width - 2))) lines.push(theme.fg(ROLE.OUTPUT, ` ${wrapped}`));
143
- }
144
- if (item.running) lines.push(theme.fg(ROLE.META, " …"));
145
- return lines;
146
- }
147
-
148
- function thinkingLines(text: string, theme: AgentsViewTheme, width: number): string[] {
149
- const lines: string[] = [];
150
- for (const [index, line] of text.split("\n").filter((line) => line.length > 0).entries()) {
151
- const prefix = index === 0 ? "∴ " : " ";
152
- for (const wrapped of wrapTextWithAnsi(line, Math.max(1, width - 2))) lines.push(theme.fg(ROLE.THINKING, `${prefix}${wrapped}`));
153
- }
154
- return lines;
155
- }
156
-
157
- export function itemLines(item: ThreadItem, theme: AgentsViewTheme, width: number): string[] {
158
- switch (item.kind) {
159
- case THREAD_ITEM.TEXT:
160
- return wrapTextWithAnsi(item.text, width).map((line) => theme.fg(ROLE.TEXT, line));
161
- case THREAD_ITEM.THINKING:
162
- return thinkingLines(item.text, theme, width);
163
- case THREAD_ITEM.TOOL:
164
- return toolLines(item, theme, width);
165
- case THREAD_ITEM.NOTE:
166
- return [theme.fg(item.text.startsWith("error") ? ROLE.NOTE_ERROR : ROLE.NOTE, truncateToWidth(`· ${item.text}`, width, "…"))];
167
- default:
168
- return [];
169
- }
170
- }
171
-
172
130
  export function taskHeader(task: TaskRecord, now: number): string {
173
131
  const parts = [task.agent, task.status, task.model, task.tokens > 0 ? formatTokens(task.tokens) : "", task.cost > 0 ? `$${task.cost.toFixed(2)}` : "", task.startedAt === null ? "" : formatElapsed((task.endedAt ?? now) - task.startedAt)];
174
132
  return parts.filter((part) => part.length > 0).join(" · ");
@@ -177,9 +135,17 @@ export function taskHeader(task: TaskRecord, now: number): string {
177
135
  export class AgentsView {
178
136
  private readonly deps: AgentsViewDeps;
179
137
  private tasks: TaskRecord[] = [];
180
- private selected = 0;
181
- private hovered: number | undefined;
138
+ private selectedId: string | undefined;
139
+ private hoveredId: string | undefined;
140
+ private narrowView: "list" | "details" = "list";
141
+ private narrowGroup: string | undefined;
142
+ private groupCursor: string | undefined;
143
+ private footerPage = 0;
144
+ private readonly actionRegions = new Map<string, NativePointerRegion>();
145
+ private readonly expanded = new Map<string, boolean>();
146
+ private readonly observedActiveGroups = new Set<string>();
182
147
  private listScroll = 0;
148
+ private readonly manualListOffsets = new Map<string, number>();
183
149
  private scope: ViewScope;
184
150
  private scroll = 0;
185
151
  private follow = true;
@@ -189,16 +155,95 @@ export class AgentsView {
189
155
  private readonly threadRegion: NativePointerRegion;
190
156
  private readonly followRegion: NativePointerRegion;
191
157
  private readonly openRegion: NativePointerRegion;
192
- private hoveredControl: "follow" | "open" | undefined;
158
+ private readonly closeRegion: NativePointerRegion;
159
+ private readonly modeRegion: NativePointerRegion;
160
+ private hoveredControl: "follow" | "open" | "close" | "mode" | undefined;
193
161
  private pointerLayout: PointerLayout | undefined;
162
+ // Keyboard input has no terminal width, so retain its last rendered width
163
+ // independently from disposable pointer hit geometry.
164
+ private lastRenderedWidth: number | undefined;
165
+ private closed = false;
194
166
  private unsubscribeTask: (() => void) | undefined;
167
+ private subscribedTaskId: string | undefined;
195
168
  private readonly unsubscribeSummary: () => void;
196
169
  private cache = new WeakMap<ThreadItem, string[]>();
197
170
  private cacheWidth = -1;
171
+ private peers: SessionGroup[] = [];
172
+ private remoteThreads = new Map<string, TaskThread>();
173
+ private presenceCursor?: PresenceCursor;
174
+ private presenceTimer?: ReturnType<typeof setTimeout>;
175
+
176
+ // One directory page or one pinned activity per turn; yield between reads.
177
+ // Keep the previous directory until a traversal completes, avoiding page flicker.
178
+ private refreshPresence(): void {
179
+ const source = this.deps.presence;
180
+ if (!source || this.closed) return;
181
+ const groups: SessionGroup[] = [];
182
+ const threads = new Map<string, TaskThread>();
183
+ const seen = new Set<string>();
184
+ let pending: Header[] = [];
185
+ let overflow = true;
186
+ const step = () => {
187
+ if (this.closed) return;
188
+ try {
189
+ if (pending.length) {
190
+ const header = pending.shift()!;
191
+ const id = `${header.sessionHash}:${header.incarnation}`;
192
+ if (!seen.has(id) && header.incarnation !== source.target?.incarnation) {
193
+ seen.add(id);
194
+ const { activity, unavailable } = readActivity(source.profile, header);
195
+ const tasks = (activity?.tasks ?? []).filter(({ summary }) => !isFinished(summary.status as TaskRecord["status"])).map(({ summary, thread }) => {
196
+ const task: TaskRecord = { ...summary, id: `peer:${id}:${summary.id}`, parentSessionId: id,
197
+ status: summary.status as TaskRecord["status"], mode: "background", prompt: "", cwd: "",
198
+ thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "", turns: 0, toolCalls: 0, tokens: 0, cost: 0 };
199
+ threads.set(task.id, { ...emptyThread(), ...thread,
200
+ items: thread.items.map((item) => item.kind === "tool" ? { ...item, args: {} } : item) as ThreadItem[] });
201
+ return task;
202
+ });
203
+ groups.push({ id, sessionId: id, label: `${header.label}${unavailable ? " · unavailable" : ""}`, tasks });
204
+ }
205
+ } else if (overflow) {
206
+ this.presenceCursor ??= new PresenceCursor(source.profile);
207
+ const page = this.presenceCursor.next();
208
+ pending = page.entries.filter((entry) => entry.recent);
209
+ overflow = page.overflow;
210
+ } else {
211
+ this.peers = groups;
212
+ this.remoteThreads = threads;
213
+ this.refreshTasks();
214
+ this.clearFooterLayout();
215
+ this.deps.requestRender();
216
+ this.stopPresenceRefresh();
217
+ this.presenceTimer = setTimeout(() => this.refreshPresence(), 1000);
218
+ this.presenceTimer.unref();
219
+ return;
220
+ }
221
+ } catch {
222
+ this.peers = [];
223
+ this.remoteThreads.clear();
224
+ this.refreshTasks();
225
+ this.clearFooterLayout();
226
+ this.deps.requestRender();
227
+ this.stopPresenceRefresh();
228
+ this.presenceTimer = setTimeout(() => this.refreshPresence(), 1000);
229
+ this.presenceTimer.unref();
230
+ return;
231
+ }
232
+ this.presenceTimer = setTimeout(step, 0);
233
+ this.presenceTimer.unref();
234
+ };
235
+ step();
236
+ }
237
+
238
+ private stopPresenceRefresh(): void {
239
+ clearTimeout(this.presenceTimer);
240
+ this.presenceCursor?.close();
241
+ this.presenceCursor = undefined;
242
+ }
198
243
 
199
244
  constructor(deps: AgentsViewDeps) {
200
245
  this.deps = deps;
201
- this.scope = deps.sessionId === undefined ? VIEW_SCOPE.ALL : VIEW_SCOPE.SESSION;
246
+ this.scope = VIEW_SCOPE.SESSION;
202
247
  this.listRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
203
248
  onWheel: (event) => this.wheelList(event),
204
249
  });
@@ -215,6 +260,16 @@ export class AgentsView {
215
260
  onLeave: () => this.clearHoveredControl("open"),
216
261
  onClick: (event) => this.clickOpen(event),
217
262
  });
263
+ this.closeRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
264
+ onHover: () => this.hoverControl("close"),
265
+ onLeave: () => this.clearHoveredControl("close"),
266
+ onClick: (event) => this.clickClose(event),
267
+ });
268
+ this.modeRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
269
+ onHover: () => this.hoverControl("mode"),
270
+ onLeave: () => this.clearHoveredControl("mode"),
271
+ onClick: (event) => this.clickMode(event),
272
+ });
218
273
  this.refreshTasks();
219
274
  this.unsubscribeSummary = deps.store.subscribeSummary(() => {
220
275
  this.clearFooterLayout();
@@ -222,12 +277,17 @@ export class AgentsView {
222
277
  this.deps.requestRender();
223
278
  });
224
279
  this.subscribeSelected();
280
+ this.refreshPresence();
225
281
  }
226
282
 
227
283
  dispose(): void {
284
+ this.closed = true;
285
+ this.stopPresenceRefresh();
228
286
  this.pointerLayout = undefined;
229
287
  this.pointerScope.dispose();
230
288
  this.unsubscribeTask?.();
289
+ this.unsubscribeTask = undefined;
290
+ this.subscribedTaskId = undefined;
231
291
  this.unsubscribeSummary();
232
292
  }
233
293
 
@@ -236,73 +296,127 @@ export class AgentsView {
236
296
  }
237
297
 
238
298
  selectedTask(): TaskRecord | undefined {
239
- return this.tasks[this.selected];
299
+ return this.tasks.find((task) => `task:${task.id}` === this.selectedId);
240
300
  }
241
301
 
242
302
  handleInput(data: string): void {
243
- if (matchesKey(data, Key.escape) || data === "q") {
244
- this.deps.onClose();
303
+ if (this.closed) return;
304
+ if (data === "q") return this.close();
305
+ if (matchesKey(data, Key.escape)) return this.back();
306
+ if (data === "F" && !this.isNarrow()) {
307
+ this.setNarrowView(this.narrowView === "list" ? "details" : "list");
245
308
  return;
246
309
  }
247
- const task = this.selectedTask();
248
- if (data === "j" || matchesKey(data, Key.down)) this.select(this.selected + 1);
249
- else if (data === "k" || matchesKey(data, Key.up)) this.select(this.selected - 1);
310
+ if ((data === "\t" || matchesKey(data, Key.enter)) && this.isNarrow()) {
311
+ if (this.narrowView === "details") this.back();
312
+ else this.activate();
313
+ return;
314
+ }
315
+ const rows = this.visibleRows();
316
+ const selected = this.selectedRow(rows);
317
+ const task = this.actionableTask();
318
+ const index = selected ? rows.findIndex((row) => row.id === selected.id) : -1;
319
+ if (this.narrowView === "details" && (data === "j" || data === "k" || matchesKey(data, Key.down) || matchesKey(data, Key.up))) {
320
+ this.scrollBy(data === "j" || matchesKey(data, Key.down) ? 1 : -1);
321
+ return;
322
+ }
323
+ if (data === "j" || matchesKey(data, Key.down)) this.select(index + 1, rows);
324
+ else if (data === "k" || matchesKey(data, Key.up)) this.select(index - 1, rows);
325
+ else if (matchesKey(data, Key.left) && selected?.kind === "heading") this.setExpanded(selected.group, false);
326
+ else if (matchesKey(data, Key.right) && selected?.kind === "heading") this.setExpanded(selected.group, true);
250
327
  else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("j"))) this.scrollBy(this.pageRows());
251
328
  else if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("k"))) this.scrollBy(-this.pageRows());
252
- else if (data === "f") {
329
+ else if (data === "f" && task) {
253
330
  this.follow = true;
254
331
  this.deps.requestRender();
255
332
  } else if (data === "a" && this.deps.sessionId !== undefined) this.toggleScope();
256
333
  else if ((data === "s" || data === "c") && task && this.canCancel(task)) this.deps.onCancel(task);
257
- else if ((data === "o" || matchesKey(data, Key.enter)) && this.canOpen(task)) this.deps.onOpen(task!);
334
+ else if ((data === "o" || matchesKey(data, Key.enter)) && task && this.canOpen(task)) this.deps.onOpen(task);
258
335
  }
259
336
 
260
337
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
338
+ if (this.closed) return undefined;
339
+ const live = this.layout(event.width);
340
+ const liveHeight = live.mode === "fallback" ? Math.min(1, live.height) : live.height;
341
+ if (event.width !== live.width || event.height !== liveHeight) return undefined;
261
342
  const layout = this.pointerLayout;
262
- if (!layout || event.width !== layout.width || event.height !== layout.height || event.x < 0 || event.y < 0 || event.x >= layout.width || event.y >= layout.height) return undefined;
343
+ if (!layout || (layout.sourceHeight !== undefined && layout.sourceHeight !== live.height) || event.width !== layout.width || event.height !== layout.height || live.mode !== layout.mode || layout.narrowView !== this.narrowView || event.x < 0 || event.y < 0 || event.x >= layout.width || event.y >= layout.height) return undefined;
344
+ if (event.y === 0) {
345
+ if (this.isInButton(event.x, layout.modeButton)) return this.modeRegion.handleMouse(event);
346
+ if (this.isInButton(event.x, layout.closeButton)) return this.closeRegion.handleMouse(event);
347
+ }
263
348
  if (event.y >= 1 && event.y < 1 + layout.bodyRows) {
264
349
  const row = event.y - 1;
265
- if (event.x >= layout.listX && event.x < layout.listX + layout.listWidth) {
266
- if (event.type === "wheel") return this.listRegion.handleMouse(event);
267
- const task = this.tasks[this.listScroll + row];
268
- return task ? this.taskRegion(row).handleMouse(event) : undefined;
350
+ if (layout.mode === "narrow" && event.x >= layout.listX && event.x < layout.listX + layout.listWidth) {
351
+ if (event.type === "wheel") return this.narrowView === "list" ? this.listRegion.handleMouse(event) : this.threadRegion.handleMouse(event);
352
+ const visible = this.narrowView === "list" ? this.visibleRows()[this.listScroll + row] : undefined;
353
+ return visible ? this.taskRegion(row).handleMouse(event) : undefined;
269
354
  }
270
- if (event.x >= layout.threadX && event.x < layout.threadX + layout.threadWidth && event.type === "wheel") {
271
- return this.threadRegion.handleMouse(event);
355
+ if (layout.mode === "panes" && event.x >= layout.listX && event.x < layout.listX + layout.listWidth) {
356
+ if (event.type === "wheel") return this.listRegion.handleMouse(event);
357
+ const visible = this.visibleRows()[this.listScroll + row];
358
+ return visible ? this.taskRegion(row).handleMouse(event) : undefined;
272
359
  }
360
+ if (layout.mode === "panes" && event.x >= layout.threadX && event.x < layout.threadX + layout.threadWidth && event.type === "wheel") return this.threadRegion.handleMouse(event);
273
361
  }
274
362
  if (event.y === layout.footerY) {
275
- if (this.isInButton(event.x, layout.followButton)) return this.followRegion.handleMouse(event);
276
- if (this.isInButton(event.x, layout.openButton)) return this.openRegion.handleMouse(event);
363
+ for (const action of layout.actions ?? []) if (this.isInButton(event.x, action)) return action.region.handleMouse(event);
277
364
  }
278
365
  return undefined;
279
366
  }
280
367
 
281
368
  render(width: number): string[] {
282
- // Finished rows age out of the session scope while the overlay is open;
283
- // the list is otherwise reordered only when a status changes.
369
+ const layout = this.layout(width);
370
+ this.lastRenderedWidth = layout.width;
371
+ if (layout.width === 0 || layout.height === 0) {
372
+ this.clearFooterLayout();
373
+ return [];
374
+ }
375
+ if (layout.mode === "fallback") {
376
+ this.clearFooterLayout();
377
+ this.closeRegion.setDisabled(false);
378
+ this.closeRegion.render(1);
379
+ this.pointerLayout = { ...layout, height: 1, sourceHeight: layout.height, narrowView: this.narrowView, closeButton: { x: 0, width: 1 } };
380
+ return ["×" + fit(" Close Agents", layout.width - 1)];
381
+ }
382
+ // Retained terminal rows never belong in either live scope.
284
383
  const now = this.deps.now();
285
384
  if (this.tasks.some((task) => !this.inScope(task, now))) this.refreshTasks();
286
- if (this.pointerLayout && this.pointerLayout.width !== width) this.clearFooterLayout();
385
+ if (this.pointerLayout && (this.pointerLayout.width !== layout.width || this.pointerLayout.height !== layout.height || this.pointerLayout.mode !== layout.mode || this.pointerLayout.narrowView !== this.narrowView)) this.clearFooterLayout();
287
386
  const theme = this.deps.theme;
288
- const inner = width - 2;
289
- const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
290
- const threadWidth = inner - listWidth - 4;
291
- const rows = this.bodyRows();
292
- const footerY = rows + 1;
293
- this.followSelection(rows);
294
- this.pointerLayout = { width, height: rows + CHROME_ROWS, listX: 2, listWidth, threadX: listWidth + 5, threadWidth, bodyRows: rows, footerY };
295
- this.listRegion.render(listWidth);
296
- this.threadRegion.render(threadWidth);
387
+ const inner = layout.width - 2;
388
+ const closeLabel = this.closeLabel(layout);
389
+ const modeLabel = this.modeLabel(layout);
390
+ this.closeRegion.setDisabled(closeLabel === undefined);
391
+ this.modeRegion.setDisabled(modeLabel === undefined);
392
+ this.followSelection(layout.bodyRows);
393
+ const closeWidth = visibleWidth(closeLabel ?? "");
394
+ const modeWidth = visibleWidth(modeLabel ?? "");
395
+ const closeX = layout.width - 1 - closeWidth;
396
+ this.pointerLayout = {
397
+ ...layout,
398
+ narrowView: this.narrowView,
399
+ closeButton: closeLabel ? { x: closeX, width: closeWidth } : undefined,
400
+ modeButton: modeLabel ? { x: closeX - modeWidth - 1, width: modeWidth } : undefined,
401
+ };
402
+ this.listRegion.render(layout.listWidth);
403
+ this.threadRegion.render(layout.threadWidth);
404
+ if (closeLabel) this.closeRegion.render(closeWidth);
405
+ if (modeLabel) this.modeRegion.render(modeWidth);
297
406
  const scope = this.deps.sessionId === undefined ? "" : `${SCOPE_LABEL[this.scope]} · `;
298
- const title = `❀ Agents · ${scope}${this.counts()}`;
299
- const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, title) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(title) - 3)}╮`);
300
- const right = this.threadWindow(rows, threadWidth);
407
+ const controlsWidth = (closeLabel ? closeWidth + 1 : 0) + (modeLabel ? modeWidth + 1 : 0);
408
+ const title = truncateToWidth(`❀ Agents · ${scope}${this.counts()}`, Math.max(0, inner - 3 - controlsWidth), "…");
409
+ const mode = modeLabel ? ` ${theme.fg(this.hoveredControl === "mode" ? "warning" : ROLE.KEY, modeLabel)}` : "";
410
+ const close = closeLabel ? ` ${theme.fg(this.hoveredControl === "close" ? "warning" : ROLE.KEY, closeLabel)}` : "";
411
+ const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, title) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(title) - 3 - controlsWidth)}`) + mode + close + theme.fg(ROLE.FRAME, "╮");
412
+ const detail = layout.mode === "panes" || this.narrowView === "details" ? this.threadWindow(layout.bodyRows, layout.threadWidth) : [];
301
413
  const body: string[] = [];
302
- for (let row = 0; row < rows; row += 1) {
303
- body.push(`${theme.fg(ROLE.FRAME, "│")} ${fit(this.taskLine(row), listWidth)} ${theme.fg(ROLE.FRAME, "│")} ${fit(right[row] ?? "", threadWidth)}${theme.fg(ROLE.FRAME, "│")}`);
414
+ for (let row = 0; row < layout.bodyRows; row += 1) {
415
+ if (layout.mode === "panes") body.push(`${theme.fg(ROLE.FRAME, "│")} ${fit(this.taskLine(row), layout.listWidth)} ${theme.fg(ROLE.FRAME, "│")} ${fit(detail[row] ?? "", layout.threadWidth)}${theme.fg(ROLE.FRAME, "│")}`);
416
+ else body.push(`${theme.fg(ROLE.FRAME, "│")} ${fit(this.narrowView === "list" ? this.taskLine(row) : detail[row] ?? "", layout.listWidth)} ${theme.fg(ROLE.FRAME, "│")}`);
304
417
  }
305
- const keys = this.keys().map(([key, label]) => `${theme.fg(ROLE.KEY, key)} ${theme.fg(ROLE.KEY_TEXT, label)}`).join(" ");
418
+ const keyHints = this.selectedRow()?.kind === "heading" ? [["←/→", "group"] as const, ...this.keys(layout.mode === "narrow")] : this.keys(layout.mode === "narrow");
419
+ const keys = keyHints.map(([key, label]) => `${theme.fg(ROLE.KEY, key)} ${theme.fg(ROLE.KEY_TEXT, label)}`).join(" ");
306
420
  const footer = this.footer(keys, inner - 2);
307
421
  const keysLine = `${theme.fg(ROLE.FRAME, "│")} ${fit(footer, inner - 2)} ${theme.fg(ROLE.FRAME, "│")}`;
308
422
  return [top, ...body, keysLine, theme.fg(ROLE.FRAME, `╰${rule(inner)}╯`)];
@@ -314,91 +428,241 @@ export class AgentsView {
314
428
 
315
429
  private counts(): string {
316
430
  const active = this.tasks.filter((task) => !isFinished(task.status)).length;
317
- return `${active} active · ${this.tasks.length - active} finished`;
431
+ return `${active} active`;
432
+ }
433
+
434
+ private actionableTask(): TaskRecord | undefined {
435
+ return this.directoryRoot() && this.narrowView === "list" ? undefined : this.selectedTask();
318
436
  }
319
437
 
320
438
  private canCancel(task: TaskRecord): boolean {
321
- return !isFinished(task.status) && (this.deps.canCancel?.(task) ?? true);
439
+ return !this.remoteThreads.has(task.id) && !isFinished(task.status) && (this.deps.canCancel?.(task) ?? true);
322
440
  }
323
441
 
324
442
  private canOpen(task: TaskRecord | undefined): boolean {
325
- return Boolean(task?.sessionPath);
443
+ return Boolean(task?.sessionPath) && !this.remoteThreads.has(task!.id);
326
444
  }
327
445
 
328
- private keys(): ReadonlyArray<readonly [string, string]> {
329
- const task = this.selectedTask();
446
+ private keys(narrow = false): ReadonlyArray<readonly [string, string]> {
447
+ const task = this.actionableTask();
448
+ const details = narrow && task ? [["tab", this.narrowView === "list" ? "details" : "back"]] as const : [];
330
449
  const stop = task && this.canCancel(task) ? [["s", "Stop selected"]] as const : [];
331
450
  const scope = this.deps.sessionId === undefined ? [] : [["a", SCOPE_KEY[this.scope]]] as const;
332
- return [...KEYS.slice(0, 3), ...stop, ...scope, ...KEYS.slice(3)];
451
+ return [...KEYS.slice(0, 1), ...details, ...KEYS.slice(1, 3), ...stop, ...scope, ...KEYS.slice(3)];
333
452
  }
334
453
 
335
454
  // A new scope reads from the top: selection, list window, and thread reset.
336
455
  private toggleScope(): void {
337
456
  this.clearFooterLayout();
338
457
  this.scope = this.scope === VIEW_SCOPE.SESSION ? VIEW_SCOPE.ALL : VIEW_SCOPE.SESSION;
458
+ this.narrowGroup = undefined;
459
+ this.groupCursor = undefined;
460
+ this.manualListOffsets.clear();
461
+ this.narrowView = "list";
339
462
  this.tasks = [];
340
- this.selected = 0;
463
+ this.selectedId = undefined;
341
464
  this.listScroll = 0;
342
- this.hovered = undefined;
465
+ this.hoveredId = undefined;
343
466
  this.scroll = 0;
344
467
  this.follow = true;
345
468
  this.refreshTasks();
346
469
  this.deps.requestRender();
347
470
  }
348
471
 
349
- // The session scope: this session's active tasks plus those that finished
350
- // within the last quarter hour. Everything else waits under "all sessions".
351
- private inScope(task: TaskRecord, now: number): boolean {
352
- if (this.scope === VIEW_SCOPE.ALL) return true;
353
- if (task.parentSessionId !== this.deps.sessionId) return false;
354
- return !isFinished(task.status) || task.endedAt === null || now - task.endedAt < SESSION_FINISHED_TTL_MS;
472
+ private inScope(task: TaskRecord, _now: number): boolean {
473
+ return !isFinished(task.status) && (task.parentSessionId === this.deps.sessionId
474
+ || (this.scope === VIEW_SCOPE.ALL && this.remoteThreads.has(task.id)));
475
+ }
476
+
477
+ private directoryRoot(): boolean {
478
+ return this.scope === VIEW_SCOPE.ALL && this.isNarrow() && !this.narrowGroup;
355
479
  }
356
480
 
357
481
  // Keep the selected row inside the list window, moving the window by the
358
482
  // least amount needed; the wheel moves the same window on its own.
359
483
  private followSelection(rows: number): void {
360
- if (this.selected < this.listScroll) this.listScroll = this.selected;
361
- else if (this.selected >= this.listScroll + rows) this.listScroll = this.selected - rows + 1;
362
- this.listScroll = Math.max(0, Math.min(this.listScroll, Math.max(0, this.tasks.length - rows)));
484
+ const manual = this.manualListOffsets.get(this.listKey());
485
+ if (manual !== undefined) {
486
+ this.listScroll = Math.min(manual, Math.max(0, this.visibleRows().length - rows));
487
+ return;
488
+ }
489
+ const selected = this.selectedRow();
490
+ const index = selected ? this.visibleRows().findIndex((row) => row.id === selected.id) : -1;
491
+ if (index >= 0 && index < this.listScroll) this.listScroll = index;
492
+ else if (index >= this.listScroll + rows) this.listScroll = index - rows + 1;
493
+ this.listScroll = Math.max(0, Math.min(this.listScroll, Math.max(0, this.visibleRows().length - rows)));
494
+ }
495
+
496
+ private layout(width = this.pointerLayout?.width ?? this.lastRenderedWidth ?? 80): AgentsViewLayout {
497
+ const rows = typeof this.deps.rows === "function" ? this.deps.rows() : this.deps.rows;
498
+ return measureAgentsViewLayout(width, rows, this.narrowView === "details");
499
+ }
500
+
501
+ private listKey(): string {
502
+ return this.isNarrow() ? this.narrowGroup ?? "root" : "panes";
503
+ }
504
+
505
+ private isNarrow(): boolean {
506
+ return measureAgentsViewLayout(this.lastRenderedWidth ?? 80, 3).mode !== "panes";
507
+ }
508
+
509
+ private back(): void {
510
+ if (this.narrowView === "details") this.setNarrowView("list");
511
+ else if (this.isNarrow() && this.narrowGroup) {
512
+ this.groupCursor = `heading:${this.narrowGroup}`;
513
+ this.narrowGroup = undefined;
514
+ this.listScroll = 0;
515
+ this.clearFooterLayout();
516
+ this.deps.requestRender();
517
+ } else this.close();
518
+ }
519
+
520
+ private activate(): void {
521
+ const selected = this.selectedRow();
522
+ if (selected?.kind === "heading" && this.isNarrow()) {
523
+ this.narrowGroup = selected.group.id;
524
+ this.listScroll = 0;
525
+ const children = this.visibleRows();
526
+ if (!children.some((row) => row.id === this.selectedId)) this.select(0, children);
527
+ this.clearFooterLayout();
528
+ this.deps.requestRender();
529
+ } else if (this.selectedTask()) this.setNarrowView("details");
363
530
  }
364
531
 
365
532
  private bodyRows(): number {
366
- return Math.max(MIN_BODY_ROWS, this.deps.rows - CHROME_ROWS);
533
+ return this.layout().bodyRows;
367
534
  }
368
535
 
369
536
  private refreshTasks(): void {
370
- const selectedId = this.tasks[this.selected]?.id;
371
- const now = this.deps.now();
372
- this.tasks = this.deps.store.list().filter((task) => this.inScope(task, now));
373
- const index = this.tasks.findIndex((task) => task.id === selectedId);
374
- this.selected = index === -1 ? Math.max(0, Math.min(this.selected, this.tasks.length - 1)) : index;
375
- this.listScroll = Math.max(0, Math.min(this.listScroll, Math.max(0, this.tasks.length - this.bodyRows())));
376
- if (this.hovered !== undefined && !this.tasks[this.hovered]) this.hovered = undefined;
377
- if (index === -1) this.subscribeSelected();
537
+ this.tasks = this.deps.store.list().filter((task) => task.parentSessionId === this.deps.sessionId
538
+ && !isFinished(task.status) && (this.deps.isLocalTask?.(task) ?? true));
539
+ if (this.scope === VIEW_SCOPE.ALL) this.tasks.push(...this.peers.flatMap((group) => group.tasks));
540
+ const groups = this.sessionGroups();
541
+ const present = new Set(groups.map((group) => group.id));
542
+ for (const id of this.observedActiveGroups) if (!present.has(id)) this.observedActiveGroups.delete(id);
543
+ for (const group of groups) {
544
+ if (group.tasks.some((task) => !isFinished(task.status))) this.observedActiveGroups.add(group.id);
545
+ }
546
+ const rows = this.allRows();
547
+ if (this.narrowGroup && !this.sessionGroups().some((group) => group.id === this.narrowGroup)) this.narrowGroup = undefined;
548
+ if (!this.selectedId || (!this.selectedTask() && !rows.some((row) => row.id === this.selectedId))) {
549
+ this.selectedId = rows.find((row) => row.kind === "task")?.id ?? rows[0]?.id;
550
+ }
551
+ if (!this.selectedTask()) this.narrowView = "list";
552
+ this.listScroll = Math.max(0, Math.min(this.listScroll, Math.max(0, rows.length - this.bodyRows())));
553
+ if (this.hoveredId && !rows.some((row) => row.id === this.hoveredId)) this.hoveredId = undefined;
554
+ this.subscribeSelected();
555
+ }
556
+
557
+ private sessionGroups(): SessionGroup[] {
558
+ const groups = new Map<string, SessionGroup>();
559
+ for (const task of this.tasks) {
560
+ const sessionId = task.parentSessionId.trim() || undefined;
561
+ const id = sessionId ? `session:${sessionId}` : `unknown:${task.id}`;
562
+ const group = groups.get(id) ?? { id, sessionId, tasks: [] };
563
+ if (!groups.has(id)) groups.set(id, group);
564
+ group.tasks.push(task);
565
+ }
566
+ for (const group of groups.values()) {
567
+ group.tasks.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
568
+ }
569
+ if (this.scope === VIEW_SCOPE.ALL) {
570
+ const localId = `session:${this.deps.sessionId}`;
571
+ const local = groups.get(localId) ?? { id: localId, sessionId: this.deps.sessionId, tasks: [] };
572
+ return [local, ...this.peers];
573
+ }
574
+ return [...groups.values()];
575
+ }
576
+
577
+ private visibleRows(): VisibleRow[] {
578
+ if (!this.isNarrow() || this.scope === VIEW_SCOPE.SESSION) return this.allRows();
579
+ const groups = this.sessionGroups();
580
+ if (!this.narrowGroup) return groups.map((group) => ({ id: `heading:${group.id}`, kind: "heading", group }));
581
+ const group = groups.find((entry) => entry.id === this.narrowGroup);
582
+ return group?.tasks.map((task) => ({ id: `task:${task.id}`, kind: "task", group, task })) ?? [];
583
+ }
584
+
585
+ private allRows(): VisibleRow[] {
586
+ const rows: VisibleRow[] = [];
587
+ for (const group of this.sessionGroups()) {
588
+ if (this.scope === VIEW_SCOPE.ALL) rows.push({ id: `heading:${group.id}`, kind: "heading", group });
589
+ if (this.scope === VIEW_SCOPE.SESSION || this.isExpanded(group)) {
590
+ for (const task of group.tasks) rows.push({ id: `task:${task.id}`, kind: "task", group, task });
591
+ }
592
+ }
593
+ return rows;
594
+ }
595
+
596
+ private selectedRow(rows = this.visibleRows()): VisibleRow | undefined {
597
+ if (this.directoryRoot() && this.narrowView === "list") return rows.find((row) => row.id === this.groupCursor) ?? rows[0];
598
+ return rows.find((row) => row.id === this.selectedId);
599
+ }
600
+
601
+ private isExpanded(group: SessionGroup): boolean {
602
+ return this.expanded.get(group.id) ?? this.observedActiveGroups.has(group.id);
603
+ }
604
+
605
+ private setExpanded(group: SessionGroup, expanded: boolean): void {
606
+ if (this.isExpanded(group) === expanded) return;
607
+ this.expanded.set(group.id, expanded);
608
+ if (!expanded) {
609
+ this.selectedId = `heading:${group.id}`;
610
+ this.narrowView = "list";
611
+ }
612
+ this.listScroll = Math.min(this.listScroll, Math.max(0, this.visibleRows().length - this.bodyRows()));
613
+ this.clearFooterLayout();
614
+ this.subscribeSelected();
615
+ this.deps.requestRender();
378
616
  }
379
617
 
380
618
  private subscribeSelected(): void {
381
- this.unsubscribeTask?.();
382
619
  const task = this.selectedTask();
383
- this.unsubscribeTask = task ? this.deps.store.subscribe(task.id, () => {
620
+ if (task?.id === this.subscribedTaskId) return;
621
+ this.unsubscribeTask?.();
622
+ this.unsubscribeTask = undefined;
623
+ this.subscribedTaskId = task?.id;
624
+ if (!task || this.remoteThreads.has(task.id)) return;
625
+ this.unsubscribeTask = this.deps.store.subscribe(task.id, () => {
384
626
  this.clearFooterLayout();
385
627
  this.refreshTasks();
386
628
  this.deps.requestRender();
387
- }) : undefined;
629
+ });
388
630
  }
389
631
 
390
632
  private taskLine(row: number): string {
391
- if (this.tasks.length === 0) return row === 0 ? this.deps.theme.fg(ROLE.EMPTY, EMPTY_LIST) : "";
392
- const index = this.listScroll + row;
393
- const task = this.tasks[index];
394
- if (!task) return "";
633
+ const visible = this.visibleRows();
634
+ if (visible.length === 0) return row === 0 ? this.deps.theme.fg(ROLE.EMPTY, EMPTY_LIST) : "";
635
+ const entry = visible[this.listScroll + row];
636
+ if (!entry) return "";
395
637
  const theme = this.deps.theme;
396
638
  this.taskRegion(row).render(this.pointerLayout?.listWidth ?? 1);
397
- const marker = index === this.selected ? theme.fg(ROLE.SELECTED, "▸") : index === this.hovered ? theme.fg(ROLE.SELECTED, "▹") : " ";
398
- const glyph = theme.fg(GLYPH_ROLE[task.status], GLYPH[task.status]);
399
- const name = theme.fg(index === this.selected || index === this.hovered ? ROLE.NAME : ROLE.NAME_IDLE, task.agent);
639
+ const selected = entry.id === this.selectedRow()?.id;
640
+ const hovered = entry.id === this.hoveredId;
641
+ const emphasis = selected ? ROLE.SELECTED : hovered ? ROLE.HOVER : undefined;
642
+ const marker = emphasis ? theme.fg(emphasis, selected ? "▸" : "▹") : " ";
643
+ if (entry.kind === "heading") {
644
+ const state = this.isExpanded(entry.group) ? "▾" : "▸";
645
+ return `${marker}${theme.fg(emphasis ?? ROLE.SELECTED, state)} ${theme.fg(emphasis ?? ROLE.NAME_IDLE, this.groupHeading(entry.group))}`;
646
+ }
647
+ const task = entry.task;
648
+ const glyph = theme.fg(GLYPH_ROLE[task.status] ?? ROLE.META, GLYPH[task.status] ?? "?");
649
+ const name = theme.fg(emphasis ?? ROLE.NAME_IDLE, `Subagent ${task.agent}`);
400
650
  const time = task.startedAt === null ? "" : theme.fg(ROLE.META, formatElapsed((task.endedAt ?? this.deps.now()) - task.startedAt));
401
- return `${marker} ${glyph} ${name} ${time}`;
651
+ return `${marker} ${theme.fg(ROLE.META, "└")} ${glyph} ${name} ${time}`;
652
+ }
653
+
654
+ private groupHeading(group: SessionGroup): string {
655
+ const count = `${group.tasks.length} ${group.tasks.length === 1 ? "Subagent" : "Subagents"}`;
656
+ if (!this.isExpanded(group)) return `${this.groupTitle(group)} · ${count}`;
657
+ const active = group.tasks.filter((task) => !isFinished(task.status)).length;
658
+ return `${this.groupTitle(group)} · ${count} · ${active} active`;
659
+ }
660
+
661
+ private groupTitle(group: SessionGroup): string {
662
+ if (group.label) return group.label;
663
+ if (!group.sessionId) return "Current orchestrator";
664
+ if (group.sessionId === this.deps.sessionId) return "Current orchestrator";
665
+ return `Orchestrator ${group.sessionId.slice(0, 8)}`;
402
666
  }
403
667
 
404
668
  private threadLines(thread: TaskThread, width: number): string[] {
@@ -411,7 +675,7 @@ export class AgentsView {
411
675
  for (const item of thread.items) {
412
676
  let rendered = this.cache.get(item);
413
677
  if (!rendered) {
414
- rendered = itemLines(item, this.deps.theme, width);
678
+ rendered = renderThreadItem(item, this.deps.theme, width);
415
679
  this.cache.set(item, rendered);
416
680
  }
417
681
  lines.push(...rendered);
@@ -421,23 +685,37 @@ export class AgentsView {
421
685
 
422
686
  private threadWindow(rows: number, width: number): string[] {
423
687
  const task = this.selectedTask();
424
- if (!task) return [];
688
+ if (!task) return [this.deps.theme.fg(ROLE.EMPTY, "Select a task to inspect its thread")];
425
689
  const theme = this.deps.theme;
426
690
  const header = theme.fg(ROLE.META, truncateToWidth(taskHeader(task, this.deps.now()), width, "…"));
427
- const lines = this.threadLines(this.deps.store.thread(task.id), width);
691
+ const lines = this.threadLines(this.remoteThreads.get(task.id) ?? this.deps.store.thread(task.id), width);
428
692
  if (lines.length === 0) return [header, theme.fg(ROLE.EMPTY, task.error ?? EMPTY_THREAD)];
429
- const visible = rows - 1;
693
+ const visible = Math.max(0, rows - 1);
430
694
  const maxScroll = Math.max(0, lines.length - visible);
431
- this.scroll = this.follow ? maxScroll : Math.min(this.scroll, maxScroll);
432
- return [header, ...lines.slice(this.scroll, this.scroll + visible)];
433
- }
434
-
435
- private select(index: number): void {
436
- const next = Math.max(0, Math.min(this.tasks.length - 1, index));
437
- if (next === this.selected) return;
438
- this.selected = next;
695
+ if (this.follow) this.scroll = maxScroll;
696
+ // A taller/wider presentation may clamp its window, not the saved position.
697
+ const start = Math.min(this.scroll, maxScroll);
698
+ return [header, ...lines.slice(start, start + visible)];
699
+ }
700
+
701
+ private select(index: number, rows = this.visibleRows(), revealSelection = true): void {
702
+ const next = rows[Math.max(0, Math.min(rows.length - 1, index))];
703
+ if (!next) return;
704
+ // Keyboard movement reveals the selection; pointer activation keeps the
705
+ // manually positioned list available for Back, even after height clamping.
706
+ if (revealSelection) this.manualListOffsets.delete(this.listKey());
707
+ if (this.directoryRoot()) {
708
+ this.groupCursor = next.id;
709
+ this.clearFooterLayout();
710
+ this.deps.requestRender();
711
+ return;
712
+ }
713
+ if (next.id === this.selectedId) return;
714
+ this.selectedId = next.id;
715
+ if (next.kind === "heading") this.narrowView = "list";
439
716
  this.scroll = 0;
440
717
  this.follow = true;
718
+ this.clearFooterLayout();
441
719
  this.subscribeSelected();
442
720
  this.deps.requestRender();
443
721
  }
@@ -460,34 +738,91 @@ export class AgentsView {
460
738
  }
461
739
 
462
740
  private footer(keys: string, width: number): string {
463
- const showButtons = visibleWidth(keys) + FOOTER_BUTTONS_WIDTH + 1 <= width;
464
- const task = this.selectedTask();
465
- this.followRegion.setDisabled(!showButtons || !task);
466
- this.openRegion.setDisabled(!showButtons || !this.canOpen(task));
467
- if (!showButtons) return keys;
468
- this.followRegion.render(FOLLOW_BUTTON.length);
469
- this.openRegion.render(OPEN_BUTTON.length);
470
- const buttonX = 2 + width - FOOTER_BUTTONS_WIDTH;
471
- if (this.pointerLayout) {
472
- this.pointerLayout.followButton = { x: buttonX, width: FOLLOW_BUTTON.length };
473
- this.pointerLayout.openButton = { x: buttonX + FOLLOW_BUTTON.length + 1, width: OPEN_BUTTON.length };
741
+ const task = this.actionableTask();
742
+ const controls = [
743
+ { label: FOLLOW_BUTTON, short: "Follow", region: this.followRegion, enabled: Boolean(task) },
744
+ { label: OPEN_BUTTON, short: "Open", region: this.openRegion, enabled: this.canOpen(task) },
745
+ ];
746
+ if (task && this.canCancel(task)) controls.push({ label: STOP_BUTTON, short: "Stop", region: this.actionRegion("stop", () => this.handleInput("s")), enabled: true });
747
+ if (this.deps.sessionId !== undefined) controls.push({ label: SCOPE_BUTTON, short: "Scope", region: this.actionRegion("scope", () => this.toggleScope()), enabled: true });
748
+ for (const control of controls) control.region.setDisabled(!control.enabled);
749
+ const required = controls.reduce((sum, control) => sum + control.label.length + 1, -1);
750
+ const shown = required <= width ? controls : [controls[this.footerPage % controls.length]];
751
+ const actions: NonNullable<PointerLayout["actions"]> = [];
752
+ let text = "";
753
+ for (const control of shown) {
754
+ const label = required <= width ? control.label : control.short;
755
+ if (text) text += " ";
756
+ actions.push({ x: 2 + visibleWidth(text), width: label.length, region: control.region });
757
+ control.region.render(label.length);
758
+ const hovered = (control.region === this.followRegion && this.hoveredControl === "follow") || (control.region === this.openRegion && this.hoveredControl === "open");
759
+ text += this.deps.theme.fg(!control.enabled ? ROLE.META : hovered ? ROLE.HOVER : ROLE.KEY, label);
760
+ }
761
+ if (required > width) {
762
+ const more = this.actionRegion("more", () => {
763
+ this.footerPage++;
764
+ this.clearFooterLayout();
765
+ this.deps.requestRender();
766
+ });
767
+ more.render(1);
768
+ actions.push({ x: 2 + width - 1, width: 1, region: more });
769
+ text = fit(text, width - 1) + ">";
770
+ } else if (visibleWidth(text) + 1 < width) text += " " + truncateToWidth(keys, width - visibleWidth(text) - 1, "…");
771
+ if (this.pointerLayout) this.pointerLayout.actions = actions;
772
+ return text;
773
+ }
774
+
775
+ private actionRegion(id: string, action: () => void): NativePointerRegion {
776
+ let region = this.actionRegions.get(id);
777
+ if (!region) {
778
+ region = this.pointerScope.wrap(EMPTY_COMPONENT, {
779
+ onClick: (event) => {
780
+ if (event.button !== "left") return undefined;
781
+ action();
782
+ return { handled: true, render: true };
783
+ },
784
+ });
785
+ this.actionRegions.set(id, region);
474
786
  }
475
- const followRole = task && this.hoveredControl === "follow" ? "warning" : task ? ROLE.KEY : ROLE.META;
476
- const openRole = this.canOpen(task) && this.hoveredControl === "open" ? "warning" : this.canOpen(task) ? ROLE.KEY : ROLE.META;
477
- return `${fit(keys, width - FOOTER_BUTTONS_WIDTH - 1)} ${this.deps.theme.fg(followRole, FOLLOW_BUTTON)} ${this.deps.theme.fg(openRole, OPEN_BUTTON)}`;
787
+ return region;
788
+ }
789
+
790
+ private modeLabel(layout: AgentsViewLayout): string | undefined {
791
+ if (this.narrowView === "details" || (this.isNarrow() && this.narrowGroup)) return layout.width < 24 ? "←" : "[← Back]";
792
+ return !this.isNarrow() && this.selectedTask() ? "[F Fullscreen]" : undefined;
793
+ }
794
+
795
+ private closeLabel(layout: AgentsViewLayout): string | undefined {
796
+ if (layout.mode === "fallback") return undefined;
797
+ return layout.width < 60 ? "[×]" : CLOSE_BUTTON;
798
+ }
799
+
800
+ private setNarrowView(view: "list" | "details"): void {
801
+ if (view === this.narrowView || (view === "details" && !this.selectedTask())) return;
802
+ this.narrowView = view;
803
+ this.clearFooterLayout();
804
+ this.deps.requestRender();
805
+ }
806
+
807
+ private close(): void {
808
+ if (this.closed) return;
809
+ this.closed = true;
810
+ this.stopPresenceRefresh();
811
+ this.pointerScope.invalidate();
812
+ this.deps.onClose();
478
813
  }
479
814
 
480
815
  private isInButton(x: number, button: PointerButtonLayout | undefined): boolean {
481
816
  return button !== undefined && x >= button.x && x < button.x + button.width;
482
817
  }
483
818
 
484
- private hoverControl(control: "follow" | "open"): TuiMouseEventResult {
819
+ private hoverControl(control: "follow" | "open" | "close" | "mode"): TuiMouseEventResult {
485
820
  if (this.hoveredControl === control) return { handled: true };
486
821
  this.hoveredControl = control;
487
822
  return { handled: true, render: true };
488
823
  }
489
824
 
490
- private clearHoveredControl(control: "follow" | "open"): void {
825
+ private clearHoveredControl(control: "follow" | "open" | "close" | "mode"): void {
491
826
  if (this.hoveredControl !== control) return;
492
827
  this.hoveredControl = undefined;
493
828
  this.deps.requestRender();
@@ -507,6 +842,19 @@ export class AgentsView {
507
842
  return { handled: true, render: true };
508
843
  }
509
844
 
845
+ private clickClose(event: TuiMouseEvent): TuiMouseEventResult | undefined {
846
+ if (event.button !== "left") return undefined;
847
+ this.close();
848
+ return { handled: true, render: true };
849
+ }
850
+
851
+ private clickMode(event: TuiMouseEvent): TuiMouseEventResult | undefined {
852
+ if (event.button !== "left") return undefined;
853
+ if (this.narrowView === "details" || (this.isNarrow() && this.narrowGroup)) this.back();
854
+ else this.activate();
855
+ return { handled: true, render: true };
856
+ }
857
+
510
858
  private taskRegion(row: number): NativePointerRegion {
511
859
  let region = this.taskRegions.get(row);
512
860
  if (!region) {
@@ -521,30 +869,39 @@ export class AgentsView {
521
869
  }
522
870
 
523
871
  private hoverTask(row: number): TuiMouseEventResult | undefined {
524
- const index = this.listScroll + row;
525
- if (!this.tasks[index] || this.hovered === index) return { handled: true };
526
- this.hovered = index;
872
+ const entry = this.visibleRows()[this.listScroll + row];
873
+ if (!entry || this.hoveredId === entry.id) return { handled: true };
874
+ this.hoveredId = entry.id;
527
875
  return { handled: true, render: true };
528
876
  }
529
877
 
530
878
  private clearHoveredTask(row: number): void {
531
- if (this.hovered !== this.listScroll + row) return;
532
- this.hovered = undefined;
879
+ const entry = this.visibleRows()[this.listScroll + row];
880
+ if (!entry || this.hoveredId !== entry.id) return;
881
+ this.hoveredId = undefined;
533
882
  this.deps.requestRender();
534
883
  }
535
884
 
536
885
  private clickTask(row: number, event: TuiMouseEvent): TuiMouseEventResult | undefined {
537
886
  if (event.button !== "left") return undefined;
538
- this.select(this.listScroll + row);
887
+ const entry = this.visibleRows()[this.listScroll + row];
888
+ if (!entry) return undefined;
889
+ if (this.isNarrow()) {
890
+ this.select(this.listScroll + row, this.visibleRows(), false);
891
+ this.activate();
892
+ } else if (entry.kind === "heading") this.setExpanded(entry.group, !this.isExpanded(entry.group));
893
+ else this.select(this.listScroll + row);
539
894
  return { handled: true, render: true };
540
895
  }
541
896
 
542
897
  private wheelList(event: TuiMouseEvent): TuiMouseEventResult {
543
898
  const delta = event.wheelDelta ?? 0;
544
- const next = Math.max(0, Math.min(Math.max(0, this.tasks.length - this.bodyRows()), this.listScroll + delta));
899
+ const next = Math.max(0, Math.min(Math.max(0, this.visibleRows().length - this.bodyRows()), this.listScroll + delta));
545
900
  if (next === this.listScroll) return { handled: true, render: false };
546
901
  this.listScroll = next;
547
- this.hovered = undefined;
902
+ this.manualListOffsets.set(this.listKey(), next);
903
+ this.clearFooterLayout();
904
+ this.hoveredId = undefined;
548
905
  return { handled: true, render: true };
549
906
  }
550
907