Преглед изворни кода

feat(tui): add vertical session tabs (#40072)

Kit Langton пре 2 недеља
родитељ
комит
0e3da4b4f1

+ 7 - 0
AGENTS.md

@@ -4,6 +4,13 @@
 - The default branch in this repo is `dev`.
 - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
 
+## Live V2 TUI Testing
+
+- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
+- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
+- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
+- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
+
 ## Branch Names
 
 Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.

+ 1 - 0
package.json

@@ -8,6 +8,7 @@
   "packageManager": "bun@1.3.14",
   "scripts": {
     "dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
+    "dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
     "dev:desktop": "bun --cwd packages/desktop dev",
     "dev:web": "bun --cwd packages/app dev",
     "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",

+ 5 - 1
packages/cli/src/commands/handlers/default.ts

@@ -45,7 +45,11 @@ export default Runtime.handler(Commands, (input) =>
     const runPromise = Effect.runPromiseWith(context)
     const service = server.service
     yield* run({
-      app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
+      app: {
+        name: process.env.OPENCODE_CLIENT ?? "cli",
+        version: OPENCODE_VERSION,
+        channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
+      },
       server: {
         endpoint: server.endpoint,
         service: service

+ 7 - 7
packages/tui/src/app.tsx

@@ -519,6 +519,9 @@ function App(props: { pair?: DialogPairCredentials }) {
   const terminalTitleEnabled = () => config.data.terminal?.title ?? true
   const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
   const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
+  const tabsVertical = () => config.data.tabs?.vertical ?? false
+  const tabsVisible = () =>
+    sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
 
   createEffect(() => {
     renderer.useMouse = config.data.mouse
@@ -1214,16 +1217,13 @@ function App(props: { pair?: DialogPairCredentials }) {
       onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
     >
       <box flexGrow={1} minHeight={0} flexDirection="row">
+        <Show when={tabsVisible() && tabsVertical()}>
+          <SessionTabs orientation="vertical" />
+        </Show>
         <box flexGrow={1} minWidth={0} flexDirection="column">
           <Show when={plugins.ready()}>
             <box flexGrow={1} minHeight={0} flexDirection="column">
-              <Show
-                when={
-                  sessionTabs.enabled() &&
-                  (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
-                  route.data.type !== "plugin"
-                }
-              >
+              <Show when={tabsVisible() && !tabsVertical()}>
                 <SessionTabs />
               </Show>
               <Switch>

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

@@ -100,6 +100,15 @@ export const settings: Setting[] = [
     values: ["cwd", "global"],
     labels: ["current directory", "global"],
   },
+  {
+    title: "Vertical",
+    category: "Tabs",
+    path: ["tabs", "vertical"],
+    default: false,
+    values: [false, true],
+    labels: ["off", "on"],
+    keywords: ["sidebar", "orientation", "left"],
+  },
   {
     title: "Layout",
     category: "Diffs",

+ 3 - 3
packages/tui/src/component/dialog-open.tsx

@@ -1,4 +1,3 @@
-import path from "path"
 import { createMemo, createResource, createSignal, onMount } from "solid-js"
 import type { SessionInfo } from "@opencode-ai/client"
 import { useTerminalDimensions } from "@opentui/solid"
@@ -18,6 +17,7 @@ import { truncateFilePath } from "../ui/file-path"
 import { stringWidth } from "../util/string-width"
 import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
 import { Spinner } from "./spinner"
+import { projectName } from "../util/project"
 
 const RECENT_LIMIT = 8
 
@@ -81,7 +81,7 @@ export function DialogOpen() {
           .slice(0, RECENT_LIMIT)
     const sessionOptions = recent.map((session) => {
       const project = data.project.get(session.projectID)
-      const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
+      const name = projectName(project)
       const running =
         data.session.status(session.id) === "running" ||
         data.session.family(session.id).some((id) => data.session.status(id) === "running")
@@ -109,7 +109,7 @@ export function DialogOpen() {
         return true
       })
       .map((project) => {
-        const title = project.name ?? path.basename(project.canonical)
+        const title = projectName(project) ?? project.canonical
         const footer = abbreviateHome(project.canonical, paths.home)
         const width =
           dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)

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

@@ -20,6 +20,7 @@ import { useSessionTabs } from "../context/session-tabs"
 import { useStorage } from "../context/storage"
 import { useConfig } from "../config"
 import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
+import { projectName } from "../util/project"
 
 export function DialogSessionList() {
   const dialog = useDialog()
@@ -123,8 +124,7 @@ export function DialogSessionList() {
     const current = data.location.info()
     if (!current) return ""
     const project = data.project.get(current.project.id)
-    if (!project) return ""
-    return project.name || path.basename(project.canonical)
+    return projectName(project) ?? ""
   })
 
   const options = createMemo(() => {
@@ -141,9 +141,7 @@ export function DialogSessionList() {
     const option = (session: SessionInfo, category: string) => {
       const directory = session.location.directory
       const project = data.project.get(session.projectID)
-      const footer = allProjects()
-        ? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
-        : undefined
+      const footer = allProjects() ? Locale.truncate(projectName(project, directory) ?? "", 20) : undefined
       const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
       const deleting = toDelete() === session.id
       return {

+ 341 - 5
packages/tui/src/component/session-tabs.tsx

@@ -1,8 +1,9 @@
-import { RGBA, TextAttributes } from "@opentui/core"
+import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
 import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
 import { useTerminalDimensions } from "@opentui/solid"
 import { useConfig } from "../config"
 import { useSessionTabs } from "../context/session-tabs"
+import { useData } from "../context/data"
 import { useTheme, useThemes } from "../context/theme"
 import {
   adaptiveSessionTabLayout,
@@ -11,6 +12,7 @@ import {
   sessionTabComplete,
   seedSessionTabMotion,
   sessionTabOverflowWidth,
+  sessionTabVerticalWidth,
   type SessionTab,
   type SessionTabUnread,
 } from "../context/session-tabs-model"
@@ -19,6 +21,7 @@ import { Locale } from "../util/locale"
 import { stringWidth } from "../util/string-width"
 import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
 import { tint } from "../theme/color"
+import { projectName } from "../util/project"
 
 // A long title fades out over its last cells instead of cutting hard.
 const FADE_WIDTH = 4
@@ -39,8 +42,343 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
 }
 
 const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
+const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) =>
+  tint(base, glow, 0.12 * unreadGlowIntensity(index, width))
 
-export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
+export function SessionTabs(
+  props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
+) {
+  if (props.orientation === "vertical")
+    return <VerticalSessionTabs controller={props.controller} animations={props.animations} />
+  return <HorizontalSessionTabs controller={props.controller} animations={props.animations} />
+}
+
+function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean }) {
+  const tabs = props.controller ?? useSessionTabs()
+  const data = useData()
+  const dimensions = useTerminalDimensions()
+  const theme = useTheme("elevated")
+  const { mode } = useThemes()
+  const config = useConfig().data
+  const animations = () => props.animations ?? config.animations ?? true
+  const width = () => sessionTabVerticalWidth(dimensions().width)
+  const hueStep = () => (mode() === "light" ? 800 : 200)
+  const accent = () => theme.hue.accent[hueStep()]
+  const activeNumber = () => theme.hue.interactive[hueStep()]
+  const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
+  const [hovered, setHovered] = createSignal<string>()
+  const [dragging, setDragging] = createSignal<string>()
+  const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
+  const newTab = () => tabs.newTab?.() ?? false
+  const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
+  const ordered = createMemo(() => {
+    const pending = preview()
+    if (!pending) return tabs.tabs()
+    return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
+  })
+  const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
+  const statuses = createMemo(
+    () =>
+      new Map(
+        items().map((tab) => {
+          const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
+          return [
+            tab.sessionID,
+            {
+              ...status,
+              complete: sessionTabComplete(status.unread, status.busy),
+              runs: status.busy && !status.attention,
+              glows:
+                tab.sessionID !== activeID() && (status.attention || (!status.busy && status.unread !== undefined)),
+            },
+          ] as const
+        }),
+      ),
+  )
+  const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
+  let rail: { screenY: number } | undefined
+  let scroll: ScrollBoxRenderable | undefined
+
+  createEffect(() => {
+    const pending = preview()
+    if (!pending || dragging()) return
+    const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
+    if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
+  })
+
+  createEffect(() => {
+    if (!scroll) return
+    const index = items().findIndex((tab) => tab.sessionID === activeID())
+    if (index === -1) return
+    const top = index * 3
+    if (top < scroll.scrollTop) return scroll.scrollTo(top)
+    if (top + 2 > scroll.scrollTop + scroll.viewport.height) {
+      scroll.scrollTo(top + 2 - scroll.viewport.height)
+    }
+  })
+
+  return (
+    <box
+      ref={(element) => (rail = element)}
+      width={width()}
+      height="100%"
+      flexShrink={0}
+      flexDirection="column"
+      paddingTop={1}
+      backgroundColor={theme.background.default}
+    >
+      <scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
+        <box flexShrink={0} flexDirection="column" gap={1}>
+          <For each={items()}>
+            {(tab, index) => {
+              const selected = () => activeID() === tab.sessionID
+              const status = createMemo(() => itemStatus(tab))
+              const [sweepLevel, setSweepLevel] = createSignal(0)
+              const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID)))
+              const project = createMemo(() => {
+                const value = session()
+                return value ? data.project.get(value.projectID) : undefined
+              })
+              const numberWidth = () => String(index() + 1).length + 1
+              const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
+              const title = () => tab.title ?? "Untitled session"
+              const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
+              const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
+              const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
+              const detail = createMemo(() => {
+                if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
+                const value = session()
+                return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
+              })
+              const background = createMemo(() => {
+                if (selected()) return theme.background.action.primary.selected
+                if (hovered() === tab.sessionID || dragging() === tab.sessionID)
+                  return theme.background.action.primary.hovered
+                return theme.background.default
+              })
+              const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a))
+              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(), Number(selected()))
+                const color = tint(base, accent(), Number(complete()))
+                return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel())
+              }
+              const foreground = () => {
+                if (hovered() === tab.sessionID) return theme.text.default
+                return selected() ? theme.text.default : theme.text.subdued
+              }
+              const complete = () => status().complete
+              const glowHue = () => {
+                if (status().attention) return theme.text.feedback.warning.default
+                if (status().unread === "error") return theme.text.feedback.error.default
+                return accent()
+              }
+              const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
+              const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45))
+              const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
+              const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
+              const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
+              const glows = () => status().glows
+              const previous = createMemo(() => items()[index() - 1])
+              const previousStatus = createMemo(() => {
+                const tab = previous()
+                return tab
+                  ? itemStatus(tab)
+                  : { ...EMPTY_SESSION_TAB_STATUS, complete: false, runs: false, glows: false }
+              })
+              const previousGlows = () => previousStatus().glows
+              const runs = () => status().runs
+              const previousRuns = () => previousStatus().runs
+              const previousGlowHue = () => {
+                if (previousStatus().attention) return theme.text.feedback.warning.default
+                if (previousStatus().unread === "error") return theme.text.feedback.error.default
+                return accent()
+              }
+              const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
+              const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
+              const separatorUpperPulseColor = createMemo(() =>
+                tint(theme.background.default, theme.text.default, 0.04),
+              )
+              const separatorLowerPulseColor = createMemo(() =>
+                tint(theme.background.default, theme.text.default, 0.05),
+              )
+              const titleColor = (index: number) => {
+                const color = glows()
+                  ? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
+                  : foreground()
+                if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
+                const position = index - (visibleTitleParts().length - FADE_WIDTH)
+                return tint(color, pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
+              }
+              const release = () => {
+                setDragging(undefined)
+                const pending = preview()
+                if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
+                if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID)
+              }
+              return (
+                <box
+                  height={2}
+                  width="100%"
+                  position="relative"
+                  flexDirection="column"
+                  backgroundColor={background()}
+                  onMouseOver={() => setHovered(tab.sessionID)}
+                  onMouseOut={() => setHovered(undefined)}
+                  onMouseDown={() => setDragging(tab.sessionID)}
+                  onMouseUp={release}
+                  onMouseDrag={(event) => {
+                    if (!rail || tab === NEW_SESSION_TAB) return
+                    const target = Math.max(
+                      0,
+                      Math.min(
+                        tabs.tabs().length - 1,
+                        Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
+                      ),
+                    )
+                    if (target !== index() && preview()?.index !== target)
+                      setPreview({ sessionID: tab.sessionID, index: target })
+                  }}
+                  onMouseDragEnd={release}
+                >
+                  <TabPulse
+                    top={-1}
+                    edge="above"
+                    enabled={animations()}
+                    active={runs()}
+                    outerActive={previousRuns()}
+                    promptPulse={status().promptPulse}
+                    outerPromptPulse={previousStatus().promptPulse}
+                    complete={complete() && !status().attention}
+                    outerComplete={previousStatus().complete && !previousStatus().attention}
+                    glow={glows()}
+                    outerGlow={previousGlows()}
+                    breathe={status().attention}
+                    outerBreathe={previousStatus().attention}
+                    color={separatorLowerPulseColor()}
+                    outerColor={separatorUpperPulseColor()}
+                    glowColor={separatorLowerColor()}
+                    outerGlowColor={separatorUpperColor()}
+                    glowTail={8}
+                    outerGlowTail={5}
+                    completionColor={separatorLowerColor()}
+                    outerCompletionColor={separatorUpperColor()}
+                    backgroundColor={theme.background.default}
+                  />
+                  <Show when={index() === items().length - 1}>
+                    <TabPulse
+                      top={2}
+                      edge="below"
+                      enabled={animations()}
+                      active={runs()}
+                      outerActive={false}
+                      promptPulse={status().promptPulse}
+                      outerPromptPulse={0}
+                      complete={complete() && !status().attention}
+                      outerComplete={false}
+                      glow={glows()}
+                      outerGlow={false}
+                      breathe={status().attention}
+                      outerBreathe={false}
+                      color={tint(theme.background.default, theme.text.default, 0.04)}
+                      outerColor={tint(theme.background.default, theme.text.default, 0.006)}
+                      glowColor={tint(theme.background.default, glowHue(), 0.1)}
+                      outerGlowColor={theme.background.default}
+                      glowTail={8}
+                      outerGlowTail={5}
+                      completionColor={tint(theme.background.default, glowHue(), 0.1)}
+                      outerCompletionColor={theme.background.default}
+                      backgroundColor={theme.background.default}
+                    />
+                  </Show>
+                  <box height={1} width="100%" flexDirection="row" position="relative">
+                    <TabPulse
+                      enabled={animations()}
+                      active={status().busy && !status().attention}
+                      promptPulse={status().promptPulse}
+                      complete={complete() && !status().attention}
+                      glow={glows()}
+                      breathe={status().attention}
+                      color={pulseColor()}
+                      glowColor={glowColor()}
+                      completionColor={glowColor()}
+                      backgroundColor={pulseBackground()}
+                      onLevel={setSweepLevel}
+                    />
+                    <box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
+                      <text
+                        width={numberWidth()}
+                        fg={numberColor()}
+                        selectable={false}
+                        attributes={selected() ? TextAttributes.BOLD : undefined}
+                      >
+                        {index() + 1}
+                      </text>
+                      <text
+                        width={titleWidth()}
+                        fg={foreground()}
+                        wrapMode="none"
+                        selectable={false}
+                        attributes={selected() ? TextAttributes.BOLD : undefined}
+                      >
+                        <Show when={glows() || titleFades()} fallback={visibleTitle()}>
+                          <For each={visibleTitleParts()}>
+                            {(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
+                          </For>
+                        </Show>
+                      </text>
+                      <text
+                        position="absolute"
+                        right={1}
+                        zIndex={2}
+                        width={1}
+                        fg={theme.text.subdued}
+                        selectable={false}
+                        onMouseUp={(event) => {
+                          if (hovered() !== tab.sessionID) return
+                          event.stopPropagation()
+                          tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
+                        }}
+                      >
+                        {hovered() === tab.sessionID ? "×" : ""}
+                      </text>
+                    </box>
+                  </box>
+                  <box height={1} width="100%" position="relative" flexDirection="row">
+                    <TabPulse
+                      enabled={animations()}
+                      active={status().busy && !status().attention}
+                      promptPulse={status().promptPulse}
+                      complete={complete() && !status().attention}
+                      glow={glows()}
+                      breathe={status().attention}
+                      color={detailPulseColor()}
+                      glowColor={detailGlowColor()}
+                      glowTail={10}
+                      completionColor={detailGlowColor()}
+                      backgroundColor={pulseBackground()}
+                    />
+                    <box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
+                      <text fg={detailColor()} wrapMode="none" selectable={false}>
+                        {detail()}
+                      </text>
+                    </box>
+                  </box>
+                </box>
+              )
+            }}
+          </For>
+        </box>
+      </scrollbox>
+    </box>
+  )
+}
+
+function HorizontalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
   const tabs = props.controller ?? useSessionTabs()
   const dimensions = useTerminalDimensions()
   const theme = useTheme()
@@ -269,9 +607,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
           // spatial falloff as the glow itself; characters beyond the tail stay neutral.
           const characterColor = (index: number) => {
             const base = foreground()
-            const color = glows()
-              ? tint(base, glowColor(), 0.12 * unreadGlowIntensity(1 + numberWidth() + index, width()))
-              : base
+            const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
             if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color
             const position = index - (displayedParts().length - FADE_WIDTH)
             return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))

+ 390 - 119
packages/tui/src/component/tab-pulse.tsx

@@ -2,16 +2,28 @@ import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderC
 import { extend } from "@opentui/solid"
 
 type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
+  edge?: "above" | "below"
   enabled?: boolean
   active?: boolean
+  outerActive?: boolean
   promptPulse?: number
+  outerPromptPulse?: number
   complete?: boolean
+  outerComplete?: boolean
   glow?: boolean
+  outerGlow?: boolean
   breathe?: boolean
+  outerBreathe?: boolean
   color?: RGBA
+  outerColor?: RGBA
   glowColor?: RGBA
+  outerGlowColor?: RGBA
+  glowTail?: number
+  outerGlowTail?: number
   flashColor?: RGBA
+  outerFlashColor?: RGBA
   completionColor?: RGBA
+  outerCompletionColor?: RGBA
   backgroundColor?: RGBA
   /** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */
   onLevel?: (level: number) => void
@@ -20,6 +32,7 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
 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_ATTACK = 450
 const RUN_HEAD = 4
 const RUN_TAIL = 18
 const RUN_FADE_OUT = 500
@@ -58,9 +71,10 @@ const attackDecay = (progress: number, attack: number, peak: number, rest: numbe
 export const completionPulseOpacity = (progress: number) => attackDecay(progress, COMPLETION_ATTACK, 1, 0)
 export const glowIgnitionLevel = (progress: number) =>
   attackDecay(progress, GLOW_IGNITION_ATTACK, GLOW_IGNITION_PEAK, 1)
-export const unreadGlowIntensity = (index: number, width: number) => {
-  const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
-  return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
+const glowIntensityAt = (index: number, tail: number) => smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
+export const unreadGlowIntensity = (index: number, width: number, maximumTail = GLOW_TAIL) => {
+  const tail = Math.min(maximumTail, Math.max(1, width - 2))
+  return glowIntensityAt(index, tail)
 }
 export function blendTabPulseColor(
   output: RGBA,
@@ -131,67 +145,66 @@ class Envelope {
 // Hoisted so the per-frame liveness check allocates no closure.
 const envelopeActive = (envelope: Envelope) => envelope.active
 
-class TabPulseRenderable extends Renderable {
-  private _enabled: boolean
-  private _active: boolean
-  private _promptPulse: number
-  private _complete: boolean
-  private _glow: boolean
-  private _breathe: boolean
-  private _color: RGBA
-  private _glowColor: RGBA
-  private _flashColor: RGBA
-  private _completionColor: RGBA
-  private _backgroundColor: RGBA
+type PulseStateOptions = {
+  enabled: boolean
+  active: boolean
+  promptPulse: number
+  complete: boolean
+  glow: boolean
+  breathe: boolean
+}
+
+class PulseState {
+  private enabled: boolean
+  private active: boolean
+  private promptPulse: number
+  private complete: boolean
+  private glow: boolean
+  private breathe: boolean
   private clock = 0
   private breatheClock = 0
   private completionPending = false
+  private runAttack = new Envelope(RUN_ATTACK, smootherstep)
   private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
   private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity)
   private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0))
   private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
   private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
-  private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
-  private renderColor = RGBA.fromInts(0, 0, 0)
-  private _onLevel: ((level: number) => void) | undefined
-  private lastLevel = 0
+  private envelopes = [this.runAttack, this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
+
+  constructor(options: PulseStateOptions) {
+    this.enabled = options.enabled
+    this.active = options.active
+    this.promptPulse = options.promptPulse
+    this.complete = options.complete
+    this.glow = options.glow
+    this.breathe = options.breathe
+    if (this.enabled && this.active) this.runAttack.start()
+  }
 
-  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._promptPulse = options.promptPulse ?? 0
-    this._complete = options.complete ?? false
-    this._glow = options.glow ?? false
-    this._breathe = options.breathe ?? false
-    this._color = options.color ?? RGBA.defaultForeground()
-    this._glowColor = options.glowColor ?? this._color
-    this._flashColor = options.flashColor ?? this._color
-    this._completionColor = options.completionColor ?? this._color
-    this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
-    this._onLevel = options.onLevel
+  private get breathing() {
+    return this.enabled && this.glow && this.breathe
   }
 
-  set onLevel(value: ((level: number) => void) | undefined) {
-    this._onLevel = value
+  get live() {
+    return this.active || this.breathing || this.envelopes.some(envelopeActive)
   }
 
-  private emitLevel(value: number) {
-    const quantized = Math.round(value * 32) / 32
-    if (quantized === this.lastLevel) return
-    this.lastLevel = quantized
-    this._onLevel?.(quantized)
+  get running() {
+    if (!this.enabled) return 0
+    return this.active ? (this.runAttack.active ? this.runAttack.level() : 1) : this.runFade.level()
   }
 
-  private get breathing() {
-    return this._enabled && this._glow && this._breathe
+  get completion() {
+    return this.completionPulse.level() * COMPLETION_OPACITY
+  }
+
+  get flash() {
+    return this.edgeFlash.level() * EDGE_FLASH_OPACITY
   }
 
-  /** Resting glow is 1; ignition overshoots on arrival, breathing swells while pending, glowOff decays after. */
-  private glowLevel() {
-    if (!this._glow) return this.glowOff.level()
+  get glowLevel() {
+    if (!this.glow) return this.glowOff.level()
     const base = this.ignition.active ? this.ignition.level() : 1
     if (!this.breathing) return base
     return (
@@ -199,83 +212,236 @@ class TabPulseRenderable extends Renderable {
     )
   }
 
-  set enabled(value: boolean) {
-    if (value === this._enabled) return
-    this._enabled = value
+  setEnabled(value: boolean) {
+    if (value === this.enabled) return false
+    this.enabled = value
     if (!value) {
       for (const envelope of this.envelopes) envelope.stop()
       this.completionPending = false
       this.breatheClock = 0
-      this.live = false
-    } else if (this._active || this.breathing) {
-      this.live = true
+    } else if (this.active) {
+      this.runAttack.restart()
     }
-    this.requestRender()
+    return true
   }
 
-  set active(value: boolean) {
-    if (value === this._active) return
-    this._active = value
-    if (!this._enabled) return
+  setActive(value: boolean) {
+    if (value === this.active) return false
+    this.active = value
+    if (!this.enabled) return true
     if (value) {
+      this.clock = 0
+      this.runAttack.restart()
       this.runFade.stop()
       this.completionPulse.stop()
       this.completionPending = false
     } else {
-      this.runFade.start()
+      const level = this.runAttack.active ? this.runAttack.level() : 1
+      this.runAttack.stop()
+      this.runFade.start(level)
       this.completionPending = true
     }
-    // The same neutral edge flash marks both the start and the finish of a run.
     this.edgeFlash.start()
-    this.live = true
-    this.requestRender()
+    return true
   }
 
-  set promptPulse(value: number) {
-    if (value === this._promptPulse) return
-    this._promptPulse = value
-    if (!this._enabled) return
-    this.edgeFlash.restart(PROMPT_FLASH_SCALE)
-    this.live = true
-    this.requestRender()
+  setPromptPulse(value: number) {
+    if (value === this.promptPulse) return false
+    this.promptPulse = value
+    if (this.enabled) this.edgeFlash.restart(PROMPT_FLASH_SCALE)
+    return true
   }
 
-  set complete(value: boolean) {
-    if (value === this._complete) return
-    this._complete = value
+  setComplete(value: boolean) {
+    if (value === this.complete) return false
+    this.complete = value
     if (!value) {
       this.completionPulse.stop()
       this.completionPending = false
     }
     if (value && this.completionPending) {
       this.completionPending = false
-      if (this._enabled) {
-        this.completionPulse.start()
-        this.live = true
-      }
+      if (this.enabled) this.completionPulse.start()
     }
-    this.requestRender()
+    return true
   }
 
-  set glow(value: boolean) {
-    if (value === this._glow) return
-    if (this._enabled && !value) this.glowOff.start(this.glowLevel())
-    this._glow = value
+  setGlow(value: boolean) {
+    if (value === this.glow) return false
+    if (this.enabled && !value) this.glowOff.start(this.glowLevel)
+    this.glow = value
     this.ignition.stop()
     this.breatheClock = 0
-    if (this._enabled && value) {
+    if (this.enabled && value) {
       this.glowOff.stop()
       this.ignition.start()
-      this.live = true
     }
+    return true
+  }
+
+  setBreathe(value: boolean) {
+    if (value === this.breathe) return false
+    this.breathe = value
+    this.breatheClock = 0
+    return true
+  }
+
+  advance(deltaTime: number) {
+    if (!this.enabled) return
+    if (this.active || this.runFade.active) this.clock += deltaTime
+    if (this.breathing) this.breatheClock += deltaTime
+    for (const envelope of this.envelopes) envelope.advance(deltaTime)
+    if (!this.completionPending) return
+    if (this.complete) {
+      this.completionPending = false
+      this.completionPulse.start()
+      return
+    }
+    if (!this.runFade.active) this.completionPending = false
+  }
+
+  fronts(width: number) {
+    const cycles = this.clock / RUN_DURATION
+    const progress = cycles % 1
+    const start = -RUN_HEAD
+    const end = width - 1 + RUN_TAIL
+    const secondProgress = cycles < 0.5 ? 0 : (cycles + 0.5) % 1
+    return [start + coast(progress) * (end - start), start + coast(secondProgress) * (end - start)] as const
+  }
+}
+
+class TabPulseRenderable extends Renderable {
+  private _enabled: boolean
+  private inner: PulseState
+  private outer: PulseState
+  private _color: RGBA
+  private _outerColor: RGBA
+  private _glowColor: RGBA
+  private _outerGlowColor: RGBA
+  private _glowTail: number
+  private _outerGlowTail: number
+  private _edge: "above" | "below" | undefined
+  private _flashColor: RGBA
+  private _outerFlashColor: RGBA
+  private _completionColor: RGBA
+  private _outerCompletionColor: RGBA
+  private _backgroundColor: RGBA
+  private renderColor = RGBA.fromInts(0, 0, 0)
+  private outerRenderColor = RGBA.fromInts(0, 0, 0)
+  private _onLevel: ((level: number) => void) | undefined
+  private lastLevel = 0
+
+  constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
+    const enabled = options.enabled ?? true
+    const active = options.active ?? false
+    const glow = options.glow ?? false
+    const breathe = options.breathe ?? false
+    const edge = options.edge
+    const outerActive = options.outerActive ?? active
+    const outerGlow = options.outerGlow ?? glow
+    const outerBreathe = options.outerBreathe ?? breathe
+    super(ctx, {
+      ...options,
+      height: 1,
+      live:
+        enabled &&
+        (active || (glow && breathe) || (edge !== undefined && (outerActive || (outerGlow && outerBreathe)))),
+    })
+    this._enabled = enabled
+    this.inner = new PulseState({
+      enabled,
+      active,
+      promptPulse: options.promptPulse ?? 0,
+      complete: options.complete ?? false,
+      glow,
+      breathe,
+    })
+    this.outer = new PulseState({
+      enabled: enabled && edge !== undefined,
+      active: outerActive,
+      promptPulse: options.outerPromptPulse ?? options.promptPulse ?? 0,
+      complete: options.outerComplete ?? options.complete ?? false,
+      glow: outerGlow,
+      breathe: outerBreathe,
+    })
+    this._color = options.color ?? RGBA.defaultForeground()
+    this._outerColor = options.outerColor ?? this._color
+    this._glowColor = options.glowColor ?? this._color
+    this._outerGlowColor = options.outerGlowColor ?? this._glowColor
+    this._glowTail = options.glowTail ?? GLOW_TAIL
+    this._outerGlowTail = options.outerGlowTail ?? this._glowTail
+    this._edge = edge
+    this._flashColor = options.flashColor ?? this._color
+    this._outerFlashColor = options.outerFlashColor ?? options.flashColor ?? this._outerColor
+    this._completionColor = options.completionColor ?? this._color
+    this._outerCompletionColor = options.outerCompletionColor ?? options.completionColor ?? this._outerColor
+    this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
+    this._onLevel = options.onLevel
+  }
+
+  set onLevel(value: ((level: number) => void) | undefined) {
+    this._onLevel = value
+  }
+
+  private emitLevel(value: number) {
+    if (!this._onLevel) return
+    const quantized = Math.round(value * 32) / 32
+    if (quantized === this.lastLevel) return
+    this.lastLevel = quantized
+    this._onLevel?.(quantized)
+  }
+
+  set enabled(value: boolean) {
+    if (value === this._enabled) return
+    this._enabled = value
+    this.inner.setEnabled(value)
+    this.outer.setEnabled(value && this._edge !== undefined)
+    this.live = this.inner.live || this.outer.live
     this.requestRender()
   }
 
+  set active(value: boolean) {
+    if (this.inner.setActive(value)) this.changed()
+  }
+
+  set outerActive(value: boolean) {
+    if (this.outer.setActive(value)) this.changed()
+  }
+
+  set promptPulse(value: number) {
+    if (this.inner.setPromptPulse(value)) this.changed()
+  }
+
+  set outerPromptPulse(value: number) {
+    if (this.outer.setPromptPulse(value)) this.changed()
+  }
+
+  set complete(value: boolean) {
+    if (this.inner.setComplete(value)) this.changed()
+  }
+
+  set outerComplete(value: boolean) {
+    if (this.outer.setComplete(value)) this.changed()
+  }
+
+  set glow(value: boolean) {
+    if (this.inner.setGlow(value)) this.changed()
+  }
+
+  set outerGlow(value: boolean) {
+    if (this.outer.setGlow(value)) this.changed()
+  }
+
   set breathe(value: boolean) {
-    if (value === this._breathe) return
-    this._breathe = value
-    this.breatheClock = 0
-    if (this.breathing) this.live = true
+    if (this.inner.setBreathe(value)) this.changed()
+  }
+
+  set outerBreathe(value: boolean) {
+    if (this.outer.setBreathe(value)) this.changed()
+  }
+
+  private changed() {
+    this.live = this.inner.live || this.outer.live
     this.requestRender()
   }
 
@@ -291,18 +457,62 @@ class TabPulseRenderable extends Renderable {
     this.requestRender()
   }
 
+  set outerColor(value: RGBA) {
+    if (value.equals(this._outerColor)) return
+    this._outerColor = value
+    this.requestRender()
+  }
+
+  set outerGlowColor(value: RGBA) {
+    if (value.equals(this._outerGlowColor)) return
+    this._outerGlowColor = value
+    this.requestRender()
+  }
+
+  set glowTail(value: number) {
+    if (value === this._glowTail) return
+    this._glowTail = value
+    this.requestRender()
+  }
+
+  set outerGlowTail(value: number) {
+    if (value === this._outerGlowTail) return
+    this._outerGlowTail = value
+    this.requestRender()
+  }
+
+  set edge(value: "above" | "below" | undefined) {
+    if (value === this._edge) return
+    this._edge = value
+    this.outer.setEnabled(this._enabled && value !== undefined)
+    this.live = this.inner.live || this.outer.live
+    this.requestRender()
+  }
+
   set flashColor(value: RGBA) {
     if (value.equals(this._flashColor)) return
     this._flashColor = value
     this.requestRender()
   }
 
+  set outerFlashColor(value: RGBA) {
+    if (value.equals(this._outerFlashColor)) return
+    this._outerFlashColor = value
+    this.requestRender()
+  }
+
   set completionColor(value: RGBA) {
     if (value.equals(this._completionColor)) return
     this._completionColor = value
     this.requestRender()
   }
 
+  set outerCompletionColor(value: RGBA) {
+    if (value.equals(this._outerCompletionColor)) return
+    this._outerCompletionColor = value
+    this.requestRender()
+  }
+
   set backgroundColor(value: RGBA) {
     if (value.equals(this._backgroundColor)) return
     this._backgroundColor = value
@@ -311,42 +521,45 @@ class TabPulseRenderable extends Renderable {
 
   protected override onUpdate(deltaTime: number): void {
     if (!this._enabled) return
-    if (this._active || this.runFade.active) this.clock += deltaTime
-    if (this.breathing) this.breatheClock += deltaTime
-    for (const envelope of this.envelopes) envelope.advance(deltaTime)
-    if (this.completionPending) {
-      if (this._complete) {
-        this.completionPending = false
-        this.completionPulse.start()
-      } else if (!this.runFade.active) {
-        this.completionPending = false
-      }
-    }
-    this.live = this._active || this.breathing || this.envelopes.some(envelopeActive)
+    this.inner.advance(deltaTime)
+    this.outer.advance(deltaTime)
+    this.live = this.inner.live || this.outer.live
   }
 
   protected override renderSelf(buffer: OptimizedBuffer): void {
     if (!this.visible || this.isDestroyed || this.width <= 0) return
-    const running = !this._enabled ? 0 : this._active ? 1 : this.runFade.level()
-    const completion = this.completionPulse.level() * COMPLETION_OPACITY
-    // The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results.
-    const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY
-    const glowLevel = this.glowLevel()
-    if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) {
+    const running = this.inner.running
+    const completion = this.inner.completion
+    const flash = this.inner.flash
+    const glowLevel = this.inner.glowLevel
+    const outerRunning = this.outer.running
+    const outerCompletion = this.outer.completion
+    const outerFlash = this.outer.flash
+    const outerGlowLevel = this.outer.glowLevel
+    if (
+      glowLevel === 0 &&
+      running === 0 &&
+      completion === 0 &&
+      flash === 0 &&
+      outerGlowLevel === 0 &&
+      outerRunning === 0 &&
+      outerCompletion === 0 &&
+      outerFlash === 0
+    ) {
       this.emitLevel(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)
-    this.emitLevel(
-      running === 0
-        ? 0
-        : Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
-            running,
-    )
+    const [front, secondFront] = this.inner.fronts(this.width)
+    const [outerFront, outerSecondFront] = this.outer.fronts(this.width)
+    if (this._onLevel)
+      this.emitLevel(
+        running === 0
+          ? 0
+          : Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
+              running,
+      )
+    const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2))
+    const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2))
     for (let index = 0; index < this.width; index++) {
       // Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow.
       const sweep =
@@ -358,6 +571,15 @@ class TabPulseRenderable extends Renderable {
             ) *
             0.14 *
             running
+      const outerSweep =
+        outerRunning === 0
+          ? 0
+          : Math.max(
+              intensityAt(index, outerFront, RUN_HEAD, RUN_TAIL),
+              intensityAt(index, outerSecondFront, RUN_HEAD, RUN_TAIL),
+            ) *
+            0.14 *
+            outerRunning
       blendTabPulseColor(
         this.renderColor,
         this._backgroundColor,
@@ -365,12 +587,34 @@ class TabPulseRenderable extends Renderable {
         this._color,
         this._flashColor,
         this._completionColor,
-        glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
+        glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
         sweep,
         flash,
         completion,
       )
-      buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
+      if (!this._edge) {
+        buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
+        continue
+      }
+      blendTabPulseColor(
+        this.outerRenderColor,
+        this._backgroundColor,
+        this._outerGlowColor,
+        this._outerColor,
+        this._outerFlashColor,
+        this._outerCompletionColor,
+        outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel,
+        outerSweep,
+        outerFlash,
+        outerCompletion,
+      )
+      buffer.setCell(
+        this.screenX + index,
+        this.screenY,
+        this._edge === "above" ? "▄" : "▀",
+        this.renderColor,
+        this.outerRenderColor,
+      )
     }
   }
 }
@@ -384,34 +628,61 @@ declare module "@opentui/solid" {
 extend({ tab_pulse: TabPulseRenderable })
 
 export function TabPulse(props: {
+  top?: number
+  width?: number
+  edge?: "above" | "below"
   enabled?: boolean
   active: boolean
+  outerActive?: boolean
   promptPulse?: number
+  outerPromptPulse?: number
   complete?: boolean
+  outerComplete?: boolean
   glow?: boolean
+  outerGlow?: boolean
   breathe?: boolean
+  outerBreathe?: boolean
   color: RGBA
+  outerColor?: RGBA
   glowColor?: RGBA
+  outerGlowColor?: RGBA
+  glowTail?: number
+  outerGlowTail?: number
   flashColor?: RGBA
+  outerFlashColor?: RGBA
   completionColor?: RGBA
+  outerCompletionColor?: RGBA
   backgroundColor: RGBA
   onLevel?: (level: number) => void
 }) {
   return (
     <tab_pulse
       position="absolute"
+      top={props.top}
+      edge={props.edge}
       zIndex={0}
-      width="100%"
+      width={props.width ?? "100%"}
       enabled={props.enabled ?? true}
       active={props.active}
+      outerActive={props.outerActive ?? props.active}
       promptPulse={props.promptPulse ?? 0}
+      outerPromptPulse={props.outerPromptPulse ?? props.promptPulse ?? 0}
       complete={props.complete ?? false}
+      outerComplete={props.outerComplete ?? props.complete ?? false}
       glow={props.glow ?? false}
+      outerGlow={props.outerGlow ?? props.glow ?? false}
       breathe={props.breathe ?? false}
+      outerBreathe={props.outerBreathe ?? props.breathe ?? false}
       color={props.color}
+      outerColor={props.outerColor ?? props.color}
       glowColor={props.glowColor ?? props.color}
+      outerGlowColor={props.outerGlowColor ?? props.glowColor ?? props.color}
+      glowTail={props.glowTail ?? GLOW_TAIL}
+      outerGlowTail={props.outerGlowTail ?? props.glowTail ?? GLOW_TAIL}
       flashColor={props.flashColor ?? props.color}
+      outerFlashColor={props.outerFlashColor ?? props.flashColor ?? props.outerColor ?? props.color}
       completionColor={props.completionColor ?? props.color}
+      outerCompletionColor={props.outerCompletionColor ?? props.completionColor ?? props.outerColor ?? props.color}
       backgroundColor={props.backgroundColor}
       onLevel={props.onLevel}
     />

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

@@ -132,6 +132,9 @@ export const Info = Schema.Struct({
       scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
         description: "Share tabs globally or keep a separate set for each working directory",
       }),
+      vertical: Schema.optional(Schema.Boolean).annotate({
+        description: "Show tabs in a left sidebar instead of a horizontal strip",
+      }),
     }),
   ).annotate({ description: "Tab strip settings" }),
   mini: Schema.optional(

+ 5 - 0
packages/tui/src/context/session-tabs-model.ts

@@ -19,6 +19,11 @@ export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: b
 export const SESSION_TAB_WIDTH = 22
 export const SESSION_TAB_MAX_WIDTH = 32
 export const SESSION_TAB_MIN_WIDTH = 8
+export const SESSION_TAB_VERTICAL_WIDTH = 30
+export const SESSION_TAB_VERTICAL_MIN_WIDTH = 20
+export function sessionTabVerticalWidth(total: number) {
+  return Math.min(SESSION_TAB_VERTICAL_WIDTH, Math.max(SESSION_TAB_VERTICAL_MIN_WIDTH, Math.floor(total * 0.3)))
+}
 // Overflow markers reserve one gap cell beside the arrow and count, e.g. "‹12 " and " 12›".
 export const sessionTabOverflowWidth = (count: number) => String(count).length + 2
 

+ 6 - 2
packages/tui/src/routes/session/index.tsx

@@ -64,6 +64,7 @@ import { errorMessage } from "../../util/error"
 import { useToast } from "../../ui/toast"
 import stripAnsi from "strip-ansi"
 import { usePromptRef } from "../../context/prompt"
+import { sessionTabVerticalWidth } from "../../context/session-tabs-model"
 import { projectedPromptInput } from "../../prompt/codec"
 import { useEpilogue } from "../../context/epilogue"
 import { normalizePath } from "../../util/path"
@@ -199,14 +200,17 @@ export function Session() {
   const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
   const groupExploration = createMemo(() => config.session?.grouping !== "none")
 
-  const wide = createMemo(() => dimensions().width > 120)
+  const tabRailWidth = createMemo(() =>
+    config.tabs?.enabled && config.tabs.vertical ? sessionTabVerticalWidth(dimensions().width) : 0,
+  )
+  const wide = createMemo(() => dimensions().width - tabRailWidth() > 120)
   const sidebarVisible = createMemo(() => {
     if (session()?.parentID) return false
     if (sidebarOpen()) return true
     if (sidebar() === "auto" && wide()) return true
     return false
   })
-  const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
+  const contentWidth = createMemo(() => dimensions().width - tabRailWidth() - (sidebarVisible() ? 42 : 0) - 4)
   const models = createMemo(() => data.location.model.list(location()) ?? [])
 
   const scrollAcceleration = createMemo(() => getScrollAcceleration(config))

+ 7 - 0
packages/tui/src/util/project.ts

@@ -0,0 +1,7 @@
+import path from "path"
+
+export function projectName(project?: { canonical: string; name?: string }, fallback = "") {
+  const canonical = project?.canonical ?? fallback
+  if (canonical === "/") return fallback ? path.basename(fallback) : undefined
+  return project?.name || path.basename(canonical)
+}

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

@@ -17,7 +17,7 @@ test("validates mini replay settings", () => {
 test("validates the session tabs setting", () => {
   const decode = Schema.decodeUnknownSync(Info)
 
-  expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
+  expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
   expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
 })
 

+ 7 - 0
packages/tui/test/context/session-tabs-model.test.ts

@@ -12,9 +12,16 @@ import {
   seedSessionTabMotion,
   sessionTabComplete,
   sessionTabOverflowWidth,
+  sessionTabVerticalWidth,
 } from "../../src/context/session-tabs-model"
 
 describe("session tabs", () => {
+  test("keeps the vertical rail compact while preserving narrow-terminal content", () => {
+    expect(sessionTabVerticalWidth(140)).toBe(30)
+    expect(sessionTabVerticalWidth(90)).toBe(27)
+    expect(sessionTabVerticalWidth(60)).toBe(20)
+  })
+
   test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
     const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
     expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])