From a86b653005207cf9529eccbb9bc8033faec3860f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 13 Aug 2026 22:08:56 -0400 Subject: [PATCH] feat(tui): diffuse unread tab glow on resolve --- packages/tui/src/component/session-tabs.tsx | 53 +++++++++++++------- packages/tui/src/component/tab-pulse.tsx | 54 +++++++++++++++++++-- 2 files changed, 86 insertions(+), 21 deletions(-) diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 68b54652109..ebce53ee7dc 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -71,8 +71,8 @@ export type SessionTabsController = Pick - tint(base, glow, 0.12 * unreadGlowIntensity(index, width)) +const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, level = 1) => + tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level) function createNumberIgnition(runs: () => boolean, prompt: () => number, animations: () => boolean) { const ignition = createAnimatable({ level: 0 }, { enabled: animations, transition: tween({ duration: 0.7 }) }) @@ -430,11 +430,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat hovered() === tab.sessionID && !selected() ? foreground() : tint(idleNumber(), tint(theme.text.default, pulseBackground(), 0.25), Number(selected())) - const color = status().attention - ? theme.text.feedback.warning.default - : status().unread === "error" - ? theme.text.feedback.error.default - : tint(base, accent(), Number(complete())) + const color = tint(base, glowHue(), numberGlow.value().level) const runningColor = runs() ? activeNumber() : color return sweepLevel() === 0 ? tint(runningColor, theme.text.default, numberIgnition.value().level) @@ -445,10 +441,13 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat return selected() ? theme.text.default : theme.text.subdued } const complete = () => status().complete + // Latched so a resolving glow fades out in the hue it lit with instead of snapping to accent. + let lastGlowHue: RGBA | undefined const glowHue = () => { - if (status().attention) return theme.text.feedback.warning.default - if (status().unread === "error") return theme.text.feedback.error.default - return accent() + if (status().attention) return (lastGlowHue = theme.text.feedback.warning.default) + if (status().unread === "error") return (lastGlowHue = theme.text.feedback.error.default) + if (status().unread !== undefined) return (lastGlowHue = accent()) + return lastGlowHue ?? accent() } const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25)) const flashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.7)) @@ -462,6 +461,21 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat ? fadeTitleColor(detailColor(), pulseBackground(), index, visibleDetailParts().length, 0) : detailColor() const glows = () => status().glows + // Text tints ride an eased level so they diffuse away with the background glow instead of snapping. + const titleGlow = createAnimatable( + { level: 0 }, + { enabled: animations, transition: tween({ duration: 0.4 }) }, + ) + createEffect(() => titleGlow.animate({ level: glows() ? 1 : 0 })) + const numberGlow = createAnimatable( + { level: 0 }, + { enabled: animations, transition: tween({ duration: 0.4 }) }, + ) + createEffect(() => + numberGlow.animate({ + level: status().attention || status().unread === "error" || complete() ? 1 : 0, + }), + ) const previous = createMemo(() => items()[index() - 1]) const previousStatus = createMemo(() => { const tab = previous() @@ -472,17 +486,22 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const previousGlows = () => previousStatus().glows const previousRuns = () => previousStatus().runs const indicatorWidth = 10 + let lastPreviousGlowHue: RGBA | undefined const previousGlowHue = () => { - if (previousStatus().attention) return theme.text.feedback.warning.default - if (previousStatus().unread === "error") return theme.text.feedback.error.default - return accent() + if (previousStatus().attention) return (lastPreviousGlowHue = theme.text.feedback.warning.default) + if (previousStatus().unread === "error") + return (lastPreviousGlowHue = theme.text.feedback.error.default) + if (previousStatus().unread !== undefined) return (lastPreviousGlowHue = accent()) + return lastPreviousGlowHue ?? accent() } const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1)) const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12)) const titleColor = (index: number) => { - const color = glows() - ? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width()) - : foreground() + const level = titleGlow.value().level + const color = + level === 0 + ? foreground() + : glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width(), level) return titleFades() ? fadeTitleColor( color, @@ -628,7 +647,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat selectable={false} attributes={selected() ? TextAttributes.BOLD : undefined} > - + 0 || titleFades()} fallback={visibleTitle()}> {(character, index) => {character}} diff --git a/packages/tui/src/component/tab-pulse.tsx b/packages/tui/src/component/tab-pulse.tsx index 865b728a31e..2aad93165df 100644 --- a/packages/tui/src/component/tab-pulse.tsx +++ b/packages/tui/src/component/tab-pulse.tsx @@ -47,6 +47,9 @@ const GLOW_IGNITION_DURATION = 600 const GLOW_IGNITION_PEAK = 1.5 const GLOW_IGNITION_ATTACK = 0.3 const GLOW_FADE_OUT = 200 +const GLOW_RELEASE_DURATION = 900 +const GLOW_RELEASE_ATTACK = 0.12 +const GLOW_RELEASE_PEAK = 1.25 const GLOW_TAIL = 12 const GLOW_OPACITY = 0.16 const DEFAULT_FOREGROUND = RGBA.defaultForeground() @@ -75,6 +78,8 @@ export const unreadGlowIntensity = (index: number, width: number, maximumTail = const tail = Math.min(maximumTail, Math.max(1, width - 2)) return glowIntensityAt(index, tail) } +/** How far a resolving glow has diffused: the resting tail spreads across the full width as it thins away. */ +const glowReleaseSpread = (progress: number, tail: number, width: number) => tail + smootherstep(progress) * width export function blendTabPulseColor( output: RGBA, background: RGBA, @@ -148,6 +153,10 @@ class Envelope { return this.clock !== undefined } + get progress() { + return this.clock === undefined ? undefined : this.clock / this.duration + } + level() { return this.clock === undefined ? 0 : this.scale * this.shape(this.clock / this.duration) } @@ -178,7 +187,19 @@ class PulseState { private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0)) private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel) private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut) - private envelopes = [this.runAttack, this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff] + // A resolving glow's send-off: a gentle swell that decays while the spatial profile diffuses outward. + private release = new Envelope(GLOW_RELEASE_DURATION, (progress) => + attackDecay(progress, GLOW_RELEASE_ATTACK, GLOW_RELEASE_PEAK, 0), + ) + private envelopes = [ + this.runAttack, + this.runFade, + this.completionPulse, + this.edgeFlash, + this.ignition, + this.glowOff, + this.release, + ] constructor(options: PulseStateOptions) { this.enabled = options.enabled @@ -211,6 +232,14 @@ class PulseState { return this.ignition.active ? this.ignition.level() : 1 } + get releaseLevel() { + return this.release.level() + } + + get releaseProgress() { + return this.release.progress + } + setEnabled(value: boolean) { if (value === this.enabled) return false this.enabled = value @@ -266,11 +295,16 @@ class PulseState { setGlow(value: boolean) { if (value === this.glow) return false - if (this.enabled && !value) this.glowOff.start(this.glowLevel) + if (this.enabled && !value) { + // Resolving the glow sends it off: residual glow drains while the release diffuses outward. + this.glowOff.start(this.glowLevel) + this.release.restart(Math.max(1, this.glowLevel)) + } this.glow = value this.ignition.stop() if (this.enabled && value) { this.glowOff.stop() + this.release.stop() this.ignition.start() } return true @@ -523,16 +557,20 @@ class TabPulseRenderable extends Renderable { const completion = this.inner.completion const flash = this.inner.flash const glowLevel = this.inner.glowLevel + const releaseLevel = this.inner.releaseLevel const outerRunning = this.outer.running const outerCompletion = this.outer.completion const outerFlash = this.outer.flash const outerGlowLevel = this.outer.glowLevel + const outerReleaseLevel = this.outer.releaseLevel if ( glowLevel === 0 && + releaseLevel === 0 && running === 0 && completion === 0 && flash === 0 && outerGlowLevel === 0 && + outerReleaseLevel === 0 && outerRunning === 0 && outerCompletion === 0 && outerFlash === 0 @@ -551,6 +589,8 @@ class TabPulseRenderable extends Renderable { ) const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2)) const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2)) + const releaseSpread = glowReleaseSpread(this.inner.releaseProgress ?? 0, glowTail, this.width) + const outerReleaseSpread = glowReleaseSpread(this.outer.releaseProgress ?? 0, outerGlowTail, this.width) const flashTail = this._flashTail === undefined ? undefined : Math.min(this._flashTail, Math.max(1, this.width - 2)) const outerFlashTail = this._outerFlashTail === undefined ? undefined : Math.min(this._outerFlashTail, Math.max(1, this.width - 2)) @@ -581,7 +621,10 @@ class TabPulseRenderable extends Renderable { this._color, this._flashColor, this._completionColor, - glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel, + Math.max( + glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel, + releaseLevel === 0 ? 0 : glowIntensityAt(index, releaseSpread) * GLOW_OPACITY * releaseLevel, + ), sweep, flashTail === undefined ? flash : flash * tabFlashIntensity(index, flashTail), completion, @@ -597,7 +640,10 @@ class TabPulseRenderable extends Renderable { this._outerColor, this._outerFlashColor, this._outerCompletionColor, - outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel, + Math.max( + outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel, + outerReleaseLevel === 0 ? 0 : glowIntensityAt(index, outerReleaseSpread) * GLOW_OPACITY * outerReleaseLevel, + ), outerSweep, outerFlashTail === undefined ? outerFlash : outerFlash * tabFlashIntensity(index, outerFlashTail), outerCompletion,