Browse Source

feat(tui): add adaptive session tabs (#39396)

Kit Langton 2 tuần trước cách đây
mục cha
commit
37a1b80d5a

+ 187 - 108
packages/tui/src/app.tsx

@@ -51,6 +51,7 @@ import { StartupLoading } from "./component/startup-loading"
 import { DevToolsBar } from "./component/devtools-bar"
 import { DevToolsBar } from "./component/devtools-bar"
 import { Reconnecting } from "./component/reconnecting"
 import { Reconnecting } from "./component/reconnecting"
 import { DataProvider, useData } from "./context/data"
 import { DataProvider, useData } from "./context/data"
+import { SessionTabsProvider, useSessionTabs } from "./context/session-tabs"
 import { LocationProvider, useLocation } from "./context/location"
 import { LocationProvider, useLocation } from "./context/location"
 import { LocalProvider, useLocal } from "./context/local"
 import { LocalProvider, useLocal } from "./context/local"
 import { PermissionProvider } from "./context/permission"
 import { PermissionProvider } from "./context/permission"
@@ -65,6 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
 import { DialogHelp } from "./ui/dialog-help"
 import { DialogHelp } from "./ui/dialog-help"
 import { DialogAgent } from "./component/dialog-agent"
 import { DialogAgent } from "./component/dialog-agent"
 import { DialogSessionList } from "./component/dialog-session-list"
 import { DialogSessionList } from "./component/dialog-session-list"
+import { SessionTabs } from "./component/session-tabs"
 import { ThemeErrorToast } from "./component/theme-error-toast"
 import { ThemeErrorToast } from "./component/theme-error-toast"
 import { ThemeProvider, useTheme, useThemes } from "./context/theme"
 import { ThemeProvider, useTheme, useThemes } from "./context/theme"
 import { Home } from "./routes/home"
 import { Home } from "./routes/home"
@@ -92,9 +94,26 @@ import { AttentionProvider } from "./context/attention"
 
 
 registerOpencodeSpinner()
 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.1",
   "session.quick_switch.2",
   "session.quick_switch.2",
   "session.quick_switch.3",
   "session.quick_switch.3",
@@ -277,113 +296,115 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                     <ErrorBoundary
                     <ErrorBoundary
                       fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
                       fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
                     >
                     >
-                    <TuiPathsProvider
-                      value={{
-                        cwd: process.cwd(),
-                        home: global.home,
-                        state: global.state,
-                        worktree: global.data + "/worktree",
-                      }}
-                    >
-                      <TuiLifecycleProvider
+                      <TuiPathsProvider
                         value={{
                         value={{
-                          add(finalizer) {
-                            finalizers.add(finalizer)
-                            return () => finalizers.delete(finalizer)
-                          },
+                          cwd: process.cwd(),
+                          home: global.home,
+                          state: global.state,
+                          worktree: global.data + "/worktree",
                         }}
                         }}
                       >
                       >
-                        <TuiTerminalEnvironmentProvider
+                        <TuiLifecycleProvider
                           value={{
                           value={{
-                            platform: process.platform,
-                            multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
-                            displayServer: process.env.WAYLAND_DISPLAY
-                              ? "wayland"
-                              : process.env.DISPLAY
-                                ? "x11"
-                                : undefined,
+                            add(finalizer) {
+                              finalizers.add(finalizer)
+                              return () => finalizers.delete(finalizer)
+                            },
                           }}
                           }}
                         >
                         >
-                          <TuiStartupProvider
+                          <TuiTerminalEnvironmentProvider
                             value={{
                             value={{
-                              initialRoute: process.env.OPENCODE_SCRAP
-                                ? { type: "plugin", id: "scrap", name: "scrap" }
-                                : process.env.OPENCODE_ROUTE
-                                  ? JSON.parse(process.env.OPENCODE_ROUTE)
+                              platform: process.platform,
+                              multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
+                              displayServer: process.env.WAYLAND_DISPLAY
+                                ? "wayland"
+                                : process.env.DISPLAY
+                                  ? "x11"
                                   : undefined,
                                   : undefined,
-                              skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
                             }}
                             }}
                           >
                           >
-                            <ClipboardProvider>
-                              <ArgsProvider {...input.args}>
-                                <ConfigProvider
-                                  config={config}
-                                  service={input.config}
-                                  options={{ terminalSuspend: process.platform !== "win32" }}
-                                >
-                                  <Keymap.Provider>
-                                    <ToastProvider>
-                                      <RouteProvider
-                                        initialRoute={
-                                          input.args.continue
-                                            ? {
-                                                type: "session",
-                                                sessionID: "dummy",
-                                              }
-                                            : undefined
-                                        }
-                                      >
-                                        <PluginRuntimeProvider value={pluginRuntime}>
-                                          <ClientProvider api={api} service={service}>
-                                            <PermissionProvider>
-                                              <DataProvider>
-                                                <LocationProvider>
-                                                  <ThemeProvider mode={mode}>
-                                                    <ThemeErrorToast />
-                                                    <LocalProvider>
-                                                      <PromptStashProvider>
-                                                        <DialogProvider>
-                                                          <FrecencyProvider>
-                                                            <PromptHistoryProvider>
-                                                              <PromptRefProvider>
-                                                                <EditorContextProvider>
-                                                                  <AttentionProvider>
-                                                                    <PluginProvider packages={input.packages}>
-                                                                      <App
-                                                                        pair={
-                                                                          input.server.endpoint.auth
-                                                                            ? input.server.endpoint.auth
-                                                                            : {
-                                                                                username: "opencode",
-                                                                                password: "",
-                                                                              }
-                                                                        }
-                                                                      />
-                                                                    </PluginProvider>
-                                                                  </AttentionProvider>
-                                                                </EditorContextProvider>
-                                                              </PromptRefProvider>
-                                                            </PromptHistoryProvider>
-                                                          </FrecencyProvider>
-                                                        </DialogProvider>
-                                                      </PromptStashProvider>
-                                                    </LocalProvider>
-                                                  </ThemeProvider>
-                                                </LocationProvider>
-                                              </DataProvider>
-                                            </PermissionProvider>
-                                          </ClientProvider>
-                                        </PluginRuntimeProvider>
-                                      </RouteProvider>
-                                    </ToastProvider>
-                                  </Keymap.Provider>
-                                </ConfigProvider>
-                              </ArgsProvider>
-                            </ClipboardProvider>
-                          </TuiStartupProvider>
-                        </TuiTerminalEnvironmentProvider>
-                      </TuiLifecycleProvider>
-                    </TuiPathsProvider>
+                            <TuiStartupProvider
+                              value={{
+                                initialRoute: process.env.OPENCODE_SCRAP
+                                  ? { type: "plugin", id: "scrap", name: "scrap" }
+                                  : process.env.OPENCODE_ROUTE
+                                    ? JSON.parse(process.env.OPENCODE_ROUTE)
+                                    : undefined,
+                                skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
+                              }}
+                            >
+                              <ClipboardProvider>
+                                <ArgsProvider {...input.args}>
+                                  <ConfigProvider
+                                    config={config}
+                                    service={input.config}
+                                    options={{ terminalSuspend: process.platform !== "win32" }}
+                                  >
+                                    <Keymap.Provider>
+                                      <ToastProvider>
+                                        <RouteProvider
+                                          initialRoute={
+                                            input.args.continue
+                                              ? {
+                                                  type: "session",
+                                                  sessionID: "dummy",
+                                                }
+                                              : undefined
+                                          }
+                                        >
+                                          <PluginRuntimeProvider value={pluginRuntime}>
+                                            <ClientProvider api={api} service={service}>
+                                              <PermissionProvider>
+                                                <DataProvider>
+                                                  <LocationProvider>
+                                                    <SessionTabsProvider>
+                                                      <ThemeProvider mode={mode}>
+                                                        <ThemeErrorToast />
+                                                        <LocalProvider>
+                                                          <PromptStashProvider>
+                                                            <DialogProvider>
+                                                              <FrecencyProvider>
+                                                                <PromptHistoryProvider>
+                                                                  <PromptRefProvider>
+                                                                    <EditorContextProvider>
+                                                                      <AttentionProvider>
+                                                                        <PluginProvider packages={input.packages}>
+                                                                          <App
+                                                                            pair={
+                                                                              input.server.endpoint.auth
+                                                                                ? input.server.endpoint.auth
+                                                                                : {
+                                                                                    username: "opencode",
+                                                                                    password: "",
+                                                                                  }
+                                                                            }
+                                                                          />
+                                                                        </PluginProvider>
+                                                                      </AttentionProvider>
+                                                                    </EditorContextProvider>
+                                                                  </PromptRefProvider>
+                                                                </PromptHistoryProvider>
+                                                              </FrecencyProvider>
+                                                            </DialogProvider>
+                                                          </PromptStashProvider>
+                                                        </LocalProvider>
+                                                      </ThemeProvider>
+                                                    </SessionTabsProvider>
+                                                  </LocationProvider>
+                                                </DataProvider>
+                                              </PermissionProvider>
+                                            </ClientProvider>
+                                          </PluginRuntimeProvider>
+                                        </RouteProvider>
+                                      </ToastProvider>
+                                    </Keymap.Provider>
+                                  </ConfigProvider>
+                                </ArgsProvider>
+                              </ClipboardProvider>
+                            </TuiStartupProvider>
+                          </TuiTerminalEnvironmentProvider>
+                        </TuiLifecycleProvider>
+                      </TuiPathsProvider>
                     </ErrorBoundary>
                     </ErrorBoundary>
                   </TuiAppProvider>
                   </TuiAppProvider>
                 </EpilogueProvider>
                 </EpilogueProvider>
@@ -419,6 +440,7 @@ function App(props: { pair?: DialogPairCredentials }) {
   const renderer = useRenderer()
   const renderer = useRenderer()
   const dialog = useDialog()
   const dialog = useDialog()
   const local = useLocal()
   const local = useLocal()
+  const sessionTabs = useSessionTabs()
   const keymap = Keymap.use()
   const keymap = Keymap.use()
   const event = useEvent()
   const event = useEvent()
   const client = useClient()
   const client = useClient()
@@ -623,9 +645,55 @@ function App(props: { pair?: DialogPairCredentials }) {
         title: `Switch to session in quick slot ${i + 1}`,
         title: `Switch to session in quick slot ${i + 1}`,
         category: "Session",
         category: "Session",
         palette: undefined,
         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",
         name: "model.list",
@@ -1004,6 +1072,18 @@ function App(props: { pair?: DialogPairCredentials }) {
     bindings: appGlobalBindingCommands,
     bindings: appGlobalBindingCommands,
   }))
   }))
 
 
