Skip to content

Commit 09409fb

Browse files
committed
fix(tui): open inline-code directory paths in Sublime Merge
Absolute and ~/ paths no longer need a trailing slash to count as directory links, and inline code holding an existing absolute directory in assistant text is now a clickable link.
1 parent abdf13d commit 09409fb

6 files changed

Lines changed: 130 additions & 5 deletions

File tree

‎Makefile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
BUN := mise exec -- bun
44

55
# Tests for changes made in this fork, relative to each package.
6-
TUI_FORK_TESTS := test/ui/directory-link.test.ts
6+
TUI_FORK_TESTS := test/ui/directory-link.test.ts test/ui/directory-markdown.test.tsx
77
CORE_FORK_TESTS := test/plugin/custom.test.ts test/plugin/supervisor.test.ts
88

99
install-deps:

‎packages/tui/src/routes/session/message-parts.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
1111
import { reasoningSummary } from "../../context/thinking"
1212
import { usePlugin } from "../../plugin/context"
1313
import { SplitBorder } from "../../ui/border"
14+
import { linkDirectoryMarkdown } from "../../ui/directory-markdown"
1415
import { Locale } from "../../util/locale"
1516
import { use } from "./render-context"
1617
import { generateThinkingSyntax } from "./thinking-syntax"
@@ -180,6 +181,7 @@ export function TextPart(props: {
180181
<box paddingLeft={3} flexShrink={0}>
181182
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
182183
<markdown
184+
ref={linkDirectoryMarkdown}
183185
syntaxStyle={syntax()}
184186
renderNode={plugins.markdown()}
185187
content={props.part.text.trim()}

‎packages/tui/src/ui/directory-link.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import type { ClipboardService } from "../context/clipboard"
77

88
const run = promisify(execFile)
99

10-
/** A directory link is a relative, absolute, or `~/` path with a trailing slash, such as `packages/core/`. */
10+
/** A directory link is an absolute or `~/` path, or a relative path with a trailing slash such as `packages/core/`. */
1111
export function directoryLinkPath(href: string, base: string) {
12-
if (!href.endsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(href)) return
13-
return path.resolve(href.startsWith("~/") ? os.homedir() : base, href.startsWith("~/") ? href.slice(2) : href)
12+
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return
13+
const home = href.startsWith("~/")
14+
if (!home && !href.startsWith("/") && !href.endsWith("/")) return
15+
return path.resolve(home ? os.homedir() : base, home ? href.slice(2) : href)
1416
}
1517

1618
/** Copies the directory path, then opens it in Sublime Merge. Rejects when it is not an existing directory. */
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { stat } from "node:fs/promises"
2+
import { CodeRenderable, Renderable } from "@opentui/core"
3+
import { directoryLinkPath } from "./directory-link"
4+
5+
const hooked = new WeakSet<Renderable>()
6+
7+
type OnChunks = NonNullable<CodeRenderable["onChunks"]>
8+
9+
/** Links inline code spans holding an existing absolute directory so a click opens it like a directory link. */
10+
export function linkDirectoryMarkdown(renderable: Renderable) {
11+
if (hooked.has(renderable)) return
12+
hooked.add(renderable)
13+
const add = renderable.add.bind(renderable)
14+
renderable.add = (child, index) => {
15+
if (child instanceof Renderable) linkDirectoryMarkdown(child)
16+
return add(child, index)
17+
}
18+
if (renderable instanceof CodeRenderable && renderable.filetype === "markdown") linkCode(renderable)
19+
renderable.getChildren().forEach(linkDirectoryMarkdown)
20+
}
21+
22+
function linkCode(renderable: CodeRenderable) {
23+
const onChunks = renderable.onChunks
24+
renderable.onChunks = async (chunks, context) =>
25+
linkDirectorySpans((await onChunks?.(chunks, context)) ?? chunks, context)
26+
const content = renderable.content
27+
renderable.content = ""
28+
renderable.content = content
29+
}
30+
31+
async function linkDirectorySpans(chunks: Parameters<OnChunks>[0], context: Parameters<OnChunks>[1]) {
32+
const candidates = context.highlights.flatMap(([start, end, group]) => {
33+
if (group !== "markup.raw" && !group.startsWith("markup.raw.")) return []
34+
const text = context.content.slice(start, end).replace(/^`+|`+$/g, "")
35+
return /^(\/|~\/)\S*$/.test(text) ? [text] : []
36+
})
37+
if (candidates.length === 0) return chunks
38+
const directories = new Set(
39+
(
40+
await Promise.all(
41+
[...new Set(candidates)].map(async (text) => {
42+
const directory = directoryLinkPath(text, "/")
43+
if (!directory) return
44+
return (await stat(directory).catch(() => undefined))?.isDirectory() ? text : undefined
45+
}),
46+
)
47+
).filter((text) => text !== undefined),
48+
)
49+
return chunks.map((chunk) => (directories.has(chunk.text) && !chunk.link ? { ...chunk, link: { url: chunk.text } } : chunk))
50+
}

‎packages/tui/test/ui/directory-link.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ test.each([
3232
["parent", "../shared/", "/work/app", "/work/shared"],
3333
["absolute", "/etc/hosts.d/", "/work", "/etc/hosts.d"],
3434
["home", "~/src/", "/work", path.join(os.homedir(), "src")],
35-
["no trailing slash", "packages/core", "/work", undefined],
35+
["absolute without trailing slash", "/var/folders/x/worktree", "/work", "/var/folders/x/worktree"],
36+
["home without trailing slash", "~/src", "/work", path.join(os.homedir(), "src")],
37+
["relative without trailing slash", "packages/core", "/work", undefined],
3638
["web link", "https://example.com/docs/", "/work", undefined],
3739
["file url", "file:///tmp/", "/work", undefined],
3840
])("directory link path: %s", (_name, href, base, expected) => {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/** @jsxImportSource @opentui/solid */
2+
import { afterEach, beforeEach, expect, test } from "bun:test"
3+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
4+
import os from "node:os"
5+
import path from "node:path"
6+
import { SyntaxStyle } from "@opentui/core"
7+
import { ManualClock } from "@opentui/core/testing"
8+
import { testRender, useRenderer } from "@opentui/solid"
9+
import { linkDirectoryMarkdown } from "../../src/ui/directory-markdown"
10+
import { clickedLink } from "../../src/ui/link"
11+
12+
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })
13+
let root: string
14+
15+
beforeEach(async () => {
16+
root = await mkdtemp(path.join(os.tmpdir(), "directory-markdown-"))
17+
await mkdir(path.join(root, "worktree"))
18+
await writeFile(path.join(root, "file.txt"), "")
19+
})
20+
21+
afterEach(() => rm(root, { recursive: true, force: true }))
22+
23+
test("inline code holding an existing absolute directory is a clickable link; files and missing paths are not", async () => {
24+
const directory = path.join(root, "worktree")
25+
const file = path.join(root, "file.txt")
26+
const missing = path.join(root, "missing")
27+
const opened: string[] = []
28+
function Transcript() {
29+
const renderer = useRenderer()
30+
return (
31+
<box
32+
onMouseUp={(event) => {
33+
const link = clickedLink(renderer, event)
34+
if (link) opened.push(link)
35+
}}
36+
>
37+
<markdown
38+
ref={linkDirectoryMarkdown}
39+
syntaxStyle={syntaxStyle}
40+
conceal
41+
content={`Dir \`${directory}\`\n\nFile \`${file}\`\n\nGone \`${missing}\``}
42+
/>
43+
</box>
44+
)
45+
}
46+
const app = await testRender(() => <Transcript />, { width: 120, height: 10, clock: new ManualClock() })
47+
48+
try {
49+
for (let attempt = 0; attempt < 50 && !app.captureCharFrame().includes("Gone"); attempt++) {
50+
await app.renderOnce()
51+
await Bun.sleep(10)
52+
}
53+
await Bun.sleep(50)
54+
await app.renderOnce()
55+
const rows = app.captureCharFrame().split("\n")
56+
const at = (label: string) => {
57+
const y = rows.findIndex((row) => row.includes(label))
58+
return [rows[y]!.indexOf("/"), y] as const
59+
}
60+
61+
await app.mockMouse.click(...at("Dir"))
62+
await app.mockMouse.click(...at("File"))
63+
await app.mockMouse.click(...at("Gone"))
64+
65+
expect(opened).toEqual([directory])
66+
} finally {
67+
app.renderer.destroy()
68+
}
69+
})

0 commit comments

Comments
 (0)