@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.
Files changed (42) hide show
  1. package/dist/agent-loop/index.js +214 -0
  2. package/dist/agent-loop/stream.js +124 -0
  3. package/dist/agent-loop/types.js +13 -0
  4. package/dist/agent-loop.js +7 -333
  5. package/dist/commands/info.js +146 -0
  6. package/dist/commands/memory.js +165 -0
  7. package/dist/commands/model.js +138 -0
  8. package/dist/commands/session.js +213 -0
  9. package/dist/commands.js +24 -643
  10. package/dist/index.js +9 -4
  11. package/dist/mcp-client.js +11 -7
  12. package/dist/multi/multi-commands.js +170 -0
  13. package/dist/multi/multi-events.js +81 -0
  14. package/dist/multi/multi-render.js +146 -0
  15. package/dist/multi/pane.js +28 -0
  16. package/dist/multi/tui-multi.js +39 -454
  17. package/dist/permissions/allowlist.js +2 -2
  18. package/dist/providers/anthropic.js +4 -2
  19. package/dist/providers/codex.js +9 -4
  20. package/dist/providers/openai-compat.js +6 -1
  21. package/dist/tools/glob.js +30 -6
  22. package/dist/tui/ansi.js +48 -0
  23. package/dist/tui/components/AgentMessage.js +5 -0
  24. package/dist/tui/components/App.js +68 -0
  25. package/dist/tui/components/Banner.js +44 -0
  26. package/dist/tui/components/ChatMessage.js +23 -0
  27. package/dist/tui/components/InputArea.js +23 -0
  28. package/dist/tui/components/Separator.js +7 -0
  29. package/dist/tui/components/StatusBar.js +25 -0
  30. package/dist/tui/components/SteerQueue.js +7 -0
  31. package/dist/tui/components/StreamingText.js +5 -0
  32. package/dist/tui/components/ThinkingIndicator.js +26 -0
  33. package/dist/tui/components/ToolCall.js +11 -0
  34. package/dist/tui/components/UserMessage.js +5 -0
  35. package/dist/tui/hooks/useKeyboardShortcuts.js +89 -0
  36. package/dist/tui/hooks/useSlashCommands.js +52 -0
  37. package/dist/tui/index.js +5 -0
  38. package/dist/tui/ink-entry.js +287 -0
  39. package/dist/tui/menu-mode.js +86 -0
  40. package/dist/tui/tool-render.js +43 -0
  41. package/dist/tui.js +149 -280
  42. 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
+ }