@phren/agent 0.1.2 → 0.1.4

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 (48) 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 -326
  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 +25 -297
  10. package/dist/config.js +6 -2
  11. package/dist/index.js +10 -4
  12. package/dist/mcp-client.js +11 -7
  13. package/dist/multi/multi-commands.js +170 -0
  14. package/dist/multi/multi-events.js +81 -0
  15. package/dist/multi/multi-render.js +146 -0
  16. package/dist/multi/pane.js +28 -0
  17. package/dist/multi/spawner.js +3 -2
  18. package/dist/multi/tui-multi.js +39 -454
  19. package/dist/permissions/allowlist.js +2 -2
  20. package/dist/permissions/shell-safety.js +8 -0
  21. package/dist/providers/anthropic.js +72 -33
  22. package/dist/providers/codex.js +121 -60
  23. package/dist/providers/openai-compat.js +6 -1
  24. package/dist/repl.js +2 -2
  25. package/dist/system-prompt.js +24 -26
  26. package/dist/tools/glob.js +30 -6
  27. package/dist/tools/shell.js +5 -2
  28. package/dist/tui/ansi.js +48 -0
  29. package/dist/tui/components/AgentMessage.js +5 -0
  30. package/dist/tui/components/App.js +70 -0
  31. package/dist/tui/components/Banner.js +44 -0
  32. package/dist/tui/components/ChatMessage.js +23 -0
  33. package/dist/tui/components/InputArea.js +23 -0
  34. package/dist/tui/components/Separator.js +7 -0
  35. package/dist/tui/components/StatusBar.js +25 -0
  36. package/dist/tui/components/SteerQueue.js +7 -0
  37. package/dist/tui/components/StreamingText.js +5 -0
  38. package/dist/tui/components/ThinkingIndicator.js +20 -0
  39. package/dist/tui/components/ToolCall.js +11 -0
  40. package/dist/tui/components/UserMessage.js +5 -0
  41. package/dist/tui/hooks/useKeyboardShortcuts.js +89 -0
  42. package/dist/tui/hooks/useSlashCommands.js +52 -0
  43. package/dist/tui/index.js +5 -0
  44. package/dist/tui/ink-entry.js +271 -0
  45. package/dist/tui/menu-mode.js +86 -0
  46. package/dist/tui/tool-render.js +43 -0
  47. package/dist/tui.js +378 -252
  48. package/package.json +9 -2
