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.
@@ -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,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 { marked } from "marked";
7
+ import { Marked } from "marked";
8
8
  import { markedTerminal } from "marked-terminal";
9
9
  import { runAgent } from "../agent/base.js";
10
- marked.use({ gfm: true });
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
- marked.use(markedTerminal({ showSectionPrefix: false, tab: 2, width }));
14
- return marked.parse(content, { async: false }).trimEnd();
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 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.7",
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,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 { marked } from "marked";
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
- marked.use({ gfm: true });
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
- marked.use(markedTerminal({ showSectionPrefix: false, tab: 2, width }));
17
- return (marked.parse(content, { async: false }) as string).trimEnd();
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(provider, trimmed, conversation, skills);
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
- const errorMsg = err instanceof Error ? err.message : String(err);
252
- setMessages((prev) => [
253
- ...prev,
254
- { role: "assistant", content: `❌ Error: ${errorMsg}` },
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, conversation, skills],
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 (key.ctrl && ch === "c") {
265
- 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
+ }
266
489
  }
267
490
  });
268
491
 
269
492
  const userCount = messages.filter((m) => m.role === "user").length;
270
493
 
271
- const items = useMemo((): DisplayItem[] => [
272
- { key: "header", type: "header" },
273
- ...messages.map((msg, i): DisplayItem => ({
274
- key: `msg-${i}`,
275
- type: "message",
276
- message: msg,
277
- })),
278
- ], [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
+ }
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>{"Ctrl+C exit"}</Text>
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>