From 7b830a4365aeb12b2e1afd0493d0140c83bece79 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:22:06 -0600 Subject: [PATCH] feat(tui): open sessions from session IDs in messages (#63224) --- .../tui/src/feature-plugins/prompt/btw.tsx | 2 + packages/tui/src/routes/session/index.tsx | 2 + .../tui/src/routes/session/message-parts.tsx | 2 + packages/tui/src/ui/link.tsx | 10 +- packages/tui/src/ui/session-link.ts | 154 ++++++++++ packages/tui/test/ui/session-link.test.tsx | 280 ++++++++++++++++++ 6 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 packages/tui/src/ui/session-link.ts create mode 100644 packages/tui/test/ui/session-link.test.tsx diff --git a/packages/tui/src/feature-plugins/prompt/btw.tsx b/packages/tui/src/feature-plugins/prompt/btw.tsx index 6aca5607022..2b9f1b5d6d0 100644 --- a/packages/tui/src/feature-plugins/prompt/btw.tsx +++ b/packages/tui/src/feature-plugins/prompt/btw.tsx @@ -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: { > {/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */} ` 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() + const [version, setVersion] = createSignal(0) + const rendered = new WeakMap() + 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) { + 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 +} diff --git a/packages/tui/test/ui/session-link.test.tsx b/packages/tui/test/ui/session-link.test.tsx new file mode 100644 index 00000000000..eb2b44d115f --- /dev/null +++ b/packages/tui/test/ui/session-link.test.tsx @@ -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() + const requested: string[] = [] + const fetched = new Map([missing, unknown].map((sessionID) => [sessionID, Promise.withResolvers()])) + 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 + let route!: ReturnType + + 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 ( + + ) + } + + const app = await mount(state.path, calls.fetch, () => , 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 + + function Transcript() { + useData().session.remember({ ...known }) + route = useRoute() + const renderer = useRenderer() + const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }, renderer.nativeScene) + onCleanup(() => syntaxStyle.destroy()) + return ( + + ) + } + + const app = await mount(state.path, calls.fetch, () => , 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 + let dialog!: ReturnType + + function Answer() { + const renderer = useRenderer() + const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }, renderer.nativeScene) + onCleanup(() => syntaxStyle.destroy()) + return + } + function Probe() { + useData().session.remember({ ...known }) + route = useRoute() + dialog = useDialog() + onMount(() => dialog.replace(() => )) + return null + } + + const app = await mount(state.path, calls.fetch, () => , 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 ( + + ) + } + + const app = await mount(state.path, calls.fetch, () => , 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( + () => ( + + + + + + + + + + + {view()} + + + + + + + + + + + ), + { width, height }, + ) + // Frame waits need the render loop; highlights and lookups land between frames. + app.renderer.start() + return app +}