diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index d6ba165e4290..c4221ee5b74d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -326,8 +326,17 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > tint(background(), theme.text.default, 0.45) + // The edge flash washes toward a brighter stop on the same background-to-text ramp, + // so it reads as a lift of the pulse color rather than a different hue. + const flashColor = () => tint(background(), theme.text.default, 0.65) const feedbackColor = () => { if (status().attention) return theme.text.feedback.warning.default if (status().unread === "error") return theme.text.feedback.error.default @@ -222,7 +225,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati const cut = Math.round(front * Math.max(parts.length, previous.length)) return [...parts.slice(0, cut), ...previous.slice(cut)] }) - const fadedTitleParts = createMemo(() => displayedParts().slice(-FADE_WIDTH)) const titleFades = createMemo( () => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH, ) @@ -230,6 +232,19 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati if (hovered() === tab.sessionID) return theme.text.default return tint(theme.text.subdued, theme.text.default, selection()) } + // 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() + ? tint(base, glowColor(), 0.12 * unreadGlowIntensity(1 + numberWidth() + index, width())) + : base + if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color + const position = index - (displayedParts().length - FADE_WIDTH) + return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1))) + } + // The running sweep's level under the number cell, reported by the pulse renderable. + const [sweepLevel, setSweepLevel] = createSignal(0) const numberColor = () => { const feedback = feedbackColor() if (feedback) return feedback @@ -237,7 +252,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati hovered() === tab.sessionID && !selected() ? foreground() : tint(idleNumber(), activeNumber(), selection()) - return tint(base, accent(), activity()) + const color = tint(base, accent(), activity()) + // The number brightens faintly as the running sweep passes beneath it. + return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel()) } const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined) const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6) @@ -271,8 +288,10 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati breathe={status().attention} color={pulseColor()} glowColor={glowColor()} + flashColor={flashColor()} completionColor={accent()} backgroundColor={background()} + onLevel={setSweepLevel} /> @@ -288,22 +307,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati selectable={false} attributes={bold()} > - - {displayedParts().slice(0, -FADE_WIDTH).join("")} - - {(character, index) => ( - - {character} - - )} + + + {(character, index) => {character}} diff --git a/packages/tui/src/component/tab-pulse.tsx b/packages/tui/src/component/tab-pulse.tsx index 7e994f0b0492..de2bce56929b 100644 --- a/packages/tui/src/component/tab-pulse.tsx +++ b/packages/tui/src/component/tab-pulse.tsx @@ -9,8 +9,11 @@ type TabPulseOptions = RenderableOptions & { breathe?: boolean color?: RGBA glowColor?: RGBA + flashColor?: RGBA completionColor?: RGBA backgroundColor?: RGBA + /** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */ + onLevel?: (level: number) => void } const clamp = (value: number) => Math.max(0, Math.min(1, value)) @@ -19,10 +22,11 @@ const RUN_DURATION = 2_800 const RUN_HEAD = 4 const RUN_TAIL = 18 const RUN_FADE_OUT = 500 -const COMPLETION_DURATION = 900 -const COMPLETION_ATTACK = 0.16 +const COMPLETION_DURATION = 1_200 +const COMPLETION_ATTACK = 0.12 const COMPLETION_OPACITY = 0.18 -const EDGE_FLASH_DURATION = 500 +const EDGE_FLASH_DURATION = 800 +const EDGE_FLASH_ATTACK = 0.1 const EDGE_FLASH_OPACITY = 0.1 const GLOW_IGNITION_DURATION = 600 const GLOW_IGNITION_PEAK = 1.5 @@ -61,9 +65,11 @@ export function blendTabPulseColor( background: RGBA, glowColor: RGBA, runningColor: RGBA, + flashColor: RGBA, completionColor: RGBA, glow: number, running: number, + flash: number, completion: number, ) { output.r = background.r + (glowColor.r - background.r) * glow @@ -72,6 +78,9 @@ export function blendTabPulseColor( output.r += (runningColor.r - output.r) * running output.g += (runningColor.g - output.g) * running output.b += (runningColor.b - output.b) * running + output.r += (flashColor.r - output.r) * flash + output.g += (flashColor.g - output.g) * flash + output.b += (flashColor.b - output.b) * flash output.r += (completionColor.r - output.r) * completion output.g += (completionColor.g - output.g) * completion output.b += (completionColor.b - output.b) * completion @@ -123,6 +132,7 @@ class TabPulseRenderable extends Renderable { private _breathe: boolean private _color: RGBA private _glowColor: RGBA + private _flashColor: RGBA private _completionColor: RGBA private _backgroundColor: RGBA private clock = 0 @@ -130,11 +140,13 @@ class TabPulseRenderable extends Renderable { private completionPending = false private runFade = new Envelope(RUN_FADE_OUT, fadeOut) private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity) - private edgeFlash = new Envelope(EDGE_FLASH_DURATION, completionPulseOpacity) + private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0)) private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel) private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut) private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff] private renderColor = RGBA.fromInts(0, 0, 0) + private _onLevel: ((level: number) => void) | undefined + private lastLevel = 0 constructor(ctx: RenderContext, options: TabPulseOptions = {}) { const enabled = options.enabled ?? true @@ -147,8 +159,21 @@ class TabPulseRenderable extends Renderable { this._breathe = options.breathe ?? false this._color = options.color ?? RGBA.defaultForeground() this._glowColor = options.glowColor ?? this._color + this._flashColor = options.flashColor ?? this._color this._completionColor = options.completionColor ?? this._color this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground() + this._onLevel = options.onLevel + } + + set onLevel(value: ((level: number) => void) | undefined) { + this._onLevel = value + } + + private emitLevel(value: number) { + const quantized = Math.round(value * 32) / 32 + if (quantized === this.lastLevel) return + this.lastLevel = quantized + this._onLevel?.(quantized) } private get breathing() { @@ -248,6 +273,12 @@ class TabPulseRenderable extends Renderable { this.requestRender() } + set flashColor(value: RGBA) { + if (value.equals(this._flashColor)) return + this._flashColor = value + this.requestRender() + } + set completionColor(value: RGBA) { if (value.equals(this._completionColor)) return this._completionColor = value @@ -283,12 +314,21 @@ class TabPulseRenderable extends Renderable { // The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results. const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY const glowLevel = this.glowLevel() - if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) return + if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) { + this.emitLevel(0) + return + } const progress = (this.clock % RUN_DURATION) / RUN_DURATION const start = -RUN_HEAD const end = this.width - 1 + RUN_TAIL const front = start + coast(progress) * (end - start) const secondFront = start + coast((progress + 0.5) % 1) * (end - start) + this.emitLevel( + running === 0 + ? 0 + : Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) * + running, + ) for (let index = 0; index < this.width; index++) { // Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow. const sweep = @@ -305,9 +345,11 @@ class TabPulseRenderable extends Renderable { this._backgroundColor, this._glowColor, this._color, + this._flashColor, this._completionColor, glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel, - Math.max(sweep, flash), + sweep, + flash, completion, ) buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor) @@ -331,8 +373,10 @@ export function TabPulse(props: { breathe?: boolean color: RGBA glowColor?: RGBA + flashColor?: RGBA completionColor?: RGBA backgroundColor: RGBA + onLevel?: (level: number) => void }) { return ( ) } diff --git a/packages/tui/src/context/route.tsx b/packages/tui/src/context/route.tsx index fa8a5a39c1c8..0c0b7721e8d1 100644 --- a/packages/tui/src/context/route.tsx +++ b/packages/tui/src/context/route.tsx @@ -55,6 +55,11 @@ function initialRoute(value: unknown): Route | undefined { "name" in value && typeof value.name === "string" ) { + const data = + "data" in value && typeof value.data === "object" && value.data !== null && !Array.isArray(value.data) + ? (value.data as Record) + : undefined + if (data) return { type: "plugin", id: value.id, name: value.name, data } return { type: "plugin", id: value.id, name: value.name } } } diff --git a/packages/tui/src/feature-plugins/system/scrap.tsx b/packages/tui/src/feature-plugins/system/scrap.tsx deleted file mode 100644 index ba5772eb92bb..000000000000 --- a/packages/tui/src/feature-plugins/system/scrap.tsx +++ /dev/null @@ -1,186 +0,0 @@ -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" -import { moveSessionTab, type SessionTab } from "../../context/session-tabs-model" - -type FixtureStatus = ReturnType - -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(() => ({ - mode: "global", - commands: [ - { - id: "app.scrap", - title: "Open scrap screen", - group: "Debug", - palette: true, - run() { - props.context.ui.router.navigate({ type: "plugin", name: "scrap" }) - props.context.ui.dialog.clear() - }, - }, - ], - })) - return null -} - -function Scrap(props: { context: Plugin.Context }) { - const dimensions = useTerminalDimensions() - const theme = props.context.theme - const elevatedTheme = theme.contextual.elevated - const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6)) - const [active, setActive] = createSignal("fixture-2") - const [animations, setAnimations] = createSignal(true) - const [statuses, setStatuses] = createSignal>({ - "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 - }, - move(sessionID, index) { - setTabs((current) => moveSessionTab(current, sessionID, index)) - }, - 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: [ - { - bind: "escape", - title: "Back home", - group: "Scrap", - run() { - 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 ( - - - - tab playground - - - h/l select | t add | d close | b busy | u unread | a attention | m motion | esc home - - - - - ) -} - -export default Plugin.define({ - id: "opencode.scrap", - setup(context) { - context.ui.router.register({ name: "scrap", render: () => }) - context.ui.slot("app", () => ) - }, -}) diff --git a/packages/tui/src/feature-plugins/system/storybook/index.tsx b/packages/tui/src/feature-plugins/system/storybook/index.tsx new file mode 100644 index 000000000000..32403bd5614f --- /dev/null +++ b/packages/tui/src/feature-plugins/system/storybook/index.tsx @@ -0,0 +1,142 @@ +import { Plugin } from "@opencode-ai/plugin/tui" +import { useTerminalDimensions } from "@opentui/solid" +import { createSignal, For, type JSX } from "solid-js" +import { sessionTabsStory } from "./session-tabs" + +/** + * A story is a full-screen, fixture-driven simulation of a real production component. Stories own + * their entire screen (including any footer) and should bind escape back to the storybook index. + */ +export type Story = { + id: string + title: string + render: (context: Plugin.Context) => JSX.Element +} + +const stories: Story[] = [sessionTabsStory] + +function Commands(props: { context: Plugin.Context }) { + props.context.keymap.layer(() => ({ + mode: "global", + commands: [ + { + id: "app.storybook", + title: "Open storybook", + group: "Debug", + palette: true, + run() { + props.context.ui.router.navigate({ type: "plugin", name: "storybook" }) + props.context.ui.dialog.clear() + }, + }, + ...stories.map((story) => ({ + id: `app.storybook.${story.id}`, + title: `Storybook: ${story.title}`, + group: "Debug", + palette: true as const, + run() { + props.context.ui.router.navigate({ type: "plugin", name: "storybook", data: { story: story.id } }) + props.context.ui.dialog.clear() + }, + })), + ], + })) + return null +} + +function StorybookIndex(props: { context: Plugin.Context }) { + const dimensions = useTerminalDimensions() + const theme = props.context.theme + const elevatedTheme = theme.contextual.elevated + const [selected, setSelected] = createSignal(0) + const open = (story: Story) => + props.context.ui.router.navigate({ type: "plugin", name: "storybook", data: { story: story.id } }) + + props.context.keymap.layer(() => ({ + commands: [ + { + bind: "escape", + title: "Back home", + group: "Storybook", + run() { + props.context.ui.router.navigate({ type: "home" }) + }, + }, + { + bind: "up,k", + title: "Previous story", + group: "Storybook", + run: () => setSelected((current) => (current + stories.length - 1) % stories.length), + }, + { + bind: "down,j", + title: "Next story", + group: "Storybook", + run: () => setSelected((current) => (current + 1) % stories.length), + }, + { + bind: "return", + title: "Open story", + group: "Storybook", + run: () => open(stories[selected()]), + }, + ...stories.map((story, index) => ({ + bind: String(index + 1), + title: `Open ${story.title}`, + group: "Storybook", + run: () => open(story), + })), + ], + })) + + return ( + + + storybook + fixture-driven simulations of production components + + + {(story, index) => ( + + {index() === selected() ? "› " : " "} + {index() + 1} {story.title} + + )} + + + + + storybook + + ↑/↓ select | enter open | esc home + + + ) +} + +export default Plugin.define({ + id: "opencode.storybook", + setup(context) { + context.ui.router.register({ + name: "storybook", + render: (input) => { + const story = stories.find((story) => story.id === input.data?.story) + if (story) return story.render(context) + return + }, + }) + context.ui.slot("app", () => ) + }, +}) diff --git a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx new file mode 100644 index 000000000000..81fd627da07c --- /dev/null +++ b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx @@ -0,0 +1,368 @@ +import { Plugin } from "@opencode-ai/plugin/tui" +import { useTerminalDimensions } from "@opentui/solid" +import { batch, createSignal, For, onCleanup } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { SessionTabs, type SessionTabsController } from "../../../component/session-tabs" +import { moveSessionTab } from "../../../context/session-tabs-model" +import type { Story } from "./index" + +type FixtureStatus = ReturnType + +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 } +const RUN_DURATION = 1_800 +const RESUME_DURATION = 900 + +// Plausible targets for the fake transcript's tool calls, picked per fixture index. +const TRANSCRIPT_FILES = [ + "packages/tui/src/component/session-tabs.tsx", + "packages/tui/src/component/tab-pulse.tsx", + "packages/tui/src/context/session-tabs-model.ts", + "packages/core/src/session/runner.ts", + "packages/server/src/routes/session.ts", + "packages/tui/src/ui/animation.ts", +] + +function SessionTabsStory(props: { context: Plugin.Context }) { + const dimensions = useTerminalDimensions() + const theme = props.context.theme + const elevatedTheme = theme.contextual.elevated + // A keyed store mirrors production: retitles mutate rows in place instead of remounting them. + const [tabStore, setTabStore] = createStore<{ items: { sessionID: string; title?: string }[] }>({ + items: FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })), + }) + const tabs = () => tabStore.items + const setItems = (next: { sessionID: string; title?: string }[]) => + setTabStore("items", reconcile(next, { key: "sessionID" })) + const [active, setActive] = createSignal("fixture-1") + const [lastEvent, setLastEvent] = createSignal("press space to start a random tab") + const [statuses, setStatuses] = createSignal>({}) + // Unread clears on select, so the transcript remembers how each session's last run ended. + const [outcomes, setOutcomes] = createSignal>({}) + const runs = new Map>() + onCleanup(() => runs.forEach(clearTimeout)) + + const number = (sessionID: string) => tabs().findIndex((tab) => tab.sessionID === sessionID) + 1 + + function finishRun(sessionID: string, resumed: boolean) { + runs.delete(sessionID) + if (!tabs().some((item) => item.sessionID === sessionID)) return + const roll = Math.random() + // A permission request pauses the still-busy run until the tab is selected. + if (!resumed && roll < 0.25) { + setStatuses((current) => ({ + ...current, + [sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), attention: true }, + })) + setLastEvent(`tab ${number(sessionID)} needs input; select it to resolve`) + return + } + const failed = roll >= 0.75 + const unread = active() === sessionID ? undefined : failed ? ("error" as const) : ("activity" as const) + batch(() => { + setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" })) + setStatuses((current) => ({ + ...current, + [sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: false, unread }, + })) + // An untitled session earns its title after its first completed run, like a real summarization. + const index = number(sessionID) - 1 + const fixture = FIXTURE_TABS.find((tab) => tab.sessionID === sessionID) + if (!failed && fixture && tabs()[index]?.title === undefined) setTabStore("items", index, "title", fixture.title) + }) + setLastEvent( + `tab ${number(sessionID)} ${failed ? "failed" : "completed"}${unread ? " (unread)" : " while selected"}`, + ) + } + + const select = (sessionID: string) => { + const status = statuses()[sessionID] + const resumes = status !== undefined && status.attention && status.busy && !runs.has(sessionID) + batch(() => { + setActive(sessionID) + if (status && (status.unread || status.attention)) + setStatuses((current) => ({ ...current, [sessionID]: { ...status, unread: undefined, attention: false } })) + }) + if (resumes) { + setLastEvent(`tab ${number(sessionID)} input resolved, resuming`) + runs.set( + sessionID, + setTimeout(() => finishRun(sessionID, true), RESUME_DURATION), + ) + } + } + + const controller = { + tabs, + current: active, + status(sessionID) { + return statuses()[sessionID] ?? EMPTY_STATUS + }, + select, + move(sessionID: string, index: number) { + const next = moveSessionTab(tabs(), sessionID, index) + if (next === tabs()) return + setItems(next.map((tab) => ({ ...tab }))) + }, + 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).map((tab) => ({ ...tab })) + const selected = next[index]?.sessionID ?? next[index - 1]?.sessionID + clearTimeout(runs.get(target)) + runs.delete(target) + batch(() => { + setItems(next) + setStatuses((current) => { + const updated = { ...current } + delete updated[target] + return updated + }) + if (active() === target && selected) select(selected) + if (active() === target && !selected) setActive(undefined) + }) + }, + } satisfies SessionTabsController + + const cycle = (direction: 1 | -1) => { + const items = tabs() + if (items.length === 0) return + const index = items.findIndex((tab) => tab.sessionID === active()) + select(items[(index + direction + items.length) % items.length].sessionID) + } + const startRun = (sessionID: string) => { + setStatuses((current) => ({ + ...current, + [sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: true, unread: undefined }, + })) + setOutcomes((current) => { + const next = { ...current } + delete next[sessionID] + return next + }) + setLastEvent(`tab ${number(sessionID)} running`) + runs.set( + sessionID, + setTimeout(() => finishRun(sessionID, false), RUN_DURATION), + ) + } + const randomInactiveTab = () => { + const candidates = tabs().filter((tab) => { + const status = controller.status(tab.sessionID) + return !status.busy && !status.unread && !status.attention + }) + // Untitled sessions run first so their title arrival is easy to trigger. + const untitled = candidates.filter((tab) => tab.title === undefined) + const pool = untitled.length > 0 ? untitled : candidates + return pool[Math.floor(Math.random() * pool.length)] + } + // A fake transcript for the selected session so tab switches feel like moving between real + // sessions; the tail line tracks the live status of the current run. + const transcript = () => { + const current = active() + if (!current) return [{ text: "no session selected", color: theme.text.subdued }] + const index = Math.max( + 0, + FIXTURE_TABS.findIndex((fixture) => fixture.sessionID === current), + ) + const fixture = FIXTURE_TABS[index] + const status = controller.status(current) + const outcome = outcomes()[current] + const file = TRANSCRIPT_FILES[index % TRANSCRIPT_FILES.length] + const lines = [ + { text: `> ${fixture.title}`, color: theme.text.default }, + { text: "", color: theme.text.default }, + ] + if (!status.busy && outcome === undefined) { + lines.push({ text: "no activity yet — press s to run this session", color: theme.text.subdued }) + return lines + } + lines.push( + { text: "● Taking a look — reading the relevant code first.", color: theme.text.default }, + { text: "", color: theme.text.default }, + { text: ` ✱ Read ${file}`, color: theme.text.subdued }, + { text: ` ✱ Edit ${file}`, color: theme.text.subdued }, + { text: ` ✱ Bash bun run test`, color: theme.text.subdued }, + { text: "", color: theme.text.default }, + ) + if (status.attention) + lines.push({ + text: "⚠ Permission required: Bash `bun run test` — select this tab to approve", + color: theme.text.feedback.warning.default, + }) + else if (status.busy) lines.push({ text: "● Working…", color: theme.text.subdued }) + else if (outcome === "failed") + lines.push({ + text: `✗ bun run test failed — 3 tests failing in ${file}`, + color: theme.text.feedback.error.default, + }) + else + lines.push({ + text: `✓ Done — updated ${file} and the tests pass.`, + color: theme.text.feedback.success.default, + }) + return lines + } + + const selectedState = () => { + const current = active() + const status = current ? controller.status(current) : EMPTY_STATUS + const activity = status.busy + ? "running" + : status.unread === "activity" + ? "completed (unread)" + : status.unread === "error" + ? "failed (unread)" + : "read" + return status.attention ? `${activity} + needs input` : activity + } + + props.context.keymap.layer(() => ({ + commands: [ + { + bind: "escape", + title: "Back to storybook", + group: "Storybook", + run() { + props.context.ui.router.navigate({ type: "plugin", name: "storybook" }) + }, + }, + { bind: "left,h", title: "Previous tab", group: "Storybook", run: () => cycle(-1) }, + { bind: "right,l", title: "Next tab", group: "Storybook", run: () => cycle(1) }, + ...Array.from({ length: 10 }, (_, index) => ({ + bind: String((index + 1) % 10), + title: `Select tab ${index + 1}`, + group: "Storybook", + run() { + const tab = tabs()[index] + if (tab) select(tab.sessionID) + }, + })), + { + bind: "space", + title: "Start a random tab", + group: "Storybook", + run() { + const tab = randomInactiveTab() + if (!tab) { + setLastEvent("every tab is busy or unread; select tabs to read them, or press r") + return + } + startRun(tab.sessionID) + }, + }, + { + // Random runs stay off the selected tab, so this is the way to watch the edge flash + // and running sweep under the cursor. + bind: "s", + title: "Run selected tab", + group: "Storybook", + run() { + const current = active() + if (!current) return + if (controller.status(current).busy) { + setLastEvent(`tab ${number(current)} is already running`) + return + } + 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: "d", title: "Close tab", group: "Storybook", run: () => controller.close() }, + { + bind: "r", + title: "Reset", + group: "Storybook", + run() { + runs.forEach(clearTimeout) + runs.clear() + batch(() => { + setItems(FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab }))) + setStatuses({}) + setOutcomes({}) + setActive("fixture-1") + }) + setLastEvent("reset; press space to start a random tab") + }, + }, + ], + })) + + return ( + + + + + + {(line) => ( + + {line.text || " "} + + )} + + + + + selected: {number(active() ?? "")} | state: {selectedState()} + + background: {lastEvent()} + + + storybook / session tabs + + + space/s run | t add | d close | r reset | ←/→ 1-0 move | drag reorders | esc back + + + + ) +} + +export const sessionTabsStory: Story = { + id: "session-tabs", + title: "Session tabs", + render: (context) => , +} diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 0c18cdafa6db..3e9600cd72d2 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -7,7 +7,7 @@ import SidebarMcp from "../feature-plugins/sidebar/mcp" import DiffViewer from "../feature-plugins/system/diff-viewer" import Notifications from "../feature-plugins/system/notifications" import Plugins from "../feature-plugins/system/plugins" -import Scrap from "../feature-plugins/system/scrap" +import Storybook from "../feature-plugins/system/storybook" export const builtins = [ HomeFooter, @@ -18,6 +18,6 @@ export const builtins = [ SidebarFooter, Notifications, Plugins, - Scrap, + Storybook, DiffViewer, ] diff --git a/packages/tui/test/component/tab-pulse.test.ts b/packages/tui/test/component/tab-pulse.test.ts index 7d216119849c..0e157f6ba330 100644 --- a/packages/tui/test/component/tab-pulse.test.ts +++ b/packages/tui/test/component/tab-pulse.test.ts @@ -10,9 +10,9 @@ import { tint } from "../../src/theme/color" test("completion pulse rises quickly and fades over the remaining duration", () => { expect(completionPulseOpacity(0)).toBe(0) - expect(completionPulseOpacity(0.08)).toBeCloseTo(0.5) - expect(completionPulseOpacity(0.16)).toBe(1) - expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5) + expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5) + expect(completionPulseOpacity(0.12)).toBe(1) + expect(completionPulseOpacity(0.56)).toBeCloseTo(0.5) expect(completionPulseOpacity(1)).toBe(0) }) @@ -44,15 +44,33 @@ test("reuses a color while preserving the original glow and pulse blend stages", const background = RGBA.fromHex("#1a1b26") const glowColor = RGBA.fromHex("#82aaff") const runningColor = RGBA.fromHex("#c8d3f5") + const flashColor = RGBA.fromHex("#e2e8fb") const completionColor = RGBA.fromHex("#ff9e64") for (const glow of [0, 0.08, 0.16]) { for (const running of [0, 0.01, 0.07, 0.14]) { - for (const completion of [0, 0.03, 0.09, 0.18]) { - blendTabPulseColor(output, background, glowColor, runningColor, completionColor, glow, running, completion) - expect(output.buffer).toEqual( - tint(tint(tint(background, glowColor, glow), runningColor, running), completionColor, completion).buffer, - ) + for (const flash of [0, 0.05, 0.1]) { + for (const completion of [0, 0.03, 0.09, 0.18]) { + blendTabPulseColor( + output, + background, + glowColor, + runningColor, + flashColor, + completionColor, + glow, + running, + flash, + completion, + ) + expect(output.buffer).toEqual( + tint( + tint(tint(tint(background, glowColor, glow), runningColor, running), flashColor, flash), + completionColor, + completion, + ).buffer, + ) + } } } }