@phren/agent 0.1.3 → 0.1.5
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/dist/agent-loop/index.js +214 -0
- package/dist/agent-loop/stream.js +124 -0
- package/dist/agent-loop/types.js +13 -0
- package/dist/agent-loop.js +7 -333
- package/dist/commands/info.js +146 -0
- package/dist/commands/memory.js +165 -0
- package/dist/commands/model.js +138 -0
- package/dist/commands/session.js +213 -0
- package/dist/commands.js +24 -643
- package/dist/index.js +9 -4
- package/dist/mcp-client.js +11 -7
- package/dist/multi/multi-commands.js +170 -0
- package/dist/multi/multi-events.js +81 -0
- package/dist/multi/multi-render.js +146 -0
- package/dist/multi/pane.js +28 -0
- package/dist/multi/tui-multi.js +39 -454
- package/dist/permissions/allowlist.js +2 -2
- package/dist/providers/anthropic.js +4 -2
- package/dist/providers/codex.js +9 -4
- package/dist/providers/openai-compat.js +6 -1
- package/dist/tools/glob.js +30 -6
- package/dist/tui/ansi.js +48 -0
- package/dist/tui/components/AgentMessage.js +5 -0
- package/dist/tui/components/App.js +68 -0
- package/dist/tui/components/Banner.js +44 -0
- package/dist/tui/components/ChatMessage.js +23 -0
- package/dist/tui/components/InputArea.js +23 -0
- package/dist/tui/components/Separator.js +7 -0
- package/dist/tui/components/StatusBar.js +25 -0
- package/dist/tui/components/SteerQueue.js +7 -0
- package/dist/tui/components/StreamingText.js +5 -0
- package/dist/tui/components/ThinkingIndicator.js +26 -0
- package/dist/tui/components/ToolCall.js +11 -0
- package/dist/tui/components/UserMessage.js +5 -0
- package/dist/tui/hooks/useKeyboardShortcuts.js +89 -0
- package/dist/tui/hooks/useSlashCommands.js +52 -0
- package/dist/tui/index.js +5 -0
- package/dist/tui/ink-entry.js +287 -0
- package/dist/tui/menu-mode.js +86 -0
- package/dist/tui/tool-render.js +43 -0
- package/dist/tui.js +149 -280
- package/package.json +9 -2
package/dist/index.js
CHANGED
|
@@ -184,12 +184,17 @@ export async function runAgentCli(raw) {
|
|
|
184
184
|
mcpCleanup?.();
|
|
185
185
|
return;
|
|
186
186
|
}
|
|
187
|
-
// Interactive mode — TUI if
|
|
187
|
+
// Interactive mode — Ink TUI if available, legacy TUI fallback, REPL if not TTY
|
|
188
188
|
if (args.interactive) {
|
|
189
189
|
const isTTY = process.stdout.isTTY && process.stdin.isTTY;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
let session;
|
|
191
|
+
if (!isTTY) {
|
|
192
|
+
session = await (await import("./repl.js")).startRepl(agentConfig);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// Ink TUI only — no legacy fallback
|
|
196
|
+
session = await (await import("./tui/ink-entry.js")).startInkTui(agentConfig);
|
|
197
|
+
}
|
|
193
198
|
// Flush anti-patterns at session end
|
|
194
199
|
if (phrenCtx) {
|
|
195
200
|
try {
|
package/dist/mcp-client.js
CHANGED
|
@@ -24,7 +24,8 @@ class McpConnection {
|
|
|
24
24
|
try {
|
|
25
25
|
const msg = JSON.parse(line);
|
|
26
26
|
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
27
|
-
const { resolve, reject } = this.pending.get(msg.id);
|
|
27
|
+
const { resolve, reject, timer } = this.pending.get(msg.id);
|
|
28
|
+
clearTimeout(timer);
|
|
28
29
|
this.pending.delete(msg.id);
|
|
29
30
|
if (msg.error)
|
|
30
31
|
reject(new Error(`MCP error: ${msg.error.message}`));
|
|
@@ -35,8 +36,10 @@ class McpConnection {
|
|
|
35
36
|
catch { /* ignore non-JSON lines */ }
|
|
36
37
|
});
|
|
37
38
|
this.proc.on("error", (err) => {
|
|
38
|
-
for (const { reject } of this.pending.values())
|
|
39
|
+
for (const { reject, timer } of this.pending.values()) {
|
|
40
|
+
clearTimeout(timer);
|
|
39
41
|
reject(err);
|
|
42
|
+
}
|
|
40
43
|
this.pending.clear();
|
|
41
44
|
});
|
|
42
45
|
}
|
|
@@ -44,15 +47,14 @@ class McpConnection {
|
|
|
44
47
|
return new Promise((resolve, reject) => {
|
|
45
48
|
const id = this.nextId++;
|
|
46
49
|
const msg = { jsonrpc: "2.0", id, method, params };
|
|
47
|
-
|
|
48
|
-
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
49
|
-
// Timeout after 30s
|
|
50
|
-
setTimeout(() => {
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
51
|
if (this.pending.has(id)) {
|
|
52
52
|
this.pending.delete(id);
|
|
53
53
|
reject(new Error(`MCP call ${method} timed out (30s)`));
|
|
54
54
|
}
|
|
55
55
|
}, 30_000);
|
|
56
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
57
|
+
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
56
58
|
});
|
|
57
59
|
}
|
|
58
60
|
async initialize() {
|
|
@@ -81,8 +83,10 @@ class McpConnection {
|
|
|
81
83
|
}
|
|
82
84
|
catch { /* ignore */ }
|
|
83
85
|
this.rl.close();
|
|
84
|
-
for (const { reject } of this.pending.values())
|
|
86
|
+
for (const { reject, timer } of this.pending.values()) {
|
|
87
|
+
clearTimeout(timer);
|
|
85
88
|
reject(new Error("Connection closed"));
|
|
89
|
+
}
|
|
86
90
|
this.pending.clear();
|
|
87
91
|
}
|
|
88
92
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createPane, appendToPane, flushPartial } from "./pane.js";
|
|
2
|
+
import { s, statusColor } from "./multi-render.js";
|
|
3
|
+
function resolveAgentTarget(target, agentOrder, panes, spawner) {
|
|
4
|
+
// Try numeric index (1-based)
|
|
5
|
+
const idx = parseInt(target, 10);
|
|
6
|
+
if (!isNaN(idx) && idx >= 1 && idx <= agentOrder.length) {
|
|
7
|
+
return agentOrder[idx - 1];
|
|
8
|
+
}
|
|
9
|
+
// Try name match
|
|
10
|
+
for (const [id, pane] of panes) {
|
|
11
|
+
if (pane.name === target)
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
// Try agent ID
|
|
15
|
+
if (spawner.getAgent(target))
|
|
16
|
+
return target;
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
function appendToSystem(ctx, text) {
|
|
20
|
+
if (!ctx.selectedId || !ctx.panes.has(ctx.selectedId)) {
|
|
21
|
+
// Create a virtual system pane
|
|
22
|
+
const pane = createPane("_system", "system");
|
|
23
|
+
ctx.panes.set("_system", pane);
|
|
24
|
+
if (!ctx.agentOrder.includes("_system"))
|
|
25
|
+
ctx.agentOrder.push("_system");
|
|
26
|
+
ctx.setSelectedId("_system");
|
|
27
|
+
appendToPane(pane, text + "\n");
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const pane = ctx.panes.get(ctx.selectedId);
|
|
31
|
+
flushPartial(pane);
|
|
32
|
+
appendToPane(pane, text + "\n");
|
|
33
|
+
}
|
|
34
|
+
ctx.render();
|
|
35
|
+
}
|
|
36
|
+
export function handleSlashCommand(line, ctx) {
|
|
37
|
+
const parts = line.split(/\s+/);
|
|
38
|
+
const cmd = parts[0].toLowerCase();
|
|
39
|
+
if (cmd === "/spawn") {
|
|
40
|
+
const name = parts[1];
|
|
41
|
+
const task = parts.slice(2).join(" ");
|
|
42
|
+
if (!name || !task) {
|
|
43
|
+
appendToSystem(ctx, "Usage: /spawn <name> <task>");
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
const opts = {
|
|
47
|
+
task,
|
|
48
|
+
cwd: process.cwd(),
|
|
49
|
+
provider: ctx.config.provider.name,
|
|
50
|
+
permissions: "auto-confirm",
|
|
51
|
+
verbose: ctx.config.verbose,
|
|
52
|
+
};
|
|
53
|
+
const agentId = ctx.spawner.spawn(opts);
|
|
54
|
+
const pane = ctx.getOrCreatePane(agentId);
|
|
55
|
+
pane.name = name;
|
|
56
|
+
appendToPane(pane, s.cyan(`Spawned agent "${name}" (${agentId}): ${task}`) + "\n");
|
|
57
|
+
ctx.setSelectedId(agentId);
|
|
58
|
+
ctx.render();
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (cmd === "/list") {
|
|
62
|
+
const agents = ctx.spawner.listAgents();
|
|
63
|
+
if (agents.length === 0) {
|
|
64
|
+
appendToSystem(ctx, "No agents.");
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const lines = ["Agents:"];
|
|
68
|
+
for (let i = 0; i < agents.length; i++) {
|
|
69
|
+
const a = agents[i];
|
|
70
|
+
const pane = ctx.panes.get(a.id);
|
|
71
|
+
const name = pane?.name ?? a.id;
|
|
72
|
+
const color = statusColor(a.status);
|
|
73
|
+
const elapsed = a.finishedAt
|
|
74
|
+
? `${((a.finishedAt - a.startedAt) / 1000).toFixed(1)}s`
|
|
75
|
+
: `${((Date.now() - a.startedAt) / 1000).toFixed(0)}s`;
|
|
76
|
+
lines.push(` ${i + 1}. ${name} [${color(a.status)}] ${s.dim(elapsed)} — ${a.task.slice(0, 50)}`);
|
|
77
|
+
}
|
|
78
|
+
appendToSystem(ctx, lines.join("\n"));
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (cmd === "/kill") {
|
|
83
|
+
const target = parts[1];
|
|
84
|
+
if (!target) {
|
|
85
|
+
appendToSystem(ctx, "Usage: /kill <name|index>");
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
const agentId = resolveAgentTarget(target, ctx.agentOrder, ctx.panes, ctx.spawner);
|
|
89
|
+
if (!agentId) {
|
|
90
|
+
appendToSystem(ctx, `Agent "${target}" not found.`);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
const ok = ctx.spawner.cancel(agentId);
|
|
94
|
+
const pane = ctx.getOrCreatePane(agentId);
|
|
95
|
+
if (ok) {
|
|
96
|
+
appendToPane(pane, s.yellow("\n--- Cancelled ---\n"));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
appendToSystem(ctx, `Agent "${target}" is not running.`);
|
|
100
|
+
}
|
|
101
|
+
ctx.render();
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
if (cmd === "/broadcast") {
|
|
105
|
+
const msg = parts.slice(1).join(" ");
|
|
106
|
+
if (!msg) {
|
|
107
|
+
appendToSystem(ctx, "Usage: /broadcast <message>");
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
const agents = ctx.spawner.listAgents();
|
|
111
|
+
let sent = 0;
|
|
112
|
+
for (const a of agents) {
|
|
113
|
+
if (a.status === "running") {
|
|
114
|
+
const pane = ctx.getOrCreatePane(a.id);
|
|
115
|
+
appendToPane(pane, s.yellow(`[broadcast] ${msg}`) + "\n");
|
|
116
|
+
sent++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
appendToSystem(ctx, `Broadcast sent to ${sent} running agent(s).`);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (cmd === "/msg") {
|
|
123
|
+
const target = parts[1];
|
|
124
|
+
const msg = parts.slice(2).join(" ");
|
|
125
|
+
if (!target || !msg) {
|
|
126
|
+
appendToSystem(ctx, "Usage: /msg <agent> <text>");
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
const agentId = resolveAgentTarget(target, ctx.agentOrder, ctx.panes, ctx.spawner);
|
|
130
|
+
if (!agentId) {
|
|
131
|
+
appendToSystem(ctx, `Agent "${target}" not found.`);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
const ok = ctx.spawner.sendToAgent(agentId, msg, "user");
|
|
135
|
+
if (ok) {
|
|
136
|
+
const recipientPane = ctx.getOrCreatePane(agentId);
|
|
137
|
+
flushPartial(recipientPane);
|
|
138
|
+
appendToPane(recipientPane, s.yellow(`[user -> ${recipientPane.name}] ${msg}`) + "\n");
|
|
139
|
+
if (ctx.selectedId && ctx.selectedId !== agentId && ctx.panes.has(ctx.selectedId)) {
|
|
140
|
+
const curPane = ctx.panes.get(ctx.selectedId);
|
|
141
|
+
flushPartial(curPane);
|
|
142
|
+
appendToPane(curPane, s.yellow(`[user -> ${recipientPane.name}] ${msg}`) + "\n");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
appendToSystem(ctx, `Agent "${target}" is not running.`);
|
|
147
|
+
}
|
|
148
|
+
ctx.render();
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
if (cmd === "/help") {
|
|
152
|
+
appendToSystem(ctx, [
|
|
153
|
+
"Commands:",
|
|
154
|
+
" /spawn <name> <task> — Spawn a new agent",
|
|
155
|
+
" /list — List all agents",
|
|
156
|
+
" /kill <name|index> — Terminate an agent",
|
|
157
|
+
" /msg <agent> <text> — Send direct message to an agent",
|
|
158
|
+
" /broadcast <msg> — Send to all running agents",
|
|
159
|
+
" /help — Show this help",
|
|
160
|
+
"",
|
|
161
|
+
"Keys:",
|
|
162
|
+
" 1-9 — Select agent by number",
|
|
163
|
+
" Ctrl+Left/Right — Cycle agents",
|
|
164
|
+
" PageUp/PageDown — Scroll output",
|
|
165
|
+
" Ctrl+D — Exit (kills all)",
|
|
166
|
+
].join("\n"));
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { appendToPane, flushPartial } from "./pane.js";
|
|
2
|
+
import { getAgentStyle } from "./agent-colors.js";
|
|
3
|
+
import { decodeDiffPayload, renderInlineDiff, DIFF_MARKER } from "./diff-renderer.js";
|
|
4
|
+
import { s, formatToolStart, formatToolEnd } from "./multi-render.js";
|
|
5
|
+
export function wireSpawnerEvents(spawner, ctx) {
|
|
6
|
+
spawner.on("text_delta", (agentId, text) => {
|
|
7
|
+
const pane = ctx.getOrCreatePane(agentId);
|
|
8
|
+
appendToPane(pane, text);
|
|
9
|
+
if (agentId === ctx.getSelectedId())
|
|
10
|
+
ctx.render();
|
|
11
|
+
});
|
|
12
|
+
spawner.on("text_block", (agentId, text) => {
|
|
13
|
+
const pane = ctx.getOrCreatePane(agentId);
|
|
14
|
+
appendToPane(pane, text + "\n");
|
|
15
|
+
if (agentId === ctx.getSelectedId())
|
|
16
|
+
ctx.render();
|
|
17
|
+
});
|
|
18
|
+
spawner.on("tool_start", (agentId, toolName, input) => {
|
|
19
|
+
const pane = ctx.getOrCreatePane(agentId);
|
|
20
|
+
flushPartial(pane);
|
|
21
|
+
appendToPane(pane, formatToolStart(toolName, input) + "\n");
|
|
22
|
+
if (agentId === ctx.getSelectedId())
|
|
23
|
+
ctx.render();
|
|
24
|
+
});
|
|
25
|
+
spawner.on("tool_end", (agentId, toolName, input, output, isError, durationMs) => {
|
|
26
|
+
const pane = ctx.getOrCreatePane(agentId);
|
|
27
|
+
flushPartial(pane);
|
|
28
|
+
const diffData = (toolName === "edit_file" || toolName === "write_file") ? decodeDiffPayload(output) : null;
|
|
29
|
+
const cleanOutput = diffData ? output.slice(0, output.indexOf(DIFF_MARKER)) : output;
|
|
30
|
+
appendToPane(pane, formatToolEnd(toolName, input, cleanOutput, isError, durationMs) + "\n");
|
|
31
|
+
if (diffData) {
|
|
32
|
+
appendToPane(pane, renderInlineDiff(diffData.oldContent, diffData.newContent, diffData.filePath) + "\n");
|
|
33
|
+
}
|
|
34
|
+
if (agentId === ctx.getSelectedId())
|
|
35
|
+
ctx.render();
|
|
36
|
+
});
|
|
37
|
+
spawner.on("status", (agentId, message) => {
|
|
38
|
+
const pane = ctx.getOrCreatePane(agentId);
|
|
39
|
+
appendToPane(pane, s.dim(message) + "\n");
|
|
40
|
+
if (agentId === ctx.getSelectedId())
|
|
41
|
+
ctx.render();
|
|
42
|
+
});
|
|
43
|
+
spawner.on("done", (agentId, result) => {
|
|
44
|
+
const pane = ctx.getOrCreatePane(agentId);
|
|
45
|
+
flushPartial(pane);
|
|
46
|
+
const style = getAgentStyle(pane.index);
|
|
47
|
+
appendToPane(pane, "\n" + style.color(`--- ${style.icon} Agent completed ---`) + "\n");
|
|
48
|
+
appendToPane(pane, s.dim(` Turns: ${result.turns} Tool calls: ${result.toolCalls}${result.totalCost ? ` Cost: ${result.totalCost}` : ""}`) + "\n");
|
|
49
|
+
ctx.render();
|
|
50
|
+
});
|
|
51
|
+
spawner.on("error", (agentId, error) => {
|
|
52
|
+
const pane = ctx.getOrCreatePane(agentId);
|
|
53
|
+
flushPartial(pane);
|
|
54
|
+
const style = getAgentStyle(pane.index);
|
|
55
|
+
appendToPane(pane, "\n" + style.color(`--- ${style.icon} Error: ${error} ---`) + "\n");
|
|
56
|
+
ctx.render();
|
|
57
|
+
});
|
|
58
|
+
spawner.on("exit", (agentId, code) => {
|
|
59
|
+
const pane = ctx.getOrCreatePane(agentId);
|
|
60
|
+
if (code !== null && code !== 0) {
|
|
61
|
+
appendToPane(pane, s.dim(` Process exited with code ${code}`) + "\n");
|
|
62
|
+
}
|
|
63
|
+
ctx.render();
|
|
64
|
+
});
|
|
65
|
+
spawner.on("message", (from, to, content) => {
|
|
66
|
+
const senderPane = ctx.panes.get(from);
|
|
67
|
+
if (senderPane) {
|
|
68
|
+
flushPartial(senderPane);
|
|
69
|
+
const toName = ctx.panes.get(to)?.name ?? to;
|
|
70
|
+
appendToPane(senderPane, s.yellow(`[${senderPane.name} -> ${toName}] ${content}`) + "\n");
|
|
71
|
+
}
|
|
72
|
+
const recipientPane = ctx.panes.get(to);
|
|
73
|
+
if (recipientPane) {
|
|
74
|
+
flushPartial(recipientPane);
|
|
75
|
+
const fromName = senderPane?.name ?? from;
|
|
76
|
+
appendToPane(recipientPane, s.yellow(`[${fromName} -> ${recipientPane.name}] ${content}`) + "\n");
|
|
77
|
+
}
|
|
78
|
+
if (from === ctx.getSelectedId() || to === ctx.getSelectedId())
|
|
79
|
+
ctx.render();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { getAgentStyle, formatAgentName } from "./agent-colors.js";
|
|
2
|
+
// ── ANSI helpers (mirrors tui.ts pattern) ────────────────────────────────────
|
|
3
|
+
export const ESC = "\x1b[";
|
|
4
|
+
export const s = {
|
|
5
|
+
reset: `${ESC}0m`,
|
|
6
|
+
bold: (t) => `${ESC}1m${t}${ESC}0m`,
|
|
7
|
+
dim: (t) => `${ESC}2m${t}${ESC}0m`,
|
|
8
|
+
cyan: (t) => `${ESC}36m${t}${ESC}0m`,
|
|
9
|
+
green: (t) => `${ESC}32m${t}${ESC}0m`,
|
|
10
|
+
yellow: (t) => `${ESC}33m${t}${ESC}0m`,
|
|
11
|
+
red: (t) => `${ESC}31m${t}${ESC}0m`,
|
|
12
|
+
gray: (t) => `${ESC}90m${t}${ESC}0m`,
|
|
13
|
+
white: (t) => `${ESC}37m${t}${ESC}0m`,
|
|
14
|
+
bgGreen: (t) => `${ESC}42m${t}${ESC}0m`,
|
|
15
|
+
bgRed: (t) => `${ESC}41m${t}${ESC}0m`,
|
|
16
|
+
bgGray: (t) => `${ESC}100m${t}${ESC}0m`,
|
|
17
|
+
bgCyan: (t) => `${ESC}46m${t}${ESC}0m`,
|
|
18
|
+
bgYellow: (t) => `${ESC}43m${t}${ESC}0m`,
|
|
19
|
+
invert: (t) => `${ESC}7m${t}${ESC}0m`,
|
|
20
|
+
};
|
|
21
|
+
function stripAnsi(t) {
|
|
22
|
+
return t.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
23
|
+
}
|
|
24
|
+
function cols() {
|
|
25
|
+
return process.stdout.columns || 80;
|
|
26
|
+
}
|
|
27
|
+
function rows() {
|
|
28
|
+
return process.stdout.rows || 24;
|
|
29
|
+
}
|
|
30
|
+
export function statusColor(status) {
|
|
31
|
+
switch (status) {
|
|
32
|
+
case "starting": return s.yellow;
|
|
33
|
+
case "running": return s.green;
|
|
34
|
+
case "done": return s.gray;
|
|
35
|
+
case "error": return s.red;
|
|
36
|
+
case "cancelled": return s.gray;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function formatToolStart(toolName, input) {
|
|
40
|
+
const preview = JSON.stringify(input).slice(0, 60);
|
|
41
|
+
return s.dim(` > ${toolName}(${preview})...`);
|
|
42
|
+
}
|
|
43
|
+
export function formatToolEnd(toolName, input, output, isError, durationMs) {
|
|
44
|
+
const dur = durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
|
|
45
|
+
const icon = isError ? s.red("x") : s.green("ok");
|
|
46
|
+
const preview = JSON.stringify(input).slice(0, 50);
|
|
47
|
+
const header = s.dim(` ${toolName}(${preview})`) + ` ${icon} ${s.dim(dur)}`;
|
|
48
|
+
const allLines = output.split("\n");
|
|
49
|
+
const w = cols();
|
|
50
|
+
const body = allLines.slice(0, 4).map((l) => s.dim(` | ${l.slice(0, w - 6)}`)).join("\n");
|
|
51
|
+
const more = allLines.length > 4 ? `\n${s.dim(` | ... (${allLines.length} lines)`)}` : "";
|
|
52
|
+
return `${header}\n${body}${more}`;
|
|
53
|
+
}
|
|
54
|
+
export function renderTopBar(spawner, panes, selectedId) {
|
|
55
|
+
const w_ = cols();
|
|
56
|
+
const agents = spawner.listAgents();
|
|
57
|
+
const tabs = [];
|
|
58
|
+
for (let i = 0; i < agents.length; i++) {
|
|
59
|
+
const a = agents[i];
|
|
60
|
+
const isSel = a.id === selectedId;
|
|
61
|
+
const pane = panes.get(a.id);
|
|
62
|
+
const paneIdx = pane?.index ?? i;
|
|
63
|
+
const stColor = statusColor(a.status);
|
|
64
|
+
const agentLabel = formatAgentName(pane?.name ?? a.task.slice(0, 12), paneIdx);
|
|
65
|
+
const statusTag = stColor(a.status);
|
|
66
|
+
const raw = ` ${i + 1}:${stripAnsi(agentLabel)} [${stripAnsi(statusTag)}] `;
|
|
67
|
+
const colored = ` ${i + 1}:${agentLabel} [${statusTag}] `;
|
|
68
|
+
const tab = isSel ? s.invert(raw) : colored;
|
|
69
|
+
tabs.push(tab);
|
|
70
|
+
}
|
|
71
|
+
if (agents.length === 0) {
|
|
72
|
+
tabs.push(s.dim(" no agents "));
|
|
73
|
+
}
|
|
74
|
+
const title = s.bold(" phren-multi ");
|
|
75
|
+
const tabStr = tabs.join(s.dim("|"));
|
|
76
|
+
const line = title + s.dim("|") + tabStr;
|
|
77
|
+
const pad = Math.max(0, w_ - stripAnsi(line).length);
|
|
78
|
+
return s.invert(stripAnsi(title)) + s.dim("|") + tabStr + " ".repeat(pad);
|
|
79
|
+
}
|
|
80
|
+
export function renderMainArea(panes, selectedId, scrollOffset) {
|
|
81
|
+
const availRows = rows() - 3; // top bar + bottom bar + input line
|
|
82
|
+
if (availRows < 1)
|
|
83
|
+
return [];
|
|
84
|
+
if (!selectedId || !panes.has(selectedId)) {
|
|
85
|
+
const emptyMsg = s.dim(" No agent selected. Use /spawn <name> <task> to create one.");
|
|
86
|
+
const lines = [emptyMsg];
|
|
87
|
+
while (lines.length < availRows)
|
|
88
|
+
lines.push("");
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
const pane = panes.get(selectedId);
|
|
92
|
+
// Include partial line if any
|
|
93
|
+
const allLines = [...pane.lines];
|
|
94
|
+
if (pane.partial)
|
|
95
|
+
allLines.push(pane.partial);
|
|
96
|
+
// Apply scroll offset
|
|
97
|
+
const totalLines = allLines.length;
|
|
98
|
+
let start = Math.max(0, totalLines - availRows - scrollOffset);
|
|
99
|
+
let end = start + availRows;
|
|
100
|
+
if (end > totalLines) {
|
|
101
|
+
end = totalLines;
|
|
102
|
+
start = Math.max(0, end - availRows);
|
|
103
|
+
}
|
|
104
|
+
const visible = allLines.slice(start, end);
|
|
105
|
+
const w_ = cols();
|
|
106
|
+
const output = [];
|
|
107
|
+
const paneStyle = getAgentStyle(pane.index);
|
|
108
|
+
const linePrefix = paneStyle.color(paneStyle.icon) + " ";
|
|
109
|
+
const prefixLen = 2; // icon + space
|
|
110
|
+
for (const line of visible) {
|
|
111
|
+
output.push(linePrefix + line.slice(0, w_ - prefixLen));
|
|
112
|
+
}
|
|
113
|
+
// Pad remaining rows
|
|
114
|
+
while (output.length < availRows)
|
|
115
|
+
output.push("");
|
|
116
|
+
return output;
|
|
117
|
+
}
|
|
118
|
+
export function renderBottomBar(spawner) {
|
|
119
|
+
const w_ = cols();
|
|
120
|
+
const agentCount = spawner.listAgents().length;
|
|
121
|
+
const runningCount = spawner.getAgentsByStatus("running").length;
|
|
122
|
+
const left = ` Agents: ${agentCount} (${runningCount} running)`;
|
|
123
|
+
const right = `1-9:select Ctrl+</>:cycle /spawn /list /kill /broadcast Ctrl+D:exit `;
|
|
124
|
+
const pad = Math.max(0, w_ - left.length - right.length);
|
|
125
|
+
return s.invert(left + " ".repeat(pad) + right);
|
|
126
|
+
}
|
|
127
|
+
export function render(w, spawner, panes, selectedId, scrollOffset, inputLine) {
|
|
128
|
+
// Hide cursor, move to top, clear screen
|
|
129
|
+
w.write(`${ESC}?25l${ESC}H${ESC}2J`);
|
|
130
|
+
// Top bar
|
|
131
|
+
w.write(renderTopBar(spawner, panes, selectedId));
|
|
132
|
+
w.write("\n");
|
|
133
|
+
// Main area
|
|
134
|
+
const mainLines = renderMainArea(panes, selectedId, scrollOffset);
|
|
135
|
+
for (const line of mainLines) {
|
|
136
|
+
w.write(line + "\n");
|
|
137
|
+
}
|
|
138
|
+
// Bottom bar
|
|
139
|
+
w.write(renderBottomBar(spawner));
|
|
140
|
+
w.write("\n");
|
|
141
|
+
// Input line
|
|
142
|
+
const prompt = s.cyan("multi> ");
|
|
143
|
+
w.write(prompt + inputLine);
|
|
144
|
+
// Show cursor
|
|
145
|
+
w.write(`${ESC}?25h`);
|
|
146
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const MAX_SCROLLBACK = 1000;
|
|
2
|
+
let nextPaneIndex = 0;
|
|
3
|
+
export function resetPaneIndex() {
|
|
4
|
+
nextPaneIndex = 0;
|
|
5
|
+
}
|
|
6
|
+
export function createPane(agentId, name) {
|
|
7
|
+
return { agentId, name, index: nextPaneIndex++, lines: [], partial: "" };
|
|
8
|
+
}
|
|
9
|
+
export function appendToPane(pane, text) {
|
|
10
|
+
// Merge with partial line buffer
|
|
11
|
+
const combined = pane.partial + text;
|
|
12
|
+
const parts = combined.split("\n");
|
|
13
|
+
// Everything except the last segment is a complete line
|
|
14
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
15
|
+
pane.lines.push(parts[i]);
|
|
16
|
+
}
|
|
17
|
+
pane.partial = parts[parts.length - 1];
|
|
18
|
+
// Enforce scrollback cap
|
|
19
|
+
if (pane.lines.length > MAX_SCROLLBACK) {
|
|
20
|
+
pane.lines.splice(0, pane.lines.length - MAX_SCROLLBACK);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function flushPartial(pane) {
|
|
24
|
+
if (pane.partial) {
|
|
25
|
+
pane.lines.push(pane.partial);
|
|
26
|
+
pane.partial = "";
|
|
27
|
+
}
|
|
28
|
+
}
|