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", 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/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/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, }, }, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 0367bef..b5fcc5c 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 { @@ -2215,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 = @@ -2738,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} /> @@ -2797,6 +2800,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/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/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/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/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/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/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", 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") +}) 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)); +}); 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(); 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(