@phren/agent 0.1.2 → 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";
@@ -144,10 +146,12 @@ export async function startTui(config, spawner) {
144
146
  const session = createSession(contextLimit);
145
147
  const w = process.stdout;
146
148
  const isTTY = process.stdout.isTTY;
149
+ const startTime = Date.now();
147
150
  let inputMode = loadInputMode();
148
151
  let pendingInput = null;
149
152
  let running = false;
150
153
  let inputLine = "";
154
+ let cursorPos = 0;
151
155
  let costStr = "";
152
156
  // ── Dual-mode state ─────────────────────────────────────────────────────
153
157
  let tuiMode = "chat";
@@ -190,6 +194,7 @@ export async function startTui(config, spawner) {
190
194
  menuFilterActive = false;
191
195
  menuFilterBuf = "";
192
196
  w.write("\x1b[?1049l"); // leave alternate screen (restores chat)
197
+ setScrollRegion(); // re-establish scroll region after alt screen
193
198
  statusBar();
194
199
  prompt(true); // skip newline — alt screen restore already positioned cursor
195
200
  }
@@ -210,16 +215,65 @@ export async function startTui(config, spawner) {
210
215
  const icon = PERMISSION_ICONS[mode];
211
216
  const rows = process.stdout.rows || 24;
212
217
  const c = cols();
213
- if (!skipNewline)
218
+ if (!skipNewline) {
219
+ // Newline within the scroll region so content scrolls up naturally
220
+ cursorToScrollEnd();
214
221
  w.write("\n");
215
- const sepLine = s.dim("─".repeat(c));
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));
216
228
  const permLine = ` ${color(`${icon} ${PERMISSION_LABELS[mode]} permissions`)} ${s.dim("(shift+tab to cycle)")}`;
217
- w.write(`${ESC}${rows - 2};1H${ESC}2K${sepLine}`);
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}`);
218
232
  w.write(`${ESC}${rows - 1};1H${ESC}2K${permLine}`);
219
- w.write(`${ESC}${rows};1H${ESC}2K${bashMode ? `${s.yellow("!")} ` : `${color(icon)} ${s.dim("▸")} `}`);
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`);
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`);
220
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
221
272
  // Terminal cleanup: restore state on exit
222
273
  function cleanupTerminal() {
274
+ if (statusRefreshTimer)
275
+ clearInterval(statusRefreshTimer);
276
+ w.write(`${ESC}r`); // reset scroll region
223
277
  w.write("\x1b[?1049l"); // leave alt screen if active
224
278
  if (process.stdin.isTTY) {
225
279
  try {
@@ -229,6 +283,15 @@ export async function startTui(config, spawner) {
229
283
  }
230
284
  }
231
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
+ });
232
295
  // Setup: clear screen, status bar at top, content area clean
233
296
  if (isTTY) {
234
297
  w.write(`${ESC}2J${ESC}H`); // clear entire screen + home
@@ -266,6 +329,7 @@ export async function startTui(config, spawner) {
266
329
  w.write(`\n ${info[0]}\n ${info[1]} ${info[2]}\n ${info[4]}\n\n ${info[6]}\n\n`);
267
330
  }
268
331
  w.write("\n");
332
+ setScrollRegion(); // establish scroll region after banner
269
333
  }
270
334
  // Raw stdin for steering
271
335
  if (process.stdin.isTTY) {
@@ -347,8 +411,83 @@ export async function startTui(config, spawner) {
347
411
  prompt(true);
348
412
  return;
349
413
  }
350
- // Tab — toggle mode (not during agent run or filter)
351
- 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
352
491
  if (tuiMode === "chat" && !running) {
353
492
  enterMenuMode();
354
493
  }
@@ -368,11 +507,13 @@ export async function startTui(config, spawner) {
368
507
  if (bashMode) {
369
508
  bashMode = false;
370
509
  inputLine = "";
510
+ cursorPos = 0;
371
511
  prompt(true);
372
512
  return;
373
513
  }
374
514
  if (inputLine) {
375
515
  inputLine = "";
516
+ cursorPos = 0;
376
517
  prompt(true);
377
518
  return;
378
519
  }
@@ -389,6 +530,7 @@ export async function startTui(config, spawner) {
389
530
  if (bashMode) {
390
531
  bashMode = false;
391
532
  inputLine = "";
533
+ cursorPos = 0;
392
534
  prompt(true);
393
535
  ctrlCCount = 0;
394
536
  return;
@@ -396,6 +538,7 @@ export async function startTui(config, spawner) {
396
538
  if (inputLine) {
397
539
  // Clear input
398
540
  inputLine = "";
541
+ cursorPos = 0;
399
542
  prompt(true);
400
543
  ctrlCCount = 0;
401
544
  return;
@@ -410,8 +553,7 @@ export async function startTui(config, spawner) {
410
553
  }
411
554
  else {
412
555
  // Actually quit
413
- if (process.stdin.isTTY)
414
- process.stdin.setRawMode(false);
556
+ cleanupTerminal();
415
557
  w.write(s.dim("\nSession ended.\n"));
416
558
  resolve(session);
417
559
  }
@@ -420,7 +562,10 @@ export async function startTui(config, spawner) {
420
562
  // Enter — submit
421
563
  if (key.name === "return") {
422
564
  const line = inputLine.trim();
565
+ cursorPos = 0;
423
566
  inputLine = "";
567
+ // Move to the bottom of the scroll region so new output scrolls naturally
568
+ cursorToScrollEnd();
424
569
  w.write("\n");
425
570
  if (!line) {
426
571
  prompt();
@@ -473,13 +618,19 @@ export async function startTui(config, spawner) {
473
618
  prompt();
474
619
  return;
475
620
  }
476
- if (handleCommand(line, {
621
+ const cmdResult = handleCommand(line, {
477
622
  session,
478
623
  contextLimit,
479
624
  undoStack: [],
480
625
  providerName: config.provider.name,
481
626
  currentModel: config.provider.model,
627
+ provider: config.provider,
628
+ systemPrompt: config.systemPrompt,
482
629
  spawner,
630
+ sessionId: config.sessionId,
631
+ startTime,
632
+ phrenPath: config.phrenCtx?.phrenPath,
633
+ phrenCtx: config.phrenCtx,
483
634
  onModelChange: (result) => {
484
635
  // Live model switch — re-resolve provider with new model
485
636
  try {
@@ -494,10 +645,15 @@ export async function startTui(config, spawner) {
494
645
  }
495
646
  catch { /* keep current provider on error */ }
496
647
  },
497
- })) {
648
+ });
649
+ if (cmdResult === true) {
498
650
  prompt();
499
651
  return;
500
652
  }
653
+ if (typeof cmdResult === "object" && cmdResult instanceof Promise) {
654
+ cmdResult.then(() => { prompt(); });
655
+ return;
656
+ }
501
657
  // If agent is running, buffer input
502
658
  if (running) {
503
659
  pendingInput = line;
@@ -521,9 +677,8 @@ export async function startTui(config, spawner) {
521
677
  historyIndex--;
522
678
  }
523
679
  inputLine = inputHistory[historyIndex];
524
- w.write(`${ESC}2K\r`);
525
- prompt(true);
526
- w.write(inputLine);
680
+ cursorPos = inputLine.length;
681
+ redrawInput();
527
682
  return;
528
683
  }
529
684
  // Down arrow — next history or restore saved
@@ -538,20 +693,112 @@ export async function startTui(config, spawner) {
538
693
  historyIndex = -1;
539
694
  inputLine = savedInput;
540
695
  }
541
- w.write(`${ESC}2K\r`);
542
- prompt(true);
543
- w.write(inputLine);
696
+ cursorPos = inputLine.length;
697
+ redrawInput();
544
698
  return;
545
699
  }
546
- // Backspace
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
547
785
  if (key.name === "backspace") {
548
- if (inputLine.length > 0) {
549
- inputLine = inputLine.slice(0, -1);
550
- w.write("\b \b");
786
+ if (cursorPos > 0) {
787
+ inputLine = inputLine.slice(0, cursorPos - 1) + inputLine.slice(cursorPos);
788
+ cursorPos--;
789
+ redrawInput();
790
+ }
791
+ return;
792
+ }
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();
551
798
  }
552
799
  return;
553
800
  }
554
- // Regular character
801
+ // Regular character — insert at cursor position
555
802
  if (key.sequence && !key.ctrl && !key.meta) {
556
803
  // ! at start of empty input toggles bash mode
557
804
  if (key.sequence === "!" && inputLine === "" && !bashMode) {
@@ -559,8 +806,9 @@ export async function startTui(config, spawner) {
559
806
  prompt(true);
560
807
  return;
561
808
  }
562
- inputLine += key.sequence;
563
- w.write(key.sequence);
809
+ inputLine = inputLine.slice(0, cursorPos) + key.sequence + inputLine.slice(cursorPos);
810
+ cursorPos += key.sequence.length;
811
+ redrawInput();
564
812
  }
565
813
  });
566
814
  // TUI hooks — render streaming text with markdown, compact tool output
@@ -578,11 +826,8 @@ export async function startTui(config, spawner) {
578
826
  w.write(`${ESC}2K\r`); // clear thinking timer line
579
827
  firstDelta = false;
580
828
  }
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
- }
829
+ // Stream directly for real-time feel — write each delta immediately
830
+ w.write(text);
586
831
  },
587
832
  onTextDone: () => {
588
833
  flushTextBuffer();
@@ -621,14 +866,26 @@ export async function startTui(config, spawner) {
621
866
  async function runAgentTurn(userInput) {
622
867
  running = true;
623
868
  firstDelta = true;
869
+ cursorToScrollEnd(); // ensure all turn output stays within scroll region
624
870
  const thinkStart = Date.now();
871
+ // Phren thinking — subtle purple/cyan breath, no spinner gimmicks
872
+ let thinkFrame = 0;
625
873
  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);
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);
629
884
  try {
630
885
  await runTurn(userInput, session, config, tuiHooks);
631
886
  clearInterval(thinkTimer);
887
+ const elapsed = ((Date.now() - thinkStart) / 1000).toFixed(1);
888
+ w.write(`${ESC}2K ${s.dim(`◆ thought for ${elapsed}s`)}\n`);
632
889
  statusBar();
633
890
  }
634
891
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/agent",
3
- "version": "0.1.2",
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.2"
16
+ "@phren/cli": "0.1.3"
17
17
  },
18
18
  "engines": {
19
19
  "node": ">=20.0.0"