@vietor/easy-agent 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @vietor/easy-agent
2
2
 
3
- Terminal-based AI agent CLI with a conversational TUI, powered by `@vietor/easy-agent-core`.
3
+ Terminal-based AI agent CLI with a conversational TUI, powered by `@vietor/agent-core`.
4
4
 
5
5
  ## Requirements
6
6
 
@@ -30,19 +30,20 @@ Create `~/.easy-agent.json` in your home directory:
30
30
  "baseUrl": "https://api.deepseek.com/v1",
31
31
  "apiKey": "your-api-key",
32
32
  "model": "deepseek-v4-flash",
33
- "reasoningEffort": "high"
33
+ "thinkingEffort": "high"
34
34
  }
35
35
  }
36
36
  ```
37
37
 
38
38
  `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.
39
39
 
40
- #### `wireApi` (optional)
40
+ #### `backend` (optional)
41
41
 
42
- Selects the wire protocol the client speaks. Valid values: `"completions"` | `"anthropic"`. Defaults to `"completions"`.
42
+ Selects the wire protocol the client speaks. Valid values: `"completions"` | `"anthropic"` | `"responses"`. Defaults to `"completions"`.
43
43
 
44
- - `"completions"` - OpenAI Chat Completions compatible endpoint (the default). `reasoningEffort` is sent as `reasoning_effort`.
45
- - `"anthropic"` - Anthropic Messages API via the official SDK. Point `baseUrl` at an Anthropic-compatible endpoint (e.g. `https://api.anthropic.com`) and `model` at a Claude model. `reasoningEffort` enables extended thinking (`"high"` = 16k budget, `"max"` = 32k).
44
+ - `"completions"` - OpenAI Chat Completions compatible endpoint (the default). `thinkingEffort` is sent as `reasoning_effort`.
45
+ - `"anthropic"` - Anthropic Messages API via the official SDK. Point `baseUrl` at an Anthropic-compatible endpoint (e.g. `https://api.anthropic.com`) and `model` at a Claude model. `thinkingEffort` enables extended thinking (`"high"` = 16k budget, `"max"` = 32k).
46
+ - `"responses"` - OpenAI Responses API via the official SDK. `thinkingEffort` is sent as `reasoning.effort`, with reasoning summaries streamed to the UI.
46
47
 
47
48
  ```json
48
49
  {
@@ -50,15 +51,15 @@ Selects the wire protocol the client speaks. Valid values: `"completions"` | `"a
50
51
  "baseUrl": "https://api.anthropic.com",
51
52
  "apiKey": "your-api-key",
52
53
  "model": "claude-sonnet-5",
53
- "wireApi": "anthropic",
54
- "reasoningEffort": "high"
54
+ "backend": "anthropic",
55
+ "thinkingEffort": "high"
55
56
  }
56
57
  }
