@phren/agent 0.1.2 → 0.1.4
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 -326
- 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 +25 -297
- package/dist/config.js +6 -2
- package/dist/index.js +10 -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/spawner.js +3 -2
- package/dist/multi/tui-multi.js +39 -454
- package/dist/permissions/allowlist.js +2 -2
- package/dist/permissions/shell-safety.js +8 -0
- package/dist/providers/anthropic.js +72 -33
- package/dist/providers/codex.js +121 -60
- package/dist/providers/openai-compat.js +6 -1
- package/dist/repl.js +2 -2
- package/dist/system-prompt.js +24 -26
- package/dist/tools/glob.js +30 -6
- package/dist/tools/shell.js +5 -2
- package/dist/tui/ansi.js +48 -0
- package/dist/tui/components/AgentMessage.js +5 -0
- package/dist/tui/components/App.js +70 -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 +20 -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 +271 -0
- package/dist/tui/menu-mode.js +86 -0
- package/dist/tui/tool-render.js +43 -0
- package/dist/tui.js +378 -252
- package/package.json +9 -2
package/dist/tui.js
CHANGED
|
@@ -9,63 +9,22 @@ 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";
|
|
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";
|
|
15
19
|
const _require = createRequire(import.meta.url);
|
|
16
20
|
const AGENT_VERSION = _require("../package.json").version;
|
|
17
|
-
// ── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
18
|
-
const ESC = "\x1b[";
|
|
19
|
-
const s = {
|
|
20
|
-
reset: `${ESC}0m`,
|
|
21
|
-
bold: (t) => `${ESC}1m${t}${ESC}0m`,
|
|
22
|
-
dim: (t) => `${ESC}2m${t}${ESC}0m`,
|
|
23
|
-
italic: (t) => `${ESC}3m${t}${ESC}0m`,
|
|
24
|
-
cyan: (t) => `${ESC}36m${t}${ESC}0m`,
|
|
25
|
-
green: (t) => `${ESC}32m${t}${ESC}0m`,
|
|
26
|
-
yellow: (t) => `${ESC}33m${t}${ESC}0m`,
|
|
27
|
-
red: (t) => `${ESC}31m${t}${ESC}0m`,
|
|
28
|
-
blue: (t) => `${ESC}34m${t}${ESC}0m`,
|
|
29
|
-
magenta: (t) => `${ESC}35m${t}${ESC}0m`,
|
|
30
|
-
gray: (t) => `${ESC}90m${t}${ESC}0m`,
|
|
31
|
-
invert: (t) => `${ESC}7m${t}${ESC}0m`,
|
|
32
|
-
// Gradient-style brand text
|
|
33
|
-
brand: (t) => `${ESC}1;35m${t}${ESC}0m`,
|
|
34
|
-
};
|
|
35
|
-
function cols() {
|
|
36
|
-
return process.stdout.columns || 80;
|
|
37
|
-
}
|
|
38
|
-
// ── Permission mode helpers ─────────────────────────────────────────────────
|
|
39
|
-
const PERMISSION_MODES = ["suggest", "auto-confirm", "full-auto"];
|
|
40
|
-
function nextPermissionMode(current) {
|
|
41
|
-
const idx = PERMISSION_MODES.indexOf(current);
|
|
42
|
-
return PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length];
|
|
43
|
-
}
|
|
44
|
-
const PERMISSION_LABELS = {
|
|
45
|
-
"suggest": "suggest",
|
|
46
|
-
"auto-confirm": "auto",
|
|
47
|
-
"full-auto": "full-auto",
|
|
48
|
-
};
|
|
49
|
-
const PERMISSION_ICONS = {
|
|
50
|
-
"suggest": "○",
|
|
51
|
-
"auto-confirm": "◐",
|
|
52
|
-
"full-auto": "●",
|
|
53
|
-
};
|
|
54
|
-
const PERMISSION_COLORS = {
|
|
55
|
-
"suggest": s.cyan,
|
|
56
|
-
"auto-confirm": s.green,
|
|
57
|
-
"full-auto": s.yellow,
|
|
58
|
-
};
|
|
59
|
-
function permTag(mode) {
|
|
60
|
-
return PERMISSION_COLORS[mode](`${PERMISSION_ICONS[mode]} ${mode}`);
|
|
61
|
-
}
|
|
62
21
|
// ── Status bar ───────────────────────────────────────────────────────────────
|
|
63
22
|
function renderStatusBar(provider, project, turns, cost, permMode, agentCount) {
|
|
64
23
|
const modeLabel = permMode ? PERMISSION_LABELS[permMode] : "";
|
|
65
24
|
const agentTag = agentCount && agentCount > 0 ? ` A${agentCount}` : "";
|
|
66
|
-
// Left: brand + provider
|
|
25
|
+
// Left: brand + provider (skip project if it matches "phren" to avoid "phren · codex · phren")
|
|
67
26
|
const parts = [" ◆ phren", provider];
|
|
68
|
-
if (project)
|
|
27
|
+
if (project && project !== "phren")
|
|
69
28
|
parts.push(project);
|
|
70
29
|
const left = parts.join(" · ");
|
|
71
30
|
// Right: mode + agents + cost + turns
|
|
@@ -82,72 +41,19 @@ function renderStatusBar(provider, project, turns, cost, permMode, agentCount) {
|
|
|
82
41
|
const pad = Math.max(0, w - left.length - right.length);
|
|
83
42
|
return s.invert(left + " ".repeat(pad) + right);
|
|
84
43
|
}
|
|
85
|
-
function stripAnsi(t) {
|
|
86
|
-
return t.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
87
|
-
}
|
|
88
|
-
// ── Tool call rendering ──────────────────────────────────────────────────────
|
|
89
|
-
const COMPACT_LINES = 3;
|
|
90
|
-
function formatDuration(ms) {
|
|
91
|
-
if (ms < 1000)
|
|
92
|
-
return `${ms}ms`;
|
|
93
|
-
if (ms < 60_000)
|
|
94
|
-
return `${(ms / 1000).toFixed(1)}s`;
|
|
95
|
-
const mins = Math.floor(ms / 60_000);
|
|
96
|
-
const secs = Math.round((ms % 60_000) / 1000);
|
|
97
|
-
return `${mins}m ${secs}s`;
|
|
98
|
-
}
|
|
99
|
-
function formatToolInput(name, input) {
|
|
100
|
-
switch (name) {
|
|
101
|
-
case "read_file":
|
|
102
|
-
case "write_file":
|
|
103
|
-
case "edit_file": return input.file_path ?? "";
|
|
104
|
-
case "shell": return (input.command ?? "").slice(0, 60);
|
|
105
|
-
case "glob": return input.pattern ?? "";
|
|
106
|
-
case "grep": return `/${input.pattern ?? ""}/ ${input.path ?? ""}`;
|
|
107
|
-
case "git_commit": return (input.message ?? "").slice(0, 50);
|
|
108
|
-
case "phren_search": return input.query ?? "";
|
|
109
|
-
case "phren_add_finding": return (input.finding ?? "").slice(0, 50);
|
|
110
|
-
default: return JSON.stringify(input).slice(0, 60);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
function renderToolCall(name, input, output, isError, durationMs) {
|
|
114
|
-
const preview = formatToolInput(name, input);
|
|
115
|
-
const dur = formatDuration(durationMs);
|
|
116
|
-
const icon = isError ? s.red("✗") : s.green("→");
|
|
117
|
-
const header = ` ${icon} ${s.bold(name)} ${s.gray(preview)} ${s.dim(dur)}`;
|
|
118
|
-
// Compact: show first 3 lines only, with overflow count
|
|
119
|
-
const allLines = output.split("\n").filter(Boolean);
|
|
120
|
-
if (allLines.length === 0)
|
|
121
|
-
return header;
|
|
122
|
-
const shown = allLines.slice(0, COMPACT_LINES);
|
|
123
|
-
const body = shown.map((l) => s.dim(` ${l.slice(0, cols() - 6)}`)).join("\n");
|
|
124
|
-
const overflow = allLines.length - COMPACT_LINES;
|
|
125
|
-
const more = overflow > 0 ? `\n${s.dim(` ... +${overflow} lines`)}` : "";
|
|
126
|
-
return `${header}\n${body}${more}`;
|
|
127
|
-
}
|
|
128
|
-
// ── Menu mode helpers ────────────────────────────────────────────────────────
|
|
129
|
-
let menuMod = null;
|
|
130
|
-
async function loadMenuModule() {
|
|
131
|
-
if (!menuMod) {
|
|
132
|
-
try {
|
|
133
|
-
menuMod = await import("@phren/cli/shell/render-api");
|
|
134
|
-
}
|
|
135
|
-
catch {
|
|
136
|
-
menuMod = null;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return menuMod;
|
|
140
|
-
}
|
|
141
44
|
// ── Main TUI ─────────────────────────────────────────────────────────────────
|
|
142
45
|
export async function startTui(config, spawner) {
|
|
143
46
|
const contextLimit = config.provider.contextWindow ?? 200_000;
|
|
144
47
|
const session = createSession(contextLimit);
|
|
145
48
|
const w = process.stdout;
|
|
146
49
|
const isTTY = process.stdout.isTTY;
|
|
50
|
+
const startTime = Date.now();
|
|
147
51
|
let inputMode = loadInputMode();
|
|
148
52
|
let pendingInput = null;
|
|
53
|
+
const steerQueue = [];
|
|
149
54
|
let running = false;
|
|
150
55
|
let inputLine = "";
|
|
56
|
+
let cursorPos = 0;
|
|
151
57
|
let costStr = "";
|
|
152
58
|
// ── Dual-mode state ─────────────────────────────────────────────────────
|
|
153
59
|
let tuiMode = "chat";
|
|
@@ -165,61 +71,79 @@ export async function startTui(config, spawner) {
|
|
|
165
71
|
const inputHistory = [];
|
|
166
72
|
let historyIndex = -1;
|
|
167
73
|
let savedInput = "";
|
|
168
|
-
// ── Menu
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
statusBar();
|
|
194
|
-
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
|
+
};
|
|
195
99
|
}
|
|
196
100
|
// Print status bar
|
|
197
101
|
function statusBar() {
|
|
198
|
-
|
|
199
|
-
return;
|
|
200
|
-
const bar = renderStatusBar(config.provider.name, config.phrenCtx?.project ?? null, session.turns, costStr, config.registry.permissionConfig.mode, spawner?.listAgents().length);
|
|
201
|
-
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.
|
|
202
103
|
}
|
|
203
|
-
// Print prompt —
|
|
104
|
+
// Print prompt — inline input bar (written at current cursor position)
|
|
204
105
|
let bashMode = false;
|
|
205
|
-
|
|
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() {
|
|
206
109
|
if (!isTTY)
|
|
207
110
|
return;
|
|
208
111
|
const mode = config.registry.permissionConfig.mode;
|
|
209
112
|
const color = PERMISSION_COLORS[mode];
|
|
210
113
|
const icon = PERMISSION_ICONS[mode];
|
|
211
|
-
const rows = process.stdout.rows || 24;
|
|
212
114
|
const c = cols();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
w.write(`${
|
|
218
|
-
w.write(
|
|
219
|
-
w.write(`${
|
|
115
|
+
const sep = s.dim("─".repeat(c));
|
|
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
|
|
125
|
+
}
|
|
126
|
+
// Redraw the input line and position the terminal cursor at cursorPos
|
|
127
|
+
function redrawInput() {
|
|
128
|
+
w.write(`${ESC}2K\r`);
|
|
129
|
+
w.write(`${bashMode ? `${s.yellow("!")} ` : `${s.dim("▸")} `}`);
|
|
130
|
+
w.write(inputLine);
|
|
131
|
+
// Move terminal cursor back from end to cursorPos
|
|
132
|
+
const back = inputLine.length - cursorPos;
|
|
133
|
+
if (back > 0)
|
|
134
|
+
w.write(`${ESC}${back}D`);
|
|
220
135
|
}
|
|
136
|
+
// Periodic status bar refresh (every 30s) — keeps cost/turns current during long tool runs
|
|
137
|
+
const statusRefreshTimer = isTTY
|
|
138
|
+
? setInterval(() => { if (tuiMode === "chat")
|
|
139
|
+
statusBar(); }, 30_000)
|
|
140
|
+
: null;
|
|
141
|
+
if (statusRefreshTimer)
|
|
142
|
+
statusRefreshTimer.unref(); // don't keep process alive
|
|
221
143
|
// Terminal cleanup: restore state on exit
|
|
222
144
|
function cleanupTerminal() {
|
|
145
|
+
if (statusRefreshTimer)
|
|
146
|
+
clearInterval(statusRefreshTimer);
|
|
223
147
|
w.write("\x1b[?1049l"); // leave alt screen if active
|
|
224
148
|
if (process.stdin.isTTY) {
|
|
225
149
|
try {
|
|
@@ -229,6 +153,8 @@ export async function startTui(config, spawner) {
|
|
|
229
153
|
}
|
|
230
154
|
}
|
|
231
155
|
process.on("exit", cleanupTerminal);
|
|
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.
|
|
232
158
|
// Setup: clear screen, status bar at top, content area clean
|
|
233
159
|
if (isTTY) {
|
|
234
160
|
w.write(`${ESC}2J${ESC}H`); // clear entire screen + home
|
|
@@ -249,7 +175,7 @@ export async function startTui(config, spawner) {
|
|
|
249
175
|
`${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""}`,
|
|
250
176
|
`${s.dim(cwd)}`,
|
|
251
177
|
``,
|
|
252
|
-
`${permTag(permMode)} ${s.dim("permissions (shift+tab to
|
|
178
|
+
`${permTag(permMode)} ${s.dim("permissions (shift+tab toggle · esc to interrupt)")}`,
|
|
253
179
|
``,
|
|
254
180
|
`${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit`,
|
|
255
181
|
];
|
|
@@ -274,57 +200,6 @@ export async function startTui(config, spawner) {
|
|
|
274
200
|
}
|
|
275
201
|
let resolve = null;
|
|
276
202
|
const done = new Promise((r) => { resolve = r; });
|
|
277
|
-
// ── Menu keypress handler ───────────────────────────────────────────────
|
|
278
|
-
async function handleMenuKeypress(key) {
|
|
279
|
-
// Filter input mode: capture text for / search
|
|
280
|
-
if (menuFilterActive) {
|
|
281
|
-
if (key.name === "escape") {
|
|
282
|
-
menuFilterActive = false;
|
|
283
|
-
menuFilterBuf = "";
|
|
284
|
-
menuState = { ...menuState, filter: undefined, cursor: 0, scroll: 0 };
|
|
285
|
-
renderMenu();
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
if (key.name === "return") {
|
|
289
|
-
menuFilterActive = false;
|
|
290
|
-
menuState = { ...menuState, filter: menuFilterBuf || undefined, cursor: 0, scroll: 0 };
|
|
291
|
-
menuFilterBuf = "";
|
|
292
|
-
renderMenu();
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
if (key.name === "backspace") {
|
|
296
|
-
menuFilterBuf = menuFilterBuf.slice(0, -1);
|
|
297
|
-
menuState = { ...menuState, filter: menuFilterBuf || undefined, cursor: 0 };
|
|
298
|
-
renderMenu();
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
if (key.sequence && !key.ctrl && !key.meta) {
|
|
302
|
-
menuFilterBuf += key.sequence;
|
|
303
|
-
menuState = { ...menuState, filter: menuFilterBuf, cursor: 0 };
|
|
304
|
-
renderMenu();
|
|
305
|
-
}
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
// "/" starts filter input
|
|
309
|
-
if (key.sequence === "/") {
|
|
310
|
-
menuFilterActive = true;
|
|
311
|
-
menuFilterBuf = "";
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
const mod = await loadMenuModule();
|
|
315
|
-
if (!mod) {
|
|
316
|
-
exitMenuMode();
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
const newState = mod.handleMenuKey(menuState, key.name ?? "", menuListCount, config.phrenCtx?.phrenPath, config.phrenCtx?.profile);
|
|
320
|
-
if (newState === null) {
|
|
321
|
-
exitMenuMode();
|
|
322
|
-
}
|
|
323
|
-
else {
|
|
324
|
-
menuState = newState;
|
|
325
|
-
renderMenu();
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
203
|
// ── Keypress router ────────────────────────────────────────────────────
|
|
329
204
|
process.stdin.on("keypress", (_ch, key) => {
|
|
330
205
|
if (!key)
|
|
@@ -343,23 +218,104 @@ export async function startTui(config, spawner) {
|
|
|
343
218
|
const next = nextPermissionMode(config.registry.permissionConfig.mode);
|
|
344
219
|
config.registry.setPermissions({ ...config.registry.permissionConfig, mode: next });
|
|
345
220
|
savePermissionMode(next);
|
|
346
|
-
//
|
|
347
|
-
|
|
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`);
|
|
348
228
|
return;
|
|
349
229
|
}
|
|
350
|
-
// Tab — toggle mode
|
|
351
|
-
if (key.name === "tab" && !menuFilterActive) {
|
|
230
|
+
// Tab — completion or toggle mode
|
|
231
|
+
if (key.name === "tab" && !key.shift && !menuFilterActive) {
|
|
232
|
+
// Slash command completion in chat mode
|
|
233
|
+
if (tuiMode === "chat" && inputLine.startsWith("/")) {
|
|
234
|
+
const SLASH_COMMANDS = [
|
|
235
|
+
"/help", "/model", "/provider", "/turns", "/clear", "/cost",
|
|
236
|
+
"/plan", "/undo", "/history", "/compact", "/context", "/mode",
|
|
237
|
+
"/spawn", "/agents", "/diff", "/git", "/files", "/cwd",
|
|
238
|
+
"/preset", "/exit",
|
|
239
|
+
];
|
|
240
|
+
const matches = SLASH_COMMANDS.filter((c) => c.startsWith(inputLine));
|
|
241
|
+
if (matches.length === 1) {
|
|
242
|
+
inputLine = matches[0];
|
|
243
|
+
cursorPos = inputLine.length;
|
|
244
|
+
redrawInput();
|
|
245
|
+
}
|
|
246
|
+
else if (matches.length > 1) {
|
|
247
|
+
// Show matches above prompt, then redraw
|
|
248
|
+
w.write(`\r${ESC}J`); // clear from cursor to end of screen
|
|
249
|
+
w.write(`\n${s.dim(" " + matches.join(" "))}\n`);
|
|
250
|
+
prompt();
|
|
251
|
+
w.write(inputLine);
|
|
252
|
+
const back = inputLine.length - cursorPos;
|
|
253
|
+
if (back > 0)
|
|
254
|
+
w.write(`${ESC}${back}D`);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// File path completion in bash mode
|
|
259
|
+
if (tuiMode === "chat" && bashMode && inputLine.length > 0) {
|
|
260
|
+
// Complete the last whitespace-delimited token as a path
|
|
261
|
+
const lastSpace = inputLine.lastIndexOf(" ");
|
|
262
|
+
const prefix = lastSpace === -1 ? "" : inputLine.slice(0, lastSpace + 1);
|
|
263
|
+
const partial = lastSpace === -1 ? inputLine : inputLine.slice(lastSpace + 1);
|
|
264
|
+
const expandedPartial = partial.replace(/^~/, os.homedir());
|
|
265
|
+
const dir = path.dirname(expandedPartial);
|
|
266
|
+
const base = path.basename(expandedPartial);
|
|
267
|
+
try {
|
|
268
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
269
|
+
const matches = entries.filter((e) => e.name.startsWith(base));
|
|
270
|
+
if (matches.length === 1) {
|
|
271
|
+
const completed = matches[0];
|
|
272
|
+
const fullPath = partial.startsWith("~")
|
|
273
|
+
? "~/" + path.relative(os.homedir(), path.join(dir, completed.name))
|
|
274
|
+
: path.join(dir, completed.name);
|
|
275
|
+
inputLine = prefix + fullPath + (completed.isDirectory() ? "/" : "");
|
|
276
|
+
cursorPos = inputLine.length;
|
|
277
|
+
redrawInput();
|
|
278
|
+
}
|
|
279
|
+
else if (matches.length > 1) {
|
|
280
|
+
const names = matches.map((e) => e.name + (e.isDirectory() ? "/" : ""));
|
|
281
|
+
w.write(`\r${ESC}J`); // clear from cursor to end of screen
|
|
282
|
+
w.write(`\n${s.dim(" " + names.join(" "))}\n`);
|
|
283
|
+
// Find longest common prefix for partial completion
|
|
284
|
+
let common = matches[0].name;
|
|
285
|
+
for (const m of matches) {
|
|
286
|
+
while (!m.name.startsWith(common))
|
|
287
|
+
common = common.slice(0, -1);
|
|
288
|
+
}
|
|
289
|
+
if (common.length > base.length) {
|
|
290
|
+
const fullPath = partial.startsWith("~")
|
|
291
|
+
? "~/" + path.relative(os.homedir(), path.join(dir, common))
|
|
292
|
+
: path.join(dir, common);
|
|
293
|
+
inputLine = prefix + fullPath;
|
|
294
|
+
cursorPos = inputLine.length;
|
|
295
|
+
}
|
|
296
|
+
prompt();
|
|
297
|
+
w.write(inputLine);
|
|
298
|
+
const back = inputLine.length - cursorPos;
|
|
299
|
+
if (back > 0)
|
|
300
|
+
w.write(`${ESC}${back}D`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch { /* dir doesn't exist or unreadable */ }
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// Default: toggle menu mode
|
|
352
307
|
if (tuiMode === "chat" && !running) {
|
|
353
|
-
|
|
308
|
+
tuiMode = "menu";
|
|
309
|
+
enterMenu(getMenuCtx());
|
|
354
310
|
}
|
|
355
311
|
else if (tuiMode === "menu") {
|
|
356
|
-
|
|
312
|
+
exitMenu(getMenuCtx());
|
|
357
313
|
}
|
|
358
314
|
return;
|
|
359
315
|
}
|
|
360
316
|
// Route to mode-specific handler
|
|
361
317
|
if (tuiMode === "menu") {
|
|
362
|
-
|
|
318
|
+
handleMenuKey(key, getMenuCtx());
|
|
363
319
|
return;
|
|
364
320
|
}
|
|
365
321
|
// ── Chat mode keys ──────────────────────────────────────────────────
|
|
@@ -368,12 +324,14 @@ export async function startTui(config, spawner) {
|
|
|
368
324
|
if (bashMode) {
|
|
369
325
|
bashMode = false;
|
|
370
326
|
inputLine = "";
|
|
371
|
-
|
|
327
|
+
cursorPos = 0;
|
|
328
|
+
redrawInput();
|
|
372
329
|
return;
|
|
373
330
|
}
|
|
374
331
|
if (inputLine) {
|
|
375
332
|
inputLine = "";
|
|
376
|
-
|
|
333
|
+
cursorPos = 0;
|
|
334
|
+
redrawInput();
|
|
377
335
|
return;
|
|
378
336
|
}
|
|
379
337
|
}
|
|
@@ -389,29 +347,32 @@ export async function startTui(config, spawner) {
|
|
|
389
347
|
if (bashMode) {
|
|
390
348
|
bashMode = false;
|
|
391
349
|
inputLine = "";
|
|
392
|
-
|
|
350
|
+
cursorPos = 0;
|
|
351
|
+
redrawInput();
|
|
393
352
|
ctrlCCount = 0;
|
|
394
353
|
return;
|
|
395
354
|
}
|
|
396
355
|
if (inputLine) {
|
|
397
356
|
// Clear input
|
|
398
357
|
inputLine = "";
|
|
399
|
-
|
|
358
|
+
cursorPos = 0;
|
|
359
|
+
redrawInput();
|
|
400
360
|
ctrlCCount = 0;
|
|
401
361
|
return;
|
|
402
362
|
}
|
|
403
363
|
// Nothing to cancel — progressive quit
|
|
404
364
|
ctrlCCount++;
|
|
405
365
|
if (ctrlCCount === 1) {
|
|
366
|
+
// Clear current prompt, print warning, redraw prompt
|
|
367
|
+
w.write(`\r${ESC}J`);
|
|
406
368
|
w.write(s.dim("\n Press Ctrl+C again to exit.\n"));
|
|
407
|
-
prompt(
|
|
369
|
+
prompt();
|
|
408
370
|
// Reset after 2 seconds
|
|
409
371
|
setTimeout(() => { ctrlCCount = 0; }, 2000);
|
|
410
372
|
}
|
|
411
373
|
else {
|
|
412
374
|
// Actually quit
|
|
413
|
-
|
|
414
|
-
process.stdin.setRawMode(false);
|
|
375
|
+
cleanupTerminal();
|
|
415
376
|
w.write(s.dim("\nSession ended.\n"));
|
|
416
377
|
resolve(session);
|
|
417
378
|
}
|
|
@@ -420,10 +381,10 @@ export async function startTui(config, spawner) {
|
|
|
420
381
|
// Enter — submit
|
|
421
382
|
if (key.name === "return") {
|
|
422
383
|
const line = inputLine.trim();
|
|
384
|
+
cursorPos = 0;
|
|
423
385
|
inputLine = "";
|
|
424
|
-
w.write("\n");
|
|
425
386
|
if (!line) {
|
|
426
|
-
|
|
387
|
+
redrawInput();
|
|
427
388
|
return;
|
|
428
389
|
}
|
|
429
390
|
// Push to history
|
|
@@ -441,7 +402,7 @@ export async function startTui(config, spawner) {
|
|
|
441
402
|
if (cdMatch) {
|
|
442
403
|
try {
|
|
443
404
|
const target = cdMatch[1].trim().replace(/^~/, os.homedir());
|
|
444
|
-
const resolved =
|
|
405
|
+
const resolved = path.resolve(process.cwd(), target);
|
|
445
406
|
process.chdir(resolved);
|
|
446
407
|
w.write(s.dim(process.cwd()) + "\n");
|
|
447
408
|
}
|
|
@@ -473,39 +434,60 @@ export async function startTui(config, spawner) {
|
|
|
473
434
|
prompt();
|
|
474
435
|
return;
|
|
475
436
|
}
|
|
476
|
-
|
|
437
|
+
const cmdResult = handleCommand(line, {
|
|
477
438
|
session,
|
|
478
439
|
contextLimit,
|
|
479
440
|
undoStack: [],
|
|
480
441
|
providerName: config.provider.name,
|
|
481
442
|
currentModel: config.provider.model,
|
|
443
|
+
provider: config.provider,
|
|
444
|
+
systemPrompt: config.systemPrompt,
|
|
482
445
|
spawner,
|
|
483
|
-
|
|
446
|
+
sessionId: config.sessionId,
|
|
447
|
+
startTime,
|
|
448
|
+
phrenPath: config.phrenCtx?.phrenPath,
|
|
449
|
+
phrenCtx: config.phrenCtx,
|
|
450
|
+
onModelChange: async (result) => {
|
|
484
451
|
// Live model switch — re-resolve provider with new model
|
|
485
452
|
try {
|
|
486
|
-
const { resolveProvider } =
|
|
453
|
+
const { resolveProvider } = await import("./providers/resolve.js");
|
|
487
454
|
const newProvider = resolveProvider(config.provider.name, result.model);
|
|
488
455
|
config.provider = newProvider;
|
|
489
456
|
// Rebuild system prompt with new model info
|
|
490
|
-
const { buildSystemPrompt } =
|
|
457
|
+
const { buildSystemPrompt } = await import("./system-prompt.js");
|
|
491
458
|
config.systemPrompt = buildSystemPrompt(config.systemPrompt.split("\n## Last session")[0], // preserve context, strip old summary
|
|
492
459
|
null, { name: newProvider.name, model: result.model });
|
|
493
460
|
statusBar();
|
|
494
461
|
}
|
|
495
462
|
catch { /* keep current provider on error */ }
|
|
496
463
|
},
|
|
497
|
-
})
|
|
464
|
+
});
|
|
465
|
+
if (cmdResult === true) {
|
|
498
466
|
prompt();
|
|
499
467
|
return;
|
|
500
468
|
}
|
|
501
|
-
|
|
469
|
+
if (typeof cmdResult === "object" && cmdResult instanceof Promise) {
|
|
470
|
+
cmdResult.then(() => { prompt(); });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
// If agent is running, add to steer queue
|
|
502
474
|
if (running) {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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`);
|
|
506
483
|
return;
|
|
507
484
|
}
|
|
508
|
-
//
|
|
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
|
|
509
491
|
runAgentTurn(line);
|
|
510
492
|
return;
|
|
511
493
|
}
|
|
@@ -521,9 +503,8 @@ export async function startTui(config, spawner) {
|
|
|
521
503
|
historyIndex--;
|
|
522
504
|
}
|
|
523
505
|
inputLine = inputHistory[historyIndex];
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
w.write(inputLine);
|
|
506
|
+
cursorPos = inputLine.length;
|
|
507
|
+
redrawInput();
|
|
527
508
|
return;
|
|
528
509
|
}
|
|
529
510
|
// Down arrow — next history or restore saved
|
|
@@ -538,34 +519,128 @@ export async function startTui(config, spawner) {
|
|
|
538
519
|
historyIndex = -1;
|
|
539
520
|
inputLine = savedInput;
|
|
540
521
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
522
|
+
cursorPos = inputLine.length;
|
|
523
|
+
redrawInput();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
// Ctrl+A — move cursor to start of line
|
|
527
|
+
if (key.ctrl && key.name === "a") {
|
|
528
|
+
cursorPos = 0;
|
|
529
|
+
redrawInput();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// Ctrl+E — move cursor to end of line
|
|
533
|
+
if (key.ctrl && key.name === "e") {
|
|
534
|
+
cursorPos = inputLine.length;
|
|
535
|
+
redrawInput();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// Ctrl+U — kill entire line
|
|
539
|
+
if (key.ctrl && key.name === "u") {
|
|
540
|
+
inputLine = "";
|
|
541
|
+
cursorPos = 0;
|
|
542
|
+
redrawInput();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
// Ctrl+K — kill from cursor to end of line
|
|
546
|
+
if (key.ctrl && key.name === "k") {
|
|
547
|
+
inputLine = inputLine.slice(0, cursorPos);
|
|
548
|
+
redrawInput();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
// Left arrow — move cursor left one character
|
|
552
|
+
if (key.name === "left" && !key.meta && !key.ctrl) {
|
|
553
|
+
if (cursorPos > 0) {
|
|
554
|
+
cursorPos--;
|
|
555
|
+
w.write(`${ESC}D`);
|
|
556
|
+
}
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
// Right arrow — move cursor right one character
|
|
560
|
+
if (key.name === "right" && !key.meta && !key.ctrl) {
|
|
561
|
+
if (cursorPos < inputLine.length) {
|
|
562
|
+
cursorPos++;
|
|
563
|
+
w.write(`${ESC}C`);
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
// Alt+Left — move cursor left by one word
|
|
568
|
+
if (key.name === "left" && (key.meta || key.ctrl)) {
|
|
569
|
+
if (cursorPos > 0) {
|
|
570
|
+
// Skip spaces, then skip non-spaces
|
|
571
|
+
let p = cursorPos;
|
|
572
|
+
while (p > 0 && inputLine[p - 1] === " ")
|
|
573
|
+
p--;
|
|
574
|
+
while (p > 0 && inputLine[p - 1] !== " ")
|
|
575
|
+
p--;
|
|
576
|
+
cursorPos = p;
|
|
577
|
+
redrawInput();
|
|
578
|
+
}
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
// Alt+Right — move cursor right by one word
|
|
582
|
+
if (key.name === "right" && (key.meta || key.ctrl)) {
|
|
583
|
+
if (cursorPos < inputLine.length) {
|
|
584
|
+
let p = cursorPos;
|
|
585
|
+
while (p < inputLine.length && inputLine[p] !== " ")
|
|
586
|
+
p++;
|
|
587
|
+
while (p < inputLine.length && inputLine[p] === " ")
|
|
588
|
+
p++;
|
|
589
|
+
cursorPos = p;
|
|
590
|
+
redrawInput();
|
|
591
|
+
}
|
|
544
592
|
return;
|
|
545
593
|
}
|
|
546
|
-
// Backspace
|
|
594
|
+
// Word-delete: Alt+Backspace, Ctrl+Backspace, Ctrl+W
|
|
595
|
+
if (((key.meta || key.ctrl) && key.name === "backspace") ||
|
|
596
|
+
(key.ctrl && key.name === "w")) {
|
|
597
|
+
if (cursorPos > 0) {
|
|
598
|
+
// Find word boundary before cursor
|
|
599
|
+
let p = cursorPos;
|
|
600
|
+
while (p > 0 && inputLine[p - 1] === " ")
|
|
601
|
+
p--;
|
|
602
|
+
while (p > 0 && inputLine[p - 1] !== " ")
|
|
603
|
+
p--;
|
|
604
|
+
inputLine = inputLine.slice(0, p) + inputLine.slice(cursorPos);
|
|
605
|
+
cursorPos = p;
|
|
606
|
+
redrawInput();
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
// Backspace — delete character before cursor
|
|
547
611
|
if (key.name === "backspace") {
|
|
548
|
-
if (
|
|
549
|
-
inputLine = inputLine.slice(0, -1);
|
|
550
|
-
|
|
612
|
+
if (cursorPos > 0) {
|
|
613
|
+
inputLine = inputLine.slice(0, cursorPos - 1) + inputLine.slice(cursorPos);
|
|
614
|
+
cursorPos--;
|
|
615
|
+
redrawInput();
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
// Delete — delete character at cursor
|
|
620
|
+
if (key.name === "delete") {
|
|
621
|
+
if (cursorPos < inputLine.length) {
|
|
622
|
+
inputLine = inputLine.slice(0, cursorPos) + inputLine.slice(cursorPos + 1);
|
|
623
|
+
redrawInput();
|
|
551
624
|
}
|
|
552
625
|
return;
|
|
553
626
|
}
|
|
554
|
-
// Regular character
|
|
627
|
+
// Regular character — insert at cursor position
|
|
555
628
|
if (key.sequence && !key.ctrl && !key.meta) {
|
|
556
629
|
// ! at start of empty input toggles bash mode
|
|
557
630
|
if (key.sequence === "!" && inputLine === "" && !bashMode) {
|
|
558
631
|
bashMode = true;
|
|
559
|
-
|
|
632
|
+
redrawInput();
|
|
560
633
|
return;
|
|
561
634
|
}
|
|
562
|
-
inputLine
|
|
563
|
-
|
|
635
|
+
inputLine = inputLine.slice(0, cursorPos) + key.sequence + inputLine.slice(cursorPos);
|
|
636
|
+
cursorPos += key.sequence.length;
|
|
637
|
+
redrawInput();
|
|
564
638
|
}
|
|
565
639
|
});
|
|
566
640
|
// TUI hooks — render streaming text with markdown, compact tool output
|
|
567
641
|
let textBuffer = "";
|
|
568
642
|
let firstDelta = true;
|
|
643
|
+
let activeThinkTimer = null;
|
|
569
644
|
function flushTextBuffer() {
|
|
570
645
|
if (!textBuffer)
|
|
571
646
|
return;
|
|
@@ -575,24 +650,43 @@ export async function startTui(config, spawner) {
|
|
|
575
650
|
const tuiHooks = {
|
|
576
651
|
onTextDelta: (text) => {
|
|
577
652
|
if (firstDelta) {
|
|
578
|
-
|
|
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
|
|
579
659
|
firstDelta = false;
|
|
580
660
|
}
|
|
581
|
-
|
|
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
|
-
}
|
|
661
|
+
w.write(text);
|
|
586
662
|
},
|
|
587
663
|
onTextDone: () => {
|
|
588
664
|
flushTextBuffer();
|
|
589
665
|
},
|
|
590
666
|
onTextBlock: (text) => {
|
|
591
|
-
|
|
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);
|
|
592
677
|
if (!text.endsWith("\n"))
|
|
593
678
|
w.write("\n");
|
|
594
679
|
},
|
|
595
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
|
+
}
|
|
596
690
|
flushTextBuffer();
|
|
597
691
|
const preview = formatToolInput(name, input);
|
|
598
692
|
const countLabel = count > 1 ? s.dim(` (${count} tools)`) : "";
|
|
@@ -609,6 +703,12 @@ export async function startTui(config, spawner) {
|
|
|
609
703
|
},
|
|
610
704
|
onStatus: (msg) => w.write(s.dim(msg)),
|
|
611
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
|
+
}
|
|
612
712
|
if (pendingInput && inputMode === "steering") {
|
|
613
713
|
const steer = pendingInput;
|
|
614
714
|
pendingInput = null;
|
|
@@ -622,26 +722,52 @@ export async function startTui(config, spawner) {
|
|
|
622
722
|
running = true;
|
|
623
723
|
firstDelta = true;
|
|
624
724
|
const thinkStart = Date.now();
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
725
|
+
// Phren thinking — subtle purple/cyan breath with rotating verbs
|
|
726
|
+
const THINK_VERBS = ["thinking", "reasoning", "recalling", "connecting", "processing"];
|
|
727
|
+
let thinkFrame = 0;
|
|
728
|
+
activeThinkTimer = setInterval(() => {
|
|
729
|
+
const elapsed = (Date.now() - thinkStart) / 1000;
|
|
730
|
+
const verb = THINK_VERBS[Math.floor(elapsed / 6) % THINK_VERBS.length];
|
|
731
|
+
const t = (Math.sin(thinkFrame * 0.08) + 1) / 2;
|
|
732
|
+
const r = Math.round(155 * (1 - t) + 40 * t);
|
|
733
|
+
const g = Math.round(140 * (1 - t) + 211 * t);
|
|
734
|
+
const b = Math.round(250 * (1 - t) + 242 * t);
|
|
735
|
+
const color = `${ESC}38;2;${r};${g};${b}m`;
|
|
736
|
+
w.write(`${ESC}2K${color}◆ ${verb}${ESC}0m ${s.dim(`${elapsed.toFixed(1)}s`)}\r`);
|
|
737
|
+
thinkFrame++;
|
|
738
|
+
}, 50);
|
|
629
739
|
try {
|
|
630
740
|
await runTurn(userInput, session, config, tuiHooks);
|
|
631
|
-
|
|
741
|
+
if (activeThinkTimer) {
|
|
742
|
+
clearInterval(activeThinkTimer);
|
|
743
|
+
activeThinkTimer = null;
|
|
744
|
+
}
|
|
745
|
+
const elapsed = ((Date.now() - thinkStart) / 1000).toFixed(1);
|
|
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`);
|
|
632
749
|
statusBar();
|
|
633
750
|
}
|
|
634
751
|
catch (err) {
|
|
635
|
-
|
|
752
|
+
if (activeThinkTimer) {
|
|
753
|
+
clearInterval(activeThinkTimer);
|
|
754
|
+
activeThinkTimer = null;
|
|
755
|
+
}
|
|
636
756
|
const msg = err instanceof Error ? err.message : String(err);
|
|
637
757
|
w.write(`${ESC}2K\r`);
|
|
638
758
|
w.write(s.red(` Error: ${msg}\n`));
|
|
639
759
|
}
|
|
640
760
|
running = false;
|
|
641
|
-
// Process queued input
|
|
642
|
-
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) {
|
|
643
768
|
const queued = pendingInput;
|
|
644
769
|
pendingInput = null;
|
|
770
|
+
w.write(`${s.bold("❯")} ${queued}\n`);
|
|
645
771
|
runAgentTurn(queued);
|
|
646
772
|
}
|
|
647
773
|
else {
|