From 86bd96e04ae24a0249c0a8ed61a545271fac3d58 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Tue, 13 Oct 2026 23:24:50 +0530 Subject: [PATCH 1/3] feat(resume): add compact and resume option on high usage (v7.0.4) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- src/ui/App.tsx | 14 ++++++++++++-- src/ui/components/ResumeConfirm.tsx | 9 +++++++-- test/compaction.test.ts | 14 ++++++++++++++ test/resume-confirm.test.tsx | 13 +++++++++++-- 6 files changed, 51 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab19cfe..5269f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.4] - 2026-10-13 + +### Added + +- **"Compact and resume" option in the cold-cache resume confirmation prompt.** When resuming a session with an expired prompt cache or large context (`ResumeConfirm`), you can now pick `2. Compact and resume` (between `Resume` and `Start a new conversation`) to resume the session and immediately run normal conversation compaction (`/compact`) so subsequent turns carry the compacted summary instead of the full history. + ## [7.0.3] - 2026-10-13 ### Fixed diff --git a/package.json b/package.json index 1bfea27..20dbf7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "7.0.3", + "version": "7.0.4", "description": "OrbCode CLI — agentic coding in your terminal, by MatterAI", "type": "module", "bin": { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index b5fcc5c..2e2545c 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -904,6 +904,9 @@ export function App({ reasoningBufferRef.current = ""; setStreamingReasoning(""); } + if (agentRef.current) { + setContextTokens(agentRef.current.lastContextTokens); + } setBusy(false); maybeFetchTitle(); refreshUsage(); @@ -1080,7 +1083,7 @@ export function App({ ); const resumeSession = useCallback( - (session: SessionData) => { + (session: SessionData): boolean => { setResumableSessions(null); setPendingResume(null); // A session from another directory continues in that directory, so its @@ -1095,7 +1098,7 @@ export function App({ kind: "error", text: `Can't resume "${session.title || session.id}": its directory ${session.cwd} no longer exists.`, }); - return; + return false; } const oldManager = mcpManagerRef.current; mcpManagerRef.current = null; @@ -1147,6 +1150,7 @@ export function App({ kind: "info", text: `Resumed session: ${session.title || session.id}${switchedDir ? ` · working directory is now ${session.cwd}` : ""}`, }); + return true; }, [createAgent, clearQueue, discardAgent, pushRow, resetTranscript], ); @@ -2082,6 +2086,12 @@ export function App({ resumeSession(session); return; } + if (decision === "compact") { + if (resumeSession(session)) { + handleCommand("/compact"); + } + return; + } handleCommand("/new"); pushRow({ kind: "info", diff --git a/src/ui/components/ResumeConfirm.tsx b/src/ui/components/ResumeConfirm.tsx index 06cea36..5048dbc 100644 --- a/src/ui/components/ResumeConfirm.tsx +++ b/src/ui/components/ResumeConfirm.tsx @@ -5,7 +5,7 @@ import { COLORS } from "../../branding.js" import { describeResumeCost, formatIdle, formatTokens, type ResumeCostEstimate } from "../../core/resumeCost.js" import { PopoverBox } from "./PopoverBox.js" -export type ResumeDecision = "resume" | "new" +export type ResumeDecision = "resume" | "compact" | "new" interface ResumeConfirmProps { estimate: ResumeCostEstimate @@ -17,6 +17,11 @@ interface ResumeConfirmProps { const OPTIONS: Array<{ decision: ResumeDecision; label: string; hint?: string }> = [ { decision: "resume", label: "Resume" }, + { + decision: "compact", + label: "Compact and resume", + hint: "Summarize the conversation so later turns use less context", + }, { decision: "new", label: "Start a new conversation", @@ -27,7 +32,7 @@ const OPTIONS: Array<{ decision: ResumeDecision; label: string; hint?: string }> /** * Shown before resuming a session whose prompt cache has expired: the first * turn re-reads the whole context at the uncached price, so a long-idle, - * large session gets a chance to start fresh instead. + * large session gets a chance to compact or start fresh instead. */ export function ResumeConfirm({ estimate, estimating = false, onDecision, onCancel }: ResumeConfirmProps) { const [selected, setSelected] = useState(0) diff --git a/test/compaction.test.ts b/test/compaction.test.ts index 6b90063..ae50347 100644 --- a/test/compaction.test.ts +++ b/test/compaction.test.ts @@ -106,6 +106,20 @@ function session(contextTokens: number, opts: { turns?: number; charsPerReply?: } } +test("compacting immediately upon resuming replaces history with the summary and updates contextTokens", async () => { + const client = new Client((_m, tools) => (tools === 0 ? summary() : final())) + const { agent } = makeAgent(client, session(200_000)) + await agent.compact() + assert.equal(client.summaries.length, 1) + assert.ok(agent.lastContextTokens < 5_000, `expected compacted contextTokens < 5000, got ${agent.lastContextTokens}`) + await agent.runTurn("now add tests") + const next = client.requests.at(-1)!.messages + assert.deepEqual(next.map((m) => m.role), ["user", "system", "user"]) + assert.match(String(next[0].content), /SUMMARY/) + assert.match(String(next[1].content), /^# Environment/) + assert.match(String(next[2].content), /now add tests\n\n\d+ tokens left<\/total_tokens>$/) +}) + test("a resumed session past 80% compacts before its first request and keeps the new message verbatim", async () => { const client = new Client((_m, tools) => (tools === 0 ? summary() : final())) const { agent } = makeAgent(client, session(200_000)) diff --git a/test/resume-confirm.test.tsx b/test/resume-confirm.test.tsx index 6327ca8..d5538be 100644 --- a/test/resume-confirm.test.tsx +++ b/test/resume-confirm.test.tsx @@ -29,7 +29,8 @@ test("resume confirm explains the cold-cache cost and defaults to Resume", async assert.match(frame, /inactive for 1d 1h 17m and is 785k tokens long/); assert.match(frame, /22% of your weekly usage limit/); assert.match(frame, /❯ 1\. Resume/); - assert.match(frame, /2\. Start a new conversation/); + assert.match(frame, /2\. Compact and resume/); + assert.match(frame, /3\. Start a new conversation/); await act(async () => { screen.mockInput.pressArrow("down"); @@ -39,7 +40,15 @@ test("resume confirm explains the cold-cache cost and defaults to Resume", async screen.mockInput.pressEnter(); await screen.flush(); }); - assert.deepEqual(decisions, ["new"]); + await act(async () => { + screen.mockInput.pressArrow("down"); + await screen.flush(); + }); + await act(async () => { + screen.mockInput.pressEnter(); + await screen.flush(); + }); + assert.deepEqual(decisions, ["compact", "new"]); } finally { act(() => screen.renderer.destroy()); } From 075df2d4d792751128ff88ba618d1e9896d71f3f Mon Sep 17 00:00:00 2001 From: code-crusher Date: Wed, 14 Oct 2026 10:15:21 +0530 Subject: [PATCH 2/3] feat(ui): clickable OSC 8 hyperlinks across markdown and completion output --- CHANGELOG.md | 4 + src/index.tsx | 5 + src/ui/App.tsx | 38 ++++++- src/ui/LoginView.tsx | 7 +- src/ui/markdown.tsx | 195 ++++++++++++++++++++++++++++++++--- src/ui/primitives.tsx | 37 ++++++- test/markdown-table.test.tsx | 106 ++++++++++++++++++- 7 files changed, 373 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5269f9e..fb79922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **"Compact and resume" option in the cold-cache resume confirmation prompt.** When resuming a session with an expired prompt cache or large context (`ResumeConfirm`), you can now pick `2. Compact and resume` (between `Resume` and `Start a new conversation`) to resume the session and immediately run normal conversation compaction (`/compact`) so subsequent turns carry the compacted summary instead of the full history. +### Fixed + +- **All Markdown links and URLs in `attempt_completion` and assistant responses are now clickable OSC 8 terminal hyperlinks across macOS, Linux, and Windows.** `Text` (`src/ui/primitives.tsx`) now supports an `href` prop backed by OpenTUI's native OSC 8 hyperlink attributes, and `renderMarkdown` (`src/ui/markdown.tsx`) attaches `href` links to `[label](url)` links (both the label and URL, including URLs with balanced parentheses), `` autolinks, bare `http://` / `https://` URLs in prose, lists, quotes, bold/italic spans, inline code spans, fenced code blocks, and Markdown table cells (including long URLs that wrap across lines inside narrow table columns or inside `attempt_completion`'s bordered box). In addition to native terminal OSC 8 handling (`Cmd+Click` / `Ctrl+Click` / `Shift+Cmd+Click`), `App.tsx` resolves the rendered cell's link URL (`getLinkUrlAt`) on direct clicks when terminal mouse reporting forwards mouse events to the TUI, opening the link in the default browser while preserving drag-to-select clipboard copying. Interactive startup (`src/index.tsx`) also defaults `COLORTERM=truecolor` when unset so OpenTUI's renderer enables OSC 8 output even in terminals or multiplexers that do not export `COLORTERM`. + ## [7.0.3] - 2026-10-13 ### Fixed diff --git a/src/index.tsx b/src/index.tsx index 4e5eb47..5825658 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -315,6 +315,11 @@ async function main(): Promise { }) const initialMode = loadSettings().theme const initialTheme = initialMode === "light" ? LIGHT_THEME : DARK_THEME + // Ensure OpenTUI's native capability detector enables OSC 8 hyperlinks and + // 24-bit color even in terminals/multiplexers that don't export COLORTERM. + if (!process.env.COLORTERM) { + process.env.COLORTERM = "truecolor" + } const renderer = await createCliRenderer({ targetFps: 60, gatherStats: false, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 2e2545c..0f4078f 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -11,7 +11,7 @@ import { useSelectionHandler, useTerminalDimensions, } from "@opentui/react"; -import { Box, Text, useApp, useInput } from "./primitives.js"; +import { Box, Text, getLinkUrlAt, useApp, useInput } from "./primitives.js"; import { useTheme, useThemeMode, @@ -472,10 +472,16 @@ export function App({ }, 2000); }, []); + const linkClickStartRef = useRef<{ x: number; y: number } | null>(null); + const linkHoveredRef = useRef(false); + useSelectionHandler((selection) => { try { const text = selection.getSelectedText(); - if (!text || text.trim().length === 0) return; + if (!text || text.trim().length === 0) { + renderer.clearSelection(); + return; + } // Only confirm when a clipboard mechanism actually succeeded. if (copyToClipboard(text, renderer)) { showToast(termCols < 40 ? "✓ Text copied" : "✓ Text copied to clipboard"); @@ -2562,6 +2568,34 @@ export function App({ position="relative" backgroundColor={theme.background} shouldFill + onMouseDown={(event) => { + if (event.button === 0) { + linkClickStartRef.current = { x: event.x, y: event.y }; + } + }} + onMouseUp={(event) => { + if (event.button !== 0 || event.defaultPrevented) { + linkClickStartRef.current = null; + return; + } + const start = linkClickStartRef.current; + linkClickStartRef.current = null; + const isPointClick = + (start !== null && start.x === event.x && start.y === event.y) || + event.modifiers.ctrl; + if (!isPointClick) return; + const url = getLinkUrlAt(renderer, event.x, event.y); + if (!url) return; + renderer.clearSelection(); + void open(url).catch(() => {}); + }} + onMouseMove={(event) => { + const hasLink = Boolean(getLinkUrlAt(renderer, event.x, event.y)); + if (hasLink !== linkHoveredRef.current) { + linkHoveredRef.current = hasLink; + renderer.setMousePointer(hasLink ? "pointer" : "default"); + } + }} onMouseScroll={(event) => { if (view !== "chat" || !event.scroll) return; const direction = event.scroll.direction; diff --git a/src/ui/LoginView.tsx b/src/ui/LoginView.tsx index e4db491..51442bb 100644 --- a/src/ui/LoginView.tsx +++ b/src/ui/LoginView.tsx @@ -106,7 +106,12 @@ export function LoginView({ onLogin }: LoginViewProps) { Approve the "Authorize OrbCode CLI" dialog at: - {authorizeUrl} + + {" "} + + {authorizeUrl} + + Esc to cancel ) : ( diff --git a/src/ui/markdown.tsx b/src/ui/markdown.tsx index 9324f5e..a7d3f04 100644 --- a/src/ui/markdown.tsx +++ b/src/ui/markdown.tsx @@ -4,8 +4,141 @@ 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 = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|(?]+>)/g + +const BARE_URL = /https?:\/\/[^\s<>`"']+/g + +interface InlineStyle { + color?: string + bold?: boolean + italic?: boolean + underline?: boolean +} + +function splitTrailingUrlPunctuation(raw: string): { url: string; trailing: string } { + let end = raw.length + while (end > 0) { + const ch = raw[end - 1] + if (/[.,;:!?'"]/.test(ch)) { + end-- + continue + } + if (ch === ")") { + const candidate = raw.slice(0, end) + const opens = (candidate.match(/\(/g) ?? []).length + const closes = (candidate.match(/\)/g) ?? []).length + if (closes > opens) { + end-- + continue + } + } + if (ch === "]") { + const candidate = raw.slice(0, end) + const opens = (candidate.match(/\[/g) ?? []).length + const closes = (candidate.match(/\]/g) ?? []).length + if (closes > opens) { + end-- + continue + } + } + break + } + return { url: raw.slice(0, end), trailing: raw.slice(end) } +} + +function extractUrls(text: string): string[] { + const urls: string[] = [] + let match: RegExpExecArray | null + BARE_URL.lastIndex = 0 + while ((match = BARE_URL.exec(text))) { + const { url } = splitTrailingUrlPunctuation(match[0]) + if (url) urls.push(url) + } + return urls +} + +function renderStyledSegment(text: string, key: string, style?: InlineStyle): React.ReactNode { + if (!style || (!style.color && !style.bold && !style.italic && !style.underline)) return text + return ( + + {text} + + ) +} + +function renderTextWithUrls(text: string, keyPrefix: string, style?: InlineStyle, fallbackUrls?: string[]): React.ReactNode[] { + const nodes: React.ReactNode[] = [] + let cursor = 0 + let match: RegExpExecArray | null + BARE_URL.lastIndex = 0 + let index = 0 + + while ((match = BARE_URL.exec(text))) { + const { url, trailing } = splitTrailingUrlPunctuation(match[0]) + if (!url) continue + if (match.index > cursor) { + nodes.push(renderStyledSegment(text.slice(cursor, match.index), `${keyPrefix}-t${index}`, style)) + } + nodes.push( + + {url} + , + ) + if (trailing) { + nodes.push(renderStyledSegment(trailing, `${keyPrefix}-p${index}`, style)) + } + cursor = match.index + match[0].length + index++ + } + + if (index > 0) { + if (cursor < text.length) { + nodes.push(renderStyledSegment(text.slice(cursor), `${keyPrefix}-tail`, style)) + } + return nodes + } + + // If a narrow table cell wrapped a long URL across multiple lines, match the + // wrapped fragment back to its full URL from the cell. + if (fallbackUrls && fallbackUrls.length > 0) { + const trimmed = text.trim() + if (trimmed.length > 0) { + const matchedUrl = fallbackUrls.find((candidate) => candidate.includes(trimmed)) + if (matchedUrl) { + const start = text.indexOf(trimmed) + const leading = text.slice(0, start) + const trailing = text.slice(start + trimmed.length) + const result: React.ReactNode[] = [] + if (leading) result.push(renderStyledSegment(leading, `${keyPrefix}-lead`, style)) + result.push( + + {trimmed} + , + ) + if (trailing) result.push(renderStyledSegment(trailing, `${keyPrefix}-trail`, style)) + return result + } + } + } + + return [renderStyledSegment(text, keyPrefix, style)] +} /** A table separator row holds nothing but pipes, dashes and alignment colons. */ const DELIMITER_ROW = /^[\s|:-]+$/ @@ -34,6 +167,8 @@ interface GridLine { edge: EdgeKind /** Cell text padded to its column; empty on border lines. */ cells: string[] + /** Full URLs present in each cell of the source row so wrapped fragments stay linked. */ + cellUrls?: string[][] bold: boolean } @@ -104,8 +239,9 @@ function splitCells(line: string): string[] { /** 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) + const link = /^\[([^\]]+)\]\(((?:[^()\s]+|\([^()\s]*\))+)\)$/.exec(token) if (link) return `${link[1]} ${link[2]}` + if (token.startsWith("<") && token.endsWith(">")) return token.slice(1, -1) if (token.startsWith("**") || token.startsWith("__")) return token.slice(2, -2) return token.slice(1, -1) }) @@ -193,10 +329,12 @@ function layoutTable(lines: string[], maxWidth: number): TableLayout { ) const contentLines = (row: string[], bold: boolean): GridLine[] => { + const urlsPerColumn = row.map((cell) => extractUrls(cell)) 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])), + cellUrls: urlsPerColumn, bold, })) } @@ -247,7 +385,12 @@ function gridRow(line: GridLine, widths: number[], key: string): React.ReactNode ) : ( - {` ${line.cells[column]} `} + {renderTextWithUrls( + ` ${line.cells[column]} `, + `${key}-c${column}-u`, + line.bold ? { bold: true } : undefined, + line.cellUrls?.[column], + )} ), ) @@ -274,7 +417,7 @@ function textRows(block: Block, blockIndex: number, maxWidth: number): React.Rea return [ │ - {line} + {renderTextWithUrls(line, `${key}-code`, { color: COLORS.accent })} , ] if (block.kind === "fenceClose") return [╰─] @@ -345,21 +488,47 @@ function renderInline(text: string, keyPrefix: string): React.ReactNode[] { let index = 0 while ((match = INLINE_MARKDOWN.exec(text))) { - if (match.index > cursor) nodes.push(text.slice(cursor, match.index)) + if (match.index > cursor) { + nodes.push(...renderTextWithUrls(text.slice(cursor, match.index), `${keyPrefix}-t${index}`)) + } const token = match[0] const key = `${keyPrefix}-${index++}` if (token.startsWith("`")) { - nodes.push({token.slice(1, -1)}) + nodes.push( + + {renderTextWithUrls(token.slice(1, -1), `${key}-code`, { color: COLORS.accent })} + , + ) } else if (token.startsWith("**") || token.startsWith("__")) { - nodes.push({token.slice(2, -2)}) + nodes.push( + + {renderTextWithUrls(token.slice(2, -2), `${key}-b`, { bold: true })} + , + ) } else if (token.startsWith("*")) { - nodes.push({token.slice(1, -1)}) + nodes.push( + + {renderTextWithUrls(token.slice(1, -1), `${key}-i`, { italic: true })} + , + ) + } else if (token.startsWith("<") && token.endsWith(">")) { + const url = token.slice(1, -1) + nodes.push( + + {url} + , + ) } else { - const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token) + const link = /^\[([^\]]+)\]\(((?:[^()\s]+|\([^()\s]*\))+)\)$/.exec(token) if (link) { nodes.push( - {link[1]} {link[2]} + + {link[1]} + {" "} + + {link[2]} + , ) } @@ -367,6 +536,8 @@ function renderInline(text: string, keyPrefix: string): React.ReactNode[] { cursor = match.index + token.length } - if (cursor < text.length) nodes.push(text.slice(cursor)) + if (cursor < text.length) { + nodes.push(...renderTextWithUrls(text.slice(cursor), `${keyPrefix}-end`)) + } return nodes } diff --git a/src/ui/primitives.tsx b/src/ui/primitives.tsx index f6e1890..27767f6 100644 --- a/src/ui/primitives.tsx +++ b/src/ui/primitives.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext } from "react" -import { createTextAttributes, type KeyEvent, type PasteMetadata } from "@opentui/core" +import { createTextAttributes, getLinkId, type CliRenderer, type KeyEvent, type PasteMetadata } from "@opentui/core" import { useKeyboard, usePaste, @@ -70,6 +70,7 @@ export interface TextProps extends Omit + {children} ) @@ -116,7 +119,13 @@ export function Text({ wrapMode={wrap === "truncate" || wrap === "truncate-start" ? "none" : "word"} truncate={wrap === "truncate" || wrap === "truncate-start"} > - {children} + {link ? ( + + {children} + + ) : ( + children + )} ) @@ -194,3 +203,25 @@ export function useApp() { const renderer = useRenderer() return { exit: () => renderer.destroy() } } + +/** + * Look up the OSC 8 hyperlink URL rendered at terminal cell `(x, y)` in the + * current frame buffer, if any. Used to open links when terminal mouse + * reporting forwards clicks to the TUI instead of the terminal window. + */ +export function getLinkUrlAt(renderer: CliRenderer, x: number, y: number): string | null { + try { + const buf = renderer.currentRenderBuffer + if (!buf || x < 0 || y < 0 || x >= buf.width || y >= buf.height) return null + const attrs = buf.buffers.attributes[y * buf.width + x] + if (!attrs) return null + const linkId = getLinkId(attrs) + if (!linkId) return null + const linkLib = buf.lib as { linkGetUrl?: (id: number, maxLen?: number) => string } + const url = linkLib.linkGetUrl?.(linkId, 2048) + return url && /^https?:\/\//i.test(url) ? url : null + } catch { + return null + } +} + diff --git a/test/markdown-table.test.tsx b/test/markdown-table.test.tsx index 4b9c3dc..cf6a2f9 100644 --- a/test/markdown-table.test.tsx +++ b/test/markdown-table.test.tsx @@ -4,8 +4,9 @@ import React from "react"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react" -import { Box, Text } from "../src/ui/primitives.js"; +import { Box, Text, getLinkUrlAt } from "../src/ui/primitives.js"; import { markdownRowCount, renderMarkdown } from "../src/ui/markdown.js"; +import { RowView } from "../src/ui/components/rows.js"; /** Render markdown into a fixed-width frame and return its lines, trailing blanks dropped. */ async function render(markdown: string, width = 80, maxWidth = width) { @@ -133,3 +134,106 @@ test("leaves surrounding markdown untouched", async () => { assert.equal(lines[9], "• done"); assert.equal(lines.length, markdownRowCount(markdown, 80, 80)); }); + +test("attaches clickable OSC 8 link URLs to markdown links, autolinks, bare URLs, code, and tables", async () => { + const markdown = [ + "Visit [Docs](https://matterai.so/docs) or [Wiki](https://en.wikipedia.org/wiki/Foo_(bar)).", + "Autolink: and bare https://orbcode.dev/bare, plus (see http://localhost:3000/test).", + "Inline code `https://orbcode.dev/inline` and **https://orbcode.dev/bold**", + "```bash", + "curl https://orbcode.dev/code-block", + "```", + "| Name | Link |", + "|---|---|", + "| Portal | https://orbcode.dev/very/long/table/url/path/that/wraps |", + ].join("\n"); + + const screen = await testRender( + + {renderMarkdown(markdown, 44)} + , + { width: 44, height: 30 }, + ); + try { + await screen.renderOnce(); + const boxRenderable = screen.renderer.root.getChildren()[0] as any; + const textRenderable = boxRenderable.getChildren()[0] as any; + const linkedChunks = textRenderable.textNode + .toChunks() + .filter((chunk: any) => chunk.link?.url) + .map((chunk: any) => ({ text: chunk.text, url: chunk.link.url })); + + const urls = new Set(linkedChunks.map((c: { text: string; url: string }) => c.url)); + assert.ok(urls.has("https://matterai.so/docs")); + assert.ok(urls.has("https://en.wikipedia.org/wiki/Foo_(bar)")); + assert.ok(urls.has("https://orbcode.dev/auto")); + assert.ok(urls.has("https://orbcode.dev/bare")); + assert.ok(urls.has("http://localhost:3000/test")); + assert.ok(urls.has("https://orbcode.dev/inline")); + assert.ok(urls.has("https://orbcode.dev/bold")); + assert.ok(urls.has("https://orbcode.dev/code-block")); + assert.ok( + urls.has("https://orbcode.dev/very/long/table/url/path/that/wraps"), + "wrapped URL inside a narrow table cell should retain the full target href", + ); + } finally { + act(() => screen.renderer.destroy()); + } +}); + +test("renders clickable links inside attempt_completion bordered row", async () => { + const completionText = + "Deployed preview at https://preview.matterai.so/deployments/1234567890/inspect and [PR #42](https://github.com/MatterAIOrg/OrbCode/pull/42)."; + const screen = await testRender( + + + , + { width: 50, height: 20 }, + ); + try { + await screen.renderOnce(); + const outerBox = screen.renderer.root.getChildren()[0] as any; + const completionBox = outerBox.getChildren()[0] as any; + const bodyText = completionBox.getChildren()[1] as any; + const linkedChunks = bodyText.textNode + .toChunks() + .filter((chunk: any) => chunk.link?.url) + .map((chunk: any) => ({ text: chunk.text, url: chunk.link.url })); + + assert.deepEqual(linkedChunks, [ + { + text: "https://preview.matterai.so/deployments/1234567890/inspect", + url: "https://preview.matterai.so/deployments/1234567890/inspect", + }, + { + text: "PR #42", + url: "https://github.com/MatterAIOrg/OrbCode/pull/42", + }, + { + text: "https://github.com/MatterAIOrg/OrbCode/pull/42", + url: "https://github.com/MatterAIOrg/OrbCode/pull/42", + }, + ]); + + const resolvedCellUrls = new Set(); + for (let y = 0; y < 20; y++) { + for (let x = 0; x < 50; x++) { + const url = getLinkUrlAt(screen.renderer, x, y); + if (url) resolvedCellUrls.add(url); + } + } + assert.deepEqual( + resolvedCellUrls, + new Set([ + "https://preview.matterai.so/deployments/1234567890/inspect", + "https://github.com/MatterAIOrg/OrbCode/pull/42", + ]), + ); + } finally { + act(() => screen.renderer.destroy()); + } +}); + From 83f4ab51e28e46abc5649177323cbc6bc8c57dfb Mon Sep 17 00:00:00 2001 From: code-crusher Date: Wed, 14 Oct 2026 10:31:55 +0530 Subject: [PATCH 3/3] fix(ui): address PR review feedback on link regex, wrapped table URLs, and toast on open failure --- src/ui/App.tsx | 2 +- src/ui/markdown.tsx | 20 +++++++++++++++----- test/markdown-table.test.tsx | 30 +++++++++++++++++++----------- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 0f4078f..564a822 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -2587,7 +2587,7 @@ export function App({ const url = getLinkUrlAt(renderer, event.x, event.y); if (!url) return; renderer.clearSelection(); - void open(url).catch(() => {}); + void open(url).catch(() => showToast("✗ Could not open link")); }} onMouseMove={(event) => { const hasLink = Boolean(getLinkUrlAt(renderer, event.x, event.y)); diff --git a/src/ui/markdown.tsx b/src/ui/markdown.tsx index a7d3f04..c7991e5 100644 --- a/src/ui/markdown.tsx +++ b/src/ui/markdown.tsx @@ -6,7 +6,7 @@ import { Text } from "./primitives.js" /** Inline markdown: code spans, bold, italics, markdown links, and autolinks. */ const INLINE_MARKDOWN = - /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|(?]+>)/g + /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|(?]+>)/g const BARE_URL = /https?:\/\/[^\s<>`"']+/g @@ -76,8 +76,18 @@ function renderTextWithUrls(text: string, keyPrefix: string, style?: InlineStyle let index = 0 while ((match = BARE_URL.exec(text))) { - const { url, trailing } = splitTrailingUrlPunctuation(match[0]) + const rawMatch = match[0] + const rawPrefixUrl = fallbackUrls?.find( + (candidate) => candidate.startsWith(rawMatch) && candidate !== rawMatch, + ) + const { url, trailing } = rawPrefixUrl + ? { url: rawMatch, trailing: "" } + : splitTrailingUrlPunctuation(rawMatch) if (!url) continue + const fullUrl = + rawPrefixUrl ?? + fallbackUrls?.find((candidate) => candidate.startsWith(url) && candidate !== url) + const href = fullUrl && !fallbackUrls?.includes(url) ? fullUrl : url if (match.index > cursor) { nodes.push(renderStyledSegment(text.slice(cursor, match.index), `${keyPrefix}-t${index}`, style)) } @@ -88,7 +98,7 @@ function renderTextWithUrls(text: string, keyPrefix: string, style?: InlineStyle bold={style?.bold} italic={style?.italic} underline - href={url} + href={href} > {url} , @@ -239,7 +249,7 @@ function splitCells(line: string): string[] { /** 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 = /^\[([^\]]+)\]\(((?:[^()\s]+|\([^()\s]*\))+)\)$/.exec(token) + const link = /^\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)$/.exec(token) if (link) return `${link[1]} ${link[2]}` if (token.startsWith("<") && token.endsWith(">")) return token.slice(1, -1) if (token.startsWith("**") || token.startsWith("__")) return token.slice(2, -2) @@ -519,7 +529,7 @@ function renderInline(text: string, keyPrefix: string): React.ReactNode[] { , ) } else { - const link = /^\[([^\]]+)\]\(((?:[^()\s]+|\([^()\s]*\))+)\)$/.exec(token) + const link = /^\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)$/.exec(token) if (link) { nodes.push( diff --git a/test/markdown-table.test.tsx b/test/markdown-table.test.tsx index cf6a2f9..4876c8b 100644 --- a/test/markdown-table.test.tsx +++ b/test/markdown-table.test.tsx @@ -164,18 +164,26 @@ test("attaches clickable OSC 8 link URLs to markdown links, autolinks, bare URLs .map((chunk: any) => ({ text: chunk.text, url: chunk.link.url })); const urls = new Set(linkedChunks.map((c: { text: string; url: string }) => c.url)); - assert.ok(urls.has("https://matterai.so/docs")); - assert.ok(urls.has("https://en.wikipedia.org/wiki/Foo_(bar)")); - assert.ok(urls.has("https://orbcode.dev/auto")); - assert.ok(urls.has("https://orbcode.dev/bare")); - assert.ok(urls.has("http://localhost:3000/test")); - assert.ok(urls.has("https://orbcode.dev/inline")); - assert.ok(urls.has("https://orbcode.dev/bold")); - assert.ok(urls.has("https://orbcode.dev/code-block")); - assert.ok( - urls.has("https://orbcode.dev/very/long/table/url/path/that/wraps"), - "wrapped URL inside a narrow table cell should retain the full target href", + assert.deepEqual( + urls, + new Set([ + "https://matterai.so/docs", + "https://en.wikipedia.org/wiki/Foo_(bar)", + "https://orbcode.dev/auto", + "https://orbcode.dev/bare", + "http://localhost:3000/test", + "https://orbcode.dev/inline", + "https://orbcode.dev/bold", + "https://orbcode.dev/code-block", + "https://orbcode.dev/very/long/table/url/path/that/wraps", + ]), + "every wrapped fragment inside a narrow table cell should retain the full target href without emitting a truncated prefix URL", ); + + // Ensure malformed markdown links with long unclosed parens run in linear time (no ReDoS). + const startMs = performance.now(); + renderMarkdown(`[Docs](https://example.com/${"a".repeat(80)}`, 80); + assert.ok(performance.now() - startMs < 50, "unclosed markdown link should parse in linear time"); } finally { act(() => screen.renderer.destroy()); }