casabot 1.1.0 → 1.1.2

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,11 +1,11 @@
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 } 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";
6
6
  import Gradient from "ink-gradient";
7
7
  import { marked } from "marked";
8
- import markedTerminal from "marked-terminal";
8
+ import { markedTerminal } from "marked-terminal";
9
9
  import { runAgent } from "../agent/base.js";
10
10
  marked.use(markedTerminal());
11
11
  function renderMarkdown(content) {
@@ -21,6 +21,27 @@ function truncateOutput(content, maxLines = 8) {
21
21
  return (lines.slice(0, maxLines).join("\n") +
22
22
  `\n … ${lines.length - maxLines} more lines`);
23
23
  }
24
+ function estimateMessageLines(message, width) {
25
+ const contentWidth = Math.max(width - 10, 20);
26
+ const countLines = (text) => text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil((line.length || 1) / contentWidth)), 0);
27
+ if (message.role === "user") {
28
+ return 2 + countLines(message.content);
29
+ }
30
+ if (message.role === "tool") {
31
+ return 3 + countLines(truncateOutput(message.content));
32
+ }
33
+ if (message.role === "assistant" && message.toolCalls?.length) {
34
+ let lines = 2;
35
+ if (message.content)
36
+ lines += countLines(message.content);
37
+ lines += 4 + (message.toolCalls?.length ?? 0);
38
+ return lines;
39
+ }
40
+ if (message.role === "assistant") {
41
+ return 2 + countLines(message.content);
42
+ }
43
+ return 2;
44
+ }
24
45
  function HRule({ width }) {
25
46
  return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "─".repeat(Math.max(width - 4, 10)) }) }));
26
47
  }
