casabot 1.1.1 → 1.1.3

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/tui/app.js CHANGED
@@ -1,5 +1,5 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useCallback, useEffect } from "react";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useCallback, useEffect, useMemo, useRef } from "react";
3
3
  import { render, Box, Text, useInput, useApp, useStdout } from "ink";
4
4
  import TextInput from "ink-text-input";
5
5
  import Spinner from "ink-spinner";
@@ -7,12 +7,13 @@ 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
- marked.use(markedTerminal());
10
+ marked.use(markedTerminal({
11
+ showSectionPrefix: false,
12
+ tab: 2,
13
+ }));
14
+ marked.use({ gfm: true });
11
15
  function renderMarkdown(content) {
12
- const result = marked.parse(content);
13
- if (typeof result === "string")
14
- return result.trimEnd();
15
- return content;
16
+ return marked.parse(content, { async: false }).trimEnd();
16
17
  }
17
18
  function truncateOutput(content, maxLines = 8) {
18
19
  const lines = content.split("\n");
@@ -21,6 +22,61 @@ function truncateOutput(content, maxLines = 8) {
21
22
  return (lines.slice(0, maxLines).join("\n") +
22
23
  `\n … ${lines.length - maxLines} more lines`);
23
24
  }
25
+ function estimateMessageLines(message, width) {
26
+ const contentWidth = Math.max(width - 10, 20);
27
+ const countLines = (text) => text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil((line.length || 1) / contentWidth)), 0);
28
+ if (message.role === "user") {
29
+ return 2 + countLines(message.content);
30
+ }
31
+ if (message.role === "tool") {
32
+ return 3 + countLines(truncateOutput(message.content));
33
+ }
34
+ if (message.role === "assistant" && message.toolCalls?.length) {
35
+ let lines = 2;
36
+ if (message.content)
37
+ lines += countLines(message.content);
38
+ lines += 4 + (message.toolCalls?.length ?? 0);
39
+ return lines;
40
+ }
41
+ if (message.role === "assistant") {
42
+ return 2 + countLines(message.content);
43
+ }
44
+ return 2;
45
+ }
46
+ function useMouseWheel(onScrollUp, onScrollDown) {
47
+ const { stdout } = useStdout();
48
+ const scrollUpRef = useRef(onScrollUp);
49
+ const scrollDownRef = useRef(onScrollDown);
50
+ useEffect(() => {
51
+ scrollUpRef.current = onScrollUp;
52
+ scrollDownRef.current = onScrollDown;
53
+ });
54
+ useEffect(() => {
55
+ if (!process.stdin.isTTY)
56
+ return;
57
+ const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1006h";
58
+ const DISABLE_MOUSE = "\x1b[?1000l\x1b[?1006l";
59
+ stdout.write(ENABLE_MOUSE);
60
+ const sgrRegex = /\x1b\[<(\d+);\d+;\d+M/g;
61
+ const handleData = (data) => {
62
+ const str = data.toString("utf8");
63
+ let match;
64
+ while ((match = sgrRegex.exec(str)) !== null) {
65
+ const button = parseInt(match[1], 10);
66
+ if (button === 64)
67
+ scrollUpRef.current();
68
+ if (button === 65)
69
+ scrollDownRef.current();
70
+ }
71
+ sgrRegex.lastIndex = 0;
72
+ };
73
+ process.stdin.on("data", handleData);
74
+ return () => {
75
+ process.stdin.off("data", handleData);
76
+ stdout.write(DISABLE_MOUSE);
77
+ };
78
+ }, [stdout]);
79
+ }
24
80
  function HRule({ width }) {
25
81
  return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "─".repeat(Math.max(width - 4, 10)) }) }));
26
82
  }
@@ -71,10 +127,17 @@ function WelcomeHint() {
71
127
  function ProcessingIndicator() {
72
128
  return (_jsxs(Box, { paddingX: 2, marginTop: 1, gap: 1, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "yellow", children: "Thinking…" })] }));
73
129
  }
