@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/agent-loop.js +10 -5
- package/dist/checkpoint.js +0 -34
- package/dist/commands.js +351 -4
- package/dist/config.js +6 -2
- package/dist/index.js +12 -2
- package/dist/multi/model-picker.js +0 -2
- package/dist/multi/provider-manager.js +0 -23
- package/dist/multi/spawner.js +3 -2
- package/dist/multi/syntax-highlight.js +0 -1
- package/dist/multi/tui-multi.js +4 -6
- package/dist/permissions/allowlist.js +0 -4
- package/dist/permissions/privacy.js +248 -0
- package/dist/permissions/shell-safety.js +8 -0
- package/dist/providers/anthropic.js +68 -31
- package/dist/providers/codex.js +112 -56
- package/dist/repl.js +2 -2
- package/dist/system-prompt.js +26 -27
- package/dist/tools/phren-add-task.js +49 -0
- package/dist/tools/shell.js +5 -2
- package/dist/tools/web-fetch.js +40 -0
- package/dist/tools/web-search.js +93 -0
- package/dist/tui.js +381 -62
- package/package.json +2 -2
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":
|
|
90
|
-
case "write_file":
|
|
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
|
|
194
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
500
|
-
inputLine = inputLine.slice(0, -1);
|
|
501
|
-
|
|
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
|
-
//
|
|
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
|
|
514
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
16
|
+
"@phren/cli": "0.1.3"
|
|
17
17
|
},
|
|
18
18
|
"engines": {
|
|
19
19
|
"node": ">=20.0.0"
|