@phren/agent 0.0.1 → 0.1.0

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/repl.js CHANGED
@@ -5,9 +5,9 @@ import * as path from "node:path";
5
5
  import * as os from "node:os";
6
6
  import { createSession, runTurn } from "./agent-loop.js";
7
7
  import { handleCommand } from "./commands.js";
8
+ import { loadInputMode, saveInputMode, savePermissionMode } from "./settings.js";
8
9
  const HISTORY_DIR = path.join(os.homedir(), ".phren-agent");
9
10
  const HISTORY_FILE = path.join(HISTORY_DIR, "repl-history.txt");
10
- const SETTINGS_FILE = path.join(HISTORY_DIR, "settings.json");
11
11
  const MAX_HISTORY = 500;
12
12
  const CYAN = "\x1b[36m";
13
13
  const RED = "\x1b[31m";
@@ -30,41 +30,6 @@ function saveHistory(lines) {
30
30
  }
31
31
  catch { /* ignore */ }
32
32
  }
33
- function loadInputMode() {
34
- try {
35
- const data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
36
- if (data.inputMode === "queue")
37
- return "queue";
38
- }
39
- catch { /* ignore */ }
40
- return "steering";
41
- }
42
- function saveInputMode(mode) {
43
- try {
44
- fs.mkdirSync(HISTORY_DIR, { recursive: true });
45
- let data = {};
46
- try {
47
- data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
48
- }
49
- catch { /* fresh */ }
50
- data.inputMode = mode;
51
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
52
- }
53
- catch { /* ignore */ }
54
- }
55
- function savePermissionMode(mode) {
56
- try {
57
- fs.mkdirSync(HISTORY_DIR, { recursive: true });
58
- let data = {};
59
- try {
60
- data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
61
- }
62
- catch { /* fresh */ }
63
- data.permissionMode = mode;
64
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
65
- }
66
- catch { /* ignore */ }
67
- }
68
33
  export async function startRepl(config) {
69
34
  const contextLimit = config.provider.contextWindow ?? 200_000;
70
35
  const session = createSession(contextLimit);
@@ -0,0 +1,42 @@
1
+ /** Shared settings persistence for agent TUI and REPL. */
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import * as os from "os";
5
+ export const SETTINGS_FILE = path.join(os.homedir(), ".phren-agent", "settings.json");
6
+ export function loadInputMode() {
7
+ try {
8
+ const data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
9
+ if (data.inputMode === "queue")
10
+ return "queue";
11
+ }
12
+ catch { }
13
+ return "steering";
14
+ }
15
+ export function saveInputMode(mode) {
16
+ try {
17
+ const dir = path.dirname(SETTINGS_FILE);
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ let data = {};
20
+ try {
21
+ data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
22
+ }
23
+ catch { }
24
+ data.inputMode = mode;
25
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
26
+ }
27
+ catch { }
28
+ }
29
+ export function savePermissionMode(mode) {
30
+ try {
31
+ const dir = path.dirname(SETTINGS_FILE);
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ let data = {};
34
+ try {
35
+ data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
36
+ }
37
+ catch { }
38
+ data.permissionMode = mode;
39
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
40
+ }
41
+ catch { }
42
+ }
@@ -15,6 +15,17 @@ export function buildSystemPrompt(phrenContext, priorSummary) {
15
15
  "- `phren_add_finding` saves insights for future sessions. Good findings: non-obvious patterns, decisions with rationale, error resolutions, architecture constraints. Bad findings: narration of what you did, obvious facts, secrets.",
16
16
  "- `phren_get_tasks` shows tracked work items. Complete tasks with `phren_complete_task` when done.",
17
17
  "",
18
+ "## Self-configuration",
19
+ "You ARE phren-agent. You can configure phren itself via shell commands:",
20
+ "- `phren init` — set up phren (MCP server, hooks, profiles)",
21
+ "- `phren add <path>` — register a project directory",
22
+ "- `phren config proactivity <level>` — set proactivity (high/medium/low)",
23
+ "- `phren config policy set <key> <value>` — configure retention, TTL, decay",
24
+ "- `phren hooks enable <tool>` — enable hooks for claude/copilot/cursor/codex",
25
+ "- `phren doctor --fix` — diagnose and self-heal",
26
+ "- `phren status` — check health",
27
+ "If the user asks you to configure phren, set up a project, or fix their install, use the shell tool to run these commands.",
28
+ "",
18
29
  "## Rules",
19
30
  "- Never write secrets, API keys, or PII to files or findings.",
20
31
  "- Prefer `edit_file` over `write_file` for existing files.",
@@ -1,5 +1,7 @@
1
1
  import * as fs from "fs";
2
+ import * as path from "path";
2
3
  import { encodeDiffPayload } from "../multi/diff-renderer.js";
4
+ import { checkSensitivePath, validatePath } from "../permissions/sandbox.js";
3
5
  export const editFileTool = {
4
6
  name: "edit_file",
5
7
  description: "Edit a file by replacing an exact string match. Preferred over write_file for modifying existing files — only changes what's needed. The old_string must appear exactly once; include surrounding lines for uniqueness if needed.",
@@ -16,6 +18,17 @@ export const editFileTool = {
16
18
  const filePath = input.path;
17
19
  const oldStr = input.old_string;
18
20
  const newStr = input.new_string;
21
+ // Defense-in-depth: sensitive path check
22
+ const resolved = path.resolve(filePath);
23
+ const sensitive = checkSensitivePath(resolved);
24
+ if (sensitive.sensitive) {
25
+ return { output: `Access denied: ${sensitive.reason}`, is_error: true };
26
+ }
27
+ // Defense-in-depth: sandbox check
28
+ const sandboxResult = validatePath(filePath, process.cwd(), []);
29
+ if (!sandboxResult.ok) {
30
+ return { output: `Path outside sandbox: ${sandboxResult.error}`, is_error: true };
31
+ }
19
32
  if (!fs.existsSync(filePath))
20
33
  return { output: `File not found: ${filePath}`, is_error: true };
21
34
  const oldContent = fs.readFileSync(filePath, "utf-8");
package/dist/tools/git.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "child_process";
2
+ import { validatePath } from "../permissions/sandbox.js";
2
3
  function git(args, cwd) {
3
4
  return execFileSync("git", args, {
4
5
  cwd,
@@ -20,6 +21,10 @@ export const gitStatusTool = {
20
21
  },
21
22
  async execute(input) {
22
23
  const cwd = input.cwd || process.cwd();
24
+ const cwdCheck = validatePath(cwd, process.cwd(), []);
25
+ if (!cwdCheck.ok) {
26
+ return { output: `Git cwd outside sandbox: ${cwdCheck.error}`, is_error: true };
27
+ }
23
28
  try {
24
29
  const output = git(["status", "--short"], cwd);
25
30
  return { output: output || "(working tree clean)" };
@@ -43,6 +48,10 @@ export const gitDiffTool = {
43
48
  },
44
49
  async execute(input) {
45
50
  const cwd = input.cwd || process.cwd();
51
+ const cwdCheck = validatePath(cwd, process.cwd(), []);
52
+ if (!cwdCheck.ok) {
53
+ return { output: `Git cwd outside sandbox: ${cwdCheck.error}`, is_error: true };
54
+ }
46
55
  const args = ["diff"];
47
56
  if (input.cached)
48
57
  args.push("--cached");
@@ -75,6 +84,10 @@ export const gitCommitTool = {
75
84
  },
76
85
  async execute(input) {
77
86
  const cwd = input.cwd || process.cwd();
87
+ const cwdCheck = validatePath(cwd, process.cwd(), []);
88
+ if (!cwdCheck.ok) {
89
+ return { output: `Git cwd outside sandbox: ${cwdCheck.error}`, is_error: true };
90
+ }
78
91
  const message = input.message;
79
92
  const files = input.files || [];
80
93
  try {
@@ -1,6 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { encodeDiffPayload } from "../multi/diff-renderer.js";
4
+ import { checkSensitivePath, validatePath } from "../permissions/sandbox.js";
4
5
  export const writeFileTool = {
5
6
  name: "write_file",
6
7
  description: "Write content to a file, creating parent directories as needed. Use for new files only — prefer edit_file for modifying existing files. Overwrites existing content entirely.",
@@ -15,6 +16,17 @@ export const writeFileTool = {
15
16
  async execute(input) {
16
17
  const filePath = input.path;
17
18
  const content = input.content;
19
+ // Defense-in-depth: sensitive path check
20
+ const resolved = path.resolve(filePath);
21
+ const sensitive = checkSensitivePath(resolved);
22
+ if (sensitive.sensitive) {
23
+ return { output: `Access denied: ${sensitive.reason}`, is_error: true };
24
+ }
25
+ // Defense-in-depth: sandbox check
26
+ const sandboxResult = validatePath(filePath, process.cwd(), []);
27
+ if (!sandboxResult.ok) {
28
+ return { output: `Path outside sandbox: ${sandboxResult.error}`, is_error: true };
29
+ }
18
30
  const oldContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
19
31
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
20
32
  fs.writeFileSync(filePath, content);
package/dist/tui.js CHANGED
@@ -8,21 +8,29 @@ import { createSession, runTurn } from "./agent-loop.js";
8
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
- import * as fs from "fs";
12
- import * as path from "path";
13
11
  import * as os from "os";
12
+ import { execSync } from "node:child_process";
13
+ import { loadInputMode, saveInputMode, savePermissionMode } from "./settings.js";
14
+ import { createRequire } from "node:module";
15
+ const _require = createRequire(import.meta.url);
16
+ const AGENT_VERSION = _require("../package.json").version;
14
17
  // ── ANSI helpers ─────────────────────────────────────────────────────────────
15
18
  const ESC = "\x1b[";
16
19
  const s = {
17
20
  reset: `${ESC}0m`,
18
21
  bold: (t) => `${ESC}1m${t}${ESC}0m`,
19
22
  dim: (t) => `${ESC}2m${t}${ESC}0m`,
23
+ italic: (t) => `${ESC}3m${t}${ESC}0m`,
20
24
  cyan: (t) => `${ESC}36m${t}${ESC}0m`,
21
25
  green: (t) => `${ESC}32m${t}${ESC}0m`,
22
26
  yellow: (t) => `${ESC}33m${t}${ESC}0m`,
23
27
  red: (t) => `${ESC}31m${t}${ESC}0m`,
28
+ blue: (t) => `${ESC}34m${t}${ESC}0m`,
29
+ magenta: (t) => `${ESC}35m${t}${ESC}0m`,
24
30
  gray: (t) => `${ESC}90m${t}${ESC}0m`,
25
31
  invert: (t) => `${ESC}7m${t}${ESC}0m`,
32
+ // Gradient-style brand text
33
+ brand: (t) => `${ESC}1;35m${t}${ESC}0m`,
26
34
  };
27
35
  function cols() {
28
36
  return process.stdout.columns || 80;
@@ -38,23 +46,28 @@ const PERMISSION_LABELS = {
38
46
  "auto-confirm": "auto",
39
47
  "full-auto": "full-auto",
40
48
  };
41
- function formatPermissionMode(mode) {
42
- const label = PERMISSION_LABELS[mode];
43
- switch (mode) {
44
- case "suggest": return s.cyan(`[${label}]`);
45
- case "auto-confirm": return s.green(`[${label}]`);
46
- case "full-auto": return s.yellow(`[${label}]`);
47
- }
48
- }
49
49
  // ── Status bar ───────────────────────────────────────────────────────────────
50
50
  function renderStatusBar(provider, project, turns, cost, permMode, agentCount) {
51
- const modeStr = permMode ? ` ${PERMISSION_LABELS[permMode]}` : "";
52
- const agentTag = agentCount && agentCount > 0 ? ` ${s.dim(`A${agentCount}`)}` : "";
53
- const left = ` ${s.bold("phren-agent")} ${s.dim("·")} ${provider}${project ? ` ${s.dim("·")} ${project}` : ""}`;
54
- const right = `${modeStr}${agentTag} ${cost ? cost + " " : ""}${s.dim(`T${turns}`)} `;
51
+ const modeLabel = permMode ? PERMISSION_LABELS[permMode] : "";
52
+ const agentTag = agentCount && agentCount > 0 ? ` A${agentCount}` : "";
53
+ // Left: brand + provider + project
54
+ const parts = [" phren", provider];
55
+ if (project)
56
+ parts.push(project);
57
+ const left = parts.join(" · ");
58
+ // Right: mode + agents + cost + turns
59
+ const rightParts = [];
60
+ if (modeLabel)
61
+ rightParts.push(modeLabel);
62
+ if (agentTag)
63
+ rightParts.push(agentTag.trim());
64
+ if (cost)
65
+ rightParts.push(cost);
66
+ rightParts.push(`T${turns}`);
67
+ const right = rightParts.join(" ") + " ";
55
68
  const w = cols();
56
- const pad = Math.max(0, w - stripAnsi(left).length - stripAnsi(right).length);
57
- return s.invert(stripAnsi(left) + " ".repeat(pad) + stripAnsi(right));
69
+ const pad = Math.max(0, w - left.length - right.length);
70
+ return s.invert(left + " ".repeat(pad) + right);
58
71
  }
59
72
  function stripAnsi(t) {
60
73
  return t.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
@@ -70,17 +83,34 @@ function formatDuration(ms) {
70
83
  const secs = Math.round((ms % 60_000) / 1000);
71
84
  return `${mins}m ${secs}s`;
72
85
  }
86
+ function formatToolInput(name, input) {
87
+ // Show the most relevant input field for each tool type
88
+ switch (name) {
89
+ case "read_file": return input.file_path ?? "";
90
+ case "write_file": return input.file_path ?? "";
91
+ case "edit_file": return input.file_path ?? "";
92
+ case "shell": return (input.command ?? "").slice(0, 60);
93
+ case "glob": return input.pattern ?? "";
94
+ case "grep": return `/${input.pattern ?? ""}/ ${input.path ?? ""}`;
95
+ case "git_commit": return (input.message ?? "").slice(0, 50);
96
+ case "phren_search": return input.query ?? "";
97
+ case "phren_add_finding": return (input.finding ?? "").slice(0, 50);
98
+ default: return JSON.stringify(input).slice(0, 60);
99
+ }
100
+ }
73
101
  function renderToolCall(name, input, output, isError, durationMs) {
74
- const inputPreview = JSON.stringify(input).slice(0, 80);
102
+ const preview = formatToolInput(name, input);
75
103
  const dur = formatDuration(durationMs);
76
- const icon = isError ? s.red("✗") : s.green("");
77
- const header = s.dim(` ${name}(${inputPreview})`) + ` ${icon} ${s.dim(dur)}`;
104
+ const icon = isError ? s.red("✗") : s.green("");
105
+ const header = ` ${icon} ${s.bold(name)} ${s.gray(preview)} ${s.dim(dur)}`;
78
106
  // Compact: show first 3 lines only, with overflow count
79
107
  const allLines = output.split("\n").filter(Boolean);
108
+ if (allLines.length === 0)
109
+ return header;
80
110
  const shown = allLines.slice(0, COMPACT_LINES);
81
- const body = shown.map((l) => s.dim(`${l.slice(0, cols() - 6)}`)).join("\n");
111
+ const body = shown.map((l) => s.dim(` ${l.slice(0, cols() - 6)}`)).join("\n");
82
112
  const overflow = allLines.length - COMPACT_LINES;
83
- const more = overflow > 0 ? `\n${s.dim(` [+${overflow} lines]`)}` : "";
113
+ const more = overflow > 0 ? `\n${s.dim(` ... +${overflow} lines`)}` : "";
84
114
  return `${header}\n${body}${more}`;
85
115
  }
86
116
  // ── Menu mode helpers ────────────────────────────────────────────────────────
@@ -118,6 +148,7 @@ export async function startTui(config, spawner) {
118
148
  let menuListCount = 0;
119
149
  let menuFilterActive = false;
120
150
  let menuFilterBuf = "";
151
+ let ctrlCCount = 0;
121
152
  // ── Menu rendering ─────────────────────────────────────────────────────
122
153
  async function renderMenu() {
123
154
  const mod = await loadMenuModule();
@@ -144,7 +175,7 @@ export async function startTui(config, spawner) {
144
175
  menuFilterBuf = "";
145
176
  w.write("\x1b[?1049l"); // leave alternate screen (restores chat)
146
177
  statusBar();
147
- prompt();
178
+ prompt(true); // skip newline — alt screen restore already positioned cursor
148
179
  }
149
180
  // Print status bar
150
181
  function statusBar() {
@@ -153,10 +184,31 @@ export async function startTui(config, spawner) {
153
184
  const bar = renderStatusBar(config.provider.name, config.phrenCtx?.project ?? null, session.turns, costStr, config.registry.permissionConfig.mode, spawner?.listAgents().length);
154
185
  w.write(`${ESC}s${ESC}H${bar}${ESC}u`); // save cursor, move to top, print, restore
155
186
  }
156
- // Print prompt
157
- function prompt() {
158
- const modeTag = inputMode === "steering" ? s.dim("[steer]") : s.dim("[queue]");
159
- w.write(`\n${s.cyan("phren>")} ${modeTag} `);
187
+ // Print prompt — bordered input bar at bottom
188
+ let bashMode = false;
189
+ function prompt(skipNewline = false) {
190
+ if (!isTTY)
191
+ return;
192
+ const mode = config.registry.permissionConfig.mode;
193
+ const modeIcon = mode === "full-auto" ? "●" : mode === "auto-confirm" ? "◐" : "○";
194
+ const modeColor = mode === "full-auto" ? s.yellow : mode === "auto-confirm" ? s.green : s.cyan;
195
+ const rows = process.stdout.rows || 24;
196
+ const c = cols();
197
+ if (!skipNewline)
198
+ w.write("\n");
199
+ // Separator line + prompt on last 2 rows
200
+ const permLabel = PERMISSION_LABELS[mode];
201
+ const separator = s.dim("─".repeat(c));
202
+ const rightHints = s.dim(`${bashMode ? "! bash" : permLabel} · shift+tab to cycle · esc to interrupt`);
203
+ const rightLen = stripAnsi(rightHints).length;
204
+ const sepLine = s.dim("─".repeat(Math.max(1, c - rightLen - 1))) + " " + rightHints;
205
+ w.write(`${ESC}${rows - 1};1H${ESC}2K${sepLine}`);
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
+ }
160
212
  }
161
213
  // Terminal cleanup: restore state on exit
162
214
  function cleanupTerminal() {
@@ -169,13 +221,50 @@ export async function startTui(config, spawner) {
169
221
  }
170
222
  }
171
223
  process.on("exit", cleanupTerminal);
172
- // Setup: alternate screen not needed just reserve top line for status
224
+ // Setup: clear screen, status bar at top, content area clean
173
225
  if (isTTY) {
174
- w.write("\n"); // make room for status bar
175
- w.write(`${ESC}1;1H`); // move to top
226
+ w.write(`${ESC}2J${ESC}H`); // clear entire screen + home
176
227
  statusBar();
177
228
  w.write(`${ESC}2;1H`); // move below status bar
178
- w.write(s.dim("phren-agent TUI. Tab: memory browser Shift+Tab: permissions /help: commands Ctrl+D: exit\n"));
229
+ // Startup banner
230
+ const project = config.phrenCtx?.project;
231
+ const cwd = process.cwd().replace(os.homedir(), "~");
232
+ 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
+ let artLines = [];
236
+ try {
237
+ const { PHREN_ART } = await import("@phren/cli/phren-art");
238
+ artLines = PHREN_ART.filter((l) => l.trim());
239
+ }
240
+ catch { /* art not available */ }
241
+ if (artLines.length > 0) {
242
+ // Art on left, info on right
243
+ const info = [
244
+ `${s.brand("◆ phren agent")} ${s.dim("v${AGENT_VERSION}")}`,
245
+ `${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""}`,
246
+ `${s.dim(cwd)}`,
247
+ ``,
248
+ `${modeColor(`${permMode === "full-auto" ? "●" : permMode === "auto-confirm" ? "◐" : "○"} ${permMode}`)} ${s.dim("permissions (shift+tab to cycle)")}`,
249
+ ``,
250
+ `${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit`,
251
+ ];
252
+ const maxArtWidth = 26; // phren art is ~24 chars wide
253
+ for (let i = 0; i < Math.max(artLines.length, info.length); i++) {
254
+ const artPart = i < artLines.length ? artLines[i] : "";
255
+ const infoPart = i < info.length ? info[i] : "";
256
+ const artPadded = artPart + " ".repeat(Math.max(0, maxArtWidth - stripAnsi(artPart).length));
257
+ w.write(`${artPadded}${infoPart}\n`);
258
+ }
259
+ }
260
+ else {
261
+ // Fallback: text-only banner
262
+ w.write(`\n ${s.brand("◆ phren agent")} ${s.dim("v${AGENT_VERSION}")}\n`);
263
+ w.write(` ${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""} ${s.dim(cwd)}\n`);
264
+ w.write(` ${modeColor(`${permMode === "full-auto" ? "●" : permMode === "auto-confirm" ? "◐" : "○"} ${permMode}`)} ${s.dim("permissions (shift+tab to cycle)")}\n\n`);
265
+ w.write(` ${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit\n\n`);
266
+ }
267
+ w.write("\n");
179
268
  }
180
269
  // Raw stdin for steering
181
270
  if (process.stdin.isTTY) {
@@ -239,13 +328,12 @@ export async function startTui(config, spawner) {
239
328
  process.stdin.on("keypress", (_ch, key) => {
240
329
  if (!key)
241
330
  return;
242
- // Ctrl+D — always exit
331
+ // Ctrl+D — clean exit
243
332
  if (key.ctrl && key.name === "d") {
244
333
  if (tuiMode === "menu")
245
- w.write("\x1b[?1049l"); // leave alt screen
246
- if (process.stdin.isTTY)
247
- process.stdin.setRawMode(false);
248
- w.write(s.dim("\nSession ended.\n"));
334
+ w.write("\x1b[?1049l");
335
+ cleanupTerminal();
336
+ w.write(`\n${s.dim(`${session.turns} turns, ${session.toolCalls} tool calls.`)}\n`);
249
337
  resolve(session);
250
338
  return;
251
339
  }
@@ -255,7 +343,9 @@ export async function startTui(config, spawner) {
255
343
  const next = nextPermissionMode(current);
256
344
  config.registry.setPermissions({ ...config.registry.permissionConfig, mode: next });
257
345
  savePermissionMode(next);
258
- w.write(s.yellow(` [mode: ${next}]\n`));
346
+ const modeColor = next === "full-auto" ? s.yellow : next === "auto-confirm" ? s.green : s.cyan;
347
+ const modeIcon = next === "full-auto" ? "●" : next === "auto-confirm" ? "◐" : "○";
348
+ w.write(` ${modeColor(`${modeIcon} ${next}`)}\n`);
259
349
  statusBar();
260
350
  if (!running)
261
351
  prompt();
@@ -277,16 +367,57 @@ export async function startTui(config, spawner) {
277
367
  return;
278
368
  }
279
369
  // ── Chat mode keys ──────────────────────────────────────────────────
280
- // Ctrl+Ccancel current or clear line
370
+ // Escapeexit bash mode, or clear input
371
+ if (key.name === "escape") {
372
+ if (bashMode) {
373
+ bashMode = false;
374
+ inputLine = "";
375
+ prompt(true);
376
+ return;
377
+ }
378
+ if (inputLine) {
379
+ inputLine = "";
380
+ prompt(true);
381
+ return;
382
+ }
383
+ }
384
+ // Ctrl+C — progressive: cancel → warn → quit
281
385
  if (key.ctrl && key.name === "c") {
282
386
  if (running) {
387
+ // Cancel current agent turn
283
388
  pendingInput = null;
284
389
  w.write(s.yellow("\n [interrupted]\n"));
390
+ ctrlCCount = 0;
391
+ return;
285
392
  }
286
- else {
393
+ if (bashMode) {
394
+ bashMode = false;
287
395
  inputLine = "";
288
- w.write("\n");
289
- prompt();
396
+ prompt(true);
397
+ ctrlCCount = 0;
398
+ return;
399
+ }
400
+ if (inputLine) {
401
+ // Clear input
402
+ inputLine = "";
403
+ prompt(true);
404
+ ctrlCCount = 0;
405
+ return;
406
+ }
407
+ // Nothing to cancel — progressive quit
408
+ ctrlCCount++;
409
+ if (ctrlCCount === 1) {
410
+ w.write(s.dim("\n Press Ctrl+C again to exit.\n"));
411
+ prompt(true);
412
+ // Reset after 2 seconds
413
+ setTimeout(() => { ctrlCCount = 0; }, 2000);
414
+ }
415
+ else {
416
+ // Actually quit
417
+ if (process.stdin.isTTY)
418
+ process.stdin.setRawMode(false);
419
+ w.write(s.dim("\nSession ended.\n"));
420
+ resolve(session);
290
421
  }
291
422
  return;
292
423
  }
@@ -299,6 +430,25 @@ export async function startTui(config, spawner) {
299
430
  prompt();
300
431
  return;
301
432
  }
433
+ // Bash mode: ! prefix runs shell directly
434
+ if (line.startsWith("!") || bashMode) {
435
+ const cmd = bashMode ? line : line.slice(1).trim();
436
+ bashMode = false;
437
+ if (cmd) {
438
+ try {
439
+ const output = execSync(cmd, { encoding: "utf-8", timeout: 30_000, cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
440
+ w.write(output);
441
+ if (!output.endsWith("\n"))
442
+ w.write("\n");
443
+ }
444
+ catch (err) {
445
+ const e = err;
446
+ w.write(s.red(e.stderr || e.message || "Command failed") + "\n");
447
+ }
448
+ }
449
+ prompt();
450
+ return;
451
+ }
302
452
  // Slash commands
303
453
  if (line === "/mode") {
304
454
  inputMode = inputMode === "steering" ? "queue" : "steering";
@@ -307,7 +457,14 @@ export async function startTui(config, spawner) {
307
457
  prompt();
308
458
  return;
309
459
  }
310
- if (handleCommand(line, { session, contextLimit, undoStack: [] })) {
460
+ if (handleCommand(line, {
461
+ session,
462
+ contextLimit,
463
+ undoStack: [],
464
+ providerName: config.provider.name,
465
+ currentModel: config.provider.model,
466
+ spawner,
467
+ })) {
311
468
  prompt();
312
469
  return;
313
470
  }
@@ -332,6 +489,12 @@ export async function startTui(config, spawner) {
332
489
  }
333
490
  // Regular character
334
491
  if (key.sequence && !key.ctrl && !key.meta) {
492
+ // ! at start of empty input toggles bash mode
493
+ if (key.sequence === "!" && inputLine === "" && !bashMode) {
494
+ bashMode = true;
495
+ prompt(true);
496
+ return;
497
+ }
335
498
  inputLine += key.sequence;
336
499
  w.write(key.sequence);
337
500
  }
@@ -360,9 +523,11 @@ export async function startTui(config, spawner) {
360
523
  if (!text.endsWith("\n"))
361
524
  w.write("\n");
362
525
  },
363
- onToolStart: (name, _input, _count) => {
526
+ onToolStart: (name, input, count) => {
364
527
  flushTextBuffer();
365
- w.write(s.dim(` ⠋ ${name}...\r`));
528
+ const preview = formatToolInput(name, input);
529
+ const countLabel = count > 1 ? s.dim(` (${count} tools)`) : "";
530
+ w.write(`${ESC}2K ${s.dim("◌")} ${s.gray(name)} ${s.dim(preview)}${countLabel}\r`);
366
531
  },
367
532
  onToolEnd: (name, input, output, isError, dur) => {
368
533
  w.write(`${ESC}2K\r`);
@@ -386,7 +551,7 @@ export async function startTui(config, spawner) {
386
551
  };
387
552
  async function runAgentTurn(userInput) {
388
553
  running = true;
389
- w.write(s.dim(" Thinking...\r"));
554
+ w.write(`${ESC}2K ${s.dim(" thinking...")}\r`);
390
555
  try {
391
556
  await runTurn(userInput, session, config, tuiHooks);
392
557
  statusBar();
@@ -410,42 +575,3 @@ export async function startTui(config, spawner) {
410
575
  prompt();
411
576
  return done;
412
577
  }
413
- // ── Settings persistence ─────────────────────────────────────────────────────
414
- const SETTINGS_FILE = path.join(os.homedir(), ".phren-agent", "settings.json");
415
- function loadInputMode() {
416
- try {
417
- const data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
418
- if (data.inputMode === "queue")
419
- return "queue";
420
- }
421
- catch { }
422
- return "steering";
423
- }
424
- function saveInputMode(mode) {
425
- try {
426
- const dir = path.dirname(SETTINGS_FILE);
427
- fs.mkdirSync(dir, { recursive: true });
428
- let data = {};
429
- try {
430
- data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
431
- }
432
- catch { }
433
- data.inputMode = mode;
434
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
435
- }
436
- catch { }
437
- }
438
- function savePermissionMode(mode) {
439
- try {
440
- const dir = path.dirname(SETTINGS_FILE);
441
- fs.mkdirSync(dir, { recursive: true });
442
- let data = {};
443
- try {
444
- data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
445
- }
446
- catch { }
447
- data.permissionMode = mode;
448
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
449
- }
450
- catch { }
451
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/agent",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Coding agent with persistent memory — powered by phren",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,12 +12,8 @@
12
12
  "files": [
13
13
  "dist"
14
14
  ],
15
- "scripts": {
16
- "build": "tsc -p tsconfig.json",
17
- "test": "echo 'Run tests from repo root: pnpm -w test'"
18
- },
19
15
  "dependencies": {
20
- "@phren/cli": "workspace:*"
16
+ "@phren/cli": "0.1.0"
21
17
  },
22
18
  "engines": {
23
19
  "node": ">=20.0.0"
@@ -35,5 +31,9 @@
35
31
  "type": "git",
36
32
  "url": "git+https://github.com/alaarab/phren.git",
37
33
  "directory": "packages/agent"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.json",
37
+ "test": "echo 'Run tests from repo root: pnpm -w test'"
38
38
  }
39
- }
39
+ }