@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.
- package/.github/workflows/ci.yml +32 -0
- package/README.md +36 -6
- package/docs/claude-parity.md +16 -14
- package/docs/field-notes-teams-setup.md +6 -4
- package/docs/smoke-test-plan.md +130 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/fs-lock.ts +21 -5
- package/extensions/teams/leader-inbox.ts +175 -0
- package/extensions/teams/leader-info-commands.ts +139 -0
- package/extensions/teams/leader-lifecycle-commands.ts +330 -0
- package/extensions/teams/leader-messaging-commands.ts +149 -0
- package/extensions/teams/leader-plan-commands.ts +96 -0
- package/extensions/teams/leader-spawn-command.ts +73 -0
- package/extensions/teams/leader-task-commands.ts +417 -0
- package/extensions/teams/leader-teams-tool.ts +238 -0
- package/extensions/teams/leader.ts +396 -1422
- package/extensions/teams/mailbox.ts +54 -29
- package/extensions/teams/names.ts +87 -0
- package/extensions/teams/protocol.ts +221 -0
- package/extensions/teams/task-store.ts +32 -21
- package/extensions/teams/team-config.ts +71 -25
- package/extensions/teams/teammate-rpc.ts +56 -22
- package/extensions/teams/teams-panel.ts +698 -0
- package/extensions/teams/teams-style.ts +62 -0
- package/extensions/teams/teams-widget.ts +235 -0
- package/extensions/teams/worker.ts +100 -138
- package/extensions/teams/worktree.ts +4 -7
- package/package.json +25 -3
- package/scripts/integration-claim-test.mts +227 -0
- package/scripts/integration-todo-test.mts +583 -0
- package/scripts/smoke-test.mjs +1 -1
- package/scripts/smoke-test.mts +424 -0
- package/skills/agent-teams/SKILL.md +136 -0
- package/tsconfig.strict.json +22 -0
- 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
|
+
}
|