@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
package/dist/tui.js CHANGED
@@ -9,63 +9,22 @@ import { handleCommand } from "./commands.js";
9
9
  import { renderMarkdown } from "./multi/markdown.js";
10
10
  import { decodeDiffPayload, renderInlineDiff, DIFF_MARKER } from "./multi/diff-renderer.js";
11
11
  import * as os from "os";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
12
14
  import { execSync } from "node:child_process";
13
15
  import { loadInputMode, saveInputMode, savePermissionMode } from "./settings.js";
14
16
  import { createRequire } from "node:module";
17
+ import { ESC, s, cols, stripAnsi, PERMISSION_COLORS, PERMISSION_ICONS, PERMISSION_LABELS, nextPermissionMode, permTag, formatToolInput, renderToolCall, } from "./tui/index.js";
18
+ import { enterMenuMode as enterMenu, exitMenuMode as exitMenu, handleMenuKeypress as handleMenuKey, } from "./tui/index.js";
15
19
  const _require = createRequire(import.meta.url);
16
20
  const AGENT_VERSION = _require("../package.json").version;
17
- // ── ANSI helpers ─────────────────────────────────────────────────────────────
18
- const ESC = "\x1b[";
19
- const s = {
20
- reset: `${ESC}0m`,
21
- bold: (t) => `${ESC}1m${t}${ESC}0m`,
22
- dim: (t) => `${ESC}2m${t}${ESC}0m`,
23
- italic: (t) => `${ESC}3m${t}${ESC}0m`,
24
- cyan: (t) => `${ESC}36m${t}${ESC}0m`,
25
- green: (t) => `${ESC}32m${t}${ESC}0m`,
26
- yellow: (t) => `${ESC}33m${t}${ESC}0m`,
27
- red: (t) => `${ESC}31m${t}${ESC}0m`,
28
- blue: (t) => `${ESC}34m${t}${ESC}0m`,
29
- magenta: (t) => `${ESC}35m${t}${ESC}0m`,
30
- gray: (t) => `${ESC}90m${t}${ESC}0m`,
31
- invert: (t) => `${ESC}7m${t}${ESC}0m`,
32
- // Gradient-style brand text
33
- brand: (t) => `${ESC}1;35m${t}${ESC}0m`,
34
- };
35
- function cols() {
36
- return process.stdout.columns || 80;
37
- }
38
- // ── Permission mode helpers ─────────────────────────────────────────────────
39
- const PERMISSION_MODES = ["suggest", "auto-confirm", "full-auto"];
40
- function nextPermissionMode(current) {
41
- const idx = PERMISSION_MODES.indexOf(current);
42
- return PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length];
43
- }
44
- const PERMISSION_LABELS = {
45
- "suggest": "suggest",
46
- "auto-confirm": "auto",
47
- "full-auto": "full-auto",
48
- };
49
- const PERMISSION_ICONS = {
50
- "suggest": "○",
51
- "auto-confirm": "◐",
52
- "full-auto": "●",
53
- };
54
- const PERMISSION_COLORS = {
55
- "suggest": s.cyan,
56
- "auto-confirm": s.green,
57
- "full-auto": s.yellow,
58
- };
59
- function permTag(mode) {
60
- return PERMISSION_COLORS[mode](`${PERMISSION_ICONS[mode]} ${mode}`);
61
- }
62
21
  // ── Status bar ───────────────────────────────────────────────────────────────
