diff --git a/packages/tui/src/ui/session-link.ts b/packages/tui/src/ui/session-link.ts index ca68d040d65..1a78c5bfeb7 100644 --- a/packages/tui/src/ui/session-link.ts +++ b/packages/tui/src/ui/session-link.ts @@ -19,7 +19,13 @@ 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 }[] } +// `sessionIDs` holds every ID the block mentions, linked or not, so a label change re-highlights only +// the blocks that show it. +type Rendered = { + text: string + ranges: { start: number; end: number; sessionID: string }[] + sessionIDs: Set +} /** * Returns a `` ref that shows session IDs as links titled after their sessions and @@ -62,6 +68,7 @@ export function useSessionLinks() { return text !== undefined && !parseSessionMention(text) ? [{ start, end: position }] : [] }) position = 0 + const sessionIDs = new Set() const parts = detectLinks(chunks, context).flatMap((chunk): { chunk: TextChunk; sessionID?: string }[] => { const start = position position += chunk.text.length @@ -74,6 +81,7 @@ export function useSessionLinks() { 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) sessionIDs.add(mention.id) if (mention && (!used.has(mention.id) || used.get(mention.id) !== label)) { used.set(mention.id, label) setVersion((value) => value + 1) @@ -95,17 +103,24 @@ export function useSessionLinks() { 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 }) + rendered.set(this, { text: parts.map((part) => part.chunk.text).join(""), ranges, sessionIDs }) 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 changed = [...used].flatMap(([sessionID, label]) => (labelOf(sessionID) === label ? [] : [sessionID])) + if (!markdown || changed.length === 0) return + changed.forEach((sessionID) => used.set(sessionID, labelOf(sessionID))) + // Toggling the callback marks the block's highlight dirty while keeping the identity OpenTUI + // checks when terminal capabilities refresh link styling. Dirty blocks highlight on the next + // frame, so labels that change within one frame share a single re-highlight. const refresh = (renderable: Renderable): void => { - if (renderable instanceof CodeRenderable && renderable.onChunks === linkChunks) { + if ( + renderable instanceof CodeRenderable && + renderable.onChunks === linkChunks && + changed.some((sessionID) => rendered.get(renderable)?.sessionIDs.has(sessionID)) + ) { renderable.onChunks = undefined renderable.onChunks = linkChunks renderable.requestRender() diff --git a/packages/tui/test/ui/session-link.test.tsx b/packages/tui/test/ui/session-link.test.tsx index eb2b44d115f..1a348dd7455 100644 --- a/packages/tui/test/ui/session-link.test.tsx +++ b/packages/tui/test/ui/session-link.test.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" -import { SyntaxStyle, TextAttributes } from "@opentui/core" +import { CodeRenderable, SyntaxStyle, TextAttributes, type MarkdownRenderable, type Renderable } from "@opentui/core" import { testRender, useRenderer } from "@opentui/solid" import { onCleanup, onMount, type JSX } from "solid-js" import { ConfigProvider } from "../../src/config" @@ -241,6 +241,65 @@ test.each([true, false])("code spans link only when they are exactly an ID, conc } }) +test("a label change re-highlights only the blocks that mention that session", async () => { + await using state = await tmpdir() + const calls = createFetch(async () => undefined) + const other = session(id("other"), "Other work") + let data!: ReturnType + let markdown!: MarkdownRenderable + + function Transcript() { + data = useData() + data.session.remember({ ...known }) + data.session.remember({ ...other }) + const links = useSessionLinks() + const renderer = useRenderer() + const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }, renderer.nativeScene) + onCleanup(() => syntaxStyle.destroy()) + return ( + { + links(renderable) + markdown = renderable + }} + internalBlockMode="top-level" + syntaxStyle={syntaxStyle} + content={`First ${known.id}\n\nSecond ${other.id}`} + /> + ) + } + + const app = await mount(state.path, calls.fetch, () => , 80, 6) + + try { + await waitForHighlightedFrame(app, (frame) => frame.includes("@Fix") && frame.includes("@Other")) + // Record which blocks get their chunk callback reset, the refresh's re-highlight trigger. + const reset: string[] = [] + const descriptor = Object.getOwnPropertyDescriptor(CodeRenderable.prototype, "onChunks")! + const blocks = (renderable: Renderable): CodeRenderable[] => [ + ...(renderable instanceof CodeRenderable ? [renderable] : []), + ...renderable.getChildren().flatMap(blocks), + ] + blocks(markdown).forEach((block) => + Object.defineProperty(block, "onChunks", { + configurable: true, + get: () => descriptor.get!.call(block), + set: (value) => { + reset.push(block.plainText) + descriptor.set!.call(block, value) + }, + }), + ) + + data.session.remember({ ...known, title: "Renamed" }) + await waitForHighlightedFrame(app, (frame) => frame.includes("@Renamed")) + expect(reset.length).toBeGreaterThan(0) + expect(reset.every((text) => text.startsWith("First"))).toBe(true) + } finally { + app.renderer.destroy() + } +}) + async function mount( state: string, fetch: typeof globalThis.fetch,