Compare commits

...

2 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
2 changed files with 61 additions and 38 deletions
+21 -6
View File
@@ -16,6 +16,7 @@ import {
type SessionTab, type SessionTab,
type SessionTabUnread, type SessionTabUnread,
} from "../context/session-tabs-model" } from "../context/session-tabs-model"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { createAnimatable, spring, tween } from "../ui/animation" import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width" import { stringWidth } from "../util/string-width"
@@ -60,6 +61,11 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
return opacity === 0 ? color : tint(color, background, opacity) 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) { function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
const [offset, setOffset] = createSignal(0) const [offset, setOffset] = createSignal(0)
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) }) const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
@@ -185,6 +191,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const numberWidth = () => 2 const numberWidth = () => 2
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0)) const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session" const title = () => tab.title ?? "Untitled session"
const placeholder = () => isPlaceholderSessionTitle(tab.title)
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0 const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() => const visibleTitle = createMemo(() =>
scrolling() scrolling()
@@ -215,8 +222,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel()) return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel())
} }
const foreground = () => { const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default const base =
return selected() ? theme.text.default : theme.text.subdued 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 complete = () => status().complete
const glowHue = () => { const glowHue = () => {
@@ -373,7 +382,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
fg={foreground()} fg={foreground()}
wrapMode="none" wrapMode="none"
selectable={false} selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined} attributes={
(selected() ? TextAttributes.BOLD : 0) | (placeholder() ? TextAttributes.ITALIC : 0) ||
undefined
}
> >
<Show when={glows() || titleFades()} fallback={visibleTitle()}> <Show when={glows() || titleFades()} fallback={visibleTitle()}>
<For each={visibleTitleParts()}> <For each={visibleTitleParts()}>
@@ -676,6 +688,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const glowColor = () => feedbackColor() ?? accent() const glowColor = () => feedbackColor() ?? accent()
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined)) const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session" 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) 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. // Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
const numberWidth = () => 2 const numberWidth = () => 2
@@ -693,8 +706,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH, () => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
) )
const foreground = () => { const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default const base =
return tint(theme.text.subdued, theme.text.default, selection()) 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 // 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. // spatial falloff as the glow itself; characters beyond the tail stay neutral.
@@ -782,7 +797,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
fg={foreground()} fg={foreground()}
wrapMode="none" wrapMode="none"
selectable={false} selectable={false}
attributes={bold()} attributes={(bold() ?? 0) | (placeholder() ? TextAttributes.ITALIC : 0) || undefined}
> >
<Show when={glows() || titleFades()} fallback={visibleTitle()}> <Show when={glows() || titleFades()} fallback={visibleTitle()}>
<For each={visibleTitleParts()}> <For each={visibleTitleParts()}>
@@ -1,6 +1,7 @@
import { Plugin } from "@opencode-ai/plugin/tui" import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid" 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 { createStore, reconcile } from "solid-js/store"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../../component/session-tabs" import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
import { moveSessionTab } from "../../../context/session-tabs-model" import { moveSessionTab } from "../../../context/session-tabs-model"
@@ -38,6 +39,9 @@ const TRANSCRIPT_FILES = [
function SessionTabsStory(props: { context: Plugin.Context }) { function SessionTabsStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions() 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 theme = props.context.theme
const elevatedTheme = theme.contextual.elevated const elevatedTheme = theme.contextual.elevated
// A keyed store mirrors production: retitles mutate rows in place instead of remounting them. // A keyed store mirrors production: retitles mutate rows in place instead of remounting them.
@@ -320,39 +324,43 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
<box <box
width={dimensions().width} width={dimensions().width}
height={dimensions().height} height={dimensions().height}
flexDirection="column" flexDirection={orientation() === "vertical" ? "row" : "column"}
backgroundColor={theme.background.default} backgroundColor={theme.background.default}
> >
<SessionTabs controller={controller} /> <SessionTabs controller={controller} orientation={orientation()} />
<box height={1} /> <box flexGrow={1} flexDirection="column">
<box flexGrow={1} paddingLeft={2} paddingRight={2} flexDirection="column"> <Show when={orientation() === undefined}>
<For each={transcript()}> <box height={1} />
{(line) => ( </Show>
<text fg={line.color} wrapMode="none" selectable={false}> <box flexGrow={1} paddingLeft={2} paddingRight={2} flexDirection="column">
{line.text || " "} <For each={transcript()}>
</text> {(line) => (
)} <text fg={line.color} wrapMode="none" selectable={false}>
</For> {line.text || " "}
</box> </text>
<box paddingLeft={2} flexDirection="column"> )}
<text fg={theme.text.subdued}> </For>
selected: {number(active() ?? "")} | state: {selectedState()} </box>
</text> <box paddingLeft={2} flexDirection="column">
<text fg={theme.text.subdued}>background: {lastEvent()}</text> <text fg={theme.text.subdued}>
</box> selected: {number(active() ?? "")} | state: {selectedState()}
<box </text>
height={1} <text fg={theme.text.subdued}>background: {lastEvent()}</text>
flexShrink={0} </box>
backgroundColor={elevatedTheme.background.default} <box
paddingLeft={1} height={1}
paddingRight={1} flexShrink={0}
flexDirection="row" backgroundColor={elevatedTheme.background.default}
> paddingLeft={1}
<text fg={elevatedTheme.text.subdued}>storybook / tabs</text> paddingRight={1}
<box flexGrow={1} /> flexDirection="row"
<text fg={elevatedTheme.text.subdued}> >
space/s run | t add | d close | r reset | / 1-0 move | drag reorders | esc back <text fg={elevatedTheme.text.subdued}>storybook / tabs</text>
</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>
</box> </box>
) )