diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b8352fe58e3..6769cc9dd27 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -15,6 +15,7 @@ import { MouseButton, type CliRenderer, type CliRendererConfig, + type MouseEvent, type ThemeMode, } from "@opentui/core" import { RouteProvider, useRoute } from "./context/route" @@ -71,7 +72,7 @@ import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open" import { SessionTabs } from "./component/session-tabs" -import { sessionTabsFitVertically } from "./ui/layout" +import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "./ui/layout" import { ThemeErrorToast } from "./component/theme-error-toast" import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme" import { Home } from "./routes/home" @@ -98,7 +99,7 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" import { AttentionProvider } from "./context/attention" -import { StorageProvider } from "./context/storage" +import { StorageProvider, useStorage } from "./context/storage" import { createTuiClipboard } from "./clipboard" registerOpencodeSpinner() @@ -471,6 +472,7 @@ function App(props: { pair?: DialogPairCredentials }) { const client = useClient() const toast = useToast() const theme = useTheme() + const tabsTheme = useTheme("elevated") const { mode, supports, setMode, locked, lock, unlock } = useThemes() const data = useData() const location = useLocation() @@ -478,6 +480,41 @@ function App(props: { pair?: DialogPairCredentials }) { const promptRef = usePromptRef() const plugins = usePlugin() const clipboard = useClipboard() + const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", { + initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH }, + }) + const [preferredTabsWidth, setPreferredTabsWidth] = createSignal(layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH) + const [tabsResizeHovered, setTabsResizeHovered] = createSignal(false) + const [tabsResizing, setTabsResizing] = createSignal(false) + let requestedTabsWidth = layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH + createEffect(() => { + if (tabsResizing()) return + requestedTabsWidth = layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH + setPreferredTabsWidth(requestedTabsWidth) + }) + const verticalTabsWidth = () => clampSessionTabsWidth(preferredTabsWidth(), dimensions().width) + const resizeVerticalTabs = (width: number) => setPreferredTabsWidth(clampSessionTabsWidth(width, dimensions().width)) + const commitVerticalTabsWidth = (width: number) => { + const next = clampSessionTabsWidth(width, dimensions().width) + setPreferredTabsWidth(next) + if (requestedTabsWidth === next) return + requestedTabsWidth = next + void updateLayout((draft) => { + draft.verticalTabsWidth = next + }).catch((error) => console.error("Failed to persist TUI layout", error)) + } + let tabsResizeMoved = false + let lastTabsBoundaryClick = 0 + const finishTabsResize = (event: MouseEvent) => { + if (!tabsResizing()) return + const next = tabsResizeMoved ? event.x + 1 : verticalTabsWidth() + setTabsResizing(false) + lastTabsBoundaryClick = tabsResizeMoved ? 0 : Date.now() + commitVerticalTabsWidth(next) + const width = clampSessionTabsWidth(next, dimensions().width) + setTabsResizeHovered(event.x >= width - 1 && event.x <= width) + event.stopPropagation() + } let openingOpen: Promise | undefined // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, // without having to open the status panel. Tracking the last alerted status avoids re-toasting @@ -536,9 +573,11 @@ 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.layout === "vertical" && sessionTabsFitVertically(dimensions().width) + const tabsVertical = () => + config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, preferredTabsWidth()) const tabsVisible = () => sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin" + const verticalTabsVisible = () => tabsVisible() && tabsVertical() createEffect(() => { renderer.useMouse = config.data.mouse @@ -1233,9 +1272,23 @@ function App(props: { pair?: DialogPairCredentials }) { }} onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined} > - - - + { + if (!tabsResizing()) return + tabsResizeMoved = true + lastTabsBoundaryClick = 0 + resizeVerticalTabs(event.x + 1) + event.stopPropagation() + }} + onMouseDragEnd={finishTabsResize} + onMouseUp={finishTabsResize} + > + + @@ -1249,7 +1302,7 @@ function App(props: { pair?: DialogPairCredentials }) { - {(_) => } + {(_) => } @@ -1264,6 +1317,45 @@ function App(props: { pair?: DialogPairCredentials }) { + + setTabsResizeHovered(true)} + onMouseOut={() => setTabsResizeHovered(false)} + onMouseDown={(event) => { + if (event.button !== MouseButton.LEFT) return + const now = Date.now() + if (now - lastTabsBoundaryClick < 300) { + lastTabsBoundaryClick = 0 + setTabsResizing(false) + setTabsResizeHovered(false) + commitVerticalTabsWidth(SESSION_SIDEBAR_WIDTH) + event.preventDefault() + event.stopPropagation() + return + } + tabsResizeMoved = false + setTabsResizing(true) + event.preventDefault() + event.stopPropagation() + }} + > + + + diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index ebce53ee7dc..84e41f68126 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -273,12 +273,13 @@ export function SessionTabs( controller?: SessionTabsController animations?: boolean orientation?: "horizontal" | "vertical" + width?: number } = {}, ) { return ( - + @@ -287,7 +288,7 @@ export function SessionTabs( ) } -function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean }) { +function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean; width?: number }) { const contextTabs = useSessionTabs() const tabs: SessionTabsController = props.controller ?? contextTabs const data = useData() @@ -295,7 +296,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const { mode } = useThemes() const config = useConfig().data const animations = () => props.animations ?? config.animations ?? true - const width = () => SESSION_SIDEBAR_WIDTH + const width = () => props.width ?? SESSION_SIDEBAR_WIDTH const hueStep = () => (mode() === "light" ? 800 : 200) const accent = () => theme.hue.accent[hueStep()] const activeNumber = () => theme.hue.interactive[hueStep()] diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 91d20b4c94a..1be96e4cdea 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -68,7 +68,6 @@ import { errorMessage } from "../../util/error" import { useToast } from "../../ui/toast" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" -import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout" import { projectedPromptInput } from "../../prompt/codec" import { deduplicateVisibleImages } from "../../prompt/attachment" import { useEpilogue } from "../../context/epilogue" @@ -141,7 +140,7 @@ function use() { return ctx } -export function Session() { +export function Session(props: { verticalTabsWidth: number }) { const setEpilogue = useEpilogue() const clipboard = useClipboard() const writeExport = async (file: string, content: string) => { @@ -228,13 +227,7 @@ export function Session() { const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word") const groupExploration = createMemo(() => config.session?.grouping !== "none") - const availableWidth = createMemo( - () => - dimensions().width - - (config.tabs?.enabled && config.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width) - ? SESSION_SIDEBAR_WIDTH - : 0), - ) + const availableWidth = createMemo(() => dimensions().width - props.verticalTabsWidth) const wide = createMemo(() => availableWidth() > 120) const sidebarVisible = createMemo(() => { if (session()?.parentID) return false @@ -290,7 +283,7 @@ export function Session() { createEffect( on( - () => [dimensions().width, dimensions().height] as const, + () => [dimensions().width, dimensions().height, props.verticalTabsWidth] as const, (_, previous) => { if (previous) clearMessageNavigation() }, diff --git a/packages/tui/src/ui/layout.ts b/packages/tui/src/ui/layout.ts index 2fa311619fa..e1dad76fe7f 100644 --- a/packages/tui/src/ui/layout.ts +++ b/packages/tui/src/ui/layout.ts @@ -1,6 +1,15 @@ export const SESSION_SIDEBAR_WIDTH = 42 +export const SESSION_SIDEBAR_MIN_WIDTH = 24 +export const SESSION_SIDEBAR_MAX_WIDTH = 72 const SESSION_CONTENT_MIN_WIDTH = 44 -export function sessionTabsFitVertically(total: number) { - return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH +export function sessionTabsFitVertically(total: number, width = SESSION_SIDEBAR_WIDTH) { + return total >= width + SESSION_CONTENT_MIN_WIDTH +} + +export function clampSessionTabsWidth(width: number, total: number) { + return Math.max( + SESSION_SIDEBAR_MIN_WIDTH, + Math.min(width, SESSION_SIDEBAR_MAX_WIDTH, total - SESSION_CONTENT_MIN_WIDTH), + ) } diff --git a/packages/tui/test/ui/layout.test.ts b/packages/tui/test/ui/layout.test.ts index 53c9a824799..23aabf98fc2 100644 --- a/packages/tui/test/ui/layout.test.ts +++ b/packages/tui/test/ui/layout.test.ts @@ -1,8 +1,26 @@ import { expect, test } from "bun:test" -import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout" +import { + clampSessionTabsWidth, + sessionTabsFitVertically, + SESSION_SIDEBAR_MAX_WIDTH, + SESSION_SIDEBAR_MIN_WIDTH, + SESSION_SIDEBAR_WIDTH, +} from "../../src/ui/layout" test("vertical tabs match the session sidebar and preserve compact content width", () => { expect(SESSION_SIDEBAR_WIDTH).toBe(42) expect(sessionTabsFitVertically(86)).toBe(true) expect(sessionTabsFitVertically(85)).toBe(false) }) + +test("vertical tabs account for a resized width", () => { + expect(sessionTabsFitVertically(104, 60)).toBe(true) + expect(sessionTabsFitVertically(103, 60)).toBe(false) +}) + +test("vertical tab width preserves minimum rail and content widths", () => { + expect(clampSessionTabsWidth(10, 120)).toBe(SESSION_SIDEBAR_MIN_WIDTH) + expect(clampSessionTabsWidth(50, 120)).toBe(50) + expect(clampSessionTabsWidth(100, 120)).toBe(SESSION_SIDEBAR_MAX_WIDTH) + expect(clampSessionTabsWidth(100, 100)).toBe(56) +})