From 37a1b80d5a14711bc240df0e7d73fbfb915980cf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 28 Jul 2026 17:12:07 -0400 Subject: [PATCH] feat(tui): add adaptive session tabs (#39396) --- packages/tui/src/app.tsx | 295 +++++++++++------- packages/tui/src/component/dialog-config.tsx | 8 + .../tui/src/component/dialog-session-list.tsx | 10 +- packages/tui/src/component/session-tabs.tsx | 231 ++++++++++++++ packages/tui/src/component/tab-pulse.tsx | 207 ++++++++++++ packages/tui/src/config/index.tsx | 3 + packages/tui/src/config/v1/keybind.ts | 30 +- .../tui/src/context/session-tabs-model.ts | 108 +++++++ packages/tui/src/context/session-tabs.tsx | 251 +++++++++++++++ .../tui/src/routes/session/composer/index.tsx | 6 - packages/tui/src/ui/animation.ts | 235 ++++++++++++++ packages/tui/src/util/locale.ts | 30 +- packages/tui/src/util/signal.ts | 26 +- packages/tui/test/component/tab-pulse.test.ts | 10 + packages/tui/test/config-v2.test.tsx | 15 +- packages/tui/test/config.test.tsx | 19 +- .../test/context/session-tabs-model.test.ts | 104 ++++++ packages/tui/test/ui/animation.test.ts | 45 +++ packages/tui/test/util/locale.test.ts | 5 + 19 files changed, 1488 insertions(+), 150 deletions(-) create mode 100644 packages/tui/src/component/session-tabs.tsx create mode 100644 packages/tui/src/component/tab-pulse.tsx create mode 100644 packages/tui/src/context/session-tabs-model.ts create mode 100644 packages/tui/src/context/session-tabs.tsx create mode 100644 packages/tui/src/ui/animation.ts create mode 100644 packages/tui/test/component/tab-pulse.test.ts create mode 100644 packages/tui/test/context/session-tabs-model.test.ts create mode 100644 packages/tui/test/ui/animation.test.ts diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 3cbe50a49d1..3ba52f6c450 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -51,6 +51,7 @@ import { StartupLoading } from "./component/startup-loading" import { DevToolsBar } from "./component/devtools-bar" import { Reconnecting } from "./component/reconnecting" import { DataProvider, useData } from "./context/data" +import { SessionTabsProvider, useSessionTabs } from "./context/session-tabs" import { LocationProvider, useLocation } from "./context/location" import { LocalProvider, useLocal } from "./context/local" import { PermissionProvider } from "./context/permission" @@ -65,6 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" +import { SessionTabs } from "./component/session-tabs" import { ThemeErrorToast } from "./component/theme-error-toast" import { ThemeProvider, useTheme, useThemes } from "./context/theme" import { Home } from "./routes/home" @@ -92,9 +94,26 @@ import { AttentionProvider } from "./context/attention" registerOpencodeSpinner() -const appGlobalBindingCommands = [ - "session.list", - "session.new", +const appGlobalBindingCommands = ["session.list", "session.new"] as const + +const sessionTabBindingCommands = [ + "session.tab.next", + "session.tab.previous", + "session.tab.next_unread", + "session.tab.previous_unread", + "session.tab.close", + "session.tab.select.1", + "session.tab.select.2", + "session.tab.select.3", + "session.tab.select.4", + "session.tab.select.5", + "session.tab.select.6", + "session.tab.select.7", + "session.tab.select.8", + "session.tab.select.9", +] as const + +const pinnedSessionBindingCommands = [ "session.quick_switch.1", "session.quick_switch.2", "session.quick_switch.3", @@ -277,113 +296,115 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { } > - - finalizers.delete(finalizer) - }, + cwd: process.cwd(), + home: global.home, + state: global.state, + worktree: global.data + "/worktree", }} > - finalizers.delete(finalizer) + }, }} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -419,6 +440,7 @@ function App(props: { pair?: DialogPairCredentials }) { const renderer = useRenderer() const dialog = useDialog() const local = useLocal() + const sessionTabs = useSessionTabs() const keymap = Keymap.use() const event = useEvent() const client = useClient() @@ -623,9 +645,55 @@ function App(props: { pair?: DialogPairCredentials }) { title: `Switch to session in quick slot ${i + 1}`, category: "Session", palette: undefined, - run: () => { - local.session.quickSwitch(i + 1) - }, + enabled: () => !sessionTabs.enabled(), + run: () => local.session.quickSwitch(i + 1), + })), + { + name: "session.tab.next", + title: "Next open session tab", + category: "Session", + palette: undefined, + enabled: sessionTabs.enabled, + run: () => sessionTabs.cycle(1), + }, + { + name: "session.tab.previous", + title: "Previous open session tab", + category: "Session", + palette: undefined, + enabled: sessionTabs.enabled, + run: () => sessionTabs.cycle(-1), + }, + { + name: "session.tab.next_unread", + title: "Next unread session tab", + category: "Session", + palette: undefined, + enabled: sessionTabs.enabled, + run: () => sessionTabs.cycleUnread(1), + }, + { + name: "session.tab.previous_unread", + title: "Previous unread session tab", + category: "Session", + palette: undefined, + enabled: sessionTabs.enabled, + run: () => sessionTabs.cycleUnread(-1), + }, + { + name: "session.tab.close", + title: "Close current session tab", + category: "Session", + enabled: sessionTabs.enabled, + run: () => sessionTabs.close(), + }, + ...Array.from({ length: 9 }, (_, i) => ({ + name: `session.tab.select.${i + 1}`, + title: `Switch to session tab ${i + 1}`, + category: "Session", + palette: undefined, + enabled: sessionTabs.enabled, + run: () => sessionTabs.selectIndex(i), })), { name: "model.list", @@ -1004,6 +1072,18 @@ function App(props: { pair?: DialogPairCredentials }) { bindings: appGlobalBindingCommands, })) + Keymap.createLayer(() => ({ + mode: "global", + enabled: sessionTabs.enabled, + bindings: sessionTabBindingCommands, + })) + + Keymap.createLayer(() => ({ + mode: "global", + enabled: () => !sessionTabs.enabled(), + bindings: pinnedSessionBindingCommands, + })) + Keymap.createLayer(() => ({ enabled: () => { const current = promptRef.current @@ -1099,16 +1179,15 @@ function App(props: { pair?: DialogPairCredentials }) { evt.preventDefault() evt.stopPropagation() }} - onMouseUp={ - copyOnSelectEnabled() - ? () => Selection.copy(renderer, toast, clipboard) - : undefined - } + onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined} > + 0 && route.data.type !== "plugin"}> + + diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index e62ce18d248..157c30b1ea7 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -93,6 +93,14 @@ export const settings: Setting[] = [ values: ["none", "auto"], keywords: ["transcript", "messages"], }, + { + title: "Tabs", + category: "Session", + path: ["session", "tabs"], + default: false, + values: [false, true], + labels: ["off", "on"], + }, { title: "Layout", category: "Diffs", diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 349d216822a..3f572ff39a2 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -15,6 +15,7 @@ import { useToast } from "../ui/toast" import { DialogSessionRename } from "./dialog-session-rename" import { Spinner } from "./spinner" import { errorMessage } from "../util/error" +import { useSessionTabs } from "../context/session-tabs" export function DialogSessionList() { const dialog = useDialog() @@ -25,6 +26,7 @@ export function DialogSessionList() { const mode = themes.mode const client = useClient() const local = useLocal() + const sessionTabs = useSessionTabs() const toast = useToast() const [filter, setFilter] = createSignal("") const shortcuts = Keymap.useShortcuts() @@ -79,6 +81,7 @@ export function DialogSessionList() { }) const quickSwitchHint = createMemo(() => { + if (sessionTabs.enabled()) return const first = shortcuts.get("session.quick_switch.1") const last = shortcuts.get("session.quick_switch.9") if (!first || !last) return @@ -96,7 +99,7 @@ export function DialogSessionList() { .filter((session) => !session.parentID) .map((session) => [session.id, session]), ) - const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID)) + const pinned = sessionTabs.enabled() ? [] : local.session.pinned().filter((sessionID) => sessionMap.has(sessionID)) const pinnedSet = new Set(pinned) const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1])) @@ -104,7 +107,7 @@ export function DialogSessionList() { const directory = session.location.directory const footer = directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : "" - const slot = slotByID.get(session.id) + const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id) const deleting = toDelete() === session.id return { title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title, @@ -164,7 +167,8 @@ export function DialogSessionList() { { command: "session.pin.toggle", title: "pin/unpin", - onTrigger: (option: { value: string }) => local.session.togglePin(option.value), + hidden: sessionTabs.enabled(), + onTrigger: (option) => local.session.togglePin(option.value), }, { command: "session.delete", diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx new file mode 100644 index 00000000000..4d6c5610585 --- /dev/null +++ b/packages/tui/src/component/session-tabs.tsx @@ -0,0 +1,231 @@ +import { RGBA, TextAttributes } from "@opentui/core" +import { For, Show, createEffect, createMemo, createSignal } from "solid-js" +import { useTerminalDimensions } from "@opentui/solid" +import { useConfig } from "../config" +import { useSessionTabs } from "../context/session-tabs" +import { useTheme, useThemes } from "../context/theme" +import { adaptiveSessionTabLayout, sessionTabComplete, SESSION_TAB_OVERFLOW_WIDTH } from "../context/session-tabs-model" +import { createAnimatable, spring } from "../ui/animation" +import { Locale } from "../util/locale" +import { TabPulse } from "./tab-pulse" +import { tint } from "../theme/color" + +export function SessionTabs() { + const tabs = useSessionTabs() + const dimensions = useTerminalDimensions() + const theme = useTheme() + const { mode } = useThemes() + const config = useConfig().data + const [hovered, setHovered] = createSignal() + 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 idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35) + const activeID = createMemo(tabs.current) + const items = tabs.tabs + const layout = createMemo((previous: ReturnType | undefined) => + adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start), + ) + const statuses = createMemo( + () => + new Map( + layout().tabs.map((tab) => { + const status = tabs.status(tab.sessionID) + return [ + tab.sessionID, + { + ...status, + complete: sessionTabComplete(status.unread, status.busy), + }, + ] as const + }), + ), + ) + const targets = createMemo(() => ({ + widths: layout().widths, + selections: layout().tabs.map((tab) => Number(tab.sessionID === activeID())), + activities: layout().tabs.map((tab) => Number(statuses().get(tab.sessionID)!.complete)), + })) + const motion = createAnimatable(targets(), { + enabled: () => config.animations ?? true, + transition: spring({ visualDuration: 0.1 }), + }) + const identity = createMemo(() => + layout() + .tabs.map((tab) => tab.sessionID) + .join(":"), + ) + let signature = "" + let total = 0 + + createEffect(() => { + const next = targets() + const nextSignature = identity() + const reset = (signature && signature !== nextSignature) || (total && total !== layout().total) + signature = nextSignature + total = layout().total + if (reset) return motion.jump(next) + motion.animate(next) + }) + + 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) + return new Map( + layout().tabs.map((tab, index) => [ + tab.sessionID, + { + width: widths[index]!, + selection: current.selections[index] ?? Number(tab.sessionID === activeID()), + activity: current.activities[index] ?? Number(statuses().get(tab.sessionID)!.complete), + }, + ]), + ) + }) + + return ( + = buffer.height || width <= 0) return + buffer.fillRect( + x, + y, + width, + 1, + RGBA.fromValues( + theme.background.default.r, + theme.background.default.g, + theme.background.default.b, + mode() === "light" ? 0.14 : 0.28, + ), + ) + }} + > + 0}> + + ‹{layout().before} + + + + {(tab) => { + const selected = () => activeID() === tab.sessionID + const status = () => statuses().get(tab.sessionID)! + 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 pulseBackground = () => background() + const pulseColor = () => tint(pulseBackground(), theme.text.default, 0.45) + const title = () => tab.title ?? "Untitled session" + const availableTitleWidth = () => Math.max(1, width() - 3) + 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 titleFades = createMemo(() => visibleTitle() !== title() && availableTitleWidth() > fadeWidth()) + 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 base = + hovered() === tab.sessionID && !selected() + ? foreground() + : tint(idleNumber(), activeNumber(), selection()) + return tint(base, accent(), activity()) + } + const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6) + return ( + setHovered(tab.sessionID)} + onMouseOut={() => setHovered(undefined)} + onMouseUp={() => tabs.select(tab.sessionID)} + > + + + + + {items().findIndex((item) => item.sessionID === tab.sessionID) + 1} + + + {visibleTitle()} + + } + > + + {visibleTitleParts().slice(0, -fadeWidth()).join("")} + + {(character, index) => ( + + {character} + + )} + + + + { + event.stopPropagation() + tabs.close(tab.sessionID) + }} + > + {hovered() === tab.sessionID ? "×" : ""} + + + + ) + }} + + 0}> + + {layout().after}› + + + + ) +} diff --git a/packages/tui/src/component/tab-pulse.tsx b/packages/tui/src/component/tab-pulse.tsx new file mode 100644 index 00000000000..a006760e2e2 --- /dev/null +++ b/packages/tui/src/component/tab-pulse.tsx @@ -0,0 +1,207 @@ +import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core" +import { extend } from "@opentui/solid" +import { tint } from "../theme/color" + +type TabPulseOptions = RenderableOptions & { + enabled?: boolean + active?: boolean + complete?: boolean + color?: RGBA + completionColor?: RGBA + backgroundColor?: RGBA +} + +const clamp = (value: number) => Math.max(0, Math.min(1, value)) +const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10) +const RUN_DURATION = 2_800 +const RUN_HEAD = 4 +const RUN_TAIL = 18 +const RUN_FADE_OUT = 500 +const COMPLETION_DURATION = 900 +const COMPLETION_ATTACK = 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)) +} +const coast = (value: number) => { + const ramp = 0.2 + if (value < ramp) return (value * value) / (2 * ramp * (1 - ramp)) + 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))) +class TabPulseRenderable extends Renderable { + private _enabled: boolean + private _active: boolean + private _complete: boolean + private _color: RGBA + private _completionColor: RGBA + private _backgroundColor: RGBA + private clock = 0 + private fadeClock: number | undefined + private completionClock: number | undefined + private completionPending = false + + constructor(ctx: RenderContext, options: TabPulseOptions = {}) { + const enabled = options.enabled ?? true + const active = options.active ?? false + super(ctx, { ...options, height: 1, live: enabled && active }) + this._enabled = enabled + this._active = active + this._complete = options.complete ?? false + this._color = options.color ?? RGBA.defaultForeground() + this._completionColor = options.completionColor ?? this._color + this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground() + } + + set enabled(value: boolean) { + if (value === this._enabled) return + this._enabled = value + if (!value) { + this.fadeClock = undefined + this.completionClock = undefined + this.completionPending = false + this.live = false + } else if (this._active) { + this.live = true + } + this.requestRender() + } + + set active(value: boolean) { + if (value === this._active) return + this._active = value + if (!this._enabled) return + if (value) { + this.fadeClock = undefined + this.completionClock = undefined + this.completionPending = false + this.live = true + } else { + this.fadeClock = 0 + this.completionPending = true + this.live = true + } + this.requestRender() + } + + set complete(value: boolean) { + if (value === this._complete) return + this._complete = value + if (!value) { + this.completionClock = undefined + this.completionPending = false + } + if (value && this.completionPending) { + this.completionClock = 0 + this.completionPending = false + this.live = this._enabled + } + this.requestRender() + } + + set color(value: RGBA) { + if (value.equals(this._color)) return + this._color = value + this.requestRender() + } + + set completionColor(value: RGBA) { + if (value.equals(this._completionColor)) return + this._completionColor = value + this.requestRender() + } + + set backgroundColor(value: RGBA) { + if (value.equals(this._backgroundColor)) return + this._backgroundColor = value + this.requestRender() + } + + 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.completionPending) { + if (this._complete) { + this.completionClock = 0 + this.completionPending = false + } else if (this.fadeClock === undefined) { + 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 + } + + 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)) + const completionOpacity = + this.completionClock === undefined ? 0 : completionPulseOpacity(this.completionClock / COMPLETION_DURATION) + if (runningOpacity === 0 && completionOpacity === 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 running = tint(this._backgroundColor, this._color, intensity * 0.14 * runningOpacity) + buffer.setCell( + this.screenX + index, + this.screenY, + " ", + RGBA.defaultForeground(), + tint(running, this._completionColor, completionOpacity * 0.18), + ) + } + } +} + +declare module "@opentui/solid" { + interface OpenTUIComponents { + tab_pulse: typeof TabPulseRenderable + } +} + +extend({ tab_pulse: TabPulseRenderable }) + +export function TabPulse(props: { + enabled?: boolean + active: boolean + complete?: boolean + color: RGBA + completionColor?: RGBA + backgroundColor: RGBA +}) { + return ( + + ) +} diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 70e2ed3d304..f924a2caad2 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -120,6 +120,9 @@ export const Info = Schema.Struct({ markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({ description: "Show Markdown syntax markers or conceal them in rendered transcript content", }), + tabs: Schema.optional(Schema.Boolean).annotate({ + description: "Use a persistent session tab strip instead of pinned quick-switch sessions", + }), }), ).annotate({ description: "Session transcript presentation settings" }), mini: Schema.optional( diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index 8ad014488e0..ab5f8ac4083 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -87,6 +87,11 @@ export const Definitions = { session_move: keybind("none", "Move session"), session_new: keybind("n", "Create a new session"), session_list: keybind("l", "List all sessions"), + session_tab_next: keybind("ctrl+tab,right", "Switch to next open session tab"), + session_tab_previous: keybind("ctrl+shift+tab,left", "Switch to previous open session tab"), + session_tab_next_unread: keybind("down", "Switch to next unread session tab"), + session_tab_previous_unread: keybind("up", "Switch to previous unread session tab"), + session_tab_close: keybind("w", "Close current session tab"), session_timeline: keybind("g", "Show session timeline"), session_fork: keybind("none", "Fork session from message"), session_rename: keybind("ctrl+r", "Rename session"), @@ -97,7 +102,7 @@ export const Definitions = { session_background: keybind("ctrl+b", "Background blocking session tools"), session_compact: keybind("c", "Compact the session"), session_queued_prompts: keybind("q", "View pending work"), - session_child_first: keybind("down,down", "Toggle subagent picker"), + session_child_first: keybind("down", "Toggle subagent picker"), session_child_cycle: keybind("right", "Go to next child session"), session_child_cycle_reverse: keybind("left", "Go to previous child session"), session_parent: keybind("up", "Go to parent session"), @@ -111,6 +116,15 @@ export const Definitions = { session_quick_switch_7: keybind("7", "Switch to session in quick slot 7"), session_quick_switch_8: keybind("8", "Switch to session in quick slot 8"), session_quick_switch_9: keybind("9", "Switch to session in quick slot 9"), + session_tab_select_1: keybind("1,ctrl+1", "Switch to session tab 1"), + session_tab_select_2: keybind("2,ctrl+2", "Switch to session tab 2"), + session_tab_select_3: keybind("3,ctrl+3", "Switch to session tab 3"), + session_tab_select_4: keybind("4,ctrl+4", "Switch to session tab 4"), + session_tab_select_5: keybind("5,ctrl+5", "Switch to session tab 5"), + session_tab_select_6: keybind("6,ctrl+6", "Switch to session tab 6"), + session_tab_select_7: keybind("7,ctrl+7", "Switch to session tab 7"), + session_tab_select_8: keybind("8,ctrl+8", "Switch to session tab 8"), + session_tab_select_9: keybind("9,ctrl+9", "Switch to session tab 9"), stash_delete: keybind("ctrl+d", "Delete stash entry"), model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"), @@ -289,6 +303,11 @@ export const CommandMap = { session_move: "session.move", session_new: "session.new", session_list: "session.list", + session_tab_next: "session.tab.next", + session_tab_previous: "session.tab.previous", + session_tab_next_unread: "session.tab.next_unread", + session_tab_previous_unread: "session.tab.previous_unread", + session_tab_close: "session.tab.close", session_timeline: "session.timeline", session_fork: "session.fork", session_rename: "session.rename", @@ -313,6 +332,15 @@ export const CommandMap = { session_quick_switch_7: "session.quick_switch.7", session_quick_switch_8: "session.quick_switch.8", session_quick_switch_9: "session.quick_switch.9", + session_tab_select_1: "session.tab.select.1", + session_tab_select_2: "session.tab.select.2", + session_tab_select_3: "session.tab.select.3", + session_tab_select_4: "session.tab.select.4", + session_tab_select_5: "session.tab.select.5", + session_tab_select_6: "session.tab.select.6", + session_tab_select_7: "session.tab.select.7", + session_tab_select_8: "session.tab.select.8", + session_tab_select_9: "session.tab.select.9", stash_delete: "stash.delete", model_provider_list: "model.dialog.provider", model_favorite_toggle: "model.dialog.favorite", diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts new file mode 100644 index 00000000000..eceecc4f819 --- /dev/null +++ b/packages/tui/src/context/session-tabs-model.ts @@ -0,0 +1,108 @@ +export type SessionTab = { + sessionID: string + title?: string +} + +export type SessionTabUnread = "activity" | "error" + +export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: boolean) { + return unread === "activity" && !busy +} + +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 + +export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] { + const index = tabs.findIndex((item) => item.sessionID === tab.sessionID) + if (index === -1) return [...tabs, tab] + if (!tab.title || tabs[index]?.title === tab.title) return tabs + return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item)) +} + +export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) { + const index = tabs.findIndex((tab) => tab.sessionID === sessionID) + if (index === -1) return { tabs: [...tabs], next: undefined } + return { + tabs: tabs.filter((tab) => tab.sessionID !== sessionID), + next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID, + } +} + +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) + const start = index === -1 ? (direction === 1 ? -1 : 0) : index + return tabs[(start + direction + tabs.length) % tabs.length] +} + +export function adaptiveSessionTabLayout( + tabs: readonly SessionTab[], + active: string | undefined, + available: number, + previousStart = 0, +) { + if (tabs.length === 0) return { tabs: [], widths: [], before: 0, after: 0, start: 0, total: 0 } + + const activeIndex = Math.max( + 0, + tabs.findIndex((tab) => tab.sessionID === active), + ) + const fit = (width: number) => + Math.min(tabs.length, Math.max(1, 1 + Math.floor((Math.max(0, width) - SESSION_TAB_WIDTH) / SESSION_TAB_MIN_WIDTH))) + const solve = (count: number, start: number, attempts: number): { count: number; start: number } => { + const nextStart = Math.min( + Math.max(0, activeIndex < start ? activeIndex : activeIndex >= start + count ? activeIndex - count + 1 : start), + tabs.length - count, + ) + const markers = + (nextStart > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) + + (nextStart + count < tabs.length ? SESSION_TAB_OVERFLOW_WIDTH : 0) + const nextCount = fit(available - markers) + if (nextCount === count || attempts === 0) return { count, start: nextStart } + return solve(nextCount, nextStart, attempts - 1) + } + const solved = solve(fit(available), previousStart, 3) + const visible = tabs.slice(solved.start, solved.start + solved.count) + const before = solved.start + 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), + ) + const roomy = contentWidth >= SESSION_TAB_WIDTH * visible.length + const total = roomy ? Math.min(contentWidth, SESSION_TAB_MAX_WIDTH * visible.length) : contentWidth + if (roomy) { + const width = Math.floor(total / visible.length) + const remainder = total - width * visible.length + return { + tabs: visible, + widths: visible.map((_, index) => width + Number(index < remainder)), + before, + after, + start: solved.start, + total, + } + } + const inactiveWidth = + visible.length === 1 + ? 0 + : Math.min( + SESSION_TAB_WIDTH, + Math.max( + SESSION_TAB_MIN_WIDTH, + Math.floor((total - Math.min(SESSION_TAB_WIDTH, total)) / (visible.length - 1)), + ), + ) + const activeWidth = visible.length === 1 ? total : total - inactiveWidth * (visible.length - 1) + + return { + tabs: visible, + widths: visible.map((tab) => (tab.sessionID === active ? activeWidth : inactiveWidth)), + before, + after, + start: solved.start, + total, + } +} diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx new file mode 100644 index 00000000000..73d892d0330 --- /dev/null +++ b/packages/tui/src/context/session-tabs.tsx @@ -0,0 +1,251 @@ +import { batch, createEffect, onCleanup, untrack } from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import path from "path" +import { isDeepEqual } from "remeda" +import { createSimpleContext } from "./helper" +import { useData } from "./data" +import { useEvent } from "./event" +import { useRoute } from "./route" +import { useTuiPaths } from "./runtime" +import { useConfig } from "../config" +import { readJson, writeJsonAtomic } from "../util/persistence" +import { isRecord } from "../util/record" +import { + closeSessionTab, + cycleSessionTab, + openSessionTab, + type SessionTab, + type SessionTabUnread, +} from "./session-tabs-model" + +type PersistedState = { + tabs: SessionTab[] + unread: Record +} + +export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({ + name: "SessionTabs", + init: () => { + const route = useRoute() + const data = useData() + const event = useEvent() + const config = useConfig().data + const filePath = path.join(useTuiPaths().state, "session-tabs.json") + const enabled = () => config.session?.tabs ?? false + const state: { + pending: boolean + saving: boolean + snapshot: string + value?: PersistedState + } = { pending: false, saving: false, snapshot: "" } + const [store, setStore] = createStore({ + ready: false, + tabs: [], + unread: {}, + }) + + const root = (sessionID: string) => data.session.root(sessionID) + const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined) + const status = (sessionID: string) => { + const session = root(sessionID) + const members = data.session.family(session) + const family = members.length > 0 ? members : [session] + return { + unread: store.unread[session], + attention: family.some( + (id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0, + ), + busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0), + } + } + + function save() { + if (!store.ready) { + state.pending = true + return + } + const value = { tabs: [...store.tabs], unread: { ...store.unread } } + const snapshot = JSON.stringify(value) + if (snapshot === state.snapshot && !state.saving) return + state.value = value + state.pending = true + flush() + } + + function flush() { + if (state.saving || !state.pending || !state.value) return + const value = state.value + const snapshot = JSON.stringify(value) + state.pending = false + if (snapshot === state.snapshot) return + state.saving = true + void writeJsonAtomic(filePath, value) + .then(() => { + state.snapshot = snapshot + }) + .catch(() => {}) + .finally(() => { + state.saving = false + flush() + }) + } + + function open(sessionID: string) { + const session = root(sessionID) + const next = openSessionTab(store.tabs, { sessionID: session, title: data.session.get(session)?.title }) + if (next === store.tabs) return { sessionID: session, changed: false } + setStore("tabs", reconcile(next)) + return { sessionID: session, changed: true } + } + + function clearUnread(sessionID: string) { + const session = root(sessionID) + if (!store.unread[session]) return false + setStore( + "unread", + produce((draft) => { + delete draft[session] + }), + ) + return true + } + + function markUnread(sessionID: string, unread: SessionTabUnread) { + if (!enabled()) return + const session = root(sessionID) + if (current() === session || !store.tabs.some((tab) => tab.sessionID === session)) return + if (store.unread[session] === unread) return + setStore("unread", session, unread) + save() + } + + readJson(filePath) + .then((value) => { + if (!isRecord(value)) return + const persisted = value + if (Array.isArray(persisted.tabs)) + setStore( + "tabs", + persisted.tabs.flatMap((tab) => { + if (!isRecord(tab) || typeof tab.sessionID !== "string") return [] + if ("title" in tab && tab.title !== undefined && typeof tab.title !== "string") return [] + return [{ sessionID: tab.sessionID, title: typeof tab.title === "string" ? tab.title : undefined }] + }), + ) + if (persisted.unread && typeof persisted.unread === "object") + setStore( + "unread", + Object.fromEntries( + Object.entries(persisted.unread).filter( + (entry): entry is [string, SessionTabUnread] => entry[1] === "activity" || entry[1] === "error", + ), + ), + ) + }) + .catch(() => {}) + .finally(() => { + setStore("ready", true) + if (state.pending) save() + else state.snapshot = JSON.stringify({ tabs: store.tabs, unread: store.unread }) + }) + + createEffect(() => { + if (!enabled()) return + if (!store.ready || route.data.type !== "session" || route.data.sessionID === "dummy") return + const routeSessionID = route.data.sessionID + batch(() => { + const opened = open(routeSessionID) + const changed = clearUnread(opened.sessionID) + if (opened.changed || changed) untrack(save) + }) + }) + + createEffect(() => { + if (!enabled() || !store.ready) return + const next = store.tabs.reduce((tabs, tab) => { + const sessionID = root(tab.sessionID) + return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title }) + }, []) + const unread = Object.entries(store.unread).reduce>((result, entry) => { + const sessionID = root(entry[0]) + result[sessionID] = result[sessionID] === "error" ? "error" : entry[1] + return result + }, {}) + if (isDeepEqual(next, store.tabs) && isDeepEqual(unread, store.unread)) return + batch(() => { + setStore("tabs", reconcile(next)) + setStore("unread", reconcile(unread)) + }) + save() + }) + + onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity"))) + onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity"))) + onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error"))) + onCleanup( + event.on("session.error", (evt) => { + if (evt.data.sessionID) markUnread(evt.data.sessionID, "error") + }), + ) + onCleanup( + event.on("session.deleted", (evt) => { + remove(evt.data.sessionID, enabled()) + }), + ) + + function remove(sessionID: string, navigate: boolean) { + const target = root(sessionID) + const closed = closeSessionTab(store.tabs, target) + if (closed.tabs.length === store.tabs.length) return + batch(() => { + setStore("tabs", reconcile(closed.tabs)) + clearUnread(target) + if (navigate && current() === target) + route.navigate(closed.next ? { type: "session", sessionID: closed.next } : { type: "home" }) + }) + save() + } + + return { + enabled, + tabs() { + return store.tabs + }, + current, + status, + select(sessionID: string) { + if (!enabled()) return + route.navigate({ type: "session", sessionID: root(sessionID) }) + }, + close(sessionID?: string) { + if (!enabled()) return + const target = sessionID ? root(sessionID) : current() + if (!target) { + const previous = store.tabs.at(-1) + if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID }) + return + } + remove(target, true) + }, + cycle(direction: 1 | -1) { + if (!enabled()) return + const tab = cycleSessionTab(store.tabs, current(), direction) + if (tab) route.navigate({ type: "session", sessionID: tab.sessionID }) + }, + cycleUnread(direction: 1 | -1) { + if (!enabled()) return + const tab = cycleSessionTab( + store.tabs.filter((tab) => store.unread[tab.sessionID] || status(tab.sessionID).attention), + current(), + direction, + ) + if (tab) route.navigate({ type: "session", sessionID: tab.sessionID }) + }, + selectIndex(index: number) { + if (!enabled()) return + const tab = store.tabs[index] + if (tab) route.navigate({ type: "session", sessionID: tab.sessionID }) + }, + } + }, +}) diff --git a/packages/tui/src/routes/session/composer/index.tsx b/packages/tui/src/routes/session/composer/index.tsx index 3bc3b96d33c..d29e206be4b 100644 --- a/packages/tui/src/routes/session/composer/index.tsx +++ b/packages/tui/src/routes/session/composer/index.tsx @@ -98,12 +98,6 @@ export function Composer(props: ComposerProps) { { bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) }, { bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) }, { bind: "escape", title: "Close composer", group: "Composer", run: close }, - { - bind: "down", - title: "Toggle composer", - group: "Composer", - run: close, - }, ], })) diff --git a/packages/tui/src/ui/animation.ts b/packages/tui/src/ui/animation.ts new file mode 100644 index 00000000000..82c92be1dc0 --- /dev/null +++ b/packages/tui/src/ui/animation.ts @@ -0,0 +1,235 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" + +type AnimatableValue = number | readonly number[] +type AnimatableTarget = Record +type Ease = (progress: number) => number + +type SpringTransition = { + type: "spring" + visualDuration: number + restDelta: number + restSpeed: number +} + +type TweenTransition = { + type: "tween" + duration: number + ease: Ease +} + +type Transition = SpringTransition | TweenTransition + +type ValueState = { + scalar: boolean + value: number[] + target: number[] + velocity: number[] + from: number[] +} + +type AnimationTask = (now: number) => boolean + +const tasks = new Set() +let timer: ReturnType | undefined + +const smoothstep = (progress: number) => progress * progress * (3 - 2 * progress) + +export function spring(options: { visualDuration: number; restDelta?: number; restSpeed?: number }): Transition { + return { + type: "spring", + visualDuration: options.visualDuration, + restDelta: options.restDelta ?? 0.002, + restSpeed: options.restSpeed ?? 0.002, + } +} + +export function tween(options: { duration: number; ease?: Ease }): Transition { + return { + type: "tween", + duration: options.duration, + ease: options.ease ?? smoothstep, + } +} + +export function createAnimatable( + initial: T, + options: { + transition: Transition + enabled?: Accessor + }, +) { + const enabled = options.enabled ?? (() => true) + const state = new Map() + const [value, setValue] = createSignal(clone(initial), { equals: false }) + let target = clone(initial) + let started = performance.now() + let previous = started + + rebuild(initial) + + const step: AnimationTask = (now) => { + if (!enabled()) { + jump(target) + return false + } + + const delta = Math.min(0.05, (now - previous) / 1_000) + previous = now + const moving = options.transition.type === "spring" ? advanceSpring(delta) : advanceTween(now) + if (!moving) { + jump(target) + return false + } + setValue(() => read()) + return true + } + + function animate(next: T) { + if (sameTarget(next)) return + target = clone(next) + if (!enabled() || !sameShape(next)) return jump(next) + + started = performance.now() + previous = started + for (const [key, current] of state) { + current.from = [...current.value] + current.target = values(next[key]!) + } + if (settled()) return jump(next) + schedule(step) + } + + function jump(next: T) { + target = clone(next) + unschedule(step) + rebuild(next) + setValue(() => clone(next)) + } + + function stop() { + unschedule(step) + } + + function rebuild(next: T) { + state.clear() + for (const [key, nextValue] of Object.entries(next)) { + const value = values(nextValue) + state.set(key, { + scalar: typeof nextValue === "number", + value, + target: [...value], + velocity: value.map(() => 0), + from: [...value], + }) + } + started = performance.now() + previous = started + } + + function sameShape(next: T) { + const entries = Object.entries(next) + if (entries.length !== state.size) return false + return entries.every(([key, nextValue]) => { + const current = state.get(key) + if (!current || current.scalar !== (typeof nextValue === "number")) return false + return current.value.length === values(nextValue).length + }) + } + + function sameTarget(next: T) { + if (!sameShape(next)) return false + return Object.entries(next).every(([key, value]) => + values(value).every((part, index) => part === state.get(key)!.target[index]), + ) + } + + function settled() { + return [...state.values()].every( + (current) => + current.value.every((value, index) => value === current.target[index]) && + current.velocity.every((velocity) => velocity === 0), + ) + } + + function advanceSpring(delta: number) { + const transition = options.transition + if (transition.type !== "spring") return false + const frequency = (2 * Math.PI) / (Math.max(0.001, transition.visualDuration) * 1.2) + let moving = false + + for (const current of state.values()) { + current.value.forEach((value, index) => { + const target = current.target[index]! + const velocity = current.velocity[index]! + const offset = value - target + const decay = Math.exp(-frequency * delta) + const nextVelocity = velocity + frequency * offset + current.value[index] = target + (offset + nextVelocity * delta) * decay + current.velocity[index] = (velocity - frequency * nextVelocity * delta) * decay + moving ||= + Math.abs(current.value[index]! - target) > transition.restDelta || + Math.abs(current.velocity[index]!) > transition.restSpeed + }) + } + return moving + } + + function advanceTween(now: number) { + const transition = options.transition + if (transition.type !== "tween") return false + const progress = Math.min(1, (now - started) / 1_000 / Math.max(0.001, transition.duration)) + const eased = transition.ease(progress) + for (const current of state.values()) + current.value.forEach((_, index) => { + const from = current.from[index]! + current.value[index] = from + (current.target[index]! - from) * eased + current.velocity[index] = 0 + }) + return progress < 1 + } + + function read() { + return Object.fromEntries( + [...state].map(([key, current]) => [key, current.scalar ? current.value[0]! : [...current.value]]), + ) as T + } + + createEffect(() => { + if (enabled()) return + jump(target) + }) + onCleanup(stop) + + return { value, animate, jump } +} + +function values(value: AnimatableValue) { + return typeof value === "number" ? [value] : [...value] +} + +function clone(target: T) { + return Object.fromEntries( + Object.entries(target).map(([key, value]) => [key, typeof value === "number" ? value : [...value]]), + ) as T +} + +function schedule(task: AnimationTask) { + tasks.add(task) + if (timer) return + timer = setInterval(tick, 16) +} + +function unschedule(task: AnimationTask) { + tasks.delete(task) + if (tasks.size > 0 || !timer) return + clearInterval(timer) + timer = undefined +} + +function tick() { + const now = performance.now() + for (const task of tasks) if (!task(now)) tasks.delete(task) + if (tasks.size > 0 || !timer) return + clearInterval(timer) + timer = undefined +} diff --git a/packages/tui/src/util/locale.ts b/packages/tui/src/util/locale.ts index 06d0b450770..6f4bdbe50ac 100644 --- a/packages/tui/src/util/locale.ts +++ b/packages/tui/src/util/locale.ts @@ -67,20 +67,30 @@ export function truncate(str: string, len: number): string { const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) +export function graphemes(str: string) { + return Array.from(graphemeSegmenter.segment(str), (item) => item.segment) +} + +export function takeWidth(str: string, width: number) { + if (width <= 0) return "" + if (stringWidth(str) <= width) return str + + const result: string[] = [] + let used = 0 + for (const segment of graphemes(str)) { + const next = stringWidth(segment) + if (used + next > width) break + result.push(segment) + used += next + } + return result.join("") +} + export function truncateWidth(str: string, width: number): string { if (width <= 0) return "" if (stringWidth(str) <= width) return str if (width === 1) return "…" - - const result: string[] = [] - let used = 0 - for (const item of graphemeSegmenter.segment(str)) { - const next = stringWidth(item.segment) - if (used + next > width - 1) break - result.push(item.segment) - used += next - } - return result.join("") + "…" + return takeWidth(str, width - 1) + "…" } export function truncateLeft(str: string, len: number): string { diff --git a/packages/tui/src/util/signal.ts b/packages/tui/src/util/signal.ts index e28c680cd41..0baf4f302c6 100644 --- a/packages/tui/src/util/signal.ts +++ b/packages/tui/src/util/signal.ts @@ -1,4 +1,5 @@ import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js" +import { createAnimatable, tween } from "../ui/animation" export function createDebouncedSignal(value: T, ms: number): [Accessor, (value: T) => void] { const [get, set] = createSignal(value) @@ -17,35 +18,32 @@ export function createDebouncedSignal(value: T, ms: number): [Accessor, (v } export function createFadeIn(show: Accessor, enabled: Accessor) { - const [alpha, setAlpha] = createSignal(show() ? 1 : 0) + const alpha = createAnimatable( + { value: show() ? 1 : 0 }, + { + enabled, + transition: tween({ duration: 0.16 }), + }, + ) let revealed = show() createEffect( on([show, enabled], ([visible, animate]) => { if (!visible) { - setAlpha(0) + alpha.jump({ value: 0 }) return } if (!animate || revealed) { revealed = true - setAlpha(1) + alpha.jump({ value: 1 }) return } - const start = performance.now() revealed = true - setAlpha(0) - - const timer = setInterval(() => { - const progress = Math.min((performance.now() - start) / 160, 1) - setAlpha(progress * progress * (3 - 2 * progress)) - if (progress >= 1) clearInterval(timer) - }, 16) - - onCleanup(() => clearInterval(timer)) + alpha.animate({ value: 1 }) }), ) - return alpha + return () => alpha.value().value } diff --git a/packages/tui/test/component/tab-pulse.test.ts b/packages/tui/test/component/tab-pulse.test.ts new file mode 100644 index 00000000000..0082dc66d20 --- /dev/null +++ b/packages/tui/test/component/tab-pulse.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test" +import { completionPulseOpacity } from "../../src/component/tab-pulse" + +test("completion pulse rises quickly and fades over the remaining duration", () => { + expect(completionPulseOpacity(0)).toBe(0) + expect(completionPulseOpacity(0.08)).toBeCloseTo(0.5) + expect(completionPulseOpacity(0.16)).toBe(1) + expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5) + expect(completionPulseOpacity(1)).toBe(0) +}) diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index c349ea52243..4438bbe5a89 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -2,13 +2,7 @@ import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" import { Schema } from "effect" -import { - resolve, - ConfigProvider, - Info, - useConfig, - type Interface, -} from "../src/config" +import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config" test("validates mini replay settings", () => { const decode = Schema.decodeUnknownSync(Info) @@ -20,6 +14,13 @@ test("validates mini replay settings", () => { expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow() }) +test("validates the session tabs setting", () => { + const decode = Schema.decodeUnknownSync(Info) + + expect(decode({ session: { tabs: true } })).toEqual({ session: { tabs: true } }) + expect(() => decode({ session: { tabs: "on" } })).toThrow() +}) + test("resolves nested config and keybind defaults", () => { const config = resolve( { diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index d6b86458ba5..90c63871c84 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -100,7 +100,24 @@ test("resolves message navigation defaults", () => { test("opens the subagent picker with down", () => { const config = resolve({}, { terminalSuspend: true }) - expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down,down" }]) + expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down" }]) +}) + +test("navigates session tabs with leader arrows", () => { + const config = resolve({}, { terminalSuspend: true }) + + expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,right" }]) + expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,left" }]) + expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "down" }]) + expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "up" }]) +}) + +test("preserves pinned session bindings alongside tab bindings", () => { + const config = resolve({}, { terminalSuspend: true }) + + expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }]) + expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "1" }]) + expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "1,ctrl+1" }]) }) test("disables suspend and assigns ctrl+z to undo when unsupported", () => { diff --git a/packages/tui/test/context/session-tabs-model.test.ts b/packages/tui/test/context/session-tabs-model.test.ts new file mode 100644 index 00000000000..ce4234c5183 --- /dev/null +++ b/packages/tui/test/context/session-tabs-model.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test" +import { + adaptiveSessionTabLayout, + closeSessionTab, + cycleSessionTab, + openSessionTab, + sessionTabComplete, +} from "../../src/context/session-tabs-model" + +describe("session tabs", () => { + 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" }]) + expect(openSessionTab(tabs, { sessionID: "b" })).toEqual([{ sessionID: "a", title: "New" }, { sessionID: "b" }]) + expect(openSessionTab(tabs, { sessionID: "a", title: "New" })).toBe(tabs) + }) + + test("selects the right tab then the left tab after closing", () => { + expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }, { sessionID: "c" }], "b")).toEqual({ + tabs: [{ sessionID: "a" }, { sessionID: "c" }], + next: "c", + }) + expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }], "b").next).toBe("a") + expect(closeSessionTab([{ sessionID: "a" }], "a").next).toBeUndefined() + }) + + test("cycles through a filtered tab set in either direction", () => { + const tabs = ["a", "c", "e"].map((sessionID) => ({ sessionID })) + expect(cycleSessionTab(tabs, "c", 1)?.sessionID).toBe("e") + expect(cycleSessionTab(tabs, "c", -1)?.sessionID).toBe("a") + expect(cycleSessionTab(tabs, "e", 1)?.sessionID).toBe("a") + expect(cycleSessionTab(tabs, "b", 1)?.sessionID).toBe("a") + }) + + test("reveals completion activity only after session work becomes idle", () => { + expect(sessionTabComplete("activity", true)).toBe(false) + expect(sessionTabComplete("activity", false)).toBe(true) + expect(sessionTabComplete("error", false)).toBe(false) + }) + + test("expands the active tab and keeps inactive widths equal", () => { + const tabs = ["a", "b", "c", "d", "e", "f", "g"].map((sessionID) => ({ sessionID })) + const layout = adaptiveSessionTabLayout(tabs, "d", 76) + + expect(layout).toMatchObject({ before: 0, after: 0, start: 0, total: 76 }) + expect(layout.widths).toEqual([9, 9, 9, 22, 9, 9, 9]) + expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(76) + }) + + test("only swaps old and new active width inside a sticky window", () => { + const tabs = ["a", "b", "c", "d", "e", "f", "g"].map((sessionID) => ({ sessionID })) + const before = adaptiveSessionTabLayout(tabs, "c", 76) + const after = adaptiveSessionTabLayout(tabs, "d", 76, before.start) + + expect(before.start).toBe(after.start) + expect(before.widths).toEqual([9, 9, 22, 9, 9, 9, 9]) + expect(after.widths).toEqual([9, 9, 9, 22, 9, 9, 9]) + }) + + test("shares roomy width equally without changing widths on selection", () => { + const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID })) + const before = adaptiveSessionTabLayout(tabs, "a", 100) + const after = adaptiveSessionTabLayout(tabs, "c", 100, before.start) + + expect(before.widths).toEqual([32, 32, 32]) + expect(after.widths).toEqual(before.widths) + expect(before.total).toBe(96) + }) + + test("caps a single tab instead of stretching it across the terminal", () => { + const layout = adaptiveSessionTabLayout([{ sessionID: "a" }], "a", 100) + + expect(layout.widths).toEqual([32]) + expect(layout.total).toBe(32) + }) + + test("fills roomy space equally below the maximum width", () => { + const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID })) + + expect(adaptiveSessionTabLayout(tabs, "b", 100).widths).toEqual([25, 25, 25, 25]) + }) + + test("expands only the active tab under compact pressure", () => { + const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID })) + const before = adaptiveSessionTabLayout(tabs, "c", 100) + const after = adaptiveSessionTabLayout(tabs, "d", 100, before.start) + + expect(before.widths).toEqual([19, 19, 24, 19, 19]) + expect(after.widths).toEqual([19, 19, 19, 24, 19]) + }) + + test("moves the window only after selection crosses its edge", () => { + const tabs = Array.from({ length: 10 }, (_, index) => ({ sessionID: String(index) })) + const initial = adaptiveSessionTabLayout(tabs, "3", 70) + const inside = adaptiveSessionTabLayout(tabs, "4", 70, initial.start) + const crossed = adaptiveSessionTabLayout(tabs, "7", 70, inside.start) + + expect(initial.start).toBe(0) + expect(inside.start).toBe(0) + expect(crossed.start).toBeGreaterThan(0) + expect(crossed.tabs.some((tab) => tab.sessionID === "7")).toBe(true) + expect(crossed.widths.reduce((total, width) => total + width, 0)).toBe(crossed.total) + }) +}) diff --git a/packages/tui/test/ui/animation.test.ts b/packages/tui/test/ui/animation.test.ts new file mode 100644 index 00000000000..44e59c5be5b --- /dev/null +++ b/packages/tui/test/ui/animation.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createAnimatable, spring, tween } from "../../src/ui/animation" + +test("animates numeric objects and arrays to their targets", async () => { + let dispose = () => {} + const visual = createRoot((nextDispose) => { + dispose = nextDispose + return createAnimatable( + { widths: [8, 8], selection: 0 }, + { transition: tween({ duration: 0.02, ease: (progress) => progress }) }, + ) + }) + + try { + visual.animate({ widths: [12, 4], selection: 1 }) + await Bun.sleep(80) + expect(visual.value()).toEqual({ widths: [12, 4], selection: 1 }) + } finally { + dispose() + } +}) + +test("retains spring state while retargeting and supports immediate jumps", async () => { + let dispose = () => {} + const visual = createRoot((nextDispose) => { + dispose = nextDispose + return createAnimatable({ value: 0 }, { transition: spring({ visualDuration: 0.02 }) }) + }) + + try { + visual.animate({ value: 1 }) + await Bun.sleep(20) + const target = visual.value().value + visual.animate({ value: target }) + await Bun.sleep(20) + expect(visual.value().value).not.toBe(target) + await Bun.sleep(80) + expect(visual.value().value).toBeCloseTo(target) + visual.jump({ value: 0 }) + expect(visual.value().value).toBe(0) + } finally { + dispose() + } +}) diff --git a/packages/tui/test/util/locale.test.ts b/packages/tui/test/util/locale.test.ts index 0da9dac7b3a..e3d3396884b 100644 --- a/packages/tui/test/util/locale.test.ts +++ b/packages/tui/test/util/locale.test.ts @@ -7,3 +7,8 @@ test("truncates text from the right by terminal width", () => { expect(Locale.truncateWidth("abcdefgh", 1)).toBe("…") expect(Locale.truncateWidth("abcdefgh", 0)).toBe("") }) + +test("takes whole graphemes within terminal width", () => { + expect(Locale.takeWidth("ab界cd", 4)).toBe("ab界") + expect(Locale.graphemes("a👨‍👩‍👧‍👦b")).toEqual(["a", "👨‍👩‍👧‍👦", "b"]) +})