ac-teams 0.0.15 → 0.0.17
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/README.md +1 -1
- package/docs/operations.md +2 -0
- package/docs/usage.md +21 -21
- package/package.json +1 -1
- package/src/coordinator.ts +31 -15
- package/src/graph.ts +13 -4
- package/src/group.ts +134 -0
- package/src/index.ts +37 -12
- package/src/panel.ts +131 -50
- package/src/render.ts +16 -14
- package/src/schemas.ts +12 -3
- package/src/store.ts +76 -20
- package/src/types.ts +27 -4
package/src/panel.ts
CHANGED
|
@@ -2,17 +2,22 @@ import { basename } from "node:path";
|
|
|
2
2
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Editor, indexedColor, matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
4
|
import { contextSummary } from "./telemetry.ts";
|
|
5
|
-
import { buildTaskGraph, moveTaskSelection, taskStatus } from "./graph.ts";
|
|
5
|
+
import { buildTaskGraph, moveTaskSelection, taskStatus, type TaskGraph } from "./graph.ts";
|
|
6
6
|
import { cleanText as clean } from "./render.ts";
|
|
7
7
|
import type { PanelHost } from "./ui-types.ts";
|
|
8
|
-
import { taskName, type TaskView } from "./types.ts";
|
|
8
|
+
import { taskName, type Message, type TaskView } from "./types.ts";
|
|
9
9
|
|
|
10
10
|
export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Promise<void> {
|
|
11
11
|
if (ctx.mode !== "tui") throw new Error("Teams panel requires interactive terminal mode");
|
|
12
12
|
let snapshot = await host.snapshot();
|
|
13
13
|
let tasks = snapshot.tasks;
|
|
14
14
|
let events = snapshot.messages.slice().reverse();
|
|
15
|
-
|
|
15
|
+
// Zoom is the title width each box gets, from the longest title (100%) down to none (number and status only).
|
|
16
|
+
const longest = () => Math.max(1, ...tasks.map(task => visibleWidth(clean(task.title))));
|
|
17
|
+
let graphs = new Map<number, TaskGraph | null>();
|
|
18
|
+
const graphAt = (budget: number) => { if (!graphs.has(budget)) graphs.set(budget, buildTaskGraph(tasks, budget)); return graphs.get(budget)!; };
|
|
19
|
+
let zoom: number | "fit" = "fit", shownZoom = longest();
|
|
20
|
+
let taskGraph = graphAt(shownZoom);
|
|
16
21
|
const drafts = host.drafts;
|
|
17
22
|
const name = (id: string | undefined) => id === "root" ? "Main" : clean(snapshot.members.find(member => member.id === id)?.name ?? id ?? "Unassigned");
|
|
18
23
|
const creator = (task: TaskView) => (task as TaskView & { creator?: string }).creator ?? "root";
|
|
@@ -26,7 +31,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
26
31
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
27
32
|
let focused = true;
|
|
28
33
|
let memberQuery = "", searching = false, hideStopped = false;
|
|
29
|
-
const tabs = ["Tasks", "Teammates", "
|
|
34
|
+
const tabs = ["Tasks", "Teammates", "Board"];
|
|
30
35
|
const selectedTask = () => tasks[taskIndex];
|
|
31
36
|
const memberIds = () => ["root", ...snapshot.members.filter(member => member.id !== "root").map(member => member.id)]
|
|
32
37
|
.filter(id => name(id).toLowerCase().includes(memberQuery.toLowerCase())
|
|
@@ -46,7 +51,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
46
51
|
taskIndex = Math.max(0, tasks.findIndex(task => task.id === taskId));
|
|
47
52
|
memberIndex = Math.max(0, memberIds().indexOf(memberId));
|
|
48
53
|
eventIndex = Math.max(0, events.findIndex(event => event.id === eventId));
|
|
49
|
-
if (changed) taskGraph =
|
|
54
|
+
if (changed) { graphs = new Map(); taskGraph = graphAt(Math.min(shownZoom, longest())); }
|
|
50
55
|
} catch (error) { if (!disposed) receipt = `Refresh failed: ${clean(String(error))}`; }
|
|
51
56
|
finally { if (!disposed) { refresh(); timer = setTimeout(() => void poll(), 2000); } }
|
|
52
57
|
}
|
|
@@ -60,6 +65,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
60
65
|
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
61
66
|
};
|
|
62
67
|
const heading = (text: string) => theme.bold(fg("text", text));
|
|
68
|
+
const tableHeader = (text: string) => theme.bold(theme.underline(fg("accent", text)));
|
|
63
69
|
const label = (key: string, value: string) => fg("muted", key.padEnd(9) + " ") + value;
|
|
64
70
|
const wrap = (lines: string[], width: number) => lines.flatMap(line => wrapTextWithAnsi(line, Math.max(1, width)));
|
|
65
71
|
const editor = new Editor(tui, {
|
|
@@ -115,23 +121,36 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
115
121
|
const task = selectedTask();
|
|
116
122
|
if (!task) return wrap([heading("No tasks yet"), "Request a task with N. Main creates and organizes it."], width);
|
|
117
123
|
const unlocked = tasks.filter(other => other.dependencies.includes(task.id));
|
|
124
|
+
const outcome = task.outcome ? snapshot.messages.find(message => message.id === task.outcome) : undefined;
|
|
125
|
+
const [first = "", ...rest] = outcome ? clean(outcome.summary).split("\n") : [];
|
|
126
|
+
const prerequisites = task.dependencies.map(id => {
|
|
127
|
+
const parent = tasks.find(item => item.id === id);
|
|
128
|
+
return parent ? `${taskTitle(parent.id)} · ${stateLabel(parent)}` : `${clean(id)} · Unavailable`;
|
|
129
|
+
});
|
|
118
130
|
return wrap([
|
|
119
|
-
heading(
|
|
120
|
-
label("
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"",
|
|
129
|
-
fg("accent", "C Give feedback to creator"), fg("accent", "O Open owner's full session"),
|
|
130
|
-
...(!detail ? [fg("muted", "Enter Full details")] : []),
|
|
131
|
+
heading(taskTitle(task.id)) + fg("muted", " · ") + stateLabel(task), "",
|
|
132
|
+
label("Owner", name(task.owner)), ...(task.delegator ? [label("Delegator", name(task.delegator))] : []),
|
|
133
|
+
label("Creator", name(creator(task))), "",
|
|
134
|
+
...(outcome ? [section(`Outcome from ${name(outcome.from)}`, width), theme.bold(first), ...rest, ...(outcome.detail ? ["", fg("muted", clean(outcome.detail))] : []), ""] : []),
|
|
135
|
+
section("Description", width), clean(task.description), "",
|
|
136
|
+
section("Dependencies", width),
|
|
137
|
+
label("Needs", prerequisites.length ? prerequisites.join(", ") : fg("muted", "Nothing; can start now")),
|
|
138
|
+
label("Unlocks", unlocked.length ? unlocked.map(item => taskTitle(item.id)).join(", ") : fg("muted", "No other tasks")), "",
|
|
139
|
+
section("Actions", width), fg("accent", "C") + " Give feedback to creator", fg("accent", "O") + " Open owner's session", "",
|
|
140
|
+
fg("dim", `ID ${task.id}`),
|
|
131
141
|
], width);
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
function graphLines(width: number) {
|
|
145
|
+
if (zoom === "fit") {
|
|
146
|
+
// Largest title width that still fits the pane (binary search; graphs are cached per width).
|
|
147
|
+
const fits = (budget: number) => { const g = graphAt(budget); return !!g && g.art.width <= width && g.art.plain.length <= bodyHeight - 1; };
|
|
148
|
+
let low = 0, high = longest();
|
|
149
|
+
if (fits(high)) low = high;
|
|
150
|
+
else while (low < high - 1) { const mid = (low + high) >> 1; if (fits(mid)) low = mid; else high = mid; }
|
|
151
|
+
shownZoom = low;
|
|
152
|
+
} else shownZoom = zoom;
|
|
153
|
+
taskGraph = graphAt(shownZoom);
|
|
135
154
|
if (!taskGraph) return undefined;
|
|
136
155
|
const { art, bounds } = taskGraph;
|
|
137
156
|
const selected = bounds.get(selectedTask()?.id ?? "");
|
|
@@ -141,7 +160,8 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
141
160
|
else if (selected.right >= columnScroll + width) columnScroll = selected.right - width + 1;
|
|
142
161
|
columnScroll = Math.max(0, Math.min(columnScroll, Math.max(0, art.width - width)));
|
|
143
162
|
const rows = art.styled.map((spans, y) => {
|
|
144
|
-
|
|
163
|
+
const row = spans.map(span => fg(span.cls === "border" || span.cls === "edge" ? "dim" : "text", span.text)).join("");
|
|
164
|
+
const replacements: { left: number; right: number; text: string }[] = [];
|
|
145
165
|
for (const task of tasks) {
|
|
146
166
|
const box = bounds.get(task.id);
|
|
147
167
|
if (!box || y < box.top || y > box.bottom) continue;
|
|
@@ -150,14 +170,24 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
150
170
|
if (active && y === box.top + 1) text = text.replace("○ ", "● ");
|
|
151
171
|
const border = y === box.top || y === box.bottom;
|
|
152
172
|
const content = sliceByColumn(text, 1, box.right - box.left - 1), status = taskStatus(task);
|
|
153
|
-
const color = status === "Done" ? "dim" : "text";
|
|
173
|
+
const color = active ? "accent" : status === "Done" ? "dim" : "text";
|
|
154
174
|
const index = y === box.bottom - 1 && content.trimEnd().endsWith(status) ? content.lastIndexOf(status) : -1;
|
|
155
175
|
const interior = index < 0 ? fg(color, content) : fg(color, content.slice(0, index)) + stateLabel(task) + fg(color, content.slice(index + status.length));
|
|
156
|
-
|
|
157
|
-
|
|
176
|
+
// The selected box keeps its status colour but gets a heavy border and bold text, so selection never competes with status.
|
|
177
|
+
const edge = (part: string) => statusText(task, active ? part.replace(/[┌┐└┘─│┬┴]/g, ch => ({ "┌": "┏", "┐": "┓", "└": "┗", "┘": "┛", "─": "━", "│": "┃", "┬": "┯", "┴": "┷" })[ch]!) : part);
|
|
178
|
+
const styled = border ? edge(text) : edge("│") + (active ? theme.bold(interior) : interior) + edge("│");
|
|
179
|
+
replacements.push({ left: box.left, right: box.right, text: styled });
|
|
158
180
|
}
|
|
181
|
+
// Slice the original row once per gap; re-slicing styled replacements multiplies ANSI codes.
|
|
182
|
+
let column = 0;
|
|
183
|
+
const parts = replacements.sort((a, b) => a.left - b.left).flatMap(box => {
|
|
184
|
+
const gap = sliceByColumn(row, column, box.left - column);
|
|
185
|
+
column = box.right + 1;
|
|
186
|
+
return [gap, box.text];
|
|
187
|
+
});
|
|
188
|
+
parts.push(sliceByColumn(row, column, art.width - column));
|
|
159
189
|
const indent = " ".repeat(Math.max(0, Math.floor((width - art.width) / 2)));
|
|
160
|
-
return indent + sliceByColumn(
|
|
190
|
+
return indent + sliceByColumn(parts.join(""), columnScroll, width);
|
|
161
191
|
});
|
|
162
192
|
return { rows, selectedRow: selected.top, selectedHeight: selected.bottom - selected.top + 1, width: art.width };
|
|
163
193
|
}
|
|
@@ -184,11 +214,13 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
184
214
|
let selectedHeight = 1, graphWidth = 0;
|
|
185
215
|
if (tab === 0) {
|
|
186
216
|
const graphData = graph ? graphLines(leftWidth) : undefined;
|
|
187
|
-
|
|
217
|
+
const titleWidth = Math.max(1, leftWidth - 12);
|
|
218
|
+
left = graphData ? graphData.rows : tasks.length ? [tableHeader(` ${pad("Task", titleWidth)} State`), "",
|
|
188
219
|
...tasks.map((task, index) => {
|
|
189
|
-
const
|
|
190
|
-
const body = fg(taskStatus(task) === "Done" ? "dim" : "text",
|
|
191
|
-
return (
|
|
220
|
+
const selected = index === taskIndex;
|
|
221
|
+
const body = fg(taskStatus(task) === "Done" ? "dim" : "text", pad(taskTitle(task.id), titleWidth));
|
|
222
|
+
return fg(selected ? "accent" : "dim", `${selected ? "●" : " "} `)
|
|
223
|
+
+ (selected ? theme.bold(body) : body) + " " + stateLabel(task);
|
|
192
224
|
})] : [heading("No tasks yet"), "N Request a task from Main"];
|
|
193
225
|
selectedRow = graphData ? graphData.selectedRow : taskIndex + 2;
|
|
194
226
|
selectedHeight = graphData?.selectedHeight ?? 1;
|
|
@@ -211,8 +243,8 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
211
243
|
const nameExtra = Math.min(spare, Math.max(0, ...ids.map(id => visibleWidth(name(id)) - nameWidth)));
|
|
212
244
|
const gap = " ".repeat(4 + Math.floor((spare - nameExtra) / gaps));
|
|
213
245
|
nameWidth += nameExtra + (spare - nameExtra) % gaps;
|
|
214
|
-
left = [
|
|
215
|
-
+ (showTasks ? `${gap}${pad("
|
|
246
|
+
left = [tableHeader(` ${pad("Member", nameWidth)}${gap}${pad("State", sessionWidth)}`
|
|
247
|
+
+ (showTasks ? `${gap}${pad("Done", tasksWidth)}` : "") + (showDirectory ? `${gap}Directory` : "")), ...ids.map((id, index) => {
|
|
216
248
|
const member = snapshot.members.find(member => member.id === id);
|
|
217
249
|
const selected = index === memberIndex;
|
|
218
250
|
const stopped = member && ["stopped", "interrupted"].includes(member.status);
|
|
@@ -230,11 +262,19 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
230
262
|
selectedRow = memberIndex + 1;
|
|
231
263
|
right = split ? memberDetails(rightWidth) : [];
|
|
232
264
|
} else {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
265
|
+
// One row per post: who, what kind, its summary headline, then task and time as quieter context.
|
|
266
|
+
const fromWidth = Math.min(16, Math.max(4, ...events.map(event => visibleWidth(name(event.from)))));
|
|
267
|
+
const taskWidth = Math.min(5, Math.max(4, ...events.map(event => event.task ? visibleWidth(taskRef(event.task)) : 0)));
|
|
268
|
+
const summaryWidth = Math.max(1, leftWidth - (2 + fromWidth + 2 + 7 + 2 + 2 + taskWidth + 2 + 5));
|
|
269
|
+
left = events.length ? [tableHeader(` ${pad("From", fromWidth)} ${pad("Kind", 7)} ${pad("Summary", summaryWidth)} ${pad("Task", taskWidth)} Time`),
|
|
270
|
+
...events.map((event, index) => {
|
|
271
|
+
const selected = index === eventIndex;
|
|
272
|
+
const from = pad(name(event.from), fromWidth), summary = pad(headline(event), summaryWidth);
|
|
273
|
+
return fg(selected ? "accent" : "dim", `${selected ? "●" : " "} `) + (selected ? theme.bold(fg("accent", from)) : from) + " "
|
|
274
|
+
+ kindLabel(event) + " " + (selected ? theme.bold(summary) : summary) + " "
|
|
275
|
+
+ fg("muted", pad(event.task ? taskRef(event.task) : "", taskWidth)) + " " + fg("dim", clock(event.time).slice(0, 5));
|
|
276
|
+
})] : [heading("No posts yet"), fg("muted", "Updates, questions, replies, and outcomes appear here.")];
|
|
277
|
+
selectedRow = eventIndex + 1;
|
|
238
278
|
right = split ? eventDetails(rightWidth) : [];
|
|
239
279
|
}
|
|
240
280
|
|
|
@@ -243,8 +283,12 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
243
283
|
if (selectedRow + selectedHeight > scroll + viewHeight) scroll = selectedRow + selectedHeight - viewHeight;
|
|
244
284
|
scroll = Math.max(0, Math.min(scroll, Math.max(0, left.length - viewHeight)));
|
|
245
285
|
const viewport = left.slice(scroll, scroll + viewHeight);
|
|
246
|
-
if (graphWidth)
|
|
247
|
-
|
|
286
|
+
if (graphWidth) {
|
|
287
|
+
const hidden = scroll || left.length > scroll + viewHeight || graphWidth > leftWidth;
|
|
288
|
+
viewport.unshift(fg("muted", `Zoom ${Math.round(100 * shownZoom / longest())}%${zoom === "fit" ? " (fit)" : ""}`
|
|
289
|
+
+ (hidden ? ` · ↑ ${scroll} rows · ↓ ${Math.max(0, left.length - scroll - viewHeight)} rows` : "")
|
|
290
|
+
+ (graphWidth > leftWidth ? ` · ← ${columnScroll} cols · ${Math.max(0, graphWidth - columnScroll - leftWidth)} cols →` : "")));
|
|
291
|
+
}
|
|
248
292
|
return Array.from({ length: bodyHeight }, (_, index) => {
|
|
249
293
|
let row = viewport[index] ?? "";
|
|
250
294
|
if (!graphWidth && index === 0 && scroll > 0) row = fg("muted", `↑ ${scroll} rows above`);
|
|
@@ -253,10 +297,39 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
253
297
|
});
|
|
254
298
|
}
|
|
255
299
|
|
|
300
|
+
/** Task number and full title; labels are too short to say what a post is about. */
|
|
301
|
+
function taskTitle(id: string) {
|
|
302
|
+
const task = tasks.find(item => item.id === id);
|
|
303
|
+
return task ? clean(`#${task.number} ${task.title}`) : clean(id);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const headline = (event: Message) => clean(event.summary).split("\n")[0];
|
|
307
|
+
const taskRef = (id: string) => { const task = tasks.find(item => item.id === id); return task ? `#${task.number}` : "#?"; };
|
|
308
|
+
const kindLabel = (event: Message) => event.kind === "outcome" ? fg("success", pad("Outcome", 7))
|
|
309
|
+
: event.replyTo ? fg("accent", pad("Reply", 7)) : fg("muted", pad("Post", 7));
|
|
310
|
+
// A labelled rule separates tiers more clearly than bold text alone.
|
|
311
|
+
const section = (title: string, width: number) => theme.bold(fg("accent", title)) + " " + fg("dim", "─".repeat(Math.max(0, width - visibleWidth(title) - 1)));
|
|
312
|
+
const post = (id: string | undefined) => snapshot.messages.find(message => message.id === id);
|
|
313
|
+
const clock = (time: string) => { const date = new Date(time); return isNaN(+date) ? clean(time) : date.toTimeString().slice(0, 8); };
|
|
314
|
+
|
|
315
|
+
// Tiers: headline (first summary line), the rest of the summary, then optional detail and the thread around it.
|
|
256
316
|
function eventDetails(width: number) {
|
|
257
317
|
const event = events[eventIndex];
|
|
258
|
-
if (!event) return wrap([heading("No
|
|
259
|
-
|
|
318
|
+
if (!event) return wrap([heading("No posts yet"), "Updates, questions, replies, and outcomes appear here."], width);
|
|
319
|
+
const [first = "", ...rest] = clean(event.summary).split("\n");
|
|
320
|
+
const parent = post(event.replyTo), task = event.task ? tasks.find(item => item.id === event.task) : undefined;
|
|
321
|
+
const replies = snapshot.messages.filter(message => message.replyTo === event.id);
|
|
322
|
+
const date = new Date(event.time);
|
|
323
|
+
return wrap([
|
|
324
|
+
kindLabel(event).trimEnd() + fg("muted", ` from ${name(event.from)}`),
|
|
325
|
+
label("To", event.recipients.map(name).join(", ") || "Team"),
|
|
326
|
+
...(event.task ? [label("Task", `${taskTitle(event.task)}${task ? ` · ${stateLabel(task)}` : ""}`)] : []),
|
|
327
|
+
...(parent ? [label("Reply to", `${name(parent.from)}: ${headline(parent)}`)] : []),
|
|
328
|
+
label("Time", isNaN(+date) ? clean(event.time) : date.toLocaleString()), "",
|
|
329
|
+
section("Summary", width), theme.bold(first), ...rest,
|
|
330
|
+
...(event.detail ? ["", section("Detail", width), clean(event.detail)] : []),
|
|
331
|
+
...(replies.length ? ["", section(`Replies (${replies.length})`, width), ...replies.map(reply => `${name(reply.from)}: ${headline(reply)}`)] : []),
|
|
332
|
+
], width);
|
|
260
333
|
}
|
|
261
334
|
|
|
262
335
|
function memberDetails(width: number) {
|
|
@@ -265,20 +338,21 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
265
338
|
if (!id) return wrap([heading("No matching teammates"), "Change the name filter or show stopped members."], width);
|
|
266
339
|
const member = snapshot.members.find(member => member.id === id);
|
|
267
340
|
const assigned = tasks.filter(task => task.owner === id);
|
|
268
|
-
|
|
341
|
+
const stopped = member && ["stopped", "interrupted"].includes(member.status);
|
|
342
|
+
return wrap([
|
|
343
|
+
heading(name(id)) + fg("muted", " · ") + (member?.needsInput ? fg("error", "Needs input") : clean(member?.status ?? (id === "root" ? "Main conversation" : "Unavailable")).replace(/^./, char => char.toUpperCase())), "",
|
|
269
344
|
label("Model", clean(member?.modelName || member?.model?.slice(member.model.indexOf("/") + 1) || "Unknown")),
|
|
270
345
|
label("Context", clean(contextSummary(member?.contextUsage).replace(/^context unknown/, "Unknown"))),
|
|
271
346
|
label("Directory", clean(member?.cwd ?? (id === host.self ? ctx.cwd : "Unavailable"))),
|
|
272
|
-
label("
|
|
273
|
-
|
|
274
|
-
...assigned.map(task => `${
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
"",
|
|
280
|
-
label("
|
|
281
|
-
label("Worktree", member?.worktree ? clean(`${member.worktree.path} (${member.worktree.branch})`) : "None"),
|
|
347
|
+
...(member?.error ? [label("Error", fg("error", clean(member.error)))] : []), "",
|
|
348
|
+
section(`Tasks (${assigned.filter(task => task.status === "done").length} of ${assigned.length} done)`, width),
|
|
349
|
+
...(assigned.length ? assigned.map(task => `${taskTitle(task.id)} · ${stateLabel(task)}`) : [fg("muted", "No assigned tasks.")]), "",
|
|
350
|
+
section("Actions", width), fg("accent", "Enter") + (stopped ? " Resume and open session" : " Open session"),
|
|
351
|
+
...(!host.child && member?.role === "member" ? [fg("accent", "P") + " Promote to main session " + fg("muted", "(stops the member, reopens its transcript as a main session)")] : []), "",
|
|
352
|
+
section("Session", width),
|
|
353
|
+
fg("muted", label("ID", id)),
|
|
354
|
+
fg("muted", label("File", clean(member?.sessionFile ?? member?.sessionId ?? "Unavailable"))),
|
|
355
|
+
fg("muted", label("Worktree", member?.worktree ? clean(`${member.worktree.path} (${member.worktree.branch})`) : "None")),
|
|
282
356
|
], width);
|
|
283
357
|
}
|
|
284
358
|
|
|
@@ -297,7 +371,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
297
371
|
: compose ? "Enter Send · Shift+Enter Newline · Esc Keep draft"
|
|
298
372
|
: detail ? tab === 0 ? "↑↓ Scroll · C Feedback · O Open owner · Esc Back"
|
|
299
373
|
: tab === 1 ? "↑↓ Scroll · Enter Open session · Esc Back" : "↑↓ Scroll · O Open related session · Esc Back"
|
|
300
|
-
: tab === 0 ? `${graph ? "↑↓←→" : "↑↓"} Select · Enter Details · G Graph/List · C Feedback · N New task · Tab Next · Esc Close`
|
|
374
|
+
: tab === 0 ? `${graph ? "↑↓←→" : "↑↓"} Select · Enter Details · G Graph/List${graph ? " · +/- Zoom · F Fit" : ""} · C Feedback · N New task · Tab Next · Esc Close`
|
|
301
375
|
: tab === 1 ? `${filters} · ↑↓ Select · → Details · Enter Open session · N New task · Tab Next · Esc Close`
|
|
302
376
|
: "↑↓ Select · Enter Details · O Open related session · Tab Next · Esc Close";
|
|
303
377
|
const teammates = snapshot.members.filter(member => member.id !== "root");
|
|
@@ -307,7 +381,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
307
381
|
const summaryText = tab === 0
|
|
308
382
|
? `${tasks.length} tasks · ${["Working", "Ready", "Blocked", "Done"].map(state => `${tasks.filter(task => taskStatus(task) === state).length} ${state.toLowerCase()}`).join(" · ")}`
|
|
309
383
|
: tab === 1 ? `${teammates.length} teammate${teammates.length === 1 ? "" : "s"} · ${["working", "idle", "stopped", "needs input"].map(state => `${memberStates.filter(value => value === state).length} ${state}`).join(" · ")}`
|
|
310
|
-
: `${events.length}
|
|
384
|
+
: `${events.length} post${events.length === 1 ? "" : "s"} · ${events.filter(event => event.kind === "outcome").length} outcomes · ${events.filter(event => event.replyTo).length} replies`;
|
|
311
385
|
const summary = wrapTextWithAnsi(fg("muted", summaryText), inner);
|
|
312
386
|
const receipts = receipt ? wrapTextWithAnsi(fg("muted", receipt), inner) : [];
|
|
313
387
|
const hints = wrapTextWithAnsi(fg("dim", help), inner);
|
|
@@ -360,6 +434,13 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
360
434
|
else if (tab === 1) memberIndex = Math.max(0, Math.min(memberIds().length - 1, memberIndex + delta));
|
|
361
435
|
else eventIndex = Math.max(0, Math.min(events.length - 1, eventIndex + delta));
|
|
362
436
|
} else if (data.toLowerCase() === "g" && tab === 0 && !detail) { graph = !graph; scroll = 0; }
|
|
437
|
+
else if (tab === 0 && graph && !detail && ["+", "=", "-"].includes(data)) {
|
|
438
|
+
// Steps of 1.25x title width, like a smooth zoom; always at least one column so each press shows a change.
|
|
439
|
+
const at = zoom === "fit" ? shownZoom : zoom;
|
|
440
|
+
zoom = data === "-" ? Math.max(0, Math.min(at - 1, Math.floor(at / 1.25)))
|
|
441
|
+
: Math.min(longest(), Math.max(at + 1, Math.ceil(at * 1.25)));
|
|
442
|
+
scroll = 0; columnScroll = 0;
|
|
443
|
+
} else if (tab === 0 && graph && !detail && data.toLowerCase() === "f") { zoom = "fit"; scroll = 0; columnScroll = 0; }
|
|
363
444
|
else if (data.toLowerCase() === "n") begin("new");
|
|
364
445
|
else if (data.toLowerCase() === "c" && tab === 0) begin("feedback");
|
|
365
446
|
else if (data.toLowerCase() === "p" && tab === 1 && !host.child) {
|
package/src/render.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { stripVTControlCharacters } from "node:util";
|
|
2
2
|
import { keyHint, type MessageRenderer, type Theme, type ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Container, Text, TruncatedText, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Box, Container, Text, TruncatedText, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
4
4
|
|
|
5
5
|
export const cleanText = (text: string): string => stripVTControlCharacters(text).replaceAll("\t", " ").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
|
|
6
6
|
const inline = (value: unknown) => cleanText(String(value)).replace(/[\n\r\t]/g, " ");
|
|
@@ -62,7 +62,7 @@ function toolSummary(data: unknown, theme: Theme, labels: Readonly<Record<string
|
|
|
62
62
|
const value = data as Record<string, unknown>;
|
|
63
63
|
if (Array.isArray(value.members)) return preview(value.members, `${value.members.length} members`, theme, labels);
|
|
64
64
|
if (Array.isArray(value.tasks)) return preview(value.tasks, value.total === undefined ? `${value.tasks.length} tasks` : `${value.tasks.length} of ${value.total} tasks`, theme, labels);
|
|
65
|
-
const label = value.title ?? value.name ?? value.brief ?? value.text ?? value.query ?? (Array.isArray(value.recipients) ? undefined : value.id === undefined ? undefined : memberLabel(value.id, labels));
|
|
65
|
+
const label = value.title ?? value.name ?? value.brief ?? value.summary ?? value.text ?? value.query ?? (Array.isArray(value.recipients) ? undefined : value.id === undefined ? undefined : memberLabel(value.id, labels));
|
|
66
66
|
const status = value.status === undefined ? "" : inline(value.status);
|
|
67
67
|
const color = status === "done" ? "success"
|
|
68
68
|
: status === "interrupted" ? "error"
|
|
@@ -75,7 +75,7 @@ function toolSummary(data: unknown, theme: Theme, labels: Readonly<Record<string
|
|
|
75
75
|
}
|
|
76
76
|
return parts.filter(Boolean).join(" ");
|
|
77
77
|
}
|
|
78
|
-
return data === undefined || data === null ? "" :
|
|
78
|
+
return data === undefined || data === null ? "" : memberLabel(data, labels);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export function teamToolRenderers(name: string, getLabels: MemberLabels = defaultLabels): ToolRenderers {
|
|
@@ -97,12 +97,12 @@ export function teamToolRenderers(name: string, getLabels: MemberLabels = defaul
|
|
|
97
97
|
try { data = JSON.parse(text); } catch { data = text; }
|
|
98
98
|
}
|
|
99
99
|
const labels = getLabels();
|
|
100
|
-
const
|
|
100
|
+
const waiting = action === "wait" && Array.isArray(data) && data.length === 0;
|
|
101
|
+
const outcome = context.isError ? `error · ${inline(text)}` : waiting ? "turn ended; the next post wakes this session" : toolSummary(data, theme, labels) || "done";
|
|
101
102
|
const request = toolSummary(fields, theme, labels);
|
|
102
103
|
const heading = `${theme.fg(context.isError ? "error" : "success", "●")} ${theme.fg("toolTitle", name)} · ${inline(action ?? "...")} → ${outcome}${request && !outcome.includes(request) ? ` · ${request}` : ""}`;
|
|
103
|
-
const requestJSON = cleanText(JSON.stringify(context.args, null, 2));
|
|
104
104
|
const responseJSON = cleanText(result.details === undefined ? text : JSON.stringify(result.details, null, 2));
|
|
105
|
-
const hiddenLines =
|
|
105
|
+
const hiddenLines = responseJSON.split("\n").length;
|
|
106
106
|
const hint = theme.fg("muted", options.expanded ? " (" : ` … +${hiddenLines} lines (`)
|
|
107
107
|
+ keyHint("app.tools.expand", options.expanded ? "to collapse" : "to expand") + theme.fg("muted", ")");
|
|
108
108
|
const row = new Container();
|
|
@@ -117,9 +117,6 @@ export function teamToolRenderers(name: string, getLabels: MemberLabels = defaul
|
|
|
117
117
|
},
|
|
118
118
|
});
|
|
119
119
|
if (options.expanded) {
|
|
120
|
-
row.addChild(new Text(theme.fg("muted", "Request"), context.outputPad, 0));
|
|
121
|
-
row.addChild(new Text(requestJSON, context.outputPad, 0));
|
|
122
|
-
row.addChild(new Text(theme.fg("muted", "Response"), context.outputPad, 0));
|
|
123
120
|
row.addChild(new Text(responseJSON, context.outputPad, 0));
|
|
124
121
|
}
|
|
125
122
|
return row;
|
|
@@ -128,13 +125,18 @@ export function teamToolRenderers(name: string, getLabels: MemberLabels = defaul
|
|
|
128
125
|
}
|
|
129
126
|
|
|
130
127
|
export function teamMessageRenderer(getLabels: MemberLabels = defaultLabels): MessageRenderer {
|
|
131
|
-
return (message, { expanded }, theme) => {
|
|
132
|
-
const details = message.details as { through?: number; messages?: { from: string;
|
|
133
|
-
const
|
|
128
|
+
return (message, { expanded, outputPad }, theme) => {
|
|
129
|
+
const details = message.details as { through?: number; messages?: { from: string; summary: string; recipients: string[]; task?: string; kind?: string }[] } | undefined;
|
|
130
|
+
const posts = details?.messages ?? [], recent = posts.at(-1);
|
|
131
|
+
const more = posts.length > 1 ? ` (+${posts.length - 1} more)` : "";
|
|
134
132
|
const labels = getLabels();
|
|
135
133
|
const name = (id: string) => memberLabel(id, labels);
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
// Lead with what happened: "count-a completed #1 Count lines · 37 lines", or "count-a → Main · #1 … · question".
|
|
135
|
+
const what = recent && (recent.kind === "outcome" && recent.task ? `${name(recent.from)} completed ${name(recent.task)}` : `${name(recent.from)} → ${recent.recipients.map(name).join(", ")}${recent.task ? ` · ${name(recent.task)}` : ""}`);
|
|
136
|
+
const preview = recent ? `${what} · ${recent.summary.split("\n")[0]}${more}` : `Updates through ${details?.through ?? "unknown"}`;
|
|
137
|
+
const box = new Box(outputPad, 0);
|
|
138
|
+
box.addChild(entry("ac-teams", details ?? { content: message.content }, expanded, theme, false, preview));
|
|
139
|
+
return box;
|
|
138
140
|
};
|
|
139
141
|
}
|
|
140
142
|
|
package/src/schemas.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const AgentSchema = Type.Object({
|
|
|
13
13
|
worktree: Type.Optional(Type.Boolean()), base: Type.Optional(id), target: Type.Optional(id),
|
|
14
14
|
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
15
15
|
depth: Type.Optional(Type.Integer({ minimum: 0, maximum: 10 })),
|
|
16
|
+
task: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Launch only: an existing to-do task the new member owns, with you as its delegator. Its activity reaches you; include its goal in the brief." })),
|
|
16
17
|
access: Type.Optional(Type.Object({
|
|
17
18
|
read: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), { maxItems: 32 })),
|
|
18
19
|
write: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), { maxItems: 32 })),
|
|
@@ -32,12 +33,19 @@ export const TaskSchema = Type.Object({
|
|
|
32
33
|
offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
33
34
|
action: Type.Union(["create", "list", "read", "edit", "claim", "start", "release", "complete", "reopen"].map(v => Type.Literal(v))),
|
|
34
35
|
id: Type.Optional(id), ...taskFields,
|
|
36
|
+
summary: Type.Optional(Type.String({ minLength: 1, maxLength: 16000, description: "Complete: the outcome, published atomically with done: what was done, how it was checked, and caveats. Start with the result itself (e.g. 37 lines); the first line is the one people see. Complete enough that the delegator can act without reading detail." })),
|
|
37
|
+
detail: Type.Optional(Type.String({ minLength: 1, maxLength: 16000, description: "Complete: optional supporting detail, read on demand." })),
|
|
35
38
|
tasks: Type.Optional(Type.Array(Type.Object({ ...taskFields, title: Type.String({ minLength: 1, maxLength: 500 }) }, { additionalProperties: false }), { minItems: 1, maxItems: 100, description: "Create several tasks atomically. Use instead of top-level task fields. Dependencies must refer to existing task IDs, not items in this batch." })),
|
|
36
39
|
}, { additionalProperties: false });
|
|
37
40
|
export const MessageSchema = Type.Object({
|
|
38
|
-
action: Type.Union(["send", "history", "search"].map(v => Type.Literal(v))),
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
action: Type.Union(["send", "history", "search", "wait"].map(v => Type.Literal(v))),
|
|
42
|
+
tasks: Type.Optional(Type.Array(id, { maxItems: 100, uniqueItems: true, description: "Wait: also wake on posts linked to these tasks, e.g. ones you delegated." })),
|
|
43
|
+
recipients: Type.Optional(Type.Array(id, { minItems: 1, maxItems: 100, uniqueItems: true, description: "Exact teammate names or member IDs." })),
|
|
44
|
+
summary: Type.Optional(Type.String({ minLength: 1, maxLength: 16000, description: "Send: delivered directly to recipients. Start with the point (the first line is the one people see) and what it changes; complete enough to act on." })),
|
|
45
|
+
detail: Type.Optional(Type.String({ minLength: 1, maxLength: 16000, description: "Send: optional supporting detail, read only when needed." })),
|
|
46
|
+
task: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Send: link an existing task; its owner and delegator receive the post." })),
|
|
47
|
+
replyTo: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Send: the post ID this answers; its author receives the reply and it stays on that post's task." })),
|
|
48
|
+
query: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
|
|
41
49
|
after: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
42
50
|
}, { additionalProperties: false });
|
|
43
51
|
const UserActionSchema = Type.Object({
|
|
@@ -67,6 +75,7 @@ export function validate(operation: string, value: unknown): void {
|
|
|
67
75
|
const args = value as { action: string; name?: string };
|
|
68
76
|
if (args.action === "launch" && !args.name) throw new Error("A descriptive teammate name is required on launch");
|
|
69
77
|
if (args.action !== "launch" && args.name !== undefined) throw new Error("A teammate name can only be set on launch");
|
|
78
|
+
if (args.action !== "launch" && (value as { task?: unknown }).task !== undefined) throw new Error("A task can only be assigned on launch");
|
|
70
79
|
if (args.action !== "launch" && (value as { access?: unknown }).access !== undefined) throw new Error("Access can only be set on launch; resume keeps the member's access");
|
|
71
80
|
}
|
|
72
81
|
}
|