Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/tui/src/component/dialog-experiments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ type Experiment = {
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = [
{
id: "tab-status-icons",
title: "Tab status icons",
description: "Show a spinner while working, a small dot when unread, and tab numbers otherwise",
},
{
id: "session-preview-tabs",
title: "Session preview tabs",
Expand Down
52 changes: 40 additions & 12 deletions packages/tui/src/component/session-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/m
import { useDialog } from "../ui/dialog"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { Spinner } from "./spinner"

// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
Expand Down Expand Up @@ -377,13 +378,13 @@ export function SessionTabs(
}

function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean; width?: number }) {
const contextTabs = useSessionTabs()
const tabs: SessionTabsController = props.controller ?? contextTabs
const tabs: SessionTabsController = props.controller ?? useSessionTabs()
const data = useData()
const theme = useTheme("elevated")
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const statusIcons = () => config.experimental?.["tab-status-icons"] === true
const width = () => props.width ?? SESSION_SIDEBAR_WIDTH
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
Expand Down Expand Up @@ -755,14 +756,26 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
<text
width={numberWidth() + 1}
fg={numberColor()}
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
<Show
when={statusIcons() && runs()}
fallback={
<text
width={numberWidth() + 1}
fg={numberColor()}
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
{(statusIcons() && !status().busy && status().unread !== undefined
? "•"
: sessionTabNumberLabel(index())
).padStart(numberWidth())}
</text>
}
>
{sessionTabNumberLabel(index()).padStart(numberWidth())}
</text>
<box width={numberWidth() + 1} flexShrink={0} paddingLeft={numberWidth() - 1}>
<Spinner color={theme.text.subdued} animations={animations()} />
</box>
</Show>
<text
width={titleWidth()}
fg={foreground()}
Expand Down Expand Up @@ -930,6 +943,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const statusIcons = () => config.experimental?.["tab-status-icons"] === true
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
Expand Down Expand Up @@ -1332,9 +1346,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row">
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
{(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())}
</text>
<Show
when={statusIcons() && runs()}
fallback={
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
{(tab === NEW_SESSION_TAB
? "+"
: statusIcons() && !status().busy && status().unread !== undefined
? "•"
: sessionTabNumberLabel(tabNumber() - 1)
).padStart(numberWidth())}
</text>
}
>
<box width={numberWidth() + 1} flexShrink={0} paddingLeft={numberWidth() - 1}>
<Spinner color={theme.text.subdued} animations={animations()} />
</box>
</Show>
<text
width={availableTitleWidth()}
fg={foreground()}
Expand Down
4 changes: 2 additions & 2 deletions packages/tui/src/component/spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ export { SPINNER_FRAMES } from "./spinner-frames"

registerOpencodeSpinner()

export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
export function Spinner(props: { children?: JSX.Element; color?: RGBA; animations?: boolean }) {
const theme = useTheme()
const config = useConfig().data
const color = () => props.color ?? theme.text.subdued
return (
<Show
when={config.animations ?? true}
when={props.animations ?? config.animations ?? true}
fallback={<text fg={color()}>{props.children ? <>⋯ {props.children}</> : "⋯"}</text>}
>
<box flexDirection="row" gap={1}>
Expand Down
123 changes: 123 additions & 0 deletions packages/tui/test/component/session-tabs-status.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider, useConfig } from "../../src/config"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../src/component/session-tabs"
import { SPINNER_FRAMES } from "../../src/component/spinner"
import { ClientProvider } from "../../src/context/client"
import { DataProvider } from "../../src/context/data"
import { ThemeProvider } from "../../src/context/theme"
import { emptyThemeSource } from "../fixture/fixture"
import { createApi, createEventStream, createFetch } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"

for (const orientation of ["horizontal", "vertical"] as const) {
for (const width of [48, 120]) {
for (const mode of ["light", "dark"] as const) {
test(`${orientation} tab status icons toggle and update at ${width} columns in ${mode} mode`, async () => {
const [finished, setFinished] = createSignal(false)
const [read, setRead] = createSignal(false)
const [blocked, setBlocked] = createSignal(true)
const controller = {
tabs: () => [
{ sessionID: "idle", title: "Idle" },
{ sessionID: "busy", title: "Busy" },
{ sessionID: "done", title: "Done" },
{ sessionID: "wait", title: "Wait" },
],
current: () => "idle",
select() {},
close() {},
move() {},
detail: () => "project",
status(sessionID) {
return {
...EMPTY_SESSION_TAB_STATUS,
busy: sessionID === "wait" || (sessionID === "busy" && !finished()),
attention: sessionID === "wait" && blocked(),
unread: !read() && (sessionID === "done" || sessionID === "busy") ? "activity" : undefined,
}
},
} satisfies SessionTabsController
let config!: ReturnType<typeof useConfig>
let configuration = { animations: false, experimental: { "tab-status-icons": false } }
function Tabs() {
config = useConfig()
return <SessionTabs controller={controller} orientation={orientation} width={Math.min(width, 32)} />
}
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider
config={createTuiResolvedConfig(configuration)}
service={{
get: async () => configuration,
update: async (update) => {
configuration = structuredClone(configuration)
update(configuration)
return configuration
},
}}
>
<ClientProvider api={createApi(createFetch(undefined, createEventStream()).fetch)}>
<DataProvider>
<ThemeProvider mode={mode} source={emptyThemeSource}>
<Tabs />
</ThemeProvider>
</DataProvider>
</ClientProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 16 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("4 Wait"))
expect(app.captureCharFrame()).toContain("1 Idle")
expect(app.captureCharFrame()).toContain("2 Busy")
expect(app.captureCharFrame()).toContain("3 Done")

await config.update((draft) => {
draft.experimental["tab-status-icons"] = true
})
await app.waitForFrame((frame) => frame.includes("⋯ Busy"))
expect(app.captureCharFrame()).toContain("• Done")
expect(app.captureCharFrame()).toContain("1 Idle")
expect(app.captureCharFrame()).toContain("4 Wait")
expect(app.captureCharFrame()).not.toMatch(/[●⚠]/)

await config.update((draft) => {
draft.animations = true
})
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} Busy`)))
expect(app.captureCharFrame()).toContain("4 Wait")

setBlocked(false)
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} Wait`)))
setFinished(true)
await app.waitForFrame((frame) => frame.includes("• Busy"))
setRead(true)
await app.waitForFrame((frame) => frame.includes("3 Done"))
expect(app.captureCharFrame()).toContain("2 Busy")
expect(app.captureCharFrame()).not.toMatch(/[•●⚠]/)

setRead(false)
await app.waitForFrame((frame) => frame.includes("• Done"))

await config.update((draft) => {
draft.experimental["tab-status-icons"] = false
})
await app.waitForFrame((frame) => frame.includes("4 Wait"))
expect(app.captureCharFrame()).toContain("2 Busy")
expect(app.captureCharFrame()).toContain("3 Done")
expect(app.captureCharFrame()).not.toContain("•")
} finally {
app.renderer.destroy()
}
})
}
}
}
Loading