feat(tui): open sessions from session IDs in messages (#63224)

This commit is contained in:
Adam
2026-11-10 21:22:06 -06:00
committed by GitHub
Unverified
parent 342d8f1e3b
commit 7b830a4365
6 changed files with 447 additions and 3 deletions
@@ -9,6 +9,7 @@ import { Keymap } from "../../context/keymap"
import { useTheme, useThemes } from "../../context/theme"
import { usePlugin } from "../../plugin/context"
import { DIALOG_INSET, DialogBleed, DialogFooter, DialogHeader, DialogLayout } from "../../ui/dialog"
import { useSessionLinks } from "../../ui/session-link"
import { useToast } from "../../ui/toast"
import { getScrollAcceleration } from "../../util/scroll"
@@ -172,6 +173,7 @@ export function Answer(props: {
>
<box paddingLeft={DIALOG_INSET} paddingRight={DIALOG_INSET} paddingTop={1} paddingBottom={1}>
<markdown
ref={useSessionLinks()}
syntaxStyle={syntax()}
renderNode={props.markdown()}
content={props.answer}
@@ -19,6 +19,7 @@ import { useRoute, useRouteData } from "../../context/route"
import { createStore } from "solid-js/store"
import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border"
import { useSessionLinks } from "../../ui/session-link"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
@@ -2223,6 +2224,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
<Show when={content()}>
<box paddingTop={1} paddingLeft={3}>
<markdown
ref={useSessionLinks()}
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
streaming={true}
@@ -15,6 +15,7 @@ import { Locale } from "../../util/locale"
import { use } from "./render-context"
import { generateThinkingSyntax } from "./thinking-syntax"
import { canonicalToolName } from "../../util/tool-display"
import { useSessionLinks } from "../../ui/session-link"
export const INLINE_TOOL_ICON_WIDTH = 2
@@ -184,6 +185,7 @@ export function TextPart(props: {
<box paddingLeft={3} flexShrink={0}>
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
<markdown
ref={useSessionLinks()}
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
content={props.part.text.trim()}
+7 -3
View File
@@ -40,8 +40,12 @@ export function Link(props: LinkProps) {
* link resolves the whole link. Modified clicks are left to the terminal's own hyperlink handling.
*/
export function clickedLink(renderer: CliRenderer, event: MouseEvent) {
if (!event.isDragging || event.modifiers.ctrl || event.modifiers.alt || event.modifiers.shift) return
const selection = renderer.getSelection()
if (!selection?.isStart || selection.behavior !== "cell") return
if (!plainClick(renderer, event)) return
return renderer.getLinkAt(event.x, event.y) ?? undefined
}
export function plainClick(renderer: CliRenderer, event: MouseEvent) {
if (!event.isDragging || event.modifiers.ctrl || event.modifiers.alt || event.modifiers.shift) return false
const selection = renderer.getSelection()
return selection?.isStart === true && selection.behavior === "cell"
}
+154
View File
@@ -0,0 +1,154 @@
import {
CodeRenderable,
TextAttributes,
detectLinks,
type ChunkRenderContext,
type CliRenderer,
type MarkdownRenderable,
type MouseEvent,
type Renderable,
type TextChunk,
} from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { createEffect, createSignal, untrack } from "solid-js"
import { parseSessionMention, sessionMentions } from "@opencode/util/session-mention"
import { displayLabel } from "@opencode/util/session-title-fallback"
import { useData } from "../context/data"
import { useRoute } from "../context/route"
import { stringWidth } from "../util/string-width"
import { useDialog } from "./dialog"
import { plainClick } from "./link"
type Rendered = { text: string; ranges: { start: number; end: number; sessionID: string }[] }
/**
* Returns a `<markdown>` ref that shows session IDs as links titled after their sessions and
* opens them on click, like the web app. The current session, archived sessions and sessions
* that cannot be loaded stay plain IDs. Links re-render when a label changes.
*/
export function useSessionLinks() {
const data = useData()
const route = useRoute()
const dialog = useDialog()
const renderer = useRenderer()
const labelOf = (sessionID: string) => {
if (route.data.type === "session" && route.data.sessionID === sessionID) return
const session = data.session.get(sessionID)
// Sync dedupes in-flight and finished loads; a miss marks the session missing.
if (!session && !data.session.missing(sessionID))
untrack(() => void data.session.sync(sessionID).catch(() => undefined))
return session && session.time.archived === undefined ? displayLabel(session) : undefined
}
// Labels each ID was last rendered with, so the effect below re-renders only on a change.
const used = new Map<string, string | undefined>()
const [version, setVersion] = createSignal(0)
const rendered = new WeakMap<CodeRenderable, Rendered>()
let markdown: MarkdownRenderable | undefined
// OpenTUI calls this as a method of each prose block, so `this` is the block. Fenced code never gets it.
function linkChunks(this: CodeRenderable, chunks: TextChunk[], context: ChunkRenderContext) {
const style = context.syntaxStyle.mergeStyles("markup.link.url")
// Like the web app, a code span links only when it is exactly a session ID, so a command such
// as `opencode --session ses_…` stays copyable. Marked by displayed offset, since detectLinks
// can split chunks.
const raw = context.highlights.filter(([, , group]) => group === "markup.raw" || group.startsWith("markup.raw."))
let position = 0
const code = chunks.flatMap((chunk, index) => {
const start = position
position += chunk.text.length
const source = context.sourceRanges?.[index]
const span = source && raw.find(([from, to]) => from <= source.start && source.end <= to)
const text = span && context.content.slice(span[0], span[1]).replace(/^`+\s?|\s?`+$/g, "")
return text !== undefined && !parseSessionMention(text) ? [{ start, end: position }] : []
})
position = 0
const parts = detectLinks(chunks, context).flatMap((chunk): { chunk: TextChunk; sessionID?: string }[] => {
const start = position
position += chunk.text.length
const plain = chunk.link || code.some((range) => range.start <= start && start < range.end)
const mentions = plain ? [] : sessionMentions(chunk.text)
if (mentions.length === 0) return [{ chunk }]
// Odd parts are the mentions.
const cuts = [0, ...mentions.flatMap((mention) => [mention.start, mention.end]), chunk.text.length]
return cuts.slice(1).flatMap((end, index) => {
const text = chunk.text.slice(cuts[index], end)
const mention = index % 2 ? mentions[(index - 1) / 2]! : undefined
const label = mention && labelOf(mention.id)
if (mention && (!used.has(mention.id) || used.get(mention.id) !== label)) {
used.set(mention.id, label)
setVersion((value) => value + 1)
}
if (!mention || label === undefined) return text ? [{ chunk: { ...chunk, text } }] : []
const link = {
...chunk,
// Raw markdown mode shows the source.
text: this.conceal ? `@${label}` : text,
fg: style.fg ?? chunk.fg,
attributes: style.attributes | TextAttributes.UNDERLINE,
}
return [{ chunk: link, sessionID: mention.id }]
})
})
let offset = 0
const ranges = parts.flatMap((part) => {
const start = offset
offset += part.chunk.text.length
return part.sessionID ? [{ start, end: offset, sessionID: part.sessionID }] : []
})
rendered.set(this, { text: parts.map((part) => part.chunk.text).join(""), ranges })
return parts.map((part) => part.chunk)
}
createEffect(() => {
version()
if (!markdown || [...used].every(([sessionID, label]) => labelOf(sessionID) === label)) return
// Toggling the callback marks each block's highlight dirty while keeping the identity OpenTUI
// checks when terminal capabilities refresh link styling.
const refresh = (renderable: Renderable): void => {
if (renderable instanceof CodeRenderable && renderable.onChunks === linkChunks) {
renderable.onChunks = undefined
renderable.onChunks = linkChunks
renderable.requestRender()
}
renderable.getChildren().forEach(refresh)
}
untrack(() => refresh(markdown!))
})
// Solid calls refs before setting content, so every block Markdown creates picks up `linkChunks`.
return (renderable: MarkdownRenderable) => {
markdown = renderable
// oxlint-disable-next-line no-restricted-globals -- OpenTUI has no public option for the markdown chunk callback.
Reflect.set(renderable, "_linkifyMarkdownChunks", linkChunks)
renderable.onMouseUp = (event) => {
const sessionID = clickedSession(renderer, event, rendered)
if (!sessionID) return
event.stopPropagation()
if (dialog.stack.length > 0) dialog.clear()
route.navigate({ type: "session", sessionID })
}
}
}
function clickedSession(renderer: CliRenderer, event: MouseEvent, rendered: WeakMap<CodeRenderable, Rendered>) {
const target = event.target
if (!plainClick(renderer, event) || !(target instanceof CodeRenderable)) return
const entry = rendered.get(target)
// The streaming preview can briefly replace the highlighted text the ranges describe.
if (!entry || entry.text !== target.plainText) return
// Concealed markdown markers are absent from plainText, so map the cell through the wrapped
// layout: rows report display columns, and a logical line starts on its row with wrap index 0.
const info = target.lineInfo
const row = event.y - target.y
const line = info.lineSources[row]
if (line === undefined) return
const column = info.lineStartCols[row]! - info.lineStartCols[row - info.lineWraps[row]!]! + event.x - target.x
const lines = entry.text.split("\n")
const lineStart = lines.slice(0, line).reduce((sum, text) => sum + text.length + 1, 0)
const lineEnd = lineStart + lines[line]!.length
return entry.ranges.find((range) => {
if (range.start < lineStart || range.end > lineEnd) return false
const start = stringWidth(entry.text.slice(lineStart, range.start))
return column >= start && column < start + stringWidth(entry.text.slice(range.start, range.end))
})?.sessionID
}
+280
View File
@@ -0,0 +1,280 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { SyntaxStyle, TextAttributes } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { onCleanup, onMount, type JSX } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { ClientProvider } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { Keymap } from "../../src/context/keymap"
import { RouteProvider, useRoute } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { StorageProvider } from "../../src/context/storage"
import { ThemeProvider } from "../../src/context/theme"
import { DialogProvider, useDialog } from "../../src/ui/dialog"
import { useSessionLinks } from "../../src/ui/session-link"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
import { createApi, createFetch, directory, json } from "../fixture/tui-client"
import { waitForHighlightedFrame } from "../fixture/frame"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
const session = (id: string, title: string, archived?: number) => ({
id,
title,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0, ...(archived === undefined ? {} : { archived }) },
})
const id = (name: string) => `ses_${name.repeat(26).slice(0, 26)}`
const current = id("current")
const known = session(id("known"), "Fix the flaky reconnect test on Windows")
const archived = session(id("archived"), "Old work", 1)
const unknown = id("unknown")
const missing = id("missing")
test.each(["top-level", "coalesced"] as const)("session links in %s markdown", async (mode) => {
await using state = await tmpdir()
const lookup = Promise.withResolvers<void>()
const requested: string[] = []
const fetched = new Map([missing, unknown].map((sessionID) => [sessionID, Promise.withResolvers<void>()]))
const calls = createFetch(async (url) => {
const sessionID = url.pathname.match(/^\/api\/session\/(ses_\w+)$/)?.[1]
if (!sessionID) return
requested.push(sessionID)
fetched.get(sessionID)?.resolve()
if (sessionID === unknown) return lookup.promise.then(() => json({ data: session(unknown, "Looked up") }))
return json(
{ _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` },
{ status: 404 },
)
})
let data!: ReturnType<typeof useData>
let route!: ReturnType<typeof useRoute>
function Transcript() {
data = useData()
route = useRoute()
data.session.remember({ ...known })
data.session.remember({ ...archived })
const renderer = useRenderer()
const syntaxStyle = SyntaxStyle.fromStyles(
{ default: { fg: "#ffffff" }, "markup.link.url": { fg: "#00ff00" } },
renderer.nativeScene,
)
onCleanup(() => syntaxStyle.destroy())
return (
<markdown
ref={useSessionLinks()}
internalBlockMode={mode}
syntaxStyle={syntaxStyle}
content={[
`**中文** see ${known.id} now`,
"",
`- current ${current}`,
`- archived ${archived.id}`,
`- unknown ${unknown}`,
`- missing ${missing}`,
"",
"```",
known.id,
"```",
].join("\n")}
/>
)
}
const app = await mount(state.path, calls.fetch, () => <Transcript />, 40, 16)
// Highlighting runs on the tree-sitter worker and lookups on the fixture server.
const linked = () =>
app
.captureSpans()
.lines.flatMap((line) => line.spans)
.filter((span) => span.attributes & TextAttributes.UNDERLINE)
.map((span) => span.text.trim())
.join(" ")
try {
await Promise.all([...fetched.values()].map((request) => request.promise))
// The client marks the session missing once it has read the 404.
await app.waitFor(() => data.session.missing(missing))
await waitForHighlightedFrame(app, () => linked().length > 0)
const frame = app.captureCharFrame()
// The long title wraps; the current, archived, missing and still-loading sessions stay IDs.
expect(linked()).toBe("@Fix the flaky reconnect test on Windows")
for (const plain of [current, archived.id, unknown, missing, known.id]) expect(frame).toContain(plain)
expect(requested.toSorted()).toEqual([missing, unknown])
lookup.resolve()
// Joins the lookup already in flight rather than fetching again.
await data.session.sync(unknown)
await waitForHighlightedFrame(app, () => linked().includes("@Looked up"))
data.session.remember({ ...known, title: "Renamed" })
await waitForHighlightedFrame(app, () => linked() === "@Renamed @Looked up")
// Lookups are cached, misses included.
expect(requested.toSorted()).toEqual([missing, unknown])
const rows = app.captureCharFrame().split("\n")
const clicked = rows.findIndex((row) => row.includes("@Looked up"))
app.renderer.clearSelection()
await app.mockMouse.click(rows[clicked]!.indexOf("@Looked up") + 4, clicked)
expect(route.data).toEqual({ type: "session", sessionID: unknown })
} finally {
app.renderer.destroy()
}
})
test("a wrapped title opens its session from either row, and plain text does not", async () => {
await using state = await tmpdir()
const calls = createFetch(async () => undefined)
let route!: ReturnType<typeof useRoute>
function Transcript() {
useData().session.remember({ ...known })
route = useRoute()
const renderer = useRenderer()
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }, renderer.nativeScene)
onCleanup(() => syntaxStyle.destroy())
return (
<markdown
ref={useSessionLinks()}
internalBlockMode="top-level"
syntaxStyle={syntaxStyle}
content={`**中文** see ${known.id} now`}
/>
)
}
const app = await mount(state.path, calls.fetch, () => <Transcript />, 30, 6)
try {
await waitForHighlightedFrame(app, (frame) => frame.includes("@Fix"))
const rows = app.captureCharFrame().split("\n")
const head = rows.findIndex((row) => row.includes("@Fix"))
const tail = rows.findIndex((row) => row.includes("Windows"))
expect(tail).toBeGreaterThan(head)
await app.mockMouse.click(rows[head]!.indexOf("see"), head)
await app.mockMouse.click(rows[tail]!.indexOf("now"), tail)
await app.mockMouse.drag(rows[head]!.indexOf("@Fix"), head, rows[head]!.indexOf("@Fix") + 3, head)
expect(route.data).toEqual({ type: "session", sessionID: current })
app.renderer.clearSelection()
await app.mockMouse.click(rows[tail]!.indexOf("Windows") + 2, tail)
expect(route.data).toEqual({ type: "session", sessionID: known.id })
} finally {
app.renderer.destroy()
}
})
test("clicking a session link in a dialog closes it and opens the session", async () => {
await using state = await tmpdir()
const calls = createFetch(async () => undefined)
let route!: ReturnType<typeof useRoute>
let dialog!: ReturnType<typeof useDialog>
function Answer() {
const renderer = useRenderer()
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }, renderer.nativeScene)
onCleanup(() => syntaxStyle.destroy())
return <markdown ref={useSessionLinks()} syntaxStyle={syntaxStyle} content={`See ${known.id}`} />
}
function Probe() {
useData().session.remember({ ...known })
route = useRoute()
dialog = useDialog()
onMount(() => dialog.replace(() => <Answer />))
return null
}
const app = await mount(state.path, calls.fetch, () => <Probe />, 80, 12)
try {
await waitForHighlightedFrame(app, (frame) => frame.includes("@Fix"))
const rows = app.captureCharFrame().split("\n")
const y = rows.findIndex((row) => row.includes("@Fix"))
await app.mockMouse.click(rows[y]!.indexOf("@Fix") + 2, y)
expect(route.data).toEqual({ type: "session", sessionID: known.id })
expect(dialog.stack).toHaveLength(0)
} finally {
app.renderer.destroy()
}
})
test.each([true, false])("code spans link only when they are exactly an ID, conceal %p", async (conceal) => {
await using state = await tmpdir()
const calls = createFetch(async () => undefined)
function Transcript() {
useData().session.remember({ ...known })
const renderer = useRenderer()
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }, renderer.nativeScene)
onCleanup(() => syntaxStyle.destroy())
return (
<markdown
ref={useSessionLinks()}
internalBlockMode="top-level"
conceal={conceal}
syntaxStyle={syntaxStyle}
content={`Run \`opencode --session ${known.id}\` or open \`${known.id}\`.`}
/>
)
}
const app = await mount(state.path, calls.fetch, () => <Transcript />, 120, 4)
const linked = () =>
app
.captureSpans()
.lines.flatMap((line) => line.spans)
.filter((span) => span.attributes & TextAttributes.UNDERLINE)
.map((span) => span.text.trim())
try {
await waitForHighlightedFrame(app, () => linked().length > 0)
expect(linked()).toEqual([conceal ? `@${known.title}` : known.id])
expect(app.captureCharFrame()).toContain(`opencode --session ${known.id}`)
} finally {
app.renderer.destroy()
}
})
async function mount(
state: string,
fetch: typeof globalThis.fetch,
view: () => JSX.Element,
width: number,
height: number,
) {
const app = await testRender(
() => (
<TestTuiContexts paths={{ state }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ToastProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: current }}>
<ClientProvider api={createApi(fetch)}>
<DataProvider directory={directory}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>{view()}</DialogProvider>
</ThemeProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
),
{ width, height },
)
// Frame waits need the render loop; highlights and lookups land between frames.
app.renderer.start()
return app
}