Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton b1ab9999e5 feat(tui): dim placeholder tab titles 2026-08-11 22:38:35 -04:00
Kit Langton 6b2429a276 feat(tui): storybook tabs follow configured layout 2026-08-11 22:38:35 -04:00
Kit Langton 99166f7c17 feat(tui): add plus button to session tab bar (#41887) 2026-08-11 22:38:08 -04:00
opencode-agent[bot] 08dcf7d731 chore: generate 2026-08-12 02:23:16 +00:00
7 changed files with 207 additions and 66 deletions
+3
View File
@@ -13083,6 +13083,9 @@
},
"text": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["id", "time", "type", "text"],
+115 -16
View File
@@ -16,6 +16,7 @@ import {
type SessionTab,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
@@ -27,6 +28,8 @@ import { marqueeText } from "../util/marquee"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
// The add button renders as " + " at the end of the strip, so the tab layout leaves it room.
const ADD_TAB_WIDTH = 3
const MARQUEE_DELAY = 600
const MARQUEE_INTERVAL = 100
@@ -42,6 +45,7 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
newTab?: () => boolean
add?: () => void
status(sessionID: string): SessionTabsStatus
}
@@ -57,6 +61,11 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
return opacity === 0 ? color : tint(color, background, opacity)
}
// A tab title is provisional until the session earns a generated or user-provided one.
function isPlaceholderSessionTitle(value: string | undefined) {
return value === NEW_SESSION_TAB_TITLE || value === "Untitled session" || isFallbackTitle(value)
}
function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
const [offset, setOffset] = createSignal(0)
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
@@ -103,22 +112,23 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const activeID = createMemo(() => (newTab() ? undefined : tabs.current()))
const ordered = createMemo(() => {
const pending = preview()
if (!pending) return tabs.tabs()
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
const items = ordered
const statuses = createMemo(
() =>
new Map(
items().map((tab) => {
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
const status = tabs.status(tab.sessionID)
return [
tab.sessionID,
{
@@ -145,6 +155,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
createEffect(() => {
if (!scroll) return
// The promoted new-session slot sits below the list, so bring the rail's bottom into view.
if (newTab()) return scroll.scrollTo(Math.max(0, items().length * 3 + 1 - scroll.viewport.height))
const index = items().findIndex((tab) => tab.sessionID === activeID())
if (index === -1) return
const top = index * 3
@@ -171,7 +183,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const selected = () => activeID() === tab.sessionID
const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0)
const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID)))
const session = createMemo(() => data.session.get(tab.sessionID))
const project = createMemo(() => {
const value = session()
return value ? data.project.get(value.projectID) : undefined
@@ -179,6 +191,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const numberWidth = () => 2
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session"
const placeholder = () => isPlaceholderSessionTitle(tab.title)
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
@@ -188,7 +201,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
const value = session()
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
})
@@ -210,8 +222,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel())
}
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return selected() ? theme.text.default : theme.text.subdued
const base =
hovered() === tab.sessionID || selected() ? theme.text.default : theme.text.subdued
// A provisional title reads dimmer than its neighbors until the real one arrives.
return placeholder() ? tint(base, pulseBackground(), 0.35) : base
}
const complete = () => status().complete
const glowHue = () => {
@@ -260,7 +274,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID)
tabs.select(tab.sessionID)
}
return (
<box
@@ -277,7 +291,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}}
onMouseUp={release}
onMouseDrag={(event) => {
if (!rail || tab === NEW_SESSION_TAB) return
if (!rail) return
const target = Math.max(
0,
Math.min(
@@ -368,7 +382,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
attributes={
(selected() ? TextAttributes.BOLD : 0) | (placeholder() ? TextAttributes.ITALIC : 0) ||
undefined
}
>
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
<For each={visibleTitleParts()}>
@@ -386,7 +403,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseUp={(event) => {
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "×" : ""}
@@ -417,6 +434,63 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
)
}}
</For>
{/* One slot with two states: a subdued affordance that promotes in place into the
active new-session tab, instead of spawning a separate pseudo tab above itself. */}
<Show when={tabs.add || newTab()}>
<box
height={1}
width="100%"
position="relative"
flexDirection="row"
paddingLeft={1}
backgroundColor={
newTab()
? theme.background.action.primary.selected
: addHovered()
? theme.background.action.primary.hovered
: theme.background.default
}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => {
if (!newTab()) tabs.add?.()
}}
>
<text
width={2}
fg={newTab() ? activeNumber() : addHovered() ? theme.text.default : idleNumber()}
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
+
</text>
<text
fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
wrapMode="none"
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
{NEW_SESSION_TAB_TITLE}
</text>
<Show when={newTab()}>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={theme.text.subdued}
selectable={false}
onMouseUp={(event) => {
if (!addHovered()) return
event.stopPropagation()
tabs.close()
}}
>
{addHovered() ? "×" : ""}
</text>
</Show>
</box>
</Show>
</box>
</scrollbox>
</box>
@@ -431,6 +505,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
@@ -449,7 +524,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
if (!pending) return tabs.tabs()
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
// The promoted new-session slot joins the strip as the active tab; the idle plus affordance
// and the promoted slot are mutually exclusive states of one control.
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
const showPlus = () => Boolean(tabs.add) && !newTab()
createEffect(() => {
const pending = preview()
if (!pending || dragging()) return
@@ -457,7 +535,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
})
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
adaptiveSessionTabLayout(
items(),
activeID(),
dimensions().width - (showPlus() ? ADD_TAB_WIDTH : 0),
previous?.start,
),
)
const statuses = createMemo(
() =>
@@ -605,6 +688,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const glowColor = () => feedbackColor() ?? accent()
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const placeholder = () => tab !== NEW_SESSION_TAB && isPlaceholderSessionTitle(tab.title)
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
const numberWidth = () => 2
@@ -622,8 +706,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
)
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return tint(theme.text.subdued, theme.text.default, selection())
const base =
hovered() === tab.sessionID ? theme.text.default : tint(theme.text.subdued, theme.text.default, selection())
// A provisional title reads dimmer than its neighbors until the real one arrives.
return placeholder() ? tint(base, background(), 0.35) : base
}
// Title characters sitting over the glow tinge toward its color, following the same
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
@@ -704,14 +790,14 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
{" "}
</text>
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{sessionTabShortcutLabel(tabNumber() - 1)}
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
</text>
<text
width={availableTitleWidth()}
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={bold()}
attributes={(bold() ?? 0) | (placeholder() ? TextAttributes.ITALIC : 0) || undefined}
>
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
<For each={visibleTitleParts()}>
@@ -746,6 +832,19 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
{" " + layout().after}
</text>
</Show>
<Show when={showPlus()}>
<text
width={ADD_TAB_WIDTH}
fg={addHovered() ? theme.text.default : theme.text.subdued}
bg={addHovered() ? theme.background.action.primary.hovered : undefined}
selectable={false}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => tabs.add?.()}
>
{" + "}
</text>
</Show>
</box>
)
}
+10
View File
@@ -7,6 +7,7 @@ import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallbac
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useConfig } from "../config"
import { useLocation } from "./location"
import { useStorage } from "./storage"
import { useTuiPaths } from "./runtime"
import {
@@ -48,6 +49,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const data = useData()
const event = useEvent()
const config = useConfig().data
const location = useLocation()
const paths = useTuiPaths()
const enabled = () => config.tabs.enabled
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
@@ -249,6 +251,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) })
},
add() {
if (!enabled()) return
const sessionID = current()
route.navigate({
type: "home",
location: (sessionID ? data.session.get(sessionID)?.location : undefined) ?? location.ref,
})
},
close(sessionID?: string) {
if (!enabled()) return
const target = sessionID ? root(sessionID) : current()
@@ -1,6 +1,7 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal, For, onCleanup } from "solid-js"
import { batch, createSignal, For, onCleanup, Show } from "solid-js"
import { useConfig } from "../../../config"
import { createStore, reconcile } from "solid-js/store"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
import { moveSessionTab } from "../../../context/session-tabs-model"
@@ -38,6 +39,9 @@ const TRANSCRIPT_FILES = [
function SessionTabsStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const config = useConfig().data
// The story follows the configured layout so both orientations are exercised.
const orientation = () => (config.tabs.layout === "vertical" ? ("vertical" as const) : undefined)
const theme = props.context.theme
const elevatedTheme = theme.contextual.elevated
// A keyed store mirrors production: retitles mutate rows in place instead of remounting them.
@@ -105,9 +109,21 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
}
}
const addTab = () => {
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
if (!next) {
setLastEvent("all fixture tabs are open")
return
}
setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }])
select(next.sessionID)
setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`)
}
const controller = {
tabs,
current: active,
add: addTab,
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
},
@@ -283,21 +299,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
startRun(current)
},
},
{
bind: "t",
title: "Add tab",
group: "Storybook",
run() {
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
if (!next) {
setLastEvent("all fixture tabs are open")
return
}
setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }])
select(next.sessionID)
setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`)
},
},
{ bind: "t", title: "Add tab", group: "Storybook", run: addTab },
{ bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() },
{
bind: "r",
@@ -322,39 +324,43 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
<box
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
flexDirection={orientation() === "vertical" ? "row" : "column"}
backgroundColor={theme.background.default}
>
<SessionTabs controller={controller} />
<box height={1} />
<box flexGrow={1} paddingLeft={2} paddingRight={2} flexDirection="column">
<For each={transcript()}>
{(line) => (
<text fg={line.color} wrapMode="none" selectable={false}>
{line.text || " "}
</text>
)}
</For>
</box>
<box paddingLeft={2} flexDirection="column">
<text fg={theme.text.subdued}>
selected: {number(active() ?? "")} | state: {selectedState()}
</text>
<text fg={theme.text.subdued}>background: {lastEvent()}</text>
</box>
<box
height={1}
flexShrink={0}
backgroundColor={elevatedTheme.background.default}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>storybook / tabs</text>
<box flexGrow={1} />
<text fg={elevatedTheme.text.subdued}>
space/s run | t add | d close | r reset | / 1-0 move | drag reorders | esc back
</text>
<SessionTabs controller={controller} orientation={orientation()} />
<box flexGrow={1} flexDirection="column">
<Show when={orientation() === undefined}>
<box height={1} />
</Show>
<box flexGrow={1} paddingLeft={2} paddingRight={2} flexDirection="column">
<For each={transcript()}>
{(line) => (
<text fg={line.color} wrapMode="none" selectable={false}>
{line.text || " "}
</text>
)}
</For>
</box>
<box paddingLeft={2} flexDirection="column">
<text fg={theme.text.subdued}>
selected: {number(active() ?? "")} | state: {selectedState()}
</text>
<text fg={theme.text.subdued}>background: {lastEvent()}</text>
</box>
<box
height={1}
flexShrink={0}
backgroundColor={elevatedTheme.background.default}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>storybook / tabs</text>
<box flexGrow={1} />
<text fg={elevatedTheme.text.subdued}>
space/s run | t add | d close | r reset | / 1-0 move | drag reorders | esc back
</text>
</box>
</box>
</box>
)
@@ -7,6 +7,7 @@ import path from "path"
import { ConfigProvider } from "../../src/config"
import { ClientProvider, useClient } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { LocationProvider } from "../../src/context/location"
import { RouteProvider, useRoute } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
@@ -86,9 +87,11 @@ async function renderSessionTabs(
>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<SessionTabsProvider>
<Probe />
</SessionTabsProvider>
<LocationProvider>
<SessionTabsProvider>
<Probe />
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
@@ -272,3 +275,17 @@ test("tracks a temporary new session tab across close and creation", async () =>
await setup.destroy()
}
})
test("add opens the new session tab carrying the current session's location", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined)
setup.tabs.add()
expect(setup.route.data).toEqual({ type: "home", location: { directory } })
await wait(() => setup.tabs.newTab())
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"])
} finally {
await setup.destroy()
}
})
+3
View File
@@ -13083,6 +13083,9 @@
},
"text": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["id", "time", "type", "text"],
+3
View File
@@ -13083,6 +13083,9 @@
},
"text": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["id", "time", "type", "text"],