From 10ebf70a075d14c4bcae414e403a236917411db5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 12:45:59 -0400 Subject: [PATCH] feat(tui): add session tab context menu (#42068) --- packages/tui/src/component/session-tabs.tsx | 182 ++++++++++++++++++-- 1 file changed, 172 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 548b0a26ae..49d2dffe60 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -1,4 +1,4 @@ -import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" +import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core" import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" import { useConfig } from "../config" @@ -18,12 +18,15 @@ import { } from "../context/session-tabs-model" import { createAnimatable, spring, tween } from "../ui/animation" import { Locale } from "../util/locale" -import { stringWidth } from "../util/string-width" import { TabPulse, unreadGlowIntensity } from "./tab-pulse" import { tint } from "../theme/color" import { SESSION_SIDEBAR_WIDTH } from "../ui/layout" import { projectName } from "../util/project" import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee" +import { useDialog } from "../ui/dialog" +import { DialogSessionRename } from "./dialog-session-rename" +import { Keymap } from "../context/keymap" +import { moveSelection } from "../ui/select-controller" // A long title fades out over its last cells instead of cutting hard. const FADE_WIDTH = 4 @@ -31,6 +34,15 @@ const FADE_WIDTH = 4 const ADD_TAB_WIDTH = 3 const MARQUEE_DELAY = 600 const MARQUEE_INTERVAL = 100 +const CONTEXT_MENU_WIDTH = 16 +const RIGHT_MOUSE_BUTTON = 2 + +type TabContextMenuState = { + x: number + y: number + sessionID?: string + title?: string +} type ContextController = ReturnType export type SessionTabsStatus = Omit, "unread"> & { @@ -154,6 +166,96 @@ function createTabMarquee(animations: () => boolean) { return { ...marquee, hovered, enter, leave } } +function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) { + const dimensions = useTerminalDimensions() + const theme = useTheme("elevated") + const dialog = useDialog() + const keymap = Keymap.use() + const actions = createMemo(() => { + const sessionID = props.state.sessionID + return [ + ...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []), + ...(sessionID + ? [ + { + title: "Rename", + run: () => DialogSessionRename.show(dialog, sessionID, props.state.title), + }, + { title: "Close", run: () => props.tabs.close(sessionID) }, + ] + : []), + ] + }) + const [selected, setSelected] = createSignal(0) + const top = () => Math.max(0, Math.min(props.state.y + 1, dimensions().height - actions().length)) + const left = () => Math.max(0, Math.min(props.state.x, dimensions().width - CONTEXT_MENU_WIDTH)) + const run = (index: number) => { + props.onClose() + actions()[index]?.run() + } + + createEffect(() => { + const popMode = keymap.mode.push("modal") + onCleanup(popMode) + }) + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ + { bind: "escape", title: "Close tab menu", group: "Tabs", run: props.onClose }, + { + bind: "up", + title: "Previous tab menu item", + group: "Tabs", + run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: -1, policy: "wrap" })), + }, + { + bind: "down", + title: "Next tab menu item", + group: "Tabs", + run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: 1, policy: "wrap" })), + }, + { bind: "return", title: "Select tab menu item", group: "Tabs", run: () => run(selected()) }, + ], + })) + return ( + { + event.preventDefault() + event.stopPropagation() + }} + > + + {(action, index) => ( + setSelected(index())} + onMouseUp={(event) => { + event.preventDefault() + event.stopPropagation() + run(index()) + }} + > + + {action.title} + + + )} + + + ) +} + export function SessionTabs( props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {}, ) { @@ -181,6 +283,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const hovered = marquee.hovered const [dragging, setDragging] = createSignal() const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>() + const [contextMenu, setContextMenu] = createSignal() const newTab = () => tabs.newTab?.() ?? false const activeID = createMemo(() => (newTab() ? undefined : tabs.current())) const ordered = createMemo(() => { @@ -212,7 +315,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat ), ) const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)! - let rail: { screenY: number } | undefined + let rail: { screenX: number; screenY: number } | undefined let scroll: ScrollBoxRenderable | undefined createEffect(() => { @@ -242,6 +345,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat height="100%" flexShrink={0} flexDirection="column" + position="relative" paddingTop={1} backgroundColor={theme.background.default} > @@ -354,11 +458,27 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat backgroundColor={background()} onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())} onMouseOut={() => marquee.leave(tab.sessionID)} - onMouseDown={() => { + onMouseDown={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) { + setDragging(undefined) + if (!rail) return + setContextMenu({ + x: event.x - rail.screenX, + y: event.y - rail.screenY, + sessionID: tab.sessionID, + title: tab.title, + }) + event.preventDefault() + event.stopPropagation() + return + } marquee.enter(tab.sessionID, title(), restingTitleWidth()) setDragging(tab.sessionID) }} - onMouseUp={release} + onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return + release() + }} onMouseDrag={(event) => { if (!rail) return const target = Math.max( @@ -467,6 +587,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat fg={theme.text.subdued} selectable={false} onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return if (hovered() !== tab.sessionID) return event.stopPropagation() tabs.close(tab.sessionID) @@ -518,7 +639,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat } onMouseOver={() => setAddHovered(true)} onMouseOut={() => setAddHovered(false)} - onMouseUp={() => { + onMouseDown={(event: MouseEvent) => { + if (event.button !== RIGHT_MOUSE_BUTTON) return + if (!rail) return + setContextMenu({ x: event.x - rail.screenX, y: event.y - rail.screenY }) + event.preventDefault() + event.stopPropagation() + }} + onMouseUp={(event: MouseEvent) => { + if (event.button === RIGHT_MOUSE_BUTTON) return if (!newTab()) tabs.add?.() }} > @@ -547,6 +676,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat fg={theme.text.subdued} selectable={false} onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return if (!addHovered()) return event.stopPropagation() tabs.close() @@ -559,6 +689,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat + + {(state) => setContextMenu(undefined)} />} + ) } @@ -578,7 +711,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim // per slot crossing; the preview holds after release until the store reflects the move, // so the strip never flashes the pre-drag order while the write is in flight. const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>() - let strip: { screenX: number } | undefined + const [contextMenu, setContextMenu] = createSignal() + let strip: { screenX: number; screenY: number } | undefined const hueStep = () => (mode() === "light" ? 800 : 200) const accent = () => theme.hue.accent[hueStep()] const activeNumber = () => theme.hue.interactive[hueStep()] @@ -825,11 +959,26 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim backgroundColor={background()} onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())} onMouseOut={() => marquee.leave(tab.sessionID)} - onMouseDown={() => { + onMouseDown={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) { + setDragging(undefined) + setContextMenu({ + x: event.x - (strip?.screenX ?? 0), + y: event.y - (strip?.screenY ?? 0), + sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID, + title: tab === NEW_SESSION_TAB ? undefined : tab.title, + }) + event.preventDefault() + event.stopPropagation() + return + } marquee.enter(tab.sessionID, title(), restingTitleWidth()) setDragging(tab.sessionID) }} - onMouseUp={release} + onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return + release() + }} onMouseDrag={(event) => { if (tab === NEW_SESSION_TAB) return const slot = slotAt(event.x) @@ -880,6 +1029,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim fg={closeColor()} selectable={false} onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return // The close mark only renders while hovered; without motion events a click can // land here first, and must select the tab instead of closing it invisibly. if (hovered() !== tab.sessionID) return @@ -907,11 +1057,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim selectable={false} onMouseOver={() => setAddHovered(true)} onMouseOut={() => setAddHovered(false)} - onMouseUp={() => tabs.add?.()} + onMouseDown={(event) => { + if (event.button !== RIGHT_MOUSE_BUTTON) return + setContextMenu({ x: event.x - (strip?.screenX ?? 0), y: event.y - (strip?.screenY ?? 0) }) + event.preventDefault() + event.stopPropagation() + }} + onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return + tabs.add?.() + }} > {" + "} + + {(state) => setContextMenu(undefined)} />} + ) }