Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton 4b21e42c32 feat(tui): wipe in first generated titles 2026-08-11 22:38:44 -04:00
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 135 additions and 44 deletions
+95 -12
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"
@@ -60,6 +61,63 @@ 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)
}
// The soft edge of the title wipe spans a few cells behind the front.
const WIPE_FEATHER = 3
// The outgoing title sits dimmed toward the background while it is being replaced.
const WIPE_OUTGOING_DIM = 0.5
// The first real title wipes in from the left over the placeholder it replaces. Only the
// placeholder → real transition animates; every other title change jumps, so routine
// syncs and renames never lag behind the data (the reason the original wipe was removed).
function createTitleWipe(title: () => string, parts: () => readonly string[], width: () => number, animations: () => boolean) {
const [outgoing, setOutgoing] = createSignal<string>()
const wipe = createAnimatable(
{ front: 1 },
{ enabled: animations, transition: tween({ duration: 0.45, ease: (progress) => 1 - (1 - progress) ** 3 }) },
)
createEffect((previous: string) => {
const next = title()
if (next === previous) return next
if (!isPlaceholderSessionTitle(previous) || isPlaceholderSessionTitle(next)) {
setOutgoing(undefined)
wipe.jump({ front: 1 })
return next
}
setOutgoing(previous)
wipe.jump({ front: 0 })
wipe.animate({ front: 1 })
return next
}, untrack(title))
const active = () => outgoing() !== undefined && wipe.value().front < 1
const displayed = createMemo(() => {
const front = wipe.value().front
const incoming = parts()
const previous = outgoing()
if (previous === undefined || front >= 1) return incoming
const previousParts = Locale.graphemes(Locale.takeWidth(previous, width()))
const length = Math.max(incoming.length, previousParts.length)
const cut = front * length
return Array.from({ length }, (_, index) => (cut - index > 0 ? (incoming[index] ?? " ") : (previousParts[index] ?? " ")))
})
// Tint toward the background per cell: the outgoing text dims as a block (deepening slightly
// as the wipe advances), and freshly revealed characters brighten over the feather behind the
// front, so the edge reads as a soft gradient instead of a hard cut.
const mix = (index: number) => {
if (!active()) return 0
const front = wipe.value().front
const distance = front * displayed().length - index
if (distance <= 0) return Math.min(1, front * 6) * (WIPE_OUTGOING_DIM + 0.25 * front)
if (distance < WIPE_FEATHER) return WIPE_OUTGOING_DIM * (1 - distance / WIPE_FEATHER)
return 0
}
return { parts: displayed, mix, active }
}
function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
const [offset, setOffset] = createSignal(0)
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
@@ -185,13 +243,20 @@ 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()
? marqueeText(title(), titleWidth(), marquee.offset())
: Locale.takeWidth(title(), titleWidth()),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const wipe = createTitleWipe(
title,
createMemo(() => Locale.graphemes(visibleTitle())),
titleWidth,
animations,
)
const visibleTitleParts = wipe.parts
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
const value = session()
@@ -215,8 +280,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 = () => {
@@ -251,7 +318,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const color = glows()
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
: foreground()
return titleFades()
const faded = titleFades()
? fadeTitleColor(
color,
pulseBackground(),
@@ -260,6 +327,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
scrolling() ? marquee.leading() : 0,
)
: color
const mix = wipe.mix(index)
return mix > 0 ? tint(faded, pulseBackground(), mix) : faded
}
const release = () => {
setDragging(undefined)
@@ -373,9 +442,12 @@ 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()}>
<Show when={glows() || titleFades() || wipe.active()} fallback={visibleTitleParts().join("")}>
<For each={visibleTitleParts()}>
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
</For>
@@ -676,6 +748,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
@@ -688,20 +761,28 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
? marqueeText(title(), availableTitleWidth(), marquee.offset())
: Locale.takeWidth(title(), availableTitleWidth()),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const wipe = createTitleWipe(
title,
createMemo(() => Locale.graphemes(visibleTitle())),
availableTitleWidth,
animations,
)
const visibleTitleParts = wipe.parts
const titleFades = createMemo(
() => 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.
const characterColor = (index: number) => {
const base = foreground()
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
return titleFades()
const faded = titleFades()
? fadeTitleColor(
color,
background(),
@@ -710,6 +791,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
scrolling() ? marquee.leading() : 0,
)
: color
const mix = wipe.mix(index)
return mix > 0 ? tint(faded, background(), mix) : faded
}
// The running sweep's level under the number cell, reported by the pulse renderable.
const [sweepLevel, setSweepLevel] = createSignal(0)
@@ -782,9 +865,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={bold()}
attributes={(bold() ?? 0) | (placeholder() ? TextAttributes.ITALIC : 0) || undefined}
>
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
<Show when={glows() || titleFades() || wipe.active()} fallback={visibleTitleParts().join("")}>
<For each={visibleTitleParts()}>
{(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
</For>
@@ -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.
@@ -320,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>
)