From 0bb6cf37be5ea6148cf37d14aa55a274d824899c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 28 Aug 2026 14:43:53 -0400 Subject: [PATCH] fix(tui): animate automatic session renames (#45957) --- packages/tui/src/component/session-tabs.tsx | 33 ++- packages/tui/src/component/tab-pulse.tsx | 6 +- packages/tui/src/component/title-shimmer.tsx | 269 ++++++++++++++++++ packages/tui/src/context/data.tsx | 24 +- packages/tui/src/context/session-tabs.tsx | 1 + packages/tui/src/routes/session/index.tsx | 9 +- packages/tui/src/routes/session/sidebar.tsx | 25 +- packages/tui/test/app-lifecycle.test.tsx | 81 ++++++ .../tui/test/component/title-shimmer.test.ts | 145 ++++++++++ 9 files changed, 572 insertions(+), 21 deletions(-) create mode 100644 packages/tui/src/component/title-shimmer.tsx create mode 100644 packages/tui/test/component/title-shimmer.test.ts diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index f52ed887883..690fab0aca7 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -41,6 +41,7 @@ import { DialogSessionRename } from "./dialog-session-rename" import { Keymap } from "../context/keymap" import { registerOpencodeSpinner } from "./register-spinner" import { SPINNER_FRAMES } from "./spinner-frames" +import "./title-shimmer" registerOpencodeSpinner() @@ -96,6 +97,7 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = { promptPulse: 0, attention: false, busy: false, + renaming: false, } export type SessionTabsController = Pick & { newTab?: () => boolean @@ -648,7 +650,7 @@ function VerticalSessionTabs(props: { const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2) const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1) const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth()) - const title = () => tab.title ?? "Untitled session" + const title = () => (props.controller ? undefined : session()?.title) ?? tab.title ?? "Untitled session" const scrolling = () => marquee.active() === tab.sessionID const visibleTitleParts = createMemo(() => scrolling() @@ -907,14 +909,21 @@ function VerticalSessionTabs(props: { unreadMarker={props.unreadMarker} attributes={selected() ? TextAttributes.BOLD : undefined} /> - - + tint(background(), feedbackColor() ?? unreadColor(), glowLevel())) const glows = () => Boolean(status().attention || (!selected() && !status().busy && status().unread !== undefined)) - const title = () => tab.title ?? "Untitled session" + const title = () => data?.session.get(tab.sessionID)?.title ?? tab.title ?? "Untitled session" const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1) const numberWidth = () => Math.max(2, String(items().length).length) // Hovering reveals the close mark, so the title's right bound shifts left of it. @@ -1492,13 +1502,18 @@ function HorizontalSessionTabs(props: { unreadMarker={props.unreadMarker} attributes={bold()} /> - @@ -1508,7 +1523,7 @@ function HorizontalSessionTabs(props: { )} - + & { } const clamp = (value: number) => Math.max(0, Math.min(1, value)) -const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10) +export const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10) const RUN_DURATION = 2_800 const RUN_ATTACK = 450 const RUN_HEAD = 4 @@ -53,11 +53,11 @@ const GLOW_RELEASE_PEAK = 1.25 const GLOW_TAIL = 12 const GLOW_OPACITY = 0.16 const DEFAULT_FOREGROUND = RGBA.defaultForeground() -const intensityAt = (index: number, front: number, head: number, tail: number) => { +export const intensityAt = (index: number, front: number, head: number, tail: number) => { const distance = front - index return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail)) } -const coast = (value: number) => { +export const coast = (value: number) => { const ramp = 0.2 if (value < ramp) return (value * value) / (2 * ramp * (1 - ramp)) if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp)) diff --git a/packages/tui/src/component/title-shimmer.tsx b/packages/tui/src/component/title-shimmer.tsx new file mode 100644 index 00000000000..a21fe8d8cc6 --- /dev/null +++ b/packages/tui/src/component/title-shimmer.tsx @@ -0,0 +1,269 @@ +import { + BoxRenderable, + OptimizedBuffer, + RGBA, + TargetChannel, + TextRenderable, + type RenderContext, + type TextOptions, +} from "@opentui/core" +import { extend } from "@opentui/solid" +import { coast, intensityAt, smootherstep } from "./tab-pulse" + +type TitleShimmerOptions = TextOptions & { + rename?: { title: string; pending: boolean } + enabled?: boolean + backdrop?: RGBA +} + +const SHIMMER_DURATION = 1200 +const SHIMMER_FADE = 240 +const ARRIVAL_DURATION = 450 +const WIPE_FEATHER = 4 +const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0) +// Native text draws wide glyphs as a head followed by flagged continuation cells. +const CONTINUATION = 0xc0000000 | 0 + +export class TitleShimmerRenderable extends TextRenderable { + private _rename: TitleShimmerOptions["rename"] + private _enabled: boolean + private _backdrop: RGBA + private pendingTitle: string | undefined + private elapsed = 0 + private blend = 0 + private fresh = true + private arrival: number | undefined + private scratch: OptimizedBuffer | undefined + private previous: OptimizedBuffer | undefined + private mask = new Float32Array(0) + private matrix = new Float32Array(16) + + constructor(ctx: RenderContext, options: TitleShimmerOptions) { + super(ctx, options) + this._rename = options.rename + this.pendingTitle = options.rename?.title + this._enabled = options.enabled ?? true + this._backdrop = options.backdrop ?? RGBA.defaultBackground() + this.matrix[15] = 1 + this.updateBackdrop() + this.live = this.animating + } + + private get animating() { + return this._enabled && (this.shimmering || this.arrival !== undefined || this.blend > 0) + } + + private get shimmering() { + return this._rename?.pending && this._rename.title === this.pendingTitle + } + + set rename(value: TitleShimmerOptions["rename"]) { + if (value?.title === this._rename?.title && value?.pending === this._rename?.pending) return + if (value?.pending && !this._rename?.pending) { + if (this.pendingTitle !== value.title) this.blend = 0 + this.pendingTitle = value.title + if (this.blend === 0) this.elapsed = 0 + this.arrival = undefined + this.previous?.destroy() + this.previous = undefined + } + // Only an automatic rename replaces the last painted title with a wipe. + if (value?.title !== this._rename?.title) { + this.arrival = value && this._rename?.pending && this._enabled && this.previous ? 0 : undefined + if (this.arrival === undefined) this.blend = 0 + } + this._rename = value + this.changed() + } + + set enabled(value: boolean) { + if (value === this._enabled) return + this._enabled = value + if (!value) { + this.arrival = undefined + this.blend = 0 + } + this.changed() + } + + set backdrop(value: RGBA) { + if (value.equals(this._backdrop)) return + this._backdrop = value + this.updateBackdrop() + this.requestRender() + } + + private updateBackdrop() { + this.matrix[3] = this._backdrop.r + this.matrix[7] = this._backdrop.g + this.matrix[11] = this._backdrop.b + } + + private changed() { + if (!this.live && this.animating) this.fresh = true + this.live = this.animating + if (!this.animating) { + this.previous?.destroy() + this.previous = undefined + } + this.requestRender() + } + + override render(buffer: OptimizedBuffer, deltaTime: number) { + if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return + if (!this.animating) return super.render(buffer, deltaTime) + // A newly live title must not inherit time spent idle before its fade started. + const delta = this.fresh ? 0 : deltaTime + this.fresh = false + this.elapsed = (this.elapsed + delta) % SHIMMER_DURATION + if (this.arrival !== undefined) { + this.arrival += delta + if (this.arrival >= ARRIVAL_DURATION) { + this.arrival = undefined + this.blend = 0 + } + } + this.blend = Math.max( + 0, + Math.min(1, this.blend + (this.shimmering || this.arrival !== undefined ? delta : -delta) / SHIMMER_FADE), + ) + this.live = this.animating + if (!this.animating) { + this.previous?.destroy() + this.previous = undefined + return super.render(buffer, deltaTime) + } + if (!this.scratch) + this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true }) + if (this.scratch.width !== this.width || this.scratch.height !== this.height) + this.scratch.resize(this.width, this.height) + + // Shade locally, then composite: colorMatrix itself does not respect ancestor scissors. + this.scratch.clear(TRANSPARENT) + // OpenTUI's framebuffer compositor can paint a cut wide glyph. Clip the native text draw first. + const clip = { + left: Math.max(0, -this.screenX), + top: Math.max(0, -this.screenY), + right: Math.min(this.width, buffer.width - this.screenX), + bottom: Math.min(this.height, buffer.height - this.screenY), + } + for (let parent = this.parent; parent; parent = parent.parent) { + if (parent.overflow === "visible" || parent.width <= 0 || parent.height <= 0) continue + const border = parent instanceof BoxRenderable ? parent.border : false + const left = Number(border === true || (Array.isArray(border) && border.includes("left"))) + const top = Number(border === true || (Array.isArray(border) && border.includes("top"))) + clip.left = Math.max(clip.left, parent.screenX - this.screenX + left) + clip.top = Math.max(clip.top, parent.screenY - this.screenY + top) + clip.right = Math.min( + clip.right, + parent.screenX - + this.screenX + + parent.width - + Number(border === true || (Array.isArray(border) && border.includes("right"))), + ) + clip.bottom = Math.min( + clip.bottom, + parent.screenY - + this.screenY + + parent.height - + Number(border === true || (Array.isArray(border) && border.includes("bottom"))), + ) + } + this.scratch.pushScissorRect( + clip.left, + clip.top, + Math.max(0, clip.right - clip.left), + Math.max(0, clip.bottom - clip.top), + ) + this.scratch.drawTextBuffer(this.textBufferView, 0, 0) + const characters = this.scratch.buffers.char + let end = 0 + for (let row = 0; row < this.height; row++) { + let column = this.width + while ( + column > 0 && + (characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0) + ) + column-- + end = Math.max(end, column) + } + const wipeFront = + this.arrival !== undefined && this.previous + ? -WIPE_FEATHER + + coast(this.arrival / ARRIVAL_DURATION) * (Math.max(end, this.previous.width) + WIPE_FEATHER * 2) + : undefined + const cut = Math.max(0, Math.min(this.width, Math.round(wipeFront ?? 0))) + if (wipeFront !== undefined && this.previous) { + this.scratch.clear(TRANSPARENT) + this.scratch.pushScissorRect(0, 0, cut, this.height) + this.scratch.drawTextBuffer(this.textBufferView, 0, 0) + this.scratch.popScissorRect() + // Snapshot slices must also end on whole glyphs; framebuffer clipping alone can split them. + for (let row = 0; row < Math.min(this.height, this.previous.height); row++) { + let left = Math.max(cut, clip.left) + let right = Math.min(this.previous.width, clip.right) + const offset = row * this.previous.width + while (left < right && (this.previous.buffers.char[offset + left] & CONTINUATION) === CONTINUATION) left++ + while ( + right > left && + right < this.previous.width && + (this.previous.buffers.char[offset + right] & CONTINUATION) === CONTINUATION + ) + right-- + if (right > left) this.scratch.drawFrameBuffer(left, row, this.previous, left, row, right - left, 1) + } + } + this.scratch.clearScissorRects() + if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3) + if (wipeFront === undefined) { + if (!this.previous) + this.previous = OptimizedBuffer.create(Math.max(1, end), this.height, this._ctx.widthMethod, { + respectAlpha: true, + }) + if (this.previous.width !== Math.max(1, end) || this.previous.height !== this.height) + this.previous.resize(Math.max(1, end), this.height) + this.previous.clear(TRANSPARENT) + this.previous.drawFrameBuffer(0, 0, this.scratch) + } + const front = -4 + coast(this.elapsed / SHIMMER_DURATION) * ((this.previous?.width ?? end) + 4 + 18) + const level = smootherstep(this.blend) + let strength = 0 + for (let cell = 0; cell < characters.length; cell++) { + const column = cell % this.width + if ((characters[cell] & CONTINUATION) !== CONTINUATION) { + const old = wipeFront === undefined || column >= cut + let visibility = old ? 1 - 0.6 * level * (1 - intensityAt(column, front, 4, 18)) : 1 + if (wipeFront !== undefined) { + let width = 1 + while (column + width < this.width && (characters[cell + width] & CONTINUATION) === CONTINUATION) width++ + const distance = old ? column - wipeFront : wipeFront - (column + width) + visibility *= smootherstep(Math.max(0, Math.min(1, distance / WIPE_FEATHER))) + } + strength = 1 - visibility + } + this.mask[cell * 3] = column + this.mask[cell * 3 + 1] = Math.floor(cell / this.width) + this.mask[cell * 3 + 2] = strength + } + this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG) + buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch) + this.markClean() + this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num) + } + + override destroy() { + this.previous?.destroy() + this.previous = undefined + this.scratch?.destroy() + this.scratch = undefined + super.destroy() + } +} + +extend({ title_shimmer: TitleShimmerRenderable }) + +declare module "@opentui/solid" { + interface OpenTUIComponents { + title_shimmer: typeof TitleShimmerRenderable + } +} diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 934910407a7..ea1890535c1 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,5 +1,6 @@ import { createData } from "@opencode-ai/client/solid" import type { Plugin } from "@opencode-ai/plugin/tui" +import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" import { useClient } from "./client" @@ -17,6 +18,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: props.directory, }) data satisfies Plugin.Context["data"] - return data + const [generatingTitles, setGeneratingTitles] = createStore>({}) + return { + ...data, + session: { + ...data.session, + title: { + pending: (sessionID: string) => generatingTitles[sessionID] === true, + async generate(sessionID: string) { + if (generatingTitles[sessionID]) return + setGeneratingTitles(sessionID, true) + await client.api.session + .rename({ sessionID, title: "" }) + .then(() => { + // The HTTP response can beat the renamed event. Keep pending until the new title is projected locally. + data.session.invalidate(sessionID) + return data.session.sync(sessionID) + }) + .finally(() => setGeneratingTitles(sessionID, undefined)) + }, + }, + }, + } }, }) diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 7e8cdfc8655..59b26fa7d43 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -173,6 +173,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp ? ("question" as const) : (false as const), busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0), + renaming: data.session.title.pending(session), } } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 4158e00aeaf..27e78b1c058 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -859,9 +859,12 @@ export function Session(props: { slash: { name: "rename", arguments: true as const }, run: (input?: string) => { if (input === undefined) return DialogSessionRename.show(dialog, route.sessionID, session()?.title) - void client.api.session - .rename({ sessionID: route.sessionID, title: input.trim() }) - .catch((error) => toast.error(error)) + const title = input.trim() + void ( + title + ? client.api.session.rename({ sessionID: route.sessionID, title }) + : data.session.title.generate(route.sessionID) + ).catch((error) => toast.error(error)) }, }, { diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index c48b42ebc4d..c1434b1fffd 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -4,6 +4,8 @@ import { useTheme } from "../../context/theme" import { useConfig } from "../../config" import { Slot } from "../../plugin/render" import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback" +import { TextAttributes } from "@opentui/core" +import "../../component/title-shimmer" import { getScrollAcceleration } from "../../util/scroll" import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout" @@ -45,11 +47,24 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { > - - {withTimestampedFallback(session()!)} - - - {session()!.location.workspaceID} + + {withTimestampedFallback(session())} + + + {session().location.workspaceID} diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 74fa2207220..686c3954eb2 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -223,6 +223,87 @@ test("session title generated while an untitled session is loading remains visib } }) +test("automatic rename refreshes the displayed title before settling, even without a renamed event", async () => { + await using state = await tmpdir() + const setup = await createTestRenderer({ width: 90, height: 20, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const events = createEventStream() + const response = Promise.withResolvers() + const bodies: unknown[] = [] + const location = { directory, project: { id: "project", directory, canonical: directory } } + const session = { + id: "ses_rename", + title: "Compiler cleanup", + projectID: "project", + location: { directory }, + agent: "build", + model: { providerID: "provider", id: "model" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const calls = createFetch(async (url, request) => { + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/agent") + return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] }) + if (url.pathname === "/api/model") + return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] }) + if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] }) + if (url.pathname === "/api/session") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/ses_rename") return json({ data: session }) + if (/^\/api\/session\/ses_rename\/(message|inbox|permission)$/.test(url.pathname)) + return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/ses_rename/rename") { + bodies.push(await request.json()) + return response.promise + } + return undefined + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { + get: async () => ({ + tabs: { enabled: true, layout: "vertical" }, + session: { sidebar: "hide" }, + }), + update: async () => ({}), + }, + packages: { resolve: async () => undefined }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), + args: { sessionID: session.id }, + log: () => {}, + }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), + ) + + await setup.waitForFrame((frame) => frame.includes(session.title) && frame.includes("Build ยท Model Provider")) + await setup.mockInput.typeText("/rename") + setup.mockInput.pressEscape() + setup.mockInput.pressEnter() + await setup.waitFor(() => bodies.length === 1) + await setup.renderOnce() + expect(bodies[0]).toEqual({ title: "" }) + expect(setup.captureCharFrame()).toContain("Compiler cleanup") + + session.title = "Simplify compiler parsing" + response.resolve(new Response(null, { status: 204 })) + await setup.waitForFrame((frame) => frame.includes(session.title), { maxPasses: 60 }) + expect(setup.captureCharFrame()).not.toContain("Compiler cleanup") + + setup.renderer.destroy() + await task + } finally { + response.resolve(new Response(null, { status: 204 })) + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + } +}) + test("session startup prompt is submitted exactly once", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const events = createEventStream() diff --git a/packages/tui/test/component/title-shimmer.test.ts b/packages/tui/test/component/title-shimmer.test.ts new file mode 100644 index 00000000000..007538cfcd9 --- /dev/null +++ b/packages/tui/test/component/title-shimmer.test.ts @@ -0,0 +1,145 @@ +import { expect, test } from "bun:test" +import { BoxRenderable, RGBA, TextAttributes, TextRenderable } from "@opentui/core" +import { createTestRenderer, ManualClock } from "@opentui/core/testing" +import { TitleShimmerRenderable } from "../../src/component/title-shimmer" + +test("shimmer fades in from idle and fades out on unchanged completion", async () => { + const clock = new ManualClock() + const app = await createTestRenderer({ width: 24, height: 1, useThread: false, clock }) + const title = new TitleShimmerRenderable(app.renderer, { + width: 24, + height: 1, + content: "Compiler cleanup", + fg: "#eeeeee", + backdrop: RGBA.fromHex("#111111"), + rename: { title: "Compiler cleanup", pending: false }, + }) + app.renderer.root.add(title) + try { + await app.renderOnce() + const frame = app.captureCharFrame() + const colors = app.captureSpans() + clock.advance(2000) + title.rename = { title: "Compiler cleanup", pending: true } + await app.renderOnce() + expect(app.captureSpans()).toEqual(colors) + clock.advance(120) + await app.renderOnce() + const middle = app.captureSpans().lines[0].spans.findLast((span) => span.text.trim())?.fg.r ?? 0 + expect(middle).toBeLessThan(colors.lines[0].spans[0].fg.r) + clock.advance(120) + await app.renderOnce() + expect(app.captureSpans().lines[0].spans.findLast((span) => span.text.trim())?.fg.r ?? 0).toBeLessThan(middle) + expect(app.captureSpans()).not.toEqual(colors) + expect(app.captureCharFrame()).toBe(frame) + title.rename = { title: "Compiler cleanup", pending: false } + await app.renderOnce() + expect(app.renderer.root.liveCount).toBe(1) + clock.advance(240) + await app.renderOnce() + expect(app.renderer.root.liveCount).toBe(0) + expect(app.captureSpans()).toEqual(colors) + title.enabled = false + title.rename = { title: "Compiler cleanup", pending: true } + await app.renderOnce() + expect(app.renderer.root.liveCount).toBe(0) + } finally { + app.renderer.destroy() + } +}) + +test("a feathered wipe keeps the old shimmer moving without dimming the revealed new title", async () => { + const clock = new ManualClock() + const app = await createTestRenderer({ width: 16, height: 1, useThread: false, clock }) + const title = new TitleShimmerRenderable(app.renderer, { + width: 16, + height: 1, + content: "ABCDEFGHIJKLMNOP", + fg: "#eeeeee", + attributes: TextAttributes.ITALIC, + backdrop: RGBA.fromHex("#111111"), + rename: { title: "ABCDEFGHIJKLMNOP", pending: true }, + }) + app.renderer.root.add(title) + try { + await app.renderOnce() + clock.advance(600) + await app.renderOnce() + const colors = app.captureSpans() + title.content = "abcdefghijklmnop" + title.rename = { title: "abcdefghijklmnop", pending: true } + await app.renderOnce() + expect(app.captureCharFrame().trim()).toBe("ABCDEFGHIJKLMNOP") + expect(app.captureSpans()).toEqual(colors) + clock.advance(225) + await app.renderOnce() + expect(app.captureCharFrame().trim()).toBe("abcdefghIJKLMNOP") + const spans = app.captureSpans().lines[0].spans + expect(spans[0].fg.equals(RGBA.fromHex("#eeeeee"))).toBe(true) + expect(spans.some((span) => span.fg.equals(RGBA.fromHex("#111111")))).toBe(true) + expect(spans.at(-1)?.fg.toInts()).not.toEqual(colors.lines[0].spans.at(-1)?.fg.toInts()) + expect(spans.every((span) => Boolean(span.attributes & TextAttributes.ITALIC))).toBe(true) + clock.advance(225) + await app.renderOnce() + expect(app.captureCharFrame().trim()).toBe("abcdefghijklmnop") + expect(app.renderer.root.liveCount).toBe(0) + + title.rename = { title: "abcdefghijklmnop", pending: false } + title.content = "Manual" + title.rename = { title: "Manual", pending: false } + await app.renderOnce() + expect(app.captureCharFrame().trim()).toBe("Manual") + expect(app.renderer.root.liveCount).toBe(0) + + title.rename = { title: "Manual", pending: true } + await app.renderOnce() + title.content = "Next" + title.rename = { title: "Next", pending: false } + title.enabled = false + await app.renderOnce() + expect(app.captureCharFrame().trim()).toBe("Next") + title.enabled = true + expect(app.renderer.root.liveCount).toBe(0) + } finally { + app.renderer.destroy() + } +}) + +test("native Unicode clipping and shorter replacement leave no split glyphs or old tail", async () => { + const clock = new ManualClock() + const app = await createTestRenderer({ width: 24, height: 3, useThread: false, clock }) + const content = "A\u65e5B \u{1f680} cafe\u0301" + const title = new TitleShimmerRenderable(app.renderer, { + width: 20, + height: 1, + content, + fg: "#eeeeee", + wrapMode: "none", + backdrop: RGBA.fromHex("#111111"), + rename: { title: content, pending: true }, + }) + const plain = new TextRenderable(app.renderer, { width: 20, height: 1, content, wrapMode: "none" }) + const shadedBox = new BoxRenderable(app.renderer, { width: 6, height: 1, marginLeft: 2, overflow: "hidden" }) + const plainBox = new BoxRenderable(app.renderer, { width: 6, height: 1, marginLeft: 2, overflow: "hidden" }) + shadedBox.add(title) + plainBox.add(plain) + app.renderer.root.add(shadedBox) + app.renderer.root.add(plainBox) + app.renderer.root.add(new TextRenderable(app.renderer, { content: "untouched" })) + try { + await app.renderOnce() + expect(app.captureCharFrame().split("\n")[0]).toBe(app.captureCharFrame().split("\n")[1]) + title.content = "Short" + title.rename = { title: "Short", pending: false } + clock.advance(200) + await app.renderOnce() + expect(app.captureCharFrame().split("\n")[0].trim()).toBe("Sh B") + expect(app.captureCharFrame().split("\n")[2]).toContain("untouched") + clock.advance(250) + await app.renderOnce() + expect(app.captureCharFrame().split("\n")[0].trim()).toBe("Short") + expect(app.renderer.root.liveCount).toBe(0) + } finally { + app.renderer.destroy() + } +})