57
58
  ```
58
59
 
59
- #### `reasoningEffort` (optional)
60
+ #### `thinkingEffort` (optional)
60
61
 
61
- Controls the depth of model reasoning. Valid values: `"high"` | `"max"`. Defaults to `"high"`. When set to `"max"` the model spends more tokens on deeper reasoning before responding — useful for complex logic, math, or multi-step analysis. The current setting is shown in the TUI header (e.g. ` · reasoning max`). Requires a model that supports the `reasoning_effort` parameter.
62
+ Controls the depth of model thinking. Valid values: `"high"` | `"max"`. Defaults to `"high"`. When set to `"max"` the model spends more tokens on deeper thinking before responding — useful for complex logic, math, or multi-step analysis. The current setting is shown in the TUI header (e.g. ` · thinking max`). Requires a model that supports the `reasoning_effort` parameter.
62
63
 
63
64
  ### Proxy
64
65
 
@@ -140,9 +141,9 @@ easy-agent --resume # list all saved sessions for this directory
140
141
 
141
142
  Type a prompt and press Enter. The agent streams its reply and calls tools as needed, showing each tool call and a one-line summary of its result. It iterates until the task is done (capped at 50 tool rounds per turn).
142
143
 
143
- The TUI header shows the model name, version, and configured reasoning effort (e.g. `deepseek-v4-flash · reasoning high`). While the agent is running, a spinner displays the thinking and reply elapsed time alongside token counts (e.g. `⠋ thinking · think 2s · reply 0s · ↑1.2k · ↓0.4k`). If the LLM emits extended thinking blocks, a collapsible reasoning panel appears — press <kbd>t</kbd> to expand/collapse it.
144
+ The TUI header shows the model name, version, and configured thinking effort (e.g. `deepseek-v4-flash · thinking high`). While the agent is running, a spinner displays the thinking and reply elapsed time alongside token counts (e.g. `⠋ thinking · think 2s · reply 0s · ↑1.2k · ↓0.4k`). If the LLM emits extended thinking blocks, a collapsible thinking panel appears — press <kbd>t</kbd> to expand/collapse it.
144
145
 
145
- A status bar at the bottom shows the context token usage with a progress bar and percentage, along with keyboard shortcut hints: <kbd>ESC</kbd> to abort a running task, <kbd>t</kbd> to toggle reasoning (when available), and <kbd>/</kbd> for commands.
146
+ A status bar at the bottom shows the context token usage with a progress bar and percentage, along with keyboard shortcut hints: <kbd>ESC</kbd> to abort a running task, <kbd>t</kbd> to toggle thinking (when available), and <kbd>/</kbd> for commands.
146
147
 
147
148
  ### Built-in tools
148
149
 
@@ -154,7 +155,7 @@ A status bar at the bottom shows the context token usage with a progress bar and
154
155
  - **Grep** — search file contents by regex with `glob`/`type` filters, context lines, case-insensitive, and `files_with_matches`/`count` output modes.
155
156
  - **WebFetch** — fetch a URL as markdown or text.
156
157
  - **AskUser** — ask the user a question and wait for their answer.
157
- - **TodoWrite** — track multi-step work as a task list (pending / in_progress / completed), shown live as a panel in the TUI.
158
+ - **TodoWrite** — track multi-step work as a task list (pending / inProgress / completed), shown live as a panel in the TUI.
158
159
  - **SubAgent** — delegate investigation (`explore`) or implementation-planning (`plan`) subtasks to a nested read-only sub-agent.
159
160
 
160
161
  ### Slash commands
@@ -165,8 +166,8 @@ A status bar at the bottom shows the context token usage with a progress bar and
165
166
  | `/clear` | Reset the conversation |
166
167
  | `/compact` | Compress the conversation into a summary to free context |
167
168
  | `/skill` | List available skills |
168
- | `/export` | Save the current conversation to `conversation-{timestamp}.jsonl` |
169
- | `/quit` or `/exit` | Leave the app |
169
+ | `/save` | Save the current session to `session-{timestamp}.jsonl` |
170
+ | `/exit` | Leave the app |
170
171
 
171
172
  ## Build from source
172
173
 
@@ -1,7 +1,7 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  export const clearCommand = {
3
3
  name: "clear",
4
- description: "Clear the conversation and log",
4
+ description: "Clear the session and log",
5
5
  async execute(ctx) {
6
6
  ctx.session.clear();
7
7
  },
@@ -48,30 +48,23 @@ export const exitCommand = {
48
48
  name: "exit",
49
49
  description: "Exit the conversation",
50
50
  async execute(ctx) {
51
- ctx.session.localStore.set("exitRequested", true);
51
+ ctx.requestExit();
52
52
  },
53
53
  };
54
- export const exportCommand = {
55
- name: "export",
56
- description: "Export the conversation to a JSONL file",
54
+ export const saveCommand = {
55
+ name: "save",
56
+ description: "Save the session to a JSONL file",
57
57
  async execute(ctx) {
58
58
  const d = new Date();
59
59
  const pad = (n) => String(n).padStart(2, "0");
60
60
  const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
61
- const file = `conversation-${ts}.jsonl`;
61
+ const file = `session-${ts}.jsonl`;
62
62
  const lines = ctx.session
63
63
  .export()
64
64
  .map((m) => JSON.stringify(m))
65
65
  .join("\n");
66
66
  writeFileSync(file, lines + "\n", "utf-8");
67
- ctx.message(`exported to ${file}`);
68
- },
69
- };
70
- export const quitCommand = {
71
- name: "quit",
72
- description: "Exit the conversation",
73
- async execute(ctx) {
74
- ctx.session.localStore.set("exitRequested", true);
67
+ ctx.message(`saved to ${file}`);
75
68
  },
76
69
  };
77
70
  export const builtinCommands = [
@@ -80,6 +73,5 @@ export const builtinCommands = [
80
73
  compactCommand,
81
74
  skillCommand,
82
75
  exitCommand,
83
- quitCommand,
84
- exportCommand,
76
+ saveCommand,
85
77
  ];
@@ -1,29 +1,32 @@
1
- import { errorMessage } from "@vietor/easy-agent-core";
1
+ import { toErrorMessage } from "@vietor/agent-core/util";
2
2
  import { builtinCommands } from "./builtin.js";
3
3
  const commands = new Map(builtinCommands.map((c) => [c.name, c]));
4
- export async function executeCommand(name, session) {
4
+ export async function executeSlashCommand(name, session, requestExit, persist) {
5
5
  const cmd = commands.get(name);
6
6
  if (cmd) {
7
- const ctx = { session, message: session.timelineNotice, error: session.timelineError };
7
+ const ctx = { session, message: session.addNotice, error: session.addError, requestExit };
8
8
  try {
9
9
  await cmd.execute(ctx);
10
10
  }
11
11
  catch (e) {
12
- ctx.error(errorMessage(e));
12
+ ctx.error(toErrorMessage(e));
13
13
  }
14
+ persist();
14
15
  return;
15
16
  }
16
17
  try {
17
- if (await session.runSkill(name))
18
+ if (await session.runSkill(name)) {
19
+ persist();
18
20
  return;
21
+ }
19
22
  }
20
23
  catch (e) {
21
- session.timelineError(errorMessage(e));
24
+ session.addError(toErrorMessage(e));
22
25
  return;
23
26
  }
24
- session.timelineError(`unknown command: /${name}`);
27
+ session.addError(`unknown command: /${name}`);
25
28
  }
26
- export function commandSchemas(session) {
29
+ export function slashCommandInfos(session) {
27
30
  const map = new Map();
28
31
  for (const c of builtinCommands)
29
32
  map.set(c.name, { name: c.name, description: c.description });
package/dist/config.js CHANGED
@@ -2,19 +2,20 @@ import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { z } from "zod";
5
- const CONFIG_FILE = ".easy-agent.json";
5
+ import { DEFAULT_BACKEND, DEFAULT_MAX_INPUT_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_THINKING_EFFORT } from "@vietor/agent-core/util";
6
+ const CONFIG_PATH = ".easy-agent.json";
6
7
  const LLMConfigSchema = z.object({
7
8
  baseUrl: z.string(),
8
9
  apiKey: z.string(),
9
10
  model: z.string(),
10
- reasoningEffort: z.enum(["high", "max"]).default("high"),
11
- wireApi: z.enum(["completions", "anthropic"]).default("completions"),
12
- maxInputTokens: z.number().int().positive().default(1_000_000),
13
- maxOutputTokens: z.number().int().positive().default(128_000),
11
+ thinkingEffort: z.enum(["high", "max"]).default(DEFAULT_THINKING_EFFORT),
12
+ backend: z.enum(["completions", "anthropic", "responses"]).default(DEFAULT_BACKEND),
13
+ maxInputTokens: z.number().int().positive().default(DEFAULT_MAX_INPUT_TOKENS),
14
+ maxOutputTokens: z.number().int().positive().default(DEFAULT_MAX_OUTPUT_TOKENS),
14
15
  });
15
16
  const MCPServerConfigSchema = z.union([
16
17
  z.object({
17
- type: z.literal("stdio").optional(),
18
+ type: z.literal("stdio").default("stdio"),
18
19
  command: z.string(),
19
20
  args: z.array(z.string()).optional(),
20
21
  env: z.record(z.string(), z.string()).optional(),
@@ -32,27 +33,27 @@ const ConfigSchema = z.object({
32
33
  mcpServers: z.record(z.string(), MCPServerConfigSchema).optional(),
33
34
  });
34
35
  export function loadConfig() {
35
- const path = join(homedir(), CONFIG_FILE);
36
+ const path = join(homedir(), CONFIG_PATH);
36
37
  let raw;
37
38
  try {
38
39
  raw = readFileSync(path, "utf-8");
39
40
  }
40
41
  catch {
41
- throw new Error(`Config not found: create ~/${CONFIG_FILE} (see README for format).`);
42
+ throw new Error(`Config not found: create ~/${CONFIG_PATH} (see README for format).`);
42
43
  }
43
44
  let parsed;
44
45
  try {
45
46
  parsed = JSON.parse(raw);
46
47
  }
47
48
  catch {
48
- throw new Error(`Invalid JSON in ~/${CONFIG_FILE}.`);
49
+ throw new Error(`Invalid JSON in ~/${CONFIG_PATH}.`);
49
50
  }
50
51
  const result = ConfigSchema.safeParse(parsed);
51
52
  if (!result.success) {
52
53
  const issues = result.error.issues
53
54
  .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
54
55
  .join("\n ");
55
- throw new Error(`Invalid config ~/${CONFIG_FILE}:\n ${issues}`);
56
+ throw new Error(`Invalid config ~/${CONFIG_PATH}:\n ${issues}`);
56
57
  }
57
58
  return result.data;
58
59
  }
package/dist/main.js CHANGED
@@ -3,10 +3,11 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { Command } from "commander";
5
5
  import { loadConfig } from "./config.js";
6
- import { tryLoadSkills, tryReadFileText, createSession, SYSTEM_PROMPT_BOUNDARY } from "@vietor/easy-agent-core";
6
+ import { createSession, SYSTEM_PROMPT_BOUNDARY, tryLoadSkills } from "@vietor/agent-core";
7
+ import { tryReadFileText } from "@vietor/agent-core/util";
7
8
  import { startApp } from "./tui/app.js";
8
9
  import { getPackageInfo } from "./util/package.js";
9
- import { FileSessionPersistence } from "./session-store.js";
10
+ import { FileSessionPersistence } from "./session-persistence.js";
10
11
  function buildSystemPromptBase(cwd) {
11
12
  return [
12
13
  "You are Easy Agent, an autonomous assistant. You complete tasks by calling tools, inspecting their results, and iterating until the work is done.",
@@ -23,7 +24,15 @@ function buildSystemPromptBase(cwd) {
23
24
  - Trust tool results as ground truth.`,
