Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions agents/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@ export class GeneralAgent extends WrappedAgent {
You are a general-purpose AI/LLM agent that can assist with a wide range of tasks.
You can take many actions via the tools provided to you.
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.
Unless otherwise specified, you should respond in Markdown, and in Table format when you have multiple items to list.
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.

## Response Formatting (IMPORTANT)
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:
- Use **bold** and *italics* for emphasis
- Use \`inline code\` for commands, function names, file paths, and technical terms
- Use fenced code blocks (\`\`\`) with language tags for any code snippets or command output
- Use tables (GFM pipe syntax) whenever presenting structured or comparative data
- Use headings (##, ###) to organize longer responses into sections
- Use bullet/numbered lists instead of prose for steps or multiple items
- 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
`;
super("GeneralAgent", instructions, config, logger);
}
Expand Down
111 changes: 110 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

94 changes: 90 additions & 4 deletions components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Box, useApp } from "ink";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Box, useApp, useStdout } from "ink";
import type { MCPServerStreamableHttp } from "@openai/agents";
import type { GeneralAgent } from "../agents/general.js";
import type { Logger, LogEvent } from "../classes/logger.js";
import type { Config } from "../classes/config.js";
import { renderMarkdown } from "../utils/markdown.js";
import { MessageArea } from "./MessageArea.js";
import { InputBox } from "./InputBox.js";
import type { MessageData } from "./Message.js";

type FocusArea = "input" | "messages";

const SCROLL_STEP = 4;

interface AppProps {
agent: GeneralAgent;
mcpServer: MCPServerStreamableHttp;
Expand All @@ -26,7 +31,17 @@ export function App({
onExit,
}: AppProps) {
const { exit } = useApp();
const [messages, setMessages] = useState<MessageData[]>([]);
const { stdout } = useStdout();
const welcomeMsg =
"Ready! Type a message to chat. Press Tab to scroll wide content with ← →.";
const [messages, setMessages] = useState<MessageData[]>([
{
role: "system",
content: welcomeMsg,
rendered: welcomeMsg,
timestamp: new Date().toLocaleTimeString(),
},
]);
const [streamingText, setStreamingText] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [toolCallCount, setToolCallCount] = useState(0);
Expand All @@ -35,6 +50,60 @@ export function App({
const queueRef = useRef<string[]>([]);
const [queueCount, setQueueCount] = useState(0);
const initialProcessed = useRef(false);
const [focusArea, setFocusArea] = useState<FocusArea>("input");
const [scrollX, setScrollX] = useState(0);
const [scrollY, setScrollY] = useState(0);

// Subtract 4 for MessageArea border (2) + paddingX (2)
const viewportWidth = (stdout?.columns || 80) - 4;
// Leave room for input box (~5 lines) + border (2) + padding (2)
const viewportHeight = (stdout?.rows || 24) - 9;

// Derive the last assistant message's rendered content for scroll viewport
const lastAssistantRendered = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") return messages[i].rendered;
}
return "";
}, [messages]);

const handleToggleFocus = useCallback(() => {
setFocusArea((prev) => {
if (prev === "input") {
setScrollX(0);
setScrollY(0);
return "messages";
}
return "input";
});
}, []);

const handleScroll = useCallback((direction: "left" | "right") => {
if (direction === "left") {
setScrollX((prev) => Math.max(0, prev - SCROLL_STEP));
} else {
setScrollX((prev) => prev + SCROLL_STEP);
}
}, []);

const handleScrollVertical = useCallback(
(direction: "up" | "down") => {
if (direction === "up") {
setScrollY((prev) => Math.max(0, prev - SCROLL_STEP));
} else {
const maxY = Math.max(
0,
lastAssistantRendered.split("\n").length - viewportHeight,
);
setScrollY((prev) => Math.min(maxY, prev + SCROLL_STEP));
}
},
[lastAssistantRendered, viewportHeight],
);

const handleReturnToInput = useCallback(() => {
setFocusArea("input");
}, []);

// Subscribe to logger events
useEffect(() => {
Expand All @@ -44,6 +113,7 @@ export function App({
{
role: "system",
content: event.message,
rendered: event.message,
timestamp: event.timestamp,
},
]);
Expand All @@ -67,6 +137,7 @@ export function App({
{
role: "user",
content: input,
rendered: `?> ${input}`,
timestamp: logger.getTimestamp(),
},
]);
Expand Down Expand Up @@ -96,15 +167,18 @@ export function App({
{
role: "assistant",
content,
rendered: renderMarkdown(content),
timestamp: logger.getTimestamp(),
},
]);
} catch (err) {
const errMsg = `Error: ${err instanceof Error ? err.message : String(err)}`;
setMessages((prev) => [
...prev,
{
role: "system",
content: `Error: ${err instanceof Error ? err.message : String(err)}`,
content: errMsg,
rendered: errMsg,
timestamp: logger.getTimestamp(),
},
]);
Expand Down Expand Up @@ -155,6 +229,7 @@ export function App({
{
role: "system",
content: "Conversation history cleared!",
rendered: "Conversation history cleared!",
timestamp: logger.getTimestamp(),
},
]);
Expand Down Expand Up @@ -182,11 +257,22 @@ export function App({
isStreaming={isStreaming}
toolCallCount={toolCallCount}
startTime={startTime}
scrollX={scrollX}
scrollY={scrollY}
focused={focusArea === "messages"}
viewportWidth={viewportWidth}
viewportHeight={viewportHeight}
lastAssistantRendered={lastAssistantRendered}
/>
<InputBox
onSubmit={handleSubmit}
contextDir={config.context_dir}
queueCount={queueCount}
focus={focusArea === "input"}
onToggleFocus={handleToggleFocus}
onScroll={handleScroll}
onScrollVertical={handleScrollVertical}
onReturnToInput={handleReturnToInput}
/>
</Box>
);
Expand Down
48 changes: 44 additions & 4 deletions components/InputBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ interface InputBoxProps {
onSubmit: (input: string) => void;
contextDir: string;
queueCount: number;
focus: boolean;
onToggleFocus: () => void;
onScroll: (direction: "left" | "right") => void;
onScrollVertical: (direction: "up" | "down") => void;
onReturnToInput: () => void;
}

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

export function InputBox({ onSubmit, contextDir, queueCount }: InputBoxProps) {
export function InputBox({
onSubmit,
contextDir,
queueCount,
focus,
onToggleFocus,
onScroll,
onScrollVertical,
onReturnToInput,
}: InputBoxProps) {
const [value, setValue] = useState("");
const historyFile = path.join(contextDir, "chat_history.txt");
const historyRef = useRef<string[]>(loadHistory(historyFile));
Expand All @@ -48,7 +62,29 @@ export function InputBox({ onSubmit, contextDir, queueCount }: InputBoxProps) {
[onSubmit, historyFile],
);

useInput((_input, key) => {
useInput((input, key) => {
if (key.tab) {
onToggleFocus();
return;
}

if (!focus) {
// In scroll mode: arrows scroll, printable chars return to input
if (key.upArrow) {
onScrollVertical("up");
} else if (key.downArrow) {
onScrollVertical("down");
} else if (key.leftArrow) {
onScroll("left");
} else if (key.rightArrow) {
onScroll("right");
} else if (input && !key.ctrl && !key.meta) {
onReturnToInput();
}
return;
}

// In input mode: up/down for history
const history = historyRef.current;
if (key.upArrow && history.length > 0) {
if (indexRef.current === -1) {
Expand All @@ -72,12 +108,16 @@ export function InputBox({ onSubmit, contextDir, queueCount }: InputBoxProps) {

return (
<Box flexDirection="column">
<Box borderStyle="round" borderColor="green" paddingX={1}>
<Box
borderStyle="round"
borderColor={focus ? "green" : "gray"}
paddingX={1}
>
<TextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
focus={true}
focus={focus}
showCursor={true}
placeholder="Type your message..."
/>
Expand Down
34 changes: 25 additions & 9 deletions components/Message.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,43 @@
import { memo } from "react";
import { Box, Text } from "ink";
import { applyHorizontalScroll } from "../utils/markdown.js";

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

export interface MessageData {
role: MessageRole;
content: string;
rendered: string;
timestamp: string;
}

export function Message({ role, content, timestamp }: MessageData) {
interface MessageProps extends MessageData {
scrollX?: number;
viewportWidth?: number;
}

export const Message = memo(function Message({
role,
rendered,
timestamp,
scrollX = 0,
viewportWidth = Infinity,
}: MessageProps) {
const color =
role === "user" ? "green" : role === "system" ? "gray" : "white";
const prefix = role === "user" ? "?> " : "";

const display = applyHorizontalScroll(rendered, scrollX, viewportWidth);

return (
<Box flexDirection="column" marginBottom={1}>
<Text>
<Text dimColor>{timestamp} </Text>
<Text color={color}>
{prefix}
{content}
</Text>
<Text dimColor>
{applyHorizontalScroll(timestamp, scrollX, viewportWidth)}
</Text>
{role === "assistant" ? (
<Text>{display}</Text>
) : (
<Text color={color}>{display}</Text>
)}
</Box>
);
}
});
Loading