@vietor/easy-agent 0.4.12 → 0.5.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 +2 -0
- package/dist/commands/builtin.js +85 -0
- package/dist/commands/dispatch.js +33 -0
- package/dist/commands/types.js +1 -0
- package/dist/config.js +21 -19
- package/dist/main.js +28 -9
- package/dist/tui/{AppHeader.js → app-header.js} +2 -2
- package/dist/tui/{App.js → app.js} +51 -47
- package/dist/tui/components/{Markdown.js → markdown.js} +38 -52
- package/dist/tui/{PromptOrCommandInput.js → prompt-or-command-input.js} +5 -6
- package/dist/tui/{TimelineView.js → timeline-view.js} +2 -2
- package/dist/tui/{TodoView.js → todo-view.js} +2 -2
- package/dist/util/{sessionStore.js → session-store.js} +36 -26
- package/package.json +13 -13
- package/dist/cli.js +0 -6
- package/dist/cmds/builtin.js +0 -29
- /package/dist/tui/{QuestionView.js → question-view.js} +0 -0
- /package/dist/tui/{Spinner.js → spinner.js} +0 -0
- /package/dist/tui/{StatusBar.js → status-bar.js} +0 -0
package/README.md
CHANGED
|
@@ -155,6 +155,7 @@ A status bar at the bottom shows the context token usage with a progress bar and
|
|
|
155
155
|
- **WebFetch** — fetch a URL as markdown or text.
|
|
156
156
|
- **AskUser** — ask the user a question and wait for their answer.
|
|
157
157
|
- **TodoWrite** — track multi-step work as a task list (pending / in_progress / completed), shown live as a panel in the TUI.
|
|
158
|
+
- **SubAgent** — delegate investigation (`explore`) or implementation-planning (`plan`) subtasks to a nested read-only sub-agent.
|
|
158
159
|
|
|
159
160
|
### Slash commands
|
|
160
161
|
|
|
@@ -163,6 +164,7 @@ A status bar at the bottom shows the context token usage with a progress bar and
|
|
|
163
164
|
| `/mcp` | List linked MCP servers, their status, and exposed tools |
|
|
164
165
|
| `/clear` | Reset the conversation |
|
|
165
166
|
| `/compact` | Compress the conversation into a summary to free context |
|
|
167
|
+
| `/skill` | List available skills |
|
|
166
168
|
| `/export` | Save the current conversation to `conversation-{timestamp}.jsonl` |
|
|
167
169
|
| `/quit` or `/exit` | Leave the app |
|
|
168
170
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
export const clearCommand = {
|
|
3
|
+
name: "clear",
|
|
4
|
+
description: "Clear the conversation and log",
|
|
5
|
+
async execute(ctx) {
|
|
6
|
+
ctx.session.clear();
|
|
7
|
+
},
|
|
8
|
+
};
|
|
9
|
+
export const mcpCommand = {
|
|
10
|
+
name: "mcp",
|
|
11
|
+
description: "List linked MCP servers",
|
|
12
|
+
async execute(ctx) {
|
|
13
|
+
const servers = ctx.session.mcpServers;
|
|
14
|
+
const text = servers.length
|
|
15
|
+
? [
|
|
16
|
+
"MCP servers:",
|
|
17
|
+
...servers.map((s) => {
|
|
18
|
+
const base = `❯ ${s.name} ⋅ ${s.type} ⋅ ${s.status} ∶ ${s.tools.join(", ") || "(no tools)"}`;
|
|
19
|
+
return s.error ? `${base}\n ${s.error}` : base;
|
|
20
|
+
}),
|
|
21
|
+
].join("\n")
|
|
22
|
+
: "No MCP servers linked.";
|
|
23
|
+
ctx.message(text);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
export const compactCommand = {
|
|
27
|
+
name: "compact",
|
|
28
|
+
description: "Compact the agent context",
|
|
29
|
+
async execute(ctx) {
|
|
30
|
+
await ctx.session.compact();
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
export const skillCommand = {
|
|
34
|
+
name: "skill",
|
|
35
|
+
description: "List available skills",
|
|
36
|
+
async execute(ctx) {
|
|
37
|
+
const skills = ctx.session.skills;
|
|
38
|
+
const text = skills.length
|
|
39
|
+
? [
|
|
40
|
+
"Skills:",
|
|
41
|
+
...skills.map((s) => `❯ ${s.name} ∶ ${s.description || "no description"}`),
|
|
42
|
+
].join("\n")
|
|
43
|
+
: "No skills available.";
|
|
44
|
+
ctx.message(text);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
export const exitCommand = {
|
|
48
|
+
name: "exit",
|
|
49
|
+
description: "Exit the conversation",
|
|
50
|
+
async execute(ctx) {
|
|
51
|
+
ctx.session.localStore.set("exitRequested", true);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
export const exportCommand = {
|
|
55
|
+
name: "export",
|
|
56
|
+
description: "Export the conversation to a JSONL file",
|
|
57
|
+
async execute(ctx) {
|
|
58
|
+
const d = new Date();
|
|
59
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
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`;
|
|
62
|
+
const lines = ctx.session
|
|
63
|
+
.export()
|
|
64
|
+
.map((m) => JSON.stringify(m))
|
|
65
|
+
.join("\n");
|
|
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);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
export const builtinCommands = [
|
|
78
|
+
clearCommand,
|
|
79
|
+
mcpCommand,
|
|
80
|
+
compactCommand,
|
|
81
|
+
skillCommand,
|
|
82
|
+
exitCommand,
|
|
83
|
+
quitCommand,
|
|
84
|
+
exportCommand,
|
|
85
|
+
];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { builtinCommands } from "./builtin.js";
|
|
2
|
+
const commands = new Map(builtinCommands.map((c) => [c.name, c]));
|
|
3
|
+
export async function executeCommand(name, session) {
|
|
4
|
+
const cmd = commands.get(name);
|
|
5
|
+
if (cmd) {
|
|
6
|
+
const ctx = { session, message: session.timelineNotice, error: session.timelineError };
|
|
7
|
+
try {
|
|
8
|
+
await cmd.execute(ctx);
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
ctx.error(e.message);
|
|
12
|
+
}
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
if (await session.runSkill(name))
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
session.timelineError(e.message);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
session.timelineError(`unknown command: /${name}`);
|
|
24
|
+
}
|
|
25
|
+
export function commandSchemas(session) {
|
|
26
|
+
const map = new Map();
|
|
27
|
+
for (const c of builtinCommands)
|
|
28
|
+
map.set(c.name, { name: c.name, description: c.description });
|
|
29
|
+
for (const s of session.skills)
|
|
30
|
+
if (!map.has(s.name))
|
|
31
|
+
map.set(s.name, { name: s.name, description: s.description ?? s.name });
|
|
32
|
+
return [...map.values()];
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/config.js
CHANGED
|
@@ -3,30 +3,32 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
const CONFIG_FILE = ".easy-agent.json";
|
|
6
|
-
const
|
|
6
|
+
const LLMConfigSchema = z.object({
|
|
7
7
|
baseUrl: z.string(),
|
|
8
8
|
apiKey: z.string(),
|
|
9
9
|
model: z.string(),
|
|
10
10
|
reasoningEffort: z.enum(["high", "max"]).default("high"),
|
|
11
11
|
wireApi: z.enum(["completions", "anthropic"]).default("completions"),
|
|
12
|
+
contextWindow: z.number().int().positive().default(1_000_000),
|
|
12
13
|
});
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
14
|
+
const MCPServerConfigSchema = z.union([
|
|
15
|
+
z.object({
|
|
16
|
+
type: z.literal("stdio").optional(),
|
|
17
|
+
command: z.string(),
|
|
18
|
+
args: z.array(z.string()).optional(),
|
|
19
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
20
|
+
enabled: z.boolean().optional(),
|
|
21
|
+
}),
|
|
22
|
+
z.object({
|
|
23
|
+
type: z.enum(["http"]),
|
|
24
|
+
url: z.string().url(),
|
|
25
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
26
|
+
enabled: z.boolean().optional(),
|
|
27
|
+
}),
|
|
28
|
+
]);
|
|
29
|
+
const ConfigSchema = z.object({
|
|
30
|
+
llm: LLMConfigSchema,
|
|
31
|
+
mcpServers: z.record(z.string(), MCPServerConfigSchema).optional(),
|
|
30
32
|
});
|
|
31
33
|
export function loadConfig() {
|
|
32
34
|
const path = join(homedir(), CONFIG_FILE);
|
|
@@ -44,7 +46,7 @@ export function loadConfig() {
|
|
|
44
46
|
catch {
|
|
45
47
|
throw new Error(`Invalid JSON in ~/${CONFIG_FILE}.`);
|
|
46
48
|
}
|
|
47
|
-
const result =
|
|
49
|
+
const result = ConfigSchema.safeParse(parsed);
|
|
48
50
|
if (!result.success) {
|
|
49
51
|
const issues = result.error.issues
|
|
50
52
|
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
|
package/dist/main.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { Command } from "commander";
|
|
5
6
|
import { loadConfig } from "./config.js";
|
|
6
7
|
import { tryLoadSkills, tryReadFileText, createSession, SYSTEM_PROMPT_BOUNDARY } from "@vietor/easy-agent-core";
|
|
7
|
-
import {
|
|
8
|
-
import { startApp } from "./tui/App.js";
|
|
8
|
+
import { startApp } from "./tui/app.js";
|
|
9
9
|
import { getPackageInfo } from "./util/package.js";
|
|
10
|
-
import { FileSessionPersistence } from "./util/
|
|
10
|
+
import { FileSessionPersistence } from "./util/session-store.js";
|
|
11
11
|
function buildSystemPromptBase(cwd) {
|
|
12
12
|
return [
|
|
13
13
|
"You are Easy Agent, an autonomous assistant. You complete tasks by calling tools, inspecting their results, and iterating until the work is done.",
|
|
@@ -18,8 +18,6 @@ function buildSystemPromptBase(cwd) {
|
|
|
18
18
|
`Environment:
|
|
19
19
|
- Platform: ${process.platform}
|
|
20
20
|
- Working directory: ${cwd}`,
|
|
21
|
-
`Decision making:
|
|
22
|
-
- When a decision belongs to the user, call AskUser and wait for the answer rather than listing options in prose. Ask when there are multiple reasonable approaches, an irreversible or consequential action, or the request is ambiguous; when you have enough to proceed, act without asking.`,
|
|
23
21
|
`Working style:
|
|
24
22
|
- Read relevant code/config before acting; do not guess implementation details or restate files from memory.
|
|
25
23
|
- Make surgical changes and match existing style; do not refactor unrelated code.
|
|
@@ -84,20 +82,41 @@ export async function main(argv = []) {
|
|
|
84
82
|
llmConfig: config.llm,
|
|
85
83
|
mcpServers: config.mcpServers,
|
|
86
84
|
skills: globalSkills,
|
|
87
|
-
commands: builtinCommands,
|
|
88
85
|
builtinTools: {
|
|
89
86
|
askUser: true,
|
|
90
87
|
todoWrite: true,
|
|
88
|
+
skill: true,
|
|
89
|
+
subAgent: true,
|
|
91
90
|
},
|
|
92
91
|
cwd: cwd,
|
|
93
92
|
sessionId,
|
|
94
93
|
persistence: store,
|
|
95
94
|
});
|
|
96
|
-
if (resume)
|
|
97
|
-
await session.restore();
|
|
95
|
+
if (resume) {
|
|
96
|
+
const restored = await session.restore();
|
|
97
|
+
if (!restored) {
|
|
98
|
+
console.error(`Session not found: ${sessionId}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
let shuttingDown = false;
|
|
103
|
+
const shutdown = () => {
|
|
104
|
+
if (shuttingDown)
|
|
105
|
+
process.exit(1);
|
|
106
|
+
shuttingDown = true;
|
|
107
|
+
session.dispose();
|
|
108
|
+
session.flush().catch(() => { }).finally(() => process.exit(0));
|
|
109
|
+
};
|
|
110
|
+
process.once("SIGINT", shutdown);
|
|
111
|
+
process.once("SIGTERM", shutdown);
|
|
98
112
|
const app = startApp(session);
|
|
99
|
-
await app.waitUntilExit().finally(() => {
|
|
113
|
+
await app.waitUntilExit().finally(async () => {
|
|
100
114
|
session.dispose();
|
|
115
|
+
await session.flush();
|
|
101
116
|
console.log(["Resume this session with:", `easy-agent --resume ${sessionId}`].join("\n"));
|
|
102
117
|
});
|
|
103
118
|
}
|
|
119
|
+
main(process.argv.slice(2)).catch((e) => {
|
|
120
|
+
console.error(e);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
|
@@ -4,7 +4,7 @@ import { Box, Text, useWindowSize } from "ink";
|
|
|
4
4
|
import { getPackageInfo } from "../util/package.js";
|
|
5
5
|
export const AppHeader = memo(function AppHeader({ cwd, model, reasoningEffort }) {
|
|
6
6
|
const { columns } = useWindowSize();
|
|
7
|
-
const
|
|
7
|
+
const pkg = getPackageInfo();
|
|
8
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",
|
|
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 })] }));
|
|
10
10
|
});
|
|
@@ -1,47 +1,62 @@
|
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
4
|
+
import { executeCommand, commandSchemas } from "../commands/dispatch.js";
|
|
5
|
+
import { Markdown } from "./components/markdown.js";
|
|
6
|
+
import { TimelineView } from "./timeline-view.js";
|
|
7
|
+
import { TodoView } from "./todo-view.js";
|
|
8
|
+
import { AppHeader } from "./app-header.js";
|
|
9
|
+
import { PromptOrCommandInput } from "./prompt-or-command-input.js";
|
|
10
|
+
import { QuestionView } from "./question-view.js";
|
|
11
|
+
import { Spinner } from "./spinner.js";
|
|
12
|
+
import { StatusBar } from "./status-bar.js";
|
|
12
13
|
const STREAM_FRAME_MS = 120;
|
|
14
|
+
function useThrottledText(frameMs) {
|
|
15
|
+
const [text, setText] = useState("");
|
|
16
|
+
const bufRef = useRef("");
|
|
17
|
+
const timerRef = useRef(undefined);
|
|
18
|
+
const append = (t) => {
|
|
19
|
+
bufRef.current += t;
|
|
20
|
+
if (timerRef.current === undefined) {
|
|
21
|
+
timerRef.current = setTimeout(() => {
|
|
22
|
+
timerRef.current = undefined;
|
|
23
|
+
setText(bufRef.current);
|
|
24
|
+
}, frameMs);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const reset = () => {
|
|
28
|
+
bufRef.current = "";
|
|
29
|
+
setText("");
|
|
30
|
+
};
|
|
31
|
+
return { text, append, reset };
|
|
32
|
+
}
|
|
13
33
|
export function App({ session }) {
|
|
14
34
|
const { exit } = useApp();
|
|
15
35
|
const { columns } = useWindowSize();
|
|
16
36
|
const view = useSyncExternalStore(session.subscribe, session.getSnapshot);
|
|
17
|
-
const [runState, setRunState] = useState({ running: false, elapsed: 0, thinkingElapsed: 0, replyElapsed: 0, inputTokens: 0, outputTokens: 0 });
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const renderTimerRef = useRef(undefined);
|
|
21
|
-
const [reasoningText, setReasoningText] = useState("");
|
|
22
|
-
const reasoningRef = useRef("");
|
|
23
|
-
const reasoningRenderTimerRef = useRef(undefined);
|
|
37
|
+
const [runState, setRunState] = useState(() => ({ running: false, elapsed: 0, thinkingElapsed: 0, replyElapsed: 0, inputTokens: 0, outputTokens: 0 }));
|
|
38
|
+
const streaming = useThrottledText(STREAM_FRAME_MS);
|
|
39
|
+
const reasoning = useThrottledText(STREAM_FRAME_MS);
|
|
24
40
|
const [showReasoning, setShowReasoning] = useState(false);
|
|
25
|
-
const allCmds = useMemo(() => session
|
|
41
|
+
const allCmds = useMemo(() => commandSchemas(session), [session]);
|
|
26
42
|
const pendingQuestion = session.getPendingQuestion();
|
|
27
43
|
useEffect(() => {
|
|
28
44
|
const unsub = session.subscribeEvents((e) => {
|
|
29
45
|
switch (e.type) {
|
|
30
46
|
case "assistant_delta":
|
|
31
|
-
|
|
32
|
-
scheduleStreamingRender();
|
|
47
|
+
streaming.append(e.text);
|
|
33
48
|
break;
|
|
34
49
|
case "reasoning_delta":
|
|
35
|
-
|
|
36
|
-
scheduleReasoningRender();
|
|
50
|
+
reasoning.append(e.text);
|
|
37
51
|
break;
|
|
38
52
|
case "reasoning_clear":
|
|
39
|
-
|
|
40
|
-
setReasoningText("");
|
|
53
|
+
reasoning.reset();
|
|
41
54
|
break;
|
|
42
55
|
case "assistant":
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
streaming.reset();
|
|
57
|
+
break;
|
|
58
|
+
case "retry":
|
|
59
|
+
streaming.reset();
|
|
45
60
|
break;
|
|
46
61
|
case "state":
|
|
47
62
|
setRunState(e);
|
|
@@ -50,29 +65,13 @@ export function App({ session }) {
|
|
|
50
65
|
});
|
|
51
66
|
return unsub;
|
|
52
67
|
}, []);
|
|
53
|
-
const scheduleStreamingRender = () => {
|
|
54
|
-
if (renderTimerRef.current)
|
|
55
|
-
return;
|
|
56
|
-
renderTimerRef.current = setTimeout(() => {
|
|
57
|
-
renderTimerRef.current = undefined;
|
|
58
|
-
setStreamingText(streamingRef.current);
|
|
59
|
-
}, STREAM_FRAME_MS);
|
|
60
|
-
};
|
|
61
|
-
const scheduleReasoningRender = () => {
|
|
62
|
-
if (reasoningRenderTimerRef.current)
|
|
63
|
-
return;
|
|
64
|
-
reasoningRenderTimerRef.current = setTimeout(() => {
|
|
65
|
-
reasoningRenderTimerRef.current = undefined;
|
|
66
|
-
setReasoningText(reasoningRef.current);
|
|
67
|
-
}, STREAM_FRAME_MS);
|
|
68
|
-
};
|
|
69
68
|
useInput((_input, key) => {
|
|
70
69
|
if (pendingQuestion) {
|
|
71
70
|
if (key.ctrl && _input === "c")
|
|
72
71
|
session.abort();
|
|
73
72
|
return;
|
|
74
73
|
}
|
|
75
|
-
if (_input === "t" && runState.running &&
|
|
74
|
+
if (_input === "t" && runState.running && reasoning.text) {
|
|
76
75
|
setShowReasoning((v) => !v);
|
|
77
76
|
return;
|
|
78
77
|
}
|
|
@@ -86,13 +85,18 @@ export function App({ session }) {
|
|
|
86
85
|
exit();
|
|
87
86
|
}
|
|
88
87
|
});
|
|
89
|
-
async function handleCommand(name
|
|
90
|
-
await
|
|
88
|
+
async function handleCommand(name) {
|
|
89
|
+
await executeCommand(name, session);
|
|
91
90
|
if (session.localStore.get("exitRequested") != null)
|
|
92
91
|
exit();
|
|
93
92
|
}
|
|
94
93
|
async function handlePrompt(text) {
|
|
95
|
-
|
|
94
|
+
try {
|
|
95
|
+
await session.startPrompt(text);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
session.timelineError(e.message);
|
|
99
|
+
}
|
|
96
100
|
}
|
|
97
101
|
let runningView = null;
|
|
98
102
|
if (runState.running) {
|
|
@@ -100,11 +104,11 @@ export function App({ session }) {
|
|
|
100
104
|
runningView = (_jsx(QuestionView, { question: pendingQuestion, onAnswer: (ans) => session.submitAnswer(pendingQuestion.id, ans) }));
|
|
101
105
|
}
|
|
102
106
|
else {
|
|
103
|
-
const spinnerLabel =
|
|
104
|
-
runningView = (_jsxs(_Fragment, { children: [
|
|
107
|
+
const spinnerLabel = streaming.text ? "replying" : "working";
|
|
108
|
+
runningView = (_jsxs(_Fragment, { children: [reasoning.text ? renderReasoning(reasoning.text, showReasoning) : null, streaming.text ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, borderStyle: "single", borderTop: false, borderRight: false, borderBottom: false, borderColor: "gray", 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 }) })] }));
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
|
-
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: !!
|
|
111
|
+
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 })] }));
|
|
108
112
|
}
|
|
109
113
|
function renderReasoning(text, expanded) {
|
|
110
114
|
const lines = text.split("\n");
|
|
@@ -37,36 +37,52 @@ function renderBlock(token) {
|
|
|
37
37
|
return null;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
function renderInline(tokens) {
|
|
40
|
+
function renderInline(tokens, mode = "react") {
|
|
41
41
|
if (!tokens || tokens.length === 0)
|
|
42
|
-
return null;
|
|
43
|
-
|
|
42
|
+
return mode === "react" ? null : "";
|
|
43
|
+
const inner = (t) => renderInline(t, mode);
|
|
44
|
+
const out = tokens.map((token, i) => {
|
|
44
45
|
const tok = token;
|
|
45
46
|
switch (tok.type) {
|
|
46
|
-
case "text":
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
case "
|
|
51
|
-
|
|
47
|
+
case "text": {
|
|
48
|
+
const content = tok.tokens ? inner(tok.tokens) : tok.text;
|
|
49
|
+
return mode === "react" ? _jsx(Text, { children: content }, i) : content;
|
|
50
|
+
}
|
|
51
|
+
case "strong": {
|
|
52
|
+
const content = inner(tok.tokens);
|
|
53
|
+
return mode === "react" ? _jsx(Text, { bold: true, children: content }, i) : content;
|
|
54
|
+
}
|
|
55
|
+
case "em": {
|
|
56
|
+
const content = inner(tok.tokens);
|
|
57
|
+
return mode === "react" ? _jsx(Text, { italic: true, children: content }, i) : content;
|
|
58
|
+
}
|
|
52
59
|
case "codespan":
|
|
53
|
-
return _jsx(Text, { color: "cyan", children: tok.text }, i);
|
|
54
|
-
case "del":
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
case "
|
|
59
|
-
|
|
60
|
+
return mode === "react" ? _jsx(Text, { color: "cyan", children: tok.text }, i) : tok.text;
|
|
61
|
+
case "del": {
|
|
62
|
+
const content = inner(tok.tokens);
|
|
63
|
+
return mode === "react" ? _jsx(Text, { strikethrough: true, children: content }, i) : content;
|
|
64
|
+
}
|
|
65
|
+
case "link": {
|
|
66
|
+
const content = inner(tok.tokens);
|
|
67
|
+
return mode === "react" ? _jsx(Text, { color: "blue", underline: true, children: content }, i) : content;
|
|
68
|
+
}
|
|
69
|
+
case "image": {
|
|
70
|
+
const content = tok.text || tok.href;
|
|
71
|
+
return mode === "react" ? _jsx(Text, { color: "magenta", children: content }, i) : content;
|
|
72
|
+
}
|
|
60
73
|
case "br":
|
|
61
|
-
return _jsx(Text, { children: "\n" }, i);
|
|
74
|
+
return mode === "react" ? _jsx(Text, { children: "\n" }, i) : "";
|
|
62
75
|
case "escape":
|
|
63
|
-
return _jsx(Text, { children: tok.text }, i);
|
|
76
|
+
return mode === "react" ? _jsx(Text, { children: tok.text }, i) : tok.text;
|
|
64
77
|
case "html":
|
|
65
|
-
return _jsx(Text, { dimColor: true, children: tok.text }, i);
|
|
78
|
+
return mode === "react" ? _jsx(Text, { dimColor: true, children: tok.text }, i) : tok.text;
|
|
66
79
|
default:
|
|
67
|
-
return
|
|
80
|
+
return mode === "react"
|
|
81
|
+
? _jsx(Text, { children: tok.text ?? "" }, i)
|
|
82
|
+
: tok.text ?? "";
|
|
68
83
|
}
|
|
69
84
|
});
|
|
85
|
+
return mode === "react" ? out : out.join("");
|
|
70
86
|
}
|
|
71
87
|
function renderList(token) {
|
|
72
88
|
const markers = token.items.map((item, i) => {
|
|
@@ -87,14 +103,14 @@ function renderTable(token) {
|
|
|
87
103
|
const natural = new Array(cols).fill(0);
|
|
88
104
|
for (const row of [token.header, ...token.rows]) {
|
|
89
105
|
for (let c = 0; c < cols; c++) {
|
|
90
|
-
const w = stringWidth(
|
|
106
|
+
const w = stringWidth(renderInline(row[c].tokens, "text"));
|
|
91
107
|
if (w > natural[c])
|
|
92
108
|
natural[c] = w;
|
|
93
109
|
}
|
|
94
110
|
}
|
|
95
111
|
const widths = fitWidths(natural, available);
|
|
96
112
|
const renderRow = (row, bold = false) => {
|
|
97
|
-
const wrapped = row.map((tc, c) => wrapText(
|
|
113
|
+
const wrapped = row.map((tc, c) => wrapText(renderInline(tc.tokens, "text"), widths[c]));
|
|
98
114
|
const height = Math.max(1, ...wrapped.map(lines => lines.length));
|
|
99
115
|
return Array.from({ length: height }, (_, r) => (_jsx(Text, { children: [
|
|
100
116
|
...row.flatMap((_, c) => [
|
|
@@ -107,36 +123,6 @@ function renderTable(token) {
|
|
|
107
123
|
const border = (l, m, r) => l + widths.map(w => "─".repeat(w + 2)).join(m) + r;
|
|
108
124
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: border("┌", "┬", "┐") }), _jsx(Fragment, { children: renderRow(token.header, true) }), _jsx(Text, { dimColor: true, children: border("├", "┼", "┤") }), token.rows.map((row, i) => (_jsx(Fragment, { children: renderRow(row) }, i))), _jsx(Text, { dimColor: true, children: border("└", "┴", "┘") })] }));
|
|
109
125
|
}
|
|
110
|
-
function tokensToText(tokens) {
|
|
111
|
-
if (!tokens)
|
|
112
|
-
return "";
|
|
113
|
-
return tokens.map(tokenToText).join("");
|
|
114
|
-
}
|
|
115
|
-
function tokenToText(token) {
|
|
116
|
-
const t = token;
|
|
117
|
-
switch (t.type) {
|
|
118
|
-
case "text":
|
|
119
|
-
return t.tokens ? tokensToText(t.tokens) : t.text;
|
|
120
|
-
case "strong":
|
|
121
|
-
case "em":
|
|
122
|
-
case "del":
|
|
123
|
-
return tokensToText(t.tokens);
|
|
124
|
-
case "codespan":
|
|
125
|
-
return t.text;
|
|
126
|
-
case "link":
|
|
127
|
-
return tokensToText(t.tokens);
|
|
128
|
-
case "image":
|
|
129
|
-
return t.text || t.href;
|
|
130
|
-
case "br":
|
|
131
|
-
return "";
|
|
132
|
-
case "escape":
|
|
133
|
-
return t.text;
|
|
134
|
-
case "html":
|
|
135
|
-
return t.text;
|
|
136
|
-
default:
|
|
137
|
-
return t.text ?? "";
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
126
|
function padAlign(text, width, align) {
|
|
141
127
|
const pad = Math.max(0, width - stringWidth(text));
|
|
142
128
|
if (align === "right")
|
|
@@ -45,20 +45,19 @@ export function PromptOrCommandInput({ commands, onCommand, onPrompt }) {
|
|
|
45
45
|
if (!text)
|
|
46
46
|
return;
|
|
47
47
|
if (text.startsWith("/")) {
|
|
48
|
-
const
|
|
49
|
-
const args = rest.join(" ");
|
|
48
|
+
const name = text.slice(1).split(/\s+/)[0];
|
|
50
49
|
if (filtered.length > 0) {
|
|
51
50
|
const cmd = filtered[selectedIndex];
|
|
52
|
-
onCommand(cmd ? cmd.name : name
|
|
51
|
+
onCommand(cmd ? cmd.name : name);
|
|
53
52
|
}
|
|
54
53
|
else {
|
|
55
|
-
onCommand(name
|
|
54
|
+
onCommand(name);
|
|
56
55
|
}
|
|
57
56
|
return;
|
|
58
57
|
}
|
|
59
|
-
const
|
|
58
|
+
const first = text.split(/\s+/)[0];
|
|
60
59
|
if (commands.some((c) => c.name === first)) {
|
|
61
|
-
onCommand(first
|
|
60
|
+
onCommand(first);
|
|
62
61
|
return;
|
|
63
62
|
}
|
|
64
63
|
onPrompt(text);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { memo, useEffect, useState } from "react";
|
|
3
3
|
import { Box, Text } from "ink";
|
|
4
|
-
import { Markdown } from "./components/
|
|
4
|
+
import { Markdown } from "./components/markdown.js";
|
|
5
5
|
export const TimelineView = memo(function TimelineView({ entry }) {
|
|
6
6
|
switch (entry.kind) {
|
|
7
7
|
case "user":
|
|
@@ -22,7 +22,7 @@ export const TimelineView = memo(function TimelineView({ entry }) {
|
|
|
22
22
|
if (entry.answer === null)
|
|
23
23
|
return null;
|
|
24
24
|
return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: `? ${entry.text}` }), _jsx(Text, { dimColor: true, children: ` ⎿ ${entry.answer || "(skipped)"}` })] }));
|
|
25
|
-
case "
|
|
25
|
+
case "notice":
|
|
26
26
|
return (_jsx(Box, { children: _jsx(Text, { color: "blue", children: entry.text }) }));
|
|
27
27
|
}
|
|
28
28
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
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
|
-
const
|
|
4
|
+
const TODO_STATUS_GLYPHS = {
|
|
5
5
|
pending: "○",
|
|
6
6
|
in_progress: "◐",
|
|
7
7
|
completed: "✓",
|
|
@@ -14,5 +14,5 @@ const COLORS = {
|
|
|
14
14
|
export const TodoView = memo(function TodoView({ todos }) {
|
|
15
15
|
const done = todos.filter((t) => t.status === "completed").length;
|
|
16
16
|
const headerColor = done === todos.length ? "green" : "cyan";
|
|
17
|
-
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: [_jsx(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray" }), _jsx(Text, { color: headerColor, children: `Tasks [${done}/${todos.length}]` }), todos.map((t, i) => (_jsx(Text, { color: COLORS[t.status], strikethrough: t.status === "completed", children: `${
|
|
17
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: [_jsx(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray" }), _jsx(Text, { color: headerColor, children: `Tasks [${done}/${todos.length}]` }), todos.map((t, i) => (_jsx(Text, { color: COLORS[t.status], strikethrough: t.status === "completed", children: `${TODO_STATUS_GLYPHS[t.status]} ${t.content}` }, i)))] }));
|
|
18
18
|
});
|
|
@@ -1,12 +1,28 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { appendFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
5
|
+
import { ellipsisText } from "@vietor/easy-agent-core";
|
|
6
|
+
const MAX_PREVIEW_LEN = 75;
|
|
4
7
|
function encodeCwd(cwd) {
|
|
5
8
|
return cwd.replace(/[\/\\:]/g, "-");
|
|
6
9
|
}
|
|
10
|
+
function parseJsonLines(text) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const line of text.split("\n")) {
|
|
13
|
+
if (!line.trim())
|
|
14
|
+
continue;
|
|
15
|
+
try {
|
|
16
|
+
out.push(JSON.parse(line));
|
|
17
|
+
}
|
|
18
|
+
catch { /* skip malformed lines */ }
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
7
22
|
export class FileSessionPersistence {
|
|
8
23
|
cwd;
|
|
9
24
|
dir;
|
|
25
|
+
writtenCounts = new Map();
|
|
10
26
|
constructor(cwd) {
|
|
11
27
|
this.cwd = cwd;
|
|
12
28
|
this.dir = join(homedir(), ".easy-agent", "projects", encodeCwd(cwd));
|
|
@@ -24,30 +40,31 @@ export class FileSessionPersistence {
|
|
|
24
40
|
return null;
|
|
25
41
|
const messages = [];
|
|
26
42
|
let todos = [];
|
|
27
|
-
for (const
|
|
28
|
-
if (
|
|
29
|
-
continue;
|
|
30
|
-
let rec;
|
|
31
|
-
try {
|
|
32
|
-
rec = JSON.parse(line);
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
const r = rec;
|
|
38
|
-
if (r.t === "m" && r.m)
|
|
43
|
+
for (const r of parseJsonLines(readFileSync(path, "utf-8"))) {
|
|
44
|
+
if (r.t === "message" && r.m)
|
|
39
45
|
messages.push(r.m);
|
|
40
46
|
else if (r.t === "todo" && r.todos)
|
|
41
47
|
todos = r.todos;
|
|
42
48
|
}
|
|
49
|
+
this.writtenCounts.set(sessionId, messages.length);
|
|
43
50
|
return { messages, todos };
|
|
44
51
|
}
|
|
45
52
|
async saveAll(sessionId, state) {
|
|
46
53
|
this.ensureDir();
|
|
54
|
+
const written = this.writtenCounts.get(sessionId) ?? 0;
|
|
55
|
+
const shrink = state.messages.length < written;
|
|
47
56
|
const lines = state.messages
|
|
48
|
-
.
|
|
57
|
+
.slice(shrink ? 0 : written)
|
|
58
|
+
.map((m) => JSON.stringify({ t: "message", m }))
|
|
49
59
|
.concat([JSON.stringify({ t: "todo", todos: state.todos })]);
|
|
50
|
-
|
|
60
|
+
const path = this.file(sessionId);
|
|
61
|
+
if (shrink) {
|
|
62
|
+
await writeFile(path, lines.join("\n") + "\n", "utf-8");
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await appendFile(path, lines.join("\n") + "\n", "utf-8");
|
|
66
|
+
}
|
|
67
|
+
this.writtenCounts.set(sessionId, state.messages.length);
|
|
51
68
|
}
|
|
52
69
|
async listSessions() {
|
|
53
70
|
if (!existsSync(this.dir))
|
|
@@ -72,31 +89,24 @@ export class FileSessionPersistence {
|
|
|
72
89
|
}
|
|
73
90
|
return out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
74
91
|
}
|
|
75
|
-
async delete(sessionId) {
|
|
76
|
-
const path = this.file(sessionId);
|
|
77
|
-
if (existsSync(path))
|
|
78
|
-
unlinkSync(path);
|
|
79
|
-
}
|
|
80
92
|
readTitle(path) {
|
|
81
93
|
const first = this.readFirstUser(path);
|
|
82
94
|
if (!first)
|
|
83
95
|
return undefined;
|
|
84
|
-
|
|
85
|
-
return oneline.length > 60 ? oneline.slice(0, 60) + "…" : oneline;
|
|
96
|
+
return ellipsisText(first, MAX_PREVIEW_LEN);
|
|
86
97
|
}
|
|
87
98
|
readFirstUser(path) {
|
|
88
99
|
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
89
100
|
if (!line.trim())
|
|
90
101
|
continue;
|
|
91
|
-
let
|
|
102
|
+
let r;
|
|
92
103
|
try {
|
|
93
|
-
|
|
104
|
+
r = JSON.parse(line);
|
|
94
105
|
}
|
|
95
106
|
catch {
|
|
96
107
|
continue;
|
|
97
108
|
}
|
|
98
|
-
|
|
99
|
-
if (r.t === "m" && r.m && r.m.role === "user" && typeof r.m.content === "string")
|
|
109
|
+
if (r.t === "message" && r.m && r.m.role === "user" && typeof r.m.content === "string")
|
|
100
110
|
return r.m.content;
|
|
101
111
|
}
|
|
102
112
|
return undefined;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vietor/easy-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"main": "dist/
|
|
5
|
+
"main": "dist/main.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"easy-agent": "dist/
|
|
7
|
+
"easy-agent": "dist/main.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist"
|
|
@@ -16,19 +16,19 @@
|
|
|
16
16
|
"url": "https://github.com/vietor/easy-agent.git"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"commander": "^13.
|
|
20
|
-
"ink": "^7.1.
|
|
19
|
+
"commander": "^13.1.0",
|
|
20
|
+
"ink": "^7.1.1",
|
|
21
21
|
"ink-text-input": "^6.0.0",
|
|
22
|
-
"marked": "^18.0.
|
|
23
|
-
"react": "^19.2.
|
|
24
|
-
"string-width": "^8.2.
|
|
22
|
+
"marked": "^18.0.7",
|
|
23
|
+
"react": "^19.2.8",
|
|
24
|
+
"string-width": "^8.2.2",
|
|
25
25
|
"zod": "^4.4.3",
|
|
26
|
-
"@vietor/easy-agent-core": "0.
|
|
26
|
+
"@vietor/easy-agent-core": "0.5.1"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@types/node": "^22.
|
|
29
|
+
"@types/node": "^22.20.1",
|
|
30
30
|
"@types/react": "^19.2.17",
|
|
31
|
-
"tsx": "^4.23.
|
|
31
|
+
"tsx": "^4.23.1",
|
|
32
32
|
"typescript": "^6.0.3"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsc",
|
|
45
|
-
"dev": "tsx src/
|
|
46
|
-
"start": "node dist/
|
|
45
|
+
"dev": "tsx src/main.ts",
|
|
46
|
+
"start": "node dist/main.js"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/dist/cli.js
DELETED
package/dist/cmds/builtin.js
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { writeFileSync } from "node:fs";
|
|
2
|
-
export const exitCommand = {
|
|
3
|
-
name: "exit",
|
|
4
|
-
description: "Exit the conversation",
|
|
5
|
-
async execute(ctx) {
|
|
6
|
-
ctx.session.localStore.set("exitRequested", true);
|
|
7
|
-
},
|
|
8
|
-
};
|
|
9
|
-
export const exportCommand = {
|
|
10
|
-
name: "export",
|
|
11
|
-
description: "Export the conversation to a JSONL file",
|
|
12
|
-
async execute(ctx) {
|
|
13
|
-
const d = new Date();
|
|
14
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
15
|
-
const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
16
|
-
const file = `conversation-${ts}.jsonl`;
|
|
17
|
-
const lines = ctx.session
|
|
18
|
-
.export()
|
|
19
|
-
.map((m) => JSON.stringify(m))
|
|
20
|
-
.join("\n");
|
|
21
|
-
writeFileSync(file, lines + "\n", "utf-8");
|
|
22
|
-
ctx.message(`exported to ${file}`);
|
|
23
|
-
},
|
|
24
|
-
};
|
|
25
|
-
export const builtinCommands = [
|
|
26
|
-
exitCommand,
|
|
27
|
-
{ ...exitCommand, name: "quit" },
|
|
28
|
-
exportCommand,
|
|
29
|
-
];
|
|
File without changes
|
|
File without changes
|
|
File without changes
|