24
25
  ].join("\n\n");
25
26
  }
26
- async function listSessions(store) {
27
+ function assembleSystemPrompt(cwd) {
28
+ const globalPrompt = tryReadFileText(join(homedir(), ".easy-agent", "AGENTS.md")) ??
29
+ tryReadFileText(join(homedir(), ".claude", "CLAUDE.md"));
30
+ const projectPrompt = tryReadFileText(join(cwd, "AGENTS.md")) ?? tryReadFileText(join(cwd, "CLAUDE.md"));
31
+ return [buildSystemPromptBase(cwd), globalPrompt, projectPrompt]
32
+ .filter(Boolean)
33
+ .join(SYSTEM_PROMPT_BOUNDARY);
34
+ }
35
+ async function listSessions(store, name) {
27
36
  const sessions = await store.listSessions();
28
37
  if (!sessions.length) {
29
38
  console.log("No previous sessions found in this directory.");
@@ -34,7 +43,7 @@ async function listSessions(store) {
34
43
  const title = s.title ? ` ${s.title}` : "";
35
44
  console.log(` ${s.id} ${new Date(s.updatedAt).toLocaleString()}${title}`);
36
45
  }
37
- console.log("\nResume with: easy-agent --resume <id>");
46
+ console.log(`\nResume with: ${name} --resume <id>`);
38
47
  }
39
48
  export async function main(argv = []) {
40
49
  const pkg = getPackageInfo();
@@ -50,7 +59,7 @@ export async function main(argv = []) {
50
59
  const cwd = process.cwd();
51
60
  const store = new FileSessionPersistence(cwd);
52
61
  if (opts.resume !== undefined && typeof opts.resume !== "string") {
53
- await listSessions(store);
62
+ await listSessions(store, program.name());
54
63
  return;
55
64
  }
56
65
  const config = loadConfig();
@@ -70,48 +79,49 @@ export async function main(argv = []) {
70
79
  if (!sessionId)
71
80
  sessionId = randomUUID();
72
81
  const globalSkills = tryLoadSkills(join(homedir(), ".easy-agent", "skills")) ?? tryLoadSkills(join(homedir(), ".claude", "skills"));
73
- const globalPrompt = tryReadFileText(join(homedir(), ".easy-agent", "AGENTS.md")) ??
74
- tryReadFileText(join(homedir(), ".claude", "CLAUDE.md"));
75
- const projectPrompt = tryReadFileText(join(cwd, "AGENTS.md")) ?? tryReadFileText(join(cwd, "CLAUDE.md"));
76
- const systemPrompt = [buildSystemPromptBase(cwd), globalPrompt, projectPrompt]
77
- .filter(Boolean)
78
- .join(SYSTEM_PROMPT_BOUNDARY);
82
+ const systemPrompt = assembleSystemPrompt(cwd);
79
83
  const session = await createSession({
80
84
  systemPrompt,
81
- llmConfig: config.llm,
85
+ llm: config.llm,
82
86
  mcpServers: config.mcpServers,
83
87
  skills: globalSkills,
84
- builtinTools: {
88
+ builtInTools: {
85
89
  askUser: true,
86
90
  todoWrite: true,
87
91
  subAgent: true,
88
92
  },
89
93
  cwd: cwd,
90
94
  sessionId,
91
- persistence: store,
95
+ clientInfo: { name: pkg.name, version: pkg.version },
92
96
  });
93
97
  if (resume) {
94
- const restored = await session.restore();
95
- if (!restored) {
98
+ const state = await store.load(sessionId);
99
+ if (!state) {
96
100
  console.error(`Session not found: ${sessionId}`);
97
101
  process.exit(1);
98
102
  }
103
+ session.importState(state);
99
104
  }
105
+ let saveChain = Promise.resolve();
106
+ const persist = () => {
107
+ const state = session.exportState();
108
+ saveChain = saveChain.catch(() => { }).then(() => store.saveAll(sessionId, state));
109
+ };
100
110
  let shuttingDown = false;
101
111
  const shutdown = () => {
102
112
  if (shuttingDown)
103
113
  process.exit(1);
104
114
  shuttingDown = true;
105
115
  session.dispose();
106
- session.flush().catch(() => { }).finally(() => process.exit(0));
116
+ saveChain.catch(() => { }).finally(() => process.exit(0));
107
117
  };
108
118
  process.once("SIGINT", shutdown);
109
119
  process.once("SIGTERM", shutdown);
110
120
  process.stdout.write("");
111
- const app = startApp(session);
121
+ const app = startApp(session, persist);
112
122
  await app.waitUntilExit().finally(async () => {
113
123
  session.dispose();
114
- await session.flush();
115
- console.log(["Resume this session with:", `easy-agent --resume ${sessionId}`].join("\n"));
124
+ await saveChain;
125
+ console.log(["Resume this session with:", `${program.name()} --resume ${sessionId}`].join("\n"));
116
126
  });
117
127
  }
@@ -1,8 +1,12 @@
1
1
  import { appendFile, writeFile } from "node:fs/promises";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, statSync } from "node:fs";
3
+ import { isDeepStrictEqual } from "node:util";
3
4
  import { homedir } from "node:os";
4
5
  import { join } from "node:path";
5
- import { ellipsisText, MAX_SUMMARY_LENGTH } from "@vietor/easy-agent-core";
6
+ import {} from "@vietor/agent-core";
7
+ import { summarizeText } from "@vietor/agent-core/util";
8
+ const MAX_TITLE_LENGTH = 75;
9
+ const MAX_TITLE_SCAN_BYTES = 64 * 1024;
6
10
  function encodeCwd(cwd) {
7
11
  return cwd.replace(/[\/\\:]/g, "-");
8
12
  }
@@ -18,10 +22,22 @@ function parseJsonLines(text) {
18
22
  }
19
23
  return out;
20
24
  }
25
+ function readFilePrefix(path, maxBytes) {
26
+ const fd = openSync(path, "r");
27
+ try {
28
+ const buffer = Buffer.allocUnsafe(maxBytes);
29
+ const n = readSync(fd, buffer, 0, maxBytes, 0);
30
+ return buffer.subarray(0, n).toString("utf-8");
31
+ }
32
+ finally {
33
+ closeSync(fd);
34
+ }
35
+ }
21
36
  export class FileSessionPersistence {
22
37
  cwd;
23
38
  dir;
24
39
  writtenCounts = new Map();
40
+ writtenTodos = new Map();
25
41
  constructor(cwd) {
26
42
  this.cwd = cwd;
27
43
  this.dir = join(homedir(), ".easy-agent", "projects", encodeCwd(cwd));
@@ -46,6 +62,7 @@ export class FileSessionPersistence {
46
62
  todos = r.todos;
47
63
  }
48
64
  this.writtenCounts.set(sessionId, messages.length);
65
+ this.writtenTodos.set(sessionId, todos);
49
66
  return { messages, todos };
50
67
  }
51
68
  async saveAll(sessionId, state) {
@@ -54,8 +71,13 @@ export class FileSessionPersistence {
54
71
  const shrink = state.messages.length < written;
55
72
  const lines = state.messages
56
73
  .slice(shrink ? 0 : written)
57
- .map((m) => JSON.stringify({ t: "message", m }))
58
- .concat([JSON.stringify({ t: "todo", todos: state.todos })]);
74
+ .map((m) => JSON.stringify({ t: "message", m }));
75
+ const lastTodos = this.writtenTodos.get(sessionId);
76
+ if (shrink || lastTodos === undefined || !isDeepStrictEqual(lastTodos, state.todos)) {
77
+ lines.push(JSON.stringify({ t: "todo", todos: state.todos }));
78
+ }
79
+ if (lines.length === 0)
80
+ return;
59
81
  const path = this.file(sessionId);
60
82
  if (shrink) {
61
83
  await writeFile(path, lines.join("\n") + "\n", "utf-8");
@@ -64,6 +86,7 @@ export class FileSessionPersistence {
64
86
  await appendFile(path, lines.join("\n") + "\n", "utf-8");
65
87
  }
66
88
  this.writtenCounts.set(sessionId, state.messages.length);
89
+ this.writtenTodos.set(sessionId, state.todos);
67
90
  }
68
91
  async listSessions() {
69
92
  if (!existsSync(this.dir))
@@ -92,10 +115,10 @@ export class FileSessionPersistence {
92
115
  const first = this.readFirstUser(path);
93
116
  if (!first)
94
117
  return undefined;
95
- return ellipsisText(first, MAX_SUMMARY_LENGTH);
118
+ return summarizeText(first, MAX_TITLE_LENGTH);
96
119
  }
97
120
  readFirstUser(path) {
98
- const first = parseJsonLines(readFileSync(path, "utf-8"))
121
+ const first = parseJsonLines(readFilePrefix(path, MAX_TITLE_SCAN_BYTES))
99
122
  .find((r) => r.t === "message" && r.m && r.m.role === "user" && typeof r.m.content === "string");
100
123
  return first?.m?.content;
101
124
  }
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { memo } from "react";
3
3
  import { Box, Text, useWindowSize } from "ink";
4
4
  import { getPackageInfo } from "../util/package.js";
5
- export const AppHeader = memo(function AppHeader({ cwd, model, reasoningEffort }) {
5
+ export const AppHeader = memo(function AppHeader({ cwd, model, thinkingEffort }) {
6
6
  const { columns } = useWindowSize();
7
7
  const pkg = getPackageInfo();
8
- const reasoning = ` · reasoning ${reasoningEffort}`;
9
- return (_jsxs(Box, { width: columns, paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Easy Agent" }), _jsxs(Text, { dimColor: true, children: [" v", pkg.version] })] }), _jsx(Text, { dimColor: true, children: `${model}${reasoning}` })] }), _jsx(Text, { dimColor: true, children: cwd })] }));
8
+ const thinking = ` · thinking ${thinkingEffort}`;
9
+ return (_jsxs(Box, { width: columns, paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Easy Agent" }), _jsxs(Text, { dimColor: true, children: [" v", pkg.version] })] }), _jsx(Text, { dimColor: true, children: `${model}${thinking}` })] }), _jsx(Text, { dimColor: true, children: cwd })] }));
10
10
  });
package/dist/tui/app.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, 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, useWindowSize } from "ink";
4
- import { createRunState, errorMessage } from "@vietor/easy-agent-core";
5
- import { executeCommand, commandSchemas } from "../commands/dispatch.js";
4
+ import { INITIAL_RUN_METRICS } from "@vietor/agent-core";
5
+ import { toErrorMessage } from "@vietor/agent-core/util";
6
+ import { executeSlashCommand, slashCommandInfos } from "../commands/dispatch.js";
6
7
  import { Markdown } from "./components/markdown.js";
7
8
  import { TimelineView } from "./timeline-view.js";
8
9
  import { TodoView } from "./todo-view.js";
@@ -31,88 +32,89 @@ function useThrottledText(frameMs) {
31
32
  };
32
33
  return { text, append, reset };
33
34
  }
34
- export function App({ session }) {
35
- const { exit } = useApp();
36
- const { columns } = useWindowSize();
37
- const view = useSyncExternalStore(session.subscribe, session.getSnapshot);
38
- const [runState, setRunState] = useState(createRunState);
35
+ function useSessionStream(session) {
36
+ const [runMetrics, setRunMetrics] = useState(INITIAL_RUN_METRICS);
39
37
  const streaming = useThrottledText(STREAM_FRAME_MS);
40
- const reasoning = useThrottledText(STREAM_FRAME_MS);
41
- const [showReasoning, setShowReasoning] = useState(false);
42
- const allCmds = useMemo(() => commandSchemas(session), [session]);
43
- const pendingQuestion = session.getPendingQuestion();
38
+ const thinking = useThrottledText(STREAM_FRAME_MS);
39
+ const [showThinking, setShowThinking] = useState(false);
44
40
  useEffect(() => {
45
- const unsub = session.subscribeEvents((e) => {
41
+ const unsub = session.onEvent((e) => {
46
42
  switch (e.type) {
47
43
  case "assistant_delta":
48
44
  streaming.append(e.text);
49
45
  break;
50
- case "reasoning_delta":
51
- reasoning.append(e.text);
46
+ case "thinking_delta":
47
+ thinking.append(e.text);
52
48
  break;
53
- case "reasoning_clear":
54
- reasoning.reset();
49
+ case "thinking_cleared":
50
+ thinking.reset();
55
51
  break;
56
52
  case "assistant":
57
- streaming.reset();
58
- break;
59
53
  case "retry":
60
54
  case "interrupted":
61
55
  streaming.reset();
62
56
  break;
63
- case "run_state":
64
- setRunState(e);
57
+ case "run_metrics":
58
+ setRunMetrics(e);
65
59
  break;
66
60
  }
67
61
  });
68
62
  return unsub;
69
- }, []);
63
+ }, [session]);
64
+ return { runMetrics, streaming, thinking, showThinking, setShowThinking };
65
+ }
66
+ export function App({ session, persist }) {
67
+ const { exit } = useApp();
68
+ const { columns } = useWindowSize();
69
+ const view = useSyncExternalStore(session.subscribe, session.getSnapshot);
70
+ const { runMetrics, streaming, thinking, showThinking, setShowThinking } = useSessionStream(session);
71
+ const allCmds = useMemo(() => slashCommandInfos(session), [session]);
72
+ const pendingQuestion = session.pendingQuestion;
70
73
  useInput((_input, key) => {
71
74
  if (pendingQuestion) {
72
75
  if (key.ctrl && _input === "c")
73
76
  session.abort();
74
77
  return;
75
78
  }
76
- if (_input === "t" && runState.running && reasoning.text) {
77
- setShowReasoning((v) => !v);
79
+ if (_input === "t" && runMetrics.running && thinking.text) {
80
+ setShowThinking((v) => !v);
78
81
  return;
79
82
  }
80
83
  if (key.escape) {
81
84
  session.abort();
82
85
  }
83
86
  else if (key.ctrl && _input === "c") {
84
- if (runState.running)
87
+ if (runMetrics.running)
85
88
  session.abort();
86
89
  else
87
90
  exit();
88
91
  }
89
92
  });
90
93
  async function handleCommand(name) {
91
- await executeCommand(name, session);
92
- if (session.localStore.get("exitRequested") != null)
93
- exit();
94
+ await executeSlashCommand(name, session, exit, persist);
94
95
  }
95
96
  async function handlePrompt(text) {
96
97
  try {
97
- await session.startPrompt(text);
98
+ await session.prompt(text);
99
+ persist();
98
100
  }
99
101
  catch (e) {
100
- session.timelineError(errorMessage(e));
102
+ session.addError(toErrorMessage(e));
101
103
  }
102
104
  }
103
105
  let runningView = null;
104
- if (runState.running) {
106
+ if (runMetrics.running) {
105
107
  if (pendingQuestion) {
106
108
  runningView = (_jsx(QuestionView, { question: pendingQuestion, onAnswer: (ans) => session.submitAnswer(pendingQuestion.id, ans) }));
107
109
  }
108
110
  else {
109
111
  const spinnerLabel = streaming.text ? "replying" : "working";
110
- runningView = (_jsxs(_Fragment, { children: [reasoning.text ? renderReasoning(reasoning.text, showReasoning) : null, streaming.text ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(Markdown, { children: streaming.text }) })) : null, _jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: spinnerLabel, thinkingElapsed: runState.thinkingElapsed, replyElapsed: runState.replyElapsed, inputTokens: runState.inputTokens, outputTokens: runState.outputTokens }) })] }));
112
+ runningView = (_jsxs(_Fragment, { children: [thinking.text ? renderThinking(thinking.text, showThinking) : null, streaming.text ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(Markdown, { children: streaming.text }) })) : null, _jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: spinnerLabel, thinkingElapsed: runMetrics.thinkingElapsed, replyElapsed: runMetrics.replyElapsed, inputTokens: runMetrics.inputTokens, outputTokens: runMetrics.outputTokens }) })] }));
111
113
  }
112
114
  }
113
- return (_jsxs(Box, { width: columns, flexDirection: "column", children: [_jsx(AppHeader, { cwd: session.cwd, model: session.model, reasoningEffort: session.reasoningEffort }), view.timeline.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: view.timeline.map((entry, i) => (_jsx(TimelineView, { entry: entry }, i))) })) : null, runningView, view.todos.length > 0 ? _jsx(TodoView, { todos: view.todos }) : null, !runState.running ? (_jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })) : null, _jsx(StatusBar, { contextTokens: session.contextTokens, contextLimit: session.compactThreshold, running: runState.running, questionPending: !!pendingQuestion, reasoningAvailable: !!reasoning.text })] }));
115
+ return (_jsxs(Box, { width: columns, flexDirection: "column", children: [_jsx(AppHeader, { cwd: session.cwd, model: session.model, thinkingEffort: session.thinkingEffort }), view.timeline.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: view.timeline.map((entry, i) => (_jsx(TimelineView, { entry: entry }, i))) })) : null, runningView, view.todos.length > 0 ? _jsx(TodoView, { todos: view.todos }) : null, !runMetrics.running ? (_jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })) : null, _jsx(StatusBar, { contextTokens: session.contextTokens, contextLimit: session.contextLimit, running: runMetrics.running, questionPending: !!pendingQuestion, thinkingAvailable: !!thinking.text })] }));
114
116
  }
