diff --git a/packages/theme/src/tui/index.ts b/packages/theme/src/tui/index.ts index fbbf8289c292..f7a0b15367cb 100644 --- a/packages/theme/src/tui/index.ts +++ b/packages/theme/src/tui/index.ts @@ -47,4 +47,4 @@ export { migrateV1 } from "./v1-migrate.js" export { migrateV1NewDesigns } from "./v1-migrate-new-designs.js" export { parseThemeDocument, resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js" export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js" -export { generateSyntax } from "./syntax.js" +export { generateSyntax, type SyntaxOverrides } from "./syntax.js" diff --git a/packages/theme/src/tui/syntax.ts b/packages/theme/src/tui/syntax.ts index fcca72989b41..60a8317b7e33 100644 --- a/packages/theme/src/tui/syntax.ts +++ b/packages/theme/src/tui/syntax.ts @@ -1,7 +1,14 @@ import { SyntaxStyle, type NativeResourceOwner, type RGBA, type ThemeTokenStyle } from "@opentui/core" import type { ResolvedThemeTokens } from "./index.js" -export function generateSyntax(theme: ResolvedThemeTokens, owner: NativeResourceOwner) { +/** Feature-local scope colors that win over the theme's own rule for that scope. */ +export type SyntaxOverrides = Readonly> + +export function generateSyntax( + theme: ResolvedThemeTokens, + owner: NativeResourceOwner, + overrides: SyntaxOverrides = {}, +) { const step = 200 const syntax = theme.syntax const markdown = theme.markdown @@ -83,6 +90,7 @@ export function generateSyntax(theme: ResolvedThemeTokens, owner: NativeResource rule(["warning"], feedback.warning.base, { bold: true }), rule(["info"], feedback.info.base), ] + for (const [scope, foreground] of Object.entries(overrides)) rules.push(rule([scope], foreground)) return SyntaxStyle.fromTheme(rules, owner) } diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index e93ac4d65508..03803e66f5ce 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -119,6 +119,7 @@ export const Definitions = { "session.tab.close": keybind("w", "Close current session tab"), "session.tab.reopen": keybind("ctrl+shift+t", "Reopen last closed session tab"), "session.timeline": keybind("g", "Show session timeline"), + "session.file_links": keybind("f", "Open a path from the last response"), "session.fork": keybind("none", "Fork session from message"), "session.rename": keybind("ctrl+r", "Rename session"), "session.delete": keybind("ctrl+d", "Delete session"), diff --git a/packages/tui/src/editor.ts b/packages/tui/src/editor.ts index 3ecff166c08a..1ad82d6ffc60 100644 --- a/packages/tui/src/editor.ts +++ b/packages/tui/src/editor.ts @@ -1,10 +1,11 @@ import type { CliRenderer } from "@opentui/core" -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" +import { existsSync, readdirSync, readFileSync, statSync, type Stats } from "node:fs" import { readFile, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { spawn } from "node:child_process" import type { Stream } from "node:stream" +import { openPath } from "@opencode/util/open" import { resolveZedDbPath, resolveZedSelection } from "./editor-zed" export { normalizePromptContent } from "./prompt/content" @@ -38,6 +39,308 @@ export async function openEditor(input: { value: string; renderer: CliRenderer; } } +const terminalEditors = new Set(["vim", "nvim", "vi", "nano", "micro", "emacs", "kak", "hx", "helix", "joe", "mcedit"]) +const gotoEditors = new Set(["code", "code-insiders", "codium", "cursor", "windsurf", "trae"]) +const colonEditors = new Set(["subl", "sublime_text", "atom", "zed", "zeditor", "hx", "helix"]) + +export type EditorInvocation = { + command: string + args: string[] + /** The editor owns the terminal, so the renderer must suspend until it exits. */ + terminal: boolean + /** Windows command shims (`.cmd`/`.bat`/bare names) need `cmd.exe` to resolve. */ + shell: boolean +} + +/** Splits `$EDITOR` into a command and arguments without invoking a shell. */ +export function splitCommand(input: string) { + const parts: string[] = [] + let current = "" + let quote: '"' | "'" | undefined + for (const char of input) { + if (quote) { + if (char === quote) quote = undefined + else current += char + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (/\s/.test(char)) { + if (current) parts.push(current) + current = "" + continue + } + current += char + } + if (current) parts.push(current) + return parts +} + +export type EditorTarget = { file: string; line?: number; column?: number } + +export type EditorBatchInvocation = EditorInvocation & { + /** Every target opens in one editor process; otherwise launch each target. */ + batch: boolean +} + +export function buildEditorBatchInvocation(input: { + editor: string + files: readonly EditorTarget[] + platform?: NodeJS.Platform +}): EditorBatchInvocation { + const parts = splitCommand(input.editor) + const command = parts[0] + if (!command) throw new Error("Set $VISUAL or $EDITOR to open files in an editor") + const name = editorName(command) + return { + command, + args: [...parts.slice(1), ...targetArgs(name, input.files)], + terminal: terminalEditors.has(name), + shell: (input.platform ?? process.platform) === "win32" && !/\.(exe|com)$/i.test(command), + batch: terminalEditors.has(name) || gotoEditors.has(name) || colonEditors.has(name), + } +} + +function editorName(command: string) { + return command + .split(/[\\/]/) + .pop()! + .toLowerCase() + .replace(/\.(exe|cmd|bat|com)$/, "") +} + +function targetArgs(name: string, files: readonly EditorTarget[]) { + if (!files.length) return [] + const first = files[0]! + const paths = files.map((file) => file.file) + if (gotoEditors.has(name)) { + if (!files.some((file) => file.line)) return paths + return ["--goto", ...files.map(locationArg)] + } + if (colonEditors.has(name)) return files.map(locationArg) + if (name === "emacs") + return first.line ? [`+${first.line}${first.column ? `:${first.column}` : ""}`, ...paths] : paths + if (name === "nano" || name === "micro" || name === "kak") + return first.line ? [`+${first.line}${first.column ? `,${first.column}` : ""}`, ...paths] : paths + if (name === "vim" || name === "nvim" || name === "vi") return first.line ? [`+${first.line}`, ...paths] : paths + return [first.file] +} + +function locationArg(file: EditorTarget) { + return file.line ? `${file.file}:${file.line}${file.column ? `:${file.column}` : ""}` : file.file +} + +export type FileLocation = { path: string; line?: number; column?: number } +export type FileOpenFailure = { path: string; message: string } +export type FileOpenResult = { opened: number; failures: FileOpenFailure[] } + +/** + * Opens resolved local paths with the user's editor, one process when the + * editor accepts several targets. Directories open in the system file manager. + * Without an editor, files open with the OS default handler except launchable + * types, which would run instead of display. Repeated paths open once with the + * first location winning. + */ +export async function openFilesAtLocation(input: { + files: readonly FileLocation[] + renderer: CliRenderer + cwd?: string +}): Promise { + const unique = new Map() + for (const file of input.files) if (!unique.has(file.path)) unique.set(file.path, file) + + const failures: FileOpenFailure[] = [] + const pending: { file: FileLocation; stat: Stats }[] = [] + let opened = 0 + const openedOnce = () => { + opened++ + } + const openFailed = (file: string) => (error: unknown) => { + failures.push(failure(file, error)) + } + + for (const file of unique.values()) { + const stat = statSync(file.path, { throwIfNoEntry: false }) + if (!stat) { + failures.push({ path: file.path, message: `Path does not exist: ${file.path}` }) + continue + } + if (stat.isDirectory()) { + // Opening a macOS bundle launches the app; reveal bundles in the file manager instead. + const target = process.platform === "darwin" && file.path.endsWith(".app") ? path.dirname(file.path) : file.path + await openPath(target).then(openedOnce, openFailed(file.path)) + continue + } + pending.push({ file, stat }) + } + + if (!pending.length) return { opened, failures } + const editor = process.env.VISUAL || process.env.EDITOR + if (!editor) { + for (const target of pending) { + if (isLaunchable(target.file.path, target.stat)) { + failures.push({ path: target.file.path, message: refusal(target.file.path) }) + continue + } + await openPath(target.file.path).then(openedOnce, openFailed(target.file.path)) + } + return { opened, failures } + } + + const cwd = input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd() + const invocation = buildEditorBatchInvocation({ + editor, + files: pending.map((target) => ({ file: target.file.path, line: target.file.line, column: target.file.column })), + }) + if (invocation.terminal) { + await input.renderer.suspend() + try { + await runTerminalEditor(invocation, cwd) + opened += pending.length + } catch (error) { + failures.push(failure(pending[0]!.file.path, error)) + } finally { + await input.renderer.resume() + } + return { opened, failures } + } + if (!invocation.batch) { + for (const target of pending) { + const single = buildEditorBatchInvocation({ + editor, + files: [{ file: target.file.path, line: target.file.line, column: target.file.column }], + }) + await launchEditor(single, cwd).then(openedOnce, openFailed(target.file.path)) + } + return { opened, failures } + } + try { + await launchEditor(invocation, cwd) + opened += pending.length + } catch (error) { + for (const target of pending) { + if (isLaunchable(target.file.path, target.stat)) { + failures.push(failure(target.file.path, error)) + continue + } + await openPath(target.file.path).then(openedOnce, openFailed(target.file.path)) + } + } + return { opened, failures } +} + +function failure(file: string, error: unknown): FileOpenFailure { + return { path: file, message: error instanceof Error ? error.message : String(error) } +} + +function refusal(file: string) { + return `Refusing to launch ${path.basename(file)}: set $VISUAL or $EDITOR to open it` +} + +/** Opens one resolved path; see `openFilesAtLocation` for the safety rules. */ +export async function openFileAtLocation(input: { + path: string + line?: number + column?: number + renderer: CliRenderer + cwd?: string +}) { + const result = await openFilesAtLocation({ + files: [{ path: input.path, line: input.line, column: input.column }], + renderer: input.renderer, + cwd: input.cwd, + }) + if (result.opened) return + throw new Error(result.failures[0]?.message ?? `Could not open ${input.path}`) +} + +// Extensions whose default handler executes the file rather than displaying it. +const launchableExtensions = new Set([ + ".app", + ".appdir", + ".appref-ms", + ".bat", + ".chm", + ".cmd", + ".com", + ".command", + ".cpl", + ".desktop", + ".diagcab", + ".exe", + ".hta", + ".inf", + ".jar", + ".js", + ".jse", + ".lnk", + ".msc", + ".msi", + ".msix", + ".pif", + ".pkg", + ".ps1", + ".ps1xml", + ".psc1", + ".psc2", + ".reg", + ".scf", + ".scr", + ".sct", + ".settingcontent-ms", + ".shb", + ".shs", + ".sys", + ".terminal", + ".vb", + ".vbe", + ".vbs", + ".workflow", + ".ws", + ".wsc", + ".wsf", + ".wsh", +]) + +function isLaunchable(file: string, stat: Stats) { + if (process.platform !== "win32" && (stat.mode & 0o111) !== 0) return true + return launchableExtensions.has(path.extname(file).toLowerCase()) +} + +function spawnInvocation(invocation: EditorInvocation, cwd: string) { + if (!invocation.shell) return spawn(invocation.command, invocation.args, { cwd, stdio: "inherit" }) + const command = [invocation.command, ...invocation.args].map((part) => `"${part}"`).join(" ") + return spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${command}"`], { + cwd, + stdio: "inherit", + windowsVerbatimArguments: true, + }) +} + +function runTerminalEditor(invocation: EditorInvocation, cwd: string) { + return new Promise((resolve, reject) => { + const child = spawnInvocation(invocation, cwd) + child.on("error", reject) + child.on("exit", (code, signal) => { + if (code === 0) return resolve() + reject(new Error(`Editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`)) + }) + }) +} + +function launchEditor(invocation: EditorInvocation, cwd: string) { + return new Promise((resolve, reject) => { + const child = spawnInvocation(invocation, cwd) + child.on("error", reject) + child.on("spawn", () => { + child.unref() + resolve() + }) + }) +} + export function discoverEditorConnection(directory: string) { const root = path.join(os.homedir(), ".claude", "ide") const contains = (parent: string) => { diff --git a/packages/tui/src/feature-plugins/system/storybook/index.tsx b/packages/tui/src/feature-plugins/system/storybook/index.tsx index bf6359d85eb0..9a0a3f43493c 100644 --- a/packages/tui/src/feature-plugins/system/storybook/index.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/index.tsx @@ -7,6 +7,7 @@ import { sessionTabsStory } from "./session-tabs" import { sessionLocationMissingStory } from "./session-location-missing" import { oneCellSpinnerStory } from "./one-cell-spinner" import { linkClicksStory } from "./link-clicks" +import { pathPickerStory } from "./path-picker" import { highlightsStory } from "./highlights" import { todoStory } from "./todo" @@ -26,6 +27,7 @@ const stories: Story[] = [ sessionLocationMissingStory, oneCellSpinnerStory, linkClicksStory, + pathPickerStory, highlightsStory, todoStory, ] diff --git a/packages/tui/src/feature-plugins/system/storybook/path-picker.tsx b/packages/tui/src/feature-plugins/system/storybook/path-picker.tsx new file mode 100644 index 000000000000..346581294b1c --- /dev/null +++ b/packages/tui/src/feature-plugins/system/storybook/path-picker.tsx @@ -0,0 +1,165 @@ +import type { Plugin } from "@opencode/plugin/tui" +import type { SessionMessageAssistant, SessionMessageAssistantText } from "@opencode/client" +import { useTerminalDimensions } from "@opentui/solid" +import { mkdirSync, writeFileSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { createSignal } from "solid-js" +import { useConfig } from "../../../config" +import { createTimelineAnchors } from "../../../routes/session/anchors" +import { TextPart } from "../../../routes/session/message-parts" +import { createPathLinks, PathLinksBar } from "../../../routes/session/path-links" +import { context } from "../../../routes/session/render-context" +import { StoryFooter } from "./footer" +import type { Story } from "./index" + +const fixture = createFixture() + +function createFixture() { + const base = path.join(os.tmpdir(), "opencode-path-links-story") + mkdirSync(path.join(base, "out"), { recursive: true }) + mkdirSync(path.join(base, "reports"), { recursive: true }) + mkdirSync(path.join(base, "logs"), { recursive: true }) + writeFileSync(path.join(base, "out", "report.md"), "# report\n") + writeFileSync(path.join(base, "notes.txt"), "notes\n") + writeFileSync(path.join(base, "reports", "final notes.md"), "final\n") + writeFileSync(path.join(base, "logs", "run.txt"), "log\n") + for (const number of [1, 2, 3, 4, 5]) writeFileSync(path.join(base, "logs", `${number}.log`), "log\n") + const absolute = path.join(base, "logs", "run.txt") + const text = [ + "I wrote the deliverables:", + "", + "- Report: `out/report.md`", + "- Same file with a location: out/report.md:12:3", + "- Folder: out/", + '- Notes with spaces: "reports/final notes.md"', + `- Absolute: \`${absolute}:7\``, + `- File URL: <${pathToFileURL(path.join(base, "notes.txt")).href}#L4C2>`, + "- Missing: /definitely-missing/opencode-story.txt", + "- Generated: `logs/1.log`, `logs/2.log`, `logs/3.log`, `logs/4.log`, `logs/5.log`", + "", + "```sh", + "cat out/report.md # fenced code is ignored", + "```", + "", + "URLs like https://example.com/out/report.md and commands like `npm install` stay plain.", + ].join("\n") + const part: SessionMessageAssistantText = { type: "text", text } + const message: SessionMessageAssistant = { + id: "path-picker", + type: "assistant", + agent: "build", + model: { providerID: "fixture", id: "fixture" }, + time: { created: 0, completed: 1 }, + content: [part], + } + return { base, part, message } +} + +function PathPickerStory(props: { context: Plugin.Context }) { + const dimensions = useTerminalDimensions() + const theme = props.context.theme + const config = useConfig() + const [narrow, setNarrow] = createSignal(true) + const width = () => (narrow() ? Math.min(64, dimensions().width) : dimensions().width) + const pathLinks = createPathLinks({ + messages: () => [fixture.message], + base: () => fixture.base, + }) + + props.context.keymap.layer(() => ({ + commands: [ + { + bind: "p", + title: "Open path picker", + group: "Storybook", + run: () => pathLinks.activate(), + }, + { + bind: "w", + title: "Toggle narrow width", + group: "Storybook", + run: () => setNarrow((value) => !value), + }, + { + bind: "r", + title: "Reset path picker", + group: "Storybook", + run: () => pathLinks.close(), + }, + { + bind: "escape", + title: "Back to storybook", + group: "Storybook", + run: () => { + if (pathLinks.active()) return pathLinks.close() + props.context.ui.router.navigate({ type: "plugin", name: "storybook" }) + }, + }, + ], + })) + + return ( + + true, + setGroupExpanded: () => {}, + thinkingMode: () => "show", + markdownMode: () => "rendered", + groupExploration: () => false, + diffWrapMode: () => "word", + models: () => [], + messageIndex: () => undefined, + legacyTurns: () => false, + config: config.data, + mutatePending: async () => false, + pendingDelivery: () => undefined, + pathLinks: pathLinks.mark, + pathLinkSyntax: pathLinks.syntax, + }} + > + + + + + + + + + + ) +} + +export const pathPickerStory: Story = { + id: "path-picker", + title: "Path picker", + render: (context) => , +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index cd03922c4acf..8d29345fe22c 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -125,6 +125,7 @@ import { parseTodos } from "../../util/todo" import { createHistoryPrepend } from "./history" import { context, use, type PendingAction } from "./render-context" import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, TextPart, toolDisplay } from "./message-parts" +import { createPathLinks, PathLinksBar } from "./path-links" import { defaultVerbosity, type GroupKind, type SessionEntry } from "./grouping/session" import { SessionGroupView } from "./group-view" import { useEntryAnchor } from "./anchor-view" @@ -192,6 +193,11 @@ export function Session(props: { createEffect(() => currentLocation.set(location())) + const pathLinks = createPathLinks({ + messages, + base: () => location()?.directory, + }) + createEffect(() => { const title = Locale.truncate(session()?.title ?? "", 50) epilogue.session({ title, sessionID: session()?.id }) @@ -1210,6 +1216,16 @@ export function Session(props: { dialog.clear() }, }, + { + title: "Open a path from the last response", + id: "session.file_links", + group: "Session", + slash: { name: "paths" }, + run: () => { + pathLinks.activate() + dialog.clear() + }, + }, { title: "Jump to last user message", id: "session.messages_last_user", @@ -1452,6 +1468,7 @@ export function Session(props: { () => route.sessionID, () => { setComposer("open", false) + pathLinks.close() clearMessageNavigation() }, ), @@ -1500,6 +1517,8 @@ export function Session(props: { models, messageIndex: (messageID) => messageIndexes().get(messageID), legacyTurns: legacy, + pathLinks: pathLinks.mark, + pathLinkSyntax: pathLinks.syntax, config, mutatePending, pendingDelivery: (inboxID) => pendingDeliveries().get(inboxID), @@ -1565,6 +1584,7 @@ export function Session(props: { + Loading session history… @@ -1986,6 +2006,7 @@ function SessionPartView(props: { diff --git a/packages/tui/src/routes/session/message-parts.tsx b/packages/tui/src/routes/session/message-parts.tsx index 5a7af359311f..4725b54f55f9 100644 --- a/packages/tui/src/routes/session/message-parts.tsx +++ b/packages/tui/src/routes/session/message-parts.tsx @@ -174,19 +174,25 @@ export function TextPart(props: { last: boolean part: SessionMessageAssistantText message: SessionMessageAssistant + partID?: string }) { const ctx = use() const theme = useTheme() const { currentSyntax: syntax } = useThemes() const plugins = usePlugin() + const content = createMemo(() => { + const text = props.part.text.trim() + return (props.partID && ctx.pathLinks?.(props.partID, text)) || text + }) + const markedSyntax = createMemo(() => (props.partID ? ctx.pathLinkSyntax?.(props.partID) : undefined)) return ( - + {/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */} +} + +/** + * Numbers paths in the last completed assistant response and lets the reader + * open one by typing its number, or check several with `m`/`a` and open them + * together. Detection happens once on activation so the markers cannot shift + * while the reader is typing. + */ +export function createPathLinks(input: { + messages: () => readonly SessionMessageInfo[] + base: () => string | undefined +}) { + const renderer = useRenderer() + const toast = useToast() + const keymap = Keymap.use() + const theme = useTheme() + const themes = useThemes() + const [state, setState] = createSignal() + const active = createMemo(() => state() !== undefined) + // The tags ride inside the Markdown source, so recolor the one inline scope + // they use for as long as the picker is open. + const markerSyntax = createSyntaxStyleMemo(() => + generateSyntax(themes.currentTokens(), renderer.nativeScene, { + "markup.strikethrough": theme.text.feedback.error.base, + }), + ) + let restoreFocus: Renderable | undefined + let timer: ReturnType | undefined + + const clearTimer = () => { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + } + + createEffect(() => { + if (!active()) return + onCleanup(keymap.mode.push(MODE)) + }) + onCleanup(clearTimer) + + function activate() { + if (state()) { + close() + return + } + const parts = collectParts(input.messages()) + if (!parts) { + toast.show({ message: "No completed response to scan for paths", variant: "info", duration: 2500 }) + return + } + const base = input.base() + if (!base) { + toast.show({ message: "No working directory for this session", variant: "warning", duration: 3000 }) + return + } + const detection = detectPathLinks(parts, { base, home: os.homedir() }) + if (!detection.links.length) { + toast.show({ message: "No paths in the last response", variant: "info", duration: 2500 }) + return + } + const focused = renderer.currentFocusedEditor + if (focused) { + restoreFocus = focused + focused.blur() + } + setState({ links: detection.links, detection, digits: "", multi: false, selected: new Set() }) + } + + function close() { + clearTimer() + const focus = restoreFocus + restoreFocus = undefined + setState(undefined) + if (focus && !focus.isDestroyed) focus.focus() + } + + function press(digit: string) { + const current = state() + if (!current) return + clearTimer() + const result = matchPathLinkDigit(current.links, current.digits, digit) + if (result.invalid) { + setState({ ...current, digits: "", notice: `No path ${result.attempted}` }) + return + } + if (result.exact && result.matches.length === 1) { + commit(result.exact) + return + } + setState({ ...current, digits: result.digits, notice: undefined }) + // A complete number that is also a prefix (1 of 1x) waits briefly so a + // reader typing 12 does not pick 1 by accident. + if (result.exact) { + const target = result.exact + timer = setTimeout(() => { + timer = undefined + commit(target) + }, AMBIGUOUS_DELAY_MS) + } + } + + function commit(link: DetectedPathLink) { + if (state()?.multi) return toggle(link.index) + void open(link) + } + + function toggle(index: number) { + const current = state() + if (!current) return + clearTimer() + const result = togglePathLinkSelection(current.selected, index, MAX_SELECTION) + if (result.capped) { + setState({ ...current, digits: "", notice: `Select up to ${MAX_SELECTION} paths` }) + return + } + setState({ ...current, digits: "", notice: undefined, selected: result.selected }) + } + + function toggleMulti() { + const current = state() + if (!current) return + clearTimer() + setState({ ...current, multi: !current.multi, digits: "", notice: undefined, selected: new Set() }) + } + + function selectAll() { + const current = state() + if (!current) return + clearTimer() + if (current.multi && current.selected.size) { + setState({ ...current, digits: "", notice: undefined, selected: new Set() }) + return + } + const selected = new Set(current.links.slice(0, MAX_SELECTION).map((link) => link.index)) + setState({ + ...current, + multi: true, + digits: "", + selected, + notice: + current.links.length > MAX_SELECTION ? `Selected first ${MAX_SELECTION} of ${current.links.length}` : undefined, + }) + } + + function backspace() { + const current = state() + if (!current) return + clearTimer() + setState({ ...current, digits: current.digits.slice(0, -1), notice: undefined }) + } + + function enter() { + const current = state() + if (!current) return + clearTimer() + if (current.multi) { + if (!current.selected.size) { + setState({ ...current, digits: "", notice: "No paths selected" }) + return + } + void openSelected(current) + return + } + if (!current.digits) return close() + const exact = current.links.find((link) => String(link.index) === current.digits) + if (exact) return open(exact) + setState({ ...current, digits: "", notice: `No path ${current.digits}` }) + } + + async function open(link: DetectedPathLink) { + clearTimer() + close() + if (!link.exists) { + toast.show({ message: `Path not found: ${link.path}`, variant: "warning", duration: 4000 }) + return + } + toast.show({ message: `Opening ${locationLabel(link)}`, variant: "info", duration: 2000 }) + await openFileAtLocation({ + path: link.path, + line: link.line, + column: link.column, + renderer, + }).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) + } + + async function openSelected(current: PathLinkState) { + const links = current.links.filter((link) => current.selected.has(link.index)) + clearTimer() + close() + const present = links.filter((link) => link.exists) + const missing = links.length - present.length + if (!present.length) { + toast.show({ message: "No selected paths exist", variant: "warning", duration: 4000 }) + return + } + toast.show({ message: `Opening ${present.length} paths`, variant: "info", duration: 2000 }) + const result = await openFilesAtLocation({ + files: present.map((link) => ({ path: link.path, line: link.line, column: link.column })), + renderer, + }).catch((error) => { + toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) + return + }) + if (!result) return + const reasons = [...(missing ? [`${missing} missing`] : []), ...result.failures.map((failure) => failure.message)] + if (!reasons.length) { + toast.show({ message: `Opened ${result.opened} paths`, variant: "info", duration: 2500 }) + return + } + const detail = `${reasons[0]}${reasons.length > 1 ? ` (+${reasons.length - 1} more)` : ""}` + toast.show({ + message: result.opened ? `Opened ${result.opened} · ${detail}` : detail, + variant: result.opened ? "warning" : "error", + duration: 5000, + }) + } + + Keymap.createLayer(() => ({ + mode: MODE, + enabled: () => state() !== undefined, + commands: [ + ..."0123456789".split("").map((digit) => ({ + bind: digit, + title: `Choose path ${digit}`, + group: "Paths", + run: () => press(digit), + })), + { bind: "backspace", title: "Edit path number", group: "Paths", run: backspace }, + { bind: "return", title: "Open numbered path", group: "Paths", run: enter }, + { bind: "m", title: "Select multiple paths", group: "Paths", run: toggleMulti }, + { bind: "a", title: "Select all paths", group: "Paths", run: selectAll }, + { bind: "escape", title: "Close path picker", group: "Paths", run: close }, + ], + })) + + return { + active, + state, + activate, + close, + /** Returns the part text with markers, or undefined to leave it unchanged. */ + mark(partID: string, text: string) { + const current = state() + if (!current) return + const part = current.detection.parts.get(partID) + if (!part || part.text !== text) return + return applyPathLinkMarkers( + text, + part.markers, + current.multi ? (index) => pathLinkSelectionTag(index, current.selected.has(index)) : pathLinkTag, + ) + }, + /** The red tag syntax for a part that currently shows markers. */ + syntax(partID: string) { + const current = state() + if (!current || !current.detection.parts.has(partID)) return + return markerSyntax() + }, + } +} + +function collectParts(messages: readonly SessionMessageInfo[]) { + const message = messages.findLast( + (item): item is SessionMessageAssistant => item.type === "assistant" && item.time.completed !== undefined, + ) + if (!message) return + const parts: PathLinkPart[] = [] + let ordinal = 0 + for (const part of message.content) { + if (part.type !== "text") continue + const id = `text:${ordinal++}` + if (!part.text.trim()) continue + parts.push({ id, text: part.text.trim() }) + } + return parts.length ? parts : undefined +} + +function locationLabel(link: DetectedPathLink) { + const suffix = link.line ? `${link.line}${link.column ? `:${link.column}` : ""}` : undefined + return suffix ? `${link.path}:${suffix}` : link.path +} + +export function PathLinksBar(props: { state: PathLinkState | undefined }) { + const theme = useTheme() + return ( + + {(state) => { + const exact = createMemo(() => + state().multi ? undefined : state().links.find((link) => String(link.index) === state().digits), + ) + const counter = createMemo(() => { + if (state().digits) return `#${state().digits}` + if (state().multi) return `${state().selected.size}/${state().links.length}` + return `${state().links.length}` + }) + const hints = createMemo(() => { + if (exact()) + return [ + { key: "enter", label: "open" }, + { key: "esc", label: "close" }, + ] + if (state().digits) + return [ + { key: "enter", label: "open" }, + { key: "backspace", label: "edit" }, + { key: "esc", label: "close" }, + ] + if (state().multi) + return [ + { key: "●○", label: "toggle" }, + { key: "a", label: "all" }, + { key: "enter", label: "open" }, + { key: "m", label: "single" }, + { key: "esc", label: "close" }, + ] + return [ + { key: "type", label: "a number" }, + { key: "m", label: "multi" }, + { key: "a", label: "all" }, + { key: "esc", label: "close" }, + ] + }) + return ( + + + ● + + + Paths + + + {counter()} + + + · + + }> + {(link) => ( + <> + + {truncateMiddle(link().display, 56)} + + + + )} + + } + > + {(notice) => ( + + {notice()} + + )} + + + ) + }} + + ) +} + +function Hints(props: { hints: readonly { key: string; label: string }[] }) { + const theme = useTheme() + return ( + + {props.hints.map((hint, position) => ( + <> + {position > 0 ? · : undefined} + {hint.key} + {hint.label} + + ))} + + ) +} diff --git a/packages/tui/src/routes/session/render-context.tsx b/packages/tui/src/routes/session/render-context.tsx index 0fe106d5fe44..c4ca874696ea 100644 --- a/packages/tui/src/routes/session/render-context.tsx +++ b/packages/tui/src/routes/session/render-context.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from "solid-js" -import type { Renderable } from "@opentui/core" +import type { Renderable, SyntaxStyle } from "@opentui/core" import type { ModelInfo } from "@opencode/client" import type { SessionInbox } from "@opencode/schema/session-inbox" import type { useConfig } from "../../config" @@ -30,6 +30,10 @@ export const context = createContext<{ diffWrapMode: () => "word" | "none" models: () => ModelInfo[] messageIndex: (messageID: string) => number | undefined + /** Adds path-picker markers to assistant text while the picker is open. */ + pathLinks?: (partID: string, text: string) => string | undefined + /** Red syntax for a part whose path markers are showing. */ + pathLinkSyntax?: (partID: string) => SyntaxStyle | undefined /** True when the session has no idle markers, so turn footers end at the next prompt. */ legacyTurns: () => boolean config: ReturnType["data"] diff --git a/packages/tui/src/util/file-links.ts b/packages/tui/src/util/file-links.ts new file mode 100644 index 000000000000..5aa8a119fdeb --- /dev/null +++ b/packages/tui/src/util/file-links.ts @@ -0,0 +1,513 @@ +import { statSync } from "node:fs" +import path from "node:path" + +export type PathLinkSource = "bare" | "inline-code" | "markdown-link" | "quoted" + +export type PathLinkCandidate = { + text: string + start: number + end: number + markerAt: number + source: PathLinkSource +} + +export type PathLinkPart = { id: string; text: string } + +export type DetectedPathLink = { + index: number + partID: string + display: string + path: string + line?: number + column?: number + kind?: "file" | "directory" + exists: boolean +} + +export type PathLinkMarker = { at: number; index: number } + +export type PathLinkDetection = { + links: DetectedPathLink[] + parts: Map +} + +export type PathLinkOptions = { + base: string + home: string + platform?: NodeJS.Platform +} + +const MAX_SUFFIX_LINE = 1_000_000 + +const fencePattern = /^ {0,3}(`{3,}|~{3,})/ + +const barePattern = + /(?|]*|[A-Za-z0-9._-]+(?:[\\/][A-Za-z0-9._-]+)+|[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,8}|[A-Za-z0-9._-]+[\\/])(?![\w/\\])/g + +const explicitPattern = /^(?:[A-Za-z]:[\\/]|\\\\|\/|~[\\/]|\.{1,2}[\\/]|file:)/i + +/** Every candidate path the transcript exposes, in source order. */ +export function scanPathCandidates(markdown: string): PathLinkCandidate[] { + const candidates: PathLinkCandidate[] = [] + let offset = 0 + let fence: { char: string; length: number } | undefined + for (const line of markdown.split("\n")) { + const start = offset + offset += line.length + 1 + const marker = fencePattern.exec(line)?.[1] + if (fence) { + if (marker && marker[0] === fence.char && marker.length >= fence.length) fence = undefined + continue + } + if (marker) { + fence = { char: marker[0]!, length: marker.length } + continue + } + scanLine(line, start, candidates) + } + return candidates +} + +/** + * Turns detected links into markers for one already-scanned text, separated + * from the path by a space so the number reads as a tag. Offsets are only valid + * for the exact text that produced the detection. + */ +export function applyPathLinkMarkers( + text: string, + markers: readonly PathLinkMarker[], + format: (index: number) => string = (index) => `[${index}]`, +) { + if (!markers.length) return text + const ordered = [...markers].sort((left, right) => left.at - right.at) + let result = "" + let cursor = 0 + for (const marker of ordered) { + if (marker.at < cursor || marker.at > text.length) continue + // Keep `path [1](x)` from becoming a Markdown link with the label `1`. + const spacer = text[marker.at] === "(" || text[marker.at] === "[" ? " " : "" + result += `${text.slice(cursor, marker.at)} ${format(marker.index)}${spacer}` + cursor = marker.at + } + return result + text.slice(cursor) +} + +// `~~` conceals in rendered Markdown, so a tag paints only while the picker's +// syntax style maps that scope to the tag color. +export function pathLinkTag(index: number) { + return `~~${index}~~` +} + +export function pathLinkSelectionTag(index: number, selected: boolean) { + return `~~${selected ? "●" : "○"}${index}~~` +} + +export type PathLinkDigitResult = { + /** Digits accepted so far; empty after an invalid prefix. */ + digits: string + matches: readonly DetectedPathLink[] + exact?: DetectedPathLink + invalid: boolean + /** The rejected prefix, for feedback. */ + attempted?: string +} + +/** Applies one typed digit to a path number, keeping longer numbers possible. */ +export function matchPathLinkDigit( + links: readonly DetectedPathLink[], + digits: string, + digit: string, +): PathLinkDigitResult { + const next = digits + digit + const matches = links.filter((link) => String(link.index).startsWith(next)) + if (!matches.length) return { digits: "", matches, invalid: true, attempted: next } + return { digits: next, matches, exact: matches.find((link) => String(link.index) === next), invalid: false } +} + +/** Toggles one link in a multi-select set without growing past `limit`. */ +export function togglePathLinkSelection(selected: ReadonlySet, index: number, limit: number) { + if (selected.has(index)) { + const next = new Set(selected) + next.delete(index) + return { selected: next, capped: false } + } + if (selected.size >= limit) return { selected, capped: true } + return { selected: new Set(selected).add(index), capped: false } +} + +/** Resolves candidates against the session directory and numbers stable links. */ +export function detectPathLinks(parts: readonly PathLinkPart[], options: PathLinkOptions): PathLinkDetection { + const platform = options.platform ?? process.platform + const stat = createStatCache() + const numbers = new Map() + const links: DetectedPathLink[] = [] + const marked = new Map() + + for (const part of parts) { + const markers: PathLinkMarker[] = [] + for (const candidate of scanPathCandidates(part.text)) { + const resolved = resolveCandidate(candidate.text, candidate.source, options.base, options.home, platform, stat) + if (!resolved) continue + const key = `${resolved.path}\0${resolved.line ?? ""}\0${resolved.column ?? ""}` + const existing = numbers.get(key) + if (existing === undefined) { + const index = links.length + 1 + numbers.set(key, index) + links.push({ + index, + partID: part.id, + display: candidate.text, + path: resolved.path, + line: resolved.line, + column: resolved.column, + kind: resolved.kind, + exists: resolved.exists, + }) + markers.push({ at: candidate.markerAt, index }) + continue + } + markers.push({ at: candidate.markerAt, index: existing }) + } + if (markers.length) marked.set(part.id, { text: part.text, markers }) + } + + return { links, parts: marked } +} + +function scanLine(line: string, offset: number, candidates: PathLinkCandidate[]) { + const ranges: Array<{ start: number; end: number }> = [] + let index = 0 + while (index < line.length) { + const char = line[index]! + if (char === "`") { + const run = countRun(line, index, "`") + const close = findRun(line, index + run, "`", run) + if (close === -1) { + index += run + continue + } + const contentStart = index + run + const content = line.slice(contentStart, close) + const trimmedStart = contentStart + content.length - content.trimStart().length + const trimmed = content.trim() + if (trimmed) { + const candidate = toCandidate(trimmed, offset + trimmedStart, offset + close + run, "inline-code") + if (candidate) candidates.push(candidate) + } + ranges.push({ start: index, end: close + run }) + index = close + run + continue + } + if (char === "[") { + const link = readMarkdownLink(line, index) + if (link) { + ranges.push({ start: index, end: link.end }) + if (link.target) { + const targetStart = link.targetStart + const candidate = toCandidate(link.target, offset + targetStart, offset + link.end, "markdown-link") + if (candidate) candidates.push(candidate) + } + index = link.end + continue + } + } + if (char === "<") { + const close = line.indexOf(">", index + 1) + if (close !== -1) { + const content = line.slice(index + 1, close) + if (/^file:/i.test(content)) { + const candidate = toCandidate(content, offset + index + 1, offset + close + 1, "markdown-link") + if (candidate) candidates.push(candidate) + } + ranges.push({ start: index, end: close + 1 }) + index = close + 1 + continue + } + } + if (char === '"' || char === "'") { + const before = index === 0 ? " " : line[index - 1]! + if (!/[\w]/.test(before)) { + const close = line.indexOf(char, index + 1) + if (close !== -1) { + const start = index + 1 + const content = line.slice(start, close).trim() + if (content) { + const candidate = toCandidate(content, offset + start, offset + close + 1, "quoted") + if (candidate) candidates.push(candidate) + } + ranges.push({ start: index, end: close + 1 }) + index = close + 1 + continue + } + } + } + if (char === "h" && /^https?:\/\//i.test(line.slice(index))) { + const url = readUrl(line, index) + if (url) { + ranges.push({ start: index, end: url }) + index = url + continue + } + } + index++ + } + + let cursor = 0 + for (const range of [...ranges, { start: line.length, end: line.length }]) { + if (range.start > cursor) scanBare(line.slice(cursor, range.start), offset + cursor, candidates) + cursor = Math.max(cursor, range.end) + } +} + +function scanBare(segment: string, offset: number, candidates: PathLinkCandidate[]) { + for (const match of segment.matchAll(barePattern)) { + const raw = match[0] + const start = match.index ?? 0 + const base = trimToken(raw) + if (!base) continue + // The path pattern stops before `:12` and `#L12`; keep an attached suffix + // with the path so its marker lands after the line reference. + const suffix = + base === raw + ? (/^(?::\d{1,7}(?::\d{1,7})?(?:-\d{1,7}(?::\d{1,7})?)?|#L\d{1,7}(?:C\d{1,7})?(?:-L?\d{1,7}(?:C\d{1,7})?)?)/i.exec( + segment.slice(start + raw.length), + )?.[0] ?? "") + : "" + const text = base + suffix + const candidate = toCandidate(text, offset + start, offset + start + text.length, "bare") + if (candidate) candidates.push(candidate) + } +} + +function toCandidate(text: string, start: number, markerAt: number, source: PathLinkSource) { + const suffix = splitSuffix(text) + const value = suffix?.path ?? text + if (!looksLikePath(value, source !== "bare")) return + return { text: suffix ? text : value, start, end: start + text.length, markerAt, source } +} + +function looksLikePath(value: string, allowSpaces = false) { + if (!value || value.length > 4096) return false + if (value.startsWith("file:")) return /^file:\/\//i.test(value) + if (/[<>|]/.test(value)) return false + if (explicitPattern.test(value)) return true + if (/\s/.test(value)) { + if (!allowSpaces) return false + return /[\\/]/.test(value) || /\.[A-Za-z0-9]{1,8}$/.test(value) + } + if (/^[A-Za-z][A-Za-z0-9+.-]*:[^\\/]/.test(value) && !/^[A-Za-z]:[\\/]/.test(value)) return false + if (/[\\/]/.test(value)) return true + return /^[^/\\]+\.[A-Za-z0-9]{1,8}$/.test(value) +} + +function splitSuffix(value: string) { + const hash = /^(.+?)#L(\d{1,7})(?:C(\d{1,7}))?(?:-L?\d+(?:C\d+)?)?$/i.exec(value) + if (hash) { + const line = Number(hash[2]) + const column = hash[3] ? Number(hash[3]) : undefined + if (validLine(line) && (column === undefined || validLine(column))) return { path: hash[1]!, line, column } + } + const colon = /^(.+?):(\d{1,7})(?::(\d{1,7}))?(?:-\d{1,7}(?::\d{1,7})?)?$/.exec(value) + if (colon && !/^[A-Za-z]$/.test(colon[1]!)) { + const line = Number(colon[2]) + const column = colon[3] ? Number(colon[3]) : undefined + if (validLine(line) && (column === undefined || validLine(column))) return { path: colon[1]!, line, column } + } + return +} + +function validLine(value: number) { + return Number.isSafeInteger(value) && value >= 1 && value <= MAX_SUFFIX_LINE +} + +function countRun(line: string, index: number, char: string) { + let length = 0 + while (line[index + length] === char) length++ + return length +} + +function findRun(line: string, from: number, char: string, length: number) { + let index = from + while (index < line.length) { + if (line[index] !== char) { + index++ + continue + } + const run = countRun(line, index, char) + if (run === length) return index + index += run + } + return -1 +} + +function readMarkdownLink(line: string, open: number) { + const labelEnd = line.indexOf("]", open + 1) + if (labelEnd === -1 || line[labelEnd + 1] !== "(") return + const targetOpen = labelEnd + 2 + let index = targetOpen + let depth = 1 + while (index < line.length) { + const char = line[index]! + if (char === "(") depth++ + if (char === ")") { + depth-- + if (depth === 0) break + } + index++ + } + if (depth !== 0) return + const inner = line.slice(targetOpen, index) + const match = /^\s*(<[^>]*>|[^\s]+)/.exec(inner) + if (!match) return { end: index + 1 } + const bare = match[1]! + const target = bare.startsWith("<") ? bare.slice(1, -1) : bare + const targetStart = targetOpen + inner.indexOf(bare) + (bare.startsWith("<") ? 1 : 0) + const path = splitSuffix(target)?.path ?? target + if (!looksLikePath(path) || path.startsWith("#")) return { end: index + 1 } + return { end: index + 1, target, targetStart } +} + +function readUrl(line: string, from: number) { + let index = from + while (index < line.length && !/[\s<>`"']/.test(line[index]!)) index++ + return index > from ? index : undefined +} + +function trimToken(raw: string) { + let value = raw + while (value.length) { + const last = value[value.length - 1]! + if (".,;:!?".includes(last)) { + value = value.slice(0, -1) + continue + } + if (last === ")" || last === "]") { + const open = last === ")" ? "(" : "[" + if (count(value, last) > count(value, open)) { + value = value.slice(0, -1) + continue + } + } + break + } + return value +} + +function count(value: string, char: string) { + let total = 0 + for (const item of value) if (item === char) total++ + return total +} + +type StatCache = Map + +function createStatCache(): StatCache { + return new Map() +} + +function statFile(file: string, cache: StatCache) { + const cached = cache.get(file) + if (cached) return cached + const result = (() => { + try { + const stat = statSync(file, { throwIfNoEntry: false }) + if (!stat) return { exists: false } + return { exists: true, kind: stat.isDirectory() ? ("directory" as const) : ("file" as const) } + } catch { + return { exists: false } + } + })() + cache.set(file, result) + return result +} + +function resolveCandidate( + raw: string, + source: PathLinkSource, + base: string, + home: string, + platform: NodeJS.Platform, + cache: StatCache, +) { + const suffix = splitSuffix(raw) + const value = suffix?.path ?? raw + const fileUrl = resolveFileUrl(value, platform) + if (fileUrl) { + const stat = statFile(fileUrl.path, cache) + return { + path: fileUrl.path, + line: fileUrl.line ?? suffix?.line, + column: fileUrl.column ?? suffix?.column, + kind: stat.kind, + exists: stat.exists, + } + } + // A file: URL we cannot resolve is remote or malformed; never treat it as a + // relative path. + if (/^file:/i.test(value)) return + const candidate = expandHome(value, home, platform) + if (!looksLikePath(candidate, source !== "bare")) return + const impl = platform === "win32" ? path.win32 : path.posix + const absolute = impl.isAbsolute(candidate) ? impl.normalize(candidate) : impl.resolve(base, candidate) + const stat = statFile(absolute, cache) + if (!stat.exists && !isExplicit(candidate)) return + return { + path: absolute, + line: suffix?.line, + column: suffix?.column, + kind: stat.kind, + exists: stat.exists, + } +} + +function resolveFileUrl(value: string, platform: NodeJS.Platform) { + if (!/^file:/i.test(value)) return + let url: URL + try { + url = new URL(value) + } catch { + return + } + if (url.protocol !== "file:") return + if (platform !== "win32" && url.hostname && url.hostname !== "localhost") return + let decoded: string + try { + decoded = decodeURIComponent(url.pathname) + } catch { + return + } + const hash = /^#L(\d+)(?:C(\d+))?/i.exec(url.hash) + const line = hash ? Number(hash[1]) : undefined + const column = hash?.[2] ? Number(hash[2]) : undefined + const windows = /^\/[A-Za-z]:/.test(decoded) + const withoutRoot = platform === "win32" && windows ? decoded.slice(1) : decoded + if (platform === "win32") { + if (url.hostname && url.hostname !== "localhost") { + return { + path: `\\\\${url.hostname}${decoded.replaceAll("/", "\\")}`, + line: line !== undefined && validLine(line) ? line : undefined, + column: column !== undefined && validLine(column) ? column : undefined, + } + } + return { + path: withoutRoot.replaceAll("/", "\\"), + line: line !== undefined && validLine(line) ? line : undefined, + column: column !== undefined && validLine(column) ? column : undefined, + } + } + return { + path: decoded, + line: line !== undefined && validLine(line) ? line : undefined, + column: column !== undefined && validLine(column) ? column : undefined, + } +} + +function expandHome(value: string, home: string, platform: NodeJS.Platform) { + if (!/^~[\\/]/.test(value)) return value + const impl = platform === "win32" ? path.win32 : path.posix + return impl.join(home, value.slice(2)) +} + +function isExplicit(value: string) { + return explicitPattern.test(value) +} diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 3a2c8b9ec700..2e5ea9118216 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1,7 +1,15 @@ import { afterEach, expect, test } from "bun:test" -import { writeFile } from "node:fs/promises" +import { existsSync } from "node:fs" +import { readFile, writeFile } from "node:fs/promises" import path from "node:path" -import { normalizePromptContent, openEditor } from "../src/editor" +import { + buildEditorBatchInvocation, + normalizePromptContent, + openEditor, + openFileAtLocation, + openFilesAtLocation, + splitCommand, +} from "../src/editor" import { tmpdir } from "./fixture/fixture" const editor = process.env.EDITOR @@ -67,3 +75,307 @@ test("normalizes a single trailing editor newline for one-line prompts", () => { test("preserves multiline prompts that end with a newline", () => { expect(normalizePromptContent("hello\nworld\n")).toBe("hello\nworld\n") }) + +test("splits an editor command without treating quotes as arguments", () => { + expect(splitCommand(`code --extensions-dir "C:\\my dir\\ext"`)).toEqual([ + "code", + "--extensions-dir", + "C:\\my dir\\ext", + ]) + expect(splitCommand("'nvim' -u NONE")).toEqual(["nvim", "-u", "NONE"]) +}) + +test("builds editor invocations with line and column support", () => { + expect( + buildEditorBatchInvocation({ + editor: "code --wait", + files: [{ file: "C:\\repo\\a.ts", line: 12, column: 3 }], + platform: "win32", + }), + ).toEqual({ + command: "code", + args: ["--wait", "--goto", "C:\\repo\\a.ts:12:3"], + terminal: false, + shell: true, + batch: true, + }) + expect( + buildEditorBatchInvocation({ editor: "nvim", files: [{ file: "/repo/a.ts", line: 4 }], platform: "linux" }), + ).toEqual({ + command: "nvim", + args: ["+4", "/repo/a.ts"], + terminal: true, + shell: false, + batch: true, + }) + expect( + buildEditorBatchInvocation({ + editor: "nano", + files: [{ file: "/a.ts", line: 4, column: 2 }], + platform: "linux", + }), + ).toEqual({ + command: "nano", + args: ["+4,2", "/a.ts"], + terminal: true, + shell: false, + batch: true, + }) + expect( + buildEditorBatchInvocation({ + editor: "emacs", + files: [{ file: "/a.ts", line: 4, column: 2 }], + platform: "linux", + }), + ).toEqual({ + command: "emacs", + args: ["+4:2", "/a.ts"], + terminal: true, + shell: false, + batch: true, + }) + expect( + buildEditorBatchInvocation({ editor: "unknown-editor", files: [{ file: "/a.ts", line: 4 }], platform: "linux" }), + ).toEqual({ + command: "unknown-editor", + args: ["/a.ts"], + terminal: false, + shell: false, + batch: false, + }) +}) + +test("passes every path to an editor that accepts several targets", () => { + expect( + buildEditorBatchInvocation({ + editor: "code", + files: [{ file: "/a.ts", line: 1 }, { file: "/b.ts" }], + platform: "linux", + }).args, + ).toEqual(["--goto", "/a.ts:1", "/b.ts"]) + expect( + buildEditorBatchInvocation({ + editor: "subl", + files: [ + { file: "/a.ts", line: 1, column: 2 }, + { file: "/b.ts", line: 5 }, + ], + platform: "darwin", + }).args, + ).toEqual(["/a.ts:1:2", "/b.ts:5"]) + expect( + buildEditorBatchInvocation({ + editor: "nvim", + files: [ + { file: "/a.ts", line: 3 }, + { file: "/b.ts", line: 9 }, + ], + platform: "darwin", + }).args, + ).toEqual(["+3", "/a.ts", "/b.ts"]) +}) + +test("treats a quoted editor executable as a path, not a shell command shim", () => { + expect( + buildEditorBatchInvocation({ + editor: `"C:\\Program Files\\Code\\Code.exe" --reuse-window`, + files: [{ file: "C:\\repo\\a.ts" }], + platform: "win32", + }), + ).toEqual({ + command: "C:\\Program Files\\Code\\Code.exe", + args: ["--reuse-window", "C:\\repo\\a.ts"], + terminal: false, + shell: false, + batch: true, + }) +}) + +test("refuses to open a path that does not exist", async () => { + await using tmp = await tmpdir() + await expect( + openFileAtLocation({ path: path.join(tmp.path, "missing.ts"), renderer: renderer() as never }), + ).rejects.toThrow("does not exist") +}) + +test("refuses to launch an executable file when no editor is configured", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "build.cmd") + await writeFile(file, "@echo off\n") + delete process.env.EDITOR + delete process.env.VISUAL + + await expect(openFileAtLocation({ path: file, renderer: renderer() as never })).rejects.toThrow("Refusing to launch") +}) + +test.skipIf(process.platform === "win32")("refuses to launch a file with an executable bit and no editor", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "tool.sh") + await writeFile(file, "#!/bin/sh\n", { mode: 0o755 }) + delete process.env.EDITOR + delete process.env.VISUAL + + await expect(openFileAtLocation({ path: file, renderer: renderer() as never })).rejects.toThrow("Refusing to launch") +}) + +test.skipIf(process.platform === "win32")("passes the line to a terminal editor and resumes the renderer", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "target.ts") + const script = path.join(tmp.path, "vim") + await writeFile(file, "const value = 1\n") + await writeFile(script, '#!/bin/sh\nprintf "%s\\n" "$@" > "$0.args"\n', { mode: 0o755 }) + delete process.env.EDITOR + process.env.VISUAL = script + const events: string[] = [] + + await openFileAtLocation({ path: file, line: 7, renderer: renderer(events) as never }) + + expect(events).toEqual(["suspend", "resume"]) + expect((await readFile(`${script}.args`, "utf8")).trim().split("\n")).toEqual(["+7", file]) +}) + +test.skipIf(process.platform === "win32")("opens several paths in one terminal editor and resumes once", async () => { + await using tmp = await tmpdir() + const first = path.join(tmp.path, "a.ts") + const second = path.join(tmp.path, "b.ts") + const script = path.join(tmp.path, "vim") + await writeFile(first, "a\n") + await writeFile(second, "b\n") + await writeFile(script, '#!/bin/sh\nprintf "%s\\n" "$@" > "$0.args"\n', { mode: 0o755 }) + delete process.env.EDITOR + process.env.VISUAL = script + const events: string[] = [] + + const result = await openFilesAtLocation({ + files: [{ path: first, line: 3 }, { path: second }, { path: first, line: 9 }], + renderer: renderer(events) as never, + }) + + expect(result).toEqual({ opened: 2, failures: [] }) + expect(events).toEqual(["suspend", "resume"]) + expect((await readFile(`${script}.args`, "utf8")).trim().split("\n")).toEqual(["+3", first, second]) +}) + +test.skipIf(process.platform === "win32")("launches one process for an editor that batches targets", async () => { + await using tmp = await tmpdir() + const first = path.join(tmp.path, "a.ts") + const second = path.join(tmp.path, "b.ts") + const script = path.join(tmp.path, "code") + await writeFile(first, "a\n") + await writeFile(second, "b\n") + await writeFile(script, '#!/bin/sh\nprintf "%s\\n" "$@" > "$0.args"\n', { mode: 0o755 }) + delete process.env.EDITOR + process.env.VISUAL = script + + const result = await openFilesAtLocation({ + files: [{ path: first }, { path: second }], + renderer: renderer() as never, + }) + + expect(result).toEqual({ opened: 2, failures: [] }) + expect(await editorArgs(`${script}.args`, 2)).toEqual([first, second]) +}) + +test.skipIf(process.platform === "win32")("launches an editor without batch support once per path", async () => { + await using tmp = await tmpdir() + const first = path.join(tmp.path, "a.ts") + const second = path.join(tmp.path, "b.ts") + const script = path.join(tmp.path, "unknown-editor") + await writeFile(first, "a\n") + await writeFile(second, "b\n") + await writeFile(script, '#!/bin/sh\nprintf "%s\\n" "$@" >> "$0.args"\n', { mode: 0o755 }) + delete process.env.EDITOR + process.env.VISUAL = script + + const result = await openFilesAtLocation({ + files: [{ path: first }, { path: second }], + renderer: renderer() as never, + }) + + expect(result).toEqual({ opened: 2, failures: [] }) + expect((await editorArgs(`${script}.args`, 2)).sort()).toEqual([first, second].sort()) +}) + +test("reports missing paths and launchable files in one batch", async () => { + await using tmp = await tmpdir() + const missing = path.join(tmp.path, "missing.ts") + const cmd = path.join(tmp.path, "build.cmd") + const ps1 = path.join(tmp.path, "task.ps1") + const scf = path.join(tmp.path, "open.scf") + await writeFile(cmd, "@echo off\n") + await writeFile(ps1, "") + await writeFile(scf, "") + delete process.env.EDITOR + delete process.env.VISUAL + + const result = await openFilesAtLocation({ + files: [{ path: missing }, { path: cmd }, { path: ps1 }, { path: scf }], + renderer: renderer() as never, + }) + + expect(result.opened).toBe(0) + expect(result.failures.map((item) => item.message)).toEqual([ + `Path does not exist: ${missing}`, + "Refusing to launch build.cmd: set $VISUAL or $EDITOR to open it", + "Refusing to launch task.ps1: set $VISUAL or $EDITOR to open it", + "Refusing to launch open.scf: set $VISUAL or $EDITOR to open it", + ]) +}) + +test.skipIf(process.platform !== "win32")("opens several paths in one Windows terminal editor shim", async () => { + await using tmp = await tmpdir() + const first = path.join(tmp.path, "a.ts") + const second = path.join(tmp.path, "b.ts") + const script = path.join(tmp.path, "vim.cmd") + const argsFile = path.join(tmp.path, "vim.args") + await writeFile(first, "a\n") + await writeFile(second, "b\n") + await writeFile(script, `@echo off\r\n(for %%a in (%*) do @echo %%~a) > "${argsFile}"\r\n`) + delete process.env.EDITOR + process.env.VISUAL = script + const events: string[] = [] + + const result = await openFilesAtLocation({ + files: [{ path: first, line: 3 }, { path: second }, { path: first, line: 9 }], + renderer: renderer(events) as never, + }) + + expect(result).toEqual({ opened: 2, failures: [] }) + expect(events).toEqual(["suspend", "resume"]) + expect((await readFile(argsFile, "utf8")).trim().split(/\r?\n/)).toEqual(["+3", first, second]) +}) + +test.skipIf(process.platform !== "win32")("launches one Windows command shim for a batched editor", async () => { + await using tmp = await tmpdir() + const first = path.join(tmp.path, "a.ts") + const second = path.join(tmp.path, "b.ts") + const script = path.join(tmp.path, "code.cmd") + const argsFile = path.join(tmp.path, "code.args") + await writeFile(first, "a\n") + await writeFile(second, "b\n") + await writeFile(script, `@echo off\r\n(for %%a in (%*) do @echo %%~a) > "${argsFile}"\r\n`) + delete process.env.EDITOR + process.env.VISUAL = script + + const result = await openFilesAtLocation({ + files: [{ path: first }, { path: second }], + renderer: renderer() as never, + }) + + expect(result).toEqual({ opened: 2, failures: [] }) + expect(await editorArgs(argsFile, 2)).toEqual([first, second]) +}) + +async function editorArgs(file: string, expected = 1) { + for (let attempt = 0; attempt < 100; attempt++) { + if (existsSync(file)) { + const args = (await readFile(file, "utf8")) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + if (args.length >= expected) return args + } + await Bun.sleep(20) + } + throw new Error(`Editor did not write ${file}`) +} diff --git a/packages/tui/test/file-links.test.ts b/packages/tui/test/file-links.test.ts new file mode 100644 index 000000000000..7143f583ecb9 --- /dev/null +++ b/packages/tui/test/file-links.test.ts @@ -0,0 +1,266 @@ +import { expect, test } from "bun:test" +import { mkdir, writeFile } from "node:fs/promises" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { + applyPathLinkMarkers, + detectPathLinks, + matchPathLinkDigit, + pathLinkSelectionTag, + pathLinkTag, + scanPathCandidates, + togglePathLinkSelection, + type DetectedPathLink, +} from "../src/util/file-links" +import { tmpdir } from "./fixture/fixture" + +test("numbers existing paths in source order and inserts markers outside syntax", async () => { + await using tmp = await tmpdir() + await mkdir(path.join(tmp.path, "out")) + await writeFile(path.join(tmp.path, "out", "report.md"), "# report") + const text = "Wrote `out/report.md` and out/report.md:12:3." + const detection = detectPathLinks([{ id: "text:0", text }], { base: tmp.path, home: tmp.path }) + + expect( + detection.links.map(({ display, line, column, exists, kind }) => ({ display, line, column, exists, kind })), + ).toEqual([ + { display: "out/report.md", line: undefined, column: undefined, exists: true, kind: "file" }, + { display: "out/report.md:12:3", line: 12, column: 3, exists: true, kind: "file" }, + ]) + expect(applyPathLinkMarkers(text, detection.parts.get("text:0")!.markers)).toBe( + "Wrote `out/report.md` [1] and out/report.md:12:3 [2].", + ) +}) + +test("detects existing directories, including a trailing slash", async () => { + await using tmp = await tmpdir() + await mkdir(path.join(tmp.path, "out")) + const detection = detectPathLinks([{ id: "text:0", text: "Files live in out/ today." }], { + base: tmp.path, + home: tmp.path, + }) + + expect(detection.links).toMatchObject([{ path: path.join(tmp.path, "out"), exists: true, kind: "directory" }]) +}) + +test("keeps quoted paths with spaces together", async () => { + await using tmp = await tmpdir() + await writeFile(path.join(tmp.path, "docs note.md"), "") + const text = 'Open "docs note.md" next.' + const detection = detectPathLinks([{ id: "text:0", text }], { base: tmp.path, home: tmp.path }) + + expect(detection.links).toMatchObject([{ display: "docs note.md", exists: true }]) + expect(applyPathLinkMarkers(text, detection.parts.get("text:0")!.markers)).toBe('Open "docs note.md" [1] next.') +}) + +test("detects local Markdown links and their target line", async () => { + await using tmp = await tmpdir() + await writeFile(path.join(tmp.path, "a.ts"), "") + const text = "See [a.ts](a.ts#L4)." + const detection = detectPathLinks([{ id: "text:0", text }], { base: tmp.path, home: tmp.path }) + + expect(detection.links).toMatchObject([{ display: "a.ts#L4", path: path.join(tmp.path, "a.ts"), line: 4 }]) + expect(applyPathLinkMarkers(text, detection.parts.get("text:0")!.markers)).toBe("See [a.ts](a.ts#L4) [1].") +}) + +test("resolves file URLs and keeps their line and column", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "a b.ts") + await writeFile(file, "") + const text = `Open <${pathToFileURL(file).href}#L4C2>.` + const detection = detectPathLinks([{ id: "text:0", text }], { base: tmp.path, home: tmp.path }) + + expect(detection.links).toMatchObject([{ path: file, line: 4, column: 2, exists: true }]) +}) + +test("expands a home directory path", async () => { + await using tmp = await tmpdir() + await writeFile(path.join(tmp.path, "notes.txt"), "") + const detection = detectPathLinks([{ id: "text:0", text: "See ~/notes.txt." }], { + base: path.join(tmp.path, "elsewhere"), + home: tmp.path, + }) + + expect(detection.links).toMatchObject([{ path: path.join(tmp.path, "notes.txt"), exists: true }]) +}) + +test("ignores fenced code, URLs, commands, and missing relative names", async () => { + await using tmp = await tmpdir() + await mkdir(path.join(tmp.path, "out")) + await writeFile(path.join(tmp.path, "out", "report.md"), "") + const text = [ + "```", + "out/report.md", + "```", + "https://example.com/out/report.md", + "Run `npm install` now.", + "Read [the docs](https://example.com/out/report.md).", + "Nothing at report.md or missing.html.", + ].join("\n") + + expect(detectPathLinks([{ id: "text:0", text }], { base: tmp.path, home: tmp.path }).links).toEqual([]) +}) + +test("lists explicitly rooted paths even when they are missing", async () => { + await using tmp = await tmpdir() + const text = "Missing ./gone.txt and report.md, plus /definitely-missing-opencode/x.txt." + const detection = detectPathLinks([{ id: "text:0", text }], { base: tmp.path, home: tmp.path }) + + expect(detection.links.map((link) => link.display)).toEqual(["./gone.txt", "/definitely-missing-opencode/x.txt"]) + expect(detection.links.every((link) => !link.exists)).toBe(true) +}) + +test("parses Windows drive paths, columns, and UNC shares without touching the filesystem", () => { + const candidates = scanPathCandidates("Copy D:/repo/a.ts:7:2 and \\\\server\\share\\b.ts now.") + expect(candidates.map((candidate) => candidate.text)).toEqual(["D:/repo/a.ts:7:2", "\\\\server\\share\\b.ts"]) + + const detection = detectPathLinks([{ id: "text:0", text: "See C:\\nope\\x.ts:9:3." }], { + base: "C:\\repo", + home: "C:\\Users\\me", + platform: "win32", + }) + expect(detection.links).toMatchObject([{ path: "C:\\nope\\x.ts", line: 9, column: 3, exists: false }]) +}) + +test("resolves Windows file URLs and UNC shares", () => { + const url = new URL("file:///C:/Users/me/report%201.txt#L3C2") + const detection = detectPathLinks([{ id: "text:0", text: `Open <${url.href}>.` }], { + base: "C:\\repo", + home: "C:\\Users\\me", + platform: "win32", + }) + expect(detection.links).toMatchObject([{ path: "C:\\Users\\me\\report 1.txt", line: 3, column: 2, exists: false }]) + + const unc = detectPathLinks([{ id: "text:0", text: "See ." }], { + base: "C:\\repo", + home: "C:\\Users\\me", + platform: "win32", + }) + expect(unc.links).toMatchObject([{ path: "\\\\127.0.0.1\\share\\x.ts", exists: false }]) +}) + +test("expands a Windows home path with either separator", () => { + for (const raw of ["~\\notes.txt", "~/notes.txt"]) { + const detection = detectPathLinks([{ id: "text:0", text: `See ${raw}.` }], { + base: "C:\\repo", + home: "C:\\Users\\me", + platform: "win32", + }) + expect(detection.links).toMatchObject([{ path: "C:\\Users\\me\\notes.txt", exists: false }]) + } +}) + +test("ignores remote and malformed file URLs on POSIX", () => { + const detection = detectPathLinks( + [{ id: "text:0", text: "See and ." }], + { base: "/repo", home: "/home/me", platform: "linux" }, + ) + + expect(detection.links).toMatchObject([{ path: "/definitely-missing/gone.ts", exists: false }]) +}) + +test("normalizes POSIX paths against the session directory", () => { + for (const platform of ["linux", "darwin"] as const) { + const detection = detectPathLinks([{ id: "text:0", text: "See ../shared/a.ts and /var/log/../tmp/b.ts." }], { + base: "/repo/work", + home: "/home/me", + platform, + }) + expect(detection.links.map((link) => link.path)).toEqual(["/repo/shared/a.ts", "/var/tmp/b.ts"]) + } +}) + +test("reuses one number for repeated occurrences", async () => { + await using tmp = await tmpdir() + await writeFile(path.join(tmp.path, "a.ts"), "") + const text = "a.ts then a.ts again" + const detection = detectPathLinks([{ id: "text:0", text }], { base: tmp.path, home: tmp.path }) + + expect(detection.links).toHaveLength(1) + expect(detection.parts.get("text:0")!.markers).toHaveLength(2) + expect(applyPathLinkMarkers(text, detection.parts.get("text:0")!.markers)).toBe("a.ts [1] then a.ts [1] again") +}) + +test("numbers paths across message parts", async () => { + await using tmp = await tmpdir() + await writeFile(path.join(tmp.path, "a.ts"), "") + await writeFile(path.join(tmp.path, "b.ts"), "") + const detection = detectPathLinks( + [ + { id: "text:0", text: "a.ts" }, + { id: "text:1", text: "b.ts" }, + ], + { base: tmp.path, home: tmp.path }, + ) + + expect(detection.links.map((link) => [link.partID, link.display])).toEqual([ + ["text:0", "a.ts"], + ["text:1", "b.ts"], + ]) +}) + +test("keeps multi-digit selection unambiguous", () => { + const links = Array.from({ length: 12 }, (_, index): DetectedPathLink => { + const number = index + 1 + return { index: number, partID: "text:0", display: `f${number}.ts`, path: `f${number}.ts`, exists: true } + }) + + const first = matchPathLinkDigit(links, "", "1") + expect(first).toMatchObject({ digits: "1", invalid: false }) + expect(first.matches.map((link) => link.index)).toEqual([1, 10, 11, 12]) + expect(first.exact?.index).toBe(1) + + const second = matchPathLinkDigit(links, "1", "2") + expect(second).toMatchObject({ digits: "12", invalid: false, exact: { index: 12 } }) + expect(second.matches).toHaveLength(1) + + expect(matchPathLinkDigit(links, "", "0")).toMatchObject({ digits: "", invalid: true, attempted: "0" }) + expect(matchPathLinkDigit(Array.from(links).slice(0, 8), "", "1")).toMatchObject({ + digits: "1", + invalid: false, + exact: { index: 1 }, + }) +}) + +test("leaves source untouched when a marker is out of range", () => { + expect(applyPathLinkMarkers("a.ts", [{ at: 99, index: 1 }])).toBe("a.ts") +}) + +test("keeps a marker next to parentheses from becoming a Markdown link", async () => { + await using tmp = await tmpdir() + await writeFile(path.join(tmp.path, "a.ts"), "") + const text = "resolved in a.ts(see below)" + const detection = detectPathLinks([{ id: "text:0", text }], { base: tmp.path, home: tmp.path }) + + expect(applyPathLinkMarkers(text, detection.parts.get("text:0")!.markers)).toBe("resolved in a.ts [1] (see below)") +}) + +test("adds a readable space between each path and its tag", async () => { + await using tmp = await tmpdir() + await writeFile(path.join(tmp.path, "a.ts"), "") + await writeFile(path.join(tmp.path, "b.ts"), "") + const text = "a.ts then b.ts then a.ts" + const detection = detectPathLinks([{ id: "text:0", text }], { base: tmp.path, home: tmp.path }) + const markers = detection.parts.get("text:0")!.markers + + expect(applyPathLinkMarkers(text, markers, pathLinkTag)).toBe("a.ts ~~1~~ then b.ts ~~2~~ then a.ts ~~1~~") + expect(applyPathLinkMarkers(text, markers, (index) => pathLinkSelectionTag(index, index === 2))).toBe( + "a.ts ~~○1~~ then b.ts ~~●2~~ then a.ts ~~○1~~", + ) +}) + +test("toggles multi-selection without exceeding the limit", () => { + const empty = new Set() + const first = togglePathLinkSelection(empty, 2, 2) + expect(first).toEqual({ selected: new Set([2]), capped: false }) + expect(empty.size).toBe(0) + + const second = togglePathLinkSelection(first.selected, 5, 2) + expect(second).toEqual({ selected: new Set([2, 5]), capped: false }) + + const capped = togglePathLinkSelection(second.selected, 7, 2) + expect(capped).toEqual({ selected: second.selected, capped: true }) + + const removed = togglePathLinkSelection(second.selected, 2, 2) + expect(removed).toEqual({ selected: new Set([5]), capped: false }) +}) diff --git a/packages/tui/test/path-link-tags.test.tsx b/packages/tui/test/path-link-tags.test.tsx new file mode 100644 index 000000000000..cb7676a36880 --- /dev/null +++ b/packages/tui/test/path-link-tags.test.tsx @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test" +import type { SyntaxStyle } from "@opentui/core" +import { testRender, useRenderer } from "@opentui/solid" +import { generateSyntax, resolveTheme, selectTheme } from "@opencode/theme/tui" +import { getOpenCodeTheme } from "../src/theme" +import { waitForHighlightedFrame } from "./fixture/frame" + +test("paints concealed path tags in the picker color after a space", async () => { + const theme = resolveTheme(selectTheme(getOpenCodeTheme(), "dark")) + const tagColor = theme.text.feedback.error.base + let syntax: SyntaxStyle | undefined + const output = await testRender( + () => { + const renderer = useRenderer() + syntax = generateSyntax(theme, renderer.nativeScene, { "markup.strikethrough": tagColor }) + return ( + + ) + }, + { width: 60, height: 4, remote: true }, + ) + + try { + const frame = await waitForHighlightedFrame(output, (value) => + value.includes("Wrote a.ts 1 and b.ts ●2 then c.ts ○3."), + ) + expect(frame).not.toContain("~~") + const spans = output.captureSpans().lines[0].spans + const tags = spans.filter((span) => /^[●○]?\d$/.test(span.text)) + expect(tags.map((span) => span.text)).toEqual(["1", "●2", "○3"]) + for (const span of tags) expect(span.fg.equals(tagColor)).toBe(true) + expect(spans[0].text).toBe("Wrote ") + expect(spans[0].fg.equals(theme.text.base)).toBe(true) + } finally { + output.renderer.destroy() + await output.renderer.closed + syntax?.destroy() + } +}) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index 63ef41144870..29f1b5621183 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -120,6 +120,28 @@ test("generates syntax with one categorical hue", () => { } }) +test("generates syntax that applies feature scope overrides last", () => { + const theme = resolveSource(complete("light", { categorical: ["red"] }), "light") + const red = theme.text.feedback.error.base + const inheritedOwner = new ResourceContext({ objectCapacity: 1, renderCellsMax: 1 }) + const overriddenOwner = new ResourceContext({ objectCapacity: 1, renderCellsMax: 1 }) + const inherited = generateSyntax(theme, inheritedOwner) + const overridden = generateSyntax(theme, overriddenOwner, { "markup.strikethrough": red }) + + try { + expect(overridden.getStyle("markup.strikethrough")?.fg?.equals(red)).toBeTrue() + expect(inherited.getStyle("markup.strikethrough")?.fg?.equals(theme.text.muted)).toBeTrue() + expect( + overridden.getStyle("markup.link.label")?.fg?.equals(inherited.getStyle("markup.link.label")!.fg!), + ).toBeTrue() + } finally { + overridden.destroy() + inherited.destroy() + overriddenOwner.destroy() + inheritedOwner.destroy() + } +}) + test("rejects incomplete themes instead of merging defaults", () => { expect(() => resolveSource({ light: { hue: light.hue } }, "light")).toThrow("Invalid theme") expect(() => resolveSource({ light: { ...light, categorical: undefined } }, "light")).toThrow(