Skip to content

Commit 37a5e20

Browse files
evantahlerclaude
andauthored
Add markdown rendering, horizontal/vertical scrolling, and performance optimizations (#11)
Introduces prettier markdown rendering with tables, code blocks, bold/italic text using marked + marked-terminal. Adds Tab-based scroll mode with arrow keys for navigating large responses (both horizontal and vertical). Implements Ink's <Static> component for completed messages to remove them from the render tree, keeping the dynamic tree small so typing remains fast with large output. Includes tests for markdown rendering and horizontal scroll clipping. Updates agent system prompt to hint at markdown formatting and scroll mode availability. Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 777f735 commit 37a5e20

10 files changed

Lines changed: 483 additions & 43 deletions

File tree

agents/general.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,16 @@ export class GeneralAgent extends WrappedAgent {
1111
You are a general-purpose AI/LLM agent that can assist with a wide range of tasks.
1212
You can take many actions via the tools provided to you.
1313
ALWAYS prefer to call tools, but only when you are CERTAIN that you understand the user's request. Otherwise, ask clarifying questions. Do not rely on any pre-existing knowledge - only use the tools provided to you.
14-
Unless otherwise specified, you should respond in Markdown, and in Table format when you have multiple items to list.
15-
You are in a terminal window that is ${cols} columns wide and ${rows} rows tall. Keep your Markdown output (tables, ASCII art, code blocks, etc.) within ${cols} columns so it renders correctly without wrapping.
14+
15+
## Response Formatting (IMPORTANT)
16+
You MUST format ALL responses using Markdown. This is critical — your output is rendered through a Markdown engine in the terminal, so raw text without Markdown formatting will look plain and unhelpful. Specifically:
17+
- Use **bold** and *italics* for emphasis
18+
- Use \`inline code\` for commands, function names, file paths, and technical terms
19+
- Use fenced code blocks (\`\`\`) with language tags for any code snippets or command output
20+
- Use tables (GFM pipe syntax) whenever presenting structured or comparative data
21+
- Use headings (##, ###) to organize longer responses into sections
22+
- Use bullet/numbered lists instead of prose for steps or multiple items
23+
- The terminal supports horizontal scrolling (Tab to enter scroll mode, ← → to pan), so do NOT truncate or abbreviate wide tables — render them at full width
1624
`;
1725
super("GeneralAgent", instructions, config, logger);
1826
}

bun.lock

Lines changed: 110 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/App.tsx

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1-
import { useState, useEffect, useCallback, useRef } from "react";
2-
import { Box, useApp } from "ink";
1+
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2+
import { Box, useApp, useStdout } from "ink";
33
import type { MCPServerStreamableHttp } from "@openai/agents";
44
import type { GeneralAgent } from "../agents/general.js";
55
import type { Logger, LogEvent } from "../classes/logger.js";
66
import type { Config } from "../classes/config.js";
7+
import { renderMarkdown } from "../utils/markdown.js";
78
import { MessageArea } from "./MessageArea.js";
89
import { InputBox } from "./InputBox.js";
910
import type { MessageData } from "./Message.js";
1011

12+
type FocusArea = "input" | "messages";
13+
14+
const SCROLL_STEP = 4;
15+
1116
interface AppProps {
1217
agent: GeneralAgent;
1318
mcpServer: MCPServerStreamableHttp;
@@ -26,7 +31,17 @@ export function App({
2631
onExit,
2732
}: AppProps) {
2833
const { exit } = useApp();
29-
const [messages, setMessages] = useState<MessageData[]>([]);
34+
const { stdout } = useStdout();
35+
const welcomeMsg =
36+
"Ready! Type a message to chat. Press Tab to scroll wide content with ← →.";
37+
const [messages, setMessages] = useState<MessageData[]>([
38+
{
39+
role: "system",
40+
content: welcomeMsg,
41+
rendered: welcomeMsg,
42+
timestamp: new Date().toLocaleTimeString(),
43+
},
44+
]);
3045
const [streamingText, setStreamingText] = useState("");
3146
const [isStreaming, setIsStreaming] = useState(false);
3247
const [toolCallCount, setToolCallCount] = useState(0);
@@ -35,6 +50,60 @@ export function App({
3550
const queueRef = useRef<string[]>([]);
3651
const [queueCount, setQueueCount] = useState(0);
3752
const initialProcessed = useRef(false);
53+
const [focusArea, setFocusArea] = useState<FocusArea>("input");
54+
const [scrollX, setScrollX] = useState(0);
55+
const [scrollY, setScrollY] = useState(0);
56+
57+
// Subtract 4 for MessageArea border (2) + paddingX (2)
58+
const viewportWidth = (stdout?.columns || 80) - 4;
59+
// Leave room for input box (~5 lines) + border (2) + padding (2)
60+
const viewportHeight = (stdout?.rows || 24) - 9;
61+
62+
// Derive the last assistant message's rendered content for scroll viewport
63+
const lastAssistantRendered = useMemo(() => {
64+
for (let i = messages.length - 1; i >= 0; i--) {
65+
if (messages[i].role === "assistant") return messages[i].rendered;
66+
}
67+
return "";
68+
}, [messages]);
69+
70+
const handleToggleFocus = useCallback(() => {
71+
setFocusArea((prev) => {
72+
if (prev === "input") {
73+
setScrollX(0);
74+
setScrollY(0);
75+
return "messages";
76+
}
77+
return "input";
78+
});
79+
}, []);
80+
81+
const handleScroll = useCallback((direction: "left" | "right") => {
82+
if (direction === "left") {
83+
setScrollX((prev) => Math.max(0, prev - SCROLL_STEP));
84+
} else {
85+
setScrollX((prev) => prev + SCROLL_STEP);
86+
}
87+
}, []);
88+
89+
const handleScrollVertical = useCallback(
90+
(direction: "up" | "down") => {
91+
if (direction === "up") {
92+
setScrollY((prev) => Math.max(0, prev - SCROLL_STEP));
93+
} else {
94+
const maxY = Math.max(
95+
0,
96+
lastAssistantRendered.split("\n").length - viewportHeight,
97+
);
98+
setScrollY((prev) => Math.min(maxY, prev + SCROLL_STEP));
99+
}
100+
},
101+
[lastAssistantRendered, viewportHeight],
102+
);
103+
104+
const handleReturnToInput = useCallback(() => {
105+
setFocusArea("input");
106+
}, []);
38107

39108
// Subscribe to logger events
40109
useEffect(() => {
@@ -44,6 +113,7 @@ export function App({
44113
{
45114
role: "system",
46115
content: event.message,
116+
rendered: event.message,
47117
timestamp: event.timestamp,
48118
},
49119
]);
@@ -67,6 +137,7 @@ export function App({
67137
{
68138
role: "user",
69139
content: input,
140+
rendered: `?> ${input}`,
70141
timestamp: logger.getTimestamp(),
71142
},
72143
]);
@@ -96,15 +167,18 @@ export function App({
96167
{
97168
role: "assistant",
98169
content,
170+
rendered: renderMarkdown(content),
99171
timestamp: logger.getTimestamp(),
100172
},
101173
]);
102174
} catch (err) {
175+
const errMsg = `Error: ${err instanceof Error ? err.message : String(err)}`;
103176
setMessages((prev) => [
104177
...prev,
105178
{
106179
role: "system",
107-
content: `Error: ${err instanceof Error ? err.message : String(err)}`,
180+
content: errMsg,
181+
rendered: errMsg,
108182
timestamp: logger.getTimestamp(),
109183
},
110184
]);
@@ -155,6 +229,7 @@ export function App({
155229
{
156230
role: "system",
157231
content: "Conversation history cleared!",
232+
rendered: "Conversation history cleared!",
158233
timestamp: logger.getTimestamp(),
159234
},
160235
]);
@@ -182,11 +257,22 @@ export function App({
182257
isStreaming={isStreaming}
183258
toolCallCount={toolCallCount}
184259
startTime={startTime}
260+
scrollX={scrollX}
261+
scrollY={scrollY}
262+
focused={focusArea === "messages"}
263+
viewportWidth={viewportWidth}
264+
viewportHeight={viewportHeight}
265+
lastAssistantRendered={lastAssistantRendered}
185266
/>
186267
<InputBox
187268
onSubmit={handleSubmit}
188269
contextDir={config.context_dir}
189270
queueCount={queueCount}
271+
focus={focusArea === "input"}
272+
onToggleFocus={handleToggleFocus}
273+
onScroll={handleScroll}
274+
onScrollVertical={handleScrollVertical}
275+
onReturnToInput={handleReturnToInput}
190276
/>
191277
</Box>
192278
);

components/InputBox.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ interface InputBoxProps {
88
onSubmit: (input: string) => void;
99
contextDir: string;
1010
queueCount: number;
11+
focus: boolean;
12+
onToggleFocus: () => void;
13+
onScroll: (direction: "left" | "right") => void;
14+
onScrollVertical: (direction: "up" | "down") => void;
15+
onReturnToInput: () => void;
1116
}
1217

1318
const DELIM = "\0";
@@ -26,7 +31,16 @@ function saveToHistory(historyFile: string, entry: string) {
2631
fs.appendFileSync(historyFile, entry + DELIM);
2732
}
2833

29-
export function InputBox({ onSubmit, contextDir, queueCount }: InputBoxProps) {
34+
export function InputBox({
35+
onSubmit,
36+
contextDir,
37+
queueCount,
38+
focus,
39+
onToggleFocus,
40+
onScroll,
41+
onScrollVertical,
42+
onReturnToInput,
43+
}: InputBoxProps) {
3044
const [value, setValue] = useState("");
3145
const historyFile = path.join(contextDir, "chat_history.txt");
3246
const historyRef = useRef<string[]>(loadHistory(historyFile));
@@ -48,7 +62,29 @@ export function InputBox({ onSubmit, contextDir, queueCount }: InputBoxProps) {
4862
[onSubmit, historyFile],
4963
);
5064

51-
useInput((_input, key) => {
65+
useInput((input, key) => {
66+
if (key.tab) {
67+
onToggleFocus();
68+
return;
69+
}
70+
71+
if (!focus) {
72+
// In scroll mode: arrows scroll, printable chars return to input
73+
if (key.upArrow) {
74+
onScrollVertical("up");
75+
} else if (key.downArrow) {
76+
onScrollVertical("down");
77+
} else if (key.leftArrow) {
78+
onScroll("left");
79+
} else if (key.rightArrow) {
80+
onScroll("right");
81+
} else if (input && !key.ctrl && !key.meta) {
82+
onReturnToInput();
83+
}
84+
return;
85+
}
86+
87+
// In input mode: up/down for history
5288
const history = historyRef.current;
5389
if (key.upArrow && history.length > 0) {
5490
if (indexRef.current === -1) {
@@ -72,12 +108,16 @@ export function InputBox({ onSubmit, contextDir, queueCount }: InputBoxProps) {
72108

73109
return (
74110
<Box flexDirection="column">
75-
<Box borderStyle="round" borderColor="green" paddingX={1}>
111+
<Box
112+
borderStyle="round"
113+
borderColor={focus ? "green" : "gray"}
114+
paddingX={1}
115+
>
76116
<TextInput
77117
value={value}
78118
onChange={setValue}
79119
onSubmit={handleSubmit}
80-
focus={true}
120+
focus={focus}
81121
showCursor={true}
82122
placeholder="Type your message..."
83123
/>

components/Message.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,43 @@
1+
import { memo } from "react";
12
import { Box, Text } from "ink";
3+
import { applyHorizontalScroll } from "../utils/markdown.js";
24

35
export type MessageRole = "user" | "assistant" | "system";
46

57
export interface MessageData {
68
role: MessageRole;
79
content: string;
10+
rendered: string;
811
timestamp: string;
912
}
1013

11-
export function Message({ role, content, timestamp }: MessageData) {
14+
interface MessageProps extends MessageData {
15+
scrollX?: number;
16+
viewportWidth?: number;
17+
}
18+
19+
export const Message = memo(function Message({
20+
role,
21+
rendered,
22+
timestamp,
23+
scrollX = 0,
24+
viewportWidth = Infinity,
25+
}: MessageProps) {
1226
const color =
1327
role === "user" ? "green" : role === "system" ? "gray" : "white";
14-
const prefix = role === "user" ? "?> " : "";
28+
29+
const display = applyHorizontalScroll(rendered, scrollX, viewportWidth);
1530

1631
return (
1732
<Box flexDirection="column" marginBottom={1}>
18-
<Text>
19-
<Text dimColor>{timestamp} </Text>
20-
<Text color={color}>
21-
{prefix}
22-
{content}
23-
</Text>
33+
<Text dimColor>
34+
{applyHorizontalScroll(timestamp, scrollX, viewportWidth)}
2435
</Text>
36+
{role === "assistant" ? (
37+
<Text>{display}</Text>
38+
) : (
39+
<Text color={color}>{display}</Text>
40+
)}
2541
</Box>
2642
);
27-
}
43+
});

0 commit comments

Comments
 (0)