mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:29:50 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b21e42c32 | |||
| b1ab9999e5 | |||
| 6b2429a276 | |||
| 99166f7c17 | |||
| 08dcf7d731 |
@@ -13083,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
|
||||
@@ -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,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 }) })
|
||||
@@ -103,22 +164,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 +207,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 +235,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,16 +243,22 @@ 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(() => {
|
||||
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 +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 = () => {
|
||||
@@ -246,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(),
|
||||
@@ -255,12 +327,14 @@ 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)
|
||||
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 +351,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,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>
|
||||
@@ -386,7 +463,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 +494,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 +565,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 +584,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 +595,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 +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
|
||||
@@ -617,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(),
|
||||
@@ -639,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)
|
||||
@@ -704,16 +858,16 @@ 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()}>
|
||||
<Show when={glows() || titleFades() || wipe.active()} fallback={visibleTitleParts().join("")}>
|
||||
<For each={visibleTitleParts()}>
|
||||
{(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
@@ -746,6 +900,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13083,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
|
||||
@@ -13083,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
|
||||
Reference in New Issue
Block a user