130
+ function ScrollIndicator({ direction, count, }) {
131
+ const arrow = direction === "above" ? "▲" : "▼";
132
+ return (_jsx(Box, { justifyContent: "center", paddingX: 2, children: _jsx(Text, { dimColor: true, children: `${arrow} ${count} more ${count === 1 ? "message" : "messages"} ${direction}` }) }));
133
+ }
134
+ // border(2) + header(3) + hrules(2) + input(3) + status(1) = 11
135
+ const CHROME_HEIGHT = 11;
74
136
  function App({ provider, conversation, skills, }) {
75
137
  const [messages, setMessages] = useState([]);
76
138
  const [input, setInput] = useState("");
77
139
  const [isProcessing, setIsProcessing] = useState(false);
140
+ const [scrollOffset, setScrollOffset] = useState(0);
78
141
  const { exit } = useApp();
79
142
  const { stdout } = useStdout();
80
143
  const [termSize, setTermSize] = useState({
@@ -93,6 +156,33 @@ function App({ provider, conversation, skills, }) {
93
156
  stdout.off("resize", onResize);
94
157
  };
95
158
  }, [stdout]);
159
+ useEffect(() => {
160
+ setScrollOffset((prev) => (prev === 0 ? 0 : prev + 1));
161
+ }, [messages.length]);
162
+ const messagesHeight = Math.max(termSize.rows - CHROME_HEIGHT, 4);
163
+ const { visibleMessages, hiddenAbove, hiddenBelow } = useMemo(() => {
164
+ if (messages.length === 0) {
165
+ return { visibleMessages: [], hiddenAbove: 0, hiddenBelow: 0 };
166
+ }
167
+ const endIndex = messages.length - scrollOffset;
168
+ let usedLines = isProcessing ? 2 : 0;
169
+ let startIndex = endIndex;
170
+ for (let i = endIndex - 1; i >= 0; i--) {
171
+ const lines = estimateMessageLines(messages[i], termSize.columns);
172
+ if (usedLines + lines > messagesHeight && startIndex < endIndex)
173
+ break;
174
+ usedLines += lines;
175
+ startIndex = i;
176
+ }
177
+ return {
178
+ visibleMessages: messages.slice(startIndex, endIndex),
179
+ hiddenAbove: startIndex,
180
+ hiddenBelow: scrollOffset,
181
+ };
182
+ }, [messages, scrollOffset, messagesHeight, termSize.columns, isProcessing]);
183
+ const maxScrollOffset = useMemo(() => {
184
+ return Math.max(0, messages.length - 1);
185
+ }, [messages.length]);
96
186
  const handleSubmit = useCallback(async (text) => {
97
187
  const trimmed = text.trim();
98
188
  if (!trimmed || isProcessing)
@@ -116,15 +206,28 @@ function App({ provider, conversation, skills, }) {
116
206
  }
117
207
  setIsProcessing(false);
118
208
  }, [isProcessing, provider, conversation, skills]);
209
+ const scrollUp = useCallback(() => {
210
+ setScrollOffset((prev) => Math.min(prev + 1, maxScrollOffset));
211
+ }, [maxScrollOffset]);
212
+ const scrollDown = useCallback(() => {
213
+ setScrollOffset((prev) => Math.max(prev - 1, 0));
214
+ }, []);
215
+ useMouseWheel(scrollUp, scrollDown);
119
216
  useInput((ch, key) => {
120
217
  if (key.ctrl && ch === "c") {
121
218
  exit();
122
219
  }
220
+ if (key.upArrow) {
221
+ scrollUp();
222
+ }
223
+ if (key.downArrow) {
224
+ scrollDown();
225
+ }
123
226
  });
124
227
  const userCount = messages.filter((m) => m.role === "user").length;
125
- return (_jsxs(Box, { flexDirection: "column", width: termSize.columns, height: termSize.rows, borderStyle: "round", borderColor: "gray", children: [_jsx(Header, {}), _jsx(HRule, { width: termSize.columns }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflowY: "hidden", justifyContent: messages.length === 0 && !isProcessing ? "center" : "flex-end", paddingBottom: 1, children: [messages.length === 0 && !isProcessing ? (_jsx(WelcomeHint, {})) : (messages.map((msg, i) => (_jsx(MessageView, { message: msg }, i)))), isProcessing && _jsx(ProcessingIndicator, {})] }), _jsx(HRule, { width: termSize.columns }), _jsx(Box, { paddingX: 1, children: _jsxs(Box, { borderStyle: "round", borderColor: isProcessing ? "gray" : "cyan", paddingX: 1, width: "100%", children: [_jsx(Text, { color: "cyan", bold: true, children: "❯ " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: (val) => {
228
+ return (_jsxs(Box, { flexDirection: "column", width: termSize.columns, height: termSize.rows, borderStyle: "round", borderColor: "gray", children: [_jsx(Header, {}), _jsx(HRule, { width: termSize.columns }), _jsx(Box, { flexDirection: "column", height: messagesHeight, overflowY: "hidden", justifyContent: "flex-end", children: messages.length === 0 && !isProcessing ? (_jsx(WelcomeHint, {})) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 && (_jsx(ScrollIndicator, { direction: "above", count: hiddenAbove })), visibleMessages.map((msg, i) => (_jsx(MessageView, { message: msg }, hiddenAbove + i))), isProcessing && _jsx(ProcessingIndicator, {}), hiddenBelow > 0 && (_jsx(ScrollIndicator, { direction: "below", count: hiddenBelow }))] })) }), _jsx(HRule, { width: termSize.columns }), _jsx(Box, { paddingX: 1, children: _jsxs(Box, { borderStyle: "round", borderColor: isProcessing ? "gray" : "cyan", paddingX: 1, width: "100%", children: [_jsx(Text, { color: "cyan", bold: true, children: "❯ " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: (val) => {
126
229
  handleSubmit(val).catch(() => { });
127
- }, placeholder: "Type your message\u2026", focus: !isProcessing, showCursor: true })] }) }), _jsxs(Box, { paddingX: 2, justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: "Ctrl+C exit" }), _jsxs(Text, { dimColor: true, children: [userCount, " ", userCount === 1 ? "message" : "messages"] })] })] }));
230
+ }, placeholder: "Type your message\u2026", focus: !isProcessing, showCursor: true })] }) }), _jsxs(Box, { paddingX: 2, justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: "Ctrl+C exit ↑↓/wheel scroll" }), _jsxs(Text, { dimColor: true, children: [userCount, " ", userCount === 1 ? "message" : "messages"] })] })] }));
128
231
  }
