indus-swarms 0.1.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 (52) hide show
  1. package/AGENTS.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/docs/claude-parity.md +151 -0
  5. package/docs/field-notes-teams-setup.md +107 -0
  6. package/docs/smoke-test-plan.md +146 -0
  7. package/eslint.config.js +74 -0
  8. package/extensions/teams/README.md +23 -0
  9. package/extensions/teams/activity-tracker.ts +234 -0
  10. package/extensions/teams/cleanup.ts +31 -0
  11. package/extensions/teams/fs-lock.ts +87 -0
  12. package/extensions/teams/hooks.ts +363 -0
  13. package/extensions/teams/index.ts +18 -0
  14. package/extensions/teams/leader-attach-commands.ts +221 -0
  15. package/extensions/teams/leader-inbox.ts +214 -0
  16. package/extensions/teams/leader-info-commands.ts +140 -0
  17. package/extensions/teams/leader-lifecycle-commands.ts +559 -0
  18. package/extensions/teams/leader-messaging-commands.ts +148 -0
  19. package/extensions/teams/leader-plan-commands.ts +95 -0
  20. package/extensions/teams/leader-spawn-command.ts +149 -0
  21. package/extensions/teams/leader-task-commands.ts +435 -0
  22. package/extensions/teams/leader-team-command.ts +382 -0
  23. package/extensions/teams/leader-teams-tool.ts +1075 -0
  24. package/extensions/teams/leader.ts +925 -0
  25. package/extensions/teams/mailbox.ts +131 -0
  26. package/extensions/teams/model-policy.ts +142 -0
  27. package/extensions/teams/names.ts +121 -0
  28. package/extensions/teams/paths.ts +37 -0
  29. package/extensions/teams/protocol.ts +241 -0
  30. package/extensions/teams/spawn-types.ts +36 -0
  31. package/extensions/teams/task-store.ts +544 -0
  32. package/extensions/teams/team-attach-claim.ts +205 -0
  33. package/extensions/teams/team-config.ts +335 -0
  34. package/extensions/teams/team-discovery.ts +59 -0
  35. package/extensions/teams/teammate-rpc.ts +261 -0
  36. package/extensions/teams/teams-panel.ts +1186 -0
  37. package/extensions/teams/teams-style.ts +322 -0
  38. package/extensions/teams/teams-ui-shared.ts +89 -0
  39. package/extensions/teams/teams-widget.ts +212 -0
  40. package/extensions/teams/worker.ts +605 -0
  41. package/extensions/teams/worktree.ts +103 -0
  42. package/package.json +53 -0
  43. package/scripts/e2e-rpc-test.mjs +277 -0
  44. package/scripts/integration-claim-test.mts +157 -0
  45. package/scripts/integration-hooks-remediation-test.mts +382 -0
  46. package/scripts/integration-spawn-overrides-test.mts +398 -0
  47. package/scripts/integration-todo-test.mts +533 -0
  48. package/scripts/lib/pi-workers.ts +105 -0
  49. package/scripts/smoke-test.mts +764 -0
  50. package/scripts/start-tmux-team.sh +91 -0
  51. package/skills/agent-teams/SKILL.md +180 -0
  52. package/tsconfig.strict.json +22 -0