115
- function renderReasoning(text, expanded) {
117
+ function renderThinking(text, expanded) {
116
118
  const lines = text.split("\n");
117
119
  const firstLine = (lines[0] ?? "").slice(0, 80);
118
120
  if (expanded) {
@@ -121,6 +123,6 @@ function renderReasoning(text, expanded) {
121
123
  const extra = lines.length > 1 ? ` …+${lines.length - 1} lines` : "";
122
124
  return (_jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsxs(Text, { dimColor: true, children: ["\u250A ", firstLine, extra, " (t to expand)"] }) }));
123
125
  }
124
- export function startApp(session) {
125
- return render(_jsx(App, { session: session }), { exitOnCtrlC: false, incrementalRendering: true });
126
+ export function startApp(session, persist) {
127
+ return render(_jsx(App, { session: session, persist: persist }), { exitOnCtrlC: false, incrementalRendering: true });
126
128
  }
@@ -1,7 +1,7 @@
1
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
- import { formatSeconds, formatCompactNumber } from "@vietor/easy-agent-core";
4
+ import { formatCompactNumber, formatDuration } from "@vietor/agent-core/util";
5
5
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
6
  export function Spinner({ label, thinkingElapsed, replyElapsed, inputTokens, outputTokens, }) {
7
7
  const [frame, setFrame] = useState(0);
@@ -9,5 +9,5 @@ export function Spinner({ label, thinkingElapsed, replyElapsed, inputTokens, out
9
9
  const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
10
10
  return () => clearInterval(id);
11
11
  }, []);
12
- return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: SPINNER_FRAMES[frame] }), _jsxs(Text, { children: [" ", label] }), _jsxs(Text, { dimColor: true, children: [" \u00B7 work ", formatSeconds(thinkingElapsed), " \u00B7 reply ", formatSeconds(replyElapsed), " \u00B7 \u2191", formatCompactNumber(inputTokens), " \u00B7 \u2193", formatCompactNumber(outputTokens)] })] }));
12
+ return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: SPINNER_FRAMES[frame] }), _jsxs(Text, { children: [" ", label] }), _jsxs(Text, { dimColor: true, children: [" \u00B7 work ", formatDuration(thinkingElapsed), " \u00B7 reply ", formatDuration(replyElapsed), " \u00B7 \u2191", formatCompactNumber(inputTokens), " \u00B7 \u2193", formatCompactNumber(outputTokens)] })] }));
13
13
  }
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { memo } from "react";
3
3
  import { Box, Text, useWindowSize } from "ink";
