diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 73b2ee9652..45ed8c6407 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -4,19 +4,33 @@ 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 { + adaptiveSessionTabLayout, + sessionTabComplete, + SESSION_TAB_OVERFLOW_WIDTH, + type SessionTabUnread, +} from "../context/session-tabs-model" import { createAnimatable, spring } from "../ui/animation" import { Locale } from "../util/locale" import { stringWidth } from "../util/string-width" import { TabPulse } from "./tab-pulse" import { tint } from "../theme/color" -export function SessionTabs() { - const tabs = useSessionTabs() +type ContextController = ReturnType +export type SessionTabsStatus = Omit, "unread"> & { + unread: SessionTabUnread | undefined +} +export type SessionTabsController = Pick & { + status(sessionID: string): SessionTabsStatus +} + +export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) { + const tabs = props.controller ?? useSessionTabs() const dimensions = useTerminalDimensions() const theme = useTheme() const { mode } = useThemes() const config = useConfig().data + const animations = () => props.animations ?? config.animations ?? true const [hovered, setHovered] = createSignal() const hueStep = () => (mode() === "light" ? 800 : 200) const accent = () => theme.hue.accent[hueStep()] @@ -48,7 +62,7 @@ export function SessionTabs() { activities: layout().tabs.map((tab) => Number(statuses().get(tab.sessionID)!.complete)), })) const motion = createAnimatable(targets(), { - enabled: () => config.animations ?? true, + enabled: animations, transition: spring({ visualDuration: 0.1 }), }) const identity = createMemo(() => @@ -167,7 +181,7 @@ export function SessionTabs() { onMouseUp={() => tabs.select(tab.sessionID)} > + +const FIXTURE_TABS = [ + { sessionID: "fixture-1", title: "Implement session tabs" }, + { sessionID: "fixture-2", title: "Investigate rendering" }, + { sessionID: "fixture-3", title: "A deliberately long session title for truncation" }, + { sessionID: "fixture-4", title: "Fix provider state" }, + { sessionID: "fixture-5", title: "Review animation" }, + { sessionID: "fixture-6", title: "Untitled behavior" }, + { sessionID: "fixture-7", title: "Queue follow-up work" }, + { sessionID: "fixture-8", title: "Check narrow layout" }, + { sessionID: "fixture-9", title: "Profile terminal output" }, + { sessionID: "fixture-10", title: "Handle permission" }, + { sessionID: "fixture-11", title: "Run focused tests" }, + { sessionID: "fixture-12", title: "Prepare review" }, +] + +const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false } function Commands(props: { context: Plugin.Context }) { props.context.keymap.layer(() => ({ @@ -24,6 +45,50 @@ function Scrap(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() const theme = props.context.theme const elevatedTheme = props.context.theme.contextual("elevated") + const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6)) + const [active, setActive] = createSignal("fixture-2") + const [animations, setAnimations] = createSignal(true) + const [statuses, setStatuses] = createSignal>({ + "fixture-2": { ...EMPTY_STATUS, busy: true }, + "fixture-3": { ...EMPTY_STATUS, unread: "activity" }, + "fixture-4": { ...EMPTY_STATUS, unread: "error" }, + "fixture-5": { ...EMPTY_STATUS, attention: true }, + "fixture-6": { ...EMPTY_STATUS, busy: true, attention: true }, + }) + const controller = { + tabs, + current: active, + status(sessionID) { + return statuses()[sessionID] ?? EMPTY_STATUS + }, + select(sessionID) { + setActive(sessionID) + }, + close(sessionID?: string) { + const target = sessionID ?? active() + if (!target) return + const items = tabs() + const index = items.findIndex((tab) => tab.sessionID === target) + if (index === -1) return + const next = items.filter((tab) => tab.sessionID !== target) + batch(() => { + setTabs(next) + if (active() === target) setActive(next[index]?.sessionID ?? next[index - 1]?.sessionID) + }) + }, + } satisfies SessionTabsController + + const cycle = (direction: 1 | -1) => { + 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) + } + const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => { + const sessionID = active() + if (!sessionID) return + setStatuses((current) => ({ ...current, [sessionID]: update(current[sessionID] ?? EMPTY_STATUS) })) + } props.context.keymap.layer(() => ({ commands: [ @@ -35,12 +100,60 @@ function Scrap(props: { context: Plugin.Context }) { props.context.ui.router.navigate({ type: "home" }) }, }, + { bind: "h", title: "Previous tab", group: "Scrap", run: () => cycle(-1) }, + { bind: "l", title: "Next tab", group: "Scrap", run: () => cycle(1) }, + { + bind: "t", + title: "Add tab", + group: "Scrap", + run() { + const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID)) + if (next) setTabs((current) => [...current, next]) + }, + }, + { bind: "d", title: "Close tab", group: "Scrap", run: () => controller.close() }, + { + bind: "b", + title: "Toggle busy", + group: "Scrap", + run: () => + updateStatus((status) => + status.busy ? { ...status, busy: false, unread: "activity" } : { ...status, busy: true, unread: undefined }, + ), + }, + { + bind: "u", + title: "Cycle unread", + group: "Scrap", + run: () => + updateStatus((status) => ({ + ...status, + unread: status.unread === undefined ? "activity" : status.unread === "activity" ? "error" : undefined, + })), + }, + { + bind: "a", + title: "Toggle attention", + group: "Scrap", + run: () => updateStatus((status) => ({ ...status, attention: !status.attention })), + }, + { + bind: "m", + title: "Toggle motion", + group: "Scrap", + run: () => setAnimations((enabled) => !enabled), + }, ], })) return ( - - + + - ~/code/anomalyco/opencode + tab playground - esc home + + h/l select | t add | d close | b busy | u unread | a attention | m motion | esc home + + ) }