diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 622321ad290..73b2ee96527 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -170,7 +170,9 @@ export function SessionTabs() { enabled={config.animations ?? true} active={status().busy} complete={status().complete} + glow={status().unread === "activity" && !status().busy && !selected() && !status().attention} color={pulseColor()} + glowColor={accent()} completionColor={accent()} backgroundColor={pulseBackground()} /> diff --git a/packages/tui/src/component/tab-pulse.tsx b/packages/tui/src/component/tab-pulse.tsx index a006760e2e2..e76ff05f2a0 100644 --- a/packages/tui/src/component/tab-pulse.tsx +++ b/packages/tui/src/component/tab-pulse.tsx @@ -6,7 +6,9 @@ type TabPulseOptions = RenderableOptions & { enabled?: boolean active?: boolean complete?: boolean + glow?: boolean color?: RGBA + glowColor?: RGBA completionColor?: RGBA backgroundColor?: RGBA } @@ -19,6 +21,8 @@ const RUN_TAIL = 18 const RUN_FADE_OUT = 500 const COMPLETION_DURATION = 900 const COMPLETION_ATTACK = 0.16 +const GLOW_TAIL = 12 +const GLOW_OPACITY = 0.16 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)) @@ -33,11 +37,17 @@ export const completionPulseOpacity = (progress: number) => progress < COMPLETION_ATTACK ? smootherstep(clamp(progress / COMPLETION_ATTACK)) : 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK))) +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)) +} class TabPulseRenderable extends Renderable { private _enabled: boolean private _active: boolean private _complete: boolean + private _glow: boolean private _color: RGBA + private _glowColor: RGBA private _completionColor: RGBA private _backgroundColor: RGBA private clock = 0 @@ -52,7 +62,9 @@ class TabPulseRenderable extends Renderable { this._enabled = enabled this._active = active this._complete = options.complete ?? false + this._glow = options.glow ?? 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() } @@ -103,12 +115,24 @@ class TabPulseRenderable extends Renderable { this.requestRender() } + set glow(value: boolean) { + if (value === this._glow) return + this._glow = value + this.requestRender() + } + set color(value: RGBA) { if (value.equals(this._color)) return this._color = value this.requestRender() } + set glowColor(value: RGBA) { + if (value.equals(this._glowColor)) return + this._glowColor = value + this.requestRender() + } + set completionColor(value: RGBA) { if (value.equals(this._completionColor)) return this._completionColor = value @@ -144,15 +168,19 @@ class TabPulseRenderable extends Renderable { } protected override renderSelf(buffer: OptimizedBuffer): void { - if (!this.visible || this.isDestroyed || !this._enabled || this.width <= 0) return - const runningOpacity = this._active - ? 1 - : this.fadeClock === undefined - ? 0 - : 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT)) + 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.completionClock === undefined ? 0 : completionPulseOpacity(this.completionClock / COMPLETION_DURATION) - if (runningOpacity === 0 && completionOpacity === 0) return + !this._enabled || this.completionClock === undefined + ? 0 + : completionPulseOpacity(this.completionClock / COMPLETION_DURATION) + if (!this._glow && runningOpacity === 0 && completionOpacity === 0) return const progress = (this.clock % RUN_DURATION) / RUN_DURATION const start = -RUN_HEAD const end = this.width - 1 + RUN_TAIL @@ -163,7 +191,10 @@ class TabPulseRenderable extends Renderable { intensityAt(index, front, RUN_HEAD, RUN_TAIL), intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL), ) - const running = tint(this._backgroundColor, this._color, intensity * 0.14 * runningOpacity) + const background = this._glow + ? tint(this._backgroundColor, this._glowColor, unreadGlowIntensity(index, this.width) * GLOW_OPACITY) + : this._backgroundColor + const running = tint(background, this._color, intensity * 0.14 * runningOpacity) buffer.setCell( this.screenX + index, this.screenY, @@ -187,7 +218,9 @@ export function TabPulse(props: { enabled?: boolean active: boolean complete?: boolean + glow?: boolean color: RGBA + glowColor?: RGBA completionColor?: RGBA backgroundColor: RGBA }) { @@ -199,7 +232,9 @@ export function TabPulse(props: { enabled={props.enabled ?? true} active={props.active} complete={props.complete ?? false} + glow={props.glow ?? false} color={props.color} + glowColor={props.glowColor ?? props.color} completionColor={props.completionColor ?? props.color} backgroundColor={props.backgroundColor} /> diff --git a/packages/tui/test/component/tab-pulse.test.ts b/packages/tui/test/component/tab-pulse.test.ts index 0082dc66d20..d06cd382b6f 100644 --- a/packages/tui/test/component/tab-pulse.test.ts +++ b/packages/tui/test/component/tab-pulse.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { completionPulseOpacity } from "../../src/component/tab-pulse" +import { completionPulseOpacity, unreadGlowIntensity } from "../../src/component/tab-pulse" test("completion pulse rises quickly and fades over the remaining duration", () => { expect(completionPulseOpacity(0)).toBe(0) @@ -8,3 +8,19 @@ test("completion pulse rises quickly and fades over the remaining duration", () expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5) expect(completionPulseOpacity(1)).toBe(0) }) + +test("unread glow peaks behind the tab number and fades to the normal background", () => { + const intensities = Array.from({ length: 22 }, (_, index) => unreadGlowIntensity(index, 22)) + + expect(intensities[0]).toBe(1) + expect(intensities[1]).toBe(1) + expect(intensities[2]).toBeLessThan(1) + expect(intensities.slice(1)).toEqual(intensities.slice(1).sort((a, b) => b - a)) + expect(intensities[13]).toBe(0) + expect(intensities.at(-1)).toBe(0) +}) + +test("unread glow reaches the normal background on compact tabs", () => { + expect(unreadGlowIntensity(0, 8)).toBe(1) + expect(unreadGlowIntensity(7, 8)).toBe(0) +})