63
22
  function renderStatusBar(provider, project, turns, cost, permMode, agentCount) {
64
23
  const modeLabel = permMode ? PERMISSION_LABELS[permMode] : "";
65
24
  const agentTag = agentCount && agentCount > 0 ? ` A${agentCount}` : "";
66
- // Left: brand + provider + project
25
+ // Left: brand + provider (skip project if it matches "phren" to avoid "phren · codex · phren")
67
26
  const parts = [" ◆ phren", provider];
68
- if (project)
27
+ if (project && project !== "phren")
69
28
  parts.push(project);
70
29
  const left = parts.join(" · ");
71
30
  // Right: mode + agents + cost + turns
@@ -82,72 +41,19 @@ function renderStatusBar(provider, project, turns, cost, permMode, agentCount) {
82
41
  const pad = Math.max(0, w - left.length - right.length);
83
42
  return s.invert(left + " ".repeat(pad) + right);
84
43
  }
85
- function stripAnsi(t) {
86
- return t.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
87
- }
88
- // ── Tool call rendering ──────────────────────────────────────────────────────
89
- const COMPACT_LINES = 3;
90
- function formatDuration(ms) {
91
- if (ms < 1000)
92
- return `${ms}ms`;
93
- if (ms < 60_000)
94
- return `${(ms / 1000).toFixed(1)}s`;
95
- const mins = Math.floor(ms / 60_000);
96
- const secs = Math.round((ms % 60_000) / 1000);
97
- return `${mins}m ${secs}s`;
98
- }
99
- function formatToolInput(name, input) {
100
- switch (name) {
101
- case "read_file":
102
- case "write_file":
103
- case "edit_file": return input.file_path ?? "";
104
- case "shell": return (input.command ?? "").slice(0, 60);
105
- case "glob": return input.pattern ?? "";
106
- case "grep": return `/${input.pattern ?? ""}/ ${input.path ?? ""}`;
107
- case "git_commit": return (input.message ?? "").slice(0, 50);
108
- case "phren_search": return input.query ?? "";
109
- case "phren_add_finding": return (input.finding ?? "").slice(0, 50);
110
- default: return JSON.stringify(input).slice(0, 60);
111
- }
112
- }
113
- function renderToolCall(name, input, output, isError, durationMs) {
114
- const preview = formatToolInput(name, input);
115
- const dur = formatDuration(durationMs);
116
- const icon = isError ? s.red("✗") : s.green("→");
117
- const header = ` ${icon} ${s.bold(name)} ${s.gray(preview)} ${s.dim(dur)}`;
118
- // Compact: show first 3 lines only, with overflow count
119
- const allLines = output.split("\n").filter(Boolean);
120
- if (allLines.length === 0)
121
- return header;
122
- const shown = allLines.slice(0, COMPACT_LINES);
123
- const body = shown.map((l) => s.dim(` ${l.slice(0, cols() - 6)}`)).join("\n");
124
- const overflow = allLines.length - COMPACT_LINES;
125
- const more = overflow > 0 ? `\n${s.dim(` ... +${overflow} lines`)}` : "";
126
- return `${header}\n${body}${more}`;
127
- }
128
- // ── Menu mode helpers ────────────────────────────────────────────────────────
129
- let menuMod = null;
130
- async function loadMenuModule() {
131
- if (!menuMod) {
132
- try {
133
- menuMod = await import("@phren/cli/shell/render-api");
134
- }
135
- catch {
136
- menuMod = null;
137
- }
138
- }
139
- return menuMod;
140
- }
141
44
  // ── Main TUI ─────────────────────────────────────────────────────────────────
142
45
  export async function startTui(config, spawner) {
143
46
  const contextLimit = config.provider.contextWindow ?? 200_000;
144
47
  const session = createSession(contextLimit);
145
48
  const w = process.stdout;
146
49
  const isTTY = process.stdout.isTTY;
50
+ const startTime = Date.now();
147
51
  let inputMode = loadInputMode();
148
52
  let pendingInput = null;
53
+ const steerQueue = [];
149
54
  let running = false;
150
55
  let inputLine = "";
56
+ let cursorPos = 0;
151
57
  let costStr = "";
152
58
  // ── Dual-mode state ─────────────────────────────────────────────────────
153
59
  let tuiMode = "chat";
@@ -165,61 +71,79 @@ export async function startTui(config, spawner) {
165
71
  const inputHistory = [];
166
72
  let historyIndex = -1;
167
73
  let savedInput = "";
168
- // ── Menu rendering ─────────────────────────────────────────────────────
169
- async function renderMenu() {
170
- const mod = await loadMenuModule();
171
- if (!mod || !config.phrenCtx)
172
- return;
173
- const result = await mod.renderMenuFrame(config.phrenCtx.phrenPath, config.phrenCtx.profile, menuState);
174
- menuListCount = result.listCount;
175
- // Full-screen write: single write to avoid flicker
176
- w.write(`${ESC}?25l${ESC}H${ESC}2J${result.output}${ESC}?25h`);
177
- }
178
- function enterMenuMode() {
179
- if (!config.phrenCtx) {
180
- w.write(s.yellow(" phren not configured — menu unavailable\n"));
181
- return;
182
- }
183
- tuiMode = "menu";
184
- menuState.project = config.phrenCtx.project ?? menuState.project;
185
- w.write("\x1b[?1049h"); // enter alternate screen
186
- renderMenu();
187
- }
188
- function exitMenuMode() {
189
- tuiMode = "chat";
190
- menuFilterActive = false;
191
- menuFilterBuf = "";
192
- w.write("\x1b[?1049l"); // leave alternate screen (restores chat)
193
- statusBar();
194
- prompt(true); // skip newline — alt screen restore already positioned cursor
74
+ // ── Menu context bridge ─────────────────────────────────────────────────
75
+ function getMenuCtx() {
76
+ return {
77
+ phrenCtx: config.phrenCtx ? {
78
+ phrenPath: config.phrenCtx.phrenPath,
79
+ profile: config.phrenCtx.profile,
80
+ project: config.phrenCtx.project ?? undefined,
81
+ } : undefined,
82
+ w,
83
+ menuState,
84
+ menuListCount,
85
+ menuFilterActive,
86
+ menuFilterBuf,
87
+ onExit: () => {
88
+ tuiMode = "chat";
89
+ statusBar();
90
+ prompt();
91
+ },
92
+ onStateChange: (st, lc, fa, fb) => {
93
+ menuState = st;
94
+ menuListCount = lc;
95
+ menuFilterActive = fa;
96
+ menuFilterBuf = fb;
97
+ },
98
+ };
195
99
  }
196
100
  // Print status bar
197
101
  function statusBar() {
198
- if (!isTTY)
199
- return;
200
- const bar = renderStatusBar(config.provider.name, config.phrenCtx?.project ?? null, session.turns, costStr, config.registry.permissionConfig.mode, spawner?.listAgents().length);
201
- w.write(`${ESC}s${ESC}H${bar}${ESC}u`); // save cursor, move to top, print, restore
102
+ // Intentionally empty — no top status bar. Info is in the bottom prompt area.
202
103
  }
203
- // Print prompt — bordered input bar at bottom
104
+ // Print prompt — inline input bar (written at current cursor position)
204
105
  let bashMode = false;
205
- function prompt(skipNewline = false) {
106
+ // Track how many lines the bottom bar occupies so we can clear it on submit
107
+ const PROMPT_LINES = 4; // separator, input, separator, permissions
108
+ function prompt() {
206
109
  if (!isTTY)
207
110
  return;
208
111
  const mode = config.registry.permissionConfig.mode;
209
112
  const color = PERMISSION_COLORS[mode];
210
113
  const icon = PERMISSION_ICONS[mode];
211
- const rows = process.stdout.rows || 24;
212
114
  const c = cols();
213
- if (!skipNewline)
214
- w.write("\n");
215
- const sepLine = s.dim("─".repeat(c));
216
- const permLine = ` ${color(`${icon} ${PERMISSION_LABELS[mode]} permissions`)} ${s.dim("(shift+tab to cycle)")}`;
217
- w.write(`${ESC}${rows - 2};1H${ESC}2K${sepLine}`);
218
- w.write(`${ESC}${rows - 1};1H${ESC}2K${permLine}`);
219
- w.write(`${ESC}${rows};1H${ESC}2K${bashMode ? `${s.yellow("!")} ` : `${color(icon)} ${s.dim("▸")} `}`);
115
+ const sep = s.dim("─".repeat(c));
116
+ const permLine = ` ${color(`${icon} ${PERMISSION_LABELS[mode]} permissions`)} ${s.dim("(shift+tab toggle · esc to interrupt)")}`;
117
+ // Write inline — this naturally sits at the bottom
118
+ w.write(`${sep}\n`);
119
+ w.write(`${bashMode ? `${s.yellow("!")} ` : `${s.dim("▸")} `}`);
120
+ w.write(`\n${sep}\n`);
121
+ w.write(`${permLine}\n`);
122
+ // Move cursor back up to the input line
123
+ w.write(`${ESC}${PROMPT_LINES - 1}A`); // move up to input line
124
+ w.write(`${ESC}${bashMode ? 3 : 4}G`); // move to column after prompt char
125
+ }
126
+ // Redraw the input line and position the terminal cursor at cursorPos
127
+ function redrawInput() {
128
+ w.write(`${ESC}2K\r`);
129
+ w.write(`${bashMode ? `${s.yellow("!")} ` : `${s.dim("▸")} `}`);
130
+ w.write(inputLine);
131
+ // Move terminal cursor back from end to cursorPos
132
+ const back = inputLine.length - cursorPos;
133
+ if (back > 0)
134
+ w.write(`${ESC}${back}D`);
220
135
  }
136
+ // Periodic status bar refresh (every 30s) — keeps cost/turns current during long tool runs
137
+ const statusRefreshTimer = isTTY
138
+ ? setInterval(() => { if (tuiMode === "chat")
139
+ statusBar(); }, 30_000)
140
+ : null;
141
+ if (statusRefreshTimer)
142
+ statusRefreshTimer.unref(); // don't keep process alive
221
143
  // Terminal cleanup: restore state on exit
222
144
  function cleanupTerminal() {
145
+ if (statusRefreshTimer)
146
+ clearInterval(statusRefreshTimer);
223
147
  w.write("\x1b[?1049l"); // leave alt screen if active
224
148
  if (process.stdin.isTTY) {
225
149
  try {
@@ -229,6 +153,8 @@ export async function startTui(config, spawner) {
229
153
  }
230
154
  }
231
155
  process.on("exit", cleanupTerminal);
156
+ // Terminal resize: do nothing — scrollback text reflows naturally.
157
+ // The Ink TUI handles resize via React re-render. Legacy TUI just lets it be.
232
158
  // Setup: clear screen, status bar at top, content area clean
233
159
  if (isTTY) {
234
160
  w.write(`${ESC}2J${ESC}H`); // clear entire screen + home
@@ -249,7 +175,7 @@ export async function startTui(config, spawner) {
249
175
  `${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""}`,
250
176
  `${s.dim(cwd)}`,
251
177
  ``,
252
- `${permTag(permMode)} ${s.dim("permissions (shift+tab to cycle)")}`,
178
+ `${permTag(permMode)} ${s.dim("permissions (shift+tab toggle · esc to interrupt)")}`,
253
179
  ``,
254
180
  `${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit`,
255
181
  ];
@@ -274,57 +200,6 @@ export async function startTui(config, spawner) {
274
200
  }
275
201
  let resolve = null;
276
202
  const done = new Promise((r) => { resolve = r; });
277
- // ── Menu keypress handler ───────────────────────────────────────────────
278
- async function handleMenuKeypress(key) {
279
- // Filter input mode: capture text for / search
280
- if (menuFilterActive) {
281
- if (key.name === "escape") {
282
- menuFilterActive = false;
283
- menuFilterBuf = "";
284
- menuState = { ...menuState, filter: undefined, cursor: 0, scroll: 0 };
285
- renderMenu();
286
- return;
287
- }
288
- if (key.name === "return") {
289
- menuFilterActive = false;
290
- menuState = { ...menuState, filter: menuFilterBuf || undefined, cursor: 0, scroll: 0 };
291
- menuFilterBuf = "";
292
- renderMenu();
293
- return;
294
- }
295
- if (key.name === "backspace") {
296
- menuFilterBuf = menuFilterBuf.slice(0, -1);
297
- menuState = { ...menuState, filter: menuFilterBuf || undefined, cursor: 0 };
298
- renderMenu();
299
- return;
300
- }
301
- if (key.sequence && !key.ctrl && !key.meta) {
302
- menuFilterBuf += key.sequence;
303
- menuState = { ...menuState, filter: menuFilterBuf, cursor: 0 };
304
- renderMenu();
305
- }
306
- return;
307
- }
308
- // "/" starts filter input
309
- if (key.sequence === "/") {
310
- menuFilterActive = true;
311
- menuFilterBuf = "";
312
- return;
313
- }
314
- const mod = await loadMenuModule();
315
- if (!mod) {
316
- exitMenuMode();
317
- return;
318
- }
319
- const newState = mod.handleMenuKey(menuState, key.name ?? "", menuListCount, config.phrenCtx?.phrenPath, config.phrenCtx?.profile);
320
- if (newState === null) {
321
- exitMenuMode();
322
- }
323
- else {
324
- menuState = newState;
325
- renderMenu();
326
- }
327
- }
328
203
  // ── Keypress router ────────────────────────────────────────────────────
329
204
  process.stdin.on("keypress", (_ch, key) => {
330
205
  if (!key)
@@ -343,23 +218,104 @@ export async function startTui(config, spawner) {
343
218
  const next = nextPermissionMode(config.registry.permissionConfig.mode);
344
219
  config.registry.setPermissions({ ...config.registry.permissionConfig, mode: next });
345
220
  savePermissionMode(next);
346
- // Just update bottom bar in-place no scrollback output
347
- prompt(true);
221
+ // Redraw the entire prompt bar in-place (permissions line changed)
222
+ w.write(`\r${ESC}J`); // clear from cursor to end of screen
223
+ prompt();
224
+ w.write(inputLine);
225
+ const back = inputLine.length - cursorPos;
226
+ if (back > 0)
227
+ w.write(`${ESC}${back}D`);
348
228
  return;
349
229
  }
350
- // Tab — toggle mode (not during agent run or filter)
351
- if (key.name === "tab" && !menuFilterActive) {
230
+ // Tab — completion or toggle mode
231
+ if (key.name === "tab" && !key.shift && !menuFilterActive) {
232
+ // Slash command completion in chat mode
233
+ if (tuiMode === "chat" && inputLine.startsWith("/")) {
234
+ const SLASH_COMMANDS = [
235
+ "/help", "/model", "/provider", "/turns", "/clear", "/cost",
236
+ "/plan", "/undo", "/history", "/compact", "/context", "/mode",
237
+ "/spawn", "/agents", "/diff", "/git", "/files", "/cwd",
238
+ "/preset", "/exit",
239
+ ];
240
+ const matches = SLASH_COMMANDS.filter((c) => c.startsWith(inputLine));
241
+ if (matches.length === 1) {
242
+ inputLine = matches[0];
243
+ cursorPos = inputLine.length;
244
+ redrawInput();
245
+ }
246
+ else if (matches.length > 1) {
247
+ // Show matches above prompt, then redraw
248
+ w.write(`\r${ESC}J`); // clear from cursor to end of screen
249
+ w.write(`\n${s.dim(" " + matches.join(" "))}\n`);
250
+ prompt();
251
+ w.write(inputLine);
252
+ const back = inputLine.length - cursorPos;
253
+ if (back > 0)
254
+ w.write(`${ESC}${back}D`);
255
+ }
256
+ return;
257
+ }
258
+ // File path completion in bash mode
259
+ if (tuiMode === "chat" && bashMode && inputLine.length > 0) {
260
+ // Complete the last whitespace-delimited token as a path
261
+ const lastSpace = inputLine.lastIndexOf(" ");
262
+ const prefix = lastSpace === -1 ? "" : inputLine.slice(0, lastSpace + 1);
263
+ const partial = lastSpace === -1 ? inputLine : inputLine.slice(lastSpace + 1);
264
+ const expandedPartial = partial.replace(/^~/, os.homedir());
265
+ const dir = path.dirname(expandedPartial);
266
+ const base = path.basename(expandedPartial);
267
+ try {
268
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
269
+ const matches = entries.filter((e) => e.name.startsWith(base));
270
+ if (matches.length === 1) {
271
+ const completed = matches[0];
272
+ const fullPath = partial.startsWith("~")
273
+ ? "~/" + path.relative(os.homedir(), path.join(dir, completed.name))
274
+ : path.join(dir, completed.name);
275
+ inputLine = prefix + fullPath + (completed.isDirectory() ? "/" : "");
276
+ cursorPos = inputLine.length;
277
+ redrawInput();
278
+ }
279
+ else if (matches.length > 1) {
280
+ const names = matches.map((e) => e.name + (e.isDirectory() ? "/" : ""));
281
+ w.write(`\r${ESC}J`); // clear from cursor to end of screen
282
+ w.write(`\n${s.dim(" " + names.join(" "))}\n`);
283
+ // Find longest common prefix for partial completion
284
+ let common = matches[0].name;
285
+ for (const m of matches) {
286
+ while (!m.name.startsWith(common))
287
+ common = common.slice(0, -1);
288
+ }
289
+ if (common.length > base.length) {
290
+ const fullPath = partial.startsWith("~")
291
+ ? "~/" + path.relative(os.homedir(), path.join(dir, common))
292
+ : path.join(dir, common);
293
+ inputLine = prefix + fullPath;
294
+ cursorPos = inputLine.length;
295
+ }
296
+ prompt();
297
+ w.write(inputLine);
298
+ const back = inputLine.length - cursorPos;
299
+ if (back > 0)
300
+ w.write(`${ESC}${back}D`);
301
+ }
302
+ }
303
+ catch { /* dir doesn't exist or unreadable */ }
304
+ return;
305
+ }
306
+ // Default: toggle menu mode
352
307
  if (tuiMode === "chat" && !running) {
353
- enterMenuMode();
308
+ tuiMode = "menu";
309
+ enterMenu(getMenuCtx());
354
310
  }
355
311
  else if (tuiMode === "menu") {
356
- exitMenuMode();
312
+ exitMenu(getMenuCtx());
357
313
  }
358
314
  return;
359
315
  }
360
316
  // Route to mode-specific handler
361
317
  if (tuiMode === "menu") {
362
- handleMenuKeypress(key);
318
+ handleMenuKey(key, getMenuCtx());
363
319
  return;
364
320
  }
365
321
  // ── Chat mode keys ──────────────────────────────────────────────────
@@ -368,12 +324,14 @@ export async function startTui(config, spawner) {
368
324
  if (bashMode) {
369
325
  bashMode = false;
370
326
  inputLine = "";
371
- prompt(true);
327
+ cursorPos = 0;
328
+ redrawInput();
372
329
  return;
373
330
  }
374
331
  if (inputLine) {
375
332
  inputLine = "";
376
- prompt(true);
333
+ cursorPos = 0;
334
+ redrawInput();
377
335
  return;
378
336
  }
379
337
  }
@@ -389,29 +347,32 @@ export async function startTui(config, spawner) {
389
347
  if (bashMode) {
390
348
  bashMode = false;
391
349
  inputLine = "";
392
- prompt(true);
350
+ cursorPos = 0;
351
+ redrawInput();
393
352
  ctrlCCount = 0;
394
353
  return;
395
354
  }
396
355
  if (inputLine) {
397
356
  // Clear input
398
357
  inputLine = "";
399
- prompt(true);
358
+ cursorPos = 0;
359
+ redrawInput();
400
360
  ctrlCCount = 0;
401
361
  return;
402
362
  }
403
363
  // Nothing to cancel — progressive quit
404
364
  ctrlCCount++;
405
365
  if (ctrlCCount === 1) {
366
+ // Clear current prompt, print warning, redraw prompt
367
+ w.write(`\r${ESC}J`);
406
368
  w.write(s.dim("\n Press Ctrl+C again to exit.\n"));
407
- prompt(true);
369
+ prompt();
408
370
  // Reset after 2 seconds
409
371
  setTimeout(() => { ctrlCCount = 0; }, 2000);
410
372
  }
411
373
  else {
412
374
  // Actually quit
413
- if (process.stdin.isTTY)
414
- process.stdin.setRawMode(false);
375
+ cleanupTerminal();
415
376
  w.write(s.dim("\nSession ended.\n"));
416
377
  resolve(session);
417
378
  }
@@ -420,10 +381,10 @@ export async function startTui(config, spawner) {
420
381
  // Enter — submit
421
382
  if (key.name === "return") {
422
383
  const line = inputLine.trim();
384
+ cursorPos = 0;
423
385
  inputLine = "";
424
- w.write("\n");
425
386
  if (!line) {
426
- prompt();
387
+ redrawInput();
427
388
  return;
428
389
  }
429
390
  // Push to history
@@ -441,7 +402,7 @@ export async function startTui(config, spawner) {
441
402
  if (cdMatch) {
442
403
  try {
443
404
  const target = cdMatch[1].trim().replace(/^~/, os.homedir());
444
- const resolved = require("path").resolve(process.cwd(), target);
405
+ const resolved = path.resolve(process.cwd(), target);
445
406
  process.chdir(resolved);
446
407
  w.write(s.dim(process.cwd()) + "\n");
447
408
  }
@@ -473,39 +434,60 @@ export async function startTui(config, spawner) {
473
434
  prompt();
474
435
  return;
475
436
  }
476
- if (handleCommand(line, {
437
+ const cmdResult = handleCommand(line, {
477
438
  session,
478
439
  contextLimit,
479
440
  undoStack: [],
480
441
  providerName: config.provider.name,
481
442
  currentModel: config.provider.model,
443
+ provider: config.provider,
444
+ systemPrompt: config.systemPrompt,
482
445
  spawner,
483
- onModelChange: (result) => {
446
+ sessionId: config.sessionId,
447
+ startTime,
448
+ phrenPath: config.phrenCtx?.phrenPath,
449
+ phrenCtx: config.phrenCtx,
450
+ onModelChange: async (result) => {
484
451
  // Live model switch — re-resolve provider with new model
485
452
  try {
486
- const { resolveProvider } = require("./providers/resolve.js");
453
+ const { resolveProvider } = await import("./providers/resolve.js");
487
454
  const newProvider = resolveProvider(config.provider.name, result.model);
488
455
  config.provider = newProvider;
489
456
  // Rebuild system prompt with new model info
490
- const { buildSystemPrompt } = require("./system-prompt.js");
457
+ const { buildSystemPrompt } = await import("./system-prompt.js");
491
458
  config.systemPrompt = buildSystemPrompt(config.systemPrompt.split("\n## Last session")[0], // preserve context, strip old summary
492
459
  null, { name: newProvider.name, model: result.model });
493
460
  statusBar();
494
461
  }
495
462
  catch { /* keep current provider on error */ }
496
463
  },
497
- })) {
464
+ });
465
+ if (cmdResult === true) {
498
466
  prompt();
499
467
  return;
500
468
  }
501
- // If agent is running, buffer input
469
+ if (typeof cmdResult === "object" && cmdResult instanceof Promise) {
470
+ cmdResult.then(() => { prompt(); });
471
+ return;
472
+ }
473
+ // If agent is running, add to steer queue
502
474
  if (running) {
503
- pendingInput = line;
504
- const label = inputMode === "steering" ? "steering" : "queued";
505
- w.write(s.dim(` ↳ ${label}: "${line.slice(0, 60)}"\n`));
475
+ if (inputMode === "steering") {
476
+ steerQueue.push(line);
477
+ }
478
+ else {
479
+ pendingInput = line;
480
+ }
481
+ // Show queued input above the thinking line
482
+ w.write(`${ESC}2K${s.dim(` ↳ ${inputMode === "steering" ? "steer" : "queued"}: ${line.slice(0, 60)}`)}\n`);
506
483
  return;
507
484
  }
508
- // Run agent turn
485
+ // Clear input line, echo user input above prompt, redraw prompt
486
+ w.write(`\r${ESC}2K`); // clear input line
487
+ // Scroll up: move to line above prompt area, write content, redraw prompt
488
+ w.write(`${ESC}${PROMPT_LINES}A`); // move up past the prompt area
489
+ w.write(`${s.bold("❯")} ${line}\n`);
490
+ prompt(); // redraw prompt below
509
491
  runAgentTurn(line);
510
492
  return;
511
493
  }
@@ -521,9 +503,8 @@ export async function startTui(config, spawner) {
521
503
  historyIndex--;
522
504
  }
523
505
  inputLine = inputHistory[historyIndex];
524
- w.write(`${ESC}2K\r`);
525
- prompt(true);
526
- w.write(inputLine);
506
+ cursorPos = inputLine.length;
507
+ redrawInput();
527
508
  return;
528
509
  }
529
510
  // Down arrow — next history or restore saved
@@ -538,34 +519,128 @@ export async function startTui(config, spawner) {
538
519
  historyIndex = -1;
539
520
  inputLine = savedInput;
540
521
  }
541
- w.write(`${ESC}2K\r`);
542
- prompt(true);
543
- w.write(inputLine);
522
+ cursorPos = inputLine.length;
523
+ redrawInput();
524
+ return;
525
+ }
526
+ // Ctrl+A — move cursor to start of line
527
+ if (key.ctrl && key.name === "a") {
528
+ cursorPos = 0;
529
+ redrawInput();
530
+ return;
531
+ }
532
+ // Ctrl+E — move cursor to end of line
533
+ if (key.ctrl && key.name === "e") {
534
+ cursorPos = inputLine.length;
535
+ redrawInput();
536
+ return;
537
+ }
538
+ // Ctrl+U — kill entire line
539
+ if (key.ctrl && key.name === "u") {
540
+ inputLine = "";
541
+ cursorPos = 0;
542
+ redrawInput();
543
+ return;
544
+ }
545
+ // Ctrl+K — kill from cursor to end of line
546
+ if (key.ctrl && key.name === "k") {
547
+ inputLine = inputLine.slice(0, cursorPos);
548
+ redrawInput();
549
+ return;
550
+ }
551
+ // Left arrow — move cursor left one character
552
+ if (key.name === "left" && !key.meta && !key.ctrl) {
553
+ if (cursorPos > 0) {
554
+ cursorPos--;
555
+ w.write(`${ESC}D`);
556
+ }
557
+ return;
558
+ }
559
+ // Right arrow — move cursor right one character
560
+ if (key.name === "right" && !key.meta && !key.ctrl) {
561
+ if (cursorPos < inputLine.length) {
562
+ cursorPos++;
563
+ w.write(`${ESC}C`);
564
+ }
565
+ return;
566
+ }
567
+ // Alt+Left — move cursor left by one word
568
+ if (key.name === "left" && (key.meta || key.ctrl)) {
569
+ if (cursorPos > 0) {
570
+ // Skip spaces, then skip non-spaces
571
+ let p = cursorPos;
572
+ while (p > 0 && inputLine[p - 1] === " ")
573
+ p--;
574
+ while (p > 0 && inputLine[p - 1] !== " ")
575
+ p--;
576
+ cursorPos = p;
577
+ redrawInput();
578
+ }
579
+ return;
580
+ }
581
+ // Alt+Right — move cursor right by one word
582
+ if (key.name === "right" && (key.meta || key.ctrl)) {
583
+ if (cursorPos < inputLine.length) {
584
+ let p = cursorPos;
585
+ while (p < inputLine.length && inputLine[p] !== " ")
586
+ p++;
587
+ while (p < inputLine.length && inputLine[p] === " ")
588
+ p++;
589
+ cursorPos = p;
590
+ redrawInput();
591
+ }
544
592
  return;
545
593
  }
546
- // Backspace
594
+ // Word-delete: Alt+Backspace, Ctrl+Backspace, Ctrl+W
595
+ if (((key.meta || key.ctrl) && key.name === "backspace") ||
596
+ (key.ctrl && key.name === "w")) {
597
+ if (cursorPos > 0) {
598
+ // Find word boundary before cursor
599
+ let p = cursorPos;
600
+ while (p > 0 && inputLine[p - 1] === " ")
601
+ p--;
602
+ while (p > 0 && inputLine[p - 1] !== " ")
603
+ p--;
604
+ inputLine = inputLine.slice(0, p) + inputLine.slice(cursorPos);
605
+ cursorPos = p;
606
+ redrawInput();
607
+ }
608
+ return;
609
+ }
610
+ // Backspace — delete character before cursor
547
611
  if (key.name === "backspace") {
548
- if (inputLine.length > 0) {
549
- inputLine = inputLine.slice(0, -1);
550
- w.write("\b \b");
612
+ if (cursorPos > 0) {
613
+ inputLine = inputLine.slice(0, cursorPos - 1) + inputLine.slice(cursorPos);
614
+ cursorPos--;
615
+ redrawInput();
616
+ }
617
+ return;
618
+ }
619
+ // Delete — delete character at cursor
620
+ if (key.name === "delete") {
621
+ if (cursorPos < inputLine.length) {
622
+ inputLine = inputLine.slice(0, cursorPos) + inputLine.slice(cursorPos + 1);
623
+ redrawInput();
551
624
  }
552
625
  return;
553
626
  }
554
- // Regular character
627
+ // Regular character — insert at cursor position
555
628
  if (key.sequence && !key.ctrl && !key.meta) {
556
629
  // ! at start of empty input toggles bash mode
557
630
  if (key.sequence === "!" && inputLine === "" && !bashMode) {
558
631
  bashMode = true;
559
- prompt(true);
632
+ redrawInput();
560
633
  return;
561
634
  }
562
- inputLine += key.sequence;
563
- w.write(key.sequence);
635
+ inputLine = inputLine.slice(0, cursorPos) + key.sequence + inputLine.slice(cursorPos);
636
+ cursorPos += key.sequence.length;
637
+ redrawInput();
564
638
  }
565
639
  });
566
640
  // TUI hooks — render streaming text with markdown, compact tool output
567
641
  let textBuffer = "";
568
642
  let firstDelta = true;
643
+ let activeThinkTimer = null;
569
644
  function flushTextBuffer() {
570
645
  if (!textBuffer)
571
646
  return;
@@ -575,24 +650,43 @@ export async function startTui(config, spawner) {
575
650
  const tuiHooks = {
576
651
  onTextDelta: (text) => {
577
652
  if (firstDelta) {
578
- w.write(`${ESC}2K\r`); // clear thinking timer line
653
+ if (activeThinkTimer) {
654
+ clearInterval(activeThinkTimer);
655
+ activeThinkTimer = null;
656
+ }
657
+ w.write(`${ESC}2K\r`);
658
+ w.write(`\n${s.brand("◆")} `); // blank line + diamond prefix for phren's response
579
659
  firstDelta = false;
580
660
  }
581
- textBuffer += text;
582
- // Flush on paragraph boundaries (double newline) or single newline for streaming feel
583
- if (textBuffer.includes("\n\n") || textBuffer.endsWith("\n")) {
584
- flushTextBuffer();
585
- }
661
+ w.write(text);
586
662
  },
587
663
  onTextDone: () => {
588
664
  flushTextBuffer();
589
665
  },
590
666
  onTextBlock: (text) => {
591
- w.write(renderMarkdown(text));
667
+ if (activeThinkTimer) {
668
+ clearInterval(activeThinkTimer);
669
+ activeThinkTimer = null;
670
+ }
671
+ if (firstDelta) {
672
+ w.write(`${ESC}2K\r`);
673
+ w.write(`\n${s.brand("◆")} `); // diamond prefix + blank line before response
674
+ firstDelta = false;
675
+ }
676
+ w.write(text);
592
677
  if (!text.endsWith("\n"))
593
678
  w.write("\n");
594
679
  },
595
680
  onToolStart: (name, input, count) => {
681
+ // Kill the thinking animation if it's still running (tools can fire before any text delta)
682
+ if (activeThinkTimer) {
683
+ clearInterval(activeThinkTimer);
684
+ activeThinkTimer = null;
685
+ }
686
+ if (firstDelta) {
687
+ w.write(`${ESC}2K\r`); // clear thinking line
688
+ firstDelta = false;
689
+ }
596
690
  flushTextBuffer();
597
691
  const preview = formatToolInput(name, input);
598
692
  const countLabel = count > 1 ? s.dim(` (${count} tools)`) : "";
@@ -609,6 +703,12 @@ export async function startTui(config, spawner) {
609
703
  },
610
704
  onStatus: (msg) => w.write(s.dim(msg)),
611
705
  getSteeringInput: () => {
706
+ // Drain steer queue first (newest steering inputs)
707
+ if (steerQueue.length > 0 && inputMode === "steering") {
708
+ const steer = steerQueue.shift();
709
+ w.write(s.yellow(` ↳ steering: ${steer}\n`));
710
+ return steer;
711
+ }
612
712
  if (pendingInput && inputMode === "steering") {
613
713
  const steer = pendingInput;
614
714
  pendingInput = null;
@@ -622,26 +722,52 @@ export async function startTui(config, spawner) {
622
722
  running = true;
623
723
  firstDelta = true;
624
724
  const thinkStart = Date.now();
625
- const thinkTimer = setInterval(() => {
626
- const elapsed = ((Date.now() - thinkStart) / 1000).toFixed(1);
627
- w.write(`${ESC}2K ${s.dim(`◌ thinking... ${elapsed}s`)}\r`);
628
- }, 100);
725
+ // Phren thinking subtle purple/cyan breath with rotating verbs
726
+ const THINK_VERBS = ["thinking", "reasoning", "recalling", "connecting", "processing"];
727
+ let thinkFrame = 0;
728
+ activeThinkTimer = setInterval(() => {
729
+ const elapsed = (Date.now() - thinkStart) / 1000;
730
+ const verb = THINK_VERBS[Math.floor(elapsed / 6) % THINK_VERBS.length];
731
+ const t = (Math.sin(thinkFrame * 0.08) + 1) / 2;
732
+ const r = Math.round(155 * (1 - t) + 40 * t);
733
+ const g = Math.round(140 * (1 - t) + 211 * t);
734
+ const b = Math.round(250 * (1 - t) + 242 * t);
735
+ const color = `${ESC}38;2;${r};${g};${b}m`;
736
+ w.write(`${ESC}2K${color}◆ ${verb}${ESC}0m ${s.dim(`${elapsed.toFixed(1)}s`)}\r`);
737
+ thinkFrame++;
738
+ }, 50);
629
739
  try {
630
740
  await runTurn(userInput, session, config, tuiHooks);
631
- clearInterval(thinkTimer);
741
+ if (activeThinkTimer) {
742
+ clearInterval(activeThinkTimer);
743
+ activeThinkTimer = null;
744
+ }
745
+ const elapsed = ((Date.now() - thinkStart) / 1000).toFixed(1);
746
+ const DONE_VERBS = ["◆ recalled", "◆ processed", "◆ connected", "◆ resolved"];
747
+ const doneVerb = DONE_VERBS[session.turns % DONE_VERBS.length];
748
+ w.write(`${ESC}2K${s.dim(`${doneVerb} in ${elapsed}s`)}\n\n`);
632
749
  statusBar();
633
750
  }
634
751
  catch (err) {
635
- clearInterval(thinkTimer);
752
+ if (activeThinkTimer) {
753
+ clearInterval(activeThinkTimer);
754
+ activeThinkTimer = null;
755
+ }
636
756
  const msg = err instanceof Error ? err.message : String(err);
637
757
  w.write(`${ESC}2K\r`);
638
758
  w.write(s.red(` Error: ${msg}\n`));
639
759
  }
640
760
  running = false;
641
- // Process queued input
642
- if (pendingInput) {
761
+ // Process queued input — steer queue first, then pending
762
+ if (steerQueue.length > 0) {
763
+ const queued = steerQueue.shift();
764
+ w.write(`${s.bold("❯")} ${queued}\n`);
765
+ runAgentTurn(queued);
766
+ }
767
+ else if (pendingInput) {
643
768
  const queued = pendingInput;
644
769
  pendingInput = null;
770
+ w.write(`${s.bold("❯")} ${queued}\n`);
645
771
  runAgentTurn(queued);
646
772
  }
647
773
  else {