@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
package/dist/tui.js CHANGED
@@ -14,60 +14,17 @@ import * as path from "node:path";
14
14
  import { execSync } from "node:child_process";
15
15
  import { loadInputMode, saveInputMode, savePermissionMode } from "./settings.js";
16
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";
17
19
  const _require = createRequire(import.meta.url);
18
20
  const AGENT_VERSION = _require("../package.json").version;
19
- // ── ANSI helpers ─────────────────────────────────────────────────────────────
20
- const ESC = "\x1b[";
21
- const s = {
22
- reset: `${ESC}0m`,
23
- bold: (t) => `${ESC}1m${t}${ESC}0m`,
24
- dim: (t) => `${ESC}2m${t}${ESC}0m`,
25
- italic: (t) => `${ESC}3m${t}${ESC}0m`,
26
- cyan: (t) => `${ESC}36m${t}${ESC}0m`,
27
- green: (t) => `${ESC}32m${t}${ESC}0m`,
28
- yellow: (t) => `${ESC}33m${t}${ESC}0m`,
29
- red: (t) => `${ESC}31m${t}${ESC}0m`,
30
- blue: (t) => `${ESC}34m${t}${ESC}0m`,
31
- magenta: (t) => `${ESC}35m${t}${ESC}0m`,
32
- gray: (t) => `${ESC}90m${t}${ESC}0m`,
33
- invert: (t) => `${ESC}7m${t}${ESC}0m`,
34
- // Gradient-style brand text
35
- brand: (t) => `${ESC}1;35m${t}${ESC}0m`,
36
- };
37
- function cols() {
38
- return process.stdout.columns || 80;
39
- }
40
- // ── Permission mode helpers ─────────────────────────────────────────────────
41
- const PERMISSION_MODES = ["suggest", "auto-confirm", "full-auto"];
42
- function nextPermissionMode(current) {
43
- const idx = PERMISSION_MODES.indexOf(current);
44
- return PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length];
45
- }
46
- const PERMISSION_LABELS = {
47
- "suggest": "suggest",
48
- "auto-confirm": "auto",
49
- "full-auto": "full-auto",
50
- };
51
- const PERMISSION_ICONS = {
52
- "suggest": "○",
53
- "auto-confirm": "◐",
54
- "full-auto": "●",
55
- };
56
- const PERMISSION_COLORS = {
57
- "suggest": s.cyan,
58
- "auto-confirm": s.green,
59
- "full-auto": s.yellow,
60
- };
61
- function permTag(mode) {
62
- return PERMISSION_COLORS[mode](`${PERMISSION_ICONS[mode]} ${mode}`);
63
- }
64
21
  // ── Status bar ───────────────────────────────────────────────────────────────
