feat: add file tools, diff view, and markdown rendering

- Add read_file, write_file, edit_file tools with diff generation
- Inline diff viewer with line numbers, colored add/remove, and
  unmodified-lines separators (OpenCode-style)
- Lightweight markdown renderer for assistant messages: headings,
  bold, italic, inline code, code blocks, lists, blockquotes, links
- Update agent system prompts with terminal environment guidance
- Install diff package for unified diff computation

Made-with: Cursor
This commit is contained in:
Ismail Pelaseyed committed 2026-03-18 21:23:24 +01:00
1 parent 87bddb4cb3
commit a07224a7f0
9 files changed
+764 -69

No files matched your search

+7 -1
View File
@@ -9,11 +9,13 @@
"@opentui/react": "^0.1.88",
"ai": "^6.0.116",
"commander": "^12.0.0",
"diff": "^8.0.3",
"dotenv": "^16.4.0",
"react": "^19.1.0",
"zod": "^4.3.6",
},
"devDependencies": {
"@types/diff": "^8.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.1.0",
"@typescript-eslint/eslint-plugin": "^8.37.0",
@@ -146,6 +148,8 @@
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -240,7 +244,7 @@
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -514,6 +518,8 @@
"@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@opentui/core/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
+2
View File
@@ -37,11 +37,13 @@
"@opentui/react": "^0.1.88",
"ai": "^6.0.116",
"commander": "^12.0.0",
"diff": "^8.0.3",
"dotenv": "^16.4.0",
"react": "^19.1.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/diff": "^8.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.1.0",
"@typescript-eslint/eslint-plugin": "^8.37.0",
+33 -7
View File
@@ -7,28 +7,50 @@ import type { AgentMode, StreamChunk, ToolCall, ToolResult } from "../types/inde
const MAX_TOOL_ROUNDS = 400;
const ENVIRONMENT = `ENVIRONMENT:
You are running inside a terminal (CLI). Your text output is rendered in a plain terminal — not a browser, not a rich text editor.
- Use plain text only. No markdown tables, no HTML, no images, no colored text.
- Use simple markers like dashes (-) or asterisks (*) for lists.
- Use indentation and blank lines for structure.
- Keep lines under 100 characters when possible.
- Use backticks for inline code and triple backticks for code blocks — these are rendered.
- Never use unicode box-drawing, fancy borders, or ASCII art in your responses.`;
const MODE_PROMPTS: Record<AgentMode, string> = {
agent: `You are Grok CLI in Agent mode — a powerful AI coding agent. You execute tasks directly using tools.
${ENVIRONMENT}
TOOLS:
- bash: Execute any shell command — viewing files, editing (sed/tee/heredocs), searching (grep/rg/find), git, builds, packages.
- read_file: Read file contents with start_line/end_line for iterative reading. Use for examining code.
- write_file: Create new files or overwrite existing ones with full content.
- edit_file: Replace a unique string in a file with new content. The old_string must be unique — include enough context lines.
- bash: Execute shell commands — searching (grep/rg/find), git, builds, tests, package managers, etc.
- search_web: Search the web for current information, documentation, APIs, tutorials, etc.
- search_x: Search X/Twitter for real-time posts, discussions, opinions, and trends.
WORKFLOW:
1. Understand the request
2. Use bash to explore and understand the codebase
3. Make changes using bash commands
2. Use read_file and bash to explore the codebase
3. Use edit_file for targeted changes, write_file for new files or full rewrites
4. Verify changes by reading modified files
5. Run tests or builds to confirm correctness
5. Run tests or builds with bash to confirm correctness
6. Use search_web or search_x when you need up-to-date information
IMPORTANT:
- Prefer edit_file for surgical changes to existing files — it shows a clean diff.
- Use write_file only for new files or when most of the file is changing.
- Use read_file instead of cat/head/tail for reading files.
Be direct. Execute, don't just describe. Show results, not plans.`,
plan: `You are Grok CLI in Plan mode — you analyze and plan but DO NOT execute changes.
${ENVIRONMENT}
TOOLS:
- bash: ONLY use for reading files (cat, head, tail, find, ls, grep) — NEVER modify files.
- read_file: Read file contents for analysis.
- bash: ONLY for searching (find, grep, ls) — NEVER modify files.
BEHAVIOR:
- Create a detailed, step-by-step implementation plan
@@ -41,8 +63,11 @@ Format your plan with clear numbered steps and file paths.`,
ask: `You are Grok CLI in Ask mode — you answer questions clearly and thoroughly.
${ENVIRONMENT}
TOOLS:
- bash: ONLY for reading files to provide context (cat, find, ls, grep) — NEVER modify.
- read_file: Read file contents for context.
- bash: ONLY for searching (find, grep, ls) — NEVER modify.
BEHAVIOR:
- Answer the user's question directly and thoroughly
@@ -220,11 +245,12 @@ function toToolCall(part: { toolCallId: string; toolName: string; args?: unknown
function toToolResult(output: unknown): ToolResult {
if (output && typeof output === "object" && "success" in output) {
const r = output as { success: boolean; output?: string };
const r = output as { success: boolean; output?: string; diff?: ToolResult["diff"] };
return {
success: r.success,
output: r.output,
error: r.success ? undefined : r.output,
diff: r.diff,
};
}
return { success: true, output: String(output) };
+48 -1
View File
@@ -1,15 +1,18 @@
import { tool, generateText } from "ai";
import { z } from "zod";
import type { BashTool } from "../tools/bash.js";
import { readFile, writeFile, editFile } from "../tools/file.js";
import type { XaiProvider } from "./client.js";
const SEARCH_MODEL = "grok-3-mini-fast";
export function createTools(bash: BashTool, provider: XaiProvider) {
const cwd = () => bash.getCwd();
return {
bash: tool({
description:
"Execute a bash command. Use this for ALL operations: viewing files (cat, less), editing files (sed, tee), searching (grep, rg, find), git, build tools, package managers, and any other shell command. Commands run in the project working directory.",
"Execute a bash command. Use for searching (grep, rg, find), git, build tools, package managers, running tests, and any other shell command. For file read/write/edit, prefer the dedicated file tools instead.",
inputSchema: z.object({
command: z.string().describe("The bash command to execute"),
timeout: z
@@ -30,6 +33,50 @@ export function createTools(bash: BashTool, provider: XaiProvider) {
},
}),
read_file: tool({
description:
"Read the contents of a file. Returns numbered lines with a header showing the range and total line count. Use start_line/end_line to read specific sections of large files iteratively.",
inputSchema: z.object({
path: z.string().describe("File path (relative to cwd or absolute)"),
start_line: z
.number()
.optional()
.describe("First line to read (1-indexed, default: 1)"),
end_line: z
.number()
.optional()
.describe("Last line to read (inclusive, default: end of file)"),
}),
execute: async ({ path, start_line, end_line }) => {
return readFile(path, cwd(), start_line, end_line);
},
}),
write_file: tool({
description:
"Create or overwrite a file with the given content. Use for creating new files or completely rewriting existing ones. Returns a diff of the changes.",
inputSchema: z.object({
path: z.string().describe("File path (relative to cwd or absolute)"),
content: z.string().describe("The full file content to write"),
}),
execute: async ({ path, content }) => {
return writeFile(path, content, cwd());
},
}),
edit_file: tool({
description:
"Edit a file by replacing a unique string with new content. The old_string must appear exactly once in the file. Include enough surrounding context lines in old_string to make it unique. Returns a diff of the changes.",
inputSchema: z.object({
path: z.string().describe("File path (relative to cwd or absolute)"),
old_string: z.string().describe("The exact text to find (must be unique in the file)"),
new_string: z.string().describe("The replacement text"),
}),
execute: async ({ path, old_string, new_string }) => {
return editFile(path, old_string, new_string, cwd());
},
}),
search_web: tool({
description:
"Search the web for current information, documentation, APIs, tutorials, news, or any real-time data. Returns summarized results with sources.",
+119
View File
@@ -0,0 +1,119 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { dirname, resolve, isAbsolute } from "path";
import { createTwoFilesPatch } from "diff";
export interface FileDiff {
filePath: string;
additions: number;
removals: number;
patch: string;
isNew: boolean;
}
export interface FileResult {
success: boolean;
output: string;
diff?: FileDiff;
}
function resolvePath(filePath: string, cwd: string): string {
return isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
}
function computeDiff(filePath: string, before: string, after: string): FileDiff {
const patch = createTwoFilesPatch(filePath, filePath, before, after, "", "", {
context: 3,
});
let additions = 0;
let removals = 0;
for (const line of patch.split("\n")) {
if (line.startsWith("+") && !line.startsWith("+++")) additions++;
if (line.startsWith("-") && !line.startsWith("---")) removals++;
}
return { filePath, additions, removals, patch, isNew: before === "" };
}
export function readFile(filePath: string, cwd: string, startLine?: number, endLine?: number): FileResult {
try {
const full = resolvePath(filePath, cwd);
if (!existsSync(full)) {
return { success: false, output: `File not found: ${filePath}` };
}
const content = readFileSync(full, "utf-8");
const lines = content.split("\n");
const totalLines = lines.length;
const start = Math.max(0, (startLine ?? 1) - 1);
const end = Math.min(totalLines, endLine ?? totalLines);
const slice = lines.slice(start, end);
const numbered = slice.map((line, i) => `${start + i + 1} | ${line}`).join("\n");
const header = `[${filePath}: lines ${start + 1}-${end} of ${totalLines}]`;
return { success: true, output: `${header}\n${numbered}` };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, output: `Failed to read file: ${msg}` };
}
}
export function writeFile(filePath: string, content: string, cwd: string): FileResult {
try {
const full = resolvePath(filePath, cwd);
const before = existsSync(full) ? readFileSync(full, "utf-8") : "";
const dir = dirname(full);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(full, content, "utf-8");
const diff = computeDiff(filePath, before, content);
const verb = before === "" ? "Created" : "Updated";
return {
success: true,
output: `${verb} ${filePath} (+${diff.additions} -${diff.removals})`,
diff,
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, output: `Failed to write file: ${msg}` };
}
}
export function editFile(
filePath: string,
oldString: string,
newString: string,
cwd: string,
): FileResult {
try {
const full = resolvePath(filePath, cwd);
if (!existsSync(full)) {
return { success: false, output: `File not found: ${filePath}` };
}
const before = readFileSync(full, "utf-8");
const count = before.split(oldString).length - 1;
if (count === 0) {
return { success: false, output: `old_string not found in ${filePath}` };
}
if (count > 1) {
return {
success: false,
output: `old_string is not unique in ${filePath} (${count} occurrences). Include more surrounding context to make it unique.`,
};
}
const after = before.replace(oldString, newString);
writeFileSync(full, after, "utf-8");
const diff = computeDiff(filePath, before, after);
return {
success: true,
output: `Edited ${filePath} (+${diff.additions} -${diff.removals})`,
diff,
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, output: `Failed to edit file: ${msg}` };
}
}
+9
View File
@@ -1,7 +1,16 @@
export interface FileDiff {
filePath: string;
additions: number;
removals: number;
patch: string;
isNew: boolean;
}
export interface ToolResult {
success: boolean;
output?: string;
error?: string;
diff?: FileDiff;
}
export interface ToolCall {
+248 -60
View File
@@ -4,20 +4,73 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
import type { ScrollBoxRenderable, TextareaRenderable, KeyBinding } from "@opentui/core";
import { decodePasteBytes, PasteEvent } from "@opentui/core";
import type { Agent } from "../agent/agent.js";
import type { ChatEntry, ToolCall, AgentMode, ModelInfo } from "../types/index.js";
import type { ChatEntry, ToolCall, AgentMode, ModelInfo, FileDiff } from "../types/index.js";
import { MODES } from "../types/index.js";
import { getModelInfo, MODELS } from "../grok/models.js";
import { saveProjectSettings } from "../utils/settings.js";
import { dark, type Theme } from "./theme.js";
import { Markdown } from "./markdown.js";
const GROK_LOGO = [
" █▃▆▃▐▃▆▃=. <▆█",
" ▅▆< .@▆# <▆█",
"@▆░ I▀▅▌▌▌ @▆▐▌▌▃▆! <▆█ ~▆▀",
"▒▆░ .*▆▆▆▆▆ _▆▁ ▒▆= i▅▄I<▆█|▁▂/",
"+▆▐: I▁▆ _▆▁ ▆▆= ▅▂\\<▆▓|▃▄l",
" █▆▃l....▆▆! _▆▁ ,▆▀i .█▆▌ <▆█ ▃▆/",
].join("\n");
const STAR_PALETTE = ["#777777", "#666666", "#4a4a4a", "#333333", "#222222"];
type Star = { col: number; ch: string };
type Row = { stars: Star[]; grok?: number };
const HERO_ROWS: Row[] = [
{ stars: [{ col: 0, ch: "·" }, { col: 13, ch: "*" }, { col: 21, ch: "·" }, { col: 34, ch: "·" }] },
{ stars: [{ col: 3, ch: "*" }, { col: 11, ch: "·" }, { col: 17, ch: "·" }, { col: 25, ch: "*" }] },
{ stars: [{ col: 6, ch: "·" }, { col: 12, ch: "·" }, { col: 15, ch: "·" }, { col: 18, ch: "·" }, { col: 24, ch: "·" }] },
{ stars: [{ col: 2, ch: "·" }, { col: 10, ch: "·" }, { col: 19, ch: "·" }, { col: 27, ch: "·" }], grok: 13 },
{ stars: [{ col: 6, ch: "·" }, { col: 12, ch: "·" }, { col: 15, ch: "·" }, { col: 18, ch: "·" }, { col: 24, ch: "·" }] },
{ stars: [{ col: 3, ch: "·" }, { col: 11, ch: "*" }, { col: 17, ch: "·" }, { col: 25, ch: "·" }] },
{ stars: [{ col: 0, ch: "*" }, { col: 13, ch: "·" }, { col: 21, ch: "*" }, { col: 34, ch: "·" }] },
];
function HeroLogo({ t }: { t: Theme }) {
const [tick, setTick] = useState(0);
const starIdx = useRef(0);
useEffect(() => {
const id = setInterval(() => setTick(n => n + 1), 900);
return () => clearInterval(id);
}, []);
starIdx.current = 0;
const nextColor = () => {
const i = starIdx.current++;
return STAR_PALETTE[(i * 7 + tick * 3 + i * tick) % STAR_PALETTE.length];
};
return (
<box flexDirection="column" alignItems="center">
{HERO_ROWS.map((row, r) => {
const els: React.ReactNode[] = [];
let cursor = 0;
for (const star of row.stars) {
if (row.grok !== undefined && cursor <= row.grok && star.col > row.grok) {
els.push(" ".repeat(row.grok - cursor));
els.push(<span key={`g${r}`} style={{ fg: t.primary }}>{"Grok"}</span>);
cursor = row.grok + 4;
}
const gap = star.col - cursor;
if (gap > 0) els.push(" ".repeat(gap));
els.push(<span key={`s${r}-${star.col}`} style={{ fg: nextColor() }}>{star.ch}</span>);
cursor = star.col + 1;
}
if (row.grok !== undefined && cursor <= row.grok) {
els.push(" ".repeat(row.grok - cursor));
els.push(<span key={`g${r}`} style={{ fg: t.primary }}>{"Grok"}</span>);
cursor = row.grok + 4;
}
els.push(" ".repeat(Math.max(0, 35 - cursor)));
return <text key={r}>{els}</text>;
})}
</box>
);
}
const SPLIT = {
topLeft: "", bottomLeft: "", vertical: "┃", topRight: "",
@@ -30,6 +83,11 @@ const EMPTY = {
bottomRight: "", horizontal: " ", bottomT: "", topT: "",
cross: "", leftT: "", rightT: "",
};
const LINE = {
topLeft: "━", bottomLeft: "━", vertical: "", topRight: "━",
bottomRight: "━", horizontal: "━", bottomT: "━", topT: "━",
cross: "━", leftT: "━", rightT: "━",
};
interface SlashMenuItem {
id: string;
@@ -311,23 +369,17 @@ export function App({ agent, initialMessage, onExit }: AppProps) {
))}
{/* Active tool calls — pending inline */}
{activeToolCalls.map((tc, i) => (
<InlineTool key={i} t={t} icon="~" pending>{toolLabel(tc)}</InlineTool>
<InlineTool key={i} t={t} pending>{toolLabel(tc)}</InlineTool>
))}
{/* Reasoning */}
{streamReasoning && (
<box paddingLeft={2} marginTop={1} border={["left"]} customBorderChars={SPLIT} borderColor={t.backgroundElement}>
<text fg={t.textDim}><i>{"Thinking: "}{trunc(streamReasoning, 300)}</i></text>
</box>
)}
{/* Streaming assistant content */}
{streamContent && (
<box paddingLeft={3} marginTop={1} flexShrink={0}>
<text fg={t.text}>{streamContent}</text>
<Markdown content={streamContent} t={t} />
</box>
)}
{/* Waiting indicator */}
{isProcessing && !streamContent && activeToolCalls.length === 0 && (
<InlineTool t={t} icon="~" pending>{"Thinking..."}</InlineTool>
<ShimmerText t={t} text="Planning next moves" />
)}
</scrollbox>
{/* Prompt */}
@@ -343,9 +395,9 @@ export function App({ agent, initialMessage, onExit }: AppProps) {
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} />
<box flexShrink={0} alignItems="center">
<text fg={t.text}>{GROK_LOGO}</text>
<HeroLogo t={t} />
</box>
<box height={3} minHeight={0} flexShrink={1} />
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} flexShrink={0}>
<PromptBox t={t} inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker} showSlashMenu={showSlashMenu}
onSubmit={handleSubmit} onPaste={handlePaste} pasteBlocks={pasteBlocks}
@@ -404,7 +456,7 @@ function PromptBox({ t, inputRef, isProcessing, showModelPicker, showSlashMenu,
modelInfo: ReturnType<typeof getModelInfo>; placeholder?: string;
}) {
return (
<box>
<box backgroundColor={t.backgroundPanel}>
<box>
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} backgroundColor={t.backgroundElement} flexShrink={0}>
<textarea
@@ -418,7 +470,7 @@ function PromptBox({ t, inputRef, isProcessing, showModelPicker, showSlashMenu,
/>
</box>
</box>
<box flexDirection="row" justifyContent="space-between" alignItems="center" backgroundColor={t.backgroundPanel} paddingLeft={2} paddingRight={2} border={["top", "bottom"]} borderColor={t.backgroundPanel} customBorderChars={EMPTY}>
<box flexDirection="row" justifyContent="space-between" alignItems="center" backgroundColor={t.backgroundPanel} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={2} alignItems="center">
<text fg={modeInfo.color}><b>{modeInfo.label}</b>{" "}</text>
<text fg={t.text}>{modelInfo?.name || model}</text>
@@ -446,7 +498,7 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index:
return (
<box
border={["left"]} customBorderChars={SPLIT} borderColor={modeColor}
marginTop={index === 0 ? 0 : 1}
marginTop={index === 0 ? 0 : 1} marginBottom={1}
>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={t.backgroundPanel} flexShrink={0}>
<text fg={t.text}>{entry.content}</text>
@@ -457,7 +509,7 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index:
case "assistant":
return (
<box paddingLeft={3} marginTop={1} flexShrink={0}>
<text fg={t.text}>{entry.content}</text>
<Markdown content={entry.content} t={t} />
</box>
);
@@ -473,45 +525,25 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index:
case "tool_result": {
const name = entry.toolCall?.function.name || "tool";
const ok = entry.toolResult?.success ?? true;
const args = toolArgs(entry.toolCall);
const output = entry.content;
const diff = entry.toolResult?.diff;
if (name === "bash" && output && output.split("\n").length > 3) {
const lines = output.split("\n");
const preview = lines.slice(0, 10).join("\n") + (lines.length > 10 ? "\n…" : "");
if ((name === "write_file" || name === "edit_file") && diff) {
const label = name === "write_file"
? `Write ${diff.filePath}`
: `Edit ${diff.filePath}`;
return (
<box
border={["left"]} customBorderChars={SPLIT} borderColor={t.background}
paddingTop={1} paddingBottom={1} paddingLeft={2} marginTop={1} gap={1}
backgroundColor={t.backgroundPanel}
>
<text fg={t.textMuted}>{"# Shell"}</text>
<box gap={1}>
<text fg={t.text}>{"$ "}{args}</text>
<text fg={t.text}>{preview}</text>
</box>
<box gap={0}>
<InlineTool t={t} pending={false}>{label}</InlineTool>
<DiffView t={t} diff={diff} />
</box>
);
}
if (name === "search_web" || name === "search_x") {
if (output && output.length > 100) {
return (
<box
border={["left"]} customBorderChars={SPLIT} borderColor={t.background}
paddingTop={1} paddingBottom={1} paddingLeft={2} marginTop={1} gap={1}
backgroundColor={t.backgroundPanel}
>
<text fg={t.textMuted}>{"# "}{name === "search_web" ? "Web Search" : "X Search"}{` "${args}"`}</text>
<text fg={t.text}>{trunc(output, 500)}</text>
</box>
);
}
return <InlineTool t={t} icon="◈" pending={false}>{name === "search_web" ? "Web" : "X"}{` Search "${args}"`}</InlineTool>;
}
if (name === "read_file") return <InlineTool t={t} pending={false}>{`Read ${tryParseArg(entry.toolCall, "path") || args}`}</InlineTool>;
if (name === "search_web" || name === "search_x") return <InlineTool t={t} pending={false}>{name === "search_web" ? "Web" : "X"}{` Search "${args}"`}</InlineTool>;
return <InlineTool t={t} icon={ok ? "$" : "✗"} pending={false}>{name === "bash" ? args : `${name} ${args}`}</InlineTool>;
return <InlineTool t={t} pending={false}>{name === "bash" ? args : `${name} ${args}`}</InlineTool>;
}
default:
@@ -519,11 +551,155 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index:
}
}
function InlineTool({ t, icon, pending, children }: { t: Theme; icon: string; pending: boolean; children: React.ReactNode }) {
/* ── Diff View ────────────────────────────────────────────────── */
type DiffRow =
| { kind: "context"; oldNum: number; newNum: number; text: string }
| { kind: "added"; newNum: number; text: string }
| { kind: "removed"; oldNum: number; text: string }
| { kind: "separator"; count: number };
const MAX_DIFF_ROWS = 20;
const LINE_NUM_WIDTH = 4;
function parsePatch(patch: string): DiffRow[] {
const lines = patch.split("\n");
const rows: DiffRow[] = [];
let oldLine = 0;
let newLine = 0;
let prevOldEnd = 0;
for (const line of lines) {
const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (hunkMatch) {
oldLine = parseInt(hunkMatch[1], 10);
newLine = parseInt(hunkMatch[2], 10);
const skipped = oldLine - prevOldEnd - 1;
if (skipped > 0) {
rows.push({ kind: "separator", count: skipped });
}
continue;
}
if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("\\")) continue;
if (line.startsWith("Index:") || line.startsWith("====")) continue;
if (line.startsWith("-")) {
rows.push({ kind: "removed", oldNum: oldLine, text: line.slice(1) });
oldLine++;
prevOldEnd = oldLine - 1;
} else if (line.startsWith("+")) {
rows.push({ kind: "added", newNum: newLine, text: line.slice(1) });
newLine++;
} else if (line.length > 0 || (oldLine > 0 && newLine > 0)) {
const content = line.startsWith(" ") ? line.slice(1) : line;
rows.push({ kind: "context", oldNum: oldLine, newNum: newLine, text: content });
oldLine++;
newLine++;
prevOldEnd = oldLine - 1;
}
}
return rows;
}
function DiffView({ t, diff }: { t: Theme; diff: FileDiff }) {
const rows = parsePatch(diff.patch);
if (rows.length === 0) return null;
const truncated = rows.length > MAX_DIFF_ROWS;
const visible = truncated ? rows.slice(0, MAX_DIFF_ROWS) : rows;
const pad = (n: number | undefined) =>
n !== undefined ? String(n).padStart(LINE_NUM_WIDTH) : " ".repeat(LINE_NUM_WIDTH);
return (
<box paddingLeft={3} marginTop={0}>
<text fg={pending ? t.text : t.textMuted}>
{pending ? `~ ` : `${icon} `}{children}
<box paddingLeft={5} marginTop={0} flexShrink={0}>
<box flexDirection="column">
{/* Header */}
<box backgroundColor={t.diffHeader} paddingLeft={1} paddingRight={1}>
<text>
<span style={{ fg: t.diffHeaderFg }}>{diff.filePath}</span>
<span style={{ fg: t.textDim }}>{" "}</span>
<span style={{ fg: t.diffRemovedFg }}>{`-${diff.removals}`}</span>
<span style={{ fg: t.textDim }}>{" "}</span>
<span style={{ fg: t.diffAddedFg }}>{`+${diff.additions}`}</span>
</text>
</box>
{/* Rows */}
{visible.map((row, i) => {
if (row.kind === "separator") {
return (
<box key={i} backgroundColor={t.diffSeparator} paddingLeft={1}>
<text fg={t.diffSeparatorFg}>{"⌃ "}{row.count}{" unmodified lines"}</text>
</box>
);
}
if (row.kind === "removed") {
return (
<box key={i} backgroundColor={t.diffRemoved} flexDirection="row">
<text fg={t.diffRemovedLineNum}>{pad(row.oldNum)}</text>
<text fg={t.diffRemovedFg}>{" " + row.text}</text>
</box>
);
}
if (row.kind === "added") {
return (
<box key={i} backgroundColor={t.diffAdded} flexDirection="row">
<text fg={t.diffAddedLineNum}>{pad(row.newNum)}</text>
<text fg={t.diffAddedFg}>{" " + row.text}</text>
</box>
);
}
return (
<box key={i} backgroundColor={t.diffContext} flexDirection="row">
<text fg={t.diffLineNumber}>{pad(row.oldNum)}</text>
<text fg={t.diffContextFg}>{" " + row.text}</text>
</box>
);
})}
{truncated && (
<box backgroundColor={t.diffSeparator} paddingLeft={1}>
<text fg={t.diffSeparatorFg}>{"⌃ "}{rows.length - MAX_DIFF_ROWS}{" more lines"}</text>
</box>
)}
</box>
</box>
);
}
function ShimmerText({ t, text }: { t: Theme; text: string }) {
const [pos, setPos] = useState(0);
useEffect(() => {
const id = setInterval(() => setPos(p => (p + 1) % (text.length + 4)), 60);
return () => clearInterval(id);
}, [text]);
const dim = "#333333";
const mid = "#555555";
const bright = "#888888";
return (
<box paddingLeft={3}>
<text>
<span style={{ fg: "#555555" }}>{"◇ "}</span>
{text.split("").map((ch, i) => {
const dist = Math.abs(i - pos);
const fg = dist === 0 ? bright : dist === 1 ? mid : dim;
return <span key={i} style={{ fg }}>{ch}</span>;
})}
</text>
</box>
);
}
function InlineTool({ t, pending, children }: { t: Theme; pending: boolean; children: React.ReactNode }) {
return (
<box paddingLeft={3}>
<text fg={t.textMuted}>
{"→ "}{children}
</text>
</box>
);
@@ -632,11 +808,23 @@ function ModelPickerModal({ t, currentModel, selectedIndex, width, height, searc
function toolArgs(tc?: ToolCall): string {
if (!tc) return "";
try { const a = JSON.parse(tc.function.arguments); return tc.function.name === "bash" ? a.command || "" : a.query || ""; } catch { return ""; }
try {
const a = JSON.parse(tc.function.arguments);
if (tc.function.name === "bash") return a.command || "";
if (tc.function.name === "read_file" || tc.function.name === "write_file" || tc.function.name === "edit_file") return a.path || "";
return a.query || "";
} catch { return ""; }
}
function tryParseArg(tc: ToolCall | undefined, key: string): string {
if (!tc) return "";
try { return JSON.parse(tc.function.arguments)[key] || ""; } catch { return ""; }
}
function toolLabel(tc: ToolCall): string {
const args = toolArgs(tc);
if (tc.function.name === "bash") return args || "Running command...";
if (tc.function.name === "read_file") return `Read ${args}`;
if (tc.function.name === "write_file") return `Write ${args}`;
if (tc.function.name === "edit_file") return `Edit ${args}`;
if (tc.function.name === "search_web") return `Web Search "${args}"`;
if (tc.function.name === "search_x") return `X Search "${args}"`;
return `${tc.function.name} ${args}`;
+275
View File
@@ -0,0 +1,275 @@
import React from "react";
import type { Theme } from "./theme.js";
/* ── Block types ─────────────────────────────────────────────── */
type Block =
| { kind: "paragraph"; text: string }
| { kind: "heading"; level: number; text: string }
| { kind: "code"; lang: string; code: string }
| { kind: "list"; ordered: boolean; items: string[] }
| { kind: "hr" }
| { kind: "blockquote"; text: string };
/* ── Block parser ────────────────────────────────────────────── */
function parseBlocks(src: string): Block[] {
const lines = src.split("\n");
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
const fenceMatch = line.match(/^(`{3,}|~{3,})(\S*)/);
if (fenceMatch) {
const fence = fenceMatch[1];
const lang = fenceMatch[2] || "";
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith(fence)) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing fence
blocks.push({ kind: "code", lang, code: codeLines.join("\n") });
continue;
}
// Heading
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
if (headingMatch) {
blocks.push({ kind: "heading", level: headingMatch[1].length, text: headingMatch[2] });
i++;
continue;
}
// Horizontal rule
if (/^(\s*[-*_]\s*){3,}$/.test(line)) {
blocks.push({ kind: "hr" });
i++;
continue;
}
// Unordered list
if (/^\s*[-*+]\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*+]\s+/, ""));
i++;
}
blocks.push({ kind: "list", ordered: false, items });
continue;
}
// Ordered list
if (/^\s*\d+[.)]\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+[.)]\s+/, ""));
i++;
}
blocks.push({ kind: "list", ordered: true, items });
continue;
}
// Blockquote
if (line.startsWith("> ")) {
const bqLines: string[] = [];
while (i < lines.length && lines[i].startsWith("> ")) {
bqLines.push(lines[i].slice(2));
i++;
}
blocks.push({ kind: "blockquote", text: bqLines.join("\n") });
continue;
}
// Empty line — skip
if (line.trim() === "") {
i++;
continue;
}
// Paragraph — collect until empty line or block start
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].match(/^(`{3,}|~{3,})/) &&
!lines[i].match(/^#{1,6}\s+/) &&
!lines[i].match(/^(\s*[-*_]\s*){3,}$/) &&
!lines[i].match(/^\s*[-*+]\s+/) &&
!lines[i].match(/^\s*\d+[.)]\s+/) &&
!lines[i].startsWith("> ")
) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length > 0) {
blocks.push({ kind: "paragraph", text: paraLines.join("\n") });
}
}
return blocks;
}
/* ── Inline parser ───────────────────────────────────────────── */
type InlineNode =
| { kind: "text"; text: string }
| { kind: "bold"; text: string }
| { kind: "italic"; text: string }
| { kind: "bolditalic"; text: string }
| { kind: "code"; text: string }
| { kind: "link"; text: string; url: string };
function parseInline(src: string): InlineNode[] {
const nodes: InlineNode[] = [];
// Order matters: bold-italic before bold before italic
const pattern =
/(`[^`]+`)|(\*\*\*[^*]+\*\*\*|___[^_]+___)|( \*\*[^*]+\*\*|__[^_]+__)|(\*[^*]+\*|_[^_]+_)|(\[[^\]]+\]\([^)]+\))/g;
let last = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(src)) !== null) {
if (match.index > last) {
nodes.push({ kind: "text", text: src.slice(last, match.index) });
}
const m = match[0];
if (match[1]) {
// inline code
nodes.push({ kind: "code", text: m.slice(1, -1) });
} else if (match[2]) {
// bold italic
nodes.push({ kind: "bolditalic", text: m.slice(3, -3) });
} else if (match[3]) {
// bold
nodes.push({ kind: "bold", text: m.slice(2, -2) });
} else if (match[4]) {
// italic
nodes.push({ kind: "italic", text: m.slice(1, -1) });
} else if (match[5]) {
// link
const linkMatch = m.match(/\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
nodes.push({ kind: "link", text: linkMatch[1], url: linkMatch[2] });
}
}
last = match.index + m.length;
}
if (last < src.length) {
nodes.push({ kind: "text", text: src.slice(last) });
}
return nodes;
}
/* ── Inline renderer ─────────────────────────────────────────── */
function InlineContent({ text, t }: { text: string; t: Theme }) {
const nodes = parseInline(text);
return (
<text>
{nodes.map((node, i) => {
switch (node.kind) {
case "text":
return <span key={i} style={{ fg: t.text }}>{node.text}</span>;
case "bold":
return <b key={i}><span style={{ fg: t.mdBold }}>{node.text}</span></b>;
case "italic":
return <i key={i}><span style={{ fg: t.mdItalic }}>{node.text}</span></i>;
case "bolditalic":
return <b key={i}><i><span style={{ fg: t.mdBold }}>{node.text}</span></i></b>;
case "code":
return <span key={i} style={{ fg: t.mdCode }}>{`\`${node.text}\``}</span>;
case "link":
return (
<span key={i}>
<span style={{ fg: t.mdLinkText }}>{node.text}</span>
<span style={{ fg: t.textDim }}>{" ("}</span>
<u><span style={{ fg: t.mdLink }}>{node.url}</span></u>
<span style={{ fg: t.textDim }}>{")"}</span>
</span>
);
}
})}
</text>
);
}
/* ── Block renderer ──────────────────────────────────────────── */
function renderBlock(block: Block, key: number, t: Theme): React.ReactNode {
switch (block.kind) {
case "heading":
return (
<box key={key} marginTop={key > 0 ? 1 : 0}>
<text>
<b><span style={{ fg: t.mdHeading }}>{block.text}</span></b>
</text>
</box>
);
case "paragraph":
return (
<box key={key}>
<InlineContent text={block.text} t={t} />
</box>
);
case "code":
return (
<box key={key} marginTop={0} marginBottom={0} backgroundColor={t.mdCodeBlockBg} paddingLeft={1} paddingRight={1}>
<text fg={t.mdCodeBlockFg}>{block.code}</text>
</box>
);
case "list":
return (
<box key={key} flexDirection="column">
{block.items.map((item, i) => (
<box key={i} flexDirection="row">
<text>
<span style={{ fg: t.mdListBullet }}>
{block.ordered ? `${i + 1}. ` : " • "}
</span>
</text>
<InlineContent text={item} t={t} />
</box>
))}
</box>
);
case "blockquote":
return (
<box key={key} paddingLeft={1} borderColor={t.mdItalic} border={["left"]}>
<text>
<i><span style={{ fg: t.mdItalic }}>{block.text}</span></i>
</text>
</box>
);
case "hr":
return (
<box key={key} marginTop={0} marginBottom={0}>
<text fg={t.mdHr}>{"─".repeat(40)}</text>
</box>
);
}
}
/* ── Public component ────────────────────────────────────────── */
export function Markdown({ content, t }: { content: string; t: Theme }) {
const blocks = parseBlocks(content);
return (
<box flexDirection="column">
{blocks.map((block, i) => renderBlock(block, i, t))}
</box>
);
}
+23
View File
@@ -11,6 +11,29 @@ export const dark = {
accent: "#5c9cf5",
selected: "#ffffff",
selectedBg: "#2a2a2a",
diffAdded: "#1e3a1e",
diffAddedFg: "#8adf8a",
diffAddedLineNum: "#3a6a3a",
diffRemoved: "#3a1e1e",
diffRemovedFg: "#df8a8a",
diffRemovedLineNum: "#6a3a3a",
diffContext: "#161616",
diffContextFg: "#999999",
diffLineNumber: "#555555",
diffHeader: "#1c1c1c",
diffHeaderFg: "#888888",
diffSeparator: "#1a1a1a",
diffSeparatorFg: "#555555",
mdHeading: "#e0e0e0",
mdBold: "#e8a465",
mdItalic: "#e5c07b",
mdCode: "#6abf6a",
mdCodeBlockBg: "#141414",
mdCodeBlockFg: "#c0c0c0",
mdLink: "#5c9cf5",
mdLinkText: "#d4a0d4",
mdHr: "#333333",
mdListBullet: "#666666",
} as const;
export type Theme = typeof dark;