+  Keymap.createLayer(() => ({
+    mode: "global",
+    enabled: sessionTabs.enabled,
+    bindings: sessionTabBindingCommands,
+  }))
+
+  Keymap.createLayer(() => ({
+    mode: "global",
+    enabled: () => !sessionTabs.enabled(),
+    bindings: pinnedSessionBindingCommands,
+  }))
+
   Keymap.createLayer(() => ({
   Keymap.createLayer(() => ({
     enabled: () => {
     enabled: () => {
       const current = promptRef.current
       const current = promptRef.current
@@ -1099,16 +1179,15 @@ function App(props: { pair?: DialogPairCredentials }) {
         evt.preventDefault()
         evt.preventDefault()
         evt.stopPropagation()
         evt.stopPropagation()
       }}
       }}
-      onMouseUp={
-        copyOnSelectEnabled()
-          ? () => Selection.copy(renderer, toast, clipboard)
-          : undefined
-      }
+      onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
     >
     >
       <box flexGrow={1} minHeight={0} flexDirection="row">
       <box flexGrow={1} minHeight={0} flexDirection="row">
         <box flexGrow={1} minWidth={0} flexDirection="column">
         <box flexGrow={1} minWidth={0} flexDirection="column">
           <Show when={plugins.ready()}>
           <Show when={plugins.ready()}>
             <box flexGrow={1} minHeight={0} flexDirection="column">
             <box flexGrow={1} minHeight={0} flexDirection="column">
+              <Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
+                <SessionTabs />
+              </Show>
               <Switch>
               <Switch>
                 <Match when={route.data.type === "home"}>
                 <Match when={route.data.type === "home"}>
                   <Home />
                   <Home />

+ 8 - 0
packages/tui/src/component/dialog-config.tsx

@@ -93,6 +93,14 @@ export const settings: Setting[] = [
     values: ["none", "auto"],
     values: ["none", "auto"],
     keywords: ["transcript", "messages"],
     keywords: ["transcript", "messages"],
   },
   },
+  {
+    title: "Tabs",
+    category: "Session",
+    path: ["session", "tabs"],
+    default: false,
+    values: [false, true],
+    labels: ["off", "on"],
+  },
   {
   {
     title: "Layout",
     title: "Layout",
     category: "Diffs",
     category: "Diffs",

+ 7 - 3
packages/tui/src/component/dialog-session-list.tsx

@@ -15,6 +15,7 @@ import { useToast } from "../ui/toast"
 import { DialogSessionRename } from "./dialog-session-rename"
 import { DialogSessionRename } from "./dialog-session-rename"
 import { Spinner } from "./spinner"
 import { Spinner } from "./spinner"
 import { errorMessage } from "../util/error"
 import { errorMessage } from "../util/error"
+import { useSessionTabs } from "../context/session-tabs"
 
 
 export function DialogSessionList() {
 export function DialogSessionList() {
   const dialog = useDialog()
   const dialog = useDialog()
@@ -25,6 +26,7 @@ export function DialogSessionList() {
   const mode = themes.mode
   const mode = themes.mode
   const client = useClient()
   const client = useClient()
   const local = useLocal()
   const local = useLocal()
+  const sessionTabs = useSessionTabs()
   const toast = useToast()
   const toast = useToast()
   const [filter, setFilter] = createSignal("")
   const [filter, setFilter] = createSignal("")
   const shortcuts = Keymap.useShortcuts()
   const shortcuts = Keymap.useShortcuts()
@@ -79,6 +81,7 @@ export function DialogSessionList() {
   })
   })
 
 
   const quickSwitchHint = createMemo(() => {
   const quickSwitchHint = createMemo(() => {
+    if (sessionTabs.enabled()) return
     const first = shortcuts.get("session.quick_switch.1")
     const first = shortcuts.get("session.quick_switch.1")
     const last = shortcuts.get("session.quick_switch.9")
     const last = shortcuts.get("session.quick_switch.9")
     if (!first || !last) return
     if (!first || !last) return
@@ -96,7 +99,7 @@ export function DialogSessionList() {
         .filter((session) => !session.parentID)
         .filter((session) => !session.parentID)
         .map((session) => [session.id, session]),
         .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 pinnedSet = new Set(pinned)
     const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
     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 directory = session.location.directory
       const footer =
       const footer =
         directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
         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
       const deleting = toDelete() === session.id
       return {
       return {
         title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
         title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
@@ -164,7 +167,8 @@ export function DialogSessionList() {
         {
         {
           command: "session.pin.toggle",
           command: "session.pin.toggle",
           title: "pin/unpin",
           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",
           command: "session.delete",

+ 231 - 0
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<string>()
+  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<typeof adaptiveSessionTabLayout> | 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 (
+    <box
+      height={1}
+      flexShrink={0}
+      position="relative"
+      flexDirection="row"
+      zIndex={1}
+      renderAfter={function (buffer) {
+        const x = Math.max(0, this.screenX)
+        const y = this.screenY + this.height
+        const width = Math.min(this.width, buffer.width - x)
+        if (y < 0 || y >= 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,
+          ),
+        )
+      }}
+    >
+      <Show when={layout().before > 0}>
+        <text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
+          ‹{layout().before}
+        </text>
+      </Show>
+      <For each={layout().tabs}>
+        {(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 (
+            <box
+              width={width()}
+              position="relative"
+              flexDirection="row"
+              backgroundColor={background()}
+              onMouseOver={() => setHovered(tab.sessionID)}
+              onMouseOut={() => setHovered(undefined)}
+              onMouseUp={() => tabs.select(tab.sessionID)}
+            >
+              <TabPulse
+                enabled={config.animations ?? true}
+                active={status().busy}
+                complete={status().complete}
+                color={pulseColor()}
+                completionColor={accent()}
+                backgroundColor={pulseBackground()}
+              />
+              <box zIndex={1} width="100%" flexDirection="row">
+                <text width={1}> </text>
+                <text width={2} fg={numberColor()} attributes={selected() ? TextAttributes.BOLD : undefined}>
+                  {items().findIndex((item) => item.sessionID === tab.sessionID) + 1}
+                </text>
+                <Show
+                  when={titleFades()}
+                  fallback={
+                    <text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
+                      {visibleTitle()}
+                    </text>
+                  }
+                >
+                  <text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
+                    {visibleTitleParts().slice(0, -fadeWidth()).join("")}
+                    <For each={fadedTitleParts()}>
+                      {(character, index) => (
+                        <span
+                          style={{
+                            fg: tint(
+                              foreground(),
+                              pulseBackground(),
+                              0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
+                            ),
+                          }}
+                        >
+                          {character}
+                        </span>
+                      )}
+                    </For>
+                  </text>
+                </Show>
+                <text
+                  position="absolute"
+                  right={1}
+                  zIndex={2}
+                  width={1}
+                  fg={closeColor()}
+                  onMouseUp={(event) => {
+                    event.stopPropagation()
+                    tabs.close(tab.sessionID)
+                  }}
+                >
+                  {hovered() === tab.sessionID ? "×" : ""}
+                </text>
+              </box>
+            </box>
+          )
+        }}
+      </For>
+      <Show when={layout().after > 0}>
+        <text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
+          {layout().after}›
+        </text>
+      </Show>
+    </box>
+  )
+}

+ 207 - 0
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<TabPulseRenderable> & {
+  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 (
+    <tab_pulse
+      position="absolute"
+      zIndex={0}
+      width="100%"
+      enabled={props.enabled ?? true}
+      active={props.active}
+      complete={props.complete ?? false}
+      color={props.color}
+      completionColor={props.completionColor ?? props.color}
+      backgroundColor={props.backgroundColor}
+    />
+  )
+}

+ 3 - 0
packages/tui/src/config/index.tsx

@@ -120,6 +120,9 @@ export const Info = Schema.Struct({
       markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
       markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
         description: "Show Markdown syntax markers or conceal them in rendered transcript content",
         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" }),
   ).annotate({ description: "Session transcript presentation settings" }),
   mini: Schema.optional(
   mini: Schema.optional(

+ 29 - 1
packages/tui/src/config/v1/keybind.ts

@@ -87,6 +87,11 @@ export const Definitions = {
   session_move: keybind("none", "Move session"),
   session_move: keybind("none", "Move session"),
   session_new: keybind("<leader>n", "Create a new session"),
   session_new: keybind("<leader>n", "Create a new session"),
   session_list: keybind("<leader>l", "List all sessions"),
   session_list: keybind("<leader>l", "List all sessions"),
+  session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
+  session_tab_previous: keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
+  session_tab_next_unread: keybind("<leader>down", "Switch to next unread session tab"),
+  session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
+  session_tab_close: keybind("<leader>w", "Close current session tab"),
   session_timeline: keybind("<leader>g", "Show session timeline"),
   session_timeline: keybind("<leader>g", "Show session timeline"),
   session_fork: keybind("none", "Fork session from message"),
   session_fork: keybind("none", "Fork session from message"),
   session_rename: keybind("ctrl+r", "Rename session"),
   session_rename: keybind("ctrl+r", "Rename session"),
@@ -97,7 +102,7 @@ export const Definitions = {
   session_background: keybind("ctrl+b", "Background blocking session tools"),
   session_background: keybind("ctrl+b", "Background blocking session tools"),
   session_compact: keybind("<leader>c", "Compact the session"),
   session_compact: keybind("<leader>c", "Compact the session"),
   session_queued_prompts: keybind("<leader>q", "View pending work"),
   session_queued_prompts: keybind("<leader>q", "View pending work"),
-  session_child_first: keybind("down,<leader>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: keybind("right", "Go to next child session"),
   session_child_cycle_reverse: keybind("left", "Go to previous child session"),
   session_child_cycle_reverse: keybind("left", "Go to previous child session"),
   session_parent: keybind("up", "Go to parent session"),
   session_parent: keybind("up", "Go to parent session"),
@@ -111,6 +116,15 @@ export const Definitions = {
   session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
   session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
   session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
   session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
   session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
   session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
+  session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
+  session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
+  session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
+  session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
+  session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
+  session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
+  session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
+  session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
+  session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
 
 
   stash_delete: keybind("ctrl+d", "Delete stash entry"),
   stash_delete: keybind("ctrl+d", "Delete stash entry"),
   model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
   model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -289,6 +303,11 @@ export const CommandMap = {
   session_move: "session.move",
   session_move: "session.move",
   session_new: "session.new",
   session_new: "session.new",
   session_list: "session.list",
   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_timeline: "session.timeline",
   session_fork: "session.fork",
   session_fork: "session.fork",
   session_rename: "session.rename",
   session_rename: "session.rename",
@@ -313,6 +332,15 @@ export const CommandMap = {
   session_quick_switch_7: "session.quick_switch.7",
   session_quick_switch_7: "session.quick_switch.7",
   session_quick_switch_8: "session.quick_switch.8",
   session_quick_switch_8: "session.quick_switch.8",
   session_quick_switch_9: "session.quick_switch.9",
   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",
   stash_delete: "stash.delete",
   model_provider_list: "model.dialog.provider",
   model_provider_list: "model.dialog.provider",
   model_favorite_toggle: "model.dialog.favorite",
   model_favorite_toggle: "model.dialog.favorite",

+ 108 - 0
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,
+  }
+}

+ 251 - 0
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<string, SessionTabUnread>
+}
+
+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<PersistedState & { ready: boolean }>({
+      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<unknown>(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<SessionTab[]>((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<Record<string, SessionTabUnread>>((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 })
+      },
+    }
+  },
+})

+ 0 - 6
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: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
       { bind: "right", title: "Next 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: "escape", title: "Close composer", group: "Composer", run: close },
-      {
-        bind: "<leader>down",
-        title: "Toggle composer",
-        group: "Composer",
-        run: close,
-      },
     ],
     ],
   }))
   }))
 
 

+ 235 - 0
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<string, AnimatableValue>
+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<AnimationTask>()
+let timer: ReturnType<typeof setInterval> | 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<T extends AnimatableTarget>(
+  initial: T,
+  options: {
+    transition: Transition
+    enabled?: Accessor<boolean>
+  },
+) {
+  const enabled = options.enabled ?? (() => true)
+  const state = new Map<string, ValueState>()
+  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<T extends AnimatableTarget>(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
+}

+ 17 - 7
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" })
 const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
 
 
-export function truncateWidth(str: string, width: number): string {
+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 (width <= 0) return ""
   if (stringWidth(str) <= width) return str
   if (stringWidth(str) <= width) return str
-  if (width === 1) return "…"
 
 
   const result: string[] = []
   const result: string[] = []
   let used = 0
   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)
+  for (const segment of graphemes(str)) {
+    const next = stringWidth(segment)
+    if (used + next > width) break
+    result.push(segment)
     used += next
     used += next
   }
   }
-  return result.join("") + "…"
+  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 "…"
+  return takeWidth(str, width - 1) + "…"
 }
 }
 
 
 export function truncateLeft(str: string, len: number): string {
 export function truncateLeft(str: string, len: number): string {

+ 12 - 14
packages/tui/src/util/signal.ts

@@ -1,4 +1,5 @@
 import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
 import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
+import { createAnimatable, tween } from "../ui/animation"
 
 
 export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, (value: T) => void] {
 export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, (value: T) => void] {
   const [get, set] = createSignal(value)
   const [get, set] = createSignal(value)
@@ -17,35 +18,32 @@ export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, (v
 }
 }
 
 
 export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
 export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
-  const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
+  const alpha = createAnimatable(
+    { value: show() ? 1 : 0 },
+    {
+      enabled,
+      transition: tween({ duration: 0.16 }),
+    },
+  )
   let revealed = show()
   let revealed = show()
 
 
   createEffect(
   createEffect(
     on([show, enabled], ([visible, animate]) => {
     on([show, enabled], ([visible, animate]) => {
       if (!visible) {
       if (!visible) {
-        setAlpha(0)
+        alpha.jump({ value: 0 })
         return
         return
       }
       }
 
 
       if (!animate || revealed) {
       if (!animate || revealed) {
         revealed = true
         revealed = true
-        setAlpha(1)
+        alpha.jump({ value: 1 })
         return
         return
       }
       }
 
 
-      const start = performance.now()
       revealed = true
       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
 }
 }

+ 10 - 0
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)
+})

+ 8 - 7
packages/tui/test/config-v2.test.tsx

@@ -2,13 +2,7 @@
 import { testRender } from "@opentui/solid"
 import { testRender } from "@opentui/solid"
 import { expect, test } from "bun:test"
 import { expect, test } from "bun:test"
 import { Schema } from "effect"
 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", () => {
 test("validates mini replay settings", () => {
   const decode = Schema.decodeUnknownSync(Info)
   const decode = Schema.decodeUnknownSync(Info)
@@ -20,6 +14,13 @@ test("validates mini replay settings", () => {
   expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow()
   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", () => {
 test("resolves nested config and keybind defaults", () => {
   const config = resolve(
   const config = resolve(
     {
     {

+ 18 - 1
packages/tui/test/config.test.tsx

@@ -100,7 +100,24 @@ test("resolves message navigation defaults", () => {
 test("opens the subagent picker with down", () => {
 test("opens the subagent picker with down", () => {
   const config = resolve({}, { terminalSuspend: true })
   const config = resolve({}, { terminalSuspend: true })
 
 
-  expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down,<leader>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,<leader>right" }])
+  expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,<leader>left" }])
+  expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
+  expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>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: "<leader>1" }])
+  expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1" }])
 })
 })
 
 
 test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
 test("disables suspend and assigns ctrl+z to undo when unsupported", () => {

+ 104 - 0
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)
+  })
+})

+ 45 - 0
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()
+  }
+})

+ 5 - 0
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", 1)).toBe("…")
   expect(Locale.truncateWidth("abcdefgh", 0)).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"])
+})