4
- import { formatCompactNumber } from "@vietor/easy-agent-core";
5
- export const StatusBar = memo(function StatusBar({ contextTokens, contextLimit, running, questionPending, reasoningAvailable }) {
4
+ import { formatCompactNumber } from "@vietor/agent-core/util";
5
+ export const StatusBar = memo(function StatusBar({ contextTokens, contextLimit, running, questionPending, thinkingAvailable }) {
6
6
  const { columns } = useWindowSize();
7
7
  const pct = Math.min(100, contextLimit > 0 ? Math.round((contextTokens / contextLimit) * 100) : 0);
8
8
  const filled = Math.round((pct / 100) * 10);
@@ -12,7 +12,7 @@ export const StatusBar = memo(function StatusBar({ contextTokens, contextLimit,
12
12
  if (questionPending)
13
13
  hints = "↑↓ select · enter confirm · esc skip";
14
14
  else if (running)
15
- hints = reasoningAvailable ? "esc stop · t reasoning" : "esc stop";
15
+ hints = thinkingAvailable ? "esc stop · t thinking" : "esc stop";
16
16
  else
17
17
  hints = "/ commands";
18
18
  return (_jsxs(Box, { width: columns, paddingX: 1, flexDirection: "row", justifyContent: "space-between", borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: `context ${formatCompactNumber(contextTokens)} ` }), _jsx(Text, { color: ctxColor, children: `▕${bar}▏ ${pct}%` })] }), _jsx(Text, { dimColor: true, children: hints })] }));
@@ -3,7 +3,7 @@ import { memo, useEffect, useState } from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import { Markdown } from "./components/markdown.js";
5
5
  export const TimelineView = memo(function TimelineView({ entry }) {
6
- switch (entry.kind) {
6
+ switch (entry.type) {
7
7
  case "user":
8
8
  return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "> " }), entry.text] }) }));
