From 3f1f6b0dc852522da15d243bd9c01f49fe581cba Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Sat, 10 Oct 2026 22:18:56 +0530 Subject: [PATCH 1/7] feat(ui): render markdown tables as bordered grids Detect table blocks (runs of pipe rows whose second line is a delimiter row) in the markdown renderer and lay them out as box-drawing grids with per-column alignment parsed from the delimiter row, cell word-wrapping, and column fitting when the grid would overflow the available width. Left-align the user block with the assistant marker and pass the available width to the renderer. Export markdownRowCount so the viewport estimator can predict the rendered height of markdown rows. Adds test/markdown-table.test.tsx covering natural, wide, and overflow cases. --- src/ui/App.tsx | 7 +- src/ui/components/rows.tsx | 39 ++-- src/ui/markdown.tsx | 387 ++++++++++++++++++++++++++++++----- test/markdown-table.test.tsx | 135 ++++++++++++ 4 files changed, 490 insertions(+), 78 deletions(-) create mode 100644 test/markdown-table.test.tsx diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 0367bef..f303399 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -124,6 +124,7 @@ import { toolGroupHeading, type Row, } from "./components/rows.js"; +import { markdownRowCount } from "./markdown.js"; import { LinkManager } from "./components/LinkManager.js"; import { PluginManager } from "./components/PluginManager.js"; import { @@ -3085,7 +3086,9 @@ function estimateRowLines(row: Row, width: number): number { ); case "assistant": // Blank messages render nothing (see rows.tsx). - return row.text.trim() ? 1 + wrapped(`● ${row.text}`) : 0; + return row.text.trim() + ? 1 + markdownRowCount(row.text, Math.max(20, w - 2), w, 2) + : 0; case "tool": { const heading = `${formatToolName(row.name)} ${row.summary}`; let h = 1 + wrapped(heading); @@ -3115,7 +3118,7 @@ function estimateRowLines(row: Row, width: number): number { case "error": return 1 + wrapped(`✗ ${row.text}`); case "completion": - return 4 + wrapped(row.text, w - 4); + return 4 + markdownRowCount(row.text, Math.max(20, w - 4), w - 4); default: return 1; } diff --git a/src/ui/components/rows.tsx b/src/ui/components/rows.tsx index 34f0256..c981109 100644 --- a/src/ui/components/rows.tsx +++ b/src/ui/components/rows.tsx @@ -262,35 +262,30 @@ export function DiffView({ diff, maxLines = MAX_DIFF_LINES, maxWidth }: DiffView ) } -/** Build a padded user block exactly as wide as the transcript. */ +/** + * Build the user block: `❯` sits in the same column as the assistant's `●`, + * and wrapped or continuation lines hang under the text, not the marker. + */ export function formatUserBlock(text: string, width: number, attachments: AttachmentSummary[] = []): string { - const lineWidth = Math.max(1, width) - const paddingX = Math.min(2, Math.floor((lineWidth - 1) / 2)) - const contentWidth = Math.max(1, lineWidth - paddingX * 2) + const lineWidth = Math.max(3, width) const normalizedText = (text || "").replace(/\t/g, " ") + const textLines = (normalizedText || (attachments.length > 0 ? "Attached files" : "")).split("\n") const attachmentLines = attachments.map( (attachment) => - ` 📎 ${attachment.name}${attachment.kind === "image" ? " · image" : ""}${attachment.truncated ? " · truncated" : ""}`, + `📎 ${attachment.name}${attachment.kind === "image" ? " · image" : ""}${attachment.truncated ? " · truncated" : ""}`, ) - const sourceLines = [`❯ ${normalizedText || (attachments.length > 0 ? "Attached files" : "")}`, ...attachmentLines].flatMap( - (line) => line.split("\n"), - ) - const blank = " ".repeat(lineWidth) - const output: string[] = [blank] - for (const sourceLine of sourceLines) { - if (sourceLine.length === 0) { - output.push(blank) + const output: string[] = [""] + for (const [index, line] of [...textLines, ...attachmentLines].entries()) { + const marker = index === 0 ? "❯ " : " " + if (line.length === 0) { + output.push(marker.trimEnd()) continue } - for (let offset = 0; offset < sourceLine.length; offset += contentWidth) { - output.push( - " ".repeat(paddingX) + - sourceLine.slice(offset, offset + contentWidth).padEnd(contentWidth) + - " ".repeat(paddingX), - ) + for (let offset = 0; offset < line.length; offset += lineWidth - 2) { + output.push((offset === 0 ? marker : " ") + line.slice(offset, offset + lineWidth - 2)) } } - output.push(blank) + output.push("") return output.join("\n") } @@ -318,7 +313,7 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w ● - {renderMarkdown(row.text.trimEnd())} + {renderMarkdown(row.text.trimEnd(), Math.max(20, width - 2))} ) @@ -387,7 +382,7 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w ✔ Task completed - {renderMarkdown(row.text.trimEnd())} + {renderMarkdown(row.text.trimEnd(), Math.max(20, width - 4))} ) } diff --git a/src/ui/markdown.tsx b/src/ui/markdown.tsx index 0f43204..9324f5e 100644 --- a/src/ui/markdown.tsx +++ b/src/ui/markdown.tsx @@ -1,10 +1,342 @@ import React from "react" +import stringWidth from "string-width" import { COLORS } from "../branding.js" import { Text } from "./primitives.js" +/** Inline markdown: code spans, bold, italics and links. */ const INLINE_MARKDOWN = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|(? { + const start = pending.findIndex((_, index) => index + 1 < pending.length && isDelimiterRow(pending[index + 1])) + for (const line of start < 0 ? pending : pending.slice(0, start)) blocks.push({ kind: "text", lines: [line] }) + if (start >= 0) blocks.push({ kind: "table", lines: pending.slice(start) }) + pending = [] + } + + for (const line of lines) { + if (isFence(line)) { + flushTable() + blocks.push({ kind: inCode ? "fenceClose" : "fenceOpen", lines: [line] }) + inCode = !inCode + continue + } + if (inCode) { + blocks.push({ kind: "code", lines: [line] }) + continue + } + if (isTableLine(line)) { + pending.push(line) + continue + } + flushTable() + blocks.push({ kind: "text", lines: [line] }) + } + flushTable() + return blocks +} + +/** Split a row into trimmed cells. Escaped pipes stay inside their cell. */ +function splitCells(line: string): string[] { + const cells = line.trim().replaceAll("\\|", ESCAPED_PIPE).split("|") + if (cells.length > 1) { + if (cells[0] === "") cells.shift() + if (cells[cells.length - 1] === "") cells.pop() + } + return cells.map((cell) => cell.replaceAll(ESCAPED_PIPE, "|").trim()) +} + +/** Cell text as it will appear: inline markers render away, links keep their URL. */ +function cellText(cell: string): string { + return cell.replace(INLINE_MARKDOWN, (token) => { + const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token) + if (link) return `${link[1]} ${link[2]}` + if (token.startsWith("**") || token.startsWith("__")) return token.slice(2, -2) + return token.slice(1, -1) + }) +} + +function alignmentOf(delimiterCell: string): Alignment { + const colonStart = delimiterCell.length > 1 && delimiterCell.startsWith(":") + const colonEnd = delimiterCell.length > 1 && delimiterCell.endsWith(":") + if (colonStart && colonEnd) return "center" + if (colonEnd) return "right" + return "left" +} + +/** + * Natural column widths, shrunk only when the grid would overflow `maxWidth`: columns + * narrower than an even share keep their width, the wider ones split what is left. + */ +function fitWidths(widths: number[], maxWidth: number): number[] { + const budget = Math.max(widths.length, maxWidth - (CHROME_PER_COLUMN * widths.length + CHROME_TOTAL)) + if (widths.reduce((sum, width) => sum + width, 0) <= budget) return widths + + const fitted = [...widths] + const open = widths.map((_, index) => index).sort((a, b) => widths[a] - widths[b]) + let remaining = budget + while (open.length > 0 && widths[open[0]] <= Math.floor(remaining / open.length)) { + remaining -= widths[open[0]] + open.shift() + } + open.forEach((index, rank) => { + const extra = rank >= open.length - (remaining % open.length) ? 1 : 0 + fitted[index] = Math.max(1, Math.floor(remaining / open.length) + extra) + }) + return fitted +} + +/** Word-wrap a cell to its column, breaking words that are wider than the column. */ +function wrapCell(text: string, width: number): string[] { + const lines: string[] = [] + let line = "" + for (const word of text.split(/\s+/).filter(Boolean)) { + const candidate = line ? `${line} ${word}` : word + if (stringWidth(candidate) <= width) { + line = candidate + continue + } + if (line) lines.push(line) + line = "" + for (const { segment } of GRAPHEMES.segment(word)) { + if (line && stringWidth(line + segment) > width) { + lines.push(line) + line = "" + } + line += segment + } + } + lines.push(line) + return lines +} + +/** A cell padded to its column, aligned as the separator row asks. */ +function padCell(text: string, width: number, align: Alignment): string { + const padding = Math.max(0, width - stringWidth(text)) + if (align === "center") { + const left = Math.floor(padding / 2) + return " ".repeat(left) + text + " ".repeat(padding - left) + } + if (align === "right") return " ".repeat(padding) + text + return text + " ".repeat(padding) +} + +/** + * Lay a table block out as grid lines: a bold header, then the body. Cells wrap inside + * their column, and once any body row wraps every body row gets a rule beneath it. + */ +function layoutTable(lines: string[], maxWidth: number): TableLayout { + const rows = lines.map(splitCells) + const columns = Math.max(1, ...rows.map((row) => row.length)) + const aligns = Array.from({ length: columns }, (_, column) => alignmentOf(rows[1][column] ?? "")) + const [header, , ...body] = rows.map((row) => Array.from({ length: columns }, (_, column) => cellText(row[column] ?? ""))) + const widths = fitWidths( + Array.from({ length: columns }, (_, column) => + Math.max(1, ...[header, ...body].map((row) => stringWidth(row[column]))), + ), + maxWidth, + ) + + const contentLines = (row: string[], bold: boolean): GridLine[] => { + const wrapped = row.map((cell, column) => wrapCell(cell, widths[column])) + return Array.from({ length: Math.max(...wrapped.map((cell) => cell.length)) }, (_, line) => ({ + edge: "content", + cells: wrapped.map((cell, column) => padCell(cell[line] ?? "", widths[column], aligns[column])), + bold, + })) + } + const border = (edge: EdgeKind): GridLine => ({ edge, cells: [], bold: false }) + const bodyLines = body.map((row) => contentLines(row, false)) + const ruled = bodyLines.some((row) => row.length > 1) + + return { + widths, + lines: [ + border("top"), + ...contentLines(header, true), + ...(body.length > 0 ? [border("mid")] : []), + ...bodyLines.flatMap((row, index) => (ruled && index > 0 ? [border("mid"), ...row] : row)), + border("bottom"), + ], + } +} + +/** Border characters of a grid row: `columns + 1` of them, corners on the edges. */ +function edgeChars(kind: EdgeKind, columns: number): string[] { + const [first, middle, last] = + kind === "top" + ? ["╭", "┬", "╮"] + : kind === "mid" + ? ["├", "┼", "┤"] + : kind === "bottom" + ? ["╰", "┴", "╯"] + : ["│", "│", "│"] + return Array.from({ length: columns + 1 }, (_, column) => + column === 0 ? first : column === columns ? last : middle, + ) +} + +/** One grid line: dim borders around dashes on border lines, padded cells on content lines. */ +function gridRow(line: GridLine, widths: number[], key: string): React.ReactNode[] { + return edgeChars(line.edge, widths.length).flatMap((edge, column) => { + const segments = [ + + {edge} + , + ] + if (column < widths.length) + segments.push( + line.cells.length === 0 ? ( + + {"─".repeat(widths[column] + 2)} + + ) : ( + + {` ${line.cells[column]} `} + + ), + ) + return segments + }) +} + +/** A markdown table drawn as a bordered grid no wider than `maxWidth`. */ +function tableRows(lines: string[], blockIndex: number, maxWidth: number): React.ReactNode[] { + const { widths, lines: grid } = layoutTable(lines, maxWidth) + return grid.map((line, index) => gridRow(line, widths, `t${blockIndex}-${index}`)) +} + +/** A plain markdown line: heading, list item, quote, rule, code fence or paragraph. */ +function textRows(block: Block, blockIndex: number, maxWidth: number): React.ReactNode[] { + const line = block.lines[0] + const key = `b${blockIndex}` + + if (block.kind === "fenceOpen") { + const language = /^\s*```(.*)$/.exec(line)?.[1].trim() ?? "" + return [{language ? `╭─ ${language}` : "╭─"}] + } + if (block.kind === "code") + return [ + + │ + {line} + , + ] + if (block.kind === "fenceClose") return [╰─] + + const header = /^(#{1,6})\s+(.*)$/.exec(line) + const bullet = /^(\s*)[-*]\s+(.*)$/.exec(line) + const ordered = /^(\s*)(\d+)\.\s+(.*)$/.exec(line) + const quote = /^>\s?(.*)$/.exec(line) + + if (header) return [{renderInline(header[2], `h-${blockIndex}`)}] + if (bullet) + return [{bullet[1]}• {renderInline(bullet[2], `b-${blockIndex}`)}] + if (ordered) + return [ + + {ordered[1]} + {ordered[2]}. {renderInline(ordered[3], `o-${blockIndex}`)} + , + ] + if (quote) return [│ {renderInline(quote[1], `q-${blockIndex}`)}] + if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(line)) + return [{"─".repeat(Math.max(1, maxWidth))}] + return [{renderInline(line, `p-${blockIndex}`)}] +} + +/** + * Markdown rendered as styled text. Every block — heading, list, quote, code block, + * table — contributes one or more terminal rows, and consecutive rows are separated + * by newlines so the surrounding text node keeps them on separate lines. + */ +export function renderMarkdown(markdown: string, maxWidth = 80): React.ReactNode { + const rows: React.ReactNode[] = [] + let blockIndex = 0 + + for (const block of splitBlocks(markdown.split("\n"))) { + const blockRows = + block.kind === "table" + ? tableRows(block.lines, blockIndex, maxWidth) + : textRows(block, blockIndex, maxWidth) + for (const row of blockRows) { + if (rows.length > 0) rows.push("\n") + rows.push(row) + } + blockIndex++ + } + + return <>{rows} +} + +/** + * Terminal rows `renderMarkdown(markdown, maxWidth)` occupies: a table's grid lines, + * and every other line wrapped at `wrapWidth`, the first after `prefix` columns. + */ +export function markdownRowCount(markdown: string, maxWidth: number, wrapWidth: number, prefix = 0): number { + const available = Math.max(1, wrapWidth) + return splitBlocks(markdown.split("\n")).reduce((sum, block, index) => { + if (block.kind === "table") return sum + layoutTable(block.lines, maxWidth).lines.length + const length = block.lines[0].length + (index === 0 ? prefix : 0) + return sum + Math.max(1, Math.ceil(Math.max(1, length) / available)) + }, 0) +} + function renderInline(text: string, keyPrefix: string): React.ReactNode[] { const nodes: React.ReactNode[] = [] let cursor = 0 @@ -27,7 +359,7 @@ function renderInline(text: string, keyPrefix: string): React.ReactNode[] { if (link) { nodes.push( - {link[1]} {link[2]} + {link[1]} {link[2]} , ) } @@ -38,56 +370,3 @@ function renderInline(text: string, keyPrefix: string): React.ReactNode[] { if (cursor < text.length) nodes.push(text.slice(cursor)) return nodes } - -/** Lightweight markdown rendered as OpenTUI-native styled text nodes. */ -export function renderMarkdown(markdown: string): React.ReactNode { - const output: React.ReactNode[] = [] - let inCodeBlock = false - - markdown.split("\n").forEach((line, lineIndex, lines) => { - const fence = /^\s*```(.*)$/.exec(line) - let content: React.ReactNode - - if (fence) { - if (!inCodeBlock) { - inCodeBlock = true - const language = fence[1].trim() - content = {language ? `╭─ ${language}` : "╭─"} - } else { - inCodeBlock = false - content = ╰─ - } - } else if (inCodeBlock) { - content = ( - - │ - {line} - - ) - } else { - const header = /^(#{1,6})\s+(.*)$/.exec(line) - const bullet = /^(\s*)[-*]\s+(.*)$/.exec(line) - const ordered = /^(\s*)(\d+)\.\s+(.*)$/.exec(line) - const quote = /^>\s?(.*)$/.exec(line) - - if (header) { - content = {renderInline(header[2], `h-${lineIndex}`)} - } else if (bullet) { - content = <>{bullet[1]}• {renderInline(bullet[2], `b-${lineIndex}`)} - } else if (ordered) { - content = <>{ordered[1]}{ordered[2]}. {renderInline(ordered[3], `o-${lineIndex}`)} - } else if (quote) { - content = │ {renderInline(quote[1], `q-${lineIndex}`)} - } else if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(line)) { - content = {"─".repeat(40)} - } else { - content = <>{renderInline(line, `p-${lineIndex}`)} - } - } - - output.push({content}) - if (lineIndex < lines.length - 1) output.push("\n") - }) - - return <>{output} -} diff --git a/test/markdown-table.test.tsx b/test/markdown-table.test.tsx new file mode 100644 index 0000000..4b9c3dc --- /dev/null +++ b/test/markdown-table.test.tsx @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import React from "react"; +import { testRender } from "@opentui/react/test-utils"; + +import { act } from "react" +import { Box, Text } from "../src/ui/primitives.js"; +import { markdownRowCount, renderMarkdown } from "../src/ui/markdown.js"; + +/** Render markdown into a fixed-width frame and return its lines, trailing blanks dropped. */ +async function render(markdown: string, width = 80, maxWidth = width) { + const screen = await testRender( + + {renderMarkdown(markdown, maxWidth)} + , + { width, height: 30 }, + ); + try { + await screen.renderOnce(); + const lines = screen + .captureCharFrame() + .split("\n") + .map((line) => line.trimEnd()); + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + return lines; + } finally { + act(() => screen.renderer.destroy()); + } +} + +const isGridLine = (line: string) => /^[│├╭╰]/.test(line); + +/** Column positions of every border character on a line. */ +const borderColumns = (line: string) => + [...line].flatMap((char, index) => ("│├┼┤╭┬╮╰┴╯".includes(char) ? [index] : [])); + +const TABLE = [ + "| | SFT objective | RL/DPO objective |", + "|---|---|---|", + "| Full weights | Heavy, needs offload | Very hard on 24 GB |", + "| LoRA | ✅ Easy, fast | ✅ Feasible (DPO easy, GRPO slow) |", +].join("\n"); + +const WIDE = [ + "| File | Change |", + "|---|---|", + "| src/ui/markdown.tsx | Table block detection, delimiter and alignment parsing, column fitting |", + "| src/ui/components/rows.tsx | Passes the available width to renderMarkdown |", +].join("\n"); + +test("renders a markdown table as a bordered grid at its natural width", async () => { + const lines = await render(TABLE); + const table = lines.filter(isGridLine); + + assert.equal(table.length, 6, `expected a 6-line grid, got:\n${lines.join("\n")}`); + assert.match(table[0], /^╭─+┬─+┬─+╮$/); + assert.match(table[1], /^│ {14}│ SFT objective {8}│ RL\/DPO objective {18}│$/); + assert.match(table[2], /^├─+┼─+┼─+┤$/); + assert.match(table[3], /^│ Full weights │ Heavy, needs offload │ Very hard on 24 GB\s+│$/); + assert.match(table[4], /^│ LoRA {9}│ ✅ Easy, fast\s+│ ✅ Feasible \(DPO easy, GRPO slow\) │$/); + assert.match(table[5], /^╰─+┴─+┴─+╯$/); + assert.ok(table[0].length < 80, "a narrow table should not stretch to the full width"); + assert.doesNotMatch(lines.join("\n"), /\|/); +}); + +test("aligns every border column across the grid", async () => { + const lines = await render(WIDE); + const borders = lines.filter(isGridLine).map(borderColumns); + + for (const columns of borders) assert.deepEqual(columns, borders[0]); +}); + +test("honours the separator row's alignment markers", async () => { + const lines = await render(["| left | center | right |", "|:---|:---:|---:|", "| a | b | c |"].join("\n")); + + assert.equal(lines[1], "│ left │ center │ right │"); + assert.equal(lines[3], "│ a │ b │ c │"); +}); + +test("keeps pipe rows that have no separator row as plain text", async () => { + const lines = await render("a | b\nc | d"); + + assert.deepEqual(lines, ["a | b", "c | d"]); +}); + +test("keeps a prose line with a pipe above a table as prose", async () => { + const lines = await render(["Pick a | b below", "| h1 | h2 |", "|---|---|", "| x | y |"].join("\n")); + + assert.equal(lines[0], "Pick a | b below"); + assert.equal(lines[1], "╭────┬────╮"); + assert.equal(lines[2], "│ h1 │ h2 │"); + assert.equal(lines[4], "│ x │ y │"); +}); + +test("shows the header of a table whose body has not streamed in yet", async () => { + const lines = await render("| h1 | h2 |\n|---|---|"); + + assert.deepEqual(lines, ["╭────┬────╮", "│ h1 │ h2 │", "╰────┴────╯"]); +}); + +test("wraps cells of a table wider than the available space", async () => { + const lines = await render(WIDE, 50); + const table = lines.filter(isGridLine); + + for (const line of table) assert.ok(line.length <= 50, `line overflows: ${line}`); + for (const line of table) assert.deepEqual(borderColumns(line), borderColumns(table[0])); + const text = table.map((line) => line.replace(/[│├┼┤╭┬╮╰┴╯─]/g, " ")).join(" ").replace(/\s+/g, " "); + assert.match(text, /src\/ui\/markdown\.tsx Table block detection, delimiter and alignment parsing, column fitting/); + assert.equal(lines.length, markdownRowCount(WIDE, 50, 50)); +}); + +test("strips inline markers from cells without eating underscores", async () => { + const lines = await render("| file | note |\n|---|---|\n| `src/my_file.ts` | **bold** and [docs](https://x.dev) |"); + + assert.equal(lines[3], "│ src/my_file.ts │ bold and docs https://x.dev │"); +}); + +test("renders inline markdown once in prose and list items", async () => { + const lines = await render("Use `foo` and **bar** here\n- item with `code` and *em*\n1. step **one**"); + + assert.deepEqual(lines, ["Use foo and bar here", "• item with code and em", "1. step one"]); +}); + +test("leaves surrounding markdown untouched", async () => { + const markdown = ["## Results", "", TABLE, "", "- done"].join("\n"); + const lines = await render(markdown); + + assert.equal(lines[0], "Results"); + assert.equal(lines[1], ""); + assert.match(lines[2], /^╭/); + assert.match(lines[7], /^╰/); + assert.equal(lines[8], ""); + assert.equal(lines[9], "• done"); + assert.equal(lines.length, markdownRowCount(markdown, 80, 80)); +}); From a86853bc934e1e40ab8b2865580285d142847546 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Sat, 10 Oct 2026 22:19:12 +0530 Subject: [PATCH 2/7] feat(ui): add translucent backdrop and spacing to popover overlays Add a backdrop scrim color to both themes (#000000B3 dark, #00000066 light) and paint it behind centered popups so the conversation dims while a picker is open. Give every popover a blank line between its content and its dim footer hint so the hint no longer crowds the list. --- src/ui/App.tsx | 1 + src/ui/components/FollowupPrompt.tsx | 4 +++- src/ui/components/HookTrustPrompt.tsx | 8 +++++--- src/ui/components/LinkManager.tsx | 6 ++++-- src/ui/components/McpApprovalPrompt.tsx | 4 +++- src/ui/components/McpMigrationPicker.tsx | 12 ++++++++---- src/ui/components/McpPicker.tsx | 24 +++++++++++++++--------- src/ui/components/ModelPicker.tsx | 4 +++- src/ui/components/ResumeConfirm.tsx | 6 ++++-- src/ui/components/RewindPicker.tsx | 20 +++++++++++++------- src/ui/components/ThemePicker.tsx | 4 +++- src/ui/theme.tsx | 4 ++++ 12 files changed, 66 insertions(+), 31 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index f303399..6eb44f0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -2798,6 +2798,7 @@ export function App({ flexDirection="column" alignItems="center" justifyContent="center" + backgroundColor={theme.backdrop} zIndex={100} > } - ↑/↓ select · enter confirm · 1-{Math.min(suggestions.length, 9)} quick pick + + ↑/↓ select · enter confirm · 1-{Math.min(suggestions.length, 9)} quick pick + ) } diff --git a/src/ui/components/HookTrustPrompt.tsx b/src/ui/components/HookTrustPrompt.tsx index 65a406a..8f72034 100644 --- a/src/ui/components/HookTrustPrompt.tsx +++ b/src/ui/components/HookTrustPrompt.tsx @@ -47,9 +47,11 @@ export function HookTrustPrompt({ cwd, commands, onDecision }: HookTrustPromptPr ))} {extra > 0 && … {extra} more} - - Only trust hooks from a repository you trust. (y) trust & enable · (n or Enter) keep disabled - + + + Only trust hooks from a repository you trust. (y) trust & enable · (n or Enter) keep disabled + + ) } diff --git a/src/ui/components/LinkManager.tsx b/src/ui/components/LinkManager.tsx index d9cc8b3..73e102e 100644 --- a/src/ui/components/LinkManager.tsx +++ b/src/ui/components/LinkManager.tsx @@ -94,8 +94,10 @@ export function LinkManager({ links, status, onAdd, onRemove, onClose }: LinkMan )} - {status && {status}} - ↑/↓ select · enter add/remove · d remove · esc done + + {status && {status}} + ↑/↓ select · enter add/remove · d remove · esc done + ) } diff --git a/src/ui/components/McpApprovalPrompt.tsx b/src/ui/components/McpApprovalPrompt.tsx index f9e1e19..bf00101 100644 --- a/src/ui/components/McpApprovalPrompt.tsx +++ b/src/ui/components/McpApprovalPrompt.tsx @@ -86,7 +86,9 @@ export function McpApprovalPrompt({ serverNames, onApprove }: McpApprovalPromptP {windowStart + VISIBLE_ROWS < count && ( ↓ {count - windowStart - VISIBLE_ROWS} more )} - space toggle · enter confirm · esc reject all + + space toggle · enter confirm · esc reject all + ) diff --git a/src/ui/components/McpMigrationPicker.tsx b/src/ui/components/McpMigrationPicker.tsx index 5f81824..9a1926b 100644 --- a/src/ui/components/McpMigrationPicker.tsx +++ b/src/ui/components/McpMigrationPicker.tsx @@ -83,7 +83,9 @@ export function McpMigrationPicker({ Install Claude Code or Claude Desktop, or add servers with `orbcode mcp add`. - esc to close + + esc to close + ); } @@ -132,9 +134,11 @@ export function McpMigrationPicker({ {windowStart + VISIBLE_ROWS < count && ( ↓ {count - windowStart - VISIBLE_ROWS} more )} - - space toggle · enter confirm ({checkedCount}/{count}) · esc cancel - + + + space toggle · enter confirm ({checkedCount}/{count}) · esc cancel + + ); } diff --git a/src/ui/components/McpPicker.tsx b/src/ui/components/McpPicker.tsx index d7f7f7c..87284a3 100644 --- a/src/ui/components/McpPicker.tsx +++ b/src/ui/components/McpPicker.tsx @@ -321,7 +321,9 @@ export function McpPicker({ manager, onChanged, onCancel, onDeleted }: McpPicker Add servers with: orbcode mcp add <name> <command> [args...] - esc to close + + esc to close + ) } @@ -344,7 +346,9 @@ export function McpPicker({ manager, onChanged, onCancel, onDeleted }: McpPicker {pendingConfirm.title} {pendingConfirm.body} - y confirm · n / esc cancel + + y confirm · n / esc cancel + ) } @@ -385,13 +389,15 @@ export function McpPicker({ manager, onChanged, onCancel, onDeleted }: McpPicker actionSelected={actionSelected} /> )} - - {busy - ? busyMessage - : actionMode - ? "↑/↓ select action · enter execute · esc back" - : "↑/↓ select server · enter open actions · esc close"} - + + + {busy + ? busyMessage + : actionMode + ? "↑/↓ select action · enter execute · esc back" + : "↑/↓ select server · enter open actions · esc close"} + + ) } diff --git a/src/ui/components/ModelPicker.tsx b/src/ui/components/ModelPicker.tsx index 66aafce..6889bc2 100644 --- a/src/ui/components/ModelPicker.tsx +++ b/src/ui/components/ModelPicker.tsx @@ -240,7 +240,9 @@ export function ModelPicker({ {windowStart + VISIBLE_ROWS < models.length && ( ↓ {models.length - windowStart - VISIBLE_ROWS} more )} - ↑/↓ select{effortHint} · enter confirm · esc cancel + + ↑/↓ select{effortHint} · enter confirm · esc cancel + ) } diff --git a/src/ui/components/ResumeConfirm.tsx b/src/ui/components/ResumeConfirm.tsx index 04dda8a..06cea36 100644 --- a/src/ui/components/ResumeConfirm.tsx +++ b/src/ui/components/ResumeConfirm.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react" -import { Text, useInput } from "../primitives.js" +import { Box, Text, useInput } from "../primitives.js" import { COLORS } from "../../branding.js" import { describeResumeCost, formatIdle, formatTokens, type ResumeCostEstimate } from "../../core/resumeCost.js" @@ -62,7 +62,9 @@ export function ResumeConfirm({ estimate, estimating = false, onDecision, onCanc ) })} - ↑/↓ select · enter confirm · esc cancel + + ↑/↓ select · enter confirm · esc cancel + ) } diff --git a/src/ui/components/RewindPicker.tsx b/src/ui/components/RewindPicker.tsx index 4727761..28f26be 100644 --- a/src/ui/components/RewindPicker.tsx +++ b/src/ui/components/RewindPicker.tsx @@ -86,7 +86,9 @@ export function RewindPicker({ points, onSelect, onCancel }: RewindPickerProps) {i + 1}. {label} ))} - ↑/↓ select · enter confirm · esc back + + ↑/↓ select · enter confirm · esc back + ) } @@ -103,19 +105,21 @@ export function RewindPicker({ points, onSelect, onCancel }: RewindPickerProps) Restore the code and/or conversation to the point before… {windowStart > 0 && ↑ {windowStart} more} - {visible.map((index) => { + {visible.map((index, i) => { const point = points[index] const isSelected = index === selected const color = isSelected ? COLORS.accent : undefined if (!point) { return ( - - {isSelected ? "❯ " : " "}(current) - + + + {isSelected ? "❯ " : " "}(current) + + ) } return ( - + {isSelected ? "❯ " : " "} {preview(point, 80)} @@ -127,7 +131,9 @@ export function RewindPicker({ points, onSelect, onCancel }: RewindPickerProps) {windowStart + VISIBLE_ROWS < total && ( ↓ {total - windowStart - VISIBLE_ROWS} more )} - ↑/↓ select · enter continue · esc cancel + + ↑/↓ select · enter continue · esc cancel + ) } diff --git a/src/ui/components/ThemePicker.tsx b/src/ui/components/ThemePicker.tsx index 3beb99e..d0f9b3d 100644 --- a/src/ui/components/ThemePicker.tsx +++ b/src/ui/components/ThemePicker.tsx @@ -69,7 +69,9 @@ export function ThemePicker({ current, onSelect, onCancel }: ThemePickerProps) { ) })} - ↑/↓ select · enter confirm · d/l quick select · esc cancel + + ↑/↓ select · enter confirm · d/l quick select · esc cancel + ) } diff --git a/src/ui/theme.tsx b/src/ui/theme.tsx index 67c2ef3..c82cc77 100644 --- a/src/ui/theme.tsx +++ b/src/ui/theme.tsx @@ -16,6 +16,8 @@ export interface OrbCodeTheme { background: string; panel: string; panelRaised: string; + /** Translucent scrim drawn over the app behind a centered popover. */ + backdrop: string; selection: string; primary: string; accent: string; @@ -37,6 +39,7 @@ export const DARK_THEME: OrbCodeTheme = { background: "#141414", panel: "#151B20", panelRaised: "#1D252C", + backdrop: "#000000B3", selection: "#203945", primary: "#E7F2F5", accent: "#c4fdff", @@ -58,6 +61,7 @@ export const LIGHT_THEME: OrbCodeTheme = { background: "#FFFFFF", panel: "#F1F3F5", panelRaised: "#E5E7EB", + backdrop: "#00000066", selection: "#D4EAF1", primary: "#12262F", accent: "#3a5455", From 693cb96b2a5bb538935e5d4cce147cf5a2aef823 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Sat, 10 Oct 2026 22:19:24 +0530 Subject: [PATCH 3/7] feat(sessions): expose git branch and size for resume picker entries Record the active git branch on each saved session and compute the session file size when listing. The resume picker now shows a second, dim line per entry with the idle time, branch, and file size instead of the message count, and drops to six visible rows to fit the taller entries. --- src/core/agent.ts | 1 + src/core/sessions.ts | 9 ++++- src/ui/components/SessionPicker.tsx | 58 +++++++++++++++++++---------- test/session-picker.test.tsx | 11 ++++-- 4 files changed, 54 insertions(+), 25 deletions(-) diff --git a/src/core/agent.ts b/src/core/agent.ts index e869c15..b1b7adc 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -927,6 +927,7 @@ export class Agent { contextTokens: this.contextTokens, todos: this.todos, lastGitHead: this.lastGitHead, + gitBranch: gitOutput(this.options.cwd, "git branch --show-current") || undefined, messages: this.messages, transcript: this.transcript, checkpoints: this.checkpoints, diff --git a/src/core/sessions.ts b/src/core/sessions.ts index ae2bed4..b9d094f 100644 --- a/src/core/sessions.ts +++ b/src/core/sessions.ts @@ -40,6 +40,10 @@ export interface SessionData { todos: string /** Last observed Git commit, used to report commits across resumed sessions. */ lastGitHead?: string + /** Git branch at the last save, shown in the resume picker. */ + gitBranch?: string + /** Size of the session file in bytes. Set by `listSessions`, never persisted. */ + sizeBytes?: number messages: OpenAI.Chat.ChatCompletionMessageParam[] /** Exact visible TUI history. Optional for sessions written before v0.4.2. */ transcript?: SessionTranscriptEntry[] @@ -108,9 +112,10 @@ export function listSessions(cwd?: string): SessionData[] { const sessions: SessionData[] = [] for (const file of files) { try { - const data = JSON.parse(fs.readFileSync(path.join(getSessionsDir(), file), "utf8")) as SessionData + const raw = fs.readFileSync(path.join(getSessionsDir(), file)) + const data = JSON.parse(raw.toString("utf8")) as SessionData if ((cwd === undefined || data.cwd === cwd) && Array.isArray(data.messages) && data.messages.length > 0) { - sessions.push(data) + sessions.push({ ...data, sizeBytes: raw.length }) } } catch { // skip unreadable session files diff --git a/src/ui/components/SessionPicker.tsx b/src/ui/components/SessionPicker.tsx index ae6688c..17f93c9 100644 --- a/src/ui/components/SessionPicker.tsx +++ b/src/ui/components/SessionPicker.tsx @@ -6,7 +6,7 @@ import { COLORS } from "../../branding.js" import type { SessionData } from "../../core/sessions.js" import { PopoverBox } from "./PopoverBox.js" -const VISIBLE_ROWS = 8 +const VISIBLE_ROWS = 6 interface SessionPickerProps { /** sessions from the current directory */ @@ -30,15 +30,24 @@ function shortDir(dir: string): string { return parts.length > 4 ? "…/" + parts.slice(-3).join("/") : withHome } +function plural(count: number, unit: string): string { + return `${count} ${unit}${count === 1 ? "" : "s"} ago` +} + function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime() - const minutes = Math.round(ms / 60_000) - if (minutes < 1) return "just now" - if (minutes < 60) return `${minutes}m ago` + const seconds = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000)) + if (seconds < 60) return plural(seconds, "second") + const minutes = Math.round(seconds / 60) + if (minutes < 60) return plural(minutes, "minute") const hours = Math.round(minutes / 60) - if (hours < 24) return `${hours}h ago` - const days = Math.round(hours / 24) - return `${days}d ago` + if (hours < 24) return plural(hours, "hour") + return plural(Math.round(hours / 24), "day") +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` } export function SessionPicker({ @@ -95,6 +104,7 @@ export function SessionPicker({ {title} {allSessions && · {showAll ? "all directories" : "this directory"}} + {sessions.length === 0 && ( No sessions {showAll ? "yet" : "in this directory"}. )} @@ -102,25 +112,33 @@ export function SessionPicker({ {visible.map((session, i) => { const index = windowStart + i const isSelected = index === selected - const userTurns = session.messages.filter((m) => m.role === "user").length + const details = [ + relativeTime(session.updatedAt), + session.gitBranch, + session.sizeBytes !== undefined ? formatSize(session.sizeBytes) : undefined, + showAll && session.cwd !== cwd ? shortDir(session.cwd) : undefined, + ].filter(Boolean) return ( - - {isSelected ? "❯ " : " "} - {index + 1}. {session.title || "(untitled)"} - - {" "} - · {relativeTime(session.updatedAt)} · {userTurns} message{userTurns === 1 ? "" : "s"} - {showAll && session.cwd !== cwd ? ` · ${shortDir(session.cwd)}` : ""} + + + {isSelected ? "❯ " : " "} + {session.title || "(untitled)"} + + + {" "} + {details.join(" · ")} - + ) })} {windowStart + VISIBLE_ROWS < sessions.length && ( ↓ {sessions.length - windowStart - VISIBLE_ROWS} more )} - - ↑/↓ select · enter resume{allSessions ? ` · tab ${showAll ? "this directory" : "all directories"}` : ""} · esc cancel - + + + ↑/↓ select · enter resume{allSessions ? ` · tab ${showAll ? "this directory" : "all directories"}` : ""} · esc cancel + + ) } diff --git a/test/session-picker.test.tsx b/test/session-picker.test.tsx index e98189e..bd9f23d 100644 --- a/test/session-picker.test.tsx +++ b/test/session-picker.test.tsx @@ -18,6 +18,8 @@ function session(id: string, title: string, cwd: string, minutesAgo: number): Se totalCost: 0, contextTokens: 0, todos: "", + gitBranch: "main", + sizeBytes: 1782580, messages: [{ role: "user", content: "hi" }], } as SessionData; } @@ -37,7 +39,7 @@ async function renderPicker(props: Partial {}} {...props} />, - { width: 110, height: 12 }, + { width: 110, height: 20 }, ); return { screen, selected }; } @@ -49,6 +51,7 @@ test("resume picker lists this directory first and Tab switches to all directori let frame = screen.captureCharFrame(); assert.match(frame, /this directory/); assert.match(frame, /fix login bug/); + assert.match(frame, /5 minutes ago · main · 1\.7MB/); assert.doesNotMatch(frame, /billing refactor/); assert.match(frame, /tab all directories/); @@ -61,8 +64,10 @@ test("resume picker lists this directory first and Tab switches to all directori assert.match(frame, /all directories/); assert.match(frame, /billing refactor/); // Sessions from other directories show where they live; local ones don't. - assert.match(frame, /billing refactor.*\/work\/billing-service/); - assert.doesNotMatch(frame, /fix login bug.*\/work\/app/); + assert.match(frame, /1 minute ago · main · 1\.7MB · \/work\/billing-service/); + assert.doesNotMatch(frame, /\/work\/app/); + // Entries are separated by a blank line. + assert.match(frame, /billing-service\s*│\n│\s*│\n│\s+fix login bug/); await act(async () => { screen.mockInput.pressEnter(); From d5546c15ddaaa10f28a5d1e9395c2f3b8ac30a72 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Sat, 10 Oct 2026 22:19:24 +0530 Subject: [PATCH 4/7] fix(ui): interrupt only on Esc when the composer is empty Esc previously aborted the running turn even when the composer held draft text. Clearing the draft (including paste chips) now consumes the Esc; only an Esc on an empty prompt reaches the agent as an interrupt, and a double Esc still opens the rewind picker. --- src/ui/App.tsx | 8 ++++--- src/ui/components/InputBox.tsx | 9 +++++++- test/ui-viewport.test.tsx | 38 ++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 6eb44f0..b5fcc5c 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -2216,9 +2216,6 @@ export function App({ } return; } - if (key.escape && busy && !pendingApproval && !pendingFollowup) { - agentRef.current?.abort(); - } if (key.tab && key.shift) { setApprovalMode((prev) => { const next: ApprovalMode = @@ -2739,6 +2736,11 @@ export function App({ onSubmit={handleSubmit} supportsImages={getModel(settings.model).supportsImages} onHeightChange={setInputBoxHeight} + // Esc interrupts only once the prompt is empty; an Esc that + // clears typed text never stops the running turn. + onEscape={() => { + if (busy) agentRef.current?.abort(); + }} onDoubleEscape={() => openRewind(true)} prefill={promptPrefill} /> diff --git a/src/ui/components/InputBox.tsx b/src/ui/components/InputBox.tsx index c3c7977..f55edbc 100644 --- a/src/ui/components/InputBox.tsx +++ b/src/ui/components/InputBox.tsx @@ -28,6 +28,8 @@ interface InputBoxProps { supportsImages: boolean /** Reports the complete rendered height, including autocomplete popups. */ onHeightChange?: (height: number) => void + /** Esc pressed on an empty prompt (an Esc that clears the prompt never reaches this). */ + onEscape?: () => void /** Esc pressed twice in quick succession on an empty prompt. */ onDoubleEscape?: () => void /** Replaces the prompt text whenever `id` changes (e.g. the message being edited after a rewind). */ @@ -150,6 +152,7 @@ export function InputBox({ onSubmit, supportsImages, onHeightChange, + onEscape, onDoubleEscape, prefill, }: InputBoxProps) { @@ -401,12 +404,16 @@ export function InputBox({ (input, key) => { const currentValue = valueRef.current const currentCursor = cursorRef.current - if (key.escape && (currentValue.length > 0 || attachmentsRef.current.length > 0)) { + if ( + key.escape && + (currentValue.length > 0 || attachmentsRef.current.length > 0 || pasteChipsRef.current.length > 0) + ) { clearComposer() lastEscapeRef.current = 0 return } if (key.escape) { + onEscape?.() const now = Date.now() if (onDoubleEscape && now - lastEscapeRef.current < DOUBLE_ESCAPE_MS) { lastEscapeRef.current = 0 diff --git a/test/ui-viewport.test.tsx b/test/ui-viewport.test.tsx index 566e0b7..9770fd1 100644 --- a/test/ui-viewport.test.tsx +++ b/test/ui-viewport.test.tsx @@ -137,6 +137,44 @@ test("treats a raw linefeed as Shift+Enter in the composer", async () => { } }); +test("Esc clears typed text without interrupting, then interrupts once empty", async () => { + let escapes = 0; + const screen = await testRender( + {}} + supportsImages + onEscape={() => { + escapes++; + }} + />, + { width: 80, height: 8, kittyKeyboard: true }, + ); + + try { + await act(async () => { + await screen.mockInput.typeText("draft"); + await screen.flush(); + screen.mockInput.pressEscape(); + await screen.flush(); + }); + + assert.equal(escapes, 0); + assert.doesNotMatch(screen.captureCharFrame(), /draft/); + + await act(async () => { + screen.mockInput.pressEscape(); + await screen.flush(); + }); + + assert.equal(escapes, 1); + } finally { + act(() => screen.renderer.destroy()); + } +}); + test("collapses multiline and large pastes into paste chips to avoid terminal overflow", async () => { let submittedText = ""; const screen = await testRender( From 0f0e0afdfd52a0cbeca1fb5bb9c97acb2c92e43e Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Sat, 10 Oct 2026 22:19:37 +0530 Subject: [PATCH 5/7] fix(tools): return actionable errors for invalid file targets file_write and multi_file_edit failed with a raw EISDIR when file_path was empty or named a directory. Return an isError result that names the problem instead: a missing path explains that the call arguments were likely truncated past the output limit, and a directory target says file_path must name a file. --- src/tools/executors/files.ts | 25 ++++++++++++++++++++++++- test/files.test.ts | 25 ++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/tools/executors/files.ts b/src/tools/executors/files.ts index 77c7081..317f6b8 100644 --- a/src/tools/executors/files.ts +++ b/src/tools/executors/files.ts @@ -150,9 +150,30 @@ export async function readFile(args: Record, context: ToolConte } } +/** + * Why a write/edit target is unusable, phrased so the model can recover. An + * empty file_path would otherwise resolve to the cwd and fail with EISDIR. + */ +function targetPathError(rawPath: string, filePath: string): string | undefined { + if (!rawPath.trim()) { + return "file_path is missing. The tool call arguments were likely cut off before file_path was sent (e.g. the output limit was reached mid-call). Re-issue the call with file_path set; for very large files, write a first part with file_write and append the rest with file_edit." + } + try { + if (fs.statSync(filePath).isDirectory()) { + return `${filePath} is a directory; file_path must name a file.` + } + } catch { + // Missing path: a new file. + } + return undefined +} + export async function fileWrite(args: Record, context: ToolContext): Promise { - const filePath = resolveWorkspacePath(context.cwd, String(args.file_path ?? "")) + const rawPath = String(args.file_path ?? "") + const filePath = resolveWorkspacePath(context.cwd, rawPath) const content = String(args.content ?? "") + const pathError = targetPathError(rawPath, filePath) + if (pathError) return { text: `Error writing file: ${pathError}`, isError: true } try { context.beforeWrite?.(filePath) fs.mkdirSync(path.dirname(filePath), { recursive: true }) @@ -339,6 +360,8 @@ function applyEdit(content: string, edit: EditSpec): EditOutcome { function editOneFile(context: ToolContext, edits: EditSpec[]): string[] { const filePath = resolveWorkspacePath(context.cwd, edits[0].file_path) + const pathError = targetPathError(String(edits[0].file_path ?? ""), filePath) + if (pathError) return edits.map(() => `FAILED: ${pathError}`) let content: string try { content = fs.readFileSync(filePath, "utf8") diff --git a/test/files.test.ts b/test/files.test.ts index 0ea8636..a60c9d3 100644 --- a/test/files.test.ts +++ b/test/files.test.ts @@ -4,7 +4,7 @@ import * as fs from "node:fs" import * as path from "node:path" import * as os from "node:os" -import { readFile } from "../src/tools/executors/files.js" +import { fileEdit, fileWrite, readFile } from "../src/tools/executors/files.js" import read_file_schema from "../src/tools/schemas/read_file.js" import { describeToolCall } from "../src/tools/index.js" @@ -104,3 +104,26 @@ test("readFile caps characters, not just lines (minified files, many files per c assert.ok(batch.text.length < 215_000, `got ${batch.text.length} chars`) assert.match(batch.text, /Skipped wide\.txt: this call reached its 200000-character limit/) }) + +test("fileWrite and fileEdit reject a missing file_path or a directory target", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "orbcode-test-")) + const context = { cwd: tmpDir, setTodos: () => {} } + + const missing = await fileWrite({ content: "hello" }, context) + assert.equal(missing.isError, true) + assert.match(missing.text, /file_path is missing/) + assert.doesNotMatch(missing.text, /EISDIR/) + + fs.mkdirSync(path.join(tmpDir, "sub")) + const dir = await fileWrite({ file_path: "sub", content: "hello" }, context) + assert.equal(dir.isError, true) + assert.match(dir.text, /is a directory/) + + const edit = await fileEdit({ old_string: "a", new_string: "b" }, context) + assert.equal(edit.isError, true) + assert.match(edit.text, /file_path is missing/) + + const ok = await fileWrite({ file_path: "sub/new.txt", content: "hello" }, context) + assert.equal(ok.isError, undefined) + assert.equal(fs.readFileSync(path.join(tmpDir, "sub/new.txt"), "utf8"), "hello") +}) From fb20e62cce0e238e4ee0cf34aac11032b1143571 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Sat, 10 Oct 2026 22:19:37 +0530 Subject: [PATCH 6/7] feat(tools): add prompt parameter to web_fetch for section extraction Long fetched pages are cut to a token budget. A required prompt describing what the caller is looking for lets the backend return the most relevant sections instead of the top of the page; without it the fetch returns the beginning of the page. --- src/tools/executors/web.ts | 2 +- src/tools/schemas/web_fetch.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tools/executors/web.ts b/src/tools/executors/web.ts index 7aef09e..59b7219 100644 --- a/src/tools/executors/web.ts +++ b/src/tools/executors/web.ts @@ -63,7 +63,7 @@ export async function webFetch(args: Record, context: ToolConte "Content-Type": "application/json", Authorization: `Bearer ${context.token}`, }, - body: JSON.stringify({ url: targetUrl }), + body: JSON.stringify({ url: targetUrl, prompt: typeof args.prompt === "string" ? args.prompt : undefined }), signal: AbortSignal.timeout(30_000), }) if (!response.ok) { diff --git a/src/tools/schemas/web_fetch.ts b/src/tools/schemas/web_fetch.ts index 09d94c4..a4c3fb4 100644 --- a/src/tools/schemas/web_fetch.ts +++ b/src/tools/schemas/web_fetch.ts @@ -5,7 +5,7 @@ export default { function: { name: "web_fetch", description: - "Fetch content from a URL using curl. Use this when you need to scrape or retrieve content from a web page. Returns the raw HTML/text content from the URL.", + "Fetch a web page as cleaned markdown. Long pages are cut to a budget: pass a prompt describing what you need and only the most relevant sections are returned; without one you get the top of the page.", strict: true, parameters: { type: "object", @@ -14,8 +14,13 @@ export default { type: "string", description: "The URL to fetch content from", }, + prompt: { + type: ["string", "null"], + description: + "What you are looking for on the page, e.g. 'rate limits for the batch API'. Null to read the page from the top", + }, }, - required: ["url"], + required: ["url", "prompt"], additionalProperties: false, }, }, From 939119a3a8f06a4da67c5887d6dcea311fea0779 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Sat, 10 Oct 2026 22:19:37 +0530 Subject: [PATCH 7/7] chore(release): bump version to 7.0.1 and add string-width dependency Depend on string-width for grapheme-aware width measurement in the markdown table renderer, register the new markdown-table test in the test:ui script, and bump the package version to 7.0.1. --- package-lock.json | 7 ++++--- package.json | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62c35c2..3dc428b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@matterailab/orbcode", - "version": "7.0.0", + "version": "7.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@matterailab/orbcode", - "version": "7.0.0", + "version": "7.0.1", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^3.0.85", @@ -22,7 +22,8 @@ "pdf-parse": "^2.4.5", "picomatch": "4.0.4", "react": "^19.2.0", - "read-excel-file": "^9.2.0" + "read-excel-file": "^9.2.0", + "string-width": "^7.2.0" }, "bin": { "orbcode": "bin/orbcode.js" diff --git a/package.json b/package.json index 576c7b4..70eaa39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "7.0.0", + "version": "7.0.1", "description": "OrbCode CLI — agentic coding in your terminal, by MatterAI", "type": "module", "bin": { @@ -48,7 +48,7 @@ "test:resume": "node --import tsx --test test/resume-cost.test.ts", "test:compaction": "node --import tsx --test test/compaction.test.ts test/agent-context.test.ts test/files.test.ts", "test:effort": "node --import tsx --test test/model-effort.test.ts test/model-persistence.test.ts", - "test:ui": "bun test test/ui-viewport.test.tsx test/session-picker.test.tsx test/rewind-picker.test.tsx test/resume-confirm.test.tsx test/model-picker.test.tsx test/effort-picker.test.tsx test/tool-group.test.tsx", + "test:ui": "bun test test/ui-viewport.test.tsx test/session-picker.test.tsx test/rewind-picker.test.tsx test/resume-confirm.test.tsx test/model-picker.test.tsx test/effort-picker.test.tsx test/tool-group.test.tsx test/markdown-table.test.tsx", "prepublishOnly": "npm run typecheck && npm run build" }, "dependencies": { @@ -65,7 +65,8 @@ "pdf-parse": "^2.4.5", "picomatch": "4.0.4", "react": "^19.2.0", - "read-excel-file": "^9.2.0" + "read-excel-file": "^9.2.0", + "string-width": "^7.2.0" }, "devDependencies": { "@types/node": "^20.17.0",