diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 45ed8c6407f..db08d10db55 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -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 export type SessionTabsStatus = Omit, "unread"> & { unread: SessionTabUnread | undefined } -export type SessionTabsController = Pick & { +export type SessionTabsController = Pick & { 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() + const [dragging, setDragging] = createSignal() + 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 ( (strip = element)} height={1} flexShrink={0} position="relative" @@ -127,7 +163,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati }} > 0}> - + ‹{layout().before} @@ -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() + 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 ( 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} > - - - {items().findIndex((item) => item.sessionID === tab.sessionID) + 1} + + {" "} - - {visibleTitle()} - - } + + {tabNumber()} + + - - {visibleTitleParts().slice(0, -fadeWidth()).join("")} + + {displayedParts().slice(0, -FADE_WIDTH).join("")} {(character, index) => ( )} - - + + { event.stopPropagation() tabs.close(tab.sessionID) @@ -241,8 +327,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati }} 0}> - - {layout().after}› + + {" " + layout().after}› diff --git a/packages/tui/src/component/tab-pulse.tsx b/packages/tui/src/component/tab-pulse.tsx index b06a4f189d2..7e994f0b049 100644 --- a/packages/tui/src/component/tab-pulse.tsx +++ b/packages/tui/src/component/tab-pulse.tsx @@ -6,6 +6,7 @@ type TabPulseOptions = RenderableOptions & { 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} diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts index 0347c8e57b7..433a19150bf 100644 --- a/packages/tui/src/context/session-tabs-model.ts +++ b/packages/tui/src/context/session-tabs-model.ts @@ -17,7 +17,8 @@ export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: b export const SESSION_TAB_WIDTH = 22 export const SESSION_TAB_MAX_WIDTH = 32 export const SESSION_TAB_MIN_WIDTH = 8 -export const SESSION_TAB_OVERFLOW_WIDTH = 3 +// Overflow markers reserve one gap cell beside the arrow and count, e.g. "‹12 " and " 12›". +export const sessionTabOverflowWidth = (count: number) => String(count).length + 2 export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] { const index = tabs.findIndex((item) => item.sessionID === tab.sessionID) @@ -35,6 +36,15 @@ export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) } } +export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: number): SessionTab[] { + const from = tabs.findIndex((tab) => tab.sessionID === sessionID) + const to = Math.max(0, Math.min(tabs.length - 1, index)) + if (from === -1 || from === to) return tabs + const next = tabs.filter((tab) => tab.sessionID !== sessionID) + next.splice(to, 0, tabs[from]) + return next +} + export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) { if (tabs.length === 0) return const index = tabs.findIndex((tab) => tab.sessionID === active) @@ -67,6 +77,38 @@ export function moveSessionTabHistory( return { history: { ...history, index: target.index }, sessionID: target.sessionID } } +export type SessionTabMotionValues = { + widths: number[] + selections: number[] + activities: number[] +} + +/** + * Seed width motion for a visible-tab membership change: retained tabs keep their current animated + * values and first-seen tabs grow in from zero width. Returns undefined when nothing is retained, + * meaning the window was fully replaced and the strip should jump. + */ +export function seedSessionTabMotion( + previous: readonly string[], + ids: readonly string[], + current: SessionTabMotionValues, + next: SessionTabMotionValues, +): SessionTabMotionValues | undefined { + const positions = ids.map((id) => previous.indexOf(id)) + if (positions.every((position) => position === -1)) return undefined + return { + widths: positions.map((position, index) => + position === -1 ? 0 : (current.widths[position] ?? next.widths[index] ?? 0), + ), + selections: positions.map( + (position, index) => (position === -1 ? next.selections[index] : current.selections[position]) ?? 0, + ), + activities: positions.map( + (position, index) => (position === -1 ? next.activities[index] : current.activities[position]) ?? 0, + ), + } +} + export function adaptiveSessionTabLayout( tabs: readonly SessionTab[], active: string | undefined, @@ -102,8 +144,8 @@ export function adaptiveSessionTabLayout( tabs.length - count, ) const markers = - (nextStart > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) + - (nextStart + count < tabs.length ? SESSION_TAB_OVERFLOW_WIDTH : 0) + (nextStart > 0 ? sessionTabOverflowWidth(nextStart) : 0) + + (nextStart + count < tabs.length ? sessionTabOverflowWidth(tabs.length - nextStart - count) : 0) const nextCount = fit(available - markers) if (nextCount === count || attempts === 0) return { count, start: nextStart } return solve(nextCount, nextStart, attempts - 1) @@ -114,7 +156,7 @@ export function adaptiveSessionTabLayout( const after = tabs.length - solved.start - solved.count const contentWidth = Math.max( 1, - available - (before > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) - (after > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0), + available - (before > 0 ? sessionTabOverflowWidth(before) : 0) - (after > 0 ? sessionTabOverflowWidth(after) : 0), ) const roomy = contentWidth >= SESSION_TAB_WIDTH * visible.length const total = roomy ? Math.min(contentWidth, SESSION_TAB_MAX_WIDTH * visible.length) : contentWidth diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 0533936e4a5..9daeb4fd54c 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -10,6 +10,7 @@ import { useTuiPaths } from "./runtime" import { closeSessionTab, cycleSessionTab, + moveSessionTab, moveSessionTabHistory, openSessionTab, recordSessionTabHistory, @@ -39,11 +40,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const config = useConfig().data const paths = useTuiPaths() const enabled = () => config.tabs?.enabled ?? false + // Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of + // mutating in place, which per-row animations and drag state depend on. const [store, updateStore] = useStorage().store("tabs", { initial: { global: empty(), cwd: {}, }, + key: "sessionID", }) const fallback = empty() let history: SessionTabHistory = { entries: [], index: -1 } @@ -177,6 +181,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp } remove(target, true) }, + move(sessionID: string, index: number) { + if (!enabled()) return + const session = root(sessionID) + if (moveSessionTab(state().tabs, session, index) === state().tabs) return + update((draft) => { + draft.tabs = moveSessionTab(draft.tabs, session, index) + }) + }, cycle(direction: 1 | -1) { if (!enabled()) return const tab = cycleSessionTab(state().tabs, current(), direction) diff --git a/packages/tui/src/context/storage.tsx b/packages/tui/src/context/storage.tsx index 40d050f69a0..a3e1d4f2b8b 100644 --- a/packages/tui/src/context/storage.tsx +++ b/packages/tui/src/context/storage.tsx @@ -8,6 +8,8 @@ import { useTuiApp, useTuiPaths } from "./runtime" type Options = { readonly initial: Value + /** Reconcile key for arrays inside the stored value, preserving item identity across updates. Defaults to "id". */ + readonly key?: string } type Entry = readonly [Store, (mutation: (draft: Value) => void) => Promise] @@ -53,7 +55,8 @@ function createStorage(root: string, channel: string) { } } const [store, setStore] = createStore(load()) - const reload = () => batch(() => setStore(reconcile(load()))) + const merge = (next: Value) => reconcile(next, { key: options.key }) + const reload = () => batch(() => setStore(merge(load()))) const update = (mutation: (draft: Value) => void) => Flock.withLock( file, @@ -62,7 +65,7 @@ function createStorage(root: string, channel: string) { mutation(draft) const next = clone(draft) await writeJsonAtomic(file, next) - batch(() => setStore(reconcile(next))) + batch(() => setStore(merge(next))) }, { dir: locks }, ) diff --git a/packages/tui/src/feature-plugins/system/scrap.tsx b/packages/tui/src/feature-plugins/system/scrap.tsx index 78a483d04ca..ba5772eb92b 100644 --- a/packages/tui/src/feature-plugins/system/scrap.tsx +++ b/packages/tui/src/feature-plugins/system/scrap.tsx @@ -2,6 +2,7 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { useTerminalDimensions } from "@opentui/solid" import { batch, createSignal } from "solid-js" import { SessionTabs, type SessionTabsController } from "../../component/session-tabs" +import { moveSessionTab, type SessionTab } from "../../context/session-tabs-model" type FixtureStatus = ReturnType @@ -45,7 +46,7 @@ function Scrap(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() const theme = props.context.theme const elevatedTheme = theme.contextual.elevated - const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6)) + const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6)) const [active, setActive] = createSignal("fixture-2") const [animations, setAnimations] = createSignal(true) const [statuses, setStatuses] = createSignal>({ @@ -61,6 +62,9 @@ function Scrap(props: { context: Plugin.Context }) { status(sessionID) { return statuses()[sessionID] ?? EMPTY_STATUS }, + move(sessionID, index) { + setTabs((current) => moveSessionTab(current, sessionID, index)) + }, select(sessionID) { setActive(sessionID) }, @@ -82,7 +86,7 @@ function Scrap(props: { context: Plugin.Context }) { const items = tabs() if (items.length === 0) return const index = items.findIndex((tab) => tab.sessionID === active()) - controller.select(items[(index + direction + items.length) % items.length]!.sessionID) + controller.select(items[(index + direction + items.length) % items.length].sessionID) } const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => { const sessionID = active() diff --git a/packages/tui/test/component/tab-pulse.test.ts b/packages/tui/test/component/tab-pulse.test.ts index 21561b6e298..7d216119849 100644 --- a/packages/tui/test/component/tab-pulse.test.ts +++ b/packages/tui/test/component/tab-pulse.test.ts @@ -1,6 +1,11 @@ import { expect, test } from "bun:test" import { RGBA } from "@opentui/core" -import { blendTabPulseColor, completionPulseOpacity, unreadGlowIntensity } from "../../src/component/tab-pulse" +import { + blendTabPulseColor, + completionPulseOpacity, + glowIgnitionLevel, + unreadGlowIntensity, +} from "../../src/component/tab-pulse" import { tint } from "../../src/theme/color" test("completion pulse rises quickly and fades over the remaining duration", () => { @@ -11,6 +16,13 @@ test("completion pulse rises quickly and fades over the remaining duration", () expect(completionPulseOpacity(1)).toBe(0) }) +test("glow ignition overshoots the resting level and settles back to it", () => { + expect(glowIgnitionLevel(0)).toBe(0) + expect(glowIgnitionLevel(0.3)).toBeCloseTo(1.5) + expect(glowIgnitionLevel(0.6)).toBeGreaterThan(1) + expect(glowIgnitionLevel(1)).toBe(1) +}) + test("unread glow peaks behind the tab number and fades to the normal background", () => { const intensities = Array.from({ length: 22 }, (_, index) => unreadGlowIntensity(index, 22)) diff --git a/packages/tui/test/context/session-tabs-model.test.ts b/packages/tui/test/context/session-tabs-model.test.ts index 6e93d53b014..c5946f7d35f 100644 --- a/packages/tui/test/context/session-tabs-model.test.ts +++ b/packages/tui/test/context/session-tabs-model.test.ts @@ -3,13 +3,65 @@ import { adaptiveSessionTabLayout, closeSessionTab, cycleSessionTab, + moveSessionTab, moveSessionTabHistory, openSessionTab, recordSessionTabHistory, + seedSessionTabMotion, sessionTabComplete, + sessionTabOverflowWidth, } from "../../src/context/session-tabs-model" describe("session tabs", () => { + test("moves a tab to a clamped index and returns the same tabs for no-ops", () => { + const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID })) + expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"]) + expect(moveSessionTab(tabs, "c", -5).map((tab) => tab.sessionID)).toEqual(["c", "a", "b"]) + expect(moveSessionTab(tabs, "b", 99).map((tab) => tab.sessionID)).toEqual(["a", "c", "b"]) + expect(moveSessionTab(tabs, "b", 1)).toBe(tabs) + expect(moveSessionTab(tabs, "missing", 0)).toBe(tabs) + }) + + test("open seeding keeps survivors and grows the new tab from zero", () => { + const seeded = seedSessionTabMotion( + ["a", "b"], + ["a", "b", "c"], + { widths: [35, 35], selections: [1, 0], activities: [0, 1] }, + { widths: [24, 23, 23], selections: [1, 0, 0], activities: [0, 1, 0] }, + ) + expect(seeded).toEqual({ widths: [35, 35, 0], selections: [1, 0, 0], activities: [0, 1, 0] }) + }) + + test("close seeding keeps survivors at their current animated widths", () => { + const seeded = seedSessionTabMotion( + ["a", "b", "c"], + ["a", "c"], + { widths: [24, 23, 23], selections: [1, 0, 0], activities: [0, 0, 1] }, + { widths: [35, 35], selections: [1, 0], activities: [0, 1] }, + ) + expect(seeded).toEqual({ widths: [24, 23], selections: [1, 0], activities: [0, 1] }) + }) + + test("window shifts keep retained tabs and grow revealed ones", () => { + const seeded = seedSessionTabMotion( + ["a", "b", "c"], + ["b", "c", "d"], + { widths: [22, 8, 8], selections: [1, 0, 0], activities: [0, 0, 0] }, + { widths: [8, 8, 22], selections: [0, 0, 1], activities: [0, 0, 0] }, + ) + expect(seeded).toEqual({ widths: [8, 8, 0], selections: [0, 0, 1], activities: [0, 0, 0] }) + }) + + test("fully replaced windows jump instead of seeding", () => { + const values = { widths: [22], selections: [1], activities: [0] } + expect(seedSessionTabMotion(["a"], ["z"], values, values)).toBeUndefined() + }) + + test("overflow markers reserve room for a gap beside their digits", () => { + expect(sessionTabOverflowWidth(5)).toBe(3) + expect(sessionTabOverflowWidth(12)).toBe(4) + }) + test("opens each session once and refreshes its title", () => { const tabs = openSessionTab([{ sessionID: "a", title: "Old" }], { sessionID: "a", title: "New" }) expect(tabs).toEqual([{ sessionID: "a", title: "New" }])