feat(tui): polish session tab animations (#39542)

This commit is contained in:
Kit Langton 2026-07-29 13:50:49 -04:00 committed by GitHub
commit b985d2eb8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 405 additions and 107 deletions

View file

@ -1,5 +1,5 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
@ -7,20 +7,24 @@ import { useTheme, useThemes } from "../context/theme"
import {
adaptiveSessionTabLayout,
sessionTabComplete,
SESSION_TAB_OVERFLOW_WIDTH,
seedSessionTabMotion,
sessionTabOverflowWidth,
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 } from "./tab-pulse"
import { tint } from "../theme/color"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
unread: SessionTabUnread | undefined
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close"> & {
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
status(sessionID: string): SessionTabsStatus
}
@ -32,9 +36,11 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [dragging, setDragging] = createSignal<string>()
let strip: { screenX: number } | undefined
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => tint(theme.hue.interactive[hueStep()], theme.background.default, 0.25)
const activeNumber = () => theme.hue.interactive[hueStep()]
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const activeID = createMemo(tabs.current)
const items = tabs.tabs
@ -73,21 +79,38 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
let signature = ""
let total = 0
createEffect(() => {
// createComputed runs before render effects, so seeded widths are visible on the first frame
// of a membership change instead of flashing the final layout.
createComputed(() => {
const next = targets()
const nextSignature = identity()
const reset = (signature && signature !== nextSignature) || (total && total !== layout().total)
const changed = Boolean(signature) && signature !== nextSignature
const resized = Boolean(total) && total !== layout().total
const previous = signature
signature = nextSignature
total = layout().total
if (reset) return motion.jump(next)
if (!changed && !resized) return motion.animate(next)
// Identity-stable total changes are terminal resizes and still jump.
if (!changed) return motion.jump(next)
const seeded = seedSessionTabMotion(
previous.split(":"),
layout().tabs.map((tab) => tab.sessionID),
untrack(motion.value),
next,
)
if (!seeded) return motion.jump(next)
motion.jump(seeded)
motion.animate(next)
})
const activeIndex = createMemo(() => layout().tabs.findIndex((tab) => tab.sessionID === activeID()))
const visuals = createMemo(() => {
const current = signature === identity() && total === layout().total ? motion.value() : targets()
const widths = current.widths.map((width) => Math.max(1, Math.round(width)))
const active = layout().tabs.findIndex((tab) => tab.sessionID === activeID())
if (active !== -1) widths[active]! += layout().total - widths.reduce((sum, width) => sum + width, 0)
const active = activeIndex()
const remainder = layout().total - widths.reduce((sum, width) => sum + width, 0)
// Absorb only rounding slack; membership animations leave a real gap while widths grow into place.
if (active !== -1 && Math.abs(remainder) <= layout().tabs.length) widths[active]! += remainder
return new Map(
layout().tabs.map((tab, index) => [
tab.sessionID,
@ -100,8 +123,21 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
)
})
// Map an absolute pointer column to the items index of the visible slot beneath it.
const slotAt = (x: number) => {
if (!strip) return undefined
const stripX = x - strip.screenX
let edge = layout().before > 0 ? sessionTabOverflowWidth(layout().before) : 0
for (const [index, width] of layout().widths.entries()) {
edge += width
if (stripX < edge) return layout().before + index
}
return layout().before + layout().widths.length - 1
}
return (
<box
ref={(element) => (strip = element)}
height={1}
flexShrink={0}
position="relative"
@ -127,7 +163,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
}}
>
<Show when={layout().before > 0}>
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
<text width={sessionTabOverflowWidth(layout().before)} fg={theme.text.subdued} selectable={false}>
{layout().before}
</text>
</Show>
@ -138,38 +174,79 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const width = () => visuals().get(tab.sessionID)?.width ?? 1
const selection = () => visuals().get(tab.sessionID)?.selection ?? Number(selected())
const activity = () => visuals().get(tab.sessionID)?.activity ?? Number(status().complete)
const background = () => {
const base =
hovered() === tab.sessionID && !selected()
? theme.background.action.primary.hovered
: theme.background.default
return tint(base, theme.raise(theme.background.surface.offset), selection())
const dragged = () => dragging() === tab.sessionID
const background = createMemo(() => {
const lifted = (hovered() === tab.sessionID || dragged()) && !selected()
const base = lifted ? theme.background.action.primary.hovered : theme.background.default
// A dragged tab lifts to full selected elevation while it is held.
return tint(base, theme.raise(theme.background.surface.offset), dragged() ? 1 : selection())
})
const pulseColor = () => tint(background(), theme.text.default, 0.45)
const feedbackColor = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
return undefined
}
const pulseBackground = () => background()
const pulseColor = () => tint(pulseBackground(), theme.text.default, 0.45)
const glowColor = () => feedbackColor() ?? accent()
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const availableTitleWidth = () => Math.max(1, width() - 3)
const [outgoingTitle, setOutgoingTitle] = createSignal<string>()
const wipe = createAnimatable({ front: 1 }, { enabled: animations, transition: tween({ duration: 0.3 }) })
createEffect((previous: string) => {
const next = title()
if (next === previous) return next
setOutgoingTitle(previous)
wipe.jump({ front: 0 })
wipe.animate({ front: 1 })
return next
}, title())
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
// The number cell keeps one trailing space, even for double-digit tabs.
const numberWidth = () => String(tabNumber()).length + 1
// 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 visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const fadeWidth = () => (hovered() === tab.sessionID ? 6 : 4)
const fadedTitleParts = createMemo(() => visibleTitleParts().slice(-fadeWidth()))
const outgoingTitleParts = createMemo(() => {
const outgoing = outgoingTitle()
if (outgoing === undefined) return undefined
return Locale.graphemes(Locale.takeWidth(outgoing, availableTitleWidth()))
})
// A new title wipes in from the left over the previous one.
const displayedParts = createMemo(() => {
const front = wipe.value().front
const parts = visibleTitleParts()
const previous = outgoingTitleParts()
if (previous === undefined || front >= 1) return parts
const cut = Math.round(front * Math.max(parts.length, previous.length))
return [...parts.slice(0, cut), ...previous.slice(cut)]
})
const fadedTitleParts = createMemo(() => displayedParts().slice(-FADE_WIDTH))
const titleFades = createMemo(
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > fadeWidth(),
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
)
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return tint(theme.text.subdued, theme.text.default, selection())
}
const numberColor = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
const feedback = feedbackColor()
if (feedback) return feedback
const base =
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), activeNumber(), selection())
return tint(base, accent(), activity())
}
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
// Releasing a drag (or a plain click) selects the tab, matching browser tab strips and
// keeping sloppy clicks indistinguishable from clean ones.
const release = () => {
setDragging(undefined)
tabs.select(tab.sessionID)
}
return (
<box
width={width()}
@ -178,40 +255,48 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseUp={() => tabs.select(tab.sessionID)}
onMouseDown={() => setDragging(tab.sessionID)}
onMouseUp={release}
onMouseDrag={(event) => {
const slot = slotAt(event.x)
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot)
}}
onMouseDragEnd={release}
>
<TabPulse
enabled={animations()}
active={status().busy}
complete={status().complete}
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
active={status().busy && !status().attention}
complete={status().complete && !status().attention}
glow={glows()}
breathe={status().attention}
color={pulseColor()}
glowColor={accent()}
glowColor={glowColor()}
completionColor={accent()}
backgroundColor={pulseBackground()}
backgroundColor={background()}
/>
<box zIndex={1} width="100%" flexDirection="row">
<text width={1}> </text>
<text width={2} fg={numberColor()} attributes={selected() ? TextAttributes.BOLD : undefined}>
{items().findIndex((item) => item.sessionID === tab.sessionID) + 1}
<text width={1} selectable={false}>
{" "}
</text>
<Show
when={titleFades()}
fallback={
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
{visibleTitle()}
</text>
}
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{tabNumber()}
</text>
<text
width={availableTitleWidth()}
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={bold()}
>
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
{visibleTitleParts().slice(0, -fadeWidth()).join("")}
<Show when={titleFades()} fallback={displayedParts().join("")}>
{displayedParts().slice(0, -FADE_WIDTH).join("")}
<For each={fadedTitleParts()}>
{(character, index) => (
<span
style={{
fg: tint(
foreground(),
pulseBackground(),
background(),
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
),
}}
@ -220,14 +305,15 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
</span>
)}
</For>
</text>
</Show>
</Show>
</text>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={closeColor()}
selectable={false}
onMouseUp={(event) => {
event.stopPropagation()
tabs.close(tab.sessionID)
@ -241,8 +327,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
}}
</For>
<Show when={layout().after > 0}>
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
{layout().after}
<text width={sessionTabOverflowWidth(layout().after)} fg={theme.text.subdued} selectable={false}>
{" " + layout().after}
</text>
</Show>
</box>

