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.
@@ -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
@@ -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
- const assistantMsg = await provider.chat(messagesWithSystem, tools);
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 App({ provider, conversation, skills, }) {
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, conversation, skills);
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
- const errorMsg = err instanceof Error ? err.message : String(err);
102
- setMessages((prev) => [
103
- ...prev,
104
- { role: "assistant", content: `❌ Error: ${errorMsg}` },
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, conversation, skills]);
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 (key.ctrl && ch === "c") {
111
- exit();
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: "header", type: "header" },
230
+ { key: `header-${convId}`, type: "header" },
117
231
  ...messages.map((msg, i) => ({
118
- key: `msg-${i}`,
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: "Ctrl+C exit" }), _jsxs(Text, { dimColor: true, children: [userCount, " ", userCount === 1 ? "message" : "messages"] })] })] }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "casabot",
3
- "version": "1.1.8",
3
+ "version": "1.1.9",
4
4
  "description": "CasAbot — Skill-driven multi-agent orchestrator system",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
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
- const assistantMsg = await provider.chat(messagesWithSystem, tools);
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(provider, trimmed, conversation, skills);
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
- const errorMsg = err instanceof Error ? err.message : String(err);
251
- setMessages((prev) => [
252
- ...prev,
253
- { role: "assistant", content: `❌ Error: ${errorMsg}` },
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, conversation, skills],
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 (key.ctrl && ch === "c") {
264
- exit();
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 items = useMemo((): DisplayItem[] => [
271
- { key: "header", type: "header" },
272
- ...messages.map((msg, i): DisplayItem => ({
273
- key: `msg-${i}`,
274
- type: "message",
275
- message: msg,
276
- })),
277
- ], [messages]);
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>{"Ctrl+C exit"}</Text>
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>