mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
feat(tui): resize vertical tab rail (#42615)
This commit is contained in:
@@ -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<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
|
||||
@@ -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}
|
||||
>
|
||||
<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()}>
|
||||
@@ -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"}>
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} />
|
||||
@@ -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()]
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user