@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.
- package/dist/agent-loop/index.js +214 -0
- package/dist/agent-loop/stream.js +124 -0
- package/dist/agent-loop/types.js +13 -0
- package/dist/agent-loop.js +7 -333
- package/dist/commands/info.js +146 -0
- package/dist/commands/memory.js +165 -0
- package/dist/commands/model.js +138 -0
- package/dist/commands/session.js +213 -0
- package/dist/commands.js +24 -643
- package/dist/index.js +9 -4
- package/dist/mcp-client.js +11 -7
- package/dist/multi/multi-commands.js +170 -0
- package/dist/multi/multi-events.js +81 -0
- package/dist/multi/multi-render.js +146 -0
- package/dist/multi/pane.js +28 -0
- package/dist/multi/tui-multi.js +39 -454
- package/dist/permissions/allowlist.js +2 -2
- package/dist/providers/anthropic.js +4 -2
- package/dist/providers/codex.js +9 -4
- package/dist/providers/openai-compat.js +6 -1
- package/dist/tools/glob.js +30 -6
- package/dist/tui/ansi.js +48 -0
- package/dist/tui/components/AgentMessage.js +5 -0
- package/dist/tui/components/App.js +68 -0
- package/dist/tui/components/Banner.js +44 -0
- package/dist/tui/components/ChatMessage.js +23 -0
- package/dist/tui/components/InputArea.js +23 -0
- package/dist/tui/components/Separator.js +7 -0
- package/dist/tui/components/StatusBar.js +25 -0
- package/dist/tui/components/SteerQueue.js +7 -0
- package/dist/tui/components/StreamingText.js +5 -0
- package/dist/tui/components/ThinkingIndicator.js +26 -0
- package/dist/tui/components/ToolCall.js +11 -0
- package/dist/tui/components/UserMessage.js +5 -0
- package/dist/tui/hooks/useKeyboardShortcuts.js +89 -0
- package/dist/tui/hooks/useSlashCommands.js +52 -0
- package/dist/tui/index.js +5 -0
- package/dist/tui/ink-entry.js +287 -0
- package/dist/tui/menu-mode.js +86 -0
- package/dist/tui/tool-render.js +43 -0
- package/dist/tui.js +149 -280
- 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
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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 —
|
|
104
|
+
// Print prompt — inline input bar (written at current cursor position)
|
|
209
105
|
let bashMode = false;
|
|
210
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
w.write(`${
|
|
231
|
-
w.write(`${
|
|
232
|
-
w.write(
|
|
233
|
-
w.write(`${
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
w.write(`${ESC}${
|
|
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
|
-
|
|
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
|
-
//
|
|
287
|
-
//
|
|
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
|
|
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
|
-
//
|
|
411
|
-
|
|
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
|
|
432
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
308
|
+
tuiMode = "menu";
|
|
309
|
+
enterMenu(getMenuCtx());
|
|
493
310
|
}
|
|
494
311
|
else if (tuiMode === "menu") {
|
|
495
|
-
|
|
312
|
+
exitMenu(getMenuCtx());
|
|
496
313
|
}
|
|
497
314
|
return;
|
|
498
315
|
}
|
|
499
316
|
// Route to mode-specific handler
|
|
500
317
|
if (tuiMode === "menu") {
|
|
501
|
-
|
|
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
|
-
|
|
328
|
+
redrawInput();
|
|
512
329
|
return;
|
|
513
330
|
}
|
|
514
331
|
if (inputLine) {
|
|
515
332
|
inputLine = "";
|
|
516
333
|
cursorPos = 0;
|
|
517
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 } =
|
|
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 } =
|
|
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,
|
|
473
|
+
// If agent is running, add to steer queue
|
|
658
474
|
if (running) {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
728
|
+
activeThinkTimer = setInterval(() => {
|
|
874
729
|
const elapsed = (Date.now() - thinkStart) / 1000;
|
|
875
|
-
|
|
876
|
-
const t = (Math.sin(thinkFrame * 0.08) + 1) / 2;
|
|
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
|
|
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
|
-
|
|
741
|
+
if (activeThinkTimer) {
|
|
742
|
+
clearInterval(activeThinkTimer);
|
|
743
|
+
activeThinkTimer = null;
|
|
744
|
+
}
|
|
887
745
|
const elapsed = ((Date.now() - thinkStart) / 1000).toFixed(1);
|
|
888
|
-
|
|
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
|
-
|
|
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 (
|
|
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 {
|