fix(tui): only re-highlight blocks whose session link changed (#63247)

This commit is contained in:
Adam
2026-11-10 21:37:41 -06:00
committed by GitHub
Unverified
parent 9c318ce47a
commit 39457ccfb3
2 changed files with 81 additions and 7 deletions
+21 -6
View File
@@ -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<string>
}
/**
* Returns a `<markdown>` 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<string>()
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()
+60 -1
View File
@@ -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<typeof useData>
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 (
<markdown
ref={(renderable: MarkdownRenderable) => {
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, () => <Transcript />, 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,