mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b21e42c32 | |||
| b1ab9999e5 | |||
| 6b2429a276 |
@@ -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,63 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
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,13 +243,20 @@ 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()
|
||||||
? marqueeText(title(), titleWidth(), marquee.offset())
|
? marqueeText(title(), titleWidth(), marquee.offset())
|
||||||
: Locale.takeWidth(title(), titleWidth()),
|
: 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 titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
||||||
const detail = createMemo(() => {
|
const detail = createMemo(() => {
|
||||||
const value = session()
|
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())
|
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 = () => {
|
||||||
@@ -251,7 +318,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||||||
const color = glows()
|
const color = glows()
|
||||||
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
|
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
|
||||||
: foreground()
|
: foreground()
|
||||||
return titleFades()
|
const faded = titleFades()
|
||||||
? fadeTitleColor(
|
? fadeTitleColor(
|
||||||
color,
|
color,
|
||||||
pulseBackground(),
|
pulseBackground(),
|
||||||
@@ -260,6 +327,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||||||
scrolling() ? marquee.leading() : 0,
|
scrolling() ? marquee.leading() : 0,
|
||||||
)
|
)
|
||||||
: color
|
: color
|
||||||
|
const mix = wipe.mix(index)
|
||||||
|
return mix > 0 ? tint(faded, pulseBackground(), mix) : faded
|
||||||
}
|
}
|
||||||
const release = () => {
|
const release = () => {
|
||||||
setDragging(undefined)
|
setDragging(undefined)
|
||||||
@@ -373,9 +442,12 @@ 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() || wipe.active()} fallback={visibleTitleParts().join("")}>
|
||||||
<For each={visibleTitleParts()}>
|
<For each={visibleTitleParts()}>
|
||||||
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
|
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
|
||||||
</For>
|
</For>
|
||||||
@@ -676,6 +748,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
|
||||||
@@ -688,20 +761,28 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||||||
? marqueeText(title(), availableTitleWidth(), marquee.offset())
|
? marqueeText(title(), availableTitleWidth(), marquee.offset())
|
||||||
: Locale.takeWidth(title(), availableTitleWidth()),
|
: 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(
|
const titleFades = createMemo(
|
||||||
() => 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.
|
||||||
const characterColor = (index: number) => {
|
const characterColor = (index: number) => {
|
||||||
const base = foreground()
|
const base = foreground()
|
||||||
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
|
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
|
||||||
return titleFades()
|
const faded = titleFades()
|
||||||
? fadeTitleColor(
|
? fadeTitleColor(
|
||||||
color,
|
color,
|
||||||
background(),
|
background(),
|
||||||
@@ -710,6 +791,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||||||
scrolling() ? marquee.leading() : 0,
|
scrolling() ? marquee.leading() : 0,
|
||||||
)
|
)
|
||||||
: color
|
: 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.
|
// The running sweep's level under the number cell, reported by the pulse renderable.
|
||||||
const [sweepLevel, setSweepLevel] = createSignal(0)
|
const [sweepLevel, setSweepLevel] = createSignal(0)
|
||||||
@@ -782,9 +865,9 @@ 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() || wipe.active()} fallback={visibleTitleParts().join("")}>
|
||||||
<For each={visibleTitleParts()}>
|
<For each={visibleTitleParts()}>
|
||||||
{(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
|
{(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user