From a07224a7f0b499df5604d7fa3db5c7f1fbe33967 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Wed, 18 Mar 2026 21:23:24 +0100 Subject: [PATCH] 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 --- bun.lock | 8 +- package.json | 2 + src/agent/agent.ts | 40 +++++- src/grok/tools.ts | 49 ++++++- src/tools/file.ts | 119 +++++++++++++++++ src/types/index.ts | 9 ++ src/ui/app.tsx | 308 +++++++++++++++++++++++++++++++++++--------- src/ui/markdown.tsx | 275 +++++++++++++++++++++++++++++++++++++++ src/ui/theme.ts | 23 ++++ 9 files changed, 764 insertions(+), 69 deletions(-) create mode 100644 src/tools/file.ts create mode 100644 src/ui/markdown.tsx diff --git a/bun.lock b/bun.lock index e35b067..e285eb8 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index e37fbf7..cd64de9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 02a34a2..ac52fa4 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -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 = { 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) }; diff --git a/src/grok/tools.ts b/src/grok/tools.ts index 6d5f962..f4a0afd 100644 --- a/src/grok/tools.ts +++ b/src/grok/tools.ts @@ -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.", diff --git a/src/tools/file.ts b/src/tools/file.ts new file mode 100644 index 0000000..a7bec3e --- /dev/null +++ b/src/tools/file.ts @@ -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}` }; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 575b839..e9f95b5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 { diff --git a/src/ui/app.tsx b/src/ui/app.tsx index cc486fc..8f5e8cc 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -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 ( + + {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({"Grok"}); + cursor = row.grok + 4; + } + const gap = star.col - cursor; + if (gap > 0) els.push(" ".repeat(gap)); + els.push({star.ch}); + cursor = star.col + 1; + } + + if (row.grok !== undefined && cursor <= row.grok) { + els.push(" ".repeat(row.grok - cursor)); + els.push({"Grok"}); + cursor = row.grok + 4; + } + + els.push(" ".repeat(Math.max(0, 35 - cursor))); + return {els}; + })} + + ); +} 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) => ( - {toolLabel(tc)} + {toolLabel(tc)} ))} - {/* Reasoning */} - {streamReasoning && ( - - {"Thinking: "}{trunc(streamReasoning, 300)} - - )} {/* Streaming assistant content */} {streamContent && ( - {streamContent} + )} {/* Waiting indicator */} {isProcessing && !streamContent && activeToolCalls.length === 0 && ( - {"Thinking..."} + )} {/* Prompt */} @@ -343,9 +395,9 @@ export function App({ agent, initialMessage, onExit }: AppProps) { - {GROK_LOGO} + - + ; placeholder?: string; }) { return ( - +