Compare commits

..

5 Commits

Author SHA1 Message Date
Kit Langton 5e763bac34 Merge remote-tracking branch 'origin/v2' into tab-context-menu
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/tui/src/component/session-tabs.tsx
2026-08-12 12:27:54 -04:00
Kit Langton bbc9307ec2 refactor(tui): simplify tab context menu 2026-08-12 12:25:42 -04:00
Kit Langton f2bfdef450 fix(tui): match tab menu styling 2026-08-12 12:16:55 -04:00
Kit Langton 686214dd7e fix(tui): clarify tab menu selection 2026-08-12 12:13:34 -04:00
Kit Langton c74be8312e feat(tui): add session tab context menu 2026-08-12 11:53:52 -04:00
+172 -10
View File
@@ -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<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "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 (
<box
position="absolute"
left={left()}
top={top()}
height={actions().length}
width={CONTEXT_MENU_WIDTH}
zIndex={2500}
flexDirection="column"
backgroundColor={theme.background.default}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
>
<For each={actions()}>
{(action, index) => (
<box
width="100%"
paddingLeft={1}
paddingRight={1}
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setSelected(index())}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
run(index())
}}
>
<text fg={theme.text.default} selectable={false}>
{action.title}
</text>
</box>
)}
</For>
</box>
)
}
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<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
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
</Show>
</box>
</scrollbox>
<Show when={contextMenu()}>
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
</Show>
</box>
)
}
@@ -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<TabContextMenuState>()
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?.()
}}
>
{" + "}
</text>
</Show>
<Show when={contextMenu()}>
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
</Show>
</box>
)
}