@tmustier/pi-agent-teams 0.1.2 → 0.2.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 (35) hide show
  1. package/.github/workflows/ci.yml +32 -0
  2. package/README.md +36 -6
  3. package/docs/claude-parity.md +16 -14
  4. package/docs/field-notes-teams-setup.md +6 -4
  5. package/docs/smoke-test-plan.md +130 -0
  6. package/extensions/teams/activity-tracker.ts +234 -0
  7. package/extensions/teams/fs-lock.ts +21 -5
  8. package/extensions/teams/leader-inbox.ts +175 -0
  9. package/extensions/teams/leader-info-commands.ts +139 -0
  10. package/extensions/teams/leader-lifecycle-commands.ts +330 -0
  11. package/extensions/teams/leader-messaging-commands.ts +149 -0
  12. package/extensions/teams/leader-plan-commands.ts +96 -0
  13. package/extensions/teams/leader-spawn-command.ts +73 -0
  14. package/extensions/teams/leader-task-commands.ts +417 -0
  15. package/extensions/teams/leader-teams-tool.ts +238 -0
  16. package/extensions/teams/leader.ts +396 -1422
  17. package/extensions/teams/mailbox.ts +54 -29
  18. package/extensions/teams/names.ts +87 -0
  19. package/extensions/teams/protocol.ts +221 -0
  20. package/extensions/teams/task-store.ts +32 -21
  21. package/extensions/teams/team-config.ts +71 -25
  22. package/extensions/teams/teammate-rpc.ts +56 -22
  23. package/extensions/teams/teams-panel.ts +698 -0
  24. package/extensions/teams/teams-style.ts +62 -0
  25. package/extensions/teams/teams-widget.ts +235 -0
  26. package/extensions/teams/worker.ts +100 -138
  27. package/extensions/teams/worktree.ts +4 -7
  28. package/package.json +25 -3
  29. package/scripts/integration-claim-test.mts +227 -0
  30. package/scripts/integration-todo-test.mts +583 -0
  31. package/scripts/smoke-test.mjs +1 -1
  32. package/scripts/smoke-test.mts +424 -0
  33. package/skills/agent-teams/SKILL.md +136 -0
  34. package/tsconfig.strict.json +22 -0
  35. package/extensions/teams/tasks.ts +0 -95
