From f0b8ad12421e6896e42f42add5abb565cc71c28c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 13:47:21 -0400 Subject: [PATCH] feat(tui): marquee hovered tab titles (#41566) --- packages/tui/src/component/session-tabs.tsx | 92 ++++++++++++++++++--- packages/tui/src/util/marquee.ts | 19 +++++ packages/tui/test/util/marquee.test.ts | 22 +++++ 3 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 packages/tui/src/util/marquee.ts create mode 100644 packages/tui/test/util/marquee.test.ts diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index e9c0977a76c..38989e0b1f8 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -1,5 +1,5 @@ import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" -import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js" +import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" import { useConfig } from "../config" import { useSessionTabs } from "../context/session-tabs" @@ -16,16 +16,19 @@ import { type SessionTab, type SessionTabUnread, } from "../context/session-tabs-model" -import { createAnimatable, spring } from "../ui/animation" +import { createAnimatable, spring, tween } from "../ui/animation" import { Locale } from "../util/locale" import { stringWidth } from "../util/string-width" import { TabPulse, unreadGlowIntensity } from "./tab-pulse" import { tint } from "../theme/color" import { SESSION_SIDEBAR_WIDTH } from "../ui/layout" import { projectName } from "../util/project" +import { marqueeText } from "../util/marquee" // A long title fades out over its last cells instead of cutting hard. const FADE_WIDTH = 4 +const MARQUEE_DELAY = 600 +const MARQUEE_INTERVAL = 100 type ContextController = ReturnType export type SessionTabsStatus = Omit, "unread"> & { @@ -46,6 +49,41 @@ const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_T const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) => tint(base, glow, 0.12 * unreadGlowIntensity(index, width)) +function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: number, leading: number) { + const fade = (position: number) => (position <= 0 ? 0 : 0.2 + 0.72 * ((position - 1) / Math.max(1, FADE_WIDTH - 1))) + const start = index < FADE_WIDTH ? FADE_WIDTH - index : 0 + const end = index - (length - FADE_WIDTH) + 1 + const opacity = Math.max(fade(start) * leading, fade(end)) + return opacity === 0 ? color : tint(color, background, opacity) +} + +function createMarquee(hovered: () => string | undefined, animations: () => boolean) { + const [offset, setOffset] = createSignal(0) + const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) }) + + createEffect(() => { + if (!hovered()) { + setOffset(0) + leading.jump({ opacity: 0 }) + return + } + setOffset(0) + leading.jump({ opacity: 0 }) + let interval: ReturnType | undefined + const delay = setTimeout(() => { + setOffset(1) + leading.animate({ opacity: 1 }) + interval = setInterval(() => setOffset((value) => value + 1), MARQUEE_INTERVAL) + }, MARQUEE_DELAY) + onCleanup(() => { + clearTimeout(delay) + if (interval) clearInterval(interval) + }) + }) + + return { offset, leading: () => leading.value().opacity } +} + export function SessionTabs( props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {}, ) { @@ -69,6 +107,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04)) const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05)) const [hovered, setHovered] = createSignal() + const marquee = createMarquee(hovered, animations) const [dragging, setDragging] = createSignal() const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>() const newTab = () => tabs.newTab?.() ?? false @@ -144,7 +183,12 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const numberWidth = () => 2 const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0)) const title = () => tab.title ?? "Untitled session" - const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth())) + const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0 + const visibleTitle = createMemo(() => + scrolling() + ? marqueeText(title(), titleWidth(), marquee.offset()) + : Locale.takeWidth(title(), titleWidth()), + ) const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle())) const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH) const detail = createMemo(() => { @@ -206,9 +250,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const color = glows() ? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width()) : foreground() - if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color - const position = index - (visibleTitleParts().length - FADE_WIDTH) - return tint(color, pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1))) + return titleFades() + ? fadeTitleColor( + color, + pulseBackground(), + index, + visibleTitleParts().length, + scrolling() ? marquee.leading() : 0, + ) + : color } const release = () => { setDragging(undefined) @@ -225,7 +275,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat backgroundColor={background()} onMouseOver={() => setHovered(tab.sessionID)} onMouseOut={() => setHovered(undefined)} - onMouseDown={() => setDragging(tab.sessionID)} + onMouseDown={() => { + setHovered(tab.sessionID) + setDragging(tab.sessionID) + }} onMouseUp={release} onMouseDrag={(event) => { if (!rail || tab === NEW_SESSION_TAB) return @@ -382,6 +435,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim const config = useConfig().data const animations = () => props.animations ?? config.animations ?? true const [hovered, setHovered] = createSignal() + const marquee = createMarquee(hovered, animations) const [dragging, setDragging] = createSignal() // A drag reorders a local preview and persists one move on release instead of writing // per slot crossing; the preview holds after release until the store reflects the move, @@ -561,7 +615,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim // Hovering reveals the close mark, so the title's right bound shifts left of it. const availableTitleWidth = () => Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0)) - const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth())) + const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0 + const visibleTitle = createMemo(() => + scrolling() + ? marqueeText(title(), availableTitleWidth(), marquee.offset()) + : Locale.takeWidth(title(), availableTitleWidth()), + ) const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle())) const titleFades = createMemo( () => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH, @@ -575,9 +634,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim const characterColor = (index: number) => { const base = foreground() const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base - if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color - const position = index - (visibleTitleParts().length - FADE_WIDTH) - return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1))) + return titleFades() + ? fadeTitleColor( + color, + background(), + index, + visibleTitleParts().length, + scrolling() ? marquee.leading() : 0, + ) + : color } // The running sweep's level under the number cell, reported by the pulse renderable. const [sweepLevel, setSweepLevel] = createSignal(0) @@ -611,7 +676,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim backgroundColor={background()} onMouseOver={() => setHovered(tab.sessionID)} onMouseOut={() => setHovered(undefined)} - onMouseDown={() => setDragging(tab.sessionID)} + onMouseDown={() => { + setHovered(tab.sessionID) + setDragging(tab.sessionID) + }} onMouseUp={release} onMouseDrag={(event) => { if (tab === NEW_SESSION_TAB) return diff --git a/packages/tui/src/util/marquee.ts b/packages/tui/src/util/marquee.ts new file mode 100644 index 00000000000..b0c740a0327 --- /dev/null +++ b/packages/tui/src/util/marquee.ts @@ -0,0 +1,19 @@ +import { Locale } from "./locale" +import { stringWidth } from "./string-width" + +const GAP = " " + +export function marqueeText(value: string, width: number, offset: number) { + if (width <= 0) return "" + if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width) + + const loop = value + GAP + const cursor = offset % stringWidth(loop) + const segments = Locale.graphemes(loop + loop) + const start = segments.reduce( + (state, segment, index) => + state.width >= cursor ? state : { index: index + 1, width: state.width + stringWidth(segment) }, + { index: 0, width: 0 }, + ).index + return Locale.takeWidth(segments.slice(start).join(""), width) +} diff --git a/packages/tui/test/util/marquee.test.ts b/packages/tui/test/util/marquee.test.ts new file mode 100644 index 00000000000..60740d0c2ab --- /dev/null +++ b/packages/tui/test/util/marquee.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { marqueeText } from "../../src/util/marquee" +import { stringWidth } from "../../src/util/string-width" + +describe("marquee text", () => { + test("keeps short text stationary", () => { + expect(marqueeText("Short", 10, 8)).toBe("Short") + }) + + test("starts clipped and scrolls through a long title", () => { + expect(marqueeText("A long session title", 8, 0)).toBe("A long s") + expect(marqueeText("A long session title", 8, 2)).toBe("long ses") + expect(marqueeText("A long session title", 8, 15)).toBe("title ") + expect(marqueeText("A long session title", 8, 20)).toBe(" A lo") + }) + + test("clips wide graphemes to terminal cells", () => { + const frame = marqueeText("Plan 🧭 the release", 8, 5) + expect(frame).toBe("🧭 the r") + expect(stringWidth(frame)).toBeLessThanOrEqual(8) + }) +})