9
9
  case "skill":
@@ -1,15 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { memo } from "react";
3
3
  import { Box, Text } from "ink";
4
- import {} from "@vietor/easy-agent-core";
4
+ import {} from "@vietor/agent-core";
5
5
  const GLYPHS = {
6
6
  pending: "○",
7
- in_progress: "◐",
7
+ inProgress: "◐",
8
8
  completed: "✓",
9
9
  };
10
10
  const COLORS = {
11
11
  pending: "gray",
12
- in_progress: "yellow",
12
+ inProgress: "yellow",
13
13
  completed: "green",
14
14
  };
15
15
  export const TodoView = memo(function TodoView({ todos }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vietor/easy-agent",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  "dist"
11
11
  ],
12
- "description": "Terminal-based AI agent CLI with conversational TUI (Ink/React) powered by easy-agent-core",
12
+ "description": "Terminal-based AI agent CLI with conversational TUI (Ink/React) powered by agent-core",
13
13
  "license": "MIT",
14
14
  "repository": {
15
15
  "type": "git",
@@ -23,7 +23,7 @@
23
23
  "react": "^19.2.8",
24
24
  "string-width": "^8.2.2",
25
25
  "zod": "^4.4.3",
26
- "@vietor/easy-agent-core": "0.6.2"
26
+ "@vietor/agent-core": "0.7.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.20.1",