@phren/agent 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js CHANGED
@@ -9,6 +9,8 @@ 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";
@@ -46,6 +48,19 @@ const PERMISSION_LABELS = {
46
48
  "auto-confirm": "auto",
47
49
  "full-auto": "full-auto",
48
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
+ }
49
64
  // ── Status bar ───────────────────────────────────────────────────────────────
50
65
  function renderStatusBar(provider, project, turns, cost, permMode, agentCount) {
51
66
  const modeLabel = permMode ? PERMISSION_LABELS[permMode] : "";
@@ -84,10 +99,9 @@ function formatDuration(ms) {
84
99
  return `${mins}m ${secs}s`;
85
100
  }
86
101
  function formatToolInput(name, input) {
87
- // Show the most relevant input field for each tool type
88
102
  switch (name) {
89
- case "read_file": return input.file_path ?? "";
90
- case "write_file": return input.file_path ?? "";
103
+ case "read_file":
104
+ case "write_file":
91
105
  case "edit_file": return input.file_path ?? "";
92
106
  case "shell": return (input.command ?? "").slice(0, 60);
93
107
  case "glob": return input.pattern ?? "";
@@ -132,10 +146,12 @@ export async function startTui(config, spawner) {
132
146
  const session = createSession(contextLimit);
133
147
  const w = process.stdout;
134
148
  const isTTY = process.stdout.isTTY;
149
+ const startTime = Date.now();
135
150
  let inputMode = loadInputMode();
136
151
  let pendingInput = null;
137
152
  let running = false;
138
153
  let inputLine = "";
154
+ let cursorPos = 0;
139
155
  let costStr = "";
140
156
  // ── Dual-mode state ─────────────────────────────────────────────────────
141
157
  let tuiMode = "chat";
@@ -149,6 +165,10 @@ export async function startTui(config, spawner) {
149
165
  let menuFilterActive = false;
150
166
  let menuFilterBuf = "";
151
167
  let ctrlCCount = 0;
168
+ // Input history
169
+ const inputHistory = [];
170
+ let historyIndex = -1;
171
+ let savedInput = "";
152
172
  // ── Menu rendering ─────────────────────────────────────────────────────
153
173
  async function renderMenu() {
154
174
  const mod = await loadMenuModule();
@@ -174,6 +194,7 @@ export async function startTui(config, spawner) {
174
194
  menuFilterActive = false;
175
195
  menuFilterBuf = "";
176
196
  w.write("\x1b[?1049l"); // leave alternate screen (restores chat)
197
+ setScrollRegion(); // re-establish scroll region after alt screen
177
198
  statusBar();
178
199
  prompt(true); // skip newline — alt screen restore already positioned cursor
179
200
  }
@@ -190,28 +211,69 @@ export async function startTui(config, spawner) {
190
211
  if (!isTTY)
191
212
  return;
192
213
  const mode = config.registry.permissionConfig.mode;
193
- const modeIcon = mode === "full-auto" ? "●" : mode === "auto-confirm" ? "◐" : "○";
194
- const modeColor = mode === "full-auto" ? s.yellow : mode === "auto-confirm" ? s.green : s.cyan;
214
+ const color = PERMISSION_COLORS[mode];
215
+ const icon = PERMISSION_ICONS[mode];
195
216
  const rows = process.stdout.rows || 24;
196
217
  const c = cols();
197
- if (!skipNewline)
218
+ if (!skipNewline) {
219
+ // Newline within the scroll region so content scrolls up naturally
220
+ cursorToScrollEnd();
198
221
  w.write("\n");
199
- // Separator line + prompt on last 2 rows
200
- const permLabel = PERMISSION_LABELS[mode];
201
- // 3 bottom rows: separator, permission line, input
202
- const sepLine = s.dim("─".repeat(c));
203
- const permLine = ` ${modeColor(`${modeIcon} ${permLabel} permissions`)} ${s.dim("(shift+tab to cycle)")}`;
204
- w.write(`${ESC}${rows - 2};1H${ESC}2K${sepLine}`);
205
- w.write(`${ESC}${rows - 1};1H${ESC}2K${permLine}`);
206
- if (bashMode) {
207
- w.write(`${ESC}${rows};1H${ESC}2K${s.yellow("!")} `);
208
- }
209
- else {
210
- w.write(`${ESC}${rows};1H${ESC}2K${modeColor(modeIcon)} ${s.dim("▸")} `);
211
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
+ 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`);
237
+ }
238
+ // Redraw the input line and position the terminal cursor at cursorPos
239
+ function redrawInput() {
240
+ w.write(`${ESC}2K\r`);
241
+ prompt(true);
242
+ w.write(inputLine);
243
+ // Move terminal cursor back from end to cursorPos
244
+ const back = inputLine.length - cursorPos;
245
+ if (back > 0)
246
+ w.write(`${ESC}${back}D`);
212
247
  }
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
+ // Periodic status bar refresh (every 30s) — keeps cost/turns current during long tool runs
266
+ const statusRefreshTimer = isTTY
267
+ ? setInterval(() => { if (tuiMode === "chat")
268
+ statusBar(); }, 30_000)
269
+ : null;
270
+ if (statusRefreshTimer)
271
+ statusRefreshTimer.unref(); // don't keep process alive
213
272
  // Terminal cleanup: restore state on exit
214
273
  function cleanupTerminal() {
274
+ if (statusRefreshTimer)
275
+ clearInterval(statusRefreshTimer);
276
+ w.write(`${ESC}r`); // reset scroll region
215
277
  w.write("\x1b[?1049l"); // leave alt screen if active
216
278
  if (process.stdin.isTTY) {
217
279
  try {
@@ -221,6 +283,15 @@ export async function startTui(config, spawner) {
221
283
  }
222
284
  }
223
285
  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
+ });
224
295
  // Setup: clear screen, status bar at top, content area clean
225
296
  if (isTTY) {
226
297
  w.write(`${ESC}2J${ESC}H`); // clear entire screen + home
@@ -230,26 +301,23 @@ export async function startTui(config, spawner) {
230
301
  const project = config.phrenCtx?.project;
231
302
  const cwd = process.cwd().replace(os.homedir(), "~");
232
303
  const permMode = config.registry.permissionConfig.mode;
233
- const modeColor = permMode === "full-auto" ? s.yellow : permMode === "auto-confirm" ? s.green : s.cyan;
234
- // Try to show the phren character art alongside info
235
304
  let artLines = [];
236
305
  try {
237
306
  const { PHREN_ART } = await import("@phren/cli/phren-art");
238
307
  artLines = PHREN_ART.filter((l) => l.trim());
239
308
  }
240
309
  catch { /* art not available */ }
310
+ const info = [
311
+ `${s.brand("◆ phren agent")} ${s.dim(`v${AGENT_VERSION}`)}`,
312
+ `${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""}`,
313
+ `${s.dim(cwd)}`,
314
+ ``,
315
+ `${permTag(permMode)} ${s.dim("permissions (shift+tab to cycle)")}`,
316
+ ``,
317
+ `${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit`,
318
+ ];
241
319
  if (artLines.length > 0) {
242
- // Art on left, info on right
243
- const info = [
244
- `${s.brand("◆ phren agent")} ${s.dim(`v${AGENT_VERSION}`)}`,
245
- `${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""}`,
246
- `${s.dim(cwd)}`,
247
- ``,
248
- `${modeColor(`${permMode === "full-auto" ? "●" : permMode === "auto-confirm" ? "◐" : "○"} ${permMode}`)} ${s.dim("permissions (shift+tab to cycle)")}`,
249
- ``,
250
- `${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit`,
251
- ];
252
- const maxArtWidth = 26; // phren art is ~24 chars wide
320
+ const maxArtWidth = 26;
253
321
  for (let i = 0; i < Math.max(artLines.length, info.length); i++) {
254
322
  const artPart = i < artLines.length ? artLines[i] : "";
255
323
  const infoPart = i < info.length ? info[i] : "";
@@ -258,13 +326,10 @@ export async function startTui(config, spawner) {
258
326
  }
259
327
  }
260
328
  else {
261
- // Fallback: text-only banner
262
- w.write(`\n ${s.brand("◆ phren agent")} ${s.dim(`v${AGENT_VERSION}`)}\n`);
263
- w.write(` ${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""} ${s.dim(cwd)}\n`);
264
- w.write(` ${modeColor(`${permMode === "full-auto" ? "●" : permMode === "auto-confirm" ? "◐" : "○"} ${permMode}`)} ${s.dim("permissions (shift+tab to cycle)")}\n\n`);
265
- w.write(` ${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit\n\n`);
329
+ w.write(`\n ${info[0]}\n ${info[1]} ${info[2]}\n ${info[4]}\n\n ${info[6]}\n\n`);
266
330
  }
267
331
  w.write("\n");
332
+ setScrollRegion(); // establish scroll region after banner
268
333
  }
269
334
  // Raw stdin for steering
270
335
  if (process.stdin.isTTY) {
@@ -339,20 +404,90 @@ export async function startTui(config, spawner) {
339
404
  }
340
405
  // Shift+Tab — cycle permission mode (works in chat mode, not during filter)
341
406
  if (key.shift && key.name === "tab" && !menuFilterActive && tuiMode === "chat") {
342
- const current = config.registry.permissionConfig.mode;
343
- const next = nextPermissionMode(current);
407
+ const next = nextPermissionMode(config.registry.permissionConfig.mode);
344
408
  config.registry.setPermissions({ ...config.registry.permissionConfig, mode: next });
345
409
  savePermissionMode(next);
346
- const modeColor = next === "full-auto" ? s.yellow : next === "auto-confirm" ? s.green : s.cyan;
347
- const modeIcon = next === "full-auto" ? "●" : next === "auto-confirm" ? "◐" : "○";
348
- w.write(` ${modeColor(`${modeIcon} ${next}`)}\n`);
349
- statusBar();
350
- if (!running)
351
- prompt();
410
+ // Just update bottom bar in-place no scrollback output
411
+ prompt(true);
352
412
  return;
353
413
  }
354
- // Tab — toggle mode (not during agent run or filter)
355
- if (key.name === "tab" && !menuFilterActive) {
414
+ // Tab — completion or toggle mode
415
+ if (key.name === "tab" && !key.shift && !menuFilterActive) {
416
+ // Slash command completion in chat mode
417
+ if (tuiMode === "chat" && inputLine.startsWith("/")) {
418
+ const SLASH_COMMANDS = [
419
+ "/help", "/model", "/provider", "/turns", "/clear", "/cost",
420
+ "/plan", "/undo", "/history", "/compact", "/context", "/mode",
421
+ "/spawn", "/agents", "/diff", "/git", "/files", "/cwd",
422
+ "/preset", "/exit",
423
+ ];
424
+ const matches = SLASH_COMMANDS.filter((c) => c.startsWith(inputLine));
425
+ if (matches.length === 1) {
426
+ inputLine = matches[0];
427
+ cursorPos = inputLine.length;
428
+ redrawInput();
429
+ }
430
+ else if (matches.length > 1) {
431
+ // Show matches in scroll area, then redraw prompt
432
+ cursorToScrollEnd();
433
+ w.write(`\n${s.dim(" " + matches.join(" "))}\n`);
434
+ prompt(true);
435
+ w.write(inputLine);
436
+ const back = inputLine.length - cursorPos;
437
+ if (back > 0)
438
+ w.write(`${ESC}${back}D`);
439
+ }
440
+ return;
441
+ }
442
+ // File path completion in bash mode
443
+ if (tuiMode === "chat" && bashMode && inputLine.length > 0) {
444
+ // Complete the last whitespace-delimited token as a path
445
+ const lastSpace = inputLine.lastIndexOf(" ");
446
+ const prefix = lastSpace === -1 ? "" : inputLine.slice(0, lastSpace + 1);
447
+ const partial = lastSpace === -1 ? inputLine : inputLine.slice(lastSpace + 1);
448
+ const expandedPartial = partial.replace(/^~/, os.homedir());
449
+ const dir = path.dirname(expandedPartial);
450
+ const base = path.basename(expandedPartial);
451
+ try {
452
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
453
+ const matches = entries.filter((e) => e.name.startsWith(base));
454
+ if (matches.length === 1) {
455
+ const completed = matches[0];
456
+ const fullPath = partial.startsWith("~")
457
+ ? "~/" + path.relative(os.homedir(), path.join(dir, completed.name))
458
+ : path.join(dir, completed.name);
459
+ inputLine = prefix + fullPath + (completed.isDirectory() ? "/" : "");
460
+ cursorPos = inputLine.length;
461
+ redrawInput();
462
+ }
463
+ else if (matches.length > 1) {
464
+ const names = matches.map((e) => e.name + (e.isDirectory() ? "/" : ""));
465
+ cursorToScrollEnd();
466
+ w.write(`\n${s.dim(" " + names.join(" "))}\n`);
467
+ // Find longest common prefix for partial completion
468
+ let common = matches[0].name;
469
+ for (const m of matches) {
470
+ while (!m.name.startsWith(common))
471
+ common = common.slice(0, -1);
472
+ }
473
+ if (common.length > base.length) {
474
+ const fullPath = partial.startsWith("~")
475
+ ? "~/" + path.relative(os.homedir(), path.join(dir, common))
476
+ : path.join(dir, common);
477
+ inputLine = prefix + fullPath;
478
+ cursorPos = inputLine.length;
479
+ }
480
+ prompt(true);
481
+ w.write(inputLine);
482
+ const back = inputLine.length - cursorPos;
483
+ if (back > 0)
484
+ w.write(`${ESC}${back}D`);
485
+ }
486
+ }
487
+ catch { /* dir doesn't exist or unreadable */ }
488
+ return;
489
+ }
490
+ // Default: toggle menu mode
356
491
  if (tuiMode === "chat" && !running) {
357
492
  enterMenuMode();
358
493
  }
@@ -372,11 +507,13 @@ export async function startTui(config, spawner) {
372
507
  if (bashMode) {
373
508
  bashMode = false;
374
509
  inputLine = "";
510
+ cursorPos = 0;
375
511
  prompt(true);
376
512
  return;
377
513
  }
378
514
  if (inputLine) {
379
515
  inputLine = "";
516
+ cursorPos = 0;
380
517
  prompt(true);
381
518
  return;
382
519
  }
@@ -393,6 +530,7 @@ export async function startTui(config, spawner) {
393
530
  if (bashMode) {
394
531
  bashMode = false;
395
532
  inputLine = "";
533
+ cursorPos = 0;
396
534
  prompt(true);
397
535
  ctrlCCount = 0;
398
536
  return;
@@ -400,6 +538,7 @@ export async function startTui(config, spawner) {
400
538
  if (inputLine) {
401
539
  // Clear input
402
540
  inputLine = "";
541
+ cursorPos = 0;
403
542
  prompt(true);
404
543
  ctrlCCount = 0;
405
544
  return;
@@ -414,8 +553,7 @@ export async function startTui(config, spawner) {
414
553
  }
415
554
  else {
416
555
  // Actually quit
417
- if (process.stdin.isTTY)
418
- process.stdin.setRawMode(false);
556
+ cleanupTerminal();
419
557
  w.write(s.dim("\nSession ended.\n"));
420
558
  resolve(session);
421
559
  }
@@ -424,12 +562,20 @@ export async function startTui(config, spawner) {
424
562
  // Enter — submit
425
563
  if (key.name === "return") {
426
564
  const line = inputLine.trim();
565
+ cursorPos = 0;
427
566
  inputLine = "";
567
+ // Move to the bottom of the scroll region so new output scrolls naturally
568
+ cursorToScrollEnd();
428
569
  w.write("\n");
429
570
  if (!line) {
430
571
  prompt();
431
572
  return;
432
573
  }
574
+ // Push to history
575
+ if (inputHistory[inputHistory.length - 1] !== line) {
576
+ inputHistory.push(line);
577
+ }
578
+ historyIndex = -1;
433
579
  // Bash mode: ! prefix runs shell directly
434
580
  if (line.startsWith("!") || bashMode) {
435
581
  const cmd = bashMode ? line : line.slice(1).trim();
@@ -472,17 +618,42 @@ export async function startTui(config, spawner) {
472
618
  prompt();
473
619
  return;
474
620
  }
475
- if (handleCommand(line, {
621
+ const cmdResult = handleCommand(line, {
476
622
  session,
477
623
  contextLimit,
478
624
  undoStack: [],
479
625
  providerName: config.provider.name,
480
626
  currentModel: config.provider.model,
627
+ provider: config.provider,
628
+ systemPrompt: config.systemPrompt,
481
629
  spawner,
482
- })) {
630
+ sessionId: config.sessionId,
631
+ startTime,
632
+ phrenPath: config.phrenCtx?.phrenPath,
633
+ phrenCtx: config.phrenCtx,
634
+ onModelChange: (result) => {
635
+ // Live model switch — re-resolve provider with new model
636
+ try {
637
+ const { resolveProvider } = require("./providers/resolve.js");
638
+ const newProvider = resolveProvider(config.provider.name, result.model);
639
+ config.provider = newProvider;
640
+ // Rebuild system prompt with new model info
641
+ const { buildSystemPrompt } = require("./system-prompt.js");
642
+ config.systemPrompt = buildSystemPrompt(config.systemPrompt.split("\n## Last session")[0], // preserve context, strip old summary
643
+ null, { name: newProvider.name, model: result.model });
644
+ statusBar();
645
+ }
646
+ catch { /* keep current provider on error */ }
647
+ },
648
+ });
649
+ if (cmdResult === true) {
483
650
  prompt();
484
651
  return;
485
652
  }
653
+ if (typeof cmdResult === "object" && cmdResult instanceof Promise) {
654
+ cmdResult.then(() => { prompt(); });
655
+ return;
656
+ }
486
657
  // If agent is running, buffer input
487
658
  if (running) {
488
659
  pendingInput = line;
@@ -494,15 +665,140 @@ export async function startTui(config, spawner) {
494
665
  runAgentTurn(line);
495
666
  return;
496
667
  }
497
- // Backspace
668
+ // Up arrow — previous history
669
+ if (key.name === "up" && !running && tuiMode === "chat") {
670
+ if (inputHistory.length === 0)
671
+ return;
672
+ if (historyIndex === -1) {
673
+ savedInput = inputLine;
674
+ historyIndex = inputHistory.length - 1;
675
+ }
676
+ else if (historyIndex > 0) {
677
+ historyIndex--;
678
+ }
679
+ inputLine = inputHistory[historyIndex];
680
+ cursorPos = inputLine.length;
681
+ redrawInput();
682
+ return;
683
+ }
684
+ // Down arrow — next history or restore saved
685
+ if (key.name === "down" && !running && tuiMode === "chat") {
686
+ if (historyIndex === -1)
687
+ return;
688
+ if (historyIndex < inputHistory.length - 1) {
689
+ historyIndex++;
690
+ inputLine = inputHistory[historyIndex];
691
+ }
692
+ else {
693
+ historyIndex = -1;
694
+ inputLine = savedInput;
695
+ }
696
+ cursorPos = inputLine.length;
697
+ redrawInput();
698
+ return;
699
+ }
700
+ // Ctrl+A — move cursor to start of line
701
+ if (key.ctrl && key.name === "a") {
702
+ cursorPos = 0;
703
+ redrawInput();
704
+ return;
705
+ }
706
+ // Ctrl+E — move cursor to end of line
707
+ if (key.ctrl && key.name === "e") {
708
+ cursorPos = inputLine.length;
709
+ redrawInput();
710
+ return;
711
+ }
712
+ // Ctrl+U — kill entire line
713
+ if (key.ctrl && key.name === "u") {
714
+ inputLine = "";
715
+ cursorPos = 0;
716
+ redrawInput();
717
+ return;
718
+ }
719
+ // Ctrl+K — kill from cursor to end of line
720
+ if (key.ctrl && key.name === "k") {
721
+ inputLine = inputLine.slice(0, cursorPos);
722
+ redrawInput();
723
+ return;
724
+ }
725
+ // Left arrow — move cursor left one character
726
+ if (key.name === "left" && !key.meta && !key.ctrl) {
727
+ if (cursorPos > 0) {
728
+ cursorPos--;
729
+ w.write(`${ESC}D`);
730
+ }
731
+ return;
732
+ }
733
+ // Right arrow — move cursor right one character
734
+ if (key.name === "right" && !key.meta && !key.ctrl) {
735
+ if (cursorPos < inputLine.length) {
736
+ cursorPos++;
737
+ w.write(`${ESC}C`);
738
+ }
739
+ return;
740
+ }
741
+ // Alt+Left — move cursor left by one word
742
+ if (key.name === "left" && (key.meta || key.ctrl)) {
743
+ if (cursorPos > 0) {
744
+ // Skip spaces, then skip non-spaces
745
+ let p = cursorPos;
746
+ while (p > 0 && inputLine[p - 1] === " ")
747
+ p--;
748
+ while (p > 0 && inputLine[p - 1] !== " ")
749
+ p--;
750
+ cursorPos = p;
751
+ redrawInput();
752
+ }
753
+ return;
754
+ }
755
+ // Alt+Right — move cursor right by one word
756
+ if (key.name === "right" && (key.meta || key.ctrl)) {
757
+ if (cursorPos < inputLine.length) {
758
+ let p = cursorPos;
759
+ while (p < inputLine.length && inputLine[p] !== " ")
760
+ p++;
761
+ while (p < inputLine.length && inputLine[p] === " ")
762
+ p++;
763
+ cursorPos = p;
764
+ redrawInput();
765
+ }
766
+ return;
767
+ }
768
+ // Word-delete: Alt+Backspace, Ctrl+Backspace, Ctrl+W
769
+ if (((key.meta || key.ctrl) && key.name === "backspace") ||
770
+ (key.ctrl && key.name === "w")) {
771
+ if (cursorPos > 0) {
772
+ // Find word boundary before cursor
773
+ let p = cursorPos;
774
+ while (p > 0 && inputLine[p - 1] === " ")
775
+ p--;
776
+ while (p > 0 && inputLine[p - 1] !== " ")
777
+ p--;
778
+ inputLine = inputLine.slice(0, p) + inputLine.slice(cursorPos);
779
+ cursorPos = p;
780
+ redrawInput();
781
+ }
782
+ return;
783
+ }
784
+ // Backspace — delete character before cursor
498
785
  if (key.name === "backspace") {
499
- if (inputLine.length > 0) {
500
- inputLine = inputLine.slice(0, -1);
501
- w.write("\b \b");
786
+ if (cursorPos > 0) {
787
+ inputLine = inputLine.slice(0, cursorPos - 1) + inputLine.slice(cursorPos);
788
+ cursorPos--;
789
+ redrawInput();
502
790
  }
503
791
  return;
504
792
  }
505
- // Regular character
793
+ // Delete — delete character at cursor
794
+ if (key.name === "delete") {
795
+ if (cursorPos < inputLine.length) {
796
+ inputLine = inputLine.slice(0, cursorPos) + inputLine.slice(cursorPos + 1);
797
+ redrawInput();
798
+ }
799
+ return;
800
+ }
801
+ // Regular character — insert at cursor position
506
802
  if (key.sequence && !key.ctrl && !key.meta) {
507
803
  // ! at start of empty input toggles bash mode
508
804
  if (key.sequence === "!" && inputLine === "" && !bashMode) {
@@ -510,12 +806,14 @@ export async function startTui(config, spawner) {
510
806
  prompt(true);
511
807
  return;
512
808
  }
513
- inputLine += key.sequence;
514
- w.write(key.sequence);
809
+ inputLine = inputLine.slice(0, cursorPos) + key.sequence + inputLine.slice(cursorPos);
810
+ cursorPos += key.sequence.length;
811
+ redrawInput();
515
812
  }
516
813
  });
517
814
  // TUI hooks — render streaming text with markdown, compact tool output
518
815
  let textBuffer = "";
816
+ let firstDelta = true;
519
817
  function flushTextBuffer() {
520
818
  if (!textBuffer)
521
819
  return;
@@ -524,11 +822,12 @@ export async function startTui(config, spawner) {
524
822
  }
525
823
  const tuiHooks = {
526
824
  onTextDelta: (text) => {
527
- textBuffer += text;
528
- // Flush on paragraph boundaries (double newline) or single newline for streaming feel
529
- if (textBuffer.includes("\n\n") || textBuffer.endsWith("\n")) {
530
- flushTextBuffer();
825
+ if (firstDelta) {
826
+ w.write(`${ESC}2K\r`); // clear thinking timer line
827
+ firstDelta = false;
531
828
  }
829
+ // Stream directly for real-time feel — write each delta immediately
830
+ w.write(text);
532
831
  },
533
832
  onTextDone: () => {
534
833
  flushTextBuffer();
@@ -566,13 +865,33 @@ export async function startTui(config, spawner) {
566
865
  };
567
866
  async function runAgentTurn(userInput) {
568
867
  running = true;
569
- w.write(`${ESC}2K ${s.dim("◌ thinking...")}\r`);
868
+ firstDelta = true;
869
+ cursorToScrollEnd(); // ensure all turn output stays within scroll region
870
+ const thinkStart = Date.now();
871
+ // Phren thinking — subtle purple/cyan breath, no spinner gimmicks
872
+ let thinkFrame = 0;
873
+ const thinkTimer = setInterval(() => {
874
+ 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
877
+ const r = Math.round(155 * (1 - t) + 40 * t);
878
+ const g = Math.round(140 * (1 - t) + 211 * t);
879
+ const b = Math.round(250 * (1 - t) + 242 * t);
880
+ 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`);
882
+ thinkFrame++;
883
+ }, 50);
570
884
  try {
571
885
  await runTurn(userInput, session, config, tuiHooks);
886
+ clearInterval(thinkTimer);
887
+ const elapsed = ((Date.now() - thinkStart) / 1000).toFixed(1);
888
+ w.write(`${ESC}2K ${s.dim(`◆ thought for ${elapsed}s`)}\n`);
572
889
  statusBar();
573
890
  }
574
891
  catch (err) {
892
+ clearInterval(thinkTimer);
575
893
  const msg = err instanceof Error ? err.message : String(err);
894
+ w.write(`${ESC}2K\r`);
576
895
  w.write(s.red(` Error: ${msg}\n`));
577
896
  }
578
897
  running = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/agent",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Coding agent with persistent memory — powered by phren",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "dist"
14
14
  ],
15
15
  "dependencies": {
16
- "@phren/cli": "0.1.1"
16
+ "@phren/cli": "0.1.3"
17
17
  },
18
18
  "engines": {
19
19
  "node": ">=20.0.0"