View file

@ -6,6 +6,7 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
active?: boolean
complete?: boolean
glow?: boolean
breathe?: boolean
color?: RGBA
glowColor?: RGBA
completionColor?: RGBA
@ -20,6 +21,15 @@ const RUN_TAIL = 18
const RUN_FADE_OUT = 500
const COMPLETION_DURATION = 900
const COMPLETION_ATTACK = 0.16
const COMPLETION_OPACITY = 0.18
const EDGE_FLASH_DURATION = 500
const EDGE_FLASH_OPACITY = 0.1
const GLOW_IGNITION_DURATION = 600
const GLOW_IGNITION_PEAK = 1.5
const GLOW_IGNITION_ATTACK = 0.3
const GLOW_FADE_OUT = 200
const GLOW_BREATHE_PERIOD = 3_600
const GLOW_BREATHE_RISE = 0.25
const GLOW_TAIL = 12
const GLOW_OPACITY = 0.16
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
@ -33,10 +43,15 @@ const coast = (value: number) => {
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
return (value - ramp / 2) / (1 - ramp)
}
export const completionPulseOpacity = (progress: number) =>
progress < COMPLETION_ATTACK
? smootherstep(clamp(progress / COMPLETION_ATTACK))
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
const fadeOut = (progress: number) => 1 - smootherstep(progress)
/** Rise to peak over the attack fraction, then settle to rest over the remainder. */
const attackDecay = (progress: number, attack: number, peak: number, rest: number) =>
progress < attack
? peak * smootherstep(clamp(progress / attack))
: peak - (peak - rest) * smootherstep(clamp((progress - attack) / (1 - attack)))
export const completionPulseOpacity = (progress: number) => attackDecay(progress, COMPLETION_ATTACK, 1, 0)
export const glowIgnitionLevel = (progress: number) =>
attackDecay(progress, GLOW_IGNITION_ATTACK, GLOW_IGNITION_PEAK, 1)
export const unreadGlowIntensity = (index: number, width: number) => {
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
@ -61,19 +76,64 @@ export function blendTabPulseColor(
output.g += (completionColor.g - output.g) * completion
output.b += (completionColor.b - output.b) * completion
}
/** A one-shot animation clock: level() follows shape over duration, scaled by the value passed to start. */
class Envelope {
private clock: number | undefined
private scale = 1
constructor(
private duration: number,
private shape: (progress: number) => number,
) {}
start(scale = 1) {
if (this.clock !== undefined) return
this.clock = 0
this.scale = scale
}
stop() {
this.clock = undefined
}
advance(delta: number) {
if (this.clock === undefined) return
this.clock += delta
if (this.clock >= this.duration) this.clock = undefined
}
get active() {
return this.clock !== undefined
}
level() {
return this.clock === undefined ? 0 : this.scale * this.shape(this.clock / this.duration)
}
}
// Hoisted so the per-frame liveness check allocates no closure.
const envelopeActive = (envelope: Envelope) => envelope.active
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _complete: boolean
private _glow: boolean
private _breathe: boolean
private _color: RGBA
private _glowColor: RGBA
private _completionColor: RGBA
private _backgroundColor: RGBA
private clock = 0
private fadeClock: number | undefined
private completionClock: number | undefined
private breatheClock = 0
private completionPending = false
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity)
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, completionPulseOpacity)
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
private renderColor = RGBA.fromInts(0, 0, 0)
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
@ -84,21 +144,36 @@ class TabPulseRenderable extends Renderable {
this._active = active
this._complete = options.complete ?? false
this._glow = options.glow ?? false
this._breathe = options.breathe ?? false
this._color = options.color ?? RGBA.defaultForeground()
this._glowColor = options.glowColor ?? this._color
this._completionColor = options.completionColor ?? this._color
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
}
private get breathing() {
return this._enabled && this._glow && this._breathe
}
/** Resting glow is 1; ignition overshoots on arrival, breathing swells while pending, glowOff decays after. */
private glowLevel() {
if (!this._glow) return this.glowOff.level()
const base = this.ignition.active ? this.ignition.level() : 1
if (!this.breathing) return base
return (
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
)
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
if (!value) {
this.fadeClock = undefined
this.completionClock = undefined
for (const envelope of this.envelopes) envelope.stop()
this.completionPending = false
this.breatheClock = 0
this.live = false
} else if (this._active) {
} else if (this._active || this.breathing) {
this.live = true
}
this.requestRender()
@ -109,15 +184,16 @@ class TabPulseRenderable extends Renderable {
this._active = value
if (!this._enabled) return
if (value) {
this.fadeClock = undefined
this.completionClock = undefined
this.runFade.stop()
this.completionPulse.stop()
this.completionPending = false
this.live = true
} else {
this.fadeClock = 0
this.runFade.start()
this.completionPending = true
this.live = true
}
// The same neutral edge flash marks both the start and the finish of a run.
this.edgeFlash.start()
this.live = true
this.requestRender()
}
@ -125,20 +201,38 @@ class TabPulseRenderable extends Renderable {
if (value === this._complete) return
this._complete = value
if (!value) {
this.completionClock = undefined
this.completionPulse.stop()
this.completionPending = false
}
if (value && this.completionPending) {
this.completionClock = 0
this.completionPending = false
this.live = this._enabled
if (this._enabled) {
this.completionPulse.start()
this.live = true
}
}
this.requestRender()
}
set glow(value: boolean) {
if (value === this._glow) return
if (this._enabled && !value) this.glowOff.start(this.glowLevel())
this._glow = value
this.ignition.stop()
this.breatheClock = 0
if (this._enabled && value) {
this.glowOff.stop()
this.ignition.start()
this.live = true
}
this.requestRender()
}
set breathe(value: boolean) {
if (value === this._breathe) return
this._breathe = value
this.breatheClock = 0
if (this.breathing) this.live = true
this.requestRender()
}
@ -168,61 +262,52 @@ class TabPulseRenderable extends Renderable {
protected override onUpdate(deltaTime: number): void {
if (!this._enabled) return
if (this._active || this.fadeClock !== undefined) this.clock += deltaTime
if (this.fadeClock !== undefined) {
this.fadeClock += deltaTime
if (this.fadeClock >= RUN_FADE_OUT) this.fadeClock = undefined
}
if (this._active || this.runFade.active) this.clock += deltaTime
if (this.breathing) this.breatheClock += deltaTime
for (const envelope of this.envelopes) envelope.advance(deltaTime)
if (this.completionPending) {
if (this._complete) {
this.completionClock = 0
this.completionPending = false
} else if (this.fadeClock === undefined) {
this.completionPulse.start()
} else if (!this.runFade.active) {
this.completionPending = false
}
}
if (this.completionClock !== undefined) {
this.completionClock += deltaTime
if (this.completionClock >= COMPLETION_DURATION) this.completionClock = undefined
}
this.live = this._active || this.fadeClock !== undefined || this.completionClock !== undefined
this.live = this._active || this.breathing || this.envelopes.some(envelopeActive)
}
protected override renderSelf(buffer: OptimizedBuffer): void {
if (!this.visible || this.isDestroyed || this.width <= 0) return
const runningOpacity = !this._enabled
? 0
: this._active
? 1
: this.fadeClock === undefined
? 0
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
const completionOpacity =
!this._enabled || this.completionClock === undefined
? 0
: completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
if (!this._glow && runningOpacity === 0 && completionOpacity === 0) return
const running = !this._enabled ? 0 : this._active ? 1 : this.runFade.level()
const completion = this.completionPulse.level() * COMPLETION_OPACITY
// The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results.
const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY
const glowLevel = this.glowLevel()
if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) return
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
const start = -RUN_HEAD
const end = this.width - 1 + RUN_TAIL
const front = start + coast(progress) * (end - start)
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
for (let index = 0; index < this.width; index++) {
const intensity = Math.max(
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
)
const glow = this._glow ? unreadGlowIntensity(index, this.width) * GLOW_OPACITY : 0
const running = intensity * 0.14 * runningOpacity
const completion = completionOpacity * 0.18
// Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow.
const sweep =
running === 0
? 0
: Math.max(
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
) *
0.14 *
running
blendTabPulseColor(
this.renderColor,
this._backgroundColor,
this._glowColor,
this._color,
this._completionColor,
glow,
running,
glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
Math.max(sweep, flash),
completion,
)
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
@ -243,6 +328,7 @@ export function TabPulse(props: {
active: boolean
complete?: boolean
glow?: boolean
breathe?: boolean
color: RGBA
glowColor?: RGBA
completionColor?: RGBA
@ -257,6 +343,7 @@ export function TabPulse(props: {
active={props.active}
complete={props.complete ?? false}
glow={props.glow ?? false}
breathe={props.breathe ?? false}
color={props.color}
glowColor={props.glowColor ?? props.color}
completionColor={props.completionColor ?? props.color}