@@ -0,0 +1,698 @@
1
+ import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2
+ import type { Theme, ThemeColor, ExtensionCommandContext } from "@mariozechner/pi-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
+
10
+ export interface InteractiveWidgetDeps {
11
+ getTeammates(): Map<string, TeammateRpc>;
12
+ getTracker(): ActivityTracker;
13
+ getTranscript(name: string): TranscriptLog;
14
+ getTasks(): TeamTask[];
15
+ getTeamConfig(): TeamConfig | null;
16
+ getStyle(): TeamsStyle;
17
+ isDelegateMode(): boolean;
18
+ sendMessage(name: string, message: string): Promise<void>;
19
+ abortComrade(name: string): void;
20
+ killComrade(name: string): void;
21
+ suppressWidget(): void;
22
+ restoreWidget(): void;
23
+ }
24
+
25
+ // ── Status icon + color (shared with teams-widget.ts) ──
26
+
27
+ const STATUS_ICON: Record<TeammateStatus, string> = {
28
+ streaming: "\u25c9",
29
+ idle: "\u25cf",
30
+ starting: "\u25cb",
31
+ stopped: "\u2717",
32
+ error: "\u2717",
33
+ };
34
+
35
+ const STATUS_COLOR: Record<TeammateStatus, ThemeColor> = {
36
+ streaming: "accent",
37
+ idle: "success",
38
+ starting: "muted",
39
+ stopped: "dim",
40
+ error: "error",
41
+ };
42
+
43
+ const TOOL_VERB: Record<string, string> = {
44
+ read: "reading\u2026",
45
+ edit: "editing\u2026",
46
+ write: "writing\u2026",
47
+ grep: "searching\u2026",
48
+ glob: "finding files\u2026",
49
+ bash: "running\u2026",
50
+ task: "delegating\u2026",
51
+ webfetch: "fetching\u2026",
52
+ websearch: "searching web\u2026",
53
+ };
54
+
55
+ // ── Helpers ──
56
+
57
+ function padRight(str: string, targetWidth: number): string {
58
+ const w = visibleWidth(str);
59
+ return w >= targetWidth ? str : str + " ".repeat(targetWidth - w);
60
+ }
61
+
62
+ function resolveStatus(rpc: TeammateRpc | undefined, cfg: TeamMember | undefined): TeammateStatus {
63
+ if (rpc) return rpc.status;
64
+ return cfg?.status === "online" ? "idle" : "stopped";
65
+ }
66
+
67
+ function formatTokens(n: number): string {
68
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
69
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
70
+ return String(n);
71
+ }
72
+
73
+ function toolActivity(toolName: string | null): string {
74
+ if (!toolName) return "";
75
+ const key = toolName.toLowerCase();
76
+ return TOOL_VERB[key] ?? `${key}\u2026`;
77
+ }
78
+
79
+ function formatTimestamp(ts: number): string {
80
+ const d = new Date(ts);
81
+ return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
82
+ }
83
+
84
+ function getComradeNames(deps: InteractiveWidgetDeps): string[] {
85
+ const teamConfig = deps.getTeamConfig();
86
+ const teammates = deps.getTeammates();
87
+ const tasks = deps.getTasks();
88
+ const leadName = teamConfig?.leadName;
89
+ const cfgMembers = teamConfig?.members ?? [];
90
+
91
+ const names = new Set<string>();
92
+ for (const name of teammates.keys()) names.add(name);
93
+ for (const m of cfgMembers) {
94
+ if (m.role === "worker" && m.status === "online") names.add(m.name);
95
+ }
96
+ for (const t of tasks) {
97
+ if (t.owner && t.owner !== leadName && t.status === "in_progress") names.add(t.owner);
98
+ }
99
+ return Array.from(names).sort();
100
+ }
101
+
102
+ // ── Row data (mirrors teams-widget.ts) ──
103
+
104
+ interface Row {
105
+ icon: string;
106
+ iconColor: ThemeColor;
107
+ name: string;
108
+ displayName: string;
109
+ statusKey: TeammateStatus;
110
+ pending: number;
111
+ completed: number;
112
+ tokensStr: string;
113
+ activityText: string;
114
+ isChairman: boolean;
115
+ }
116
+
117
+ type WidgetMode = "overview" | "session" | "dm";
118
+
119
+ // ── Transcript formatting ──
120
+
121
+ function formatTranscriptEntry(entry: TranscriptEntry, theme: Theme, width: number): string[] {
122
+ const ts = formatTimestamp(entry.timestamp);
123
+ const tsStr = theme.fg("dim", ts);
124
+ const maxTextWidth = width - 12; // " HH:MM:SS " prefix
125
+
126
+ if (entry.kind === "text") {
127
+ // Wrap long text lines
128
+ const lines: string[] = [];
129
+ const text = entry.text;
130
+ if (visibleWidth(text) <= maxTextWidth) {
131
+ lines.push(` ${tsStr} ${theme.fg("dim", theme.italic(text))}`);
132
+ } else {
133
+ // Simple word wrap
134
+ let remaining = text;
135
+ let first = true;
136
+ while (remaining.length > 0) {
137
+ const chunk = remaining.slice(0, maxTextWidth);
138
+ remaining = remaining.slice(maxTextWidth);
139
+ if (first) {
140
+ lines.push(` ${tsStr} ${theme.fg("dim", theme.italic(chunk))}`);
141
+ first = false;
142
+ } else {
143
+ lines.push(` ${" ".repeat(10)}${theme.fg("dim", theme.italic(chunk))}`);
144
+ }
145
+ }
146
+ }
147
+ return lines;
148
+ }
149
+
150
+ if (entry.kind === "tool_start") {
151
+ const verb = TOOL_VERB[entry.toolName.toLowerCase()] ?? `${entry.toolName}\u2026`;
152
+ return [` ${tsStr} ${theme.fg("warning", verb)}`];
153
+ }
154
+
155
+ if (entry.kind === "tool_end") {
156
+ const dur = entry.durationMs < 1000
157
+ ? `${(entry.durationMs / 1000).toFixed(1)}s`
158
+ : `${(entry.durationMs / 1000).toFixed(1)}s`;
159
+ return [` ${tsStr} ${theme.fg("muted", entry.toolName)} ${theme.fg("dim", "\u2500")} ${theme.fg("dim", dur)}`];
160
+ }
161
+
162
+ if (entry.kind === "turn_end") {
163
+ const tokStr = formatTokens(entry.tokens);
164
+ const label = `\u2500\u2500 turn ${String(entry.turnNumber)} complete \u2500\u2500 ${tokStr} tokens \u2500\u2500`;
165
+ return [` ${theme.fg("dim", label)}`];
166
+ }
167
+
168
+ return [];
169
+ }
170
+
171
+ // ── Main export ──
172
+
173
+ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps: InteractiveWidgetDeps): Promise<void> {
174
+ const style = deps.getStyle();
175
+ const strings = getTeamsStrings(style);
176
+ const names = getComradeNames(deps);
177
+ if (names.length === 0) {
178
+ ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s to show`, "info");
179
+ return;
180
+ }
181
+
182
+ // Hide persistent widget while interactive one is open.
183
+ deps.suppressWidget();
184
+
185
+ try {
186
+ await ctx.ui.custom<void>(
187
+ (tui, theme, _kb, done) => {
188
+ let mode: WidgetMode = "overview";
189
+ let cursorIndex = 0;
190
+ let sessionName: string | null = null;
191
+ let dmTarget: string | null = null;
192
+ let dmBuffer = "";
193
+ let notification: { text: string; color: ThemeColor } | null = null;
194
+ let notificationTimer: ReturnType<typeof setTimeout> | null = null;
195
+ let sessionScrollOffset = 0;
196
+ let sessionAutoFollow = true;
197
+
198
+ const refreshInterval = setInterval(() => tui.requestRender(), 1000);
199
+
200
+ function showNotification(text: string, color: ThemeColor = "success") {
201
+ notification = { text, color };
202
+ if (notificationTimer) clearTimeout(notificationTimer);
203
+ notificationTimer = setTimeout(() => {
204
+ notification = null;
205
+ tui.requestRender();
206
+ }, 3000);
207
+ tui.requestRender();
208
+ }
209
+
210
+ // ── Build row data (same logic as persistent widget) ──
211
+
212
+ function buildRows(): { rows: Row[]; comradeNames: string[] } {
213
+ const teammates = deps.getTeammates();
214
+ const tracker = deps.getTracker();
215
+ const tasks = deps.getTasks();
216
+ const teamConfig = deps.getTeamConfig();
217
+ const leadName = teamConfig?.leadName;
218
+ const cfgMembers = teamConfig?.members ?? [];
219
+ const cfgByName = new Map<string, TeamMember>();
220
+ for (const m of cfgMembers) cfgByName.set(m.name, m);
221
+
222
+ const rows: Row[] = [];
223
+
224
+ // Chairman
225
+ if (leadName) {
226
+ const leadTasks = tasks.filter((t) => t.owner === leadName);
227
+ rows.push({
228
+ icon: "\u25c6",
229
+ iconColor: "accent",
230
+ displayName: strings.leaderTitle,
231
+ statusKey: "idle",
232
+ pending: leadTasks.filter((t) => t.status === "pending").length,
233
+ completed: leadTasks.filter((t) => t.status === "completed").length,
234
+ tokensStr: "\u2014",
235
+ activityText: "",
236
+ isChairman: true,
237
+ name: leadName,
238
+ });
239
+ }
240
+
241
+ // Comrades
242
+ const comradeNames = getComradeNames(deps);
243
+ for (const name of comradeNames) {
244
+ const rpc = teammates.get(name);
245
+ const cfg = cfgByName.get(name);
246
+ const statusKey = resolveStatus(rpc, cfg);
247
+ const activity = tracker.get(name);
248
+ const owned = tasks.filter((t) => t.owner === name);
249
+
250
+ rows.push({
251
+ icon: STATUS_ICON[statusKey],
252
+ iconColor: STATUS_COLOR[statusKey],
253
+ displayName: formatMemberDisplayName(style, name),
254
+ statusKey,
255
+ pending: owned.filter((t) => t.status === "pending").length,
256
+ completed: owned.filter((t) => t.status === "completed").length,
257
+ tokensStr: formatTokens(activity.totalTokens),
258
+ activityText: toolActivity(activity.currentToolName),
259
+ isChairman: false,
260
+ name,
261
+ });
262
+ }
263
+
264
+ return { rows, comradeNames };
265
+ }
266
+
267
+ // ── Overview render (identical to persistent widget + cursor) ──
268
+
269
+ function renderOverview(width: number): string[] {
270
+ const tasks = deps.getTasks();
271
+ const tracker = deps.getTracker();
272
+ const delegateMode = deps.isDelegateMode();
273
+ const { rows, comradeNames } = buildRows();
274
+
275
+ // Clamp cursor
276
+ if (cursorIndex >= comradeNames.length) cursorIndex = Math.max(0, comradeNames.length - 1);
277
+
278
+ const lines: string[] = [];
279
+
280
+ // Header
281
+ let header = " " + theme.bold(theme.fg("accent", "Teams"));
282
+ if (delegateMode) header += " " + theme.fg("warning", "[delegate]");
283
+ lines.push(truncateToWidth(header, width));
284
+
285
+ if (rows.length === 0) {
286
+ lines.push(
287
+ truncateToWidth(
288
+ " " + theme.fg("dim", `(no ${strings.memberTitle.toLowerCase()}s) /team spawn <name>`),
289
+ width,
290
+ ),
291
+ );
292
+ } else {
293
+ // Column widths
294
+ const totalPending = tasks.filter((t) => t.status === "pending").length;
295
+ const totalCompleted = tasks.filter((t) => t.status === "completed").length;
296
+ let totalTokensRaw = 0;
297
+ for (const name of comradeNames) totalTokensRaw += tracker.get(name).totalTokens;
298
+ const totalTokensStr = formatTokens(totalTokensRaw);
299
+
300
+ const nameColWidth = Math.max(...rows.map((r) => visibleWidth(r.displayName)));
301
+ const pW = Math.max(
302
+ ...rows.map((r) => String(r.pending).length),
303
+ String(totalPending).length,
304
+ );
305
+ const cW = Math.max(
306
+ ...rows.map((r) => String(r.completed).length),
307
+ String(totalCompleted).length,
308
+ );
309
+ const tokW = Math.max(
310
+ ...rows.map((r) => r.tokensStr.length),
311
+ totalTokensStr.length,
312
+ );
313
+
314
+ // Render rows
315
+ for (const r of rows) {
316
+ const isSelected = !r.isChairman && comradeNames.indexOf(r.name) === cursorIndex;
317
+ const pointer = isSelected ? theme.fg("accent", "\u25b8") : " ";
318
+ const icon = theme.fg(r.iconColor, r.icon);
319
+ const styledName = isSelected
320
+ ? theme.bold(theme.fg("accent", r.displayName))
321
+ : theme.bold(r.displayName);
322
+ const statusLabel = theme.fg(STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
323
+ const pNum = String(r.pending).padStart(pW);
324
+ const cNum = String(r.completed).padStart(cW);
325
+ const tokStr = r.tokensStr.padStart(tokW);
326
+ const cols = theme.fg(
327
+ "dim",
328
+ ` \u00b7 ${pNum} pending \u00b7 ${cNum} complete \u00b7 ${tokStr} tokens`,
329
+ );
330
+ const actLabel = r.activityText
331
+ ? " " + theme.fg("warning", r.activityText)
332
+ : "";
333
+
334
+ const row = `${pointer}${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${cols}${actLabel}`;
335
+ lines.push(truncateToWidth(row, width));
336
+ }
337
+
338
+ // Separator + Total
339
+ const sepLine = " " + theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
340
+ lines.push(truncateToWidth(sepLine, width));
341
+
342
+ const totalLabel = theme.bold("Total");
343
+ const totalTaskCount = totalPending + totalCompleted;
344
+ const pct =
345
+ totalTaskCount > 0 ? Math.round((totalCompleted / totalTaskCount) * 100) : 0;
346
+ const pctLabel = theme.fg("success", padRight(`${pct}%`, 9));
347
+ const tpNum = String(totalPending).padStart(pW);
348
+ const tcNum = String(totalCompleted).padStart(cW);
349
+ const ttokStr = totalTokensStr.padStart(tokW);
350
+ const totalSuffix = theme.fg(
351
+ "muted",
352
+ ` \u00b7 ${tpNum} pending \u00b7 ${tcNum} complete \u00b7 ${ttokStr} tokens`,
353
+ );
354
+ const totalRow = ` ${padRight(totalLabel, nameColWidth + 3)} ${pctLabel}${totalSuffix}`;
355
+ lines.push(truncateToWidth(totalRow, width));
356
+ }
357
+
358
+ // Notification
359
+ if (notification) {
360
+ lines.push(truncateToWidth(" " + theme.fg(notification.color, notification.text), width));
361
+ }
362
+
363
+ // Hints
364
+ const hints = theme.fg(
365
+ "dim",
366
+ " \u2191\u2193 select \u00b7 enter view \u00b7 m message \u00b7 a abort \u00b7 k kill \u00b7 esc close",
367
+ );
368
+ lines.push(truncateToWidth(hints, width));
369
+
370
+ return lines;
371
+ }
372
+
373
+ // ── Session render ──
374
+
375
+ function renderSession(width: number): string[] {
376
+ if (!sessionName) return renderOverview(width);
377
+
378
+ const rpc = deps.getTeammates().get(sessionName);
379
+ const cfg = (deps.getTeamConfig()?.members ?? []).find((m) => m.name === sessionName);
380
+ const statusKey = resolveStatus(rpc, cfg);
381
+ const activity = deps.getTracker().get(sessionName);
382
+ const tasks = deps.getTasks();
383
+ const activeTask = tasks.find(
384
+ (t) => t.owner === sessionName && t.status === "in_progress",
385
+ );
386
+ const transcript = deps.getTranscript(sessionName);
387
+
388
+ const lines: string[] = [];
389
+ const sep = theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
390
+
391
+ // Header
392
+ const icon = theme.fg(STATUS_COLOR[statusKey], STATUS_ICON[statusKey]);
393
+ const nameStr = theme.bold(theme.fg("accent", formatMemberDisplayName(style, sessionName)));
394
+ const status = theme.fg(STATUS_COLOR[statusKey], statusKey);
395
+ const tokens = theme.fg("dim", `${formatTokens(activity.totalTokens)} tokens`);
396
+ const taskLabel = activeTask
397
+ ? ` ${theme.fg("muted", "\u00b7")} ${theme.fg("dim", `#${String(activeTask.id)} ${activeTask.subject}`)}`
398
+ : "";
399
+ lines.push(truncateToWidth(` ${icon} ${nameStr} \u2014 ${status} \u00b7 ${tokens}${taskLabel}`, width));
400
+ lines.push(truncateToWidth(` ${sep}`, width));
401
+
402
+ // Format all transcript entries into rendered lines
403
+ const allTranscriptLines: string[] = [];
404
+ for (const entry of transcript.getEntries()) {
405
+ const formatted = formatTranscriptEntry(entry, theme, width);
406
+ for (const fl of formatted) {
407
+ allTranscriptLines.push(truncateToWidth(fl, width));
408
+ }
409
+ }
410
+
411
+ const totalLines = allTranscriptLines.length;
412
+
413
+ if (totalLines === 0) {
414
+ // Show current activity or waiting message when transcript is empty
415
+ if (activity.currentToolName) {
416
+ lines.push(truncateToWidth(
417
+ ` ${theme.fg("warning", toolActivity(activity.currentToolName))}`,
418
+ width,
419
+ ));
420
+ } else if (statusKey === "streaming") {
421
+ lines.push(truncateToWidth(` ${theme.fg("dim", theme.italic("thinking\u2026"))}`, width));
422
+ } else {
423
+ lines.push(truncateToWidth(` ${theme.fg("dim", theme.italic("waiting for activity\u2026"))}`, width));
424
+ }
425
+ } else {
426
+ // Determine visible window size based on terminal height
427
+ const termHeight = process.stdout.rows || 24;
428
+ // Reserve: header(2) + scrollBar(1) + notification(0-1) + hintsSep(1) + hints(1)
429
+ const notifLines = notification ? 1 : 0;
430
+ const chromeLines = 2 + 1 + notifLines + 1 + 1;
431
+ const viewportHeight = Math.max(3, termHeight - chromeLines);
432
+
433
+ // Apply scroll windowing only if content exceeds viewport
434
+ if (totalLines <= viewportHeight) {
435
+ // Everything fits — just show all lines
436
+ for (const tl of allTranscriptLines) lines.push(tl);
437
+ sessionScrollOffset = 0;
438
+ } else {
439
+ const maxScroll = totalLines - viewportHeight;
440
+
441
+ // Clamp
442
+ if (sessionScrollOffset > maxScroll) sessionScrollOffset = maxScroll;
443
+ if (sessionScrollOffset < 0) sessionScrollOffset = 0;
444
+ if (sessionAutoFollow) sessionScrollOffset = 0;
445
+
446
+ const endIndex = totalLines - sessionScrollOffset;
447
+ const startIndex = Math.max(0, endIndex - viewportHeight);
448
+ const visible = allTranscriptLines.slice(startIndex, endIndex);
449
+ for (const vl of visible) lines.push(vl);
450
+ }
451
+ }
452
+
453
+ // Scroll indicator bar
454
+ if (sessionScrollOffset > 0) {
455
+ lines.push(truncateToWidth(
456
+ ` ${theme.fg("accent", `\u2193 ${String(sessionScrollOffset)} more line${sessionScrollOffset === 1 ? "" : "s"} (g to follow)`)}`,
457
+ width,
458
+ ));
459
+ } else if (totalLines > 0) {
460
+ lines.push(truncateToWidth(
461
+ ` ${theme.fg("success", "\u25cf following")}`,
462
+ width,
463
+ ));
464
+ }
465
+
466
+ // Notification
467
+ if (notification) {
468
+ lines.push(
469
+ truncateToWidth(" " + theme.fg(notification.color, notification.text), width),
470
+ );
471
+ }
472
+
473
+ // Hints
474
+ lines.push(truncateToWidth(` ${sep}`, width));
475
+ lines.push(truncateToWidth(
476
+ theme.fg("dim", " \u2191\u2193 scroll \u00b7 g follow \u00b7 m message \u00b7 a abort \u00b7 k kill \u00b7 esc back"),
477
+ width,
478
+ ));
479
+
480
+ return lines;
481
+ }
482
+
483
+ // ── DM render ──
484
+
485
+ function renderDm(width: number): string[] {
486
+ const lines: string[] = [];
487
+ const sep = theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
488
+
489
+ lines.push(
490
+ truncateToWidth(
491
+ ` ${theme.bold(theme.fg("accent", `Message to ${formatMemberDisplayName(style, dmTarget ?? "")}`))}`,
492
+ width,
493
+ ),
494
+ );
495
+ lines.push(truncateToWidth(` ${sep}`, width));
496
+ lines.push(
497
+ truncateToWidth(` ${theme.fg("accent", "\u25b8")} ${dmBuffer}\u2588`, width),
498
+ );
499
+ lines.push(truncateToWidth(` ${sep}`, width));
500
+ lines.push(
501
+ truncateToWidth(` ${theme.fg("dim", "enter send \u00b7 esc cancel")}`, width),
502
+ );
503
+
504
+ return lines;
505
+ }
506
+
507
+ // ── Component ──
508
+
509
+ return {
510
+ render(width: number): string[] {
511
+ switch (mode) {
512
+ case "overview":
513
+ return renderOverview(width);
514
+ case "session":
515
+ return renderSession(width);
516
+ case "dm":
517
+ return renderDm(width);
518
+ }
519
+ },
520
+
521
+ handleInput(data: string): void {
522
+ // ── DM mode ──
523
+ if (mode === "dm") {
524
+ if (matchesKey(data, "escape")) {
525
+ mode = sessionName ? "session" : "overview";
526
+ dmBuffer = "";
527
+ dmTarget = null;
528
+ tui.requestRender();
529
+ return;
530
+ }
531
+ if (matchesKey(data, "enter")) {
532
+ if (dmBuffer.trim() && dmTarget) {
533
+ const msg = dmBuffer.trim();
534
+ const target = dmTarget;
535
+ void deps.sendMessage(target, msg);
536
+ showNotification(`Message sent to ${formatMemberDisplayName(style, target)}`);
537
+ dmBuffer = "";
538
+ mode = sessionName ? "session" : "overview";
539
+ dmTarget = null;
540
+ }
541
+ tui.requestRender();
542
+ return;
543
+ }
544
+ if (matchesKey(data, "backspace")) {
545
+ dmBuffer = dmBuffer.slice(0, -1);
546
+ tui.requestRender();
547
+ return;
548
+ }
549
+ // Regular character input
550
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
551
+ dmBuffer += data;
552
+ tui.requestRender();
553
+ return;
554
+ }
555
+ return;
556
+ }
557
+
558
+ // ── Session mode ──
559
+ if (mode === "session") {
560
+ if (matchesKey(data, "escape")) {
561
+ mode = "overview";
562
+ sessionName = null;
563
+ tui.requestRender();
564
+ return;
565
+ }
566
+ if (matchesKey(data, "up")) {
567
+ sessionScrollOffset += 1;
568
+ sessionAutoFollow = false;
569
+ tui.requestRender();
570
+ return;
571
+ }
572
+ if (matchesKey(data, "down")) {
573
+ sessionScrollOffset = Math.max(0, sessionScrollOffset - 1);
574
+ if (sessionScrollOffset === 0) sessionAutoFollow = true;
575
+ tui.requestRender();
576
+ return;
577
+ }
578
+ if (matchesKey(data, "pageUp")) {
579
+ const h = process.stdout.rows || 24;
580
+ const jump = Math.max(1, Math.floor(h / 2));
581
+ sessionScrollOffset += jump;
582
+ sessionAutoFollow = false;
583
+ tui.requestRender();
584
+ return;
585
+ }
586
+ if (matchesKey(data, "pageDown")) {
587
+ const h = process.stdout.rows || 24;
588
+ const jump = Math.max(1, Math.floor(h / 2));
589
+ sessionScrollOffset = Math.max(0, sessionScrollOffset - jump);
590
+ if (sessionScrollOffset === 0) sessionAutoFollow = true;
591
+ tui.requestRender();
592
+ return;
593
+ }
594
+ if (data === "g" || matchesKey(data, "end")) {
595
+ sessionScrollOffset = 0;
596
+ sessionAutoFollow = true;
597
+ tui.requestRender();
598
+ return;
599
+ }
600
+ if (data === "m") {
601
+ dmTarget = sessionName;
602
+ mode = "dm";
603
+ dmBuffer = "";
604
+ tui.requestRender();
605
+ return;
606
+ }
607
+ if (data === "a") {
608
+ if (sessionName) {
609
+ deps.abortComrade(sessionName);
610
+ showNotification(`Abort sent to ${formatMemberDisplayName(style, sessionName)}`, "warning");
611
+ }
612
+ return;
613
+ }
614
+ if (data === "k") {
615
+ if (sessionName) {
616
+ const name = sessionName;
617
+ deps.killComrade(name);
618
+ showNotification(`${formatMemberDisplayName(style, name)} ${strings.killedVerb}`, "error");
619
+ mode = "overview";
620
+ sessionName = null;
621
+ tui.requestRender();
622
+ }
623
+ return;
624
+ }
625
+ return;
626
+ }
627
+
628
+ // ── Overview mode ──
629
+ const comradeNames = getComradeNames(deps);
630
+
631
+ if (matchesKey(data, "escape") || data === "q") {
632
+ done(undefined);
633
+ return;
634
+ }
635
+ if (matchesKey(data, "up")) {
636
+ cursorIndex = Math.max(0, cursorIndex - 1);
637
+ tui.requestRender();
638
+ return;
639
+ }
640
+ if (matchesKey(data, "down")) {
641
+ cursorIndex = Math.min(comradeNames.length - 1, cursorIndex + 1);
642
+ tui.requestRender();
643
+ return;
644
+ }
645
+ if (matchesKey(data, "enter")) {
646
+ const name = comradeNames[cursorIndex];
647
+ if (name) {
648
+ sessionName = name;
649
+ mode = "session";
650
+ sessionScrollOffset = 0;
651
+ sessionAutoFollow = true;
652
+ tui.requestRender();
653
+ }
654
+ return;
655
+ }
656
+ if (data === "m") {
657
+ const name = comradeNames[cursorIndex];
658
+ if (name) {
659
+ dmTarget = name;
660
+ mode = "dm";
661
+ dmBuffer = "";
662
+ tui.requestRender();
663
+ }
664
+ return;
665
+ }
666
+ if (data === "a") {
667
+ const name = comradeNames[cursorIndex];
668
+ if (name) {
669
+ deps.abortComrade(name);
670
+ showNotification(`Abort sent to ${formatMemberDisplayName(style, name)}`, "warning");
671
+ }
672
+ return;
673
+ }
674
+ if (data === "k") {
675
+ const name = comradeNames[cursorIndex];
676
+ if (name) {
677
+ deps.killComrade(name);
678
+ showNotification(`${formatMemberDisplayName(style, name)} ${strings.killedVerb}`, "error");
679
+ tui.requestRender();
680
+ }
681
+ return;
682
+ }
683
+ },
684
+
685
+ invalidate() {},
686
+
687
+ dispose() {
688
+ clearInterval(refreshInterval);
689
+ if (notificationTimer) clearTimeout(notificationTimer);
690
+ },
691
+ };
692
+ },
693
+ {},
694
+ );
695
+ } finally {
696
+ deps.restoreWidget();
697
+ }
698
+ }