65
22
  function renderStatusBar(provider, project, turns, cost, permMode, agentCount) {
66
23
  const modeLabel = permMode ? PERMISSION_LABELS[permMode] : "";
67
24
  const agentTag = agentCount && agentCount > 0 ? ` A${agentCount}` : "";
68
- // Left: brand + provider + project
25
+ // Left: brand + provider (skip project if it matches "phren" to avoid "phren · codex · phren")
69
26
  const parts = [" ◆ phren", provider];
70
- if (project)
27
+ if (project && project !== "phren")
71
28
  parts.push(project);
72
29
  const left = parts.join(" · ");
73
30
  // Right: mode + agents + cost + turns
@@ -84,62 +41,6 @@ function renderStatusBar(provider, project, turns, cost, permMode, agentCount) {
84
41
  const pad = Math.max(0, w - left.length - right.length);
85
42
  return s.invert(left + " ".repeat(pad) + right);
86
43
  }
87
- function stripAnsi(t) {
88
- return t.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
89
- }
90
- // ── Tool call rendering ──────────────────────────────────────────────────────
91
- const COMPACT_LINES = 3;
92
- function formatDuration(ms) {
93
- if (ms < 1000)
94
- return `${ms}ms`;
95
- if (ms < 60_000)
96
- return `${(ms / 1000).toFixed(1)}s`;
97
- const mins = Math.floor(ms / 60_000);
98
- const secs = Math.round((ms % 60_000) / 1000);
99
- return `${mins}m ${secs}s`;
100
- }
101
- function formatToolInput(name, input) {
102
- switch (name) {
103
- case "read_file":
104
- case "write_file":
105
- case "edit_file": return input.file_path ?? "";
106
- case "shell": return (input.command ?? "").slice(0, 60);
107
- case "glob": return input.pattern ?? "";
108
- case "grep": return `/${input.pattern ?? ""}/ ${input.path ?? ""}`;
109
- case "git_commit": return (input.message ?? "").slice(0, 50);
110
- case "phren_search": return input.query ?? "";
111
- case "phren_add_finding": return (input.finding ?? "").slice(0, 50);
112
- default: return JSON.stringify(input).slice(0, 60);
113
- }
114
- }
115
- function renderToolCall(name, input, output, isError, durationMs) {
116
- const preview = formatToolInput(name, input);
117
- const dur = formatDuration(durationMs);
118
- const icon = isError ? s.red("✗") : s.green("→");
119
- const header = ` ${icon} ${s.bold(name)} ${s.gray(preview)} ${s.dim(dur)}`;
120
- // Compact: show first 3 lines only, with overflow count
121
- const allLines = output.split("\n").filter(Boolean);
122
- if (allLines.length === 0)
123
- return header;
124
- const shown = allLines.slice(0, COMPACT_LINES);
125
- const body = shown.map((l) => s.dim(` ${l.slice(0, cols() - 6)}`)).join("\n");
126
- const overflow = allLines.length - COMPACT_LINES;
127
- const more = overflow > 0 ? `\n${s.dim(` ... +${overflow} lines`)}` : "";
128
- return `${header}\n${body}${more}`;
129
- }
130
- // ── Menu mode helpers ────────────────────────────────────────────────────────
131
- let menuMod = null;
132
- async function loadMenuModule() {
133
- if (!menuMod) {
134
- try {
135
- menuMod = await import("@phren/cli/shell/render-api");
136
- }
137
- catch {
138
- menuMod = null;
139
- }
140
- }
141
- return menuMod;
142
- }
143
44
  // ── Main TUI ─────────────────────────────────────────────────────────────────
144
45
  export async function startTui(config, spawner) {
145
46
  const contextLimit = config.provider.contextWindow ?? 200_000;
@@ -149,6 +50,7 @@ export async function startTui(config, spawner) {
149
50
  const startTime = Date.now();
150
51
  let inputMode = loadInputMode();
151
52
  let pendingInput = null;
53
+ const steerQueue = [];
152
54
  let running = false;
153
55
  let inputLine = "";
154
56
  let cursorPos = 0;
@@ -169,99 +71,68 @@ export async function startTui(config, spawner) {
169
71
  const inputHistory = [];
170
72
  let historyIndex = -1;
171
73
  let savedInput = "";
172
- // ── Menu rendering ─────────────────────────────────────────────────────
173
- async function renderMenu() {
174
- const mod = await loadMenuModule();
175
- if (!mod || !config.phrenCtx)
176
- return;
177
- const result = await mod.renderMenuFrame(config.phrenCtx.phrenPath, config.phrenCtx.profile, menuState);
178
- menuListCount = result.listCount;
179
- // Full-screen write: single write to avoid flicker
180
- w.write(`${ESC}?25l${ESC}H${ESC}2J${result.output}${ESC}?25h`);
181
- }
182
- function enterMenuMode() {
183
- if (!config.phrenCtx) {
184
- w.write(s.yellow(" phren not configured — menu unavailable\n"));
185
- return;
186
- }
187
- tuiMode = "menu";
188
- menuState.project = config.phrenCtx.project ?? menuState.project;
189
- w.write("\x1b[?1049h"); // enter alternate screen
190
- renderMenu();
191
- }
192
- function exitMenuMode() {
193
- tuiMode = "chat";
194
- menuFilterActive = false;
195
- menuFilterBuf = "";
196
- w.write("\x1b[?1049l"); // leave alternate screen (restores chat)
197
- setScrollRegion(); // re-establish scroll region after alt screen
198
- statusBar();
199
- 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
+ };
200
99
  }
201
100
  // Print status bar
202
101
  function statusBar() {
203
- if (!isTTY)
204
- return;
205
- const bar = renderStatusBar(config.provider.name, config.phrenCtx?.project ?? null, session.turns, costStr, config.registry.permissionConfig.mode, spawner?.listAgents().length);
206
- 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.
207
103
  }
208
- // Print prompt — bordered input bar at bottom
104
+ // Print prompt — inline input bar (written at current cursor position)
209
105
  let bashMode = false;
210
- 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() {
211
109
  if (!isTTY)
212
110
  return;
213
111
  const mode = config.registry.permissionConfig.mode;
214
112
  const color = PERMISSION_COLORS[mode];
215
113
  const icon = PERMISSION_ICONS[mode];
216
- const rows = process.stdout.rows || 24;
217
114
  const c = cols();
218
- if (!skipNewline) {
219
- // Newline within the scroll region so content scrolls up naturally
220
- cursorToScrollEnd();
221
- w.write("\n");
222
- }
223
- // Draw the fixed bottom bar outside the scroll region.
224
- // Temporarily reset DECSTBM so writes to rows (rows-4)..(rows) work.
225
- w.write(`${ESC}r`); // reset scroll region temporarily
226
- // Layout (bottom up): blank, permissions, separator, input, separator
227
115
  const sep = s.dim("─".repeat(c));
228
- const permLine = ` ${color(`${icon} ${PERMISSION_LABELS[mode]} permissions`)} ${s.dim("(shift+tab to cycle)")}`;
229
- w.write(`${ESC}${rows - 4};1H${ESC}2K${sep}`);
230
- w.write(`${ESC}${rows - 3};1H${ESC}2K${bashMode ? `${s.yellow("!")} ` : `${s.dim("▸")} `}`);
231
- w.write(`${ESC}${rows - 2};1H${ESC}2K${sep}`);
232
- w.write(`${ESC}${rows - 1};1H${ESC}2K${permLine}`);
233
- w.write(`${ESC}${rows};1H${ESC}2K`); // blank bottom row
234
- // Re-establish scroll region and position cursor at the input line
235
- setScrollRegion();
236
- w.write(`${ESC}${rows - 3};${bashMode ? 3 : 4}H`);
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
237
125
  }
238
126
  // Redraw the input line and position the terminal cursor at cursorPos
239
127
  function redrawInput() {
240
128
  w.write(`${ESC}2K\r`);
241
- prompt(true);
129
+ w.write(`${bashMode ? `${s.yellow("!")} ` : `${s.dim("▸")} `}`);
242
130
  w.write(inputLine);
243
131
  // Move terminal cursor back from end to cursorPos
244
132
  const back = inputLine.length - cursorPos;
245
133
  if (back > 0)
246
134
  w.write(`${ESC}${back}D`);
247
135
  }
248
- // ── Scroll region management ─────────────────────────────────────────
249
- // DECSTBM: rows 1..(rows-5) scroll; bottom 5 rows are fixed for the input bar.
250
- function setScrollRegion() {
251
- if (!isTTY)
252
- return;
253
- const rows = process.stdout.rows || 24;
254
- const scrollBottom = Math.max(1, rows - 5);
255
- w.write(`${ESC}1;${scrollBottom}r`);
256
- }
257
- // Move cursor to the bottom of the scroll region so new output scrolls naturally.
258
- function cursorToScrollEnd() {
259
- if (!isTTY)
260
- return;
261
- const rows = process.stdout.rows || 24;
262
- const scrollBottom = Math.max(1, rows - 5);
263
- w.write(`${ESC}${scrollBottom};1H`);
264
- }
265
136
  // Periodic status bar refresh (every 30s) — keeps cost/turns current during long tool runs
266
137
  const statusRefreshTimer = isTTY
267
138
  ? setInterval(() => { if (tuiMode === "chat")
@@ -273,7 +144,6 @@ export async function startTui(config, spawner) {
273
144
  function cleanupTerminal() {
274
145
  if (statusRefreshTimer)
275
146
  clearInterval(statusRefreshTimer);
276
- w.write(`${ESC}r`); // reset scroll region
277
147
  w.write("\x1b[?1049l"); // leave alt screen if active
278
148
  if (process.stdin.isTTY) {
279
149
  try {
@@ -283,15 +153,8 @@ export async function startTui(config, spawner) {
283
153
  }
284
154
  }
285
155
  process.on("exit", cleanupTerminal);
286
- // Re-establish scroll region on terminal resize.
287
- // Node's "resize" event already fires on SIGWINCH no separate signal handler needed.
288
- process.stdout.on("resize", () => {
289
- if (tuiMode === "chat") {
290
- setScrollRegion();
291
- statusBar();
292
- prompt(true);
293
- }
294
- });
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.
295
158
  // Setup: clear screen, status bar at top, content area clean
296
159
  if (isTTY) {
297
160
  w.write(`${ESC}2J${ESC}H`); // clear entire screen + home
@@ -312,7 +175,7 @@ export async function startTui(config, spawner) {
312
175
  `${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""}`,
313
176
  `${s.dim(cwd)}`,
314
177
  ``,
315
- `${permTag(permMode)} ${s.dim("permissions (shift+tab to cycle)")}`,
178
+ `${permTag(permMode)} ${s.dim("permissions (shift+tab toggle · esc to interrupt)")}`,
316
179
  ``,
317
180
  `${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit`,
318
181
  ];
@@ -329,7 +192,6 @@ export async function startTui(config, spawner) {
329
192
  w.write(`\n ${info[0]}\n ${info[1]} ${info[2]}\n ${info[4]}\n\n ${info[6]}\n\n`);
330
193
  }
331
194
  w.write("\n");
332
- setScrollRegion(); // establish scroll region after banner
333
195
  }
334
196
  // Raw stdin for steering
335
197
  if (process.stdin.isTTY) {
@@ -338,57 +200,6 @@ export async function startTui(config, spawner) {
338
200
  }
339
201
  let resolve = null;
340
202
  const done = new Promise((r) => { resolve = r; });
341
- // ── Menu keypress handler ───────────────────────────────────────────────
342
- async function handleMenuKeypress(key) {
343
- // Filter input mode: capture text for / search
344
- if (menuFilterActive) {
345
- if (key.name === "escape") {
346
- menuFilterActive = false;
347
- menuFilterBuf = "";
348
- menuState = { ...menuState, filter: undefined, cursor: 0, scroll: 0 };
349
- renderMenu();
350
- return;
351
- }
352
- if (key.name === "return") {
353
- menuFilterActive = false;
354
- menuState = { ...menuState, filter: menuFilterBuf || undefined, cursor: 0, scroll: 0 };
355
- menuFilterBuf = "";
356
- renderMenu();
357
- return;
358
- }
359
- if (key.name === "backspace") {
360
- menuFilterBuf = menuFilterBuf.slice(0, -1);
361
- menuState = { ...menuState, filter: menuFilterBuf || undefined, cursor: 0 };
362
- renderMenu();
363
- return;
364
- }
365
- if (key.sequence && !key.ctrl && !key.meta) {
366
- menuFilterBuf += key.sequence;
367
- menuState = { ...menuState, filter: menuFilterBuf, cursor: 0 };
368
- renderMenu();
369
- }
370
- return;
371
- }
372
- // "/" starts filter input
373
- if (key.sequence === "/") {
374
- menuFilterActive = true;
375
- menuFilterBuf = "";
376
- return;
377
- }
378
- const mod = await loadMenuModule();
379
- if (!mod) {
380
- exitMenuMode();
381
- return;
382
- }
383
- const newState = mod.handleMenuKey(menuState, key.name ?? "", menuListCount, config.phrenCtx?.phrenPath, config.phrenCtx?.profile);
384
- if (newState === null) {
385
- exitMenuMode();
386
- }
387
- else {
388
- menuState = newState;
389
- renderMenu();
390
- }
391
- }
392
203
  // ── Keypress router ────────────────────────────────────────────────────
393
204
  process.stdin.on("keypress", (_ch, key) => {
394
205
  if (!key)
@@ -407,8 +218,13 @@ export async function startTui(config, spawner) {
407
218
  const next = nextPermissionMode(config.registry.permissionConfig.mode);
408
219
  config.registry.setPermissions({ ...config.registry.permissionConfig, mode: next });
409
220
  savePermissionMode(next);
410
- // Just update bottom bar in-place no scrollback output
411
- 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`);
412
228
  return;
413
229
  }
414
230
  // Tab — completion or toggle mode
@@ -428,10 +244,10 @@ export async function startTui(config, spawner) {
428
244
  redrawInput();
429
245
  }
430
246
  else if (matches.length > 1) {
431
- // Show matches in scroll area, then redraw prompt
432
- cursorToScrollEnd();
247
+ // Show matches above prompt, then redraw
248
+ w.write(`\r${ESC}J`); // clear from cursor to end of screen
433
249
  w.write(`\n${s.dim(" " + matches.join(" "))}\n`);
434
- prompt(true);
250
+ prompt();
435
251
  w.write(inputLine);
436
252
  const back = inputLine.length - cursorPos;
437
253
  if (back > 0)
@@ -462,7 +278,7 @@ export async function startTui(config, spawner) {
462
278
  }
463
279
  else if (matches.length > 1) {
464
280
  const names = matches.map((e) => e.name + (e.isDirectory() ? "/" : ""));
465
- cursorToScrollEnd();
281
+ w.write(`\r${ESC}J`); // clear from cursor to end of screen
466
282
  w.write(`\n${s.dim(" " + names.join(" "))}\n`);
467
283
  // Find longest common prefix for partial completion
468
284
  let common = matches[0].name;
@@ -477,7 +293,7 @@ export async function startTui(config, spawner) {
477
293
  inputLine = prefix + fullPath;
478
294
  cursorPos = inputLine.length;
479
295
  }
480
- prompt(true);
296
+ prompt();
481
297
  w.write(inputLine);
482
298
  const back = inputLine.length - cursorPos;
483
299
  if (back > 0)
@@ -489,16 +305,17 @@ export async function startTui(config, spawner) {
489
305
  }
490
306
  // Default: toggle menu mode
491
307
  if (tuiMode === "chat" && !running) {
492
- enterMenuMode();
308
+ tuiMode = "menu";
309
+ enterMenu(getMenuCtx());
493
310
  }
494
311
  else if (tuiMode === "menu") {
495
- exitMenuMode();
312
+ exitMenu(getMenuCtx());
496
313
  }
497
314
  return;
498
315
  }
499
316
  // Route to mode-specific handler
500
317
  if (tuiMode === "menu") {
501
- handleMenuKeypress(key);
318
+ handleMenuKey(key, getMenuCtx());
502
319
  return;
503
320
  }
504
321
  // ── Chat mode keys ──────────────────────────────────────────────────
@@ -508,13 +325,13 @@ export async function startTui(config, spawner) {
508
325
  bashMode = false;
509
326
  inputLine = "";
510
327
  cursorPos = 0;
511
- prompt(true);
328
+ redrawInput();
512
329
  return;
513
330
  }
514
331
  if (inputLine) {
515
332
  inputLine = "";
516
333
  cursorPos = 0;
517
- prompt(true);
334
+ redrawInput();
518
335
  return;
519
336
  }
520
337
  }
@@ -531,7 +348,7 @@ export async function startTui(config, spawner) {
531
348
  bashMode = false;
532
349
  inputLine = "";
533
350
  cursorPos = 0;
534
- prompt(true);
351
+ redrawInput();
535
352
  ctrlCCount = 0;
536
353
  return;
537
354
  }
@@ -539,15 +356,17 @@ export async function startTui(config, spawner) {
539
356
  // Clear input
540
357
  inputLine = "";
541
358
  cursorPos = 0;
542
- prompt(true);
359
+ redrawInput();
543
360
  ctrlCCount = 0;
544
361
  return;
545
362
  }
546
363
  // Nothing to cancel — progressive quit
547
364
  ctrlCCount++;
548
365
  if (ctrlCCount === 1) {
366
+ // Clear current prompt, print warning, redraw prompt
367
+ w.write(`\r${ESC}J`);
549
368
  w.write(s.dim("\n Press Ctrl+C again to exit.\n"));
550
- prompt(true);
369
+ prompt();
551
370
  // Reset after 2 seconds
552
371
  setTimeout(() => { ctrlCCount = 0; }, 2000);
553
372
  }
@@ -564,11 +383,8 @@ export async function startTui(config, spawner) {
564
383
  const line = inputLine.trim();
565
384
  cursorPos = 0;
566
385
  inputLine = "";
567
- // Move to the bottom of the scroll region so new output scrolls naturally
568
- cursorToScrollEnd();
569
- w.write("\n");
570
386
  if (!line) {
571
- prompt();
387
+ redrawInput();
572
388
  return;
573
389
  }
574
390
  // Push to history
@@ -586,7 +402,7 @@ export async function startTui(config, spawner) {
586
402
  if (cdMatch) {
587
403
  try {
588
404
  const target = cdMatch[1].trim().replace(/^~/, os.homedir());
589
- const resolved = require("path").resolve(process.cwd(), target);
405
+ const resolved = path.resolve(process.cwd(), target);
590
406
  process.chdir(resolved);
591
407
  w.write(s.dim(process.cwd()) + "\n");
592
408
  }
@@ -631,14 +447,14 @@ export async function startTui(config, spawner) {
631
447
  startTime,
632
448
  phrenPath: config.phrenCtx?.phrenPath,
633
449
  phrenCtx: config.phrenCtx,
634
- onModelChange: (result) => {
450
+ onModelChange: async (result) => {
635
451
  // Live model switch — re-resolve provider with new model
636
452
  try {
637
- const { resolveProvider } = require("./providers/resolve.js");
453
+ const { resolveProvider } = await import("./providers/resolve.js");
638
454
  const newProvider = resolveProvider(config.provider.name, result.model);
639
455
  config.provider = newProvider;
640
456
  // Rebuild system prompt with new model info
641
- const { buildSystemPrompt } = require("./system-prompt.js");
457
+ const { buildSystemPrompt } = await import("./system-prompt.js");
642
458
  config.systemPrompt = buildSystemPrompt(config.systemPrompt.split("\n## Last session")[0], // preserve context, strip old summary
643
459
  null, { name: newProvider.name, model: result.model });
644
460
  statusBar();
@@ -654,14 +470,24 @@ export async function startTui(config, spawner) {
654
470
  cmdResult.then(() => { prompt(); });
655
471
  return;
656
472
  }
657
- // If agent is running, buffer input
473
+ // If agent is running, add to steer queue
658
474
  if (running) {
659
- pendingInput = line;
660
- const label = inputMode === "steering" ? "steering" : "queued";
661
- 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`);
662
483
  return;
663
484
  }
664
- // 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
665
491
  runAgentTurn(line);
666
492
  return;
667
493
  }
@@ -803,7 +629,7 @@ export async function startTui(config, spawner) {
803
629
  // ! at start of empty input toggles bash mode
804
630
  if (key.sequence === "!" && inputLine === "" && !bashMode) {
805
631
  bashMode = true;
806
- prompt(true);
632
+ redrawInput();
807
633
  return;
808
634
  }
809
635
  inputLine = inputLine.slice(0, cursorPos) + key.sequence + inputLine.slice(cursorPos);
@@ -814,6 +640,7 @@ export async function startTui(config, spawner) {
814
640
  // TUI hooks — render streaming text with markdown, compact tool output
815
641
  let textBuffer = "";
816
642
  let firstDelta = true;
643
+ let activeThinkTimer = null;
817
644
  function flushTextBuffer() {
818
645
  if (!textBuffer)
819
646
  return;
@@ -823,21 +650,43 @@ export async function startTui(config, spawner) {
823
650
  const tuiHooks = {
824
651
  onTextDelta: (text) => {
825
652
  if (firstDelta) {
826
- 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
827
659
  firstDelta = false;
828
660
  }
829
- // Stream directly for real-time feel — write each delta immediately
830
661
  w.write(text);
831
662
  },
832
663
  onTextDone: () => {
833
664
  flushTextBuffer();
834
665
  },
835
666
  onTextBlock: (text) => {
836
- 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);
837
677
  if (!text.endsWith("\n"))
838
678
  w.write("\n");
839
679
  },
840
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
+ }
841
690
  flushTextBuffer();
842
691
  const preview = formatToolInput(name, input);
843
692
  const countLabel = count > 1 ? s.dim(` (${count} tools)`) : "";
@@ -854,6 +703,12 @@ export async function startTui(config, spawner) {
854
703
  },
855
704
  onStatus: (msg) => w.write(s.dim(msg)),
856
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
+ }
857
712
  if (pendingInput && inputMode === "steering") {
858
713
  const steer = pendingInput;
859
714
  pendingInput = null;
@@ -866,39 +721,53 @@ export async function startTui(config, spawner) {
866
721
  async function runAgentTurn(userInput) {
867
722
  running = true;
868
723
  firstDelta = true;
869
- cursorToScrollEnd(); // ensure all turn output stays within scroll region
870
724
  const thinkStart = Date.now();
871
- // Phren thinking — subtle purple/cyan breath, no spinner gimmicks
725
+ // Phren thinking — subtle purple/cyan breath with rotating verbs
726
+ const THINK_VERBS = ["thinking", "reasoning", "recalling", "connecting", "processing"];
872
727
  let thinkFrame = 0;
873
- const thinkTimer = setInterval(() => {
728
+ activeThinkTimer = setInterval(() => {
874
729
  const elapsed = (Date.now() - thinkStart) / 1000;
875
- // Gentle sine-wave interpolation between phren purple and cyan
876
- const t = (Math.sin(thinkFrame * 0.08) + 1) / 2; // 0..1, slow oscillation
730
+ const verb = THINK_VERBS[Math.floor(elapsed / 6) % THINK_VERBS.length];
731
+ const t = (Math.sin(thinkFrame * 0.08) + 1) / 2;
877
732
  const r = Math.round(155 * (1 - t) + 40 * t);
878
733
  const g = Math.round(140 * (1 - t) + 211 * t);
879
734
  const b = Math.round(250 * (1 - t) + 242 * t);
880
735
  const color = `${ESC}38;2;${r};${g};${b}m`;
881
- w.write(`${ESC}2K ${color}◆ thinking${ESC}0m ${s.dim(`${elapsed.toFixed(1)}s`)}\r`);
736
+ w.write(`${ESC}2K${color}◆ ${verb}${ESC}0m ${s.dim(`${elapsed.toFixed(1)}s`)}\r`);
882
737
  thinkFrame++;
883
738
  }, 50);
884
739
  try {
885
740
  await runTurn(userInput, session, config, tuiHooks);
886
- clearInterval(thinkTimer);
741
+ if (activeThinkTimer) {
742
+ clearInterval(activeThinkTimer);
743
+ activeThinkTimer = null;
744
+ }
887
745
  const elapsed = ((Date.now() - thinkStart) / 1000).toFixed(1);
888
- w.write(`${ESC}2K ${s.dim(`◆ thought for ${elapsed}s`)}\n`);
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`);
889
749
  statusBar();
890
750
  }
891
751
  catch (err) {
892
- clearInterval(thinkTimer);
752
+ if (activeThinkTimer) {
753
+ clearInterval(activeThinkTimer);
754
+ activeThinkTimer = null;
755
+ }
893
756
  const msg = err instanceof Error ? err.message : String(err);
894
757
  w.write(`${ESC}2K\r`);
895
758
  w.write(s.red(` Error: ${msg}\n`));
896
759
  }
897
760
  running = false;
898
- // Process queued input
899
- 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) {
900
768
  const queued = pendingInput;
901
769
  pendingInput = null;
770
+ w.write(`${s.bold("❯")} ${queued}\n`);
902
771
  runAgentTurn(queued);
903
772
  }
904
773
  else {