129
232
  export function startTUI(provider, conversation, skills) {
130
233
  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.1",
3
+ "version": "1.1.3",
4
4
  "description": "CasAbot — Skill-driven multi-agent orchestrator system",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/tui/app.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useCallback, useEffect } from "react";
1
+ import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import { render, Box, Text, useInput, useApp, useStdout } from "ink";
3
3
  import TextInput from "ink-text-input";
4
4
  import Spinner from "ink-spinner";
@@ -9,12 +9,16 @@ 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
11
 
12
- marked.use(markedTerminal());
12
+ marked.use(
13
+ markedTerminal({
14
+ showSectionPrefix: false,
15
+ tab: 2,
16
+ }),
17
+ );
18
+ marked.use({ gfm: true });
13
19
 
14
20
  function renderMarkdown(content: string): string {
15
- const result = marked.parse(content);
16
- if (typeof result === "string") return result.trimEnd();
17
- return content;
21
+ return (marked.parse(content, { async: false }) as string).trimEnd();
18
22
  }
19
23
 
20
24
  function truncateOutput(content: string, maxLines = 8): string {
@@ -26,6 +30,74 @@ function truncateOutput(content: string, maxLines = 8): string {
26
30
  );
27
31
  }
28
32
 
33
+ function estimateMessageLines(message: Message, width: number): number {
34
+ const contentWidth = Math.max(width - 10, 20);
35
+ const countLines = (text: string): number =>
36
+ text.split("\n").reduce(
37
+ (sum, line) =>
38
+ sum + Math.max(1, Math.ceil((line.length || 1) / contentWidth)),
39
+ 0,
40
+ );
41
+
42
+ if (message.role === "user") {
43
+ return 2 + countLines(message.content);
44
+ }
45
+ if (message.role === "tool") {
46
+ return 3 + countLines(truncateOutput(message.content));
47
+ }
48
+ if (message.role === "assistant" && message.toolCalls?.length) {
49
+ let lines = 2;
50
+ if (message.content) lines += countLines(message.content);
51
+ lines += 4 + (message.toolCalls?.length ?? 0);
52
+ return lines;
53
+ }
54
+ if (message.role === "assistant") {
55
+ return 2 + countLines(message.content);
56
+ }
57
+ return 2;
58
+ }
59
+
60
+ function useMouseWheel(
61
+ onScrollUp: () => void,
62
+ onScrollDown: () => void,
63
+ ): void {
64
+ const { stdout } = useStdout();
65
+ const scrollUpRef = useRef(onScrollUp);
66
+ const scrollDownRef = useRef(onScrollDown);
67
+
68
+ useEffect(() => {
69
+ scrollUpRef.current = onScrollUp;
70
+ scrollDownRef.current = onScrollDown;
71
+ });
72
+
73
+ useEffect(() => {
74
+ if (!process.stdin.isTTY) return;
75
+
76
+ const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1006h";
77
+ const DISABLE_MOUSE = "\x1b[?1000l\x1b[?1006l";
78
+ stdout.write(ENABLE_MOUSE);
79
+
80
+ const sgrRegex = /\x1b\[<(\d+);\d+;\d+M/g;
81
+
82
+ const handleData = (data: Buffer): void => {
83
+ const str = data.toString("utf8");
84
+ let match;
85
+ while ((match = sgrRegex.exec(str)) !== null) {
86
+ const button = parseInt(match[1], 10);
87
+ if (button === 64) scrollUpRef.current();
88
+ if (button === 65) scrollDownRef.current();
89
+ }
90
+ sgrRegex.lastIndex = 0;
91
+ };
92
+
93
+ process.stdin.on("data", handleData);
94
+ return () => {
95
+ process.stdin.off("data", handleData);
96
+ stdout.write(DISABLE_MOUSE);
97
+ };
98
+ }, [stdout]);
99
+ }
100
+
29
101
  function HRule({ width }: { width: number }): React.ReactElement {
30
102
  return (
31
103
  <Box paddingX={1}>
@@ -186,12 +258,32 @@ function ProcessingIndicator(): React.ReactElement {
186
258
  );
187
259
  }
188
260
 
261
+ function ScrollIndicator({
262
+ direction,
263
+ count,
264
+ }: {
265
+ direction: "above" | "below";
266
+ count: number;
267
+ }): React.ReactElement {
268
+ const arrow = direction === "above" ? "▲" : "▼";
269
+ return (
270
+ <Box justifyContent="center" paddingX={2}>
271
+ <Text dimColor>
272
+ {`${arrow} ${count} more ${count === 1 ? "message" : "messages"} ${direction}`}
273
+ </Text>
274
+ </Box>
275
+ );
276
+ }
277
+
189
278
  interface AppProps {
190
279
  provider: ChatProvider;
191
280
  conversation: ConversationHistory;
192
281
  skills: Skill[];
193
282
  }
194
283
 
284
+ // border(2) + header(3) + hrules(2) + input(3) + status(1) = 11
285
+ const CHROME_HEIGHT = 11;
286
+
195
287
  function App({
196
288
  provider,
197
289
  conversation,
@@ -200,6 +292,7 @@ function App({
200
292
  const [messages, setMessages] = useState<Message[]>([]);
201
293
  const [input, setInput] = useState("");
202
294
  const [isProcessing, setIsProcessing] = useState(false);
295
+ const [scrollOffset, setScrollOffset] = useState(0);
203
296
  const { exit } = useApp();
204
297
  const { stdout } = useStdout();
205
298
 
@@ -221,6 +314,39 @@ function App({
221
314
  };
222
315
  }, [stdout]);
223
316
 
317
+ useEffect(() => {
318
+ setScrollOffset((prev) => (prev === 0 ? 0 : prev + 1));
319
+ }, [messages.length]);
320
+
321
+ const messagesHeight = Math.max(termSize.rows - CHROME_HEIGHT, 4);
322
+
323
+ const { visibleMessages, hiddenAbove, hiddenBelow } = useMemo(() => {
324
+ if (messages.length === 0) {
325
+ return { visibleMessages: [] as Message[], hiddenAbove: 0, hiddenBelow: 0 };
326
+ }
327
+
328
+ const endIndex = messages.length - scrollOffset;
329
+ let usedLines = isProcessing ? 2 : 0;
330
+ let startIndex = endIndex;
331
+
332
+ for (let i = endIndex - 1; i >= 0; i--) {
333
+ const lines = estimateMessageLines(messages[i], termSize.columns);
334
+ if (usedLines + lines > messagesHeight && startIndex < endIndex) break;
335
+ usedLines += lines;
336
+ startIndex = i;
337
+ }
338
+
339
+ return {
340
+ visibleMessages: messages.slice(startIndex, endIndex),
341
+ hiddenAbove: startIndex,
342
+ hiddenBelow: scrollOffset,
343
+ };
344
+ }, [messages, scrollOffset, messagesHeight, termSize.columns, isProcessing]);
345
+
346
+ const maxScrollOffset = useMemo(() => {
347
+ return Math.max(0, messages.length - 1);
348
+ }, [messages.length]);
349
+
224
350
  const handleSubmit = useCallback(
225
351
  async (text: string) => {
226
352
  const trimmed = text.trim();
@@ -250,10 +376,26 @@ function App({
250
376
  [isProcessing, provider, conversation, skills],
251
377
  );
252
378
 
379
+ const scrollUp = useCallback(() => {
380
+ setScrollOffset((prev) => Math.min(prev + 1, maxScrollOffset));
381
+ }, [maxScrollOffset]);
382
+
383
+ const scrollDown = useCallback(() => {
384
+ setScrollOffset((prev) => Math.max(prev - 1, 0));
385
+ }, []);
386
+
387
+ useMouseWheel(scrollUp, scrollDown);
388
+
253
389
  useInput((ch, key) => {
254
390
  if (key.ctrl && ch === "c") {
255
391
  exit();
256
392
  }
393
+ if (key.upArrow) {
394
+ scrollUp();
395
+ }
396
+ if (key.downArrow) {
397
+ scrollDown();
398
+ }
257
399
  });
258
400
 
259
401
  const userCount = messages.filter((m) => m.role === "user").length;
@@ -271,19 +413,26 @@ function App({
271
413
 
272
414
  <Box
273
415
  flexDirection="column"
274
- flexGrow={1}
416
+ height={messagesHeight}
275
417
  overflowY="hidden"
276
- justifyContent={messages.length === 0 && !isProcessing ? "center" : "flex-end"}
277
- paddingBottom={1}
418
+ justifyContent="flex-end"
278
419
  >
279
420
  {messages.length === 0 && !isProcessing ? (
280
421
  <WelcomeHint />
281
422
  ) : (
282
- messages.map((msg, i) => (
283
- <MessageView key={i} message={msg} />
284
- ))
423
+ <>
424
+ {hiddenAbove > 0 && (
425
+ <ScrollIndicator direction="above" count={hiddenAbove} />
426
+ )}
427
+ {visibleMessages.map((msg, i) => (
428
+ <MessageView key={hiddenAbove + i} message={msg} />
429
+ ))}
430
+ {isProcessing && <ProcessingIndicator />}
431
+ {hiddenBelow > 0 && (
432
+ <ScrollIndicator direction="below" count={hiddenBelow} />
433
+ )}
434
+ </>
285
435
  )}
286
- {isProcessing && <ProcessingIndicator />}
287
436
  </Box>
288
437
 
289
438
  <HRule width={termSize.columns} />
@@ -312,7 +461,7 @@ function App({
312
461
  </Box>
313
462
 
314
463
  <Box paddingX={2} justifyContent="space-between">
315
- <Text dimColor>{"Ctrl+C exit"}</Text>
464
+ <Text dimColor>{"Ctrl+C exit ↑↓/wheel scroll"}</Text>
316
465
  <Text dimColor>
317
466
  {userCount} {userCount === 1 ? "message" : "messages"}
318
467
  </Text>