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.
- package/AGENTS.md +14 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/docs/claude-parity.md +151 -0
- package/docs/field-notes-teams-setup.md +107 -0
- package/docs/smoke-test-plan.md +146 -0
- package/eslint.config.js +74 -0
- package/extensions/teams/README.md +23 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/cleanup.ts +31 -0
- package/extensions/teams/fs-lock.ts +87 -0
- package/extensions/teams/hooks.ts +363 -0
- package/extensions/teams/index.ts +18 -0
- package/extensions/teams/leader-attach-commands.ts +221 -0
- package/extensions/teams/leader-inbox.ts +214 -0
- package/extensions/teams/leader-info-commands.ts +140 -0
- package/extensions/teams/leader-lifecycle-commands.ts +559 -0
- package/extensions/teams/leader-messaging-commands.ts +148 -0
- package/extensions/teams/leader-plan-commands.ts +95 -0
- package/extensions/teams/leader-spawn-command.ts +149 -0
- package/extensions/teams/leader-task-commands.ts +435 -0
- package/extensions/teams/leader-team-command.ts +382 -0
- package/extensions/teams/leader-teams-tool.ts +1075 -0
- package/extensions/teams/leader.ts +925 -0
- package/extensions/teams/mailbox.ts +131 -0
- package/extensions/teams/model-policy.ts +142 -0
- package/extensions/teams/names.ts +121 -0
- package/extensions/teams/paths.ts +37 -0
- package/extensions/teams/protocol.ts +241 -0
- package/extensions/teams/spawn-types.ts +36 -0
- package/extensions/teams/task-store.ts +544 -0
- package/extensions/teams/team-attach-claim.ts +205 -0
- package/extensions/teams/team-config.ts +335 -0
- package/extensions/teams/team-discovery.ts +59 -0
- package/extensions/teams/teammate-rpc.ts +261 -0
- package/extensions/teams/teams-panel.ts +1186 -0
- package/extensions/teams/teams-style.ts +322 -0
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +212 -0
- package/extensions/teams/worker.ts +605 -0
- package/extensions/teams/worktree.ts +103 -0
- package/package.json +53 -0
- package/scripts/e2e-rpc-test.mjs +277 -0
- package/scripts/integration-claim-test.mts +157 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +398 -0
- package/scripts/integration-todo-test.mts +533 -0
- package/scripts/lib/pi-workers.ts +105 -0
- package/scripts/smoke-test.mts +764 -0
- package/scripts/start-tmux-team.sh +91 -0
- package/skills/agent-teams/SKILL.md +180 -0
- 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
|
+
}
|