@@ -0,0 +1,271 @@
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
+ const line = input.trim();
103
+ if (!line)
104
+ return;
105
+ // Track input history (skip duplicates of the last entry)
106
+ if (inputHistory.length === 0 || inputHistory[inputHistory.length - 1] !== line) {
107
+ inputHistory.push(line);
108
+ }
109
+ // Bash mode: ! prefix
110
+ if (line.startsWith("!")) {
111
+ const cmd = line.slice(1).trim();
112
+ let output = "";
113
+ if (cmd) {
114
+ const cdMatch = cmd.match(/^cd\s+(.*)/);
115
+ if (cdMatch) {
116
+ try {
117
+ const target = cdMatch[1].trim().replace(/^~/, os.homedir());
118
+ process.chdir(path.resolve(process.cwd(), target));
119
+ output = process.cwd();
120
+ }
121
+ catch (err) {
122
+ output = err.message;
123
+ }
124
+ }
125
+ else {
126
+ try {
127
+ output = execSync(cmd, { encoding: "utf-8", timeout: 30_000, cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
128
+ }
129
+ catch (err) {
130
+ const e = err;
131
+ output = e.stderr || e.message || "Command failed";
132
+ }
133
+ }
134
+ }
135
+ if (output) {
136
+ completedMessages.push({ id: nextId(), kind: "status", text: output.replace(/\n$/, "") });
137
+ }
138
+ update();
139
+ return;
140
+ }
141
+ // Slash commands
142
+ if (line === "/mode") {
143
+ inputMode = inputMode === "steering" ? "queue" : "steering";
144
+ saveInputMode(inputMode);
145
+ completedMessages.push({ id: nextId(), kind: "status", text: `Input mode: ${inputMode}` });
146
+ update();
147
+ return;
148
+ }
149
+ // Slash commands — capture stderr output and display as status message
150
+ if (line.startsWith("/")) {
151
+ if (slashCommands.tryHandleCommand(line)) {
152
+ update();
153
+ return;
154
+ }
155
+ }
156
+ // If agent running, queue input for steering
157
+ if (running) {
158
+ if (inputMode === "steering") {
159
+ steerQueueBuf.push(line);
160
+ }
161
+ else {
162
+ pendingInput = line;
163
+ }
164
+ update();
165
+ return;
166
+ }
167
+ // Normal user message — add to completed history and run agent turn
168
+ completedMessages.push({ id: nextId(), kind: "user", text: line });
169
+ update();
170
+ runAgentTurn(line);
171
+ }
172
+ // TurnHooks bridge — updates mutable state, calls update()
173
+ const tuiHooks = {
174
+ onTextDelta: (text) => {
175
+ thinking = false;
176
+ streamingText += text;
177
+ update();
178
+ },
179
+ onTextDone: () => {
180
+ // streaming complete — finalized in runAgentTurn
181
+ },
182
+ onTextBlock: (text) => {
183
+ thinking = false;
184
+ streamingText += text;
185
+ update();
186
+ },
187
+ onToolStart: (_name, _input, _count) => {
188
+ thinking = false;
189
+ update();
190
+ },
191
+ onToolEnd: (name, input, output, isError, dur) => {
192
+ const diffData = (name === "edit_file" || name === "write_file") ? decodeDiffPayload(output) : null;
193
+ const cleanOutput = diffData ? output.slice(0, output.indexOf(DIFF_MARKER)) : output;
194
+ currentToolCalls.push({ name, input, output: cleanOutput, isError, durationMs: dur });
195
+ update();
196
+ },
197
+ onStatus: () => { update(); },
198
+ getSteeringInput: () => {
199
+ if (steerQueueBuf.length > 0 && inputMode === "steering") {
200
+ return steerQueueBuf.shift();
201
+ }
202
+ if (pendingInput && inputMode === "steering") {
203
+ const steer = pendingInput;
204
+ pendingInput = null;
205
+ return steer;
206
+ }
207
+ return null;
208
+ },
209
+ };
210
+ async function runAgentTurn(userInput) {
211
+ running = true;
212
+ thinking = true;
213
+ thinkStartTime = Date.now();
214
+ thinkElapsed = null;
215
+ streamingText = "";
216
+ currentToolCalls = [];
217
+ update();
218
+ try {
219
+ await runTurn(userInput, session, config, tuiHooks);
220
+ }
221
+ catch (err) {
222
+ const msg = err instanceof Error ? err.message : String(err);
223
+ streamingText += `\nError: ${msg}`;
224
+ }
225
+ // Compute elapsed time
226
+ const elapsed = ((Date.now() - thinkStartTime) / 1000).toFixed(1);
227
+ // Finalize: move streaming content + tool calls to completed messages
228
+ thinking = false;
229
+ if (streamingText || currentToolCalls.length > 0) {
230
+ completedMessages.push({
231
+ id: nextId(),
232
+ kind: "assistant",
233
+ text: streamingText,
234
+ toolCalls: currentToolCalls.length > 0 ? [...currentToolCalls] : undefined,
235
+ });
236
+ }
237
+ streamingText = "";
238
+ currentToolCalls = [];
239
+ running = false;
240
+ thinkElapsed = elapsed;
241
+ update();
242
+ // Clear elapsed indicator after a brief display
243
+ setTimeout(() => {
244
+ thinkElapsed = null;
245
+ update();
246
+ }, 2000);
247
+ // Process queued input — steer queue first, then pending
248
+ if (steerQueueBuf.length > 0) {
249
+ const queued = steerQueueBuf.shift();
250
+ completedMessages.push({ id: nextId(), kind: "user", text: queued });
251
+ update();
252
+ runAgentTurn(queued);
253
+ }
254
+ else if (pendingInput) {
255
+ const queued = pendingInput;
256
+ pendingInput = null;
257
+ completedMessages.push({ id: nextId(), kind: "user", text: queued });
258
+ update();
259
+ runAgentTurn(queued);
260
+ }
261
+ }
262
+ // Initial render
263
+ 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 });
264
+ rerender = app.rerender;
265
+ const done = new Promise((r) => { resolveSession = r; });
266
+ app.waitUntilExit().then(() => {
267
+ if (resolveSession)
268
+ resolveSession(session);
269
+ });
270
+ return done;
271
+ }
@@ -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
+ }