ac-teams 0.0.1 → 0.0.3
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 +28 -183
- package/docs/development.md +89 -0
- package/docs/implementation-plan.md +4 -4
- package/docs/operations.md +101 -0
- package/docs/usage.md +130 -0
- package/package.json +5 -2
- package/src/commands.ts +21 -0
- package/src/config.ts +29 -2
- package/src/connection.ts +4 -1
- package/src/coordinator.ts +156 -30
- package/src/graph.ts +79 -0
- package/src/index.ts +155 -13
- package/src/panel.ts +309 -0
- package/src/render.ts +71 -0
- package/src/schemas.ts +27 -1
- package/src/store.ts +45 -14
- package/src/task-summary.ts +34 -0
- package/src/telemetry.ts +19 -0
- package/src/terminal-agent.ts +110 -0
- package/src/terminal-ui.ts +174 -0
- package/src/types.ts +41 -3
- package/src/ui-types.ts +19 -0
package/src/index.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { AgentSchema, TaskSchema, MessageSchema, validate } from "./schemas.ts";
|
|
4
|
-
import type { AgentArgs,
|
|
5
|
-
import {
|
|
4
|
+
import type { AgentArgs, Message, TaskView, UserAction } from "./types.ts";
|
|
5
|
+
import type { PanelHost, TeamSnapshot } from "./ui-types.ts";
|
|
6
|
+
import { installTeamFooter, visitMember, showDisplayConfig } from "./terminal-ui.ts";
|
|
7
|
+
import { readTelemetry } from "./telemetry.ts";
|
|
8
|
+
import { resolveTeammate, teamsCompletions } from "./commands.ts";
|
|
9
|
+
import { showTeamsPanel } from "./panel.ts";
|
|
10
|
+
import { teamToolRenderers, renderTeamMessage, cleanText } from "./render.ts";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { Coordinator, assistantEvent, taskRequestGuidance } from "./coordinator.ts";
|
|
6
13
|
import { Connection } from "./connection.ts";
|
|
7
|
-
import { policy } from "./config.ts";
|
|
14
|
+
import { policy, readDisplaySettings, defaultDisplaySettings } from "./config.ts";
|
|
8
15
|
|
|
9
16
|
export default function teams(pi: ExtensionAPI) {
|
|
10
17
|
pi.registerFlag("teams-limit", { description: "Explicit maximum live ac-teams members (default 10, idle members count)", type: "string" });
|
|
@@ -18,9 +25,15 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
18
25
|
let context: ExtensionContext | undefined;
|
|
19
26
|
let warned = false;
|
|
20
27
|
let exiting = false;
|
|
28
|
+
let disposeUI: (() => void) | undefined;
|
|
29
|
+
let telemetryTimer: NodeJS.Timeout | undefined;
|
|
30
|
+
const drafts = new Map<string, string>();
|
|
31
|
+
const display = { ...defaultDisplaySettings };
|
|
32
|
+
const self = environment[1] ?? "root";
|
|
33
|
+
pi.registerMessageRenderer("ac-teams", renderTeamMessage);
|
|
21
34
|
const warn = (message: string) => context?.ui.notify(message, "warning");
|
|
22
|
-
const notify = (through: number) => {
|
|
23
|
-
pi.sendMessage({ customType: "ac-teams", content: `New team messages through sequence ${through}. Read team_message history after your last cursor. Reply only with useful updates, not acknowledgements.`, display: true, details: { through } }, { deliverAs: "followUp", triggerTurn: true });
|
|
35
|
+
const notify = (through: number, messages: Message[] = []) => {
|
|
36
|
+
pi.sendMessage({ customType: "ac-teams", content: `New team messages through sequence ${through}. Read team_message history after your last cursor. Reply only with useful updates, not acknowledgements.`, display: true, details: { through, messages } }, { deliverAs: "followUp", triggerTurn: true });
|
|
24
37
|
if (connection) void connection.request("ack", { through }).catch(error => warn(String(error)));
|
|
25
38
|
else coordinator?.ack("root", through);
|
|
26
39
|
};
|
|
@@ -31,10 +44,11 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
31
44
|
id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile(),
|
|
32
45
|
...policy(ctx.cwd, pi.getFlag("teams-project-config") === true, { limit: pi.getFlag("teams-limit") as string | undefined, depth: pi.getFlag("teams-depth") as string | undefined }),
|
|
33
46
|
extension: fileURLToPath(import.meta.url), notice: notify, warning: warn,
|
|
47
|
+
interactive: ctx.mode === "tui", model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinking: pi.getThinkingLevel(),
|
|
34
48
|
}).then(value => { coordinator = value; return value; }, error => { opening = undefined; throw error; });
|
|
35
49
|
return opening;
|
|
36
50
|
}
|
|
37
|
-
async function handle(operation:
|
|
51
|
+
async function handle(operation: "agent" | "task" | "message", value: unknown, ctx: ExtensionContext): Promise<unknown> {
|
|
38
52
|
validate(operation, value);
|
|
39
53
|
let args = value;
|
|
40
54
|
if (operation === "agent") {
|
|
@@ -56,41 +70,136 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
56
70
|
}
|
|
57
71
|
return (await root(ctx)).handle("root", operation, args, authorized);
|
|
58
72
|
}
|
|
73
|
+
async function snapshot(ctx: ExtensionContext): Promise<TeamSnapshot> {
|
|
74
|
+
if (!child && !opening) return { members: [{ id: "root", cwd: ctx.cwd, depth: 0, status: ctx.isIdle() ? "idle" : "busy", notified: 0 }], tasks: [], messages: [], limit: 10, depth: 2 };
|
|
75
|
+
const status = await handle("agent", { action: "list" }, ctx) as Pick<TeamSnapshot, "members" | "limit" | "depth"> & { lastMessage: number };
|
|
76
|
+
const tasks: TaskView[] = [];
|
|
77
|
+
let total = 0;
|
|
78
|
+
do {
|
|
79
|
+
const page = await handle("task", { action: "list", offset: tasks.length, limit: 100 }, ctx) as { tasks: TaskView[]; total: number };
|
|
80
|
+
tasks.push(...page.tasks); total = page.total;
|
|
81
|
+
if (!page.tasks.length) break;
|
|
82
|
+
} while (tasks.length < total);
|
|
83
|
+
const messages = await handle("message", { action: "history", after: Math.max(0, status.lastMessage - 100), limit: 100 }, ctx) as Message[];
|
|
84
|
+
return { members: status.members, limit: status.limit, depth: status.depth, tasks, messages };
|
|
85
|
+
}
|
|
86
|
+
function host(ctx: ExtensionContext): PanelHost {
|
|
87
|
+
const userAction = async (args: UserAction): Promise<string> => {
|
|
88
|
+
const value = { ...args, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinking: pi.getThinkingLevel() };
|
|
89
|
+
if (child) {
|
|
90
|
+
if (!connection) throw new Error("Team coordinator is not connected");
|
|
91
|
+
return await connection.request("ui", value) as string;
|
|
92
|
+
}
|
|
93
|
+
return await (await root(ctx)).handle("root", "ui", value) as string;
|
|
94
|
+
};
|
|
95
|
+
const api: PanelHost = {
|
|
96
|
+
self, drafts, snapshot: () => snapshot(ctx),
|
|
97
|
+
visit: async id => {
|
|
98
|
+
if (id === self) return;
|
|
99
|
+
const socket = child ? process.env.AC_TEAMS_TERMINAL_SOCKET : join((await root(ctx)).store.dir, "t.sock");
|
|
100
|
+
if (!socket) throw new Error("This member has no live terminal transport");
|
|
101
|
+
await visitMember(ctx, api, socket, id);
|
|
102
|
+
},
|
|
103
|
+
requestTask: text => userAction({ action: "task_request", text }),
|
|
104
|
+
feedback: (task, text) => userAction({ action: "feedback", id: task.id, text }),
|
|
105
|
+
resume: async id => { await userAction({ action: "resume", id }); },
|
|
106
|
+
};
|
|
107
|
+
return api;
|
|
108
|
+
}
|
|
109
|
+
function report(ctx: ExtensionContext, event: { type: string; text?: string }) {
|
|
110
|
+
if (exiting) return;
|
|
111
|
+
if (child && ctx.mode === "tui") void connection?.request("event", event).catch(error => warn(cleanText(String(error))));
|
|
112
|
+
else if (!child) coordinator?.recordEvent("root", event);
|
|
113
|
+
}
|
|
114
|
+
function startTelemetry(ctx: ExtensionContext) {
|
|
115
|
+
clearInterval(telemetryTimer);
|
|
116
|
+
let previous = "", pending = false;
|
|
117
|
+
const publish = async () => {
|
|
118
|
+
if (exiting || pending || (!connection && !coordinator)) return;
|
|
119
|
+
pending = true;
|
|
120
|
+
try {
|
|
121
|
+
const event = readTelemetry(ctx), encoded = JSON.stringify(event);
|
|
122
|
+
if (encoded === previous) return;
|
|
123
|
+
if (child) await connection?.request("event", event);
|
|
124
|
+
else coordinator?.recordEvent("root", event);
|
|
125
|
+
previous = encoded;
|
|
126
|
+
} catch (error) { if (!exiting) warn(cleanText(String(error))); }
|
|
127
|
+
finally { pending = false; }
|
|
128
|
+
};
|
|
129
|
+
telemetryTimer = setInterval(() => void publish(), 1000);
|
|
130
|
+
telemetryTimer.unref();
|
|
131
|
+
void publish();
|
|
132
|
+
}
|
|
133
|
+
pi.on("agent_start", (_event, ctx) => report(ctx, { type: "agent_start" }));
|
|
134
|
+
pi.on("agent_settled", (_event, ctx) => report(ctx, { type: "agent_settled" }));
|
|
135
|
+
pi.on("tool_execution_start", (event, ctx) => report(ctx, { type: "activity", text: `Running ${event.toolName}`.slice(0, 500) }));
|
|
136
|
+
pi.on("message_end", (event, ctx) => {
|
|
137
|
+
const change = assistantEvent(event.message);
|
|
138
|
+
if (change) report(ctx, change);
|
|
139
|
+
});
|
|
59
140
|
function output(data: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: data }; }
|
|
60
141
|
const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling. Use stable member/task IDs; query status and history instead of acknowledging notifications. Limits cover only ac-teams, not other launchers or shell commands.";
|
|
61
142
|
pi.registerTool({
|
|
62
143
|
name: "team_agent", label: "Team agent", description: "Launch/resume Pi members, list/status, stop owned subtrees, change root limits, and explicitly clean stopped worktrees.",
|
|
63
|
-
promptGuidelines: [guidance, "Delegation needs the user's authorization. Give each member a bounded brief
|
|
144
|
+
promptGuidelines: [guidance, "Delegation needs the user's authorization. Give each new member a unique descriptive name (one or two lowercase words, hyphenated if two), plus a bounded brief. Names remain stable on resume. Avoid concurrent writers in the same cwd. Worktrees are opt-in."],
|
|
64
145
|
parameters: AgentSchema,
|
|
146
|
+
...teamToolRenderers("team_agent"),
|
|
65
147
|
async execute(_id, args, _signal, _update, ctx) { return output(await handle("agent", args, ctx)); },
|
|
66
148
|
});
|
|
67
149
|
pi.registerTool({
|
|
68
150
|
name: "team_task", label: "Team task", description: "Manage shared tasks: create/list/read/edit, claim a reservation, start work, complete, release ownership, or explicitly reopen. Progress is todo/in_progress/done; blockedBy is derived from unfinished dependencies. Mutations support revision checks.",
|
|
69
|
-
promptGuidelines: ["Claim reserves a to-do task even if blocked; start requires ownership and all dependencies done. Complete requires in-progress work and acceptance=true after checking the criteria. Release clears ownership, returns in-progress work to todo, and leaves done work done. Reopen is explicit and cannot invalidate active or done dependents."],
|
|
151
|
+
promptGuidelines: [taskRequestGuidance, "Give every task a concise 1–3-word label, alongside its full title and acceptance criteria. Stable team-local numbers are assigned automatically; use UUIDs for API references. Interim and final goals are ordinary tasks. Add dependencies only for real prerequisites, never merely to connect the diagram.", "Claim reserves a to-do task even if blocked; start requires ownership and all dependencies done. Complete requires in-progress work and acceptance=true after checking the criteria. Release clears ownership, returns in-progress work to todo, and leaves done work done. Reopen is explicit and cannot invalidate active or done dependents."],
|
|
70
152
|
parameters: TaskSchema,
|
|
153
|
+
...teamToolRenderers("team_task"),
|
|
71
154
|
async execute(_id, args, _signal, _update, ctx) { return output(await handle("task", args, ctx)); },
|
|
72
155
|
});
|
|
73
156
|
pi.registerTool({
|
|
74
157
|
name: "team_message", label: "Team message", description: "Send a persistent direct/team message or read cursor-paged history. New messages queue a compact follow-up notification for each recipient.",
|
|
75
158
|
parameters: MessageSchema,
|
|
159
|
+
...teamToolRenderers("team_message"),
|
|
76
160
|
async execute(_id, args, _signal, _update, ctx) { return output(await handle("message", args, ctx)); },
|
|
77
161
|
});
|
|
78
162
|
async function close() {
|
|
79
163
|
exiting = true;
|
|
164
|
+
clearInterval(telemetryTimer); telemetryTimer = undefined;
|
|
165
|
+
disposeUI?.(); disposeUI = undefined;
|
|
80
166
|
connection?.close(); connection = undefined;
|
|
81
167
|
if (opening) await (await opening).close();
|
|
82
168
|
coordinator = undefined; opening = undefined;
|
|
83
169
|
}
|
|
84
170
|
pi.on("session_start", async (_event, ctx) => {
|
|
171
|
+
if (!child && coordinator && coordinator.store.state.id !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
|
|
85
172
|
context = ctx; exiting = false;
|
|
86
173
|
if (child) {
|
|
87
174
|
if (environment.some(value => !value)) throw new Error("Incomplete AC_TEAMS membership environment; refusing independent-root fallback");
|
|
88
175
|
connection = new Connection(environment[0]!, environment[1]!, environment[2]!);
|
|
89
|
-
connection.on("notice", through => notify(through));
|
|
90
|
-
connection.on("disconnect", () => { warn("Team coordinator disconnected; this member is shutting down"); ctx.shutdown(); });
|
|
176
|
+
connection.on("notice", (through, messages) => notify(through, messages));
|
|
177
|
+
connection.on("disconnect", () => { warn("Team coordinator disconnected; this member is shutting down"); ctx.abort(); ctx.shutdown(); });
|
|
178
|
+
connection.on("command", (command: { id: string; command: string; args: Record<string, unknown> }) => {
|
|
179
|
+
void (async () => {
|
|
180
|
+
let data: unknown = {}, error: string | undefined;
|
|
181
|
+
try {
|
|
182
|
+
if (command.command === "get_state") data = { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), model: ctx.model ? { id: ctx.model.id, provider: ctx.model.provider, name: ctx.model.name } : undefined, contextUsage: readTelemetry(ctx).contextUsage, isStreaming: !ctx.isIdle() };
|
|
183
|
+
else if (command.command === "prompt") {
|
|
184
|
+
if (typeof command.args.message !== "string" || !command.args.message.trim() || command.args.message.length > 16000) throw new Error("Invalid native prompt");
|
|
185
|
+
pi.sendUserMessage(command.args.message, { deliverAs: "followUp" });
|
|
186
|
+
} else if (command.command === "abort") ctx.abort();
|
|
187
|
+
else if (command.command !== "shutdown") throw new Error("Unsupported native command");
|
|
188
|
+
} catch (failure) { error = String(failure).slice(0, 2000); }
|
|
189
|
+
try { await connection?.request("control_result", { id: command.id, data, error }); }
|
|
190
|
+
catch { return; }
|
|
191
|
+
if (command.command === "shutdown" && !error) ctx.shutdown();
|
|
192
|
+
})();
|
|
193
|
+
});
|
|
91
194
|
await connection.open();
|
|
92
195
|
await connection.request("join", { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), tools: pi.getAllTools().map(tool => tool.name), trusted: ctx.isProjectTrusted() });
|
|
93
196
|
}
|
|
197
|
+
if (ctx.mode === "tui") {
|
|
198
|
+
try { Object.assign(display, readDisplaySettings()); }
|
|
199
|
+
catch (error) { Object.assign(display, defaultDisplaySettings); warn(String(error)); }
|
|
200
|
+
disposeUI?.(); disposeUI = installTeamFooter(pi, ctx, host(ctx), display);
|
|
201
|
+
}
|
|
202
|
+
startTelemetry(ctx);
|
|
94
203
|
if (!warned) {
|
|
95
204
|
warned = true;
|
|
96
205
|
const competitors = pi.getAllTools().filter(tool => ["Agent", "SubagentWorkflow", "subagent"].includes(tool.name));
|
|
@@ -115,17 +224,50 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
115
224
|
if (!count) return;
|
|
116
225
|
if (!ctx.hasUI || !await ctx.ui.confirm("Stop active team and exit?", `${count} live member(s), including idle members, will be stopped. Worktrees and native sessions are retained.`)) return { cancel: true };
|
|
117
226
|
});
|
|
227
|
+
if (child) pi.registerShortcut("ctrl+shift+left", {
|
|
228
|
+
description: "Return to Main",
|
|
229
|
+
handler: async ctx => { if (ctx.mode === "tui") await host(ctx).visit("root"); },
|
|
230
|
+
});
|
|
118
231
|
pi.registerCommand("teams", {
|
|
119
|
-
description: "Team
|
|
232
|
+
description: "Team panel; /teams teammate <name-or-id>, config, back, stop, or status",
|
|
233
|
+
async getArgumentCompletions(prefix) {
|
|
234
|
+
if (!prefix.includes(" ")) return teamsCompletions(prefix, []);
|
|
235
|
+
if (!context) return null;
|
|
236
|
+
try {
|
|
237
|
+
const members = child || opening ? (await handle("agent", { action: "list" }, context) as Pick<TeamSnapshot, "members">).members : [{ id: "root", status: "idle" as const }];
|
|
238
|
+
return teamsCompletions(prefix, members);
|
|
239
|
+
} catch { return null; }
|
|
240
|
+
},
|
|
120
241
|
async handler(args, ctx) {
|
|
121
242
|
context = ctx;
|
|
243
|
+
if (args.trim() === "config") {
|
|
244
|
+
if (ctx.mode !== "tui") { ctx.ui.notify("/teams config requires an interactive terminal", "error"); return; }
|
|
245
|
+
try { Object.assign(display, readDisplaySettings()); await showDisplayConfig(ctx, display); }
|
|
246
|
+
catch (error) { ctx.ui.notify(cleanText(String(error)), "error"); }
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
122
249
|
if (args.trim() === "stop") {
|
|
123
250
|
if (child) throw new Error("A member can only stop its descendants using team_agent");
|
|
124
251
|
const team = await root(ctx);
|
|
125
252
|
for (const member of [...team.live()].sort((a, b) => b.depth - a.depth)) await team.stopTree(member.id);
|
|
126
253
|
}
|
|
127
|
-
|
|
128
|
-
|
|
254
|
+
if (ctx.mode === "tui" && args.trim() !== "stop" && args.trim() !== "status") {
|
|
255
|
+
if (!child) await root(ctx);
|
|
256
|
+
const api = host(ctx);
|
|
257
|
+
if (args.trim() === "back") await api.visit("root");
|
|
258
|
+
else if (/^(teammate|open)\s+/.test(args.trim())) {
|
|
259
|
+
const value = args.trim().replace(/^(teammate|open)\s+/, "");
|
|
260
|
+
const members = (await api.snapshot()).members;
|
|
261
|
+
const id = resolveTeammate(members, value);
|
|
262
|
+
if (id) await api.visit(id);
|
|
263
|
+
else ctx.ui.notify(`Unknown teammate: ${cleanText(value)}. Use autocomplete or /teams panel.`, "error");
|
|
264
|
+
}
|
|
265
|
+
else if (!args.trim() || args.trim() === "panel") await showTeamsPanel(ctx, api);
|
|
266
|
+
else ctx.ui.notify("Usage: /teams [panel|teammate <name-or-id>|config|back|stop|status]", "info");
|
|
267
|
+
} else {
|
|
268
|
+
const status = await handle("agent", { action: "list" }, ctx);
|
|
269
|
+
ctx.ui.notify(JSON.stringify(status, null, 2), "info");
|
|
270
|
+
}
|
|
129
271
|
},
|
|
130
272
|
});
|
|
131
273
|
}
|
package/src/panel.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Editor, indexedColor, matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { modelContext } from "./telemetry.ts";
|
|
4
|
+
import { buildTaskGraph, moveTaskSelection, taskStatus } from "./graph.ts";
|
|
5
|
+
import { cleanText as clean } from "./render.ts";
|
|
6
|
+
import type { PanelHost } from "./ui-types.ts";
|
|
7
|
+
import { taskName, type TaskView } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Promise<void> {
|
|
10
|
+
if (ctx.mode !== "tui") throw new Error("Teams panel requires interactive terminal mode");
|
|
11
|
+
let snapshot = await host.snapshot();
|
|
12
|
+
let tasks = snapshot.tasks;
|
|
13
|
+
let events = snapshot.messages.slice().reverse();
|
|
14
|
+
let taskGraph = buildTaskGraph(tasks);
|
|
15
|
+
const drafts = host.drafts;
|
|
16
|
+
const name = (id: string | undefined) => id === "root" ? "Main" : clean(snapshot.members.find(member => member.id === id)?.name ?? id ?? "Unassigned");
|
|
17
|
+
const creator = (task: TaskView) => (task as TaskView & { creator?: string }).creator ?? "root";
|
|
18
|
+
const destination = await ctx.ui.custom<string | undefined>((tui, theme, _kb, done) => {
|
|
19
|
+
let tab = 0, taskIndex = 0, memberIndex = 0, eventIndex = 0;
|
|
20
|
+
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 compose: "new" | "feedback" | undefined;
|
|
23
|
+
let sending = false, opening = false, disposed = false;
|
|
24
|
+
let composeTask: TaskView | undefined;
|
|
25
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
26
|
+
let focused = true;
|
|
27
|
+
const tabs = ["Tasks", "Teammates", "Activity"];
|
|
28
|
+
const selectedTask = () => tasks[taskIndex];
|
|
29
|
+
const memberIds = () => ["root", ...snapshot.members.filter(member => member.id !== "root").map(member => member.id)];
|
|
30
|
+
const refresh = () => { if (!disposed) tui.requestRender(); };
|
|
31
|
+
async function poll() {
|
|
32
|
+
try {
|
|
33
|
+
const next = await host.snapshot();
|
|
34
|
+
if (disposed) return;
|
|
35
|
+
const taskId = selectedTask()?.id, memberId = memberIds()[memberIndex], eventId = events[eventIndex]?.id;
|
|
36
|
+
const changed = JSON.stringify(next.tasks) !== JSON.stringify(tasks);
|
|
37
|
+
snapshot = next; tasks = next.tasks; events = next.messages.slice().reverse();
|
|
38
|
+
taskIndex = Math.max(0, tasks.findIndex(task => task.id === taskId));
|
|
39
|
+
memberIndex = Math.max(0, memberIds().indexOf(memberId));
|
|
40
|
+
eventIndex = Math.max(0, events.findIndex(event => event.id === eventId));
|
|
41
|
+
if (changed) taskGraph = buildTaskGraph(tasks);
|
|
42
|
+
} catch (error) { if (!disposed) receipt = `Refresh failed: ${clean(String(error))}`; }
|
|
43
|
+
finally { if (!disposed) { refresh(); timer = setTimeout(() => void poll(), 2000); } }
|
|
44
|
+
}
|
|
45
|
+
timer = setTimeout(() => void poll(), 2000);
|
|
46
|
+
const fg = (color: Parameters<Theme["fg"]>[0], text: string) => theme.fg(color, text);
|
|
47
|
+
const statusText = (task: TaskView, text: string) => taskStatus(task) === "Ready" ? fg("muted", text)
|
|
48
|
+
: theme.style(text, { fg: indexedColor(({ Done: 2, Blocked: 1, Working: 6, Ready: 7 })[taskStatus(task)]) });
|
|
49
|
+
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)));
|
|
51
|
+
const heading = (text: string) => theme.bold(fg("text", text));
|
|
52
|
+
const label = (key: string, value: string) => fg("muted", key.padEnd(10)) + value;
|
|
53
|
+
const wrap = (lines: string[], width: number) => lines.flatMap(line => wrapTextWithAnsi(line, Math.max(1, width)));
|
|
54
|
+
const editor = new Editor(tui, {
|
|
55
|
+
borderColor: text => fg("accent", text),
|
|
56
|
+
selectList: {
|
|
57
|
+
selectedPrefix: text => fg("accent", text), selectedText: text => fg("accent", text),
|
|
58
|
+
description: text => fg("muted", text), scrollInfo: text => fg("dim", text), noMatch: text => fg("warning", text),
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const draftKey = () => compose === "new" ? "new" : `feedback:${clean(composeTask!.id)}`;
|
|
62
|
+
const begin = (kind: "new" | "feedback") => {
|
|
63
|
+
if (kind === "feedback" && !selectedTask()) return;
|
|
64
|
+
composeTask = kind === "feedback" ? selectedTask() : undefined;
|
|
65
|
+
compose = kind;
|
|
66
|
+
editor.setText(drafts.get(draftKey()) ?? "");
|
|
67
|
+
editor.focused = focused;
|
|
68
|
+
refresh();
|
|
69
|
+
};
|
|
70
|
+
editor.onSubmit = text => {
|
|
71
|
+
if (sending) return;
|
|
72
|
+
// Pi clears the editor before onSubmit; restore it until delivery succeeds.
|
|
73
|
+
editor.setText(text);
|
|
74
|
+
const message = clean(text).trim();
|
|
75
|
+
if (!message) { receipt = "Write a request or feedback before sending."; refresh(); return; }
|
|
76
|
+
const key = draftKey(), kind = compose, task = composeTask;
|
|
77
|
+
drafts.set(key, editor.getExpandedText());
|
|
78
|
+
sending = true; receipt = "Sending..."; refresh();
|
|
79
|
+
void (async () => {
|
|
80
|
+
try {
|
|
81
|
+
const result = kind === "new" ? await host.requestTask(message) : await host.feedback(task!, message);
|
|
82
|
+
drafts.delete(key);
|
|
83
|
+
compose = undefined; editor.focused = false;
|
|
84
|
+
receipt = clean(result);
|
|
85
|
+
} catch (error) { receipt = `Delivery failed: ${clean(String(error))}. Draft kept; retry or Esc.`; }
|
|
86
|
+
finally { sending = false; refresh(); }
|
|
87
|
+
})();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
async function openSession(id: string) {
|
|
91
|
+
if (opening || sending) return;
|
|
92
|
+
opening = true; receipt = `Opening ${name(id)}...`; refresh();
|
|
93
|
+
try {
|
|
94
|
+
const member = snapshot.members.find(member => member.id === id);
|
|
95
|
+
if (member && ["stopped", "interrupted"].includes(member.status)) await host.resume(id);
|
|
96
|
+
done(id);
|
|
97
|
+
} catch (error) { receipt = `Could not open ${name(id)}: ${clean(String(error))}`; }
|
|
98
|
+
finally { opening = false; refresh(); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function taskDetails(width: number) {
|
|
102
|
+
const task = selectedTask();
|
|
103
|
+
if (!task) return wrap([heading("No tasks yet"), "Request a task with N. Main creates and organizes it."], width);
|
|
104
|
+
const unlocked = tasks.filter(other => other.dependencies.includes(task.id));
|
|
105
|
+
return wrap([
|
|
106
|
+
heading(clean(taskName(task))), label("Title", clean(task.title)), label("ID", task.id), "",
|
|
107
|
+
label("State", stateLabel(task)), label("Owner", name(task.owner)), label("Creator", name(creator(task))), "",
|
|
108
|
+
heading("Description"), clean(task.description), "",
|
|
109
|
+
heading("Prerequisites"),
|
|
110
|
+
...(task.dependencies.length ? task.dependencies.map(id => {
|
|
111
|
+
const parent = tasks.find(item => item.id === id);
|
|
112
|
+
return parent ? `${clean(taskName(parent))} · ${taskStatus(parent)}` : `${clean(id)} · Unavailable`;
|
|
113
|
+
}) : [fg("muted", "None; this task can start independently.")]), "",
|
|
114
|
+
heading("Unlocks"), ...(unlocked.length ? unlocked.map(item => clean(taskName(item))) : [fg("muted", "No dependent tasks.")]),
|
|
115
|
+
"",
|
|
116
|
+
fg("accent", "C Give feedback to creator"), fg("accent", "O Open owner's full session"),
|
|
117
|
+
...(!detail ? [fg("muted", "Enter Full details")] : []),
|
|
118
|
+
], width);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function graphLines(width: number) {
|
|
122
|
+
if (!taskGraph) return undefined;
|
|
123
|
+
const { art, bounds } = taskGraph;
|
|
124
|
+
const selected = bounds.get(selectedTask()?.id ?? "");
|
|
125
|
+
if (!selected) return undefined;
|
|
126
|
+
if (selected.left < columnScroll) columnScroll = selected.left;
|
|
127
|
+
if (selected.right - selected.left + 1 >= width) columnScroll = selected.left;
|
|
128
|
+
else if (selected.right >= columnScroll + width) columnScroll = selected.right - width + 1;
|
|
129
|
+
columnScroll = Math.max(0, Math.min(columnScroll, Math.max(0, art.width - width)));
|
|
130
|
+
const rows = art.styled.map((spans, y) => {
|
|
131
|
+
let row = spans.map(span => fg(span.cls === "border" || span.cls === "edge" ? "dim" : "text", span.text)).join("");
|
|
132
|
+
for (const task of tasks) {
|
|
133
|
+
const box = bounds.get(task.id);
|
|
134
|
+
if (!box || y < box.top || y > box.bottom) continue;
|
|
135
|
+
const active = task.id === selectedTask()?.id;
|
|
136
|
+
let text = sliceByColumn(art.plain[y], box.left, box.right - box.left + 1);
|
|
137
|
+
if (active && y === box.top + 1) text = text.replace("○ ", "● ");
|
|
138
|
+
const border = y === box.top || y === box.bottom;
|
|
139
|
+
const content = sliceByColumn(text, 1, box.right - box.left - 1), status = taskStatus(task);
|
|
140
|
+
const color = status === "Done" ? "dim" : "text";
|
|
141
|
+
const index = y === box.bottom - 1 && content.trimEnd().endsWith(status) ? content.lastIndexOf(status) : -1;
|
|
142
|
+
const interior = index < 0 ? fg(color, content) : fg(color, content.slice(0, index)) + stateLabel(task) + fg(color, content.slice(index + status.length));
|
|
143
|
+
const styled = border ? statusText(task, text) : statusText(task, "│") + (active ? theme.bold(interior) : interior) + statusText(task, "│");
|
|
144
|
+
row = sliceByColumn(row, 0, box.left) + styled + sliceByColumn(row, box.right + 1, art.width);
|
|
145
|
+
}
|
|
146
|
+
const indent = " ".repeat(Math.max(0, Math.floor((width - art.width) / 2)));
|
|
147
|
+
return indent + sliceByColumn(row, columnScroll, width);
|
|
148
|
+
});
|
|
149
|
+
return { rows, selectedRow: selected.top, selectedHeight: selected.bottom - selected.top + 1, width: art.width };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function body(width: number): string[] {
|
|
153
|
+
if (compose) {
|
|
154
|
+
const target = compose === "new" ? "Main" : name(creator(composeTask!));
|
|
155
|
+
const title = compose === "new" ? "Request a new task" : `Give feedback · ${clean(taskName(composeTask!))}`;
|
|
156
|
+
const description = compose === "new" ? "Describe the outcome you want. Main will review existing work and explain whether to reuse, extend, or create a task."
|
|
157
|
+
: "Your comment goes to the task creator with task context.";
|
|
158
|
+
const intro = wrap([heading(title), label("To", target), "", description, ""], width);
|
|
159
|
+
const input = editor.render(width);
|
|
160
|
+
return [...intro, ...input].slice(0, bodyHeight);
|
|
161
|
+
}
|
|
162
|
+
if (detail) {
|
|
163
|
+
const lines = tab === 0 ? taskDetails(width) : memberDetails(width);
|
|
164
|
+
detailScroll = Math.min(detailScroll, Math.max(0, lines.length - bodyHeight));
|
|
165
|
+
return lines.slice(detailScroll, detailScroll + bodyHeight);
|
|
166
|
+
}
|
|
167
|
+
const split = width >= 83;
|
|
168
|
+
const leftWidth = split ? Math.floor((width - 3) * 0.6) : width;
|
|
169
|
+
const rightWidth = width - leftWidth - 3;
|
|
170
|
+
let left: string[], right: string[], selectedRow: number;
|
|
171
|
+
let selectedHeight = 1, graphWidth = 0;
|
|
172
|
+
if (tab === 0) {
|
|
173
|
+
const graphData = graph ? graphLines(leftWidth) : undefined;
|
|
174
|
+
left = graphData ? graphData.rows : tasks.length ? [fg("muted", " Task / State"), "",
|
|
175
|
+
...tasks.map((task, index) => {
|
|
176
|
+
const text = `${index === taskIndex ? "●" : " "} ${clean(taskName(task))}`;
|
|
177
|
+
const body = fg(taskStatus(task) === "Done" ? "dim" : "text", truncateToWidth(text, Math.max(1, leftWidth - 10)));
|
|
178
|
+
return (index === taskIndex ? theme.bold(body) : body) + " · " + stateLabel(task);
|
|
179
|
+
})] : [heading("No tasks yet"), "N Request a task from Main"];
|
|
180
|
+
selectedRow = graphData ? graphData.selectedRow : taskIndex + 2;
|
|
181
|
+
selectedHeight = graphData?.selectedHeight ?? 1;
|
|
182
|
+
graphWidth = graphData?.width ?? 0;
|
|
183
|
+
right = split ? taskDetails(rightWidth) : [];
|
|
184
|
+
if (graph && !graphData && tasks.length) left[0] = fg("muted", "Graph unavailable or too large; showing list.");
|
|
185
|
+
} else if (tab === 1) {
|
|
186
|
+
left = [fg("muted", " Member Session"), "", ...memberIds().map((id, index) => {
|
|
187
|
+
const description = id === "root" ? "Main conversation" : snapshot.members.find(member => member.id === id)?.status ?? "Unavailable";
|
|
188
|
+
return fg(index === memberIndex ? "accent" : "text", `${index === memberIndex ? "●" : " "} ${name(id).padEnd(12)} ${clean(description)}`);
|
|
189
|
+
})];
|
|
190
|
+
selectedRow = memberIndex + 2;
|
|
191
|
+
right = split ? memberDetails(rightWidth) : [];
|
|
192
|
+
} else {
|
|
193
|
+
left = events.length ? [fg("muted", " Recent messages · newest first"), "", ...events.flatMap((event, index) => [
|
|
194
|
+
`${index === eventIndex ? "●" : " "} ${name(event.from)} → ${event.recipients.map(name).join(", ") || "Team"}`,
|
|
195
|
+
fg("muted", ` ${clean(event.time)} · ${clean(event.text).split("\n")[0]}`), "",
|
|
196
|
+
])] : [heading("No activity yet"), fg("muted", "Recent persistent team messages appear here.")];
|
|
197
|
+
selectedRow = eventIndex * 3 + 2;
|
|
198
|
+
right = split ? eventDetails(rightWidth) : [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const viewHeight = Math.max(1, bodyHeight - (graphWidth ? 1 : 0));
|
|
202
|
+
if (selectedRow < scroll) scroll = selectedRow;
|
|
203
|
+
if (selectedRow + selectedHeight > scroll + viewHeight) scroll = selectedRow + selectedHeight - viewHeight;
|
|
204
|
+
scroll = Math.max(0, Math.min(scroll, Math.max(0, left.length - viewHeight)));
|
|
205
|
+
const viewport = left.slice(scroll, scroll + viewHeight);
|
|
206
|
+
if (graphWidth) viewport.unshift(fg("muted", `↑ ${scroll} rows above · ↓ ${Math.max(0, left.length - scroll - viewHeight)} below`
|
|
207
|
+
+ (graphWidth > leftWidth ? ` · ← ${columnScroll} cols · ${Math.max(0, graphWidth - columnScroll - leftWidth)} →` : "")));
|
|
208
|
+
return Array.from({ length: bodyHeight }, (_, index) => {
|
|
209
|
+
let row = viewport[index] ?? "";
|
|
210
|
+
if (!graphWidth && index === 0 && scroll > 0) row = fg("muted", `↑ ${scroll} rows above`);
|
|
211
|
+
if (!graphWidth && index === bodyHeight - 1 && scroll + bodyHeight < left.length) row = fg("muted", `↓ ${left.length - scroll - bodyHeight} rows below`);
|
|
212
|
+
return split ? pad(row, leftWidth) + fg("dim", " │ ") + truncateToWidth(right[index] ?? "", rightWidth) : row;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function eventDetails(width: number) {
|
|
217
|
+
const event = events[eventIndex];
|
|
218
|
+
if (!event) return wrap([heading("No activity yet"), "Recent persistent team messages appear here."], width);
|
|
219
|
+
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);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function memberDetails(width: number) {
|
|
223
|
+
if (tab === 2) return eventDetails(width);
|
|
224
|
+
const id = memberIds()[memberIndex];
|
|
225
|
+
const member = snapshot.members.find(member => member.id === id);
|
|
226
|
+
const assigned = tasks.filter(task => task.owner === id);
|
|
227
|
+
return wrap([heading(name(id)), "", label("State", clean(member?.status ?? (id === "root" ? "Main conversation" : "Unavailable"))),
|
|
228
|
+
label("ID", id), label("Model / context", clean(modelContext(member ?? {}))),
|
|
229
|
+
label("Cwd", clean(member?.cwd ?? (id === host.self ? ctx.cwd : "Unavailable"))),
|
|
230
|
+
label("Session", clean(member?.sessionFile ?? member?.sessionId ?? "Unavailable")),
|
|
231
|
+
label("Worktree", member?.worktree ? clean(`${member.worktree.path} (${member.worktree.branch})`) : "None"),
|
|
232
|
+
...(member?.error ? [label("Error", clean(member.error))] : []), "", heading("Assigned tasks"),
|
|
233
|
+
...assigned.map(task => `${clean(taskName(task))} · ${taskStatus(task)}`),
|
|
234
|
+
...(assigned.length ? [] : [fg("muted", "No assigned tasks.")]), "",
|
|
235
|
+
heading("Message with full context"), "Open the native Pi session to read its conversation and type your message there.", "",
|
|
236
|
+
fg("accent", member && ["stopped", "interrupted"].includes(member.status) ? "Enter Resume and open" : "Enter Open full session"),
|
|
237
|
+
fg("muted", "No separate message composer."),
|
|
238
|
+
], width);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
get focused() { return focused; },
|
|
243
|
+
set focused(value: boolean) { focused = value; editor.focused = value && !!compose; },
|
|
244
|
+
render(width: number) {
|
|
245
|
+
height = Math.max(1, tui.terminal.rows);
|
|
246
|
+
const inner = Math.max(1, width - 4);
|
|
247
|
+
const border = (left: string, right: string) => fg("dim", left + "─".repeat(Math.max(0, width - 2)) + right);
|
|
248
|
+
const row = (text: string) => fg("dim", "│ ") + pad(text, inner) + fg("dim", " │");
|
|
249
|
+
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}`);
|
|
250
|
+
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"}`) : "");
|
|
251
|
+
const help = compose ? "Enter Send · Shift+Enter Newline · Esc Keep draft"
|
|
252
|
+
: detail ? tab === 0 ? "↑↓ Scroll · C Feedback · O Open owner · Esc Back" : "↑↓ Scroll · O Open related session · Esc Back"
|
|
253
|
+
: tab === 0 ? `${graph ? "↑↓←→" : "↑↓"} Select · Enter Details · G Graph/List · C Feedback · N New task · Tab Next · Esc Close`
|
|
254
|
+
: tab === 1 ? "↑↓ Select · → Details · Enter Open session · N New task · Tab Next · Esc Close"
|
|
255
|
+
: "↑↓ Select · Enter Details · O Open related session · Tab Next · Esc Close";
|
|
256
|
+
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);
|
|
257
|
+
const receipts = wrapTextWithAnsi(fg("muted", receipt), inner);
|
|
258
|
+
const hints = wrapTextWithAnsi(fg("dim", help), inner);
|
|
259
|
+
bodyHeight = Math.max(1, height - 6 - summary.length - receipts.length - hints.length);
|
|
260
|
+
const content = body(inner);
|
|
261
|
+
return [border("╭", "╮"), row(title), ...summary.map(row), row(tabLine), border("├", "┤"),
|
|
262
|
+
...Array.from({ length: bodyHeight }, (_, index) => row(content[index] ?? "")), border("├", "┤"), ...receipts.map(row), ...hints.map(row), border("╰", "╯")].slice(0, height).map(line => truncateToWidth(line, width));
|
|
263
|
+
},
|
|
264
|
+
invalidate() { editor.invalidate(); },
|
|
265
|
+
dispose() {
|
|
266
|
+
disposed = true; clearTimeout(timer);
|
|
267
|
+
if (compose) drafts.set(draftKey(), editor.getExpandedText());
|
|
268
|
+
},
|
|
269
|
+
handleInput(data: string) {
|
|
270
|
+
if (compose) {
|
|
271
|
+
if (sending) return;
|
|
272
|
+
if (matchesKey(data, "escape")) {
|
|
273
|
+
drafts.set(draftKey(), editor.getExpandedText());
|
|
274
|
+
compose = undefined; editor.focused = false; receipt = "Draft kept locally; nothing sent.";
|
|
275
|
+
} else editor.handleInput(data);
|
|
276
|
+
refresh(); return;
|
|
277
|
+
}
|
|
278
|
+
if (opening) return;
|
|
279
|
+
const direction = (["up", "down", "left", "right"] as const).find(key => matchesKey(data, key));
|
|
280
|
+
if (matchesKey(data, "escape")) { if (detail) { detail = false; detailScroll = 0; } else done(undefined); }
|
|
281
|
+
else if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { tab = (tab + (matchesKey(data, "shift+tab") ? 2 : 1)) % 3; detail = false; scroll = 0; detailScroll = 0; }
|
|
282
|
+
else if (tab === 0 && graph && !detail && taskGraph && selectedTask() && direction) {
|
|
283
|
+
const id = moveTaskSelection(taskGraph.bounds, selectedTask()!.id, direction);
|
|
284
|
+
taskIndex = tasks.findIndex(task => task.id === id);
|
|
285
|
+
} else if (tab === 1 && matchesKey(data, "right")) { detail = true; detailScroll = 0; }
|
|
286
|
+
else if (matchesKey(data, "down") || matchesKey(data, "up")) {
|
|
287
|
+
const delta = matchesKey(data, "down") ? 1 : -1;
|
|
288
|
+
if (detail) detailScroll = Math.max(0, detailScroll + delta);
|
|
289
|
+
else if (tab === 0) taskIndex = Math.max(0, Math.min(tasks.length - 1, taskIndex + delta));
|
|
290
|
+
else if (tab === 1) memberIndex = Math.max(0, Math.min(memberIds().length - 1, memberIndex + delta));
|
|
291
|
+
else eventIndex = Math.max(0, Math.min(events.length - 1, eventIndex + delta));
|
|
292
|
+
} else if (data.toLowerCase() === "g" && tab === 0 && !detail) { graph = !graph; scroll = 0; }
|
|
293
|
+
else if (data.toLowerCase() === "n") begin("new");
|
|
294
|
+
else if (data.toLowerCase() === "c" && tab === 0) begin("feedback");
|
|
295
|
+
else if (matchesKey(data, "enter") || data.toLowerCase() === "o") {
|
|
296
|
+
if (tab === 1 || data.toLowerCase() === "o") {
|
|
297
|
+
const id = tab === 1 ? memberIds()[memberIndex] : tab === 0 ? selectedTask()?.owner : events[eventIndex]?.from;
|
|
298
|
+
if (!id) { receipt = "No related session available."; refresh(); return; }
|
|
299
|
+
void openSession(id);
|
|
300
|
+
} else { detail = true; detailScroll = 0; }
|
|
301
|
+
}
|
|
302
|
+
refresh();
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}, { overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%", margin: 0 } });
|
|
306
|
+
if (destination) {
|
|
307
|
+
try { await host.visit(destination); } catch (error) { ctx.ui.notify(`Could not open session: ${clean(String(error))}`, "error"); }
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import type { MessageRenderer, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { truncateToWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
export const cleanText = (text: string): string => stripVTControlCharacters(text).replaceAll("\t", " ").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
|
|
6
|
+
const inline = (value: unknown) => cleanText(String(value)).replace(/[\n\r\t]/g, " ");
|
|
7
|
+
const fieldName = (key: string) => key.replace(/([a-z])([A-Z])/g, "$1 $2").replaceAll("_", " ").replace(/^./, c => c.toUpperCase());
|
|
8
|
+
|
|
9
|
+
function payloadLines(value: unknown, theme: Theme, width: number, indent = 2): string[] {
|
|
10
|
+
const prefix = " ".repeat(Math.min(indent, Math.max(0, width - 1)));
|
|
11
|
+
if (value && typeof value === "object") {
|
|
12
|
+
const entries = Array.isArray(value) ? value.map((item, i) => [String(i + 1), item] as const) : Object.entries(value);
|
|
13
|
+
if (!entries.length) return [prefix + (Array.isArray(value) ? "[]" : "{}")];
|
|
14
|
+
return entries.flatMap(([key, item]) => {
|
|
15
|
+
const label = `${fieldName(inline(key))}:`;
|
|
16
|
+
const nested = item !== null && typeof item === "object";
|
|
17
|
+
const multiline = typeof item === "string" && item.includes("\n");
|
|
18
|
+
if (nested || multiline) return [
|
|
19
|
+
...wrapTextWithAnsi(theme.fg("muted", label), Math.max(1, width - prefix.length)).map(line => prefix + line),
|
|
20
|
+
...payloadLines(item, theme, width, indent + 2),
|
|
21
|
+
];
|
|
22
|
+
return wrapTextWithAnsi(theme.fg("muted", label) + " " + cleanText(String(item)), Math.max(1, width - prefix.length)).map(line => prefix + line);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return cleanText(String(value)).split("\n").flatMap(line => wrapTextWithAnsi(line, Math.max(1, width - prefix.length)).map(part => prefix + part));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function summary(value: unknown): string {
|
|
29
|
+
if (Array.isArray(value)) return `${value.length} item(s)`;
|
|
30
|
+
if (value && typeof value === "object") {
|
|
31
|
+
const fields = value as Record<string, unknown>;
|
|
32
|
+
return ["action", "name", ...(typeof fields.name === "string" ? [] : ["id"]), "status", "title", "from", "through", "error", "text", "content"].filter(key => fields[key] !== undefined && typeof fields[key] !== "object")
|
|
33
|
+
.map(key => `${fieldName(key)}: ${inline(fields[key])}`).join(" · ") || `${Object.keys(fields).length} field(s)`;
|
|
34
|
+
}
|
|
35
|
+
return inline(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function entry(title: string, value: unknown, expanded: boolean, theme: Theme, error = false, preview?: string): Component {
|
|
39
|
+
return {
|
|
40
|
+
render(width) {
|
|
41
|
+
const lines = [truncateToWidth(theme.fg(error ? "error" : "accent", `${inline(title)} · ${preview === undefined ? summary(value) : inline(preview)}`), width)];
|
|
42
|
+
if (expanded) lines.push("", ...payloadLines(value, theme, width));
|
|
43
|
+
return lines.map(line => truncateToWidth(line, width));
|
|
44
|
+
},
|
|
45
|
+
invalidate() {},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function teamToolRenderers(name: string): ToolRenderers {
|
|
50
|
+
return {
|
|
51
|
+
renderCall(args, theme, context) {
|
|
52
|
+
return entry(name, args, context.expanded, theme);
|
|
53
|
+
},
|
|
54
|
+
renderResult(result, options, theme, context) {
|
|
55
|
+
let data: unknown = result.details;
|
|
56
|
+
if (data === undefined) {
|
|
57
|
+
const text = result.content.filter(part => part.type === "text").map(part => part.text).join("\n");
|
|
58
|
+
try { data = JSON.parse(text); } catch { data = text; }
|
|
59
|
+
}
|
|
60
|
+
return entry(`${name} ${options.isPartial ? "receiving" : "result"}`, data, options.expanded, theme, context.isError);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const renderTeamMessage: MessageRenderer = (message, { expanded }, theme) => {
|
|
66
|
+
const details = message.details as { through?: number; messages?: { from: string; text: string; recipients: string[] }[] } | undefined;
|
|
67
|
+
const recent = details?.messages?.at(-1);
|
|
68
|
+
const name = (id: string) => id === "root" ? "Main" : id;
|
|
69
|
+
const preview = recent ? `${name(recent.from)} → ${recent.recipients.map(name).join(", ")} · ${recent.text.split("\n")[0]}` : `Updates through ${details?.through ?? "unknown"}`;
|
|
70
|
+
return entry("Team message", details ?? { content: message.content }, expanded, theme, false, preview);
|
|
71
|
+
};
|