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/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
- let taskGraph = buildTaskGraph(tasks);
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", "Activity"];
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 = buildTaskGraph(tasks);
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(clean(taskName(task))), label("Title", clean(task.title)), label("ID", task.id), "",
120
- label("State", stateLabel(task)), label("Owner", name(task.owner)), label("Creator", name(creator(task))), "",
121
- heading("Description"), clean(task.description), "",
122
- heading("Prerequisites"),
123
- ...(task.dependencies.length ? task.dependencies.map(id => {
124
- const parent = tasks.find(item => item.id === id);
125
- return parent ? `${clean(taskName(parent))} · ${taskStatus(parent)}` : `${clean(id)} · Unavailable`;
126
- }) : [fg("muted", "None; this task can start independently.")]), "",
127
- heading("Unlocks"), ...(unlocked.length ? unlocked.map(item => clean(taskName(item))) : [fg("muted", "No dependent tasks.")]),
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
- let row = spans.map(span => fg(span.cls === "border" || span.cls === "edge" ? "dim" : "text", span.text)).join("");
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
- const styled = border ? statusText(task, text) : statusText(task, "│") + (active ? theme.bold(interior) : interior) + statusText(task, "│");
157
- row = sliceByColumn(row, 0, box.left) + styled + sliceByColumn(row, box.right + 1, art.width);
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(row, columnScroll, width);
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
- left = graphData ? graphData.rows : tasks.length ? [fg("muted", " Task / State"), "",
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 text = `${index === taskIndex ? "●" : " "} ${clean(taskName(task))}`;
190
- const body = fg(taskStatus(task) === "Done" ? "dim" : "text", truncateToWidth(text, Math.max(1, leftWidth - 10)));
191
- return (index === taskIndex ? theme.bold(body) : body) + " · " + stateLabel(task);
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 = [fg("muted", ` ${pad("Member", nameWidth)}${gap}${pad("Session", sessionWidth)}`
215
- + (showTasks ? `${gap}${pad("Tasks", tasksWidth)}` : "") + (showDirectory ? `${gap}Directory` : "")), ...ids.map((id, index) => {
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
- left = events.length ? [fg("muted", " Recent messages · newest first"), "", ...events.flatMap((event, index) => [
234
- `${index === eventIndex ? "●" : " "} ${name(event.from)} → ${event.recipients.map(name).join(", ") || "Team"}`,
235
- fg("muted", ` ${clean(event.time)} · ${clean(event.text).split("\n")[0]}`), "",
236
- ])] : [heading("No activity yet"), fg("muted", "Recent persistent team messages appear here.")];
237
- selectedRow = eventIndex * 3 + 2;
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) viewport.unshift(fg("muted", `↑ ${scroll} rows above · ↓ ${Math.max(0, left.length - scroll - viewHeight)} below`
247
- + (graphWidth > leftWidth ? ` · ← ${columnScroll} cols · ${Math.max(0, graphWidth - columnScroll - leftWidth)} →` : "")));
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 activity yet"), "Recent persistent team messages appear here."], width);
259
- return wrap([heading("Team message"), "", label("From", name(event.from)), label("To", event.recipients.map(name).join(", ") || "Team"), label("Time", clean(event.time)), "", clean(event.text)], width);
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
- return wrap([heading(name(id)), "", label("State", member?.needsInput ? fg("error", "Needs input") : clean(member?.status ?? (id === "root" ? "Main conversation" : "Unavailable")).replace(/^./, char => char.toUpperCase())),
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("Tasks", taskProgress(id)), fg("muted", "Completed / currently assigned"),
273
- ...(member?.error ? [label("Error", clean(member.error))] : []), "", heading("Assigned tasks"),
274
- ...assigned.map(task => `${clean(taskName(task))} · ${taskStatus(task)}`),
275
- ...(assigned.length ? [] : [fg("muted", "No assigned tasks.")]), "",
276
- fg("accent", member && ["stopped", "interrupted"].includes(member.status) ? "Enter Resume and open" : "Enter Open session"),
277
- ...(!detail ? [fg("muted", "→ Details")] : []),
278
- ...(!host.child && member?.role === "member" ? [fg("accent", "P Promote to main session"), fg("muted", "Stops member; reopens transcript as a main session.")] : []),
279
- "", heading("Session details"), label("ID", id),
280
- label("Session", clean(member?.sessionFile ?? member?.sessionId ?? "Unavailable")),
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} message${events.length === 1 ? "" : "s"} · newest first`;
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 ? "" : inline(data);
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 outcome = context.isError ? `error · ${inline(text)}` : toolSummary(data, theme, labels) || "done";
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 = requestJSON.split("\n").length + responseJSON.split("\n").length + 2;
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; text: string; recipients: string[] }[] } | undefined;
133
- const recent = details?.messages?.at(-1);
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
- const preview = recent ? `${name(recent.from)} → ${recent.recipients.map(name).join(", ")} · ${recent.text.split("\n")[0]}` : `Updates through ${details?.through ?? "unknown"}`;
137
- return entry("Team message", details ?? { content: message.content }, expanded, theme, false, preview);
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
- recipients: Type.Optional(Type.Array(id, { minItems: 1, maxItems: 100, uniqueItems: true, description: "Exact teammate names or member IDs. Each must resolve to a different live member. Omit to notify all other live members." })),
40
- text: Type.Optional(text), query: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
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
  }