casabot 1.1.8 → 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 +134 -15
- package/package.json +1 -1
- package/src/agent/base.ts +28 -1
- package/src/tui/app.tsx +266 -20
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,5 +1,5 @@
|
|
|
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";
|
|
@@ -7,6 +7,7 @@ import Gradient from "ink-gradient";
|
|
|
7
7
|
import { Marked } from "marked";
|
|
8
8
|
import { markedTerminal } from "marked-terminal";
|
|
9
9
|
import { runAgent } from "../agent/base.js";
|
|
10
|
+
import { createConversation, listConversations, saveConversation, appendMessage, } from "../history/store.js";
|
|
10
11
|
function renderMarkdown(content) {
|
|
11
12
|
const width = Math.max((process.stdout.columns ?? 80) - 8, 40);
|
|
12
13
|
const md = new Marked({ gfm: true });
|
|
@@ -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,4 +1,4 @@
|
|
|
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";
|
|
@@ -8,6 +8,12 @@ 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
|
+
import {
|
|
12
|
+
createConversation,
|
|
13
|
+
listConversations,
|
|
14
|
+
saveConversation,
|
|
15
|
+
appendMessage,
|
|
16
|
+
} from "../history/store.js";
|
|
11
17
|
|
|
12
18
|
function renderMarkdown(content: string): string {
|
|
13
19
|
const width = Math.max((process.stdout.columns ?? 80) - 8, 40);
|
|
@@ -25,6 +31,19 @@ function truncateOutput(content: string, maxLines = 8): string {
|
|
|
25
31
|
);
|
|
26
32
|
}
|
|
27
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
|
+
|
|
28
47
|
function HRule({ columns }: { columns: number }): React.ReactElement {
|
|
29
48
|
return (
|
|
30
49
|
<Box paddingX={1} width={columns}>
|
|
@@ -208,10 +227,145 @@ function ProcessingIndicator({ columns }: { columns: number }): React.ReactEleme
|
|
|
208
227
|
);
|
|
209
228
|
}
|
|
210
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
|
+
|
|
211
363
|
type DisplayItem =
|
|
212
364
|
| { key: string; type: "header" }
|
|
213
365
|
| { key: string; type: "message"; message: Message };
|
|
214
366
|
|
|
367
|
+
type AppMode = "chat" | "history";
|
|
368
|
+
|
|
215
369
|
interface AppProps {
|
|
216
370
|
provider: ChatProvider;
|
|
217
371
|
conversation: ConversationHistory;
|
|
@@ -220,61 +374,149 @@ interface AppProps {
|
|
|
220
374
|
|
|
221
375
|
function App({
|
|
222
376
|
provider,
|
|
223
|
-
conversation,
|
|
377
|
+
conversation: initialConversation,
|
|
224
378
|
skills,
|
|
225
379
|
}: AppProps): React.ReactElement {
|
|
226
380
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
227
381
|
const [input, setInput] = useState("");
|
|
228
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);
|
|
229
386
|
const { exit } = useApp();
|
|
230
387
|
const { stdout } = useStdout();
|
|
231
388
|
const columns = stdout.columns ?? 80;
|
|
232
389
|
|
|
390
|
+
const handleCancel = useCallback(() => {
|
|
391
|
+
if (abortControllerRef.current) {
|
|
392
|
+
abortControllerRef.current.abort();
|
|
393
|
+
}
|
|
394
|
+
}, []);
|
|
395
|
+
|
|
233
396
|
const handleSubmit = useCallback(
|
|
234
397
|
async (text: string) => {
|
|
235
398
|
const trimmed = text.trim();
|
|
236
399
|
if (!trimmed || isProcessing) return;
|
|
237
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
|
+
|
|
238
422
|
setInput("");
|
|
239
423
|
setIsProcessing(true);
|
|
240
424
|
|
|
241
425
|
const userMsg: Message = { role: "user", content: trimmed };
|
|
242
426
|
setMessages((prev) => [...prev, userMsg]);
|
|
243
427
|
|
|
428
|
+
const controller = new AbortController();
|
|
429
|
+
abortControllerRef.current = controller;
|
|
430
|
+
|
|
244
431
|
try {
|
|
245
|
-
const generator = runAgent(
|
|
432
|
+
const generator = runAgent(
|
|
433
|
+
provider,
|
|
434
|
+
trimmed,
|
|
435
|
+
conversationRef.current,
|
|
436
|
+
skills,
|
|
437
|
+
controller.signal,
|
|
438
|
+
);
|
|
246
439
|
for await (const msg of generator) {
|
|
440
|
+
if (controller.signal.aborted) break;
|
|
247
441
|
setMessages((prev) => [...prev, msg]);
|
|
248
442
|
}
|
|
249
443
|
} catch (err: unknown) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
+
}
|
|
255
451
|
}
|
|
256
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;
|
|
257
463
|
setIsProcessing(false);
|
|
258
464
|
},
|
|
259
|
-
[isProcessing, provider,
|
|
465
|
+
[isProcessing, provider, skills],
|
|
260
466
|
);
|
|
261
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
|
+
|
|
262
478
|
useInput((ch, key) => {
|
|
263
|
-
if (
|
|
264
|
-
|
|
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
|
+
}
|
|
265
489
|
}
|
|
266
490
|
});
|
|
267
491
|
|
|
268
492
|
const userCount = messages.filter((m) => m.role === "user").length;
|
|
269
493
|
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
type: "
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
+
}
|
|
278
520
|
|
|
279
521
|
return (
|
|
280
522
|
<Box flexDirection="column" width={columns}>
|
|
@@ -325,7 +567,11 @@ function App({
|
|
|
325
567
|
</Box>
|
|
326
568
|
|
|
327
569
|
<Box paddingX={2} width={columns} justifyContent="space-between">
|
|
328
|
-
<Text dimColor>
|
|
570
|
+
<Text dimColor>
|
|
571
|
+
{isProcessing
|
|
572
|
+
? "ESC / Ctrl+C cancel"
|
|
573
|
+
: "/new /history Ctrl+C exit"}
|
|
574
|
+
</Text>
|
|
329
575
|
<Text dimColor>
|
|
330
576
|
{userCount} {userCount === 1 ? "message" : "messages"}
|
|
331
577
|
</Text>
|