casabot 1.1.7 → 1.1.9
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/dist/agent/base.d.ts +1 -1
- package/dist/agent/base.js +24 -2
- package/dist/tui/app.js +138 -19
- package/package.json +1 -1
- package/src/agent/base.ts +28 -1
- package/src/tui/app.tsx +270 -25
package/dist/agent/base.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ChatProvider } from "../providers/base.js";
|
|
2
2
|
import type { Message, Skill, ConversationHistory } from "../config/types.js";
|
|
3
3
|
export declare function buildSystemPrompt(skills: Skill[]): string;
|
|
4
|
-
export declare function runAgent(provider: ChatProvider, userMessage: string, conversation: ConversationHistory, skills: Skill[]): AsyncGenerator<Message>;
|
|
4
|
+
export declare function runAgent(provider: ChatProvider, userMessage: string, conversation: ConversationHistory, skills: Skill[], signal: AbortSignal): AsyncGenerator<Message>;
|
|
5
5
|
//# sourceMappingURL=base.d.ts.map
|
package/dist/agent/base.js
CHANGED
|
@@ -3,6 +3,16 @@ import { appendMessage } from "../history/store.js";
|
|
|
3
3
|
import { formatSkillsForPrompt } from "../skills/loader.js";
|
|
4
4
|
import { CASABOT_HOME } from "../config/manager.js";
|
|
5
5
|
const MAX_ITERATIONS = 20;
|
|
6
|
+
function raceAbort(promise, signal) {
|
|
7
|
+
if (signal.aborted)
|
|
8
|
+
return Promise.reject(new Error("AbortError"));
|
|
9
|
+
return Promise.race([
|
|
10
|
+
promise,
|
|
11
|
+
new Promise((_, reject) => {
|
|
12
|
+
signal.addEventListener("abort", () => reject(new Error("AbortError")), { once: true });
|
|
13
|
+
}),
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
6
16
|
export function buildSystemPrompt(skills) {
|
|
7
17
|
const skillList = formatSkillsForPrompt(skills);
|
|
8
18
|
return `You are the base agent of CasAbot. Cassiopeia A — Freely creates everything, like a supernova explosion.
|
|
@@ -33,23 +43,35 @@ export function buildSystemPrompt(skills) {
|
|
|
33
43
|
## ${skillList}
|
|
34
44
|
`;
|
|
35
45
|
}
|
|
36
|
-
export async function* runAgent(provider, userMessage, conversation, skills) {
|
|
46
|
+
export async function* runAgent(provider, userMessage, conversation, skills, signal) {
|
|
37
47
|
const systemPrompt = buildSystemPrompt(skills);
|
|
38
48
|
const userMsg = { role: "user", content: userMessage };
|
|
39
49
|
await appendMessage(conversation, userMsg);
|
|
40
50
|
const tools = [TERMINAL_TOOL];
|
|
41
51
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
52
|
+
if (signal.aborted)
|
|
53
|
+
return;
|
|
42
54
|
const messagesWithSystem = [
|
|
43
55
|
{ role: "system", content: systemPrompt },
|
|
44
56
|
...conversation.messages,
|
|
45
57
|
];
|
|
46
|
-
|
|
58
|
+
let assistantMsg;
|
|
59
|
+
try {
|
|
60
|
+
assistantMsg = await raceAbort(provider.chat(messagesWithSystem, tools), signal);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (signal.aborted)
|
|
64
|
+
return;
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
47
67
|
await appendMessage(conversation, assistantMsg);
|
|
48
68
|
yield assistantMsg;
|
|
49
69
|
if (!assistantMsg.toolCalls?.length) {
|
|
50
70
|
return;
|
|
51
71
|
}
|
|
52
72
|
for (const toolCall of assistantMsg.toolCalls) {
|
|
73
|
+
if (signal.aborted)
|
|
74
|
+
return;
|
|
53
75
|
let result;
|
|
54
76
|
if (toolCall.name === "run_command") {
|
|
55
77
|
try {
|
package/dist/tui/app.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useCallback, useMemo } from "react";
|
|
2
|
+
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
3
3
|
import { render, Box, Text, Static, useInput, useApp, useStdout } from "ink";
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
5
|
import Spinner from "ink-spinner";
|
|
6
6
|
import Gradient from "ink-gradient";
|
|
7
|
-
import {
|
|
7
|
+
import { Marked } from "marked";
|
|
8
8
|
import { markedTerminal } from "marked-terminal";
|
|
9
9
|
import { runAgent } from "../agent/base.js";
|
|
10
|
-
|
|
10
|
+
import { createConversation, listConversations, saveConversation, appendMessage, } from "../history/store.js";
|
|
11
11
|
function renderMarkdown(content) {
|
|
12
12
|
const width = Math.max((process.stdout.columns ?? 80) - 8, 40);
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const md = new Marked({ gfm: true });
|
|
14
|
+
md.use(markedTerminal({ showSectionPrefix: false, tab: 2, width, reflowText: true }));
|
|
15
|
+
return md.parse(content, { async: false }).trimEnd();
|
|
15
16
|
}
|
|
16
17
|
function truncateOutput(content, maxLines = 8) {
|
|
17
18
|
const lines = content.split("\n");
|
|
@@ -20,6 +21,18 @@ function truncateOutput(content, maxLines = 8) {
|
|
|
20
21
|
return (lines.slice(0, maxLines).join("\n") +
|
|
21
22
|
`\n … ${lines.length - maxLines} more lines`);
|
|
22
23
|
}
|
|
24
|
+
function formatDate(iso) {
|
|
25
|
+
const d = new Date(iso);
|
|
26
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
27
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
28
|
+
}
|
|
29
|
+
function getPreview(conv, maxLen = 50) {
|
|
30
|
+
const firstUser = conv.messages.find((m) => m.role === "user");
|
|
31
|
+
if (!firstUser)
|
|
32
|
+
return "(empty session)";
|
|
33
|
+
const text = firstUser.content.replace(/\n/g, " ").trim();
|
|
34
|
+
return text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text;
|
|
35
|
+
}
|
|
23
36
|
function HRule({ columns }) {
|
|
24
37
|
return (_jsx(Box, { paddingX: 1, width: columns, children: _jsx(Text, { dimColor: true, children: "─".repeat(Math.max(columns - 4, 1)) }) }));
|
|
25
38
|
}
|
|
@@ -76,50 +89,154 @@ function WelcomeHint({ columns }) {
|
|
|
76
89
|
function ProcessingIndicator({ columns }) {
|
|
77
90
|
return (_jsxs(Box, { paddingX: 2, marginTop: 1, gap: 1, width: columns, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "yellow", children: "Thinking…" })] }));
|
|
78
91
|
}
|
|
79
|
-
function
|
|
92
|
+
function HistoryBrowser({ columns, currentId, onSelect, onBack, }) {
|
|
93
|
+
const [conversations, setConversations] = useState([]);
|
|
94
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
95
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
listConversations()
|
|
98
|
+
.then((convs) => {
|
|
99
|
+
setConversations(convs);
|
|
100
|
+
setIsLoading(false);
|
|
101
|
+
})
|
|
102
|
+
.catch(() => {
|
|
103
|
+
setIsLoading(false);
|
|
104
|
+
});
|
|
105
|
+
}, []);
|
|
106
|
+
useInput((ch, key) => {
|
|
107
|
+
if (key.escape) {
|
|
108
|
+
onBack();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (key.return && conversations.length > 0) {
|
|
112
|
+
onSelect(conversations[selectedIndex]);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (key.upArrow) {
|
|
116
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
117
|
+
}
|
|
118
|
+
if (key.downArrow && conversations.length > 0) {
|
|
119
|
+
setSelectedIndex((prev) => Math.min(conversations.length - 1, prev + 1));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
const boxWidth = Math.max(columns - 4, 10);
|
|
123
|
+
return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, width: columns, children: [_jsx(Box, { paddingX: 2, children: _jsx(Gradient, { name: "vice", children: _jsx(Text, { bold: true, children: "📋 Session History" }) }) }), _jsx(HRule, { columns: columns }), isLoading ? (_jsxs(Box, { paddingX: 2, marginTop: 1, gap: 1, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "yellow", children: "Loading sessions…" })] })) : conversations.length === 0 ? (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No previous sessions found." }) })) : (_jsx(Box, { flexDirection: "column", marginX: 2, marginTop: 1, borderStyle: "round", borderColor: "gray", paddingX: 1, paddingY: 1, width: boxWidth, overflow: "hidden", children: conversations.map((conv, i) => {
|
|
124
|
+
const isSelected = i === selectedIndex;
|
|
125
|
+
const isCurrent = conv.id === currentId;
|
|
126
|
+
const dateStr = formatDate(conv.startedAt);
|
|
127
|
+
const preview = getPreview(conv, Math.max(boxWidth - dateStr.length - 12, 20));
|
|
128
|
+
const msgCount = conv.messages.filter((m) => m.role === "user").length;
|
|
129
|
+
return (_jsxs(Box, { width: boxWidth - 4, children: [_jsx(Text, { color: isSelected ? "cyan" : undefined, bold: isSelected, dimColor: !isSelected, children: isSelected ? " ▶ " : " " }), _jsx(Text, { color: isSelected ? "cyan" : undefined, bold: isSelected, dimColor: !isSelected, children: dateStr }), _jsx(Text, { dimColor: !isSelected, children: " │ " }), _jsx(Text, { color: isSelected ? "white" : undefined, dimColor: !isSelected, wrap: "truncate", children: preview }), isCurrent ? (_jsx(Text, { color: "green", bold: true, children: " (current)" })) : null, _jsx(Text, { dimColor: true, children: ` [${msgCount}]` })] }, conv.id));
|
|
130
|
+
}) })), _jsx(HRule, { columns: columns }), _jsxs(Box, { paddingX: 2, width: columns, justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: "↑↓ navigate Enter select ESC back" }), _jsx(Text, { dimColor: true, children: `${conversations.length} sessions` })] })] }));
|
|
131
|
+
}
|
|
132
|
+
function App({ provider, conversation: initialConversation, skills, }) {
|
|
80
133
|
const [messages, setMessages] = useState([]);
|
|
81
134
|
const [input, setInput] = useState("");
|
|
82
135
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
136
|
+
const [mode, setMode] = useState("chat");
|
|
137
|
+
const conversationRef = useRef(initialConversation);
|
|
138
|
+
const abortControllerRef = useRef(null);
|
|
83
139
|
const { exit } = useApp();
|
|
84
140
|
const { stdout } = useStdout();
|
|
85
141
|
const columns = stdout.columns ?? 80;
|
|
142
|
+
const handleCancel = useCallback(() => {
|
|
143
|
+
if (abortControllerRef.current) {
|
|
144
|
+
abortControllerRef.current.abort();
|
|
145
|
+
}
|
|
146
|
+
}, []);
|
|
86
147
|
const handleSubmit = useCallback(async (text) => {
|
|
87
148
|
const trimmed = text.trim();
|
|
88
149
|
if (!trimmed || isProcessing)
|
|
89
150
|
return;
|
|
151
|
+
if (trimmed === "/new") {
|
|
152
|
+
setInput("");
|
|
153
|
+
if (conversationRef.current.messages.length > 0) {
|
|
154
|
+
await saveConversation(conversationRef.current);
|
|
155
|
+
}
|
|
156
|
+
process.stdout.write("\x1Bc");
|
|
157
|
+
const newConv = createConversation();
|
|
158
|
+
conversationRef.current = newConv;
|
|
159
|
+
setMessages([]);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (trimmed === "/history") {
|
|
163
|
+
setInput("");
|
|
164
|
+
if (conversationRef.current.messages.length > 0) {
|
|
165
|
+
await saveConversation(conversationRef.current);
|
|
166
|
+
}
|
|
167
|
+
setMode("history");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
90
170
|
setInput("");
|
|
91
171
|
setIsProcessing(true);
|
|
92
172
|
const userMsg = { role: "user", content: trimmed };
|
|
93
173
|
setMessages((prev) => [...prev, userMsg]);
|
|
174
|
+
const controller = new AbortController();
|
|
175
|
+
abortControllerRef.current = controller;
|
|
94
176
|
try {
|
|
95
|
-
const generator = runAgent(provider, trimmed,
|
|
177
|
+
const generator = runAgent(provider, trimmed, conversationRef.current, skills, controller.signal);
|
|
96
178
|
for await (const msg of generator) {
|
|
179
|
+
if (controller.signal.aborted)
|
|
180
|
+
break;
|
|
97
181
|
setMessages((prev) => [...prev, msg]);
|
|
98
182
|
}
|
|
99
183
|
}
|
|
100
184
|
catch (err) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
185
|
+
if (!controller.signal.aborted) {
|
|
186
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
187
|
+
setMessages((prev) => [
|
|
188
|
+
...prev,
|
|
189
|
+
{ role: "assistant", content: `❌ Error: ${errorMsg}` },
|
|
190
|
+
]);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (controller.signal.aborted) {
|
|
194
|
+
const cancelMsg = {
|
|
195
|
+
role: "assistant",
|
|
196
|
+
content: "⏹ Cancelled.",
|
|
197
|
+
};
|
|
198
|
+
setMessages((prev) => [...prev, cancelMsg]);
|
|
199
|
+
await appendMessage(conversationRef.current, cancelMsg);
|
|
106
200
|
}
|
|
201
|
+
abortControllerRef.current = null;
|
|
107
202
|
setIsProcessing(false);
|
|
108
|
-
}, [isProcessing, provider,
|
|
203
|
+
}, [isProcessing, provider, skills]);
|
|
204
|
+
const handleHistorySelect = useCallback(async (conv) => {
|
|
205
|
+
if (conversationRef.current.messages.length > 0) {
|
|
206
|
+
await saveConversation(conversationRef.current);
|
|
207
|
+
}
|
|
208
|
+
process.stdout.write("\x1Bc");
|
|
209
|
+
conversationRef.current = conv;
|
|
210
|
+
setMessages([...conv.messages]);
|
|
211
|
+
setMode("chat");
|
|
212
|
+
}, []);
|
|
109
213
|
useInput((ch, key) => {
|
|
110
|
-
if (
|
|
111
|
-
|
|
214
|
+
if (mode !== "chat")
|
|
215
|
+
return;
|
|
216
|
+
if (isProcessing) {
|
|
217
|
+
if (key.escape || (key.ctrl && ch === "c")) {
|
|
218
|
+
handleCancel();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
if (key.ctrl && ch === "c") {
|
|
223
|
+
exit();
|
|
224
|
+
}
|
|
112
225
|
}
|
|
113
226
|
});
|
|
114
227
|
const userCount = messages.filter((m) => m.role === "user").length;
|
|
228
|
+
const convId = conversationRef.current.id;
|
|
115
229
|
const items = useMemo(() => [
|
|
116
|
-
{ key:
|
|
230
|
+
{ key: `header-${convId}`, type: "header" },
|
|
117
231
|
...messages.map((msg, i) => ({
|
|
118
|
-
key:
|
|
232
|
+
key: `${convId}-msg-${i}`,
|
|
119
233
|
type: "message",
|
|
120
234
|
message: msg,
|
|
121
235
|
})),
|
|
122
|
-
], [messages]);
|
|
236
|
+
], [messages, convId]);
|
|
237
|
+
if (mode === "history") {
|
|
238
|
+
return (_jsx(HistoryBrowser, { columns: columns, currentId: conversationRef.current.id, onSelect: handleHistorySelect, onBack: () => setMode("chat") }));
|
|
239
|
+
}
|
|
123
240
|
return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(Static, { items: items, children: (item) => {
|
|
124
241
|
if (item.type === "header") {
|
|
125
242
|
return (_jsx(Box, { flexDirection: "column", width: columns, children: _jsx(HeaderBlock, { columns: columns }) }, item.key));
|
|
@@ -127,7 +244,9 @@ function App({ provider, conversation, skills, }) {
|
|
|
127
244
|
return (_jsx(Box, { flexDirection: "column", width: columns, children: _jsx(MessageView, { message: item.message, columns: columns }) }, item.key));
|
|
128
245
|
} }), messages.length === 0 && !isProcessing && _jsx(WelcomeHint, { columns: columns }), isProcessing && _jsx(ProcessingIndicator, { columns: columns }), _jsx(HRule, { columns: columns }), _jsx(Box, { paddingX: 1, width: columns, children: _jsxs(Box, { borderStyle: "round", borderColor: isProcessing ? "gray" : "cyan", paddingX: 1, width: Math.max(columns - 2, 10), overflow: "hidden", children: [_jsx(Text, { color: "cyan", bold: true, children: "❯ " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: (val) => {
|
|
129
246
|
handleSubmit(val).catch(() => { });
|
|
130
|
-
}, placeholder: "Type your message\u2026", focus: !isProcessing, showCursor: true })] }) }), _jsxs(Box, { paddingX: 2, width: columns, justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children:
|
|
247
|
+
}, placeholder: "Type your message\u2026", focus: !isProcessing, showCursor: true })] }) }), _jsxs(Box, { paddingX: 2, width: columns, justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: isProcessing
|
|
248
|
+
? "ESC / Ctrl+C cancel"
|
|
249
|
+
: "/new /history Ctrl+C exit" }), _jsxs(Text, { dimColor: true, children: [userCount, " ", userCount === 1 ? "message" : "messages"] })] })] }));
|
|
131
250
|
}
|
|
132
251
|
export function startTUI(provider, conversation, skills) {
|
|
133
252
|
render(_jsx(App, { provider: provider, conversation: conversation, skills: skills }));
|
package/package.json
CHANGED
package/src/agent/base.ts
CHANGED
|
@@ -7,6 +7,20 @@ import { CASABOT_HOME } from "../config/manager.js";
|
|
|
7
7
|
|
|
8
8
|
const MAX_ITERATIONS = 20;
|
|
9
9
|
|
|
10
|
+
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
11
|
+
if (signal.aborted) return Promise.reject(new Error("AbortError"));
|
|
12
|
+
return Promise.race([
|
|
13
|
+
promise,
|
|
14
|
+
new Promise<never>((_, reject) => {
|
|
15
|
+
signal.addEventListener(
|
|
16
|
+
"abort",
|
|
17
|
+
() => reject(new Error("AbortError")),
|
|
18
|
+
{ once: true },
|
|
19
|
+
);
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
export function buildSystemPrompt(skills: Skill[]): string {
|
|
11
25
|
const skillList = formatSkillsForPrompt(skills);
|
|
12
26
|
|
|
@@ -44,6 +58,7 @@ export async function* runAgent(
|
|
|
44
58
|
userMessage: string,
|
|
45
59
|
conversation: ConversationHistory,
|
|
46
60
|
skills: Skill[],
|
|
61
|
+
signal: AbortSignal,
|
|
47
62
|
): AsyncGenerator<Message> {
|
|
48
63
|
const systemPrompt = buildSystemPrompt(skills);
|
|
49
64
|
|
|
@@ -53,11 +68,21 @@ export async function* runAgent(
|
|
|
53
68
|
const tools = [TERMINAL_TOOL];
|
|
54
69
|
|
|
55
70
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
71
|
+
if (signal.aborted) return;
|
|
72
|
+
|
|
56
73
|
const messagesWithSystem: Message[] = [
|
|
57
74
|
{ role: "system", content: systemPrompt },
|
|
58
75
|
...conversation.messages,
|
|
59
76
|
];
|
|
60
|
-
|
|
77
|
+
|
|
78
|
+
let assistantMsg: Message;
|
|
79
|
+
try {
|
|
80
|
+
assistantMsg = await raceAbort(provider.chat(messagesWithSystem, tools), signal);
|
|
81
|
+
} catch (err: unknown) {
|
|
82
|
+
if (signal.aborted) return;
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
await appendMessage(conversation, assistantMsg);
|
|
62
87
|
yield assistantMsg;
|
|
63
88
|
|
|
@@ -66,6 +91,8 @@ export async function* runAgent(
|
|
|
66
91
|
}
|
|
67
92
|
|
|
68
93
|
for (const toolCall of assistantMsg.toolCalls) {
|
|
94
|
+
if (signal.aborted) return;
|
|
95
|
+
|
|
69
96
|
let result: string;
|
|
70
97
|
|
|
71
98
|
if (toolCall.name === "run_command") {
|
package/src/tui/app.tsx
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
|
-
import React, { useState, useCallback, useMemo } from "react";
|
|
1
|
+
import React, { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
2
2
|
import { render, Box, Text, Static, useInput, useApp, useStdout } from "ink";
|
|
3
3
|
import TextInput from "ink-text-input";
|
|
4
4
|
import Spinner from "ink-spinner";
|
|
5
5
|
import Gradient from "ink-gradient";
|
|
6
|
-
import {
|
|
6
|
+
import { Marked } from "marked";
|
|
7
7
|
import { markedTerminal } from "marked-terminal";
|
|
8
8
|
import type { ChatProvider } from "../providers/base.js";
|
|
9
9
|
import type { ConversationHistory, Message, Skill } from "../config/types.js";
|
|
10
10
|
import { runAgent } from "../agent/base.js";
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
createConversation,
|
|
13
|
+
listConversations,
|
|
14
|
+
saveConversation,
|
|
15
|
+
appendMessage,
|
|
16
|
+
} from "../history/store.js";
|
|
13
17
|
|
|
14
18
|
function renderMarkdown(content: string): string {
|
|
15
19
|
const width = Math.max((process.stdout.columns ?? 80) - 8, 40);
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
const md = new Marked({ gfm: true });
|
|
21
|
+
md.use(markedTerminal({ showSectionPrefix: false, tab: 2, width, reflowText: true }));
|
|
22
|
+
return (md.parse(content, { async: false }) as string).trimEnd();
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
function truncateOutput(content: string, maxLines = 8): string {
|
|
@@ -26,6 +31,19 @@ function truncateOutput(content: string, maxLines = 8): string {
|
|
|
26
31
|
);
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
function formatDate(iso: string): string {
|
|
35
|
+
const d = new Date(iso);
|
|
36
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
37
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getPreview(conv: ConversationHistory, maxLen = 50): string {
|
|
41
|
+
const firstUser = conv.messages.find((m) => m.role === "user");
|
|
42
|
+
if (!firstUser) return "(empty session)";
|
|
43
|
+
const text = firstUser.content.replace(/\n/g, " ").trim();
|
|
44
|
+
return text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text;
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
function HRule({ columns }: { columns: number }): React.ReactElement {
|
|
30
48
|
return (
|
|
31
49
|
<Box paddingX={1} width={columns}>
|
|
@@ -209,10 +227,145 @@ function ProcessingIndicator({ columns }: { columns: number }): React.ReactEleme
|
|
|
209
227
|
);
|
|
210
228
|
}
|
|
211
229
|
|
|
230
|
+
interface HistoryBrowserProps {
|
|
231
|
+
columns: number;
|
|
232
|
+
currentId: string;
|
|
233
|
+
onSelect: (conversation: ConversationHistory) => void;
|
|
234
|
+
onBack: () => void;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function HistoryBrowser({
|
|
238
|
+
columns,
|
|
239
|
+
currentId,
|
|
240
|
+
onSelect,
|
|
241
|
+
onBack,
|
|
242
|
+
}: HistoryBrowserProps): React.ReactElement {
|
|
243
|
+
const [conversations, setConversations] = useState<ConversationHistory[]>([]);
|
|
244
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
245
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
246
|
+
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
listConversations()
|
|
249
|
+
.then((convs) => {
|
|
250
|
+
setConversations(convs);
|
|
251
|
+
setIsLoading(false);
|
|
252
|
+
})
|
|
253
|
+
.catch(() => {
|
|
254
|
+
setIsLoading(false);
|
|
255
|
+
});
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
258
|
+
useInput((ch, key) => {
|
|
259
|
+
if (key.escape) {
|
|
260
|
+
onBack();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (key.return && conversations.length > 0) {
|
|
264
|
+
onSelect(conversations[selectedIndex]);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (key.upArrow) {
|
|
268
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
269
|
+
}
|
|
270
|
+
if (key.downArrow && conversations.length > 0) {
|
|
271
|
+
setSelectedIndex((prev) => Math.min(conversations.length - 1, prev + 1));
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const boxWidth = Math.max(columns - 4, 10);
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<Box flexDirection="column" paddingTop={1} width={columns}>
|
|
279
|
+
<Box paddingX={2}>
|
|
280
|
+
<Gradient name="vice">
|
|
281
|
+
<Text bold>{"📋 Session History"}</Text>
|
|
282
|
+
</Gradient>
|
|
283
|
+
</Box>
|
|
284
|
+
|
|
285
|
+
<HRule columns={columns} />
|
|
286
|
+
|
|
287
|
+
{isLoading ? (
|
|
288
|
+
<Box paddingX={2} marginTop={1} gap={1}>
|
|
289
|
+
<Text color="yellow">
|
|
290
|
+
<Spinner type="dots" />
|
|
291
|
+
</Text>
|
|
292
|
+
<Text color="yellow">{"Loading sessions…"}</Text>
|
|
293
|
+
</Box>
|
|
294
|
+
) : conversations.length === 0 ? (
|
|
295
|
+
<Box paddingX={2} marginTop={1}>
|
|
296
|
+
<Text dimColor>{"No previous sessions found."}</Text>
|
|
297
|
+
</Box>
|
|
298
|
+
) : (
|
|
299
|
+
<Box
|
|
300
|
+
flexDirection="column"
|
|
301
|
+
marginX={2}
|
|
302
|
+
marginTop={1}
|
|
303
|
+
borderStyle="round"
|
|
304
|
+
borderColor="gray"
|
|
305
|
+
paddingX={1}
|
|
306
|
+
paddingY={1}
|
|
307
|
+
width={boxWidth}
|
|
308
|
+
overflow="hidden"
|
|
309
|
+
>
|
|
310
|
+
{conversations.map((conv, i) => {
|
|
311
|
+
const isSelected = i === selectedIndex;
|
|
312
|
+
const isCurrent = conv.id === currentId;
|
|
313
|
+
const dateStr = formatDate(conv.startedAt);
|
|
314
|
+
const preview = getPreview(conv, Math.max(boxWidth - dateStr.length - 12, 20));
|
|
315
|
+
const msgCount = conv.messages.filter((m) => m.role === "user").length;
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<Box key={conv.id} width={boxWidth - 4}>
|
|
319
|
+
<Text
|
|
320
|
+
color={isSelected ? "cyan" : undefined}
|
|
321
|
+
bold={isSelected}
|
|
322
|
+
dimColor={!isSelected}
|
|
323
|
+
>
|
|
324
|
+
{isSelected ? " ▶ " : " "}
|
|
325
|
+
</Text>
|
|
326
|
+
<Text
|
|
327
|
+
color={isSelected ? "cyan" : undefined}
|
|
328
|
+
bold={isSelected}
|
|
329
|
+
dimColor={!isSelected}
|
|
330
|
+
>
|
|
331
|
+
{dateStr}
|
|
332
|
+
</Text>
|
|
333
|
+
<Text dimColor={!isSelected}>{" │ "}</Text>
|
|
334
|
+
<Text
|
|
335
|
+
color={isSelected ? "white" : undefined}
|
|
336
|
+
dimColor={!isSelected}
|
|
337
|
+
wrap="truncate"
|
|
338
|
+
>
|
|
339
|
+
{preview}
|
|
340
|
+
</Text>
|
|
341
|
+
{isCurrent ? (
|
|
342
|
+
<Text color="green" bold>{" (current)"}</Text>
|
|
343
|
+
) : null}
|
|
344
|
+
<Text dimColor>
|
|
345
|
+
{` [${msgCount}]`}
|
|
346
|
+
</Text>
|
|
347
|
+
</Box>
|
|
348
|
+
);
|
|
349
|
+
})}
|
|
350
|
+
</Box>
|
|
351
|
+
)}
|
|
352
|
+
|
|
353
|
+
<HRule columns={columns} />
|
|
354
|
+
|
|
355
|
+
<Box paddingX={2} width={columns} justifyContent="space-between">
|
|
356
|
+
<Text dimColor>{"↑↓ navigate Enter select ESC back"}</Text>
|
|
357
|
+
<Text dimColor>{`${conversations.length} sessions`}</Text>
|
|
358
|
+
</Box>
|
|
359
|
+
</Box>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
212
363
|
type DisplayItem =
|
|
213
364
|
| { key: string; type: "header" }
|
|
214
365
|
| { key: string; type: "message"; message: Message };
|
|
215
366
|
|
|
367
|
+
type AppMode = "chat" | "history";
|
|
368
|
+
|
|
216
369
|
interface AppProps {
|
|
217
370
|
provider: ChatProvider;
|
|
218
371
|
conversation: ConversationHistory;
|
|
@@ -221,61 +374,149 @@ interface AppProps {
|
|
|
221
374
|
|
|
222
375
|
function App({
|
|
223
376
|
provider,
|
|
224
|
-
conversation,
|
|
377
|
+
conversation: initialConversation,
|
|
225
378
|
skills,
|
|
226
379
|
}: AppProps): React.ReactElement {
|
|
227
380
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
228
381
|
const [input, setInput] = useState("");
|
|
229
382
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
383
|
+
const [mode, setMode] = useState<AppMode>("chat");
|
|
384
|
+
const conversationRef = useRef<ConversationHistory>(initialConversation);
|
|
385
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
230
386
|
const { exit } = useApp();
|
|
231
387
|
const { stdout } = useStdout();
|
|
232
388
|
const columns = stdout.columns ?? 80;
|
|
233
389
|
|
|
390
|
+
const handleCancel = useCallback(() => {
|
|
391
|
+
if (abortControllerRef.current) {
|
|
392
|
+
abortControllerRef.current.abort();
|
|
393
|
+
}
|
|
394
|
+
}, []);
|
|
395
|
+
|
|
234
396
|
const handleSubmit = useCallback(
|
|
235
397
|
async (text: string) => {
|
|
236
398
|
const trimmed = text.trim();
|
|
237
399
|
if (!trimmed || isProcessing) return;
|
|
238
400
|
|
|
401
|
+
if (trimmed === "/new") {
|
|
402
|
+
setInput("");
|
|
403
|
+
if (conversationRef.current.messages.length > 0) {
|
|
404
|
+
await saveConversation(conversationRef.current);
|
|
405
|
+
}
|
|
406
|
+
process.stdout.write("\x1Bc");
|
|
407
|
+
const newConv = createConversation();
|
|
408
|
+
conversationRef.current = newConv;
|
|
409
|
+
setMessages([]);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (trimmed === "/history") {
|
|
414
|
+
setInput("");
|
|
415
|
+
if (conversationRef.current.messages.length > 0) {
|
|
416
|
+
await saveConversation(conversationRef.current);
|
|
417
|
+
}
|
|
418
|
+
setMode("history");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
239
422
|
setInput("");
|
|
240
423
|
setIsProcessing(true);
|
|
241
424
|
|
|
242
425
|
const userMsg: Message = { role: "user", content: trimmed };
|
|
243
426
|
setMessages((prev) => [...prev, userMsg]);
|
|
244
427
|
|
|
428
|
+
const controller = new AbortController();
|
|
429
|
+
abortControllerRef.current = controller;
|
|
430
|
+
|
|
245
431
|
try {
|
|
246
|
-
const generator = runAgent(
|
|
432
|
+
const generator = runAgent(
|
|
433
|
+
provider,
|
|
434
|
+
trimmed,
|
|
435
|
+
conversationRef.current,
|
|
436
|
+
skills,
|
|
437
|
+
controller.signal,
|
|
438
|
+
);
|
|
247
439
|
for await (const msg of generator) {
|
|
440
|
+
if (controller.signal.aborted) break;
|
|
248
441
|
setMessages((prev) => [...prev, msg]);
|
|
249
442
|
}
|
|
250
443
|
} catch (err: unknown) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
444
|
+
if (!controller.signal.aborted) {
|
|
445
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
446
|
+
setMessages((prev) => [
|
|
447
|
+
...prev,
|
|
448
|
+
{ role: "assistant", content: `❌ Error: ${errorMsg}` },
|
|
449
|
+
]);
|
|
450
|
+
}
|
|
256
451
|
}
|
|
257
452
|
|
|
453
|
+
if (controller.signal.aborted) {
|
|
454
|
+
const cancelMsg: Message = {
|
|
455
|
+
role: "assistant",
|
|
456
|
+
content: "⏹ Cancelled.",
|
|
457
|
+
};
|
|
458
|
+
setMessages((prev) => [...prev, cancelMsg]);
|
|
459
|
+
await appendMessage(conversationRef.current, cancelMsg);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
abortControllerRef.current = null;
|
|
258
463
|
setIsProcessing(false);
|
|
259
464
|
},
|
|
260
|
-
[isProcessing, provider,
|
|
465
|
+
[isProcessing, provider, skills],
|
|
261
466
|
);
|
|
262
467
|
|
|
468
|
+
const handleHistorySelect = useCallback(async (conv: ConversationHistory) => {
|
|
469
|
+
if (conversationRef.current.messages.length > 0) {
|
|
470
|
+
await saveConversation(conversationRef.current);
|
|
471
|
+
}
|
|
472
|
+
process.stdout.write("\x1Bc");
|
|
473
|
+
conversationRef.current = conv;
|
|
474
|
+
setMessages([...conv.messages]);
|
|
475
|
+
setMode("chat");
|
|
476
|
+
}, []);
|
|
477
|
+
|
|
263
478
|
useInput((ch, key) => {
|
|
264
|
-
if (
|
|
265
|
-
|
|
479
|
+
if (mode !== "chat") return;
|
|
480
|
+
|
|
481
|
+
if (isProcessing) {
|
|
482
|
+
if (key.escape || (key.ctrl && ch === "c")) {
|
|
483
|
+
handleCancel();
|
|
484
|
+
}
|
|
485
|
+
} else {
|
|
486
|
+
if (key.ctrl && ch === "c") {
|
|
487
|
+
exit();
|
|
488
|
+
}
|
|
266
489
|
}
|
|
267
490
|
});
|
|
268
491
|
|
|
269
492
|
const userCount = messages.filter((m) => m.role === "user").length;
|
|
270
493
|
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
type: "
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
494
|
+
const convId = conversationRef.current.id;
|
|
495
|
+
|
|
496
|
+
const items = useMemo(
|
|
497
|
+
(): DisplayItem[] => [
|
|
498
|
+
{ key: `header-${convId}`, type: "header" },
|
|
499
|
+
...messages.map(
|
|
500
|
+
(msg, i): DisplayItem => ({
|
|
501
|
+
key: `${convId}-msg-${i}`,
|
|
502
|
+
type: "message",
|
|
503
|
+
message: msg,
|
|
504
|
+
}),
|
|
505
|
+
),
|
|
506
|
+
],
|
|
507
|
+
[messages, convId],
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
if (mode === "history") {
|
|
511
|
+
return (
|
|
512
|
+
<HistoryBrowser
|
|
513
|
+
columns={columns}
|
|
514
|
+
currentId={conversationRef.current.id}
|
|
515
|
+
onSelect={handleHistorySelect}
|
|
516
|
+
onBack={() => setMode("chat")}
|
|
517
|
+
/>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
279
520
|
|
|
280
521
|
return (
|
|
281
522
|
<Box flexDirection="column" width={columns}>
|
|
@@ -326,7 +567,11 @@ function App({
|
|
|
326
567
|
</Box>
|
|
327
568
|
|
|
328
569
|
<Box paddingX={2} width={columns} justifyContent="space-between">
|
|
329
|
-
<Text dimColor>
|
|
570
|
+
<Text dimColor>
|
|
571
|
+
{isProcessing
|
|
572
|
+
? "ESC / Ctrl+C cancel"
|
|
573
|
+
: "/new /history Ctrl+C exit"}
|
|
574
|
+
</Text>
|
|
330
575
|
<Text dimColor>
|
|
331
576
|
{userCount} {userCount === 1 ? "message" : "messages"}
|
|
332
577
|
</Text>
|