@@ -71,10 +92,17 @@ function WelcomeHint() {
71
92
  function ProcessingIndicator() {
72
93
  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
94
  }
95
+ function ScrollIndicator({ direction, count, }) {
96
+ const arrow = direction === "above" ? "▲" : "▼";
97
+ return (_jsx(Box, { justifyContent: "center", paddingX: 2, children: _jsx(Text, { dimColor: true, children: `${arrow} ${count} more ${count === 1 ? "message" : "messages"} ${direction}` }) }));
98
+ }
99
+ // border(2) + header(3) + hrules(2) + input(3) + status(1) = 11
100
+ const CHROME_HEIGHT = 11;
74
101
  function App({ provider, conversation, skills, }) {
75
102
  const [messages, setMessages] = useState([]);
76
103
  const [input, setInput] = useState("");
77
104
  const [isProcessing, setIsProcessing] = useState(false);
105
+ const [scrollOffset, setScrollOffset] = useState(0);
78
106
  const { exit } = useApp();
79
107
  const { stdout } = useStdout();
80
108
  const [termSize, setTermSize] = useState({
@@ -93,6 +121,33 @@ function App({ provider, conversation, skills, }) {
93
121
  stdout.off("resize", onResize);
94
122
  };
95
123
  }, [stdout]);
124
+ useEffect(() => {
125
+ setScrollOffset((prev) => (prev === 0 ? 0 : prev + 1));
126
+ }, [messages.length]);
127
+ const messagesHeight = Math.max(termSize.rows - CHROME_HEIGHT, 4);
128
+ const { visibleMessages, hiddenAbove, hiddenBelow } = useMemo(() => {
129
+ if (messages.length === 0) {
130
+ return { visibleMessages: [], hiddenAbove: 0, hiddenBelow: 0 };
131
+ }
132
+ const endIndex = messages.length - scrollOffset;
133
+ let usedLines = isProcessing ? 2 : 0;
134
+ let startIndex = endIndex;
135
+ for (let i = endIndex - 1; i >= 0; i--) {
136
+ const lines = estimateMessageLines(messages[i], termSize.columns);
137
+ if (usedLines + lines > messagesHeight && startIndex < endIndex)
138
+ break;
139
+ usedLines += lines;
140
+ startIndex = i;
141
+ }
142
+ return {
143
+ visibleMessages: messages.slice(startIndex, endIndex),
144
+ hiddenAbove: startIndex,
145
+ hiddenBelow: scrollOffset,
146
+ };
147
+ }, [messages, scrollOffset, messagesHeight, termSize.columns, isProcessing]);
148
+ const maxScrollOffset = useMemo(() => {
149
+ return Math.max(0, messages.length - 1);
150
+ }, [messages.length]);
96
151
  const handleSubmit = useCallback(async (text) => {
97
152
  const trimmed = text.trim();
98
153
  if (!trimmed || isProcessing)
@@ -120,11 +175,17 @@ function App({ provider, conversation, skills, }) {
120
175
  if (key.ctrl && ch === "c") {
121
176
  exit();
122
177
  }
178
+ if (key.upArrow) {
179
+ setScrollOffset((prev) => Math.min(prev + 1, maxScrollOffset));
180
+ }
181
+ if (key.downArrow) {
182
+ setScrollOffset((prev) => Math.max(prev - 1, 0));
183
+ }
123
184
  });
124
185
  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) => {
186
+ 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
187
  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"] })] })] }));
188
+ }, 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 ↑↓ scroll" }), _jsxs(Text, { dimColor: true, children: [userCount, " ", userCount === 1 ? "message" : "messages"] })] })] }));
128
189
  }
129
190
  export function startTUI(provider, conversation, skills) {
130
191
  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.0",
3
+ "version": "1.1.2",
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,10 +1,10 @@
1
- import React, { useState, useCallback, useEffect } from "react";
1
+ import React, { useState, useCallback, useEffect, useMemo } 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";
5
5
  import Gradient from "ink-gradient";
6
6
  import { marked } from "marked";
7
- import markedTerminal from "marked-terminal";
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";
@@ -26,6 +26,33 @@ function truncateOutput(content: string, maxLines = 8): string {
26
26
  );
27
27
  }
28
28
 
29
+ function estimateMessageLines(message: Message, width: number): number {
30
+ const contentWidth = Math.max(width - 10, 20);
31
+ const countLines = (text: string): number =>
32
+ text.split("\n").reduce(
33
+ (sum, line) =>
34
+ sum + Math.max(1, Math.ceil((line.length || 1) / contentWidth)),
35
+ 0,
36
+ );
37
+
38
+ if (message.role === "user") {
39
+ return 2 + countLines(message.content);
40
+ }
41
+ if (message.role === "tool") {
42
+ return 3 + countLines(truncateOutput(message.content));
43
+ }
44
+ if (message.role === "assistant" && message.toolCalls?.length) {
45
+ let lines = 2;
46
+ if (message.content) lines += countLines(message.content);
47
+ lines += 4 + (message.toolCalls?.length ?? 0);
48
+ return lines;
49
+ }
50
+ if (message.role === "assistant") {
51
+ return 2 + countLines(message.content);
52
+ }
53
+ return 2;
54
+ }
55
+
29
56
  function HRule({ width }: { width: number }): React.ReactElement {
30
57
  return (
31
58
  <Box paddingX={1}>
@@ -186,12 +213,32 @@ function ProcessingIndicator(): React.ReactElement {
186
213
  );
187
214
  }
188
215
 
216
+ function ScrollIndicator({
217
+ direction,
218
+ count,
219
+ }: {
220
+ direction: "above" | "below";
221
+ count: number;
222
+ }): React.ReactElement {
223
+ const arrow = direction === "above" ? "▲" : "▼";
224
+ return (
225
+ <Box justifyContent="center" paddingX={2}>
226
+ <Text dimColor>
227
+ {`${arrow} ${count} more ${count === 1 ? "message" : "messages"} ${direction}`}
228
+ </Text>
229
+ </Box>
230
+ );
231
+ }
232
+
189
233
  interface AppProps {
190
234
  provider: ChatProvider;
191
235
  conversation: ConversationHistory;
192
236
  skills: Skill[];
193
237
  }
194
238
 
239
+ // border(2) + header(3) + hrules(2) + input(3) + status(1) = 11
240
+ const CHROME_HEIGHT = 11;
241
+
195
242
  function App({
196
243
  provider,
197
244
  conversation,
@@ -200,6 +247,7 @@ function App({
200
247
  const [messages, setMessages] = useState<Message[]>([]);
201
248
  const [input, setInput] = useState("");
202
249
  const [isProcessing, setIsProcessing] = useState(false);
250
+ const [scrollOffset, setScrollOffset] = useState(0);
203
251
  const { exit } = useApp();
204
252
  const { stdout } = useStdout();
205
253
 
@@ -221,6 +269,39 @@ function App({
221
269
  };
222
270
  }, [stdout]);
223
271
 
272
+ useEffect(() => {
273
+ setScrollOffset((prev) => (prev === 0 ? 0 : prev + 1));
274
+ }, [messages.length]);
275
+
276
+ const messagesHeight = Math.max(termSize.rows - CHROME_HEIGHT, 4);
277
+
278
+ const { visibleMessages, hiddenAbove, hiddenBelow } = useMemo(() => {
279
+ if (messages.length === 0) {
280
+ return { visibleMessages: [] as Message[], hiddenAbove: 0, hiddenBelow: 0 };
281
+ }
282
+
283
+ const endIndex = messages.length - scrollOffset;
284
+ let usedLines = isProcessing ? 2 : 0;
285
+ let startIndex = endIndex;
286
+
287
+ for (let i = endIndex - 1; i >= 0; i--) {
288
+ const lines = estimateMessageLines(messages[i], termSize.columns);
289
+ if (usedLines + lines > messagesHeight && startIndex < endIndex) break;
290
+ usedLines += lines;
291
+ startIndex = i;
292
+ }
293
+
294
+ return {
295
+ visibleMessages: messages.slice(startIndex, endIndex),
296
+ hiddenAbove: startIndex,
297
+ hiddenBelow: scrollOffset,
298
+ };
299
+ }, [messages, scrollOffset, messagesHeight, termSize.columns, isProcessing]);
300
+
301
+ const maxScrollOffset = useMemo(() => {
302
+ return Math.max(0, messages.length - 1);
303
+ }, [messages.length]);
304
+
224
305
  const handleSubmit = useCallback(
225
306
  async (text: string) => {
226
307
  const trimmed = text.trim();
@@ -254,6 +335,12 @@ function App({
254
335
  if (key.ctrl && ch === "c") {
255
336
  exit();
256
337
  }
338
+ if (key.upArrow) {
339
+ setScrollOffset((prev) => Math.min(prev + 1, maxScrollOffset));
340
+ }
341
+ if (key.downArrow) {
342
+ setScrollOffset((prev) => Math.max(prev - 1, 0));
343
+ }
257
344
  });
258
345
 
259
346
  const userCount = messages.filter((m) => m.role === "user").length;
@@ -271,19 +358,26 @@ function App({
271
358
 
272
359
  <Box
273
360
  flexDirection="column"
274
- flexGrow={1}
361
+ height={messagesHeight}
275
362
  overflowY="hidden"
276
- justifyContent={messages.length === 0 && !isProcessing ? "center" : "flex-end"}
277
- paddingBottom={1}
363
+ justifyContent="flex-end"
278
364
  >
279
365
  {messages.length === 0 && !isProcessing ? (
280
366
  <WelcomeHint />
281
367
  ) : (
282
- messages.map((msg, i) => (
283
- <MessageView key={i} message={msg} />
284
- ))
368
+ <>
369
+ {hiddenAbove > 0 && (
370
+ <ScrollIndicator direction="above" count={hiddenAbove} />
371
+ )}
372
+ {visibleMessages.map((msg, i) => (
373
+ <MessageView key={hiddenAbove + i} message={msg} />
374
+ ))}
375
+ {isProcessing && <ProcessingIndicator />}
376
+ {hiddenBelow > 0 && (
377
+ <ScrollIndicator direction="below" count={hiddenBelow} />
378
+ )}
379
+ </>
285
380
  )}
286
- {isProcessing && <ProcessingIndicator />}
287
381
  </Box>
288
382
 
289
383
  <HRule width={termSize.columns} />
@@ -312,7 +406,7 @@ function App({
312
406
  </Box>
313
407
 
314
408
  <Box paddingX={2} justifyContent="space-between">
315
- <Text dimColor>{"Ctrl+C exit"}</Text>
409
+ <Text dimColor>{"Ctrl+C exit ↑↓ scroll"}</Text>
316
410
  <Text dimColor>
317
411
  {userCount} {userCount === 1 ? "message" : "messages"}
318
412
  </Text>
@@ -1,5 +1,5 @@
1
1
  declare module "marked-terminal" {
2
2
  import type { MarkedExtension } from "marked";
3
- function markedTerminal(options?: Record<string, unknown>): MarkedExtension;
4
- export default markedTerminal;
3
+ export function markedTerminal(options?: Record<string, unknown>): MarkedExtension;
4
+ export default function Renderer(options?: Record<string, unknown>, highlightOptions?: Record<string, unknown>): void;
5
5
  }