@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/tools/glob.js
CHANGED
|
@@ -3,12 +3,36 @@ import * as path from "path";
|
|
|
3
3
|
import { validatePath } from "../permissions/sandbox.js";
|
|
4
4
|
/** Simple glob matching without external dependencies. Supports * and ** patterns. */
|
|
5
5
|
function matchGlob(pattern, filePath) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
// Normalize path separators
|
|
7
|
+
const p = pattern.replace(/\\/g, "/");
|
|
8
|
+
const f = filePath.replace(/\\/g, "/");
|
|
9
|
+
// Build regex: escape special chars, then convert glob tokens
|
|
10
|
+
let regex = "";
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < p.length) {
|
|
13
|
+
if (p[i] === "*" && p[i + 1] === "*") {
|
|
14
|
+
// ** matches any depth of directories
|
|
15
|
+
regex += ".*";
|
|
16
|
+
i += 2;
|
|
17
|
+
if (p[i] === "/")
|
|
18
|
+
i++; // skip trailing /
|
|
19
|
+
}
|
|
20
|
+
else if (p[i] === "*") {
|
|
21
|
+
// * matches anything except /
|
|
22
|
+
regex += "[^/]*";
|
|
23
|
+
i++;
|
|
24
|
+
}
|
|
25
|
+
else if (p[i] === "?") {
|
|
26
|
+
regex += "[^/]";
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
// Escape regex special chars
|
|
31
|
+
regex += p[i].replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return new RegExp(`^${regex}$`).test(f);
|
|
12
36
|
}
|
|
13
37
|
function walkDir(dir, base, results, maxResults) {
|
|
14
38
|
if (results.length >= maxResults)
|
package/dist/tui/ansi.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
2
|
+
export const ESC = "\x1b[";
|
|
3
|
+
export const s = {
|
|
4
|
+
reset: `${ESC}0m`,
|
|
5
|
+
bold: (t) => `${ESC}1m${t}${ESC}0m`,
|
|
6
|
+
dim: (t) => `${ESC}2m${t}${ESC}0m`,
|
|
7
|
+
italic: (t) => `${ESC}3m${t}${ESC}0m`,
|
|
8
|
+
cyan: (t) => `${ESC}36m${t}${ESC}0m`,
|
|
9
|
+
green: (t) => `${ESC}32m${t}${ESC}0m`,
|
|
10
|
+
yellow: (t) => `${ESC}33m${t}${ESC}0m`,
|
|
11
|
+
red: (t) => `${ESC}31m${t}${ESC}0m`,
|
|
12
|
+
blue: (t) => `${ESC}34m${t}${ESC}0m`,
|
|
13
|
+
magenta: (t) => `${ESC}35m${t}${ESC}0m`,
|
|
14
|
+
gray: (t) => `${ESC}90m${t}${ESC}0m`,
|
|
15
|
+
invert: (t) => `${ESC}7m${t}${ESC}0m`,
|
|
16
|
+
// Gradient-style brand text
|
|
17
|
+
brand: (t) => `${ESC}1;35m${t}${ESC}0m`,
|
|
18
|
+
};
|
|
19
|
+
export function cols() {
|
|
20
|
+
return process.stdout.columns || 80;
|
|
21
|
+
}
|
|
22
|
+
export function stripAnsi(t) {
|
|
23
|
+
return t.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
24
|
+
}
|
|
25
|
+
// ── Permission mode helpers ─────────────────────────────────────────────────
|
|
26
|
+
export const PERMISSION_MODES = ["suggest", "auto-confirm", "full-auto"];
|
|
27
|
+
export function nextPermissionMode(current) {
|
|
28
|
+
const idx = PERMISSION_MODES.indexOf(current);
|
|
29
|
+
return PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length];
|
|
30
|
+
}
|
|
31
|
+
export const PERMISSION_LABELS = {
|
|
32
|
+
"suggest": "suggest",
|
|
33
|
+
"auto-confirm": "auto",
|
|
34
|
+
"full-auto": "full-auto",
|
|
35
|
+
};
|
|
36
|
+
export const PERMISSION_ICONS = {
|
|
37
|
+
"suggest": "○",
|
|
38
|
+
"auto-confirm": "◐",
|
|
39
|
+
"full-auto": "●",
|
|
40
|
+
};
|
|
41
|
+
export const PERMISSION_COLORS = {
|
|
42
|
+
"suggest": s.cyan,
|
|
43
|
+
"auto-confirm": s.green,
|
|
44
|
+
"full-auto": s.yellow,
|
|
45
|
+
};
|
|
46
|
+
export function permTag(mode) {
|
|
47
|
+
return PERMISSION_COLORS[mode](`${PERMISSION_ICONS[mode]} ${mode}`);
|
|
48
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
export function AgentMessage({ text }) {
|
|
4
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "magenta", children: ["◆", " ", _jsx(Text, { children: text })] }), _jsx(Text, { children: "" })] }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback } from "react";
|
|
3
|
+
import { Static, Box, Text, useApp } from "ink";
|
|
4
|
+
import { Banner } from "./Banner.js";
|
|
5
|
+
import { ToolCall } from "./ToolCall.js";
|
|
6
|
+
import { ThinkingIndicator } from "./ThinkingIndicator.js";
|
|
7
|
+
import { SteerQueue } from "./SteerQueue.js";
|
|
8
|
+
import { InputArea, PermissionsLine } from "./InputArea.js";
|
|
9
|
+
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts.js";
|
|
10
|
+
export function App({ state, completedMessages, streamingText, completedToolCalls, thinking, thinkStartTime, thinkElapsed, steerQueue, running, showBanner, inputHistory, onSubmit, onPermissionCycle, onCancelTurn, onExit, }) {
|
|
11
|
+
const { exit } = useApp();
|
|
12
|
+
const [inputValue, setInputValue] = useState("");
|
|
13
|
+
const [bashMode, setBashMode] = useState(false);
|
|
14
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
15
|
+
const [ctrlCCount, setCtrlCCount] = useState(0);
|
|
16
|
+
const handleSubmit = useCallback((value) => {
|
|
17
|
+
setInputValue("");
|
|
18
|
+
setHistoryIndex(-1);
|
|
19
|
+
if (value === "")
|
|
20
|
+
return;
|
|
21
|
+
// ! at start of empty input toggles bash mode
|
|
22
|
+
if (value === "!" && !bashMode) {
|
|
23
|
+
setBashMode(true);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
onSubmit(bashMode ? `!${value}` : value);
|
|
27
|
+
setBashMode(false);
|
|
28
|
+
}, [bashMode, onSubmit]);
|
|
29
|
+
useKeyboardShortcuts({
|
|
30
|
+
isRunning: running,
|
|
31
|
+
inputValue,
|
|
32
|
+
bashMode,
|
|
33
|
+
inputHistory,
|
|
34
|
+
historyIndex,
|
|
35
|
+
ctrlCCount,
|
|
36
|
+
onSetInput: setInputValue,
|
|
37
|
+
onSetBashMode: setBashMode,
|
|
38
|
+
onSetHistoryIndex: setHistoryIndex,
|
|
39
|
+
onSetCtrlCCount: setCtrlCCount,
|
|
40
|
+
onExit: () => { onExit(); exit(); },
|
|
41
|
+
onCyclePermissions: onPermissionCycle,
|
|
42
|
+
onCancelTurn,
|
|
43
|
+
});
|
|
44
|
+
// Build Static items — banner first, then completed messages
|
|
45
|
+
const staticItems = [];
|
|
46
|
+
if (showBanner) {
|
|
47
|
+
staticItems.push({ id: "banner", kind: "banner" });
|
|
48
|
+
}
|
|
49
|
+
for (const msg of completedMessages) {
|
|
50
|
+
staticItems.push(msg);
|
|
51
|
+
}
|
|
52
|
+
return (_jsxs(_Fragment, { children: [_jsx(Static, { items: staticItems, children: (item) => {
|
|
53
|
+
if (item.kind === "banner") {
|
|
54
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(Banner, { version: state.version }) }, "banner"));
|
|
55
|
+
}
|
|
56
|
+
if (item.kind === "user") {
|
|
57
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { bold: true, children: ["\u276f", " ", item.text] }) }, item.id));
|
|
58
|
+
}
|
|
59
|
+
if (item.kind === "assistant") {
|
|
60
|
+
const aMsg = item;
|
|
61
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 2, children: [aMsg.text ? (_jsxs(Box, { children: [_jsxs(Text, { color: "magenta", children: ["\u25c6", " "] }), _jsx(Text, { children: aMsg.text })] })) : null, aMsg.toolCalls?.map((tc, i) => (_jsx(ToolCall, { ...tc }, i)))] }, item.id));
|
|
62
|
+
}
|
|
63
|
+
if (item.kind === "status") {
|
|
64
|
+
return _jsx(Text, { dimColor: true, children: item.text }, item.id);
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
} }), _jsxs(Box, { flexDirection: "column", children: [completedToolCalls.map((tc, i) => (_jsx(ToolCall, { ...tc }, `tc-${i}`))), streamingText !== "" && (_jsxs(Box, { paddingLeft: 2, children: [_jsxs(Text, { color: "magenta", children: ["\u25c6", " "] }), _jsx(Text, { children: streamingText })] })), thinking && _jsx(ThinkingIndicator, { startTime: thinkStartTime }), thinkElapsed !== null && (_jsxs(Text, { dimColor: true, children: [" ", "\u25c6", " thought for ", thinkElapsed, "s"] })), ctrlCCount > 0 && !running && (_jsxs(Text, { dimColor: true, children: [" ", "Press Ctrl+C again to exit."] })), _jsx(SteerQueue, { items: steerQueue }), _jsx(InputArea, { value: inputValue, onChange: setInputValue, onSubmit: handleSubmit, bashMode: bashMode, focus: !running }), _jsx(PermissionsLine, { mode: state.permMode })] })] }));
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
const _require = createRequire(import.meta.url);
|
|
6
|
+
let cachedArt = null;
|
|
7
|
+
let artLoaded = false;
|
|
8
|
+
function getArtLines() {
|
|
9
|
+
if (artLoaded)
|
|
10
|
+
return cachedArt ?? [];
|
|
11
|
+
artLoaded = true;
|
|
12
|
+
try {
|
|
13
|
+
const mod = _require("@phren/cli/phren-art");
|
|
14
|
+
cachedArt = mod.PHREN_ART.filter((l) => l.trim());
|
|
15
|
+
return cachedArt;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function stripAnsi(t) {
|
|
22
|
+
return t.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
23
|
+
}
|
|
24
|
+
export function Banner({ version }) {
|
|
25
|
+
const cwd = process.cwd().replace(os.homedir(), "~");
|
|
26
|
+
const artLines = getArtLines();
|
|
27
|
+
const maxArtWidth = 26;
|
|
28
|
+
const infoLines = [
|
|
29
|
+
{ key: "title", node: _jsx(Text, { bold: true, color: "magenta", children: "◆ phren" }) },
|
|
30
|
+
{ key: "version", node: _jsx(Text, { dimColor: true, children: " v" + version }) },
|
|
31
|
+
{ key: "cwd", node: _jsx(Text, { dimColor: true, children: cwd }) },
|
|
32
|
+
];
|
|
33
|
+
if (artLines.length > 0) {
|
|
34
|
+
const rowCount = Math.max(artLines.length, infoLines.length);
|
|
35
|
+
return (_jsxs(Box, { flexDirection: "column", children: [Array.from({ length: rowCount }, (_, i) => {
|
|
36
|
+
const artLine = i < artLines.length ? artLines[i] : "";
|
|
37
|
+
const artVisible = stripAnsi(artLine).length;
|
|
38
|
+
const padding = Math.max(0, maxArtWidth - artVisible);
|
|
39
|
+
const info = i < infoLines.length ? infoLines[i] : null;
|
|
40
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: artLine + " ".repeat(padding) }), info ? info.node : _jsx(Text, { children: "" })] }, i));
|
|
41
|
+
}), _jsx(Text, { children: "" })] }));
|
|
42
|
+
}
|
|
43
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { children: [infoLines[0].node, infoLines[1].node] }), infoLines[2].node, _jsx(Text, { children: "" })] }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { formatToolInput, formatDuration } from "../tool-render.js";
|
|
4
|
+
const COMPACT_LINES = 3;
|
|
5
|
+
export function ChatMessage({ role, text, toolCalls }) {
|
|
6
|
+
if (role === "user") {
|
|
7
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, children: [_jsx(Text, { bold: true, children: "You: " }), _jsx(Text, { children: text })] }));
|
|
8
|
+
}
|
|
9
|
+
if (role === "system") {
|
|
10
|
+
return (_jsx(Box, { marginBottom: 0, children: _jsx(Text, { dimColor: true, children: text }) }));
|
|
11
|
+
}
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, children: [_jsx(Text, { children: text }), toolCalls?.map((tc, i) => (_jsx(ToolCallLine, { ...tc }, i)))] }));
|
|
13
|
+
}
|
|
14
|
+
function ToolCallLine({ name, input, output, isError, durationMs }) {
|
|
15
|
+
const preview = formatToolInput(name, input);
|
|
16
|
+
const dur = formatDuration(durationMs);
|
|
17
|
+
const icon = isError ? "✗" : "→";
|
|
18
|
+
const iconColor = isError ? "red" : "green";
|
|
19
|
+
const allLines = output.split("\n").filter(Boolean);
|
|
20
|
+
const shown = allLines.slice(0, COMPACT_LINES);
|
|
21
|
+
const overflow = allLines.length - COMPACT_LINES;
|
|
22
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [" ", _jsx(Text, { color: iconColor, children: icon }), " ", _jsx(Text, { bold: true, children: name }), " ", _jsx(Text, { color: "gray", children: preview }), " ", _jsx(Text, { dimColor: true, children: dur })] }), shown.map((line, j) => (_jsxs(Text, { dimColor: true, children: [" ", line.slice(0, (process.stdout.columns || 80) - 6)] }, j))), overflow > 0 && _jsxs(Text, { dimColor: true, children: [" ", "... +", overflow, " lines"] })] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useStdout } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
import { PERMISSION_LABELS, PERMISSION_ICONS } from "../ansi.js";
|
|
5
|
+
export function InputArea({ value, onChange, onSubmit, bashMode, focus }) {
|
|
6
|
+
const { stdout } = useStdout();
|
|
7
|
+
const columns = stdout?.columns || 80;
|
|
8
|
+
const sep = "\u2500".repeat(columns);
|
|
9
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: sep }), _jsxs(Box, { children: [bashMode
|
|
10
|
+
? _jsx(Text, { color: "yellow", children: "! " })
|
|
11
|
+
: _jsxs(Text, { dimColor: true, children: ["\u25b8", " "] }), _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit, focus: focus, showCursor: true })] }), _jsx(Text, { dimColor: true, children: sep })] }));
|
|
12
|
+
}
|
|
13
|
+
const PERM_COLOR_MAP = {
|
|
14
|
+
"suggest": "cyan",
|
|
15
|
+
"auto-confirm": "green",
|
|
16
|
+
"full-auto": "yellow",
|
|
17
|
+
};
|
|
18
|
+
export function PermissionsLine({ mode }) {
|
|
19
|
+
const icon = PERMISSION_ICONS[mode];
|
|
20
|
+
const label = PERMISSION_LABELS[mode];
|
|
21
|
+
const color = PERM_COLOR_MAP[mode];
|
|
22
|
+
return (_jsxs(Text, { children: [" ", _jsxs(Text, { color: color, children: [icon, " ", label, " permissions"] }), " ", _jsxs(Text, { dimColor: true, children: ["(shift+tab toggle ", "\u00b7", " esc to interrupt)"] })] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text, useStdout } from "ink";
|
|
3
|
+
export function Separator() {
|
|
4
|
+
const { stdout } = useStdout();
|
|
5
|
+
const columns = stdout?.columns || 80;
|
|
6
|
+
return _jsx(Text, { dimColor: true, children: "\u2500".repeat(columns) });
|
|
7
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text, useStdout } from "ink";
|
|
3
|
+
import { PERMISSION_LABELS } from "../ansi.js";
|
|
4
|
+
export function StatusBar({ provider, project, turns, cost, permMode, agentCount }) {
|
|
5
|
+
const { stdout } = useStdout();
|
|
6
|
+
const width = stdout?.columns || 80;
|
|
7
|
+
const modeLabel = permMode ? PERMISSION_LABELS[permMode] : "";
|
|
8
|
+
const agentTag = agentCount && agentCount > 0 ? `A${agentCount}` : "";
|
|
9
|
+
const leftParts = [" \u25c6 phren", provider];
|
|
10
|
+
if (project)
|
|
11
|
+
leftParts.push(project);
|
|
12
|
+
const left = leftParts.join(" \u00b7 ");
|
|
13
|
+
const rightParts = [];
|
|
14
|
+
if (modeLabel)
|
|
15
|
+
rightParts.push(modeLabel);
|
|
16
|
+
if (agentTag)
|
|
17
|
+
rightParts.push(agentTag);
|
|
18
|
+
if (cost)
|
|
19
|
+
rightParts.push(cost);
|
|
20
|
+
rightParts.push(`T${turns}`);
|
|
21
|
+
const right = rightParts.join(" ") + " ";
|
|
22
|
+
const pad = Math.max(0, width - left.length - right.length);
|
|
23
|
+
const fullLine = left + " ".repeat(pad) + right;
|
|
24
|
+
return _jsx(Text, { inverse: true, children: fullLine });
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
export function SteerQueue({ items }) {
|
|
4
|
+
if (items.length === 0)
|
|
5
|
+
return null;
|
|
6
|
+
return (_jsx(Box, { flexDirection: "column", children: items.map((item, i) => (_jsxs(Text, { color: "yellow", children: [" ", "\u21B3 steer: ", item.slice(0, 60)] }, i))) }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useMemo } from "react";
|
|
3
|
+
import { Text } from "ink";
|
|
4
|
+
// Phren's own thinking verbs — memory-oriented, not generic
|
|
5
|
+
const THINKING_VERBS = [
|
|
6
|
+
"thinking", "reasoning", "recalling", "connecting", "processing",
|
|
7
|
+
];
|
|
8
|
+
export function ThinkingIndicator({ startTime }) {
|
|
9
|
+
const [frame, setFrame] = useState(0);
|
|
10
|
+
// Pick a random verb once per mount
|
|
11
|
+
const verb = useMemo(() => THINKING_VERBS[Math.floor(Math.random() * THINKING_VERBS.length)], []);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const timer = setInterval(() => {
|
|
14
|
+
setFrame((f) => f + 1);
|
|
15
|
+
}, 50);
|
|
16
|
+
return () => clearInterval(timer);
|
|
17
|
+
}, []);
|
|
18
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
19
|
+
// Gentle sine-wave interpolation between phren purple and cyan
|
|
20
|
+
const t = (Math.sin(frame * 0.08) + 1) / 2;
|
|
21
|
+
const r = Math.round(155 * (1 - t) + 40 * t);
|
|
22
|
+
const g = Math.round(140 * (1 - t) + 211 * t);
|
|
23
|
+
const b = Math.round(250 * (1 - t) + 242 * t);
|
|
24
|
+
const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
25
|
+
return (_jsxs(Text, { children: [" ", _jsxs(Text, { color: hex, children: ["\u25c6", " ", verb] }), " ", _jsxs(Text, { dimColor: true, children: [elapsed, "s"] })] }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { formatToolInput, formatDuration, COMPACT_LINES } from "../tool-render.js";
|
|
4
|
+
export function ToolCall({ name, input, output, isError, durationMs }) {
|
|
5
|
+
const preview = formatToolInput(name, input);
|
|
6
|
+
const dur = formatDuration(durationMs);
|
|
7
|
+
const allLines = output.split("\n").filter(Boolean);
|
|
8
|
+
const shown = allLines.slice(0, COMPACT_LINES);
|
|
9
|
+
const overflow = allLines.length - COMPACT_LINES;
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { children: [_jsxs(Text, { color: isError ? "red" : "green", children: [isError ? "\u2717" : "\u2192", " "] }), _jsx(Text, { bold: true, children: name }), _jsxs(Text, { color: "gray", children: [" ", preview] }), _jsxs(Text, { dimColor: true, children: [" ", dur] })] }), shown.map((line, i) => (_jsx(Text, { dimColor: true, children: " " + line.slice(0, 120) }, i))), overflow > 0 && (_jsx(Text, { dimColor: true, children: " ... +" + overflow + " lines" }))] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
export function UserMessage({ text }) {
|
|
4
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["❯", " ", text] }), _jsx(Text, { children: "" })] }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useInput } from "ink";
|
|
2
|
+
const SLASH_COMMANDS = [
|
|
3
|
+
"/help", "/turns", "/clear", "/cwd", "/files", "/cost", "/plan", "/undo",
|
|
4
|
+
"/context", "/model", "/provider", "/preset", "/session", "/history",
|
|
5
|
+
"/compact", "/diff", "/git", "/mem", "/ask", "/spawn", "/agents",
|
|
6
|
+
"/mode", "/exit", "/quit",
|
|
7
|
+
];
|
|
8
|
+
export function useKeyboardShortcuts(opts) {
|
|
9
|
+
useInput((input, key) => {
|
|
10
|
+
// Reset Ctrl+C count on any non-Ctrl+C keypress
|
|
11
|
+
if (!(input === "c" && key.ctrl)) {
|
|
12
|
+
if (opts.ctrlCCount > 0)
|
|
13
|
+
opts.onSetCtrlCCount(0);
|
|
14
|
+
}
|
|
15
|
+
// Ctrl+D -- exit cleanly
|
|
16
|
+
if (key.ctrl && input === "d") {
|
|
17
|
+
opts.onExit();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// Shift+Tab -- cycle permission mode
|
|
21
|
+
if (key.shift && key.tab) {
|
|
22
|
+
opts.onCyclePermissions();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Ctrl+C -- progressive: cancel turn / clear input / warn / quit
|
|
26
|
+
if (key.ctrl && input === "c") {
|
|
27
|
+
if (opts.isRunning) {
|
|
28
|
+
opts.onCancelTurn();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (opts.inputValue) {
|
|
32
|
+
opts.onSetInput("");
|
|
33
|
+
opts.onSetCtrlCCount(0);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (opts.ctrlCCount >= 1) {
|
|
37
|
+
opts.onExit();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
opts.onSetCtrlCCount(opts.ctrlCCount + 1);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Escape -- exit bash mode or clear input
|
|
44
|
+
if (key.escape) {
|
|
45
|
+
if (opts.bashMode) {
|
|
46
|
+
opts.onSetBashMode(false);
|
|
47
|
+
opts.onSetInput("");
|
|
48
|
+
}
|
|
49
|
+
else if (opts.inputValue) {
|
|
50
|
+
opts.onSetInput("");
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// Up arrow -- recall older history
|
|
55
|
+
if (key.upArrow && !opts.isRunning) {
|
|
56
|
+
const history = opts.inputHistory;
|
|
57
|
+
if (history.length === 0)
|
|
58
|
+
return;
|
|
59
|
+
const newIndex = Math.min(opts.historyIndex + 1, history.length - 1);
|
|
60
|
+
opts.onSetHistoryIndex(newIndex);
|
|
61
|
+
opts.onSetInput(history[history.length - 1 - newIndex]);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Down arrow -- recall newer history
|
|
65
|
+
if (key.downArrow && !opts.isRunning) {
|
|
66
|
+
const history = opts.inputHistory;
|
|
67
|
+
if (opts.historyIndex <= 0) {
|
|
68
|
+
opts.onSetHistoryIndex(-1);
|
|
69
|
+
opts.onSetInput("");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const newIndex = opts.historyIndex - 1;
|
|
73
|
+
opts.onSetHistoryIndex(newIndex);
|
|
74
|
+
opts.onSetInput(history[history.length - 1 - newIndex]);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Tab -- slash command completion when input starts with /
|
|
78
|
+
if (key.tab && !key.shift && !opts.isRunning) {
|
|
79
|
+
const val = opts.inputValue;
|
|
80
|
+
if (val.startsWith("/") && val.length > 1) {
|
|
81
|
+
const matches = SLASH_COMMANDS.filter(c => c.startsWith(val));
|
|
82
|
+
if (matches.length === 1) {
|
|
83
|
+
opts.onSetInput(matches[0]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash command handling for the Ink TUI.
|
|
3
|
+
* Captures stderr output from handleCommand and returns it as display text.
|
|
4
|
+
*/
|
|
5
|
+
import { handleCommand } from "../../commands.js";
|
|
6
|
+
export function useSlashCommands(opts) {
|
|
7
|
+
return {
|
|
8
|
+
/** Try to handle input as a slash command. Returns true if handled. */
|
|
9
|
+
tryHandleCommand(input) {
|
|
10
|
+
if (!input.startsWith("/"))
|
|
11
|
+
return false;
|
|
12
|
+
// Capture stderr writes during command execution
|
|
13
|
+
const captured = [];
|
|
14
|
+
const origWrite = process.stderr.write;
|
|
15
|
+
process.stderr.write = ((chunk) => {
|
|
16
|
+
captured.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
|
|
17
|
+
return true;
|
|
18
|
+
});
|
|
19
|
+
try {
|
|
20
|
+
const result = handleCommand(input, opts.commandContext);
|
|
21
|
+
if (result instanceof Promise) {
|
|
22
|
+
// Async command — restore stderr, then capture async output
|
|
23
|
+
process.stderr.write = origWrite;
|
|
24
|
+
result.then(() => {
|
|
25
|
+
// For async commands, stderr was already restored so output went to real stderr.
|
|
26
|
+
// Future improvement: capture async output too.
|
|
27
|
+
});
|
|
28
|
+
flush();
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
if (result === true) {
|
|
32
|
+
flush();
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
// Not a recognized command (result === false)
|
|
36
|
+
flush();
|
|
37
|
+
return true; // Still starts with /, so don't send to agent
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
process.stderr.write = origWrite;
|
|
41
|
+
}
|
|
42
|
+
function flush() {
|
|
43
|
+
if (captured.length > 0) {
|
|
44
|
+
// Strip ANSI dim/reset codes for cleaner display in Ink
|
|
45
|
+
const text = captured.join("").replace(/\n$/, "");
|
|
46
|
+
if (text)
|
|
47
|
+
opts.onOutput(text);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ESC, s, cols, stripAnsi } from "./ansi.js";
|
|
2
|
+
export { PERMISSION_MODES, nextPermissionMode, PERMISSION_LABELS, PERMISSION_ICONS, PERMISSION_COLORS, permTag } from "./ansi.js";
|
|
3
|
+
export { COMPACT_LINES, formatDuration, formatToolInput, renderToolCall } from "./tool-render.js";
|
|
4
|
+
export { loadMenuModule, renderMenu, enterMenuMode, exitMenuMode, handleMenuKeypress } from "./menu-mode.js";
|
|
5
|
+
export { startInkTui } from "./ink-entry.js";
|