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:
1 parent
87bddb4cb3
commit
a07224a7f0
9 files changed
+764
-69
No files matched your search
@@ -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=="],
|
||||
|
||||
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user