@vietor/easy-agent 0.2.1 → 0.3.1

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.
@@ -1,7 +1,7 @@
1
1
  import { resolveCwd, runRgLines } from "../util/ripgrep.js";
2
2
  const DESCRIPTION = [
3
3
  "List files under a directory, optionally filtered by a glob pattern (e.g. **/*.ts); omit pattern to list every file.",
4
- "Skips node_modules and .git.",
4
+ "Includes hidden files (e.g. .env, .gitignore); skips node_modules and .git.",
5
5
  "Returns paths relative to the root, one per line.",
6
6
  ].join(" ");
7
7
  export const globTool = {
@@ -15,14 +15,14 @@ export const globTool = {
15
15
  },
16
16
  required: [],
17
17
  },
18
- async execute(args) {
18
+ async execute(args, ctx) {
19
19
  const cwd = resolveCwd(args.path);
20
20
  const rgArgs = ["--files"];
21
21
  const pattern = args.pattern;
22
22
  if (pattern)
23
23
  rgArgs.push("-g", pattern);
24
24
  rgArgs.push(".");
25
- const files = await runRgLines(rgArgs, cwd);
25
+ const files = await runRgLines(rgArgs, cwd, ctx.signal);
26
26
  return files.length ? files.join("\n") : "(no matches)";
27
27
  },
28
28
  summaryArg: ["pattern", "path"],
@@ -1,9 +1,9 @@
1
1
  import { resolveCwd, runRgLines } from "../util/ripgrep.js";
2
- const MAX_MATCHES = 200;
2
+ const DEFAULT_HEAD_LIMIT = 200;
3
3
  const DESCRIPTION = [
4
4
  "Search file contents under a directory recursively for a regex pattern (RE2 syntax).",
5
- "Skips node_modules and .git.",
6
- "Returns matching lines as path:line: content, capped at 200 matches.",
5
+ "Includes hidden files (e.g. .env, .gitignore); skips node_modules and .git.",
6
+ "By default returns matching lines as path:line:content, capped at 200 lines; use output_mode, head_limit, and context options to control output.",
7
7
  ].join(" ");
8
8
  export const grepTool = {
9
9
  name: "Grep",
@@ -13,23 +13,60 @@ export const grepTool = {
13
13
  properties: {
14
14
  pattern: { type: "string" },
15
15
  path: { type: "string", description: "root directory, defaults to cwd" },
16
+ glob: { type: "string", description: "glob pattern to filter files (e.g. *.ts)" },
17
+ type: { type: "string", description: "file type to search (e.g. ts, js, py, rust, go)" },
18
+ output_mode: {
19
+ type: "string",
20
+ enum: ["content", "files_with_matches", "count"],
21
+ description: "content (default, matching lines), files_with_matches (file paths only), count (match counts per file)",
22
+ },
23
+ ignore_case: { type: "boolean", description: "case-insensitive match" },
24
+ before: { type: "number", description: "lines to show before each match" },
25
+ after: { type: "number", description: "lines to show after each match" },
26
+ context: { type: "number", description: "lines to show before and after each match" },
27
+ only_matching: { type: "boolean", description: "print only the matched (non-empty) parts" },
28
+ multiline: { type: "boolean", description: "allow patterns to span newlines" },
29
+ head_limit: { type: "number", description: "max output lines (default 200)" },
16
30
  },
17
31
  required: ["pattern"],
18
32
  },
19
- async execute(args) {
33
+ async execute(args, ctx) {
20
34
  const cwd = resolveCwd(args.path);
21
- const rgArgs = [
22
- "--line-number",
23
- "--with-filename",
24
- "--no-heading",
25
- args.pattern,
26
- ".",
27
- ];
28
- const lines = await runRgLines(rgArgs, cwd);
35
+ const rgArgs = ["--line-number", "--with-filename", "--no-heading"];
36
+ if (args.ignore_case)
37
+ rgArgs.push("-i");
38
+ if (args.only_matching)
39
+ rgArgs.push("-o");
40
+ if (args.multiline)
41
+ rgArgs.push("-U", "--multiline-dotall");
42
+ const context = args.context;
43
+ if (context)
44
+ rgArgs.push("-C", String(context));
45
+ else {
46
+ const before = args.before;
47
+ const after = args.after;
48
+ if (before)
49
+ rgArgs.push("-B", String(before));
50
+ if (after)
51
+ rgArgs.push("-A", String(after));
52
+ }
53
+ if (args.glob)
54
+ rgArgs.push("-g", args.glob);
55
+ if (args.type)
56
+ rgArgs.push("-t", args.type);
57
+ const output_mode = args.output_mode || "content";
58
+ if (output_mode === "files_with_matches")
59
+ rgArgs.push("-l");
60
+ else if (output_mode === "count")
61
+ rgArgs.push("-c");
62
+ rgArgs.push(args.pattern, ".");
63
+ const lines = await runRgLines(rgArgs, cwd, ctx.signal);
29
64
  if (!lines.length)
30
65
  return "(no matches)";
31
- if (lines.length > MAX_MATCHES)
32
- return lines.slice(0, MAX_MATCHES).join("\n") + "\n(truncated)";
66
+ const headLimit = args.head_limit || DEFAULT_HEAD_LIMIT;
67
+ if (lines.length > headLimit) {
68
+ return lines.slice(0, headLimit).join("\n") + `\n(${lines.length - headLimit} more matches, truncated)`;
69
+ }
33
70
  return lines.join("\n");
34
71
  },
35
72
  summaryArg: ["pattern", "path"],
@@ -5,6 +5,7 @@ import { fileEditTool } from "./file_edit.js";
5
5
  import { globTool } from "./glob.js";
6
6
  import { grepTool } from "./grep.js";
7
7
  import { webFetchTool } from "./web_fetch.js";
8
+ import { askUserTool } from "./ask_user.js";
8
9
  export class ToolRegistry {
9
10
  tools = new Map();
10
11
  register(tool) {
@@ -20,12 +21,12 @@ export class ToolRegistry {
20
21
  },
21
22
  }));
22
23
  }
23
- async execute(name, args) {
24
+ async execute(name, args, ctx) {
24
25
  const tool = this.tools.get(name);
25
26
  if (!tool)
26
27
  return { content: `Error: unknown tool ${name}`, isError: true };
27
28
  try {
28
- const r = await tool.execute(args);
29
+ const r = await tool.execute(args, ctx);
29
30
  return typeof r === "string" ? { content: r } : r;
30
31
  }
31
32
  catch (e) {
@@ -46,6 +47,6 @@ export class ToolRegistry {
46
47
  }
47
48
  }
48
49
  export function registerBuiltinTools(tools) {
49
- for (const t of [shellTool, fileReadTool, fileWriteTool, fileEditTool, globTool, grepTool, webFetchTool])
50
+ for (const t of [shellTool, fileReadTool, fileWriteTool, fileEditTool, globTool, grepTool, webFetchTool, askUserTool])
50
51
  tools.register(t);
51
52
  }
@@ -22,9 +22,9 @@ export const shellTool = {
22
22
  properties: { command: { type: "string" } },
23
23
  required: ["command"],
24
24
  },
25
- async execute(args) {
25
+ async execute(args, ctx) {
26
26
  const command = args.command;
27
- const r = await runProcess(shell, [...shellArgs, commandPrefix + command]);
27
+ const r = await runProcess(shell, [...shellArgs, commandPrefix + command], {}, ctx.signal);
28
28
  if (r.status === 0 && !r.error) {
29
29
  return r.stdout || "(no output)";
30
30
  }
@@ -76,7 +76,7 @@ function htmlToMarkdown(html) {
76
76
  return turndown.turndown(html);
77
77
  }
78
78
  function mimeFrom(contentType) {
79
- return contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
79
+ return contentType.split(";", 1)[0].trim().toLowerCase();
80
80
  }
81
81
  function isTextualMime(mime) {
82
82
  return (!mime ||
@@ -110,7 +110,7 @@ export const webFetchTool = {
110
110
  },
111
111
  required: ["url"],
112
112
  },
113
- async execute(args) {
113
+ async execute(args, ctx) {
114
114
  const url = args.url;
115
115
  const format = (args.format || "markdown").toLowerCase();
116
116
  let res;
@@ -120,6 +120,7 @@ export const webFetchTool = {
120
120
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
121
121
  },
122
122
  redirect: "follow",
123
+ signal: ctx.signal,
123
124
  });
124
125
  }
125
126
  catch (e) {
package/dist/tui/App.js CHANGED
@@ -3,150 +3,72 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "reac
3
3
  import { Box, render, Text, useApp, useInput } from "ink";
4
4
  import { Markdown } from "./components/Markdown.js";
5
5
  import { LogView } from "./LogView.js";
6
- import { LogStore } from "./LogStore.js";
7
6
  import { AppHeader } from "./AppHeader.js";
8
7
  import { PromptOrCommandInput } from "./PromptOrCommandInput.js";
8
+ import { QuestionView } from "./QuestionView.js";
9
9
  import { Spinner } from "./Spinner.js";
10
10
  import { compactDisplay } from "../util/format.js";
11
11
  const STREAM_FRAME_MS = 240;
12
- export function App({ agent, commands, mcp }) {
12
+ export function App({ session }) {
13
13
  const { exit } = useApp();
14
- const [store] = useState(() => new LogStore());
15
- const log = useSyncExternalStore(store.subscribe, store.getSnapshot);
16
- const [status, setStatus] = useState("idle");
14
+ useSyncExternalStore(session.subscribe, session.getSnapshot);
15
+ const [running, setRunning] = useState(false);
16
+ const [elapsed, setElapsed] = useState(0);
17
+ const [usage, setUsage] = useState({ prompt: 0, completion: 0 });
17
18
  const [, setTick] = useState(0);
18
19
  const streamingRef = useRef("");
19
20
  const renderTimerRef = useRef(undefined);
20
- const abortRef = useRef(null);
21
- const startRef = useRef(0);
22
- const timerRef = useRef(undefined);
23
- const [elapsed, setElapsed] = useState(0);
24
- const [usage, setUsage] = useState({ prompt: 0, completion: 0 });
25
- const allCmds = useMemo(() => commands.schemas(), []);
21
+ const allCmds = useMemo(() => session.commandSchemas, [session]);
22
+ const pendingQuestion = session.logEntries.find((e) => e.kind === "question" && e.answer === null);
26
23
  useEffect(() => {
27
- for (const msg of mcp.flushErrors())
28
- store.append({ kind: "error", text: msg });
29
- mcp.onError = (msg) => store.append({ kind: "error", text: msg });
30
- return () => {
31
- mcp.onError = undefined;
32
- };
24
+ session.setCallbacks({
25
+ onStreaming: (text) => {
26
+ streamingRef.current = text;
27
+ scheduleStreamingRender();
28
+ },
29
+ onRunStateChange: (r) => setRunning(r),
30
+ onElapsedChange: (s) => setElapsed(s),
31
+ onUsageChange: (p, c) => setUsage({ prompt: p, completion: c }),
32
+ });
33
33
  }, []);
34
- const cancelStreamingRender = () => {
35
- if (renderTimerRef.current) {
36
- clearTimeout(renderTimerRef.current);
37
- renderTimerRef.current = undefined;
38
- }
39
- };
40
34
  const scheduleStreamingRender = () => {
41
35
  if (renderTimerRef.current)
42
36
  return;
43
37
  renderTimerRef.current = setTimeout(() => {
44
38
  renderTimerRef.current = undefined;
45
- setStatus("streaming");
46
39
  setTick((t) => t + 1);
47
40
  }, STREAM_FRAME_MS);
48
41
  };
49
- const flushStreaming = () => {
50
- cancelStreamingRender();
51
- if (streamingRef.current) {
52
- store.append({ kind: "assistant", text: streamingRef.current });
53
- streamingRef.current = "";
54
- }
55
- };
56
- const onEvent = (e) => {
57
- if (e.type === "delta") {
58
- streamingRef.current += e.text;
59
- scheduleStreamingRender();
60
- }
61
- else if (e.type === "tool_start") {
62
- flushStreaming();
63
- setStatus("thinking");
64
- store.append({ kind: "tool", id: e.id, name: e.name, summary: e.summary, result: null });
65
- }
66
- else if (e.type === "retry") {
67
- cancelStreamingRender();
68
- streamingRef.current = "";
69
- setStatus("thinking");
70
- store.append({ kind: "retry", attempt: e.attempt, max: e.max });
71
- }
72
- else if (e.type === "tool_end") {
73
- setStatus("thinking");
74
- store.setToolResult(e.id, e.result, e.isError);
75
- }
76
- else if (e.type === "error") {
77
- flushStreaming();
78
- store.append({ kind: "error", text: e.text });
79
- }
80
- else if (e.type === "interrupted") {
81
- flushStreaming();
82
- store.append({ kind: "interrupted" });
83
- }
84
- else if (e.type === "usage") {
85
- setUsage({ prompt: e.promptTokens, completion: e.completionTokens });
86
- }
87
- };
88
42
  useInput((_input, key) => {
43
+ if (pendingQuestion) {
44
+ if (key.ctrl && _input === "c")
45
+ session.abort();
46
+ return;
47
+ }
89
48
  if (key.escape) {
90
- abortRef.current?.abort();
49
+ session.abort();
91
50
  }
92
51
  else if (key.ctrl && _input === "c") {
93
- if (abortRef.current)
94
- abortRef.current.abort();
52
+ if (running)
53
+ session.abort();
95
54
  else
96
55
  exit();
97
56
  }
98
57
  });
99
58
  async function handleCommand(name) {
100
- return await commands.execute(name, { agent, mcp }, {
101
- exit,
102
- clearLog: () => store.clear(),
103
- info: (t) => store.append({ kind: "system", text: t }),
104
- error: (t) => store.append({ kind: "error", text: t }),
105
- thinking: (on) => setStatus(on ? "thinking" : "idle"),
106
- runSkill: (s) => handleSkill(s),
107
- });
108
- }
109
- async function runAgent(entry, run) {
110
- store.append(entry);
111
- setStatus("thinking");
112
- streamingRef.current = "";
113
- startRef.current = Date.now();
114
- setElapsed(0);
115
- setUsage({ prompt: 0, completion: 0 });
116
- timerRef.current = setInterval(() => {
117
- setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
118
- }, 1000);
119
- const controller = new AbortController();
120
- abortRef.current = controller;
121
- try {
122
- await run(controller.signal);
123
- flushStreaming();
124
- }
125
- catch (e) {
126
- flushStreaming();
127
- store.append({ kind: "error", text: e.message });
128
- }
129
- finally {
130
- clearInterval(timerRef.current);
131
- timerRef.current = undefined;
132
- abortRef.current = null;
133
- setStatus("idle");
134
- }
59
+ await session.executeCommand(name, { exit, setRunning });
135
60
  }
136
61
  async function handlePrompt(text) {
137
- if (commands.exists(text)) {
62
+ if (session.isCommand(text)) {
138
63
  await handleCommand(text);
139
64
  }
140
65
  else {
141
- await runAgent({ kind: "user", text }, (signal) => agent.run(text, onEvent, signal));
66
+ await session.startPrompt(text);
142
67
  }
143
68
  }
144
- async function handleSkill(skill) {
145
- await runAgent({ kind: "skill", name: skill.name }, (signal) => agent.runSkill(skill, onEvent, signal));
146
- }
147
- return (_jsxs(Box, { flexDirection: "column", minWidth: 80, children: [_jsx(AppHeader, {}), _jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: log.map((entry, i) => (_jsx(LogView, { entry: entry }, i))) }), status === "streaming" && streamingRef.current ? (_jsx(Box, { paddingLeft: 1, paddingRight: 1, children: _jsx(Markdown, { color: "green", children: streamingRef.current }) })) : null, status === "thinking" ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: "thinking", elapsed: elapsed, promptTokens: usage.prompt, completionTokens: usage.completion }) })) : null, status === "idle" ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["[CTX ", compactDisplay(agent.contextTokens), "] \u00B7 ESC to stop \u00B7 \"/quit\" to leave"] }), _jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })] })) : null] }));
69
+ return (_jsxs(Box, { flexDirection: "column", minWidth: 80, children: [_jsx(AppHeader, {}), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: [session.logEntries.length === 0 ? (_jsx(Box, { paddingTop: 2 })) : null, session.logEntries.map((entry, i) => (_jsx(LogView, { entry: entry }, i)))] }), running && pendingQuestion ? (_jsx(QuestionView, { question: pendingQuestion, onAnswer: (ans) => session.submitAnswer(pendingQuestion.id, ans) })) : running && streamingRef.current ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(Markdown, { color: "green", children: streamingRef.current }) })) : running ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: "thinking", elapsed: elapsed, promptTokens: usage.prompt, completionTokens: usage.completion }) })) : null, !running ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["[CTX ", compactDisplay(session.contextTokens), "] \u00B7 ESC to stop \u00B7 type / for commands"] }), _jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })] })) : null] }));
148
70
  }
149
- export function startApp(agent, commands, mcp) {
150
- process.stdout.write("\u001B[2J\u001B[H");
151
- return render(_jsx(App, { agent: agent, commands: commands, mcp: mcp }), { exitOnCtrlC: false });
71
+ export function startApp(session) {
72
+ process.stdout.write("");
73
+ return render(_jsx(App, { session: session }), { exitOnCtrlC: false });
152
74
  }
@@ -3,5 +3,5 @@ import { Box, Text } from "ink";
3
3
  import { getPackageInfo } from "../util/package.js";
4
4
  export function AppHeader() {
5
5
  const pkginfo = getPackageInfo();
6
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "red", bold: true, children: "Easy Agent" }), _jsxs(Text, { dimColor: true, children: [" v", pkginfo.version] })] }), _jsx(Text, { dimColor: true, children: process.cwd() })] }));
6
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "red", bold: true, children: "Easy Agent" }), _jsxs(Text, { dimColor: true, children: [" v", pkginfo.version] })] }), _jsx(Text, { dimColor: true, children: process.cwd() })] }), _jsx(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray" })] }));
7
7
  }
@@ -14,20 +14,24 @@ function preview(isError, text) {
14
14
  export const LogView = memo(function Entry({ entry }) {
15
15
  switch (entry.kind) {
16
16
  case "user":
17
- return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: `❯ ${entry.text}` }) }));
17
+ return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "\u276F " }), entry.text] }) }));
18
18
  case "skill":
19
- return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "magenta", children: `◈ skill: ${entry.name}` }) }));
19
+ return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "magenta", children: "\u25C8 " }), _jsx(Text, { dimColor: true, children: "skill " }), _jsx(Text, { color: "magenta", bold: true, children: entry.name })] }) }));
20
20
  case "assistant":
21
21
  return (_jsx(Box, { marginTop: 1, children: _jsx(Markdown, { color: "green", children: entry.text }) }));
22
22
  case "tool":
23
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: `● ${entry.name}${entry.summary ? ` ${entry.summary}` : ""}` }), entry.result !== null ? (_jsx(Text, { color: entry.isError ? "red" : "gray", children: ` ${preview(entry.isError ?? false, entry.result)}` })) : null] }));
23
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u25CF " }), _jsx(Text, { color: "yellow", bold: true, children: entry.name }), entry.summary ? _jsxs(Text, { dimColor: true, children: [" ", entry.summary] }) : null] }), entry.result !== null ? (_jsx(Text, { color: entry.isError ? "red" : "gray", children: ` ${preview(entry.isError ?? false, entry.result)}` })) : null] }));
24
24
  case "retry":
25
- return (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: `↻ Retry ${entry.attempt}/${entry.max}` }) }));
25
+ return (_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u21BB " }), _jsxs(Text, { dimColor: true, children: ["retry ", entry.attempt, "/", entry.max] })] }) }));
26
26
  case "error":
27
- return (_jsx(Box, { children: _jsx(Text, { color: "red", children: `✗ ${entry.text}` }) }));
27
+ return (_jsx(Box, { children: _jsxs(Text, { color: "red", children: [_jsx(Text, { bold: true, children: "\u2717 " }), entry.text] }) }));
28
28
  case "interrupted":
29
- return (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "\u25FC interrupted" }) }));
29
+ return (_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u25FC " }), _jsx(Text, { dimColor: true, children: "interrupted" })] }) }));
30
+ case "question":
31
+ if (entry.answer === null)
32
+ return null;
33
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: `? ${entry.text}` }), _jsx(Text, { dimColor: true, children: ` › ${entry.answer || "(skipped)"}` })] }));
30
34
  case "system":
31
- return (_jsx(Box, { children: _jsx(Text, { color: "gray", children: entry.text }) }));
35
+ return (_jsx(Box, { children: _jsx(Text, { color: "blue", children: entry.text }) }));
32
36
  }
33
37
  });
@@ -32,6 +32,16 @@ export function PromptOrCommandInput({ commands, onCommand, onPrompt }) {
32
32
  setInput("");
33
33
  }
34
34
  }, { isActive: showMenu });
35
+ const visibleItems = useMemo(() => {
36
+ if (!showMenu || filtered.length === 0)
37
+ return { start: 0, items: [] };
38
+ const total = filtered.length;
39
+ const half = Math.floor(MAX_ITEMS / 2);
40
+ let start = Math.max(0, selectedIndex - half);
41
+ if (start + MAX_ITEMS > total)
42
+ start = Math.max(0, total - MAX_ITEMS);
43
+ return { start, items: filtered.slice(start, start + MAX_ITEMS) };
44
+ }, [showMenu, filtered, selectedIndex]);
35
45
  const onSubmit = (value) => {
36
46
  const text = value.trim();
37
47
  setInput("");
@@ -46,16 +56,8 @@ export function PromptOrCommandInput({ commands, onCommand, onPrompt }) {
46
56
  }
47
57
  onPrompt(text);
48
58
  };
49
- return (_jsxs(Box, { flexDirection: "column", children: [showMenu && filtered.length > 0 ? (_jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", children: (() => {
50
- const total = filtered.length;
51
- const half = Math.floor(MAX_ITEMS / 2);
52
- let start = Math.max(0, selectedIndex - half);
53
- if (start + MAX_ITEMS > total)
54
- start = Math.max(0, total - MAX_ITEMS);
55
- const visible = filtered.slice(start, start + MAX_ITEMS);
56
- return visible.map((cmd, i) => {
57
- const realIdx = start + i;
58
- return (_jsxs(Box, { children: [_jsxs(Text, { color: realIdx === selectedIndex ? "cyan" : undefined, children: [realIdx === selectedIndex ? "▸ " : " ", "/", cmd.name] }), _jsxs(Text, { dimColor: true, children: [" ", cmd.description] })] }, cmd.name));
59
- });
60
- })() })) : null, _jsxs(Box, { borderStyle: "single", borderLeft: false, borderRight: false, borderColor: "gray", children: [_jsx(Text, { color: "gray", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit })] })] }));
59
+ return (_jsxs(Box, { flexDirection: "column", children: [showMenu && filtered.length > 0 ? (_jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", children: visibleItems.items.map((cmd, i) => {
60
+ const realIdx = visibleItems.start + i;
61
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: realIdx === selectedIndex ? "cyan" : undefined, children: [realIdx === selectedIndex ? "▸ " : " ", "/", cmd.name] }), _jsxs(Text, { dimColor: true, children: [" ", cmd.description] })] }, cmd.name));
62
+ }) })) : null, _jsxs(Box, { borderStyle: "single", borderLeft: false, borderRight: false, borderColor: "gray", children: [_jsx(Text, { color: "gray", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit })] })] }));
61
63
  }
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ const CUSTOM_LABEL = "✎ Custom input";
6
+ export function QuestionView({ question, onAnswer }) {
7
+ const hasOptions = question.options.length > 0;
8
+ const items = hasOptions ? [...question.options, CUSTOM_LABEL] : [];
9
+ const [selected, setSelected] = useState(0);
10
+ const [mode, setMode] = useState(hasOptions ? "select" : "input");
11
+ const [text, setText] = useState("");
12
+ useInput((input, key) => {
13
+ if (mode === "select") {
14
+ if (key.upArrow) {
15
+ setSelected((i) => (i <= 0 ? items.length - 1 : i - 1));
16
+ }
17
+ else if (key.downArrow) {
18
+ setSelected((i) => (i >= items.length - 1 ? 0 : i + 1));
19
+ }
20
+ else if (key.return) {
21
+ if (selected === items.length - 1)
22
+ setMode("input");
23
+ else
24
+ onAnswer(items[selected]);
25
+ }
26
+ else if (key.escape) {
27
+ onAnswer("");
28
+ }
29
+ else if (input && !key.ctrl && !key.meta) {
30
+ setText(input);
31
+ setMode("input");
32
+ }
33
+ }
34
+ else if (key.escape) {
35
+ if (hasOptions)
36
+ setMode("select");
37
+ else
38
+ onAnswer("");
39
+ }
40
+ });
41
+ if (mode === "input") {
42
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { color: "cyan", children: `? ${question.text}` }), _jsxs(Box, { borderStyle: "single", borderLeft: false, borderRight: false, borderColor: "gray", children: [_jsx(Text, { color: "gray", children: "\u276F " }), _jsx(TextInput, { value: text, onChange: setText, onSubmit: () => onAnswer(text) })] })] }));
43
+ }
44
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { color: "cyan", children: `? ${question.text}` }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", children: items.map((item, i) => (_jsx(Box, { children: _jsxs(Text, { color: i === selected ? "cyan" : undefined, children: [i === selected ? "▸ " : " ", item] }) }, item))) })] }));
45
+ }
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { Text } from "ink";
4
4
  import { timeDisplay, compactDisplay } from "../util/format.js";
@@ -9,5 +9,5 @@ export function Spinner({ label, elapsed, promptTokens, completionTokens, }) {
9
9
  const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
10
10
  return () => clearInterval(id);
11
11
  }, []);
12
- return (_jsxs(Text, { color: "gray", children: [SPINNER_FRAMES[frame], " ", label, " \u00B7 ", timeDisplay(elapsed), " \u00B7 \u2191", compactDisplay(promptTokens), " \u00B7 \u2193", compactDisplay(completionTokens)] }));
12
+ return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: SPINNER_FRAMES[frame] }), _jsxs(Text, { children: [" ", label] }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", timeDisplay(elapsed), " \u00B7 \u2191", compactDisplay(promptTokens), " \u00B7 \u2193", compactDisplay(completionTokens)] })] }));
13
13
  }
@@ -1,12 +1,12 @@
1
- import { readFileSync, existsSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  const __dirname = import.meta.dirname;
4
4
  const MAX_PARENT_TRAVERSAL = 10;
5
5
  let _pkg = null;
6
6
  function findPackageJson() {
7
7
  let current = __dirname;
8
8
  for (let i = 0; i < MAX_PARENT_TRAVERSAL; i++) {
9
- const pkgPath = join(current, 'package.json');
9
+ const pkgPath = join(current, "package.json");
10
10
  if (existsSync(pkgPath)) {
11
11
  return pkgPath;
12
12
  }
@@ -15,12 +15,12 @@ function findPackageJson() {
15
15
  break;
16
16
  current = parent;
17
17
  }
18
- throw new Error('Cannot find package.json');
18
+ throw new Error("Cannot find package.json");
19
19
  }
20
20
  export function getPackageInfo() {
21
21
  if (_pkg === null) {
22
22
  const pkgPath = findPackageJson();
23
- _pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
23
+ _pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
24
24
  }
25
25
  return _pkg;
26
26
  }
@@ -1,11 +1,17 @@
1
1
  import { spawn } from "node:child_process";
2
2
  const MAX_BUFFER = 10 * 1024 * 1024;
3
- export function runProcess(cmd, args, opts = {}) {
3
+ export function runProcess(cmd, args, opts = {}, signal) {
4
4
  return new Promise((resolve) => {
5
5
  const child = spawn(cmd, args, {
6
6
  cwd: opts.cwd,
7
7
  stdio: ["ignore", "pipe", "pipe"],
8
8
  });
9
+ const onAbort = () => child.kill();
10
+ if (signal) {
11
+ signal.addEventListener("abort", onAbort, { once: true });
12
+ if (signal.aborted)
13
+ onAbort();
14
+ }
9
15
  const outChunks = [];
10
16
  const errChunks = [];
11
17
  let size = 0;
@@ -21,8 +27,12 @@ export function runProcess(cmd, args, opts = {}) {
21
27
  child.stderr?.on("data", (c) => {
22
28
  errChunks.push(c);
23
29
  });
24
- child.on("error", (error) => resolve({ stdout: "", stderr: "", status: null, error }));
30
+ child.on("error", (error) => {
31
+ signal?.removeEventListener("abort", onAbort);
32
+ resolve({ stdout: "", stderr: "", status: null, error });
33
+ });
25
34
  child.on("close", (status) => {
35
+ signal?.removeEventListener("abort", onAbort);
26
36
  const stdout = Buffer.concat(outChunks).toString("utf-8");
27
37
  const stderr = Buffer.concat(errChunks).toString("utf-8");
28
38
  resolve(overflow
@@ -5,9 +5,9 @@ export function resolveCwd(path) {
5
5
  const root = path || ".";
6
6
  return isAbsolute(root) ? root : join(process.cwd(), root);
7
7
  }
8
- export async function runRgLines(args, cwd) {
9
- const rgArgs = ["--hidden", "--path-separator", "/", "-g", "!.git/**", ...args];
10
- const r = await runProcess(rgPath, rgArgs, { cwd });
8
+ export async function runRgLines(args, cwd, signal) {
9
+ const rgArgs = ["--hidden", "--path-separator", "/", "-g", "!.git/**", "-g", "!node_modules/**", ...args];
10
+ const r = await runProcess(rgPath, rgArgs, { cwd }, signal);
11
11
  if (r.error)
12
12
  throw r.error;
13
13
  if (r.status !== 0 && r.status !== 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vietor/easy-agent",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "cli"
21
21
  ],
22
22
  "author": "",
23
- "license": "ISC",
23
+ "license": "MIT",
24
24
  "packageManager": "pnpm@10.30.3",
25
25
  "dependencies": {
26
26
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,35 +0,0 @@
1
- export class LogStore {
2
- entries = [];
3
- listeners = new Set();
4
- getSnapshot = () => this.entries;
5
- subscribe = (listener) => {
6
- this.listeners.add(listener);
7
- return () => {
8
- this.listeners.delete(listener);
9
- };
10
- };
11
- append(entry) {
12
- this.entries = [...this.entries, entry];
13
- this.emit();
14
- }
15
- clear() {
16
- this.entries = [];
17
- this.emit();
18
- }
19
- setToolResult(id, result, isError) {
20
- for (let i = this.entries.length - 1; i >= 0; i--) {
21
- const entry = this.entries[i];
22
- if (entry.kind === "tool" && entry.id === id && entry.result === null) {
23
- const copy = [...this.entries];
24
- copy[i] = { ...entry, result, isError };
25
- this.entries = copy;
26
- this.emit();
27
- return;
28
- }
29
- }
30
- }
31
- emit() {
32
- for (const listener of this.listeners)
33
- listener();
34
- }
35
- }