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