feat(tui): add session tab playground (#39432)
This commit is contained in:
parent
f64b50d71b
commit
5504245f7b
2 changed files with 139 additions and 9 deletions
|
|
@ -4,19 +4,33 @@ import { useTerminalDimensions } from "@opentui/solid"
|
|||
import { useConfig } from "../config"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { adaptiveSessionTabLayout, sessionTabComplete, SESSION_TAB_OVERFLOW_WIDTH } from "../context/session-tabs-model"
|
||||
import {
|
||||
adaptiveSessionTabLayout,
|
||||
sessionTabComplete,
|
||||
SESSION_TAB_OVERFLOW_WIDTH,
|
||||
type SessionTabUnread,
|
||||
} from "../context/session-tabs-model"
|
||||
import { createAnimatable, spring } from "../ui/animation"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
|
||||
export function SessionTabs() {
|
||||
const tabs = useSessionTabs()
|
||||
type ContextController = ReturnType<typeof useSessionTabs>
|
||||
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
|
||||
unread: SessionTabUnread | undefined
|
||||
}
|
||||
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close"> & {
|
||||
status(sessionID: string): SessionTabsStatus
|
||||
}
|
||||
|
||||
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
|
|
@ -48,7 +62,7 @@ export function SessionTabs() {
|
|||
activities: layout().tabs.map((tab) => Number(statuses().get(tab.sessionID)!.complete)),
|
||||
}))
|
||||
const motion = createAnimatable(targets(), {
|
||||
enabled: () => config.animations ?? true,
|
||||
enabled: animations,
|
||||
transition: spring({ visualDuration: 0.1 }),
|
||||
})
|
||||
const identity = createMemo(() =>
|
||||
|
|
@ -167,7 +181,7 @@ export function SessionTabs() {
|
|||
onMouseUp={() => tabs.select(tab.sessionID)}
|
||||
>
|
||||
<TabPulse
|
||||
enabled={config.animations ?? true}
|
||||
enabled={animations()}
|
||||
active={status().busy}
|
||||
complete={status().complete}
|
||||
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,26 @@
|
|||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createSignal } from "solid-js"
|
||||
import { SessionTabs, type SessionTabsController } from "../../component/session-tabs"
|
||||
|
||||
type FixtureStatus = ReturnType<SessionTabsController["status"]>
|
||||
|
||||
const FIXTURE_TABS = [
|
||||
{ sessionID: "fixture-1", title: "Implement session tabs" },
|
||||
{ sessionID: "fixture-2", title: "Investigate rendering" },
|
||||
{ sessionID: "fixture-3", title: "A deliberately long session title for truncation" },
|
||||
{ sessionID: "fixture-4", title: "Fix provider state" },
|
||||
{ sessionID: "fixture-5", title: "Review animation" },
|
||||
{ sessionID: "fixture-6", title: "Untitled behavior" },
|
||||
{ sessionID: "fixture-7", title: "Queue follow-up work" },
|
||||
{ sessionID: "fixture-8", title: "Check narrow layout" },
|
||||
{ sessionID: "fixture-9", title: "Profile terminal output" },
|
||||
{ sessionID: "fixture-10", title: "Handle permission" },
|
||||
{ sessionID: "fixture-11", title: "Run focused tests" },
|
||||
{ sessionID: "fixture-12", title: "Prepare review" },
|
||||
]
|
||||
|
||||
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
props.context.keymap.layer(() => ({
|
||||
|
|
@ -24,6 +45,50 @@ function Scrap(props: { context: Plugin.Context }) {
|
|||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme
|
||||
const elevatedTheme = props.context.theme.contextual("elevated")
|
||||
const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6))
|
||||
const [active, setActive] = createSignal<string | undefined>("fixture-2")
|
||||
const [animations, setAnimations] = createSignal(true)
|
||||
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({
|
||||
"fixture-2": { ...EMPTY_STATUS, busy: true },
|
||||
"fixture-3": { ...EMPTY_STATUS, unread: "activity" },
|
||||
"fixture-4": { ...EMPTY_STATUS, unread: "error" },
|
||||
"fixture-5": { ...EMPTY_STATUS, attention: true },
|
||||
"fixture-6": { ...EMPTY_STATUS, busy: true, attention: true },
|
||||
})
|
||||
const controller = {
|
||||
tabs,
|
||||
current: active,
|
||||
status(sessionID) {
|
||||
return statuses()[sessionID] ?? EMPTY_STATUS
|
||||
},
|
||||
select(sessionID) {
|
||||
setActive(sessionID)
|
||||
},
|
||||
close(sessionID?: string) {
|
||||
const target = sessionID ?? active()
|
||||
if (!target) return
|
||||
const items = tabs()
|
||||
const index = items.findIndex((tab) => tab.sessionID === target)
|
||||
if (index === -1) return
|
||||
const next = items.filter((tab) => tab.sessionID !== target)
|
||||
batch(() => {
|
||||
setTabs(next)
|
||||
if (active() === target) setActive(next[index]?.sessionID ?? next[index - 1]?.sessionID)
|
||||
})
|
||||
},
|
||||
} satisfies SessionTabsController
|
||||
|
||||
const cycle = (direction: 1 | -1) => {
|
||||
const items = tabs()
|
||||
if (items.length === 0) return
|
||||
const index = items.findIndex((tab) => tab.sessionID === active())
|
||||
controller.select(items[(index + direction + items.length) % items.length]!.sessionID)
|
||||
}
|
||||
const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => {
|
||||
const sessionID = active()
|
||||
if (!sessionID) return
|
||||
setStatuses((current) => ({ ...current, [sessionID]: update(current[sessionID] ?? EMPTY_STATUS) }))
|
||||
}
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
commands: [
|
||||
|
|
@ -35,12 +100,60 @@ function Scrap(props: { context: Plugin.Context }) {
|
|||
props.context.ui.router.navigate({ type: "home" })
|
||||
},
|
||||
},
|
||||
{ bind: "h", title: "Previous tab", group: "Scrap", run: () => cycle(-1) },
|
||||
{ bind: "l", title: "Next tab", group: "Scrap", run: () => cycle(1) },
|
||||
{
|
||||
bind: "t",
|
||||
title: "Add tab",
|
||||
group: "Scrap",
|
||||
run() {
|
||||
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
|
||||
if (next) setTabs((current) => [...current, next])
|
||||
},
|
||||
},
|
||||
{ bind: "d", title: "Close tab", group: "Scrap", run: () => controller.close() },
|
||||
{
|
||||
bind: "b",
|
||||
title: "Toggle busy",
|
||||
group: "Scrap",
|
||||
run: () =>
|
||||
updateStatus((status) =>
|
||||
status.busy ? { ...status, busy: false, unread: "activity" } : { ...status, busy: true, unread: undefined },
|
||||
),
|
||||
},
|
||||
{
|
||||
bind: "u",
|
||||
title: "Cycle unread",
|
||||
group: "Scrap",
|
||||
run: () =>
|
||||
updateStatus((status) => ({
|
||||
...status,
|
||||
unread: status.unread === undefined ? "activity" : status.unread === "activity" ? "error" : undefined,
|
||||
})),
|
||||
},
|
||||
{
|
||||
bind: "a",
|
||||
title: "Toggle attention",
|
||||
group: "Scrap",
|
||||
run: () => updateStatus((status) => ({ ...status, attention: !status.attention })),
|
||||
},
|
||||
{
|
||||
bind: "m",
|
||||
title: "Toggle motion",
|
||||
group: "Scrap",
|
||||
run: () => setAnimations((enabled) => !enabled),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
|
||||
<box flexGrow={1} />
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<SessionTabs controller={controller} animations={animations()} />
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
|
|
@ -49,10 +162,13 @@ function Scrap(props: { context: Plugin.Context }) {
|
|||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={elevatedTheme.text.subdued}>~/code/anomalyco/opencode</text>
|
||||
<text fg={elevatedTheme.text.subdued}>tab playground</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={elevatedTheme.text.subdued}>esc home</text>
|
||||
<text fg={elevatedTheme.text.subdued}>
|
||||
h/l select | t add | d close | b busy | u unread | a attention | m motion | esc home
|
||||
</text>
|
||||
</box>
|
||||
<box flexGrow={1} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue