@vietor/easy-agent 0.2.1 → 0.4.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.
- package/README.md +42 -31
- package/dist/cli.js +0 -0
- package/dist/cmds/builtin.js +18 -10
- package/dist/config.js +25 -3
- package/dist/main.js +22 -48
- package/dist/tui/App.js +48 -113
- package/dist/tui/AppHeader.js +5 -4
- package/dist/tui/LogList.js +9 -0
- package/dist/tui/LogView.js +11 -7
- package/dist/tui/PromptOrCommandInput.js +15 -16
- package/dist/tui/QuestionView.js +45 -0
- package/dist/tui/Spinner.js +2 -2
- package/dist/tui/TodoView.js +19 -0
- package/package.json +18 -21
- package/dist/cmds/registry.js +0 -30
- package/dist/cmds/types.js +0 -1
- package/dist/core/agent.js +0 -116
- package/dist/core/session.js +0 -75
- package/dist/llm/client.js +0 -74
- package/dist/llm/types.js +0 -1
- package/dist/mcp/client.js +0 -45
- package/dist/mcp/server.js +0 -85
- package/dist/skills/loader.js +0 -43
- package/dist/skills/types.js +0 -1
- package/dist/tools/file_edit.js +0 -39
- package/dist/tools/file_read.js +0 -18
- package/dist/tools/file_write.js +0 -22
- package/dist/tools/glob.js +0 -29
- package/dist/tools/grep.js +0 -36
- package/dist/tools/registry.js +0 -51
- package/dist/tools/shell.js +0 -34
- package/dist/tools/types.js +0 -1
- package/dist/tools/web_fetch.js +0 -140
- package/dist/tui/LogStore.js +0 -35
- package/dist/util/async.js +0 -58
- package/dist/util/fs.js +0 -17
- package/dist/util/package.js +0 -26
- package/dist/util/process.js +0 -33
- package/dist/util/ripgrep.js +0 -17
package/README.md
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vietor/easy-agent
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
[npm]: https://img.shields.io/npm/v/@vietor/easy-agent.svg?style=flat-square
|
|
6
|
-
|
|
7
|
-
An autonomous coding agent in the terminal.
|
|
3
|
+
Terminal-based AI agent CLI with a conversational TUI, powered by `@vietor/easy-agent-core`.
|
|
8
4
|
|
|
9
5
|
## Requirements
|
|
10
6
|
|
|
@@ -12,7 +8,7 @@ An autonomous coding agent in the terminal.
|
|
|
12
8
|
|
|
13
9
|
## Install
|
|
14
10
|
|
|
15
|
-
Install globally
|
|
11
|
+
Install globally:
|
|
16
12
|
|
|
17
13
|
```bash
|
|
18
14
|
npm install -g @vietor/easy-agent
|
|
@@ -31,18 +27,18 @@ Create `~/.easy-agent.json` in your home directory:
|
|
|
31
27
|
```json
|
|
32
28
|
{
|
|
33
29
|
"llm": {
|
|
34
|
-
"baseUrl": "https://api.
|
|
30
|
+
"baseUrl": "https://api.openai.com/v1",
|
|
35
31
|
"apiKey": "your-api-key",
|
|
36
|
-
"model": "
|
|
32
|
+
"model": "gpt-4o"
|
|
37
33
|
}
|
|
38
34
|
}
|
|
39
35
|
```
|
|
40
36
|
|
|
41
37
|
`llm.baseUrl`, `llm.apiKey`, and `llm.model` are all required. Point `baseUrl` at any OpenAI-compatible endpoint (OpenAI, Azure OpenAI, local servers, etc.) and set `model` to a model that endpoint serves.
|
|
42
38
|
|
|
43
|
-
###
|
|
39
|
+
### MCP servers (optional)
|
|
44
40
|
|
|
45
|
-
Add an `mcpServers` map to expose external tools through the Model Context Protocol. Each entry
|
|
41
|
+
Add an `mcpServers` map to expose external tools through the Model Context Protocol. Each entry is either a local process (stdio) or a remote endpoint (Streamable HTTP):
|
|
46
42
|
|
|
47
43
|
```json
|
|
48
44
|
{
|
|
@@ -50,28 +46,29 @@ Add an `mcpServers` map to expose external tools through the Model Context Proto
|
|
|
50
46
|
"mcpServers": {
|
|
51
47
|
"chrome-devtools": {
|
|
52
48
|
"command": "npx",
|
|
53
|
-
"args": [
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
49
|
+
"args": ["chrome-devtools-mcp@latest", "--auto-connect", "--accept-insecure-certs"]
|
|
50
|
+
},
|
|
51
|
+
"api-server": {
|
|
52
|
+
"type": "http",
|
|
53
|
+
"url": "https://mcp.example.com/mcp",
|
|
54
|
+
"headers": { "Authorization": "Bearer your-token" }
|
|
58
55
|
}
|
|
59
56
|
}
|
|
60
57
|
}
|
|
61
58
|
```
|
|
62
59
|
|
|
63
|
-
|
|
60
|
+
A stdio server omits `type` (or sets `"stdio"`); only `command` is required, `args` and `env` are optional. A remote server sets `type` to `"http"` with a required `url`; `headers` is optional and sent with every request (use it for static auth tokens). Either entry accepts an optional `enabled` (defaults to `true`); set `false` to keep a server configured but skip starting it. MCP tools become available to the agent as `MCP__<server>__<tool>`. If a server fails to connect within 30s, it is disabled and the error is shown in the TUI — for stdio servers the captured stderr tail is included to help diagnose startup failures — and the rest keep running.
|
|
64
61
|
|
|
65
|
-
###
|
|
62
|
+
### Agent instructions (optional)
|
|
66
63
|
|
|
67
64
|
Easy Agent reads instructions files and appends them to the system prompt, so you can set persistent rules, conventions, or preferences. It looks in two places:
|
|
68
65
|
|
|
69
|
-
1. **Global** — your home directory, applied to every
|
|
66
|
+
1. **Global** — your home directory, applied to every conversation. Checks `~/.agents/AGENTS.md` first, then `~/.claude/CLAUDE.md`; uses the first one found.
|
|
70
67
|
2. **Project** — your current working directory, applied per-project. Checks `./AGENTS.md` first, then `./CLAUDE.md`; uses the first one found.
|
|
71
68
|
|
|
72
69
|
Both global and project files are loaded and concatenated into the system prompt if they exist.
|
|
73
70
|
|
|
74
|
-
###
|
|
71
|
+
### Skills (optional)
|
|
75
72
|
|
|
76
73
|
Skills are reusable prompts that register themselves as slash commands. Create a subdirectory for each skill under `~/.agents/skills/` (or `~/.claude/skills/`) with a `SKILL.md` file:
|
|
77
74
|
|
|
@@ -104,22 +101,36 @@ Launch the TUI:
|
|
|
104
101
|
easy-agent
|
|
105
102
|
```
|
|
106
103
|
|
|
107
|
-
Type a prompt and press Enter. The agent streams its reply and calls tools as needed, showing each tool call and a one-line preview of its result. It iterates until the task is done (capped at
|
|
104
|
+
Type a prompt and press Enter. The agent streams its reply and calls tools as needed, showing each tool call and a one-line preview of its result. It iterates until the task is done (capped at 50 tool rounds per turn).
|
|
108
105
|
|
|
109
|
-
Built-in tools
|
|
106
|
+
### Built-in tools
|
|
110
107
|
|
|
111
108
|
- **Shell** — run shell commands using this platform's native syntax.
|
|
112
|
-
- **FileRead** — read a file
|
|
109
|
+
- **FileRead** — read a file with line numbers; supports `offset`/`limit` for paging large files.
|
|
113
110
|
- **FileWrite** — create or fully overwrite a file.
|
|
114
|
-
- **FileEdit** — replace
|
|
111
|
+
- **FileEdit** — replace exact matches in a file; `replace_all` for every occurrence.
|
|
115
112
|
- **Glob** — list files, optionally filtered by a glob pattern.
|
|
116
|
-
- **Grep** — search file contents by regex.
|
|
113
|
+
- **Grep** — search file contents by regex with `glob`/`type` filters, context lines, case-insensitive, and `files_with_matches`/`count` output modes.
|
|
117
114
|
- **WebFetch** — fetch a URL as markdown or text.
|
|
115
|
+
- **AskUser** — ask the user a question and wait for their answer.
|
|
116
|
+
- **TodoWrite** — track multi-step work as a task list (pending / in_progress / completed), shown live as a panel in the TUI.
|
|
117
|
+
|
|
118
|
+
### Slash commands
|
|
118
119
|
|
|
119
|
-
|
|
120
|
+
| Command | Description |
|
|
121
|
+
|---------|-------------|
|
|
122
|
+
| `/mcp` | List linked MCP servers, their status, and exposed tools |
|
|
123
|
+
| `/clear` | Reset the conversation |
|
|
124
|
+
| `/compact` | Compress the conversation into a summary to free context |
|
|
125
|
+
| `/export` | Save the current conversation to `conversation-{timestamp}.jsonl` |
|
|
126
|
+
| `/quit` or `/exit` | Leave the app |
|
|
120
127
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
-
|
|
128
|
+
## Build from source
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
git clone <repo-url>
|
|
132
|
+
cd easy-agent
|
|
133
|
+
pnpm install
|
|
134
|
+
pnpm build # build core → CLI
|
|
135
|
+
pnpm --filter @vietor/easy-agent dev # hot-reload dev mode
|
|
136
|
+
```
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/cmds/builtin.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { writeFileSync } from "node:fs";
|
|
2
2
|
export const exitCommand = {
|
|
3
3
|
name: "exit",
|
|
4
|
-
description: "Exit the
|
|
4
|
+
description: "Exit the conversation",
|
|
5
5
|
async execute(_ctx, host) {
|
|
6
6
|
host.exit();
|
|
7
7
|
},
|
|
@@ -9,9 +9,8 @@ export const exitCommand = {
|
|
|
9
9
|
export const clearCommand = {
|
|
10
10
|
name: "clear",
|
|
11
11
|
description: "Clear the conversation and log",
|
|
12
|
-
async execute(ctx
|
|
13
|
-
ctx.
|
|
14
|
-
host.clearLog();
|
|
12
|
+
async execute(ctx) {
|
|
13
|
+
ctx.session.clear();
|
|
15
14
|
},
|
|
16
15
|
};
|
|
17
16
|
export const mcpCommand = {
|
|
@@ -22,7 +21,7 @@ export const mcpCommand = {
|
|
|
22
21
|
const text = servers.length
|
|
23
22
|
? [
|
|
24
23
|
"MCP servers:",
|
|
25
|
-
...servers.map((s) => `❯ ${s.name} ⋅ ${s.status} ∶ ${s.tools.join(", ") || "(no tools)"}`),
|
|
24
|
+
...servers.map((s) => `❯ ${s.name} ⋅ ${s.type} ⋅ ${s.status} ∶ ${s.tools.join(", ") || "(no tools)"}`),
|
|
26
25
|
].join("\n")
|
|
27
26
|
: "No MCP servers linked.";
|
|
28
27
|
host.info(text);
|
|
@@ -34,8 +33,9 @@ export const compactCommand = {
|
|
|
34
33
|
async execute(ctx, host) {
|
|
35
34
|
host.thinking(true);
|
|
36
35
|
try {
|
|
37
|
-
await ctx.
|
|
38
|
-
|
|
36
|
+
const ok = await ctx.session.compact();
|
|
37
|
+
if (ok)
|
|
38
|
+
host.info("context compacted");
|
|
39
39
|
}
|
|
40
40
|
catch (e) {
|
|
41
41
|
host.error(e.message);
|
|
@@ -47,14 +47,14 @@ export const compactCommand = {
|
|
|
47
47
|
};
|
|
48
48
|
export const exportCommand = {
|
|
49
49
|
name: "export",
|
|
50
|
-
description: "Export the
|
|
50
|
+
description: "Export the conversation to a JSONL file",
|
|
51
51
|
async execute(ctx, host) {
|
|
52
52
|
try {
|
|
53
53
|
const d = new Date();
|
|
54
54
|
const pad = (n) => String(n).padStart(2, "0");
|
|
55
55
|
const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
56
|
-
const file = `
|
|
57
|
-
const lines = ctx.
|
|
56
|
+
const file = `conversation-${ts}.jsonl`;
|
|
57
|
+
const lines = ctx.session
|
|
58
58
|
.export()
|
|
59
59
|
.map((m) => JSON.stringify(m))
|
|
60
60
|
.join("\n");
|
|
@@ -66,3 +66,11 @@ export const exportCommand = {
|
|
|
66
66
|
}
|
|
67
67
|
},
|
|
68
68
|
};
|
|
69
|
+
export const builtinCommands = [
|
|
70
|
+
exitCommand,
|
|
71
|
+
{ ...exitCommand, name: "quit" },
|
|
72
|
+
clearCommand,
|
|
73
|
+
mcpCommand,
|
|
74
|
+
compactCommand,
|
|
75
|
+
exportCommand,
|
|
76
|
+
];
|
package/dist/config.js
CHANGED
|
@@ -8,19 +8,41 @@ const LLMConfig = z.object({
|
|
|
8
8
|
apiKey: z.string(),
|
|
9
9
|
model: z.string(),
|
|
10
10
|
});
|
|
11
|
-
const
|
|
11
|
+
const StdioServerConfig = z.object({
|
|
12
|
+
type: z.literal("stdio").optional(),
|
|
12
13
|
command: z.string(),
|
|
13
14
|
args: z.array(z.string()).optional(),
|
|
14
15
|
env: z.record(z.string(), z.string()).optional(),
|
|
16
|
+
enabled: z.boolean().optional(),
|
|
15
17
|
});
|
|
18
|
+
const RemoteServerConfig = z.object({
|
|
19
|
+
type: z.enum(["http"]),
|
|
20
|
+
url: z.string().url(),
|
|
21
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
22
|
+
enabled: z.boolean().optional(),
|
|
23
|
+
});
|
|
24
|
+
const MCPServerConfig = z.union([StdioServerConfig, RemoteServerConfig]);
|
|
16
25
|
const Config = z.object({
|
|
17
26
|
llm: LLMConfig,
|
|
18
27
|
mcpServers: z.record(z.string(), MCPServerConfig).optional(),
|
|
19
28
|
});
|
|
20
29
|
export function loadConfig() {
|
|
21
30
|
const path = join(homedir(), CONFIG_FILE);
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = readFileSync(path, "utf-8");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error(`Config not found: create ~/${CONFIG_FILE} (see README for format).`);
|
|
37
|
+
}
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error(`Invalid JSON in ~/${CONFIG_FILE}.`);
|
|
44
|
+
}
|
|
45
|
+
const result = Config.safeParse(parsed);
|
|
24
46
|
if (!result.success) {
|
|
25
47
|
const issues = result.error.issues
|
|
26
48
|
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
|
package/dist/main.js
CHANGED
|
@@ -1,61 +1,35 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loadConfig } from "./config.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { Agent } from "./core/agent.js";
|
|
7
|
-
import { ToolRegistry, registerBuiltinTools } from "./tools/registry.js";
|
|
8
|
-
import { CommandRegistry, registerBuiltinCommands } from "./cmds/registry.js";
|
|
9
|
-
import { tryLoadSkills } from "./skills/loader.js";
|
|
10
|
-
import { tryReadFileText, readFirstFileContent } from "./util/fs.js";
|
|
11
|
-
import { MCPServers } from "./mcp/server.js";
|
|
4
|
+
import { tryLoadSkills, tryReadFileText, readFirstFileContent, startSession, } from "@vietor/easy-agent-core";
|
|
5
|
+
import { builtinCommands } from "./cmds/builtin.js";
|
|
12
6
|
import { startApp } from "./tui/App.js";
|
|
13
|
-
const SYSTEM_PROMPT_BASE =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- Prefer dedicated tools (FileRead, FileEdit, Glob, Grep) over the Shell tool when they fit the task.
|
|
21
|
-
- Read a file before editing it; make minimal, surgical changes that match the surrounding code style.
|
|
22
|
-
- Reference code as file_path:line_number.
|
|
23
|
-
|
|
24
|
-
Output:
|
|
25
|
-
- Be concise and use GitHub-flavored markdown.
|
|
26
|
-
- State what you did and stop once the task is complete. Report outcomes faithfully, and do not narrate alternatives you will not pursue.`;
|
|
7
|
+
const SYSTEM_PROMPT_BASE = [
|
|
8
|
+
"You are Easy Agent, an autonomous assistant. You complete tasks by calling tools, inspecting their results, and iterating until the work is done.",
|
|
9
|
+
`Output:
|
|
10
|
+
- Be concise and use GitHub-flavored markdown.
|
|
11
|
+
- State what you did and stop once the task is complete; report outcomes faithfully.
|
|
12
|
+
- Do not lay out alternative approaches in prose - if a choice is the user's, use AskUser.`,
|
|
13
|
+
].join("\n\n");
|
|
27
14
|
export async function main() {
|
|
28
15
|
const config = loadConfig();
|
|
29
|
-
const llm = new LLMClient(config.llm);
|
|
30
|
-
const tools = new ToolRegistry();
|
|
31
|
-
registerBuiltinTools(tools);
|
|
32
|
-
const commands = new CommandRegistry();
|
|
33
|
-
registerBuiltinCommands(commands);
|
|
34
|
-
const mcp = new MCPServers();
|
|
35
|
-
mcp
|
|
36
|
-
.connect(config.mcpServers)
|
|
37
|
-
.then((list) => {
|
|
38
|
-
for (const t of list)
|
|
39
|
-
tools.register(t);
|
|
40
|
-
})
|
|
41
|
-
.catch((e) => mcp.report(`MCP connect failed: ${e.message}`));
|
|
42
16
|
const globalSkills = readFirstFileContent([join(homedir(), ".agents", "skills"), join(homedir(), ".claude", "skills")], tryLoadSkills);
|
|
43
|
-
if (globalSkills) {
|
|
44
|
-
globalSkills.forEach((skill) => commands.register({
|
|
45
|
-
name: skill.name,
|
|
46
|
-
description: skill.description ?? skill.name,
|
|
47
|
-
execute: async (_, host) => {
|
|
48
|
-
await host.runSkill(skill);
|
|
49
|
-
},
|
|
50
|
-
}));
|
|
51
|
-
}
|
|
52
17
|
const globalPrompt = readFirstFileContent([join(homedir(), ".agents", "AGENTS.md"), join(homedir(), ".claude", "CLAUDE.md")], tryReadFileText);
|
|
53
18
|
const projectPrompt = readFirstFileContent([join(process.cwd(), "AGENTS.md"), join(process.cwd(), "CLAUDE.md")], tryReadFileText);
|
|
54
19
|
const systemPrompt = [SYSTEM_PROMPT_BASE, globalPrompt, projectPrompt]
|
|
55
20
|
.filter(Boolean)
|
|
56
21
|
.join("\n\n=================\n\n");
|
|
57
|
-
const session =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
22
|
+
const session = await startSession({
|
|
23
|
+
systemPrompt,
|
|
24
|
+
llmConfig: config.llm,
|
|
25
|
+
mcpServers: config.mcpServers,
|
|
26
|
+
skills: globalSkills,
|
|
27
|
+
commands: builtinCommands,
|
|
28
|
+
enableTools: {
|
|
29
|
+
askUser: true,
|
|
30
|
+
todoWrite: true
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const app = startApp(session);
|
|
34
|
+
await app.waitUntilExit().finally(() => session.dispose());
|
|
61
35
|
}
|
package/dist/tui/App.js
CHANGED
|
@@ -2,151 +2,86 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
3
|
import { Box, render, Text, useApp, useInput } from "ink";
|
|
4
4
|
import { Markdown } from "./components/Markdown.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { LogList } from "./LogList.js";
|
|
6
|
+
import { TodoView } from "./TodoView.js";
|
|
7
7
|
import { AppHeader } from "./AppHeader.js";
|
|
8
8
|
import { PromptOrCommandInput } from "./PromptOrCommandInput.js";
|
|
9
|
+
import { QuestionView } from "./QuestionView.js";
|
|
9
10
|
import { Spinner } from "./Spinner.js";
|
|
10
11
|
import { compactDisplay } from "../util/format.js";
|
|
11
|
-
const STREAM_FRAME_MS =
|
|
12
|
-
export function App({
|
|
12
|
+
const STREAM_FRAME_MS = 120;
|
|
13
|
+
export function App({ session }) {
|
|
13
14
|
const { exit } = useApp();
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const [
|
|
17
|
-
const [,
|
|
15
|
+
const version = useSyncExternalStore(session.subscribe, session.getSnapshot);
|
|
16
|
+
const [running, setRunning] = useState(false);
|
|
17
|
+
const [runElapsed, setRunElapsed] = useState(0);
|
|
18
|
+
const [runUsage, setRunUsage] = useState({ prompt: 0, completion: 0 });
|
|
19
|
+
const [streamingText, setStreamingText] = useState("");
|
|
18
20
|
const streamingRef = useRef("");
|
|
19
21
|
const renderTimerRef = useRef(undefined);
|
|
20
|
-
const
|
|
21
|
-
const
|
|
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(), []);
|
|
22
|
+
const allCmds = useMemo(() => session.commandSchemas, [session]);
|
|
23
|
+
const pendingQuestion = useMemo(() => session.logEntries.find((e) => e.kind === "question" && e.answer === null), [session, version]);
|
|
26
24
|
useEffect(() => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
session.setCallbacks({
|
|
26
|
+
onStreaming: (text) => {
|
|
27
|
+
streamingRef.current = text;
|
|
28
|
+
scheduleStreamingRender();
|
|
29
|
+
},
|
|
30
|
+
onRunStateChange: (r) => setRunning(r),
|
|
31
|
+
onRunElapsedChange: (s) => setRunElapsed(s),
|
|
32
|
+
onRunUsageChange: (p, c) => setRunUsage({ prompt: p, completion: c }),
|
|
33
|
+
});
|
|
33
34
|
}, []);
|
|
34
|
-
const cancelStreamingRender = () => {
|
|
35
|
-
if (renderTimerRef.current) {
|
|
36
|
-
clearTimeout(renderTimerRef.current);
|
|
37
|
-
renderTimerRef.current = undefined;
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
35
|
const scheduleStreamingRender = () => {
|
|
41
36
|
if (renderTimerRef.current)
|
|
42
37
|
return;
|
|
43
38
|
renderTimerRef.current = setTimeout(() => {
|
|
44
39
|
renderTimerRef.current = undefined;
|
|
45
|
-
|
|
46
|
-
setTick((t) => t + 1);
|
|
40
|
+
setStreamingText(streamingRef.current);
|
|
47
41
|
}, STREAM_FRAME_MS);
|
|
48
42
|
};
|
|
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
43
|
useInput((_input, key) => {
|
|
44
|
+
if (pendingQuestion) {
|
|
45
|
+
if (key.ctrl && _input === "c")
|
|
46
|
+
session.abort();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
89
49
|
if (key.escape) {
|
|
90
|
-
|
|
50
|
+
session.abort();
|
|
91
51
|
}
|
|
92
52
|
else if (key.ctrl && _input === "c") {
|
|
93
|
-
if (
|
|
94
|
-
|
|
53
|
+
if (running)
|
|
54
|
+
session.abort();
|
|
95
55
|
else
|
|
96
56
|
exit();
|
|
97
57
|
}
|
|
98
58
|
});
|
|
99
59
|
async function handleCommand(name) {
|
|
100
|
-
|
|
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
|
-
}
|
|
60
|
+
await session.executeCommand(name, { exit, setRunning });
|
|
135
61
|
}
|
|
136
62
|
async function handlePrompt(text) {
|
|
137
|
-
if (
|
|
63
|
+
if (session.isCommand(text)) {
|
|
138
64
|
await handleCommand(text);
|
|
139
65
|
}
|
|
140
66
|
else {
|
|
141
|
-
await
|
|
67
|
+
await session.startPrompt(text);
|
|
142
68
|
}
|
|
143
69
|
}
|
|
144
|
-
|
|
145
|
-
|
|
70
|
+
let runningView = null;
|
|
71
|
+
if (running) {
|
|
72
|
+
if (pendingQuestion) {
|
|
73
|
+
runningView = (_jsx(QuestionView, { question: pendingQuestion, onAnswer: (ans) => session.submitAnswer(pendingQuestion.id, ans) }));
|
|
74
|
+
}
|
|
75
|
+
else if (streamingText) {
|
|
76
|
+
runningView = (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(Markdown, { color: "green", children: streamingText }) }));
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
runningView = (_jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: "thinking", elapsed: runElapsed, promptTokens: runUsage.prompt, completionTokens: runUsage.completion }) }));
|
|
80
|
+
}
|
|
146
81
|
}
|
|
147
|
-
return (_jsxs(Box, { flexDirection: "column", minWidth: 80, children: [_jsx(AppHeader, {}), _jsx(
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", minWidth: 80, children: [_jsx(AppHeader, {}), _jsx(LogList, { session: session }), session.todos.length > 0 ? _jsx(TodoView, { todos: session.todos }) : null, runningView, !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
83
|
}
|
|
149
|
-
export function startApp(
|
|
150
|
-
process.stdout.write("
|
|
151
|
-
return render(_jsx(App, {
|
|
84
|
+
export function startApp(session) {
|
|
85
|
+
process.stdout.write("[2J[H");
|
|
86
|
+
return render(_jsx(App, { session: session }), { exitOnCtrlC: false, incrementalRendering: true });
|
|
152
87
|
}
|
package/dist/tui/AppHeader.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { memo } from "react";
|
|
2
3
|
import { Box, Text } from "ink";
|
|
3
|
-
import { getPackageInfo } from "
|
|
4
|
-
export function AppHeader() {
|
|
4
|
+
import { getPackageInfo } from "@vietor/easy-agent-core";
|
|
5
|
+
export const AppHeader = memo(function AppHeader() {
|
|
5
6
|
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() })] }));
|
|
7
|
-
}
|
|
7
|
+
return (_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() })] }));
|
|
8
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useSyncExternalStore } from "react";
|
|
3
|
+
import { Box } from "ink";
|
|
4
|
+
import { LogView } from "./LogView.js";
|
|
5
|
+
export const LogList = memo(function LogList({ session }) {
|
|
6
|
+
useSyncExternalStore(session.subscribe, session.getSnapshot);
|
|
7
|
+
const entries = session.logEntries;
|
|
8
|
+
return (_jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: entries.map((entry, i) => (_jsx(LogView, { entry: entry }, i))) }));
|
|
9
|
+
});
|
package/dist/tui/LogView.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: "
|
|
35
|
+
return (_jsx(Box, { children: _jsx(Text, { color: "blue", children: entry.text }) }));
|
|
32
36
|
}
|
|
33
37
|
});
|
|
@@ -16,10 +16,7 @@ export function PromptOrCommandInput({ commands, onCommand, onPrompt }) {
|
|
|
16
16
|
}, [showMenu, prefix, commands]);
|
|
17
17
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
18
18
|
useEffect(() => {
|
|
19
|
-
|
|
20
|
-
setSelectedIndex(0);
|
|
21
|
-
else if (selectedIndex >= filtered.length)
|
|
22
|
-
setSelectedIndex(Math.max(0, filtered.length - 1));
|
|
19
|
+
setSelectedIndex((prev) => Math.max(0, Math.min(prev, Math.max(0, filtered.length - 1))));
|
|
23
20
|
}, [filtered.length]);
|
|
24
21
|
useInput((_input, key) => {
|
|
25
22
|
if (key.upArrow) {
|
|
@@ -32,6 +29,16 @@ export function PromptOrCommandInput({ commands, onCommand, onPrompt }) {
|
|
|
32
29
|
setInput("");
|
|
33
30
|
}
|
|
34
31
|
}, { isActive: showMenu });
|
|
32
|
+
const visibleItems = useMemo(() => {
|
|
33
|
+
if (!showMenu || filtered.length === 0)
|
|
34
|
+
return { start: 0, items: [] };
|
|
35
|
+
const total = filtered.length;
|
|
36
|
+
const half = Math.floor(MAX_ITEMS / 2);
|
|
37
|
+
let start = Math.max(0, selectedIndex - half);
|
|
38
|
+
if (start + MAX_ITEMS > total)
|
|
39
|
+
start = Math.max(0, total - MAX_ITEMS);
|
|
40
|
+
return { start, items: filtered.slice(start, start + MAX_ITEMS) };
|
|
41
|
+
}, [showMenu, filtered, selectedIndex]);
|
|
35
42
|
const onSubmit = (value) => {
|
|
36
43
|
const text = value.trim();
|
|
37
44
|
setInput("");
|
|
@@ -46,16 +53,8 @@ export function PromptOrCommandInput({ commands, onCommand, onPrompt }) {
|
|
|
46
53
|
}
|
|
47
54
|
onPrompt(text);
|
|
48
55
|
};
|
|
49
|
-
return (_jsxs(Box, { flexDirection: "column", children: [showMenu && filtered.length > 0 ? (_jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", children: (() => {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
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 })] })] }));
|
|
56
|
+
return (_jsxs(Box, { flexDirection: "column", children: [showMenu && filtered.length > 0 ? (_jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", children: visibleItems.items.map((cmd, i) => {
|
|
57
|
+
const realIdx = visibleItems.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
|
+
}) })) : 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
60
|
}
|