Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 99 additions & 7 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
MouseButton,
type CliRenderer,
type CliRendererConfig,
type MouseEvent,
type ThemeMode,
} from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
Expand Down Expand Up @@ -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"
Expand All @@ -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()
Expand Down Expand Up @@ -471,13 +472,49 @@ 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()
const exit = useExit()
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<SessionInfo[]> | 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1233,9 +1272,23 @@ 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" />
<box
flexGrow={1}
minHeight={0}
flexDirection="row"
position="relative"
onMouseDrag={(event) => {
if (!tabsResizing()) return
tabsResizeMoved = true
lastTabsBoundaryClick = 0
resizeVerticalTabs(event.x + 1)
event.stopPropagation()
}}
onMouseDragEnd={finishTabsResize}
onMouseUp={finishTabsResize}
>
<Show when={verticalTabsVisible()}>
<SessionTabs orientation="vertical" width={verticalTabsWidth()} />
</Show>
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
Expand All @@ -1249,7 +1302,7 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
{(_) => <Session />}
{(_) => <Session verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0} />}
</Show>
</Match>
<Match when={route.data.type === "plugin"}>
Expand All @@ -1264,6 +1317,45 @@ function App(props: { pair?: DialogPairCredentials }) {
<Slot path="app" />
</Show>
</box>
<Show when={verticalTabsVisible()}>
<box
position="absolute"
left={verticalTabsWidth() - 1}
top={0}
zIndex={10}
width={2}
height="100%"
onMouseOver={() => 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()
}}
>
<box
width={1}
height="100%"
backgroundColor={
tabsResizeHovered() || tabsResizing()
? tabsTheme.background.action.primary.hovered
: tabsTheme.background.default
}
/>
</box>
</Show>
</box>
<Show when={devtools()}>
<DevToolsBar />
Expand Down
7 changes: 4 additions & 3 deletions packages/tui/src/component/session-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,12 +273,13 @@ export function SessionTabs(
controller?: SessionTabsController
animations?: boolean
orientation?: "horizontal" | "vertical"
width?: number
} = {},
) {
return (
<Switch>
<Match when={props.orientation === "vertical"}>
<VerticalSessionTabs controller={props.controller} animations={props.animations} />
<VerticalSessionTabs controller={props.controller} animations={props.animations} width={props.width} />
</Match>
<Match when={true}>
<HorizontalSessionTabs controller={props.controller} animations={props.animations} />
Expand All @@ -287,15 +288,15 @@ 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()
const theme = useTheme("elevated")
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()]
Expand Down
13 changes: 3 additions & 10 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
},
Expand Down
13 changes: 11 additions & 2 deletions packages/tui/src/ui/layout.ts
Original file line number Diff line number Diff line change
@@ -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),
)
}
20 changes: 19 additions & 1 deletion packages/tui/test/ui/layout.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading