@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.
Files changed (42) hide show
  1. package/dist/agent-loop/index.js +214 -0
  2. package/dist/agent-loop/stream.js +124 -0
  3. package/dist/agent-loop/types.js +13 -0
  4. package/dist/agent-loop.js +7 -333
  5. package/dist/commands/info.js +146 -0
  6. package/dist/commands/memory.js +165 -0
  7. package/dist/commands/model.js +138 -0
  8. package/dist/commands/session.js +213 -0
  9. package/dist/commands.js +24 -643
  10. package/dist/index.js +9 -4
  11. package/dist/mcp-client.js +11 -7
  12. package/dist/multi/multi-commands.js +170 -0
  13. package/dist/multi/multi-events.js +81 -0
  14. package/dist/multi/multi-render.js +146 -0
  15. package/dist/multi/pane.js +28 -0
  16. package/dist/multi/tui-multi.js +39 -454
  17. package/dist/permissions/allowlist.js +2 -2
  18. package/dist/providers/anthropic.js +4 -2
  19. package/dist/providers/codex.js +9 -4
  20. package/dist/providers/openai-compat.js +6 -1
  21. package/dist/tools/glob.js +30 -6
  22. package/dist/tui/ansi.js +48 -0
  23. package/dist/tui/components/AgentMessage.js +5 -0
  24. package/dist/tui/components/App.js +68 -0
  25. package/dist/tui/components/Banner.js +44 -0
  26. package/dist/tui/components/ChatMessage.js +23 -0
  27. package/dist/tui/components/InputArea.js +23 -0
  28. package/dist/tui/components/Separator.js +7 -0
  29. package/dist/tui/components/StatusBar.js +25 -0
  30. package/dist/tui/components/SteerQueue.js +7 -0
  31. package/dist/tui/components/StreamingText.js +5 -0
  32. package/dist/tui/components/ThinkingIndicator.js +26 -0
  33. package/dist/tui/components/ToolCall.js +11 -0
  34. package/dist/tui/components/UserMessage.js +5 -0
  35. package/dist/tui/hooks/useKeyboardShortcuts.js +89 -0
  36. package/dist/tui/hooks/useSlashCommands.js +52 -0
  37. package/dist/tui/index.js +5 -0
  38. package/dist/tui/ink-entry.js +287 -0
  39. package/dist/tui/menu-mode.js +86 -0
  40. package/dist/tui/tool-render.js +43 -0
  41. package/dist/tui.js +149 -280
  42. package/package.json +9 -2
@@ -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
- const regex = pattern
7
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
8
- .replace(/\*\*/g, "{{GLOBSTAR}}")
9
- .replace(/\*/g, "[^/]*")
10
- .replace(/{{GLOBSTAR}}/g, ".*");
11
- return new RegExp(`^${regex}$`).test(filePath);
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)
@@ -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,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ export function StreamingText({ text }) {
4
+ return _jsx(Text, { children: text });
5
+ }
@@ -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";