@@ -0,0 +1,1186 @@
1
+ import { matchesKey, truncateToWidth, visibleWidth } from "indusagi/tui";
2
+ import type { Theme, ThemeColor, ExtensionCommandContext } from "indusagi-coding-agent";
3
+ import type { TeammateRpc, TeammateStatus } from "./teammate-rpc.js";
4
+ import type { ActivityTracker, TranscriptLog, TranscriptEntry } from "./activity-tracker.js";
5
+ import type { TeamTask } from "./task-store.js";
6
+ import type { TeamConfig, TeamMember } from "./team-config.js";
7
+ import type { TeamsStyle } from "./teams-style.js";
8
+ import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
9
+ import {
10
+ STATUS_COLOR,
11
+ STATUS_ICON,
12
+ formatTokens,
13
+ getVisibleWorkerNames,
14
+ padRight,
15
+ resolveStatus,
16
+ toolActivity,
17
+ toolVerb,
18
+ } from "./teams-ui-shared.js";
19
+
20
+ export interface InteractiveWidgetDeps {
21
+ getTeammates(): Map<string, TeammateRpc>;
22
+ getTracker(): ActivityTracker;
23
+ getTranscript(name: string): TranscriptLog;
24
+ getTasks(): TeamTask[];
25
+ getTeamConfig(): TeamConfig | null;
26
+ getStyle(): TeamsStyle;
27
+ isDelegateMode(): boolean;
28
+ sendMessage(name: string, message: string): Promise<void>;
29
+ abortMember(name: string): void;
30
+ killMember(name: string): void;
31
+ setTaskStatus(taskId: string, status: TeamTask["status"]): Promise<boolean>;
32
+ unassignTask(taskId: string): Promise<boolean>;
33
+ assignTask(taskId: string, ownerName: string): Promise<boolean>;
34
+ getActiveTeamId(): string | null;
35
+ getSessionTeamId(): string | null;
36
+ suppressWidget(): void;
37
+ restoreWidget(): void;
38
+ }
39
+
40
+ function formatTimestamp(ts: number): string {
41
+ const d = new Date(ts);
42
+ return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
43
+ }
44
+
45
+ function shortTeamId(teamId: string): string {
46
+ return teamId.length <= 12 ? teamId : `${teamId.slice(0, 8)}…`;
47
+ }
48
+
49
+ // ── Row data (mirrors teams-widget.ts) ──
50
+
51
+ interface Row {
52
+ icon: string;
53
+ iconColor: ThemeColor;
54
+ name: string;
55
+ displayName: string;
56
+ statusKey: TeammateStatus;
57
+ pending: number;
58
+ completed: number;
59
+ tokensStr: string;
60
+ activityText: string;
61
+ isChairman: boolean;
62
+ }
63
+
64
+ type WidgetMode = "overview" | "session" | "dm" | "tasks" | "reassign";
65
+
66
+ // ── Transcript formatting ──
67
+
68
+ function summarizeTranscriptEntry(entry: TranscriptEntry | undefined): string | null {
69
+ if (!entry) return null;
70
+ if (entry.kind === "text") {
71
+ const compact = entry.text.replace(/\s+/g, " ").trim();
72
+ if (!compact) return null;
73
+ return compact.length > 96 ? `${compact.slice(0, 95)}…` : compact;
74
+ }
75
+ if (entry.kind === "tool_start") return `running ${entry.toolName}`;
76
+ if (entry.kind === "tool_end") return `finished ${entry.toolName} (${(entry.durationMs / 1000).toFixed(1)}s)`;
77
+ const tok = formatTokens(entry.tokens);
78
+ return `turn ${String(entry.turnNumber)} complete (${tok} tokens)`;
79
+ }
80
+
81
+ function taskStatusRank(status: TeamTask["status"]): number {
82
+ if (status === "in_progress") return 0;
83
+ if (status === "pending") return 1;
84
+ return 2;
85
+ }
86
+
87
+ function parseTaskId(taskId: string): number {
88
+ const parsed = Number.parseInt(taskId, 10);
89
+ return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
90
+ }
91
+
92
+ function unresolvedDependencies(task: TeamTask, taskById: ReadonlyMap<string, TeamTask>): string[] {
93
+ const unresolved: string[] = [];
94
+ for (const depId of task.blockedBy) {
95
+ const dep = taskById.get(depId);
96
+ if (!dep || dep.status !== "completed") unresolved.push(depId);
97
+ }
98
+ return unresolved;
99
+ }
100
+
101
+ function getQualityGateStatus(task: TeamTask): "failed" | "passed" | null {
102
+ const raw = task.metadata?.["qualityGateStatus"];
103
+ if (raw === "failed" || raw === "passed") return raw;
104
+ return null;
105
+ }
106
+
107
+ function getQualityGateSummary(task: TeamTask): string | null {
108
+ const raw = task.metadata?.["qualityGateSummary"];
109
+ if (typeof raw !== "string") return null;
110
+ const trimmed = raw.trim();
111
+ return trimmed.length > 0 ? trimmed : null;
112
+ }
113
+
114
+ function formatTranscriptEntry(entry: TranscriptEntry, theme: Theme, width: number): string[] {
115
+ const ts = formatTimestamp(entry.timestamp);
116
+ const tsStr = theme.fg("dim", ts);
117
+ const maxTextWidth = width - 12; // " HH:MM:SS " prefix
118
+
119
+ if (entry.kind === "text") {
120
+ // Wrap long text lines
121
+ const lines: string[] = [];
122
+ const text = entry.text;
123
+ if (visibleWidth(text) <= maxTextWidth) {
124
+ lines.push(` ${tsStr} ${theme.fg("dim", theme.italic(text))}`);
125
+ } else {
126
+ // Simple word wrap
127
+ let remaining = text;
128
+ let first = true;
129
+ while (remaining.length > 0) {
130
+ const chunk = remaining.slice(0, maxTextWidth);
131
+ remaining = remaining.slice(maxTextWidth);
132
+ if (first) {
133
+ lines.push(` ${tsStr} ${theme.fg("dim", theme.italic(chunk))}`);
134
+ first = false;
135
+ } else {
136
+ lines.push(` ${" ".repeat(10)}${theme.fg("dim", theme.italic(chunk))}`);
137
+ }
138
+ }
139
+ }
140
+ return lines;
141
+ }
142
+
143
+ if (entry.kind === "tool_start") {
144
+ const verb = toolVerb(entry.toolName);
145
+ return [` ${tsStr} ${theme.fg("warning", verb)}`];
146
+ }
147
+
148
+ if (entry.kind === "tool_end") {
149
+ const dur = entry.durationMs < 1000
150
+ ? `${(entry.durationMs / 1000).toFixed(1)}s`
151
+ : `${(entry.durationMs / 1000).toFixed(1)}s`;
152
+ return [` ${tsStr} ${theme.fg("muted", entry.toolName)} ${theme.fg("dim", "\u2500")} ${theme.fg("dim", dur)}`];
153
+ }
154
+
155
+ if (entry.kind === "turn_end") {
156
+ const tokStr = formatTokens(entry.tokens);
157
+ const label = `\u2500\u2500 turn ${String(entry.turnNumber)} complete \u2500\u2500 ${tokStr} tokens \u2500\u2500`;
158
+ return [` ${theme.fg("dim", label)}`];
159
+ }
160
+
161
+ return [];
162
+ }
163
+
164
+ // ── Main export ──
165
+
166
+ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps: InteractiveWidgetDeps): Promise<void> {
167
+ const style = deps.getStyle();
168
+ const strings = getTeamsStrings(style);
169
+ const names = getVisibleWorkerNames({
170
+ teammates: deps.getTeammates(),
171
+ teamConfig: deps.getTeamConfig(),
172
+ tasks: deps.getTasks(),
173
+ });
174
+ if (names.length === 0) {
175
+ ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s to show`, "info");
176
+ return;
177
+ }
178
+
179
+ // Hide persistent widget while interactive one is open.
180
+ deps.suppressWidget();
181
+
182
+ try {
183
+ await ctx.ui.custom<void>(
184
+ (tui, theme, _kb, done) => {
185
+ let mode: WidgetMode = "overview";
186
+ let cursorIndex = 0;
187
+ let sessionName: string | null = null;
188
+ let dmTarget: string | null = null;
189
+ let dmBuffer = "";
190
+ let dmReturnMode: Exclude<WidgetMode, "dm"> = "overview";
191
+ let notification: { text: string; color: ThemeColor } | null = null;
192
+ let notificationTimer: ReturnType<typeof setTimeout> | null = null;
193
+ let sessionScrollOffset = 0;
194
+ let sessionAutoFollow = true;
195
+ let taskViewOwner: string | null = null;
196
+ let taskCursorIndex = 0;
197
+ let taskReturnMode: "overview" | "session" = "overview";
198
+ let reassignTaskId: string | null = null;
199
+ let reassignCursorIndex = 0;
200
+
201
+ const refreshInterval = setInterval(() => tui.requestRender(), 1000);
202
+
203
+ function renderAttachBanner(width: number): string | null {
204
+ const activeTeamId = deps.getActiveTeamId();
205
+ const sessionTeamId = deps.getSessionTeamId();
206
+ if (!activeTeamId || !sessionTeamId || activeTeamId === sessionTeamId) return null;
207
+ return truncateToWidth(
208
+ ` ${theme.fg("warning", `attached: ${shortTeamId(activeTeamId)} (session ${shortTeamId(sessionTeamId)}) · /team detach`)}`,
209
+ width,
210
+ );
211
+ }
212
+
213
+ function isTaskToggleKey(data: string): boolean {
214
+ return data === "t" || data === "T" || matchesKey(data, "shift+t");
215
+ }
216
+
217
+ function showNotification(text: string, color: ThemeColor = "success") {
218
+ notification = { text, color };
219
+ if (notificationTimer) clearTimeout(notificationTimer);
220
+ notificationTimer = setTimeout(() => {
221
+ notification = null;
222
+ tui.requestRender();
223
+ }, 3000);
224
+ tui.requestRender();
225
+ }
226
+
227
+ function openTaskView(ownerName: string, from: "overview" | "session") {
228
+ taskViewOwner = ownerName;
229
+ taskCursorIndex = 0;
230
+ taskReturnMode = from;
231
+ mode = "tasks";
232
+ tui.requestRender();
233
+ }
234
+
235
+ function getOwnedTasks(ownerName: string): TeamTask[] {
236
+ return deps
237
+ .getTasks()
238
+ .filter((task) => task.owner === ownerName)
239
+ .sort((a, b) => {
240
+ const rank = taskStatusRank(a.status) - taskStatusRank(b.status);
241
+ if (rank !== 0) return rank;
242
+ return parseTaskId(a.id) - parseTaskId(b.id);
243
+ });
244
+ }
245
+
246
+ function getSelectedOwnedTask(ownerName: string): TeamTask | null {
247
+ const owned = getOwnedTasks(ownerName);
248
+ if (owned.length === 0) return null;
249
+ const clamped = Math.max(0, Math.min(taskCursorIndex, owned.length - 1));
250
+ taskCursorIndex = clamped;
251
+ return owned[clamped] ?? null;
252
+ }
253
+
254
+ function getReassignableMembers(): string[] {
255
+ return getVisibleWorkerNames({
256
+ teammates: deps.getTeammates(),
257
+ teamConfig: deps.getTeamConfig(),
258
+ tasks: deps.getTasks(),
259
+ });
260
+ }
261
+
262
+ function openReassign(taskId: string, currentOwner: string) {
263
+ const members = getReassignableMembers();
264
+ if (members.length === 0) {
265
+ showNotification(`No ${strings.memberTitle.toLowerCase()}s available`, "error");
266
+ return;
267
+ }
268
+ reassignTaskId = taskId;
269
+ reassignCursorIndex = Math.max(0, members.indexOf(currentOwner));
270
+ mode = "reassign";
271
+ tui.requestRender();
272
+ }
273
+
274
+ // ── Build row data (same logic as persistent widget) ──
275
+
276
+ function buildRows(): { rows: Row[]; memberNames: string[] } {
277
+ const teammates = deps.getTeammates();
278
+ const tracker = deps.getTracker();
279
+ const tasks = deps.getTasks();
280
+ const teamConfig = deps.getTeamConfig();
281
+ const leadName = teamConfig?.leadName;
282
+ const cfgMembers = teamConfig?.members ?? [];
283
+ const cfgByName = new Map<string, TeamMember>();
284
+ for (const m of cfgMembers) cfgByName.set(m.name, m);
285
+
286
+ const rows: Row[] = [];
287
+
288
+ // Leader control
289
+ if (leadName) {
290
+ const leadTasks = tasks.filter((t) => t.owner === leadName);
291
+ rows.push({
292
+ icon: "\u25c6",
293
+ iconColor: "accent",
294
+ displayName: strings.leaderControlTitle,
295
+ statusKey: "idle",
296
+ pending: leadTasks.filter((t) => t.status === "pending").length,
297
+ completed: leadTasks.filter((t) => t.status === "completed").length,
298
+ tokensStr: "\u2014",
299
+ activityText: "",
300
+ isChairman: true,
301
+ name: leadName,
302
+ });
303
+ }
304
+
305
+ // Workers
306
+ const memberNames = getVisibleWorkerNames({ teammates, teamConfig, tasks });
307
+ for (const name of memberNames) {
308
+ const rpc = teammates.get(name);
309
+ const cfg = cfgByName.get(name);
310
+ const statusKey = resolveStatus(rpc, cfg);
311
+ const activity = tracker.get(name);
312
+ const owned = tasks.filter((t) => t.owner === name);
313
+
314
+ rows.push({
315
+ icon: STATUS_ICON[statusKey],
316
+ iconColor: STATUS_COLOR[statusKey],
317
+ displayName: formatMemberDisplayName(style, name),
318
+ statusKey,
319
+ pending: owned.filter((t) => t.status === "pending").length,
320
+ completed: owned.filter((t) => t.status === "completed").length,
321
+ tokensStr: formatTokens(activity.totalTokens),
322
+ activityText: toolActivity(activity.currentToolName),
323
+ isChairman: false,
324
+ name,
325
+ });
326
+ }
327
+
328
+ return { rows, memberNames };
329
+ }
330
+
331
+ // ── Overview render (identical to persistent widget + cursor) ──
332
+
333
+ function renderOverview(width: number): string[] {
334
+ const tasks = deps.getTasks();
335
+ const tracker = deps.getTracker();
336
+ const delegateMode = deps.isDelegateMode();
337
+ const { rows, memberNames } = buildRows();
338
+
339
+ // Clamp cursor
340
+ if (cursorIndex >= memberNames.length) cursorIndex = Math.max(0, memberNames.length - 1);
341
+
342
+ const lines: string[] = [];
343
+
344
+ // Header
345
+ let header = " " + theme.bold(theme.fg("accent", "Teams"));
346
+ if (delegateMode) header += " " + theme.fg("warning", "[delegate]");
347
+ lines.push(truncateToWidth(header, width));
348
+ const attachBanner = renderAttachBanner(width);
349
+ if (attachBanner) lines.push(attachBanner);
350
+
351
+ if (rows.length === 0) {
352
+ lines.push(
353
+ truncateToWidth(
354
+ " " + theme.fg("dim", `(no ${strings.memberTitle.toLowerCase()}s) /team spawn <name>`),
355
+ width,
356
+ ),
357
+ );
358
+ } else {
359
+ // Column widths
360
+ const totalPending = tasks.filter((t) => t.status === "pending").length;
361
+ const totalCompleted = tasks.filter((t) => t.status === "completed").length;
362
+ let totalTokensRaw = 0;
363
+ for (const name of memberNames) totalTokensRaw += tracker.get(name).totalTokens;
364
+ const totalTokensStr = formatTokens(totalTokensRaw);
365
+
366
+ const nameColWidth = Math.max(...rows.map((r) => visibleWidth(r.displayName)));
367
+ const pW = Math.max(
368
+ ...rows.map((r) => String(r.pending).length),
369
+ String(totalPending).length,
370
+ );
371
+ const cW = Math.max(
372
+ ...rows.map((r) => String(r.completed).length),
373
+ String(totalCompleted).length,
374
+ );
375
+ const tokW = Math.max(
376
+ ...rows.map((r) => r.tokensStr.length),
377
+ totalTokensStr.length,
378
+ );
379
+
380
+ // Render rows
381
+ for (const r of rows) {
382
+ const isSelected = !r.isChairman && memberNames.indexOf(r.name) === cursorIndex;
383
+ const pointer = isSelected ? theme.fg("accent", "\u25b8") : " ";
384
+ const icon = theme.fg(r.iconColor, r.icon);
385
+ const styledName = isSelected
386
+ ? theme.bold(theme.fg("accent", r.displayName))
387
+ : theme.bold(r.displayName);
388
+ const statusLabel = theme.fg(STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
389
+ const pNum = String(r.pending).padStart(pW);
390
+ const cNum = String(r.completed).padStart(cW);
391
+ const tokStr = r.tokensStr.padStart(tokW);
392
+ const cols = theme.fg(
393
+ "dim",
394
+ ` \u00b7 ${pNum} pending \u00b7 ${cNum} complete \u00b7 ${tokStr} tokens`,
395
+ );
396
+ const actLabel = r.activityText
397
+ ? " " + theme.fg("warning", r.activityText)
398
+ : "";
399
+
400
+ const row = `${pointer}${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${cols}${actLabel}`;
401
+ lines.push(truncateToWidth(row, width));
402
+ }
403
+
404
+ // Separator + Total
405
+ const sepLine = " " + theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
406
+ lines.push(truncateToWidth(sepLine, width));
407
+
408
+ const totalLabel = theme.bold("Total");
409
+ const totalTaskCount = totalPending + totalCompleted;
410
+ const pct =
411
+ totalTaskCount > 0 ? Math.round((totalCompleted / totalTaskCount) * 100) : 0;
412
+ const pctLabel = theme.fg("success", padRight(`${pct}%`, 9));
413
+ const tpNum = String(totalPending).padStart(pW);
414
+ const tcNum = String(totalCompleted).padStart(cW);
415
+ const ttokStr = totalTokensStr.padStart(tokW);
416
+ const totalSuffix = theme.fg(
417
+ "muted",
418
+ ` \u00b7 ${tpNum} pending \u00b7 ${tcNum} complete \u00b7 ${ttokStr} tokens`,
419
+ );
420
+ const totalRow = ` ${padRight(totalLabel, nameColWidth + 3)} ${pctLabel}${totalSuffix}`;
421
+ lines.push(truncateToWidth(totalRow, width));
422
+ }
423
+
424
+ const selectedName = memberNames[cursorIndex];
425
+ if (selectedName) {
426
+ const selectedLabel = formatMemberDisplayName(style, selectedName);
427
+ const owned = tasks.filter((t) => t.owner === selectedName);
428
+ const activeTask = owned.find((t) => t.status === "in_progress");
429
+ const latestCompleted = owned
430
+ .filter((t) => t.status === "completed")
431
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0))
432
+ .at(0);
433
+ const entries = deps.getTranscript(selectedName).getEntries();
434
+ const lastSummary = summarizeTranscriptEntry(entries.at(-1));
435
+
436
+ lines.push(truncateToWidth(` ${theme.fg("muted", "selected:")} ${theme.bold(theme.fg("accent", selectedLabel))}`, width));
437
+ if (activeTask) {
438
+ lines.push(
439
+ truncateToWidth(
440
+ ` ${theme.fg("dim", "active:")} ${theme.fg("warning", `#${String(activeTask.id)} ${activeTask.subject}`)}`,
441
+ width,
442
+ ),
443
+ );
444
+ } else if (latestCompleted) {
445
+ lines.push(
446
+ truncateToWidth(
447
+ ` ${theme.fg("dim", "last done:")} ${theme.fg("success", `#${String(latestCompleted.id)} ${latestCompleted.subject}`)}`,
448
+ width,
449
+ ),
450
+ );
451
+ }
452
+ if (lastSummary) {
453
+ lines.push(truncateToWidth(` ${theme.fg("dim", "last event:")} ${theme.fg("muted", lastSummary)}`, width));
454
+ }
455
+ }
456
+
457
+ // Notification
458
+ if (notification) {
459
+ lines.push(truncateToWidth(" " + theme.fg(notification.color, notification.text), width));
460
+ }
461
+
462
+ // Hints
463
+ const hints = theme.fg(
464
+ "dim",
465
+ " \u2191\u2193/ws select \u00b7 1-9 jump \u00b7 enter view \u00b7 t/shift+t tasks \u00b7 m/d message \u00b7 a abort \u00b7 k kill \u00b7 esc close",
466
+ );
467
+ lines.push(truncateToWidth(hints, width));
468
+
469
+ return lines;
470
+ }
471
+
472
+ // ── Session render ──
473
+
474
+ function renderSession(width: number): string[] {
475
+ if (!sessionName) return renderOverview(width);
476
+
477
+ const rpc = deps.getTeammates().get(sessionName);
478
+ const cfg = (deps.getTeamConfig()?.members ?? []).find((m) => m.name === sessionName);
479
+ const statusKey = resolveStatus(rpc, cfg);
480
+ const activity = deps.getTracker().get(sessionName);
481
+ const tasks = deps.getTasks();
482
+ const activeTask = tasks.find(
483
+ (t) => t.owner === sessionName && t.status === "in_progress",
484
+ );
485
+ const transcript = deps.getTranscript(sessionName);
486
+
487
+ const lines: string[] = [];
488
+ const sep = theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
489
+
490
+ // Header
491
+ const icon = theme.fg(STATUS_COLOR[statusKey], STATUS_ICON[statusKey]);
492
+ const nameStr = theme.bold(theme.fg("accent", formatMemberDisplayName(style, sessionName)));
493
+ const status = theme.fg(STATUS_COLOR[statusKey], statusKey);
494
+ const tokens = theme.fg("dim", `${formatTokens(activity.totalTokens)} tokens`);
495
+ const taskLabel = activeTask
496
+ ? ` ${theme.fg("muted", "\u00b7")} ${theme.fg("dim", `#${String(activeTask.id)} ${activeTask.subject}`)}`
497
+ : "";
498
+ lines.push(truncateToWidth(` ${icon} ${nameStr} \u2014 ${status} \u00b7 ${tokens}${taskLabel}`, width));
499
+ const attachBanner = renderAttachBanner(width);
500
+ if (attachBanner) lines.push(attachBanner);
501
+ lines.push(truncateToWidth(` ${sep}`, width));
502
+
503
+ // Format all transcript entries into rendered lines
504
+ const allTranscriptLines: string[] = [];
505
+ for (const entry of transcript.getEntries()) {
506
+ const formatted = formatTranscriptEntry(entry, theme, width);
507
+ for (const fl of formatted) {
508
+ allTranscriptLines.push(truncateToWidth(fl, width));
509
+ }
510
+ }
511
+
512
+ const totalLines = allTranscriptLines.length;
513
+
514
+ if (totalLines === 0) {
515
+ // Show current activity or waiting message when transcript is empty
516
+ if (activity.currentToolName) {
517
+ lines.push(truncateToWidth(
518
+ ` ${theme.fg("warning", toolActivity(activity.currentToolName))}`,
519
+ width,
520
+ ));
521
+ } else if (statusKey === "streaming") {
522
+ lines.push(truncateToWidth(` ${theme.fg("dim", theme.italic("thinking\u2026"))}`, width));
523
+ } else {
524
+ lines.push(truncateToWidth(` ${theme.fg("dim", theme.italic("waiting for activity\u2026"))}`, width));
525
+ }
526
+ } else {
527
+ // Determine visible window size based on terminal height
528
+ const termHeight = process.stdout.rows || 24;
529
+ // Reserve: header(2 + optional attach banner) + scrollBar(1) + notification(0-1) + hintsSep(1) + hints(1)
530
+ const notifLines = notification ? 1 : 0;
531
+ const attachLines = renderAttachBanner(width) ? 1 : 0;
532
+ const chromeLines = 2 + attachLines + 1 + notifLines + 1 + 1;
533
+ const viewportHeight = Math.max(3, termHeight - chromeLines);
534
+
535
+ // Apply scroll windowing only if content exceeds viewport
536
+ if (totalLines <= viewportHeight) {
537
+ // Everything fits — just show all lines
538
+ for (const tl of allTranscriptLines) lines.push(tl);
539
+ sessionScrollOffset = 0;
540
+ } else {
541
+ const maxScroll = totalLines - viewportHeight;
542
+
543
+ // Clamp
544
+ if (sessionScrollOffset > maxScroll) sessionScrollOffset = maxScroll;
545
+ if (sessionScrollOffset < 0) sessionScrollOffset = 0;
546
+ if (sessionAutoFollow) sessionScrollOffset = 0;
547
+
548
+ const endIndex = totalLines - sessionScrollOffset;
549
+ const startIndex = Math.max(0, endIndex - viewportHeight);
550
+ const visible = allTranscriptLines.slice(startIndex, endIndex);
551
+ for (const vl of visible) lines.push(vl);
552
+ }
553
+ }
554
+
555
+ // Scroll indicator bar
556
+ if (sessionScrollOffset > 0) {
557
+ lines.push(truncateToWidth(
558
+ ` ${theme.fg("accent", `\u2193 ${String(sessionScrollOffset)} more line${sessionScrollOffset === 1 ? "" : "s"} (g to follow)`)}`,
559
+ width,
560
+ ));
561
+ } else if (totalLines > 0) {
562
+ lines.push(truncateToWidth(
563
+ ` ${theme.fg("success", "\u25cf following")}`,
564
+ width,
565
+ ));
566
+ }
567
+
568
+ // Notification
569
+ if (notification) {
570
+ lines.push(
571
+ truncateToWidth(" " + theme.fg(notification.color, notification.text), width),
572
+ );
573
+ }
574
+
575
+ // Hints
576
+ lines.push(truncateToWidth(` ${sep}`, width));
577
+ lines.push(truncateToWidth(
578
+ theme.fg("dim", " \u2191\u2193/ws scroll \u00b7 g follow \u00b7 t/shift+t tasks \u00b7 m/d message \u00b7 a abort \u00b7 k kill \u00b7 esc back"),
579
+ width,
580
+ ));
581
+
582
+ return lines;
583
+ }
584
+
585
+ // ── Task list render ──
586
+
587
+ function renderTasks(width: number): string[] {
588
+ if (!taskViewOwner) return renderOverview(width);
589
+
590
+ const ownerName = taskViewOwner;
591
+ const ownerLabel = formatMemberDisplayName(style, ownerName);
592
+ const allTasks = deps.getTasks();
593
+ const taskById = new Map<string, TeamTask>();
594
+ for (const task of allTasks) taskById.set(task.id, task);
595
+
596
+ const ownerTasks = getOwnedTasks(ownerName);
597
+
598
+ if (taskCursorIndex >= ownerTasks.length) taskCursorIndex = Math.max(0, ownerTasks.length - 1);
599
+
600
+ const pendingCount = ownerTasks.filter((t) => t.status === "pending").length;
601
+ const inProgressCount = ownerTasks.filter((t) => t.status === "in_progress").length;
602
+ const completedCount = ownerTasks.filter((t) => t.status === "completed").length;
603
+ const blockedCount = ownerTasks.filter((t) => t.status === "pending" && unresolvedDependencies(t, taskById).length > 0).length;
604
+
605
+ const lines: string[] = [];
606
+ const sep = theme.fg("dim", "─".repeat(Math.max(0, width - 2)));
607
+ const returnLabel = taskReturnMode === "session" ? "esc/t/shift+t back to transcript" : "esc/t/shift+t back";
608
+
609
+ lines.push(truncateToWidth(` ${theme.bold(theme.fg("accent", `Tasks · ${ownerLabel}`))}`, width));
610
+ const attachBanner = renderAttachBanner(width);
611
+ if (attachBanner) lines.push(attachBanner);
612
+ lines.push(
613
+ truncateToWidth(
614
+ ` ${theme.fg("dim", `${inProgressCount} in progress · ${pendingCount} pending · ${blockedCount} blocked · ${completedCount} done`)}`,
615
+ width,
616
+ ),
617
+ );
618
+
619
+ if (ownerTasks.length === 0) {
620
+ lines.push(truncateToWidth(` ${theme.fg("dim", theme.italic("no tasks assigned"))}`, width));
621
+ if (notification) lines.push(truncateToWidth(` ${theme.fg(notification.color, notification.text)}`, width));
622
+ lines.push(truncateToWidth(` ${sep}`, width));
623
+ lines.push(
624
+ truncateToWidth(
625
+ theme.fg("dim", ` ${returnLabel} · m/d message · a abort · k kill · enter open transcript`),
626
+ width,
627
+ ),
628
+ );
629
+ return lines;
630
+ }
631
+
632
+ const termHeight = process.stdout.rows || 24;
633
+ const notifLines = notification ? 1 : 0;
634
+ const detailLines = 4;
635
+ const attachLines = renderAttachBanner(width) ? 1 : 0;
636
+ const chromeLines = 2 + attachLines + detailLines + notifLines + 1 + 1;
637
+ const viewportHeight = Math.max(3, termHeight - chromeLines);
638
+
639
+ let start = 0;
640
+ if (ownerTasks.length > viewportHeight) {
641
+ const ideal = taskCursorIndex - Math.floor(viewportHeight / 2);
642
+ const maxStart = ownerTasks.length - viewportHeight;
643
+ start = Math.max(0, Math.min(maxStart, ideal));
644
+ }
645
+ const end = Math.min(ownerTasks.length, start + viewportHeight);
646
+
647
+ for (let idx = start; idx < end; idx++) {
648
+ const task = ownerTasks[idx];
649
+ if (!task) continue;
650
+ const unresolved = unresolvedDependencies(task, taskById);
651
+ const isBlocked = task.status === "pending" && unresolved.length > 0;
652
+ const statusLabel = isBlocked ? "blocked" : task.status;
653
+ const statusColor: ThemeColor = statusLabel === "in_progress"
654
+ ? "warning"
655
+ : statusLabel === "completed"
656
+ ? "success"
657
+ : statusLabel === "blocked"
658
+ ? "error"
659
+ : "muted";
660
+ const selected = idx === taskCursorIndex;
661
+ const pointer = selected ? theme.fg("accent", "▸") : " ";
662
+ const subject = task.subject.length > 58 ? `${task.subject.slice(0, 57)}…` : task.subject;
663
+ const qgStatus = getQualityGateStatus(task);
664
+ const depTag = unresolved.length > 0 ? ` deps:${String(unresolved.length)}` : "";
665
+ const qgTag = qgStatus === "failed" ? " qg:fail" : qgStatus === "passed" ? " qg:ok" : "";
666
+ const row = `${pointer}${theme.fg(statusColor, statusLabel.padEnd(11))} ${theme.fg("dim", `#${task.id}`)} ${subject}${theme.fg("dim", `${depTag}${qgTag}`)}`;
667
+ lines.push(truncateToWidth(row, width));
668
+ }
669
+
670
+ const selectedTask = ownerTasks[taskCursorIndex];
671
+ if (selectedTask) {
672
+ const unresolved = unresolvedDependencies(selectedTask, taskById);
673
+ const depSummary = selectedTask.blockedBy.length === 0
674
+ ? "none"
675
+ : selectedTask.blockedBy
676
+ .map((depId) => {
677
+ const dep = taskById.get(depId);
678
+ if (!dep) return `#${depId}?`;
679
+ return dep.status === "completed" ? `#${depId}:done` : `#${depId}:open`;
680
+ })
681
+ .join(", ");
682
+ const blockSummary = selectedTask.blocks.length === 0
683
+ ? "none"
684
+ : selectedTask.blocks.map((id) => `#${id}`).join(", ");
685
+ const desc = selectedTask.description.replace(/\s+/g, " ").trim();
686
+ const descPreview = desc.length > 90 ? `${desc.slice(0, 89)}…` : desc || "(no description)";
687
+
688
+ lines.push(truncateToWidth(` ${sep}`, width));
689
+ lines.push(
690
+ truncateToWidth(
691
+ ` ${theme.fg("muted", "selected:")} ${theme.bold(`#${selectedTask.id} ${selectedTask.subject}`)}`,
692
+ width,
693
+ ),
694
+ );
695
+ lines.push(
696
+ truncateToWidth(
697
+ ` ${theme.fg("dim", "depends on:")} ${theme.fg(unresolved.length > 0 ? "error" : "muted", depSummary)}`,
698
+ width,
699
+ ),
700
+ );
701
+ lines.push(truncateToWidth(` ${theme.fg("dim", "blocking:")} ${theme.fg("muted", blockSummary)}`, width));
702
+ lines.push(truncateToWidth(` ${theme.fg("dim", "desc:")} ${theme.fg("muted", descPreview)}`, width));
703
+ const qgStatus = getQualityGateStatus(selectedTask);
704
+ if (qgStatus) {
705
+ const qgSummary = getQualityGateSummary(selectedTask);
706
+ const qgColor: ThemeColor = qgStatus === "failed" ? "error" : "success";
707
+ const qgText = qgSummary ? `${qgStatus} · ${qgSummary}` : qgStatus;
708
+ lines.push(truncateToWidth(` ${theme.fg("dim", "quality gate:")} ${theme.fg(qgColor, qgText)}`, width));
709
+ }
710
+ }
711
+
712
+ if (notification) lines.push(truncateToWidth(` ${theme.fg(notification.color, notification.text)}`, width));
713
+ lines.push(truncateToWidth(` ${sep}`, width));
714
+ lines.push(
715
+ truncateToWidth(
716
+ theme.fg("dim", ` ↑↓/ws select · enter transcript · c complete · p pending · i in-progress · u unassign · r reassign · m/d message · ${returnLabel}`),
717
+ width,
718
+ ),
719
+ );
720
+
721
+ return lines;
722
+ }
723
+
724
+ // ── Reassign render ──
725
+
726
+ function renderReassign(width: number): string[] {
727
+ if (!reassignTaskId) return renderTasks(width);
728
+ const members = getReassignableMembers();
729
+ const task = deps.getTasks().find((t) => t.id === reassignTaskId);
730
+
731
+ const lines: string[] = [];
732
+ const sep = theme.fg("dim", "─".repeat(Math.max(0, width - 2)));
733
+
734
+ if (!task) {
735
+ lines.push(truncateToWidth(` ${theme.fg("error", `Task #${reassignTaskId} not found`)}`, width));
736
+ lines.push(truncateToWidth(` ${sep}`, width));
737
+ lines.push(truncateToWidth(theme.fg("dim", " esc back"), width));
738
+ return lines;
739
+ }
740
+
741
+ lines.push(truncateToWidth(` ${theme.bold(theme.fg("accent", `Reassign #${task.id}`))}`, width));
742
+ const attachBanner = renderAttachBanner(width);
743
+ if (attachBanner) lines.push(attachBanner);
744
+ lines.push(truncateToWidth(` ${theme.fg("dim", task.subject)}`, width));
745
+ const ownerLabel = task.owner ? formatMemberDisplayName(style, task.owner) : "(unassigned)";
746
+ lines.push(truncateToWidth(` ${theme.fg("muted", `current owner: ${ownerLabel}`)}`, width));
747
+
748
+ if (members.length === 0) {
749
+ lines.push(truncateToWidth(` ${theme.fg("error", `No ${strings.memberTitle.toLowerCase()}s available`)}`, width));
750
+ lines.push(truncateToWidth(` ${sep}`, width));
751
+ lines.push(truncateToWidth(theme.fg("dim", " esc back"), width));
752
+ return lines;
753
+ }
754
+
755
+ reassignCursorIndex = Math.max(0, Math.min(reassignCursorIndex, members.length - 1));
756
+ for (let i = 0; i < members.length; i++) {
757
+ const name = members[i];
758
+ if (!name) continue;
759
+ const selected = i === reassignCursorIndex;
760
+ const pointer = selected ? theme.fg("accent", "▸") : " ";
761
+ const display = formatMemberDisplayName(style, name);
762
+ const current = task.owner === name ? theme.fg("dim", " (current)") : "";
763
+ lines.push(truncateToWidth(`${pointer}${theme.bold(display)}${current}`, width));
764
+ }
765
+
766
+ if (notification) lines.push(truncateToWidth(` ${theme.fg(notification.color, notification.text)}`, width));
767
+ lines.push(truncateToWidth(` ${sep}`, width));
768
+ lines.push(
769
+ truncateToWidth(
770
+ theme.fg("dim", " ↑↓/ws select · 1-9 jump · enter assign · esc cancel"),
771
+ width,
772
+ ),
773
+ );
774
+
775
+ return lines;
776
+ }
777
+
778
+ // ── DM render ──
779
+
780
+ function renderDm(width: number): string[] {
781
+ const lines: string[] = [];
782
+ const sep = theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
783
+
784
+ lines.push(
785
+ truncateToWidth(
786
+ ` ${theme.bold(theme.fg("accent", `Message to ${formatMemberDisplayName(style, dmTarget ?? "")}`))}`,
787
+ width,
788
+ ),
789
+ );
790
+ const attachBanner = renderAttachBanner(width);
791
+ if (attachBanner) lines.push(attachBanner);
792
+ lines.push(truncateToWidth(` ${sep}`, width));
793
+ lines.push(
794
+ truncateToWidth(` ${theme.fg("accent", "\u25b8")} ${dmBuffer}\u2588`, width),
795
+ );
796
+ lines.push(truncateToWidth(` ${sep}`, width));
797
+ lines.push(
798
+ truncateToWidth(` ${theme.fg("dim", "enter send \u00b7 esc cancel")}`, width),
799
+ );
800
+
801
+ return lines;
802
+ }
803
+
804
+ // ── Component ──
805
+
806
+ return {
807
+ render(width: number): string[] {
808
+ switch (mode) {
809
+ case "overview":
810
+ return renderOverview(width);
811
+ case "session":
812
+ return renderSession(width);
813
+ case "dm":
814
+ return renderDm(width);
815
+ case "tasks":
816
+ return renderTasks(width);
817
+ case "reassign":
818
+ return renderReassign(width);
819
+ }
820
+ },
821
+
822
+ handleInput(data: string): void {
823
+ // ── DM mode ──
824
+ if (mode === "dm") {
825
+ if (matchesKey(data, "escape")) {
826
+ mode = dmReturnMode;
827
+ dmBuffer = "";
828
+ dmTarget = null;
829
+ tui.requestRender();
830
+ return;
831
+ }
832
+ if (matchesKey(data, "enter")) {
833
+ if (dmBuffer.trim() && dmTarget) {
834
+ const msg = dmBuffer.trim();
835
+ const target = dmTarget;
836
+ void deps.sendMessage(target, msg);
837
+ showNotification(`Message sent to ${formatMemberDisplayName(style, target)}`);
838
+ dmBuffer = "";
839
+ mode = dmReturnMode;
840
+ dmTarget = null;
841
+ }
842
+ tui.requestRender();
843
+ return;
844
+ }
845
+ if (matchesKey(data, "backspace")) {
846
+ dmBuffer = dmBuffer.slice(0, -1);
847
+ tui.requestRender();
848
+ return;
849
+ }
850
+ // Regular character input
851
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
852
+ dmBuffer += data;
853
+ tui.requestRender();
854
+ return;
855
+ }
856
+ return;
857
+ }
858
+
859
+ // ── Reassign mode ──
860
+ if (mode === "reassign") {
861
+ if (matchesKey(data, "escape")) {
862
+ mode = "tasks";
863
+ reassignTaskId = null;
864
+ tui.requestRender();
865
+ return;
866
+ }
867
+ const members = getReassignableMembers();
868
+ if (members.length === 0 || !reassignTaskId) {
869
+ mode = "tasks";
870
+ reassignTaskId = null;
871
+ tui.requestRender();
872
+ return;
873
+ }
874
+ if (matchesKey(data, "up") || data === "w") {
875
+ reassignCursorIndex = Math.max(0, reassignCursorIndex - 1);
876
+ tui.requestRender();
877
+ return;
878
+ }
879
+ if (matchesKey(data, "down") || data === "s") {
880
+ reassignCursorIndex = Math.min(members.length - 1, reassignCursorIndex + 1);
881
+ tui.requestRender();
882
+ return;
883
+ }
884
+ if (/^[1-9]$/.test(data)) {
885
+ const jump = Number.parseInt(data, 10) - 1;
886
+ if (jump < members.length) {
887
+ reassignCursorIndex = jump;
888
+ tui.requestRender();
889
+ }
890
+ return;
891
+ }
892
+ if (matchesKey(data, "enter")) {
893
+ const taskId = reassignTaskId;
894
+ const targetName = members[reassignCursorIndex];
895
+ if (!taskId || !targetName) return;
896
+ const oldOwner = taskViewOwner;
897
+ mode = "tasks";
898
+ reassignTaskId = null;
899
+ void deps.assignTask(taskId, targetName)
900
+ .then((ok) => {
901
+ if (ok) {
902
+ taskViewOwner = targetName;
903
+ taskCursorIndex = 0;
904
+ showNotification(`Reassigned task #${taskId} to ${formatMemberDisplayName(style, targetName)}`);
905
+ } else {
906
+ taskViewOwner = oldOwner;
907
+ showNotification(`Failed to reassign task #${taskId}`, "error");
908
+ }
909
+ tui.requestRender();
910
+ })
911
+ .catch(() => {
912
+ taskViewOwner = oldOwner;
913
+ showNotification(`Failed to reassign task #${taskId}`, "error");
914
+ tui.requestRender();
915
+ });
916
+ tui.requestRender();
917
+ return;
918
+ }
919
+ return;
920
+ }
921
+
922
+ // ── Tasks mode ──
923
+ if (mode === "tasks") {
924
+ if (matchesKey(data, "escape") || isTaskToggleKey(data)) {
925
+ mode = taskReturnMode;
926
+ taskViewOwner = null;
927
+ tui.requestRender();
928
+ return;
929
+ }
930
+ if (!taskViewOwner) {
931
+ mode = "overview";
932
+ tui.requestRender();
933
+ return;
934
+ }
935
+ if (matchesKey(data, "up") || data === "w") {
936
+ taskCursorIndex = Math.max(0, taskCursorIndex - 1);
937
+ tui.requestRender();
938
+ return;
939
+ }
940
+ if (matchesKey(data, "down") || data === "s") {
941
+ const ownedCount = getOwnedTasks(taskViewOwner).length;
942
+ taskCursorIndex = Math.min(Math.max(0, ownedCount - 1), taskCursorIndex + 1);
943
+ tui.requestRender();
944
+ return;
945
+ }
946
+ if (data === "c" || data === "p" || data === "i" || data === "u" || data === "r") {
947
+ const selected = getSelectedOwnedTask(taskViewOwner);
948
+ if (!selected) {
949
+ showNotification("No task selected", "error");
950
+ return;
951
+ }
952
+
953
+ if (data === "r") {
954
+ openReassign(selected.id, taskViewOwner);
955
+ return;
956
+ }
957
+
958
+ if (data === "u") {
959
+ const taskId = selected.id;
960
+ void deps.unassignTask(taskId)
961
+ .then((ok) => {
962
+ if (ok) showNotification(`Unassigned task #${taskId}`);
963
+ else showNotification(`Failed to unassign task #${taskId}`, "error");
964
+ })
965
+ .catch(() => showNotification(`Failed to unassign task #${taskId}`, "error"));
966
+ return;
967
+ }
968
+
969
+ const targetStatus: TeamTask["status"] = data === "c"
970
+ ? "completed"
971
+ : data === "i"
972
+ ? "in_progress"
973
+ : "pending";
974
+ if (selected.status === targetStatus) {
975
+ showNotification(`Task #${selected.id} already ${targetStatus}`, "muted");
976
+ return;
977
+ }
978
+ const taskId = selected.id;
979
+ void deps.setTaskStatus(taskId, targetStatus)
980
+ .then((ok) => {
981
+ if (ok) showNotification(`Task #${taskId} set to ${targetStatus}`);
982
+ else showNotification(`Failed to update task #${taskId}`, "error");
983
+ })
984
+ .catch(() => showNotification(`Failed to update task #${taskId}`, "error"));
985
+ return;
986
+ }
987
+ if (matchesKey(data, "enter") || data === "o") {
988
+ sessionName = taskViewOwner;
989
+ mode = "session";
990
+ sessionScrollOffset = 0;
991
+ sessionAutoFollow = true;
992
+ taskViewOwner = null;
993
+ tui.requestRender();
994
+ return;
995
+ }
996
+ if (data === "m" || data === "d") {
997
+ dmTarget = taskViewOwner;
998
+ dmReturnMode = "tasks";
999
+ mode = "dm";
1000
+ dmBuffer = "";
1001
+ tui.requestRender();
1002
+ return;
1003
+ }
1004
+ if (data === "a") {
1005
+ deps.abortMember(taskViewOwner);
1006
+ showNotification(`${formatMemberDisplayName(style, taskViewOwner)} ${strings.abortRequestedVerb}`, "warning");
1007
+ return;
1008
+ }
1009
+ if (data === "k") {
1010
+ const target = taskViewOwner;
1011
+ deps.killMember(target);
1012
+ showNotification(`${formatMemberDisplayName(style, target)} ${strings.killedVerb} (SIGTERM)`, "warning");
1013
+ if (sessionName === target) {
1014
+ sessionName = null;
1015
+ taskReturnMode = "overview";
1016
+ }
1017
+ tui.requestRender();
1018
+ return;
1019
+ }
1020
+ return;
1021
+ }
1022
+
1023
+ // ── Session mode ──
1024
+ if (mode === "session") {
1025
+ if (matchesKey(data, "escape")) {
1026
+ mode = "overview";
1027
+ sessionName = null;
1028
+ tui.requestRender();
1029
+ return;
1030
+ }
1031
+ if (matchesKey(data, "up") || data === "w") {
1032
+ sessionScrollOffset += 1;
1033
+ sessionAutoFollow = false;
1034
+ tui.requestRender();
1035
+ return;
1036
+ }
1037
+ if (matchesKey(data, "down") || data === "s") {
1038
+ sessionScrollOffset = Math.max(0, sessionScrollOffset - 1);
1039
+ if (sessionScrollOffset === 0) sessionAutoFollow = true;
1040
+ tui.requestRender();
1041
+ return;
1042
+ }
1043
+ if (matchesKey(data, "pageUp")) {
1044
+ const h = process.stdout.rows || 24;
1045
+ const jump = Math.max(1, Math.floor(h / 2));
1046
+ sessionScrollOffset += jump;
1047
+ sessionAutoFollow = false;
1048
+ tui.requestRender();
1049
+ return;
1050
+ }
1051
+ if (matchesKey(data, "pageDown")) {
1052
+ const h = process.stdout.rows || 24;
1053
+ const jump = Math.max(1, Math.floor(h / 2));
1054
+ sessionScrollOffset = Math.max(0, sessionScrollOffset - jump);
1055
+ if (sessionScrollOffset === 0) sessionAutoFollow = true;
1056
+ tui.requestRender();
1057
+ return;
1058
+ }
1059
+ if (data === "g" || matchesKey(data, "end")) {
1060
+ sessionScrollOffset = 0;
1061
+ sessionAutoFollow = true;
1062
+ tui.requestRender();
1063
+ return;
1064
+ }
1065
+ if (isTaskToggleKey(data) && sessionName) {
1066
+ openTaskView(sessionName, "session");
1067
+ return;
1068
+ }
1069
+ if (data === "m" || data === "d") {
1070
+ dmTarget = sessionName;
1071
+ dmReturnMode = "session";
1072
+ mode = "dm";
1073
+ dmBuffer = "";
1074
+ tui.requestRender();
1075
+ return;
1076
+ }
1077
+ if (data === "a") {
1078
+ if (sessionName) {
1079
+ deps.abortMember(sessionName);
1080
+ showNotification(`${formatMemberDisplayName(style, sessionName)} ${strings.abortRequestedVerb}`, "warning");
1081
+ }
1082
+ return;
1083
+ }
1084
+ if (data === "k") {
1085
+ if (sessionName) {
1086
+ const name = sessionName;
1087
+ deps.killMember(name);
1088
+ showNotification(`${formatMemberDisplayName(style, name)} ${strings.killedVerb} (SIGTERM)`, "warning");
1089
+ mode = "overview";
1090
+ sessionName = null;
1091
+ tui.requestRender();
1092
+ }
1093
+ return;
1094
+ }
1095
+ return;
1096
+ }
1097
+
1098
+ // ── Overview mode ──
1099
+ const memberNames = getVisibleWorkerNames({
1100
+ teammates: deps.getTeammates(),
1101
+ teamConfig: deps.getTeamConfig(),
1102
+ tasks: deps.getTasks(),
1103
+ });
1104
+
1105
+ if (matchesKey(data, "escape") || data === "q") {
1106
+ done(undefined);
1107
+ return;
1108
+ }
1109
+ if (matchesKey(data, "up") || data === "w") {
1110
+ cursorIndex = Math.max(0, cursorIndex - 1);
1111
+ tui.requestRender();
1112
+ return;
1113
+ }
1114
+ if (matchesKey(data, "down") || data === "s") {
1115
+ cursorIndex = Math.min(memberNames.length - 1, cursorIndex + 1);
1116
+ tui.requestRender();
1117
+ return;
1118
+ }
1119
+ if (/^[1-9]$/.test(data)) {
1120
+ const jump = Number.parseInt(data, 10) - 1;
1121
+ if (jump < memberNames.length) {
1122
+ cursorIndex = jump;
1123
+ tui.requestRender();
1124
+ }
1125
+ return;
1126
+ }
1127
+ if (isTaskToggleKey(data)) {
1128
+ const name = memberNames[cursorIndex];
1129
+ if (name) openTaskView(name, "overview");
1130
+ return;
1131
+ }
1132
+ if (matchesKey(data, "enter")) {
1133
+ const name = memberNames[cursorIndex];
1134
+ if (name) {
1135
+ sessionName = name;
1136
+ mode = "session";
1137
+ sessionScrollOffset = 0;
1138
+ sessionAutoFollow = true;
1139
+ tui.requestRender();
1140
+ }
1141
+ return;
1142
+ }
1143
+ if (data === "m" || data === "d") {
1144
+ const name = memberNames[cursorIndex];
1145
+ if (name) {
1146
+ dmTarget = name;
1147
+ dmReturnMode = "overview";
1148
+ mode = "dm";
1149
+ dmBuffer = "";
1150
+ tui.requestRender();
1151
+ }
1152
+ return;
1153
+ }
1154
+ if (data === "a") {
1155
+ const name = memberNames[cursorIndex];
1156
+ if (name) {
1157
+ deps.abortMember(name);
1158
+ showNotification(`${formatMemberDisplayName(style, name)} ${strings.abortRequestedVerb}`, "warning");
1159
+ }
1160
+ return;
1161
+ }
1162
+ if (data === "k") {
1163
+ const name = memberNames[cursorIndex];
1164
+ if (name) {
1165
+ deps.killMember(name);
1166
+ showNotification(`${formatMemberDisplayName(style, name)} ${strings.killedVerb} (SIGTERM)`, "warning");
1167
+ tui.requestRender();
1168
+ }
1169
+ return;
1170
+ }
1171
+ },
1172
+
1173
+ invalidate() {},
1174
+
1175
+ dispose() {
1176
+ clearInterval(refreshInterval);
1177
+ if (notificationTimer) clearTimeout(notificationTimer);
1178
+ },
1179
+ };
1180
+ },
1181
+ {},
1182
+ );
1183
+ } finally {
1184
+ deps.restoreWidget();
1185
+ }
1186
+ }