@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
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { createSession, runTurn } from "../agent-loop.js";
|
|
4
|
+
import { useSlashCommands } from "./hooks/useSlashCommands.js";
|
|
5
|
+
import { decodeDiffPayload, DIFF_MARKER } from "../multi/diff-renderer.js";
|
|
6
|
+
import * as os from "os";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { loadInputMode, saveInputMode, savePermissionMode } from "../settings.js";
|
|
10
|
+
import { nextPermissionMode } from "./ansi.js";
|
|
11
|
+
import { App } from "./components/App.js";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
const _require = createRequire(import.meta.url);
|
|
14
|
+
const AGENT_VERSION = _require("../../package.json").version;
|
|
15
|
+
export async function startInkTui(config, spawner) {
|
|
16
|
+
const contextLimit = config.provider.contextWindow ?? 200_000;
|
|
17
|
+
const session = createSession(contextLimit);
|
|
18
|
+
const startTime = Date.now();
|
|
19
|
+
let inputMode = loadInputMode();
|
|
20
|
+
let pendingInput = null;
|
|
21
|
+
const steerQueueBuf = [];
|
|
22
|
+
const inputHistory = [];
|
|
23
|
+
let running = false;
|
|
24
|
+
let msgCounter = 0;
|
|
25
|
+
// Mutable render state — updated then pushed to React via rerender()
|
|
26
|
+
const completedMessages = [];
|
|
27
|
+
let streamingText = "";
|
|
28
|
+
let thinking = false;
|
|
29
|
+
let thinkStartTime = 0;
|
|
30
|
+
let thinkElapsed = null;
|
|
31
|
+
let currentToolCalls = [];
|
|
32
|
+
function nextId() {
|
|
33
|
+
return `msg-${++msgCounter}`;
|
|
34
|
+
}
|
|
35
|
+
function getAppState() {
|
|
36
|
+
return {
|
|
37
|
+
provider: config.provider.name,
|
|
38
|
+
project: config.phrenCtx?.project ?? null,
|
|
39
|
+
turns: session.turns,
|
|
40
|
+
cost: "",
|
|
41
|
+
permMode: config.registry.permissionConfig.mode,
|
|
42
|
+
agentCount: spawner?.listAgents().length ?? 0,
|
|
43
|
+
version: AGENT_VERSION,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Re-render the Ink app with current state
|
|
47
|
+
let rerender = null;
|
|
48
|
+
function update() {
|
|
49
|
+
if (!rerender)
|
|
50
|
+
return;
|
|
51
|
+
rerender(_jsx(App, { state: getAppState(), completedMessages: [...completedMessages], streamingText: streamingText, completedToolCalls: [...currentToolCalls], thinking: thinking, thinkStartTime: thinkStartTime, thinkElapsed: thinkElapsed, steerQueue: [...steerQueueBuf], running: running, showBanner: true, inputHistory: [...inputHistory], onSubmit: handleSubmit, onPermissionCycle: handlePermissionCycle, onCancelTurn: handleCancelTurn, onExit: handleExit }));
|
|
52
|
+
}
|
|
53
|
+
function handlePermissionCycle() {
|
|
54
|
+
const next = nextPermissionMode(config.registry.permissionConfig.mode);
|
|
55
|
+
config.registry.setPermissions({ ...config.registry.permissionConfig, mode: next });
|
|
56
|
+
savePermissionMode(next);
|
|
57
|
+
update();
|
|
58
|
+
}
|
|
59
|
+
let resolveSession = null;
|
|
60
|
+
function handleExit() {
|
|
61
|
+
if (resolveSession)
|
|
62
|
+
resolveSession(session);
|
|
63
|
+
}
|
|
64
|
+
function handleCancelTurn() {
|
|
65
|
+
// Signal cancellation — the running turn will see this via steering
|
|
66
|
+
pendingInput = null;
|
|
67
|
+
steerQueueBuf.length = 0;
|
|
68
|
+
update();
|
|
69
|
+
}
|
|
70
|
+
// Slash command handler — captures stderr and displays as status messages
|
|
71
|
+
const slashCommands = useSlashCommands({
|
|
72
|
+
commandContext: {
|
|
73
|
+
session,
|
|
74
|
+
contextLimit,
|
|
75
|
+
undoStack: [],
|
|
76
|
+
providerName: config.provider.name,
|
|
77
|
+
currentModel: config.provider.model,
|
|
78
|
+
provider: config.provider,
|
|
79
|
+
systemPrompt: config.systemPrompt,
|
|
80
|
+
spawner,
|
|
81
|
+
sessionId: config.sessionId,
|
|
82
|
+
startTime,
|
|
83
|
+
phrenPath: config.phrenCtx?.phrenPath,
|
|
84
|
+
phrenCtx: config.phrenCtx,
|
|
85
|
+
onModelChange: async (result) => {
|
|
86
|
+
try {
|
|
87
|
+
const { resolveProvider } = await import("../providers/resolve.js");
|
|
88
|
+
const newProvider = resolveProvider(config.provider.name, result.model);
|
|
89
|
+
config.provider = newProvider;
|
|
90
|
+
const { buildSystemPrompt } = await import("../system-prompt.js");
|
|
91
|
+
config.systemPrompt = buildSystemPrompt(config.systemPrompt.split("\n## Last session")[0], null, { name: newProvider.name, model: result.model });
|
|
92
|
+
update();
|
|
93
|
+
}
|
|
94
|
+
catch { /* keep current provider */ }
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
onOutput: (text) => {
|
|
98
|
+
completedMessages.push({ id: nextId(), kind: "status", text });
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
function handleSubmit(input) {
|
|
102
|
+
console.error(`[BRIDGE] handleSubmit: ${JSON.stringify(input.slice(0, 40))} running=${running}`);
|
|
103
|
+
const line = input.trim();
|
|
104
|
+
if (!line)
|
|
105
|
+
return;
|
|
106
|
+
// Track input history (skip duplicates of the last entry)
|
|
107
|
+
if (inputHistory.length === 0 || inputHistory[inputHistory.length - 1] !== line) {
|
|
108
|
+
inputHistory.push(line);
|
|
109
|
+
}
|
|
110
|
+
// Bash mode: ! prefix
|
|
111
|
+
if (line.startsWith("!")) {
|
|
112
|
+
const cmd = line.slice(1).trim();
|
|
113
|
+
let output = "";
|
|
114
|
+
if (cmd) {
|
|
115
|
+
const cdMatch = cmd.match(/^cd\s+(.*)/);
|
|
116
|
+
if (cdMatch) {
|
|
117
|
+
try {
|
|
118
|
+
const target = cdMatch[1].trim().replace(/^~/, os.homedir());
|
|
119
|
+
process.chdir(path.resolve(process.cwd(), target));
|
|
120
|
+
output = process.cwd();
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
output = err.message;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
try {
|
|
128
|
+
output = execSync(cmd, { encoding: "utf-8", timeout: 30_000, cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
const e = err;
|
|
132
|
+
output = e.stderr || e.message || "Command failed";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (output) {
|
|
137
|
+
completedMessages.push({ id: nextId(), kind: "status", text: output.replace(/\n$/, "") });
|
|
138
|
+
}
|
|
139
|
+
update();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Slash commands
|
|
143
|
+
if (line === "/mode") {
|
|
144
|
+
inputMode = inputMode === "steering" ? "queue" : "steering";
|
|
145
|
+
saveInputMode(inputMode);
|
|
146
|
+
completedMessages.push({ id: nextId(), kind: "status", text: `Input mode: ${inputMode}` });
|
|
147
|
+
update();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// Slash commands — capture stderr output and display as status message
|
|
151
|
+
if (line.startsWith("/")) {
|
|
152
|
+
if (slashCommands.tryHandleCommand(line)) {
|
|
153
|
+
update();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// If agent running, queue input for steering
|
|
158
|
+
if (running) {
|
|
159
|
+
if (inputMode === "steering") {
|
|
160
|
+
steerQueueBuf.push(line);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
pendingInput = line;
|
|
164
|
+
}
|
|
165
|
+
update();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Normal user message — add to completed history and run agent turn
|
|
169
|
+
completedMessages.push({ id: nextId(), kind: "user", text: line });
|
|
170
|
+
update();
|
|
171
|
+
runAgentTurn(line);
|
|
172
|
+
}
|
|
173
|
+
// TurnHooks bridge — updates mutable state, calls update()
|
|
174
|
+
const tuiHooks = {
|
|
175
|
+
onTextDelta: (text) => {
|
|
176
|
+
console.error(`[BRIDGE] onTextDelta: ${JSON.stringify(text.slice(0, 40))}`);
|
|
177
|
+
thinking = false;
|
|
178
|
+
streamingText += text;
|
|
179
|
+
update();
|
|
180
|
+
},
|
|
181
|
+
onTextDone: () => {
|
|
182
|
+
console.error("[BRIDGE] onTextDone");
|
|
183
|
+
// streaming complete — finalized in runAgentTurn
|
|
184
|
+
},
|
|
185
|
+
onTextBlock: (text) => {
|
|
186
|
+
console.error(`[BRIDGE] onTextBlock: ${JSON.stringify(text.slice(0, 40))}`);
|
|
187
|
+
thinking = false;
|
|
188
|
+
streamingText += text;
|
|
189
|
+
update();
|
|
190
|
+
},
|
|
191
|
+
onToolStart: (_name, _input, _count) => {
|
|
192
|
+
console.error(`[BRIDGE] onToolStart: ${_name} (${_count} tools)`);
|
|
193
|
+
thinking = false;
|
|
194
|
+
update();
|
|
195
|
+
},
|
|
196
|
+
onToolEnd: (name, input, output, isError, dur) => {
|
|
197
|
+
console.error(`[BRIDGE] onToolEnd: ${name} isError=${isError} dur=${dur}ms`);
|
|
198
|
+
const diffData = (name === "edit_file" || name === "write_file") ? decodeDiffPayload(output) : null;
|
|
199
|
+
const cleanOutput = diffData ? output.slice(0, output.indexOf(DIFF_MARKER)) : output;
|
|
200
|
+
currentToolCalls.push({ name, input, output: cleanOutput, isError, durationMs: dur });
|
|
201
|
+
update();
|
|
202
|
+
},
|
|
203
|
+
onStatus: (msg) => { console.error(`[BRIDGE] onStatus: ${msg.slice(0, 60)}`); update(); },
|
|
204
|
+
getSteeringInput: () => {
|
|
205
|
+
const result = (() => {
|
|
206
|
+
if (steerQueueBuf.length > 0 && inputMode === "steering") {
|
|
207
|
+
return steerQueueBuf.shift();
|
|
208
|
+
}
|
|
209
|
+
if (pendingInput && inputMode === "steering") {
|
|
210
|
+
const steer = pendingInput;
|
|
211
|
+
pendingInput = null;
|
|
212
|
+
return steer;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
})();
|
|
216
|
+
if (result)
|
|
217
|
+
console.error(`[BRIDGE] getSteeringInput: ${JSON.stringify(result.slice(0, 40))}`);
|
|
218
|
+
return result;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
async function runAgentTurn(userInput) {
|
|
222
|
+
console.error(`[BRIDGE] runAgentTurn START: ${JSON.stringify(userInput.slice(0, 40))}`);
|
|
223
|
+
running = true;
|
|
224
|
+
thinking = true;
|
|
225
|
+
thinkStartTime = Date.now();
|
|
226
|
+
thinkElapsed = null;
|
|
227
|
+
streamingText = "";
|
|
228
|
+
currentToolCalls = [];
|
|
229
|
+
update();
|
|
230
|
+
try {
|
|
231
|
+
await runTurn(userInput, session, config, tuiHooks);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
235
|
+
console.error(`[BRIDGE] runAgentTurn ERROR: ${msg}`);
|
|
236
|
+
streamingText += `\nError: ${msg}`;
|
|
237
|
+
}
|
|
238
|
+
// Compute elapsed time
|
|
239
|
+
const elapsed = ((Date.now() - thinkStartTime) / 1000).toFixed(1);
|
|
240
|
+
console.error(`[BRIDGE] runAgentTurn END: elapsed=${elapsed}s streamingText=${streamingText.length}chars toolCalls=${currentToolCalls.length}`);
|
|
241
|
+
// Finalize: move streaming content + tool calls to completed messages
|
|
242
|
+
thinking = false;
|
|
243
|
+
if (streamingText || currentToolCalls.length > 0) {
|
|
244
|
+
completedMessages.push({
|
|
245
|
+
id: nextId(),
|
|
246
|
+
kind: "assistant",
|
|
247
|
+
text: streamingText,
|
|
248
|
+
toolCalls: currentToolCalls.length > 0 ? [...currentToolCalls] : undefined,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
streamingText = "";
|
|
252
|
+
currentToolCalls = [];
|
|
253
|
+
running = false;
|
|
254
|
+
thinkElapsed = elapsed;
|
|
255
|
+
update();
|
|
256
|
+
// Clear elapsed indicator after a brief display
|
|
257
|
+
setTimeout(() => {
|
|
258
|
+
thinkElapsed = null;
|
|
259
|
+
update();
|
|
260
|
+
}, 2000);
|
|
261
|
+
// Process queued input — steer queue first, then pending
|
|
262
|
+
if (steerQueueBuf.length > 0) {
|
|
263
|
+
const queued = steerQueueBuf.shift();
|
|
264
|
+
completedMessages.push({ id: nextId(), kind: "user", text: queued });
|
|
265
|
+
update();
|
|
266
|
+
runAgentTurn(queued);
|
|
267
|
+
}
|
|
268
|
+
else if (pendingInput) {
|
|
269
|
+
const queued = pendingInput;
|
|
270
|
+
pendingInput = null;
|
|
271
|
+
completedMessages.push({ id: nextId(), kind: "user", text: queued });
|
|
272
|
+
update();
|
|
273
|
+
runAgentTurn(queued);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Clear screen before initial render — start clean
|
|
277
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
278
|
+
// Initial render
|
|
279
|
+
const app = render(_jsx(App, { state: getAppState(), completedMessages: [], streamingText: "", completedToolCalls: [], thinking: false, thinkStartTime: 0, thinkElapsed: null, steerQueue: [], running: false, showBanner: true, inputHistory: [], onSubmit: handleSubmit, onPermissionCycle: handlePermissionCycle, onCancelTurn: handleCancelTurn, onExit: handleExit }), { exitOnCtrlC: false });
|
|
280
|
+
rerender = app.rerender;
|
|
281
|
+
const done = new Promise((r) => { resolveSession = r; });
|
|
282
|
+
app.waitUntilExit().then(() => {
|
|
283
|
+
if (resolveSession)
|
|
284
|
+
resolveSession(session);
|
|
285
|
+
});
|
|
286
|
+
return done;
|
|
287
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ESC, s } from "./ansi.js";
|
|
2
|
+
let menuMod = null;
|
|
3
|
+
export async function loadMenuModule() {
|
|
4
|
+
if (!menuMod) {
|
|
5
|
+
try {
|
|
6
|
+
menuMod = await import("@phren/cli/shell/render-api");
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
menuMod = null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return menuMod;
|
|
13
|
+
}
|
|
14
|
+
export async function renderMenu(ctx) {
|
|
15
|
+
const mod = await loadMenuModule();
|
|
16
|
+
if (!mod || !ctx.phrenCtx)
|
|
17
|
+
return;
|
|
18
|
+
const result = await mod.renderMenuFrame(ctx.phrenCtx.phrenPath, ctx.phrenCtx.profile, ctx.menuState);
|
|
19
|
+
ctx.onStateChange(ctx.menuState, result.listCount, ctx.menuFilterActive, ctx.menuFilterBuf);
|
|
20
|
+
// Full-screen write: single write to avoid flicker
|
|
21
|
+
ctx.w.write(`${ESC}?25l${ESC}H${ESC}2J${result.output}${ESC}?25h`);
|
|
22
|
+
}
|
|
23
|
+
export function enterMenuMode(ctx) {
|
|
24
|
+
if (!ctx.phrenCtx) {
|
|
25
|
+
ctx.w.write(s.yellow(" phren not configured — menu unavailable\n"));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
ctx.menuState.project = ctx.phrenCtx.project ?? ctx.menuState.project;
|
|
29
|
+
ctx.w.write("\x1b[?1049h"); // enter alternate screen
|
|
30
|
+
renderMenu(ctx);
|
|
31
|
+
}
|
|
32
|
+
export function exitMenuMode(ctx) {
|
|
33
|
+
ctx.onStateChange(ctx.menuState, ctx.menuListCount, false, "");
|
|
34
|
+
ctx.w.write("\x1b[?1049l"); // leave alternate screen (restores chat)
|
|
35
|
+
ctx.onExit();
|
|
36
|
+
}
|
|
37
|
+
export async function handleMenuKeypress(key, ctx) {
|
|
38
|
+
// Filter input mode: capture text for / search
|
|
39
|
+
if (ctx.menuFilterActive) {
|
|
40
|
+
if (key.name === "escape") {
|
|
41
|
+
ctx.menuState = { ...ctx.menuState, filter: undefined, cursor: 0, scroll: 0 };
|
|
42
|
+
ctx.onStateChange(ctx.menuState, ctx.menuListCount, false, "");
|
|
43
|
+
renderMenu(ctx);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (key.name === "return") {
|
|
47
|
+
const filter = ctx.menuFilterBuf || undefined;
|
|
48
|
+
ctx.menuState = { ...ctx.menuState, filter, cursor: 0, scroll: 0 };
|
|
49
|
+
ctx.onStateChange(ctx.menuState, ctx.menuListCount, false, "");
|
|
50
|
+
renderMenu(ctx);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (key.name === "backspace") {
|
|
54
|
+
const buf = ctx.menuFilterBuf.slice(0, -1);
|
|
55
|
+
ctx.menuState = { ...ctx.menuState, filter: buf || undefined, cursor: 0 };
|
|
56
|
+
ctx.onStateChange(ctx.menuState, ctx.menuListCount, ctx.menuFilterActive, buf);
|
|
57
|
+
renderMenu(ctx);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (key.sequence && !key.ctrl && !key.meta) {
|
|
61
|
+
const buf = ctx.menuFilterBuf + key.sequence;
|
|
62
|
+
ctx.menuState = { ...ctx.menuState, filter: buf, cursor: 0 };
|
|
63
|
+
ctx.onStateChange(ctx.menuState, ctx.menuListCount, ctx.menuFilterActive, buf);
|
|
64
|
+
renderMenu(ctx);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// "/" starts filter input
|
|
69
|
+
if (key.sequence === "/") {
|
|
70
|
+
ctx.onStateChange(ctx.menuState, ctx.menuListCount, true, "");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const mod = await loadMenuModule();
|
|
74
|
+
if (!mod) {
|
|
75
|
+
exitMenuMode(ctx);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const newState = mod.handleMenuKey(ctx.menuState, key.name ?? "", ctx.menuListCount, ctx.phrenCtx?.phrenPath, ctx.phrenCtx?.profile);
|
|
79
|
+
if (newState === null) {
|
|
80
|
+
exitMenuMode(ctx);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
ctx.menuState = newState;
|
|
84
|
+
renderMenu(ctx);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool call rendering: duration formatting, input preview, compact output.
|
|
3
|
+
*/
|
|
4
|
+
import { s, cols } from "./ansi.js";
|
|
5
|
+
export const COMPACT_LINES = 3;
|
|
6
|
+
export function formatDuration(ms) {
|
|
7
|
+
if (ms < 1000)
|
|
8
|
+
return `${ms}ms`;
|
|
9
|
+
if (ms < 60_000)
|
|
10
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
11
|
+
const mins = Math.floor(ms / 60_000);
|
|
12
|
+
const secs = Math.round((ms % 60_000) / 1000);
|
|
13
|
+
return `${mins}m ${secs}s`;
|
|
14
|
+
}
|
|
15
|
+
export function formatToolInput(name, input) {
|
|
16
|
+
switch (name) {
|
|
17
|
+
case "read_file":
|
|
18
|
+
case "write_file":
|
|
19
|
+
case "edit_file": return input.file_path ?? "";
|
|
20
|
+
case "shell": return (input.command ?? "").slice(0, 60);
|
|
21
|
+
case "glob": return input.pattern ?? "";
|
|
22
|
+
case "grep": return `/${input.pattern ?? ""}/ ${input.path ?? ""}`;
|
|
23
|
+
case "git_commit": return (input.message ?? "").slice(0, 50);
|
|
24
|
+
case "phren_search": return input.query ?? "";
|
|
25
|
+
case "phren_add_finding": return (input.finding ?? "").slice(0, 50);
|
|
26
|
+
default: return JSON.stringify(input).slice(0, 60);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function renderToolCall(name, input, output, isError, durationMs) {
|
|
30
|
+
const preview = formatToolInput(name, input);
|
|
31
|
+
const dur = formatDuration(durationMs);
|
|
32
|
+
const icon = isError ? s.red("✗") : s.green("→");
|
|
33
|
+
const header = ` ${icon} ${s.bold(name)} ${s.gray(preview)} ${s.dim(dur)}`;
|
|
34
|
+
// Compact: show first 3 lines only, with overflow count
|
|
35
|
+
const allLines = output.split("\n").filter(Boolean);
|
|
36
|
+
if (allLines.length === 0)
|
|
37
|
+
return header;
|
|
38
|
+
const shown = allLines.slice(0, COMPACT_LINES);
|
|
39
|
+
const body = shown.map((l) => s.dim(` ${l.slice(0, cols() - 6)}`)).join("\n");
|
|
40
|
+
const overflow = allLines.length - COMPACT_LINES;
|
|
41
|
+
const more = overflow > 0 ? `\n${s.dim(` ... +${overflow} lines`)}` : "";
|
|
42
|
+
return `${header}\n${body}${more}`;
|
|
43
|
+
}
|