ac-teams 0.0.11 → 0.0.12

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/docs/usage.md CHANGED
@@ -24,7 +24,10 @@ Context shows Pi's reported token estimate, window size, and percentage, for exa
24
24
  - Ctrl+Shift+Left returns from a member to Main. Switching preserves the member's process, session, and draft. Returning does not stop it.
25
25
  - `/teams` opens three tabs. Tab moves between Tasks, Teammates, and Activity; Esc closes the panel.
26
26
  - **Tasks:** Arrow keys select nodes in the dependency graph. G toggles graph/list, and Enter shows full details. Green means Done, red Blocked, cyan Working, and neutral Ready; written labels remain visible. Large or unsupported graphs fall back to the list.
27
- - **Teammates:** Enter opens a live member, or quietly resumes a stopped member's saved session before opening it. Root can open any member; member views can open their descendants.
27
+ - **Teammates:** Shows working, idle, stopped, and needs-input counts; interrupted sessions count as needing input. Enter opens a live member, or quietly resumes a stopped member's saved session before opening it. Right opens details; Esc returns to the roster. Model, context, and directory appear above session IDs and paths. Root can open any member; member views can open their descendants.
28
+ - Wider rosters also show Tasks (completed / currently assigned total) and the directory's final folder name. Reassigned tasks leave the previous owner's counts. Narrower views drop Directory first, then Tasks; full paths and counts remain in details.
29
+ - Press `/` to filter names (case-insensitive). Enter applies the filter; Esc while editing clears it. Backspace edits the query. Press `/` again to edit an applied filter.
30
+ - H hides or shows stopped members. Interrupted sessions remain visible for recovery. All members are visible by default; filters affect only this panel, not team state.
28
31
  - **Activity:** Shows the latest 100 persistent team messages rather than every tool update. Use `team_message history` to read older messages.
29
32
  - N composes a task request for Main. C on a task sends feedback to its creator. Enter sends; Shift+Enter adds a newline; Esc cancels without losing the draft. These messages do not directly edit the task graph. Feedback resumes a stopped creator when possible; otherwise, the receipt explicitly reports delivery to Main instead.
30
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-teams",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "Small, persistent teams for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/panel.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { basename } from "node:path";
1
2
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
3
  import { Editor, indexedColor, matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
- import { modelContext } from "./telemetry.ts";
4
+ import { contextSummary } from "./telemetry.ts";
4
5
  import { buildTaskGraph, moveTaskSelection, taskStatus } from "./graph.ts";
5
6
  import { cleanText as clean } from "./render.ts";
6
7
  import type { PanelHost } from "./ui-types.ts";
@@ -18,15 +19,22 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
18
19
  const destination = await ctx.ui.custom<string | undefined>((tui, theme, _kb, done) => {
19
20
  let tab = 0, taskIndex = 0, memberIndex = 0, eventIndex = 0;
20
21
  let graph = true, detail = false, scroll = 0, detailScroll = 0, columnScroll = 0;
21
- let height = 30, bodyHeight = 21, receipt = "Select a task or teammate. N requests a task from Main.";
22
+ let height = 30, bodyHeight = 21, receipt = "";
22
23
  let compose: "new" | "feedback" | undefined;
23
24
  let sending = false, opening = false, disposed = false;
24
25
  let composeTask: TaskView | undefined;
25
26
  let timer: ReturnType<typeof setTimeout> | undefined;
26
27
  let focused = true;
28
+ let memberQuery = "", searching = false, hideStopped = false;
27
29
  const tabs = ["Tasks", "Teammates", "Activity"];
28
30
  const selectedTask = () => tasks[taskIndex];
29
- const memberIds = () => ["root", ...snapshot.members.filter(member => member.id !== "root").map(member => member.id)];
31
+ const memberIds = () => ["root", ...snapshot.members.filter(member => member.id !== "root").map(member => member.id)]
32
+ .filter(id => name(id).toLowerCase().includes(memberQuery.toLowerCase())
33
+ && (!hideStopped || snapshot.members.find(member => member.id === id)?.status !== "stopped"));
34
+ const taskProgress = (id: string) => {
35
+ const assigned = tasks.filter(task => task.owner === id);
36
+ return `${assigned.filter(task => task.status === "done").length}/${assigned.length}`;
37
+ };
30
38
  const refresh = () => { if (!disposed) tui.requestRender(); };
31
39
  async function poll() {
32
40
  try {
@@ -47,9 +55,12 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
47
55
  const statusText = (task: TaskView, text: string) => taskStatus(task) === "Ready" ? fg("muted", text)
48
56
  : theme.style(text, { fg: indexedColor(({ Done: 2, Blocked: 1, Working: 6, Ready: 7 })[taskStatus(task)]) });
49
57
  const stateLabel = (task: TaskView) => statusText(task, taskStatus(task));
50
- const pad = (text: string, width: number) => truncateToWidth(text, width) + " ".repeat(Math.max(0, width - visibleWidth(text)));
58
+ const pad = (text: string, width: number) => {
59
+ const clipped = truncateToWidth(text, width);
60
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
61
+ };
51
62
  const heading = (text: string) => theme.bold(fg("text", text));
52
- const label = (key: string, value: string) => fg("muted", key.padEnd(10)) + value;
63
+ const label = (key: string, value: string) => fg("muted", key.padEnd(9) + " ") + value;
53
64
  const wrap = (lines: string[], width: number) => lines.flatMap(line => wrapTextWithAnsi(line, Math.max(1, width)));
54
65
  const editor = new Editor(tui, {
55
66
  borderColor: text => fg("accent", text),
@@ -185,13 +196,38 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
185
196
  right = split ? taskDetails(rightWidth) : [];
186
197
  if (graph && !graphData && tasks.length) left[0] = fg("muted", "Graph unavailable or too large; showing list.");
187
198
  } else if (tab === 1) {
188
- left = [fg("muted", " Member Session"), "", ...memberIds().map((id, index) => {
199
+ const ids = memberIds();
200
+ const sessionWidth = Math.min(17, Math.max(1, Math.floor(leftWidth / 2)));
201
+ const preferredNameWidth = Math.min(24, Math.max(18, ...ids.map(id => visibleWidth(name(id)))));
202
+ const tasksWidth = Math.max(5, ...ids.map(id => taskProgress(id).length));
203
+ const showTasks = leftWidth >= 2 + preferredNameWidth + 4 + sessionWidth + 4 + tasksWidth;
204
+ const directory = (id: string) => clean(basename(snapshot.members.find(member => member.id === id)?.cwd ?? (id === host.self ? ctx.cwd : ""))) || "—";
205
+ const directorySpace = showTasks ? leftWidth - (2 + preferredNameWidth + 4 + sessionWidth + 4 + tasksWidth + 4) : 0;
206
+ const showDirectory = directorySpace >= 10;
207
+ const directoryWidth = showDirectory ? Math.min(directorySpace, Math.max(10, ...ids.map(id => visibleWidth(directory(id))))) : 0;
208
+ let nameWidth = Math.max(1, Math.min(preferredNameWidth, leftWidth - sessionWidth - 6));
209
+ const gaps = 1 + Number(showTasks) + Number(showDirectory);
210
+ const spare = Math.max(0, leftWidth - (2 + nameWidth + sessionWidth + (showTasks ? tasksWidth : 0) + directoryWidth + 4 * gaps));
211
+ const nameExtra = Math.min(spare, Math.max(0, ...ids.map(id => visibleWidth(name(id)) - nameWidth)));
212
+ const gap = " ".repeat(4 + Math.floor((spare - nameExtra) / gaps));
213
+ 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) => {
189
216
  const member = snapshot.members.find(member => member.id === id);
190
- const description = id === "root" ? "Main conversation" : member?.status ?? "Unavailable";
191
- return fg(index === memberIndex ? "accent" : "text", `${index === memberIndex ? "●" : " "} ${name(id).padEnd(12)} `)
192
- + (member?.needsInput ? fg("error", "Needs input") : fg(index === memberIndex ? "accent" : "text", clean(description)));
217
+ const selected = index === memberIndex;
218
+ const stopped = member && ["stopped", "interrupted"].includes(member.status);
219
+ const description = member?.needsInput ? "Needs input" : id === "root" ? "Main conversation"
220
+ : clean(member?.status ?? "Unavailable").replace(/^./, char => char.toUpperCase());
221
+ const color = member?.needsInput || member?.status === "interrupted" ? "error"
222
+ : member?.status === "busy" ? "accent" : "muted";
223
+ const memberName = fg(selected ? "accent" : stopped ? "dim" : "text", pad(name(id), nameWidth));
224
+ return fg(selected ? "accent" : "dim", `${selected ? "●" : " "} `)
225
+ + (selected ? theme.bold(memberName) : memberName) + gap + fg(color, pad(description, sessionWidth))
226
+ + (showTasks ? fg("muted", `${gap}${pad(taskProgress(id), tasksWidth)}`) : "")
227
+ + (showDirectory ? fg("muted", `${gap}${pad(directory(id), directoryWidth)}`) : "");
193
228
  })];
194
- selectedRow = memberIndex + 2;
229
+ if (!ids.length) left.push(fg("muted", "No matching teammates."));
230
+ selectedRow = memberIndex + 1;
195
231
  right = split ? memberDetails(rightWidth) : [];
196
232
  } else {
197
233
  left = events.length ? [fg("muted", " Recent messages · newest first"), "", ...events.flatMap((event, index) => [
@@ -226,20 +262,23 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
226
262
  function memberDetails(width: number) {
227
263
  if (tab === 2) return eventDetails(width);
228
264
  const id = memberIds()[memberIndex];
265
+ if (!id) return wrap([heading("No matching teammates"), "Change the name filter or show stopped members."], width);
229
266
  const member = snapshot.members.find(member => member.id === id);
230
267
  const assigned = tasks.filter(task => task.owner === id);
231
- return wrap([heading(name(id)), "", label("State", member?.needsInput ? fg("error", "Needs input") : clean(member?.status ?? (id === "root" ? "Main conversation" : "Unavailable"))),
232
- label("ID", id), label("Model / context", clean(modelContext(member ?? {}))),
233
- label("Cwd", clean(member?.cwd ?? (id === host.self ? ctx.cwd : "Unavailable"))),
234
- label("Session", clean(member?.sessionFile ?? member?.sessionId ?? "Unavailable")),
235
- label("Worktree", member?.worktree ? clean(`${member.worktree.path} (${member.worktree.branch})`) : "None"),
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())),
269
+ label("Model", clean(member?.modelName || member?.model?.slice(member.model.indexOf("/") + 1) || "Unknown")),
270
+ label("Context", clean(contextSummary(member?.contextUsage).replace(/^context unknown/, "Unknown"))),
271
+ label("Directory", clean(member?.cwd ?? (id === host.self ? ctx.cwd : "Unavailable"))),
272
+ label("Tasks", taskProgress(id)), fg("muted", "Completed / currently assigned"),
236
273
  ...(member?.error ? [label("Error", clean(member.error))] : []), "", heading("Assigned tasks"),
237
274
  ...assigned.map(task => `${clean(taskName(task))} · ${taskStatus(task)}`),
238
275
  ...(assigned.length ? [] : [fg("muted", "No assigned tasks.")]), "",
239
- heading("Message with full context"), "Open the native Pi session to read its conversation and type your message there.", "",
240
- fg("accent", member && ["stopped", "interrupted"].includes(member.status) ? "Enter Resume and open" : "Enter Open full session"),
241
- fg("muted", "No separate message composer."),
242
- ...(!host.child && member?.role === "member" ? ["", fg("accent", "P Promote to main session"), fg("muted", "Stops it and reopens its transcript as a main session in its own terminal.")] : []),
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"),
243
282
  ], width);
244
283
  }
245
284
 
@@ -253,13 +292,24 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
253
292
  const row = (text: string) => fg("dim", "│ ") + pad(text, inner) + fg("dim", " │");
254
293
  const title = heading("Teams") + fg("muted", ` · ${name(host.self)} · ${snapshot.members.filter(member => member.id !== "root" && ["starting", "busy", "idle", "stopping"].includes(member.status)).length}/${snapshot.limit} live · depth ${snapshot.depth}`);
255
294
  const tabLine = tabs.map((text, index) => index === tab ? theme.bold(fg("accent", `[${text}]`)) : fg("muted", ` ${text} `)).join(" ") + (tab === 0 && inner >= 78 ? fg("muted", ` ${graph ? "Graph · prerequisites above · G List" : "List · G Graph"}`) : "");
256
- const help = compose ? "Enter Send · Shift+Enter Newline · Esc Keep draft"
257
- : detail ? tab === 0 ? "↑↓ Scroll · C Feedback · O Open owner · Esc Back" : "↑↓ Scroll · O Open related session · Esc Back"
295
+ const filters = `${searching || memberQuery ? `/ Filter: ${memberQuery}${searching ? "▏" : ""}` : "/ Filter"} · H ${hideStopped ? "Stopped: hidden" : "Hide stopped"}`;
296
+ const help = searching ? `${filters} · Enter Apply · Esc Clear`
297
+ : compose ? "Enter Send · Shift+Enter Newline · Esc Keep draft"
298
+ : detail ? tab === 0 ? "↑↓ Scroll · C Feedback · O Open owner · Esc Back"
299
+ : tab === 1 ? "↑↓ Scroll · Enter Open session · Esc Back" : "↑↓ Scroll · O Open related session · Esc Back"
258
300
  : tab === 0 ? `${graph ? "↑↓←→" : "↑↓"} Select · Enter Details · G Graph/List · C Feedback · N New task · Tab Next · Esc Close`
259
- : tab === 1 ? "↑↓ Select · → Details · Enter Open session · N New task · Tab Next · Esc Close"
301
+ : tab === 1 ? `${filters} · ↑↓ Select · → Details · Enter Open session · N New task · Tab Next · Esc Close`
260
302
  : "↑↓ Select · Enter Details · O Open related session · Tab Next · Esc Close";
261
- const summary = wrapTextWithAnsi(fg("muted", `${tasks.length} tasks · ${["Working", "Ready", "Blocked", "Done"].map(state => `${tasks.filter(task => taskStatus(task) === state).length} ${state.toLowerCase()}`).join(" · ")}`), inner);
262
- const receipts = wrapTextWithAnsi(fg("muted", receipt), inner);
303
+ const teammates = snapshot.members.filter(member => member.id !== "root");
304
+ const memberStates = teammates.map(member => member.needsInput || member.status === "interrupted" ? "needs input"
305
+ : ["starting", "busy", "stopping"].includes(member.status) ? "working"
306
+ : member.status === "stopped" ? "stopped" : "idle");
307
+ const summaryText = tab === 0
308
+ ? `${tasks.length} tasks · ${["Working", "Ready", "Blocked", "Done"].map(state => `${tasks.filter(task => taskStatus(task) === state).length} ${state.toLowerCase()}`).join(" · ")}`
309
+ : 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`;
311
+ const summary = wrapTextWithAnsi(fg("muted", summaryText), inner);
312
+ const receipts = receipt ? wrapTextWithAnsi(fg("muted", receipt), inner) : [];
263
313
  const hints = wrapTextWithAnsi(fg("dim", help), inner);
264
314
  bodyHeight = Math.max(1, height - 6 - summary.length - receipts.length - hints.length);
265
315
  const content = body(inner);
@@ -281,6 +331,21 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
281
331
  refresh(); return;
282
332
  }
283
333
  if (opening) return;
334
+ if (searching) {
335
+ if (matchesKey(data, "escape")) { memberQuery = ""; searching = false; }
336
+ else if (matchesKey(data, "enter")) searching = false;
337
+ else if (matchesKey(data, "backspace")) memberQuery = Array.from(memberQuery).slice(0, -1).join("");
338
+ else {
339
+ const text = data.replace(/^\x1b\[200~|\x1b\[201~$/g, "");
340
+ if (!/[\x00-\x1f\x7f-\x9f]/.test(text)) memberQuery += clean(text);
341
+ }
342
+ memberIndex = 0; scroll = 0; refresh(); return;
343
+ }
344
+ if (tab === 1 && !detail && (data === "/" || data.toLowerCase() === "h")) {
345
+ if (data === "/") searching = true;
346
+ else hideStopped = !hideStopped;
347
+ memberIndex = 0; scroll = 0; refresh(); return;
348
+ }
284
349
  const direction = (["up", "down", "left", "right"] as const).find(key => matchesKey(data, key));
285
350
  if (matchesKey(data, "escape")) { if (detail) { detail = false; detailScroll = 0; } else done(undefined); }
286
351
  else if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { tab = (tab + (matchesKey(data, "shift+tab") ? 2 : 1)) % 3; detail = false; scroll = 0; detailScroll = 0; }
package/src/telemetry.ts CHANGED
@@ -10,10 +10,13 @@ export function readTelemetry(ctx: ExtensionContext): Telemetry {
10
10
 
11
11
  export function modelContext(member: Pick<Member, "model" | "modelName" | "contextUsage">): string {
12
12
  const model = member.modelName || member.model?.slice(member.model.indexOf("/") + 1) || "Model unknown";
13
- const usage = member.contextUsage;
13
+ return `${model} · ${contextSummary(member.contextUsage)}`;
14
+ }
15
+
16
+ export function contextSummary(usage: Member["contextUsage"]): string {
14
17
  const count = (value: number) => value >= 1000 ? `${(value / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(value);
15
18
  const context = !usage ? "context unknown" : usage.tokens === null || usage.percent === null
16
19
  ? `context unknown / ${count(usage.contextWindow)}`
17
20
  : `${count(usage.tokens)} / ${count(usage.contextWindow)} (${usage.percent.toFixed(1).replace(/\.0$/, "")}%)`;
18
- return `${model} · ${context}`;
21
+ return context;
19
22
  }