ソースを参照

feat(tui): replace scrap screen with component storybook (#39548)

Kit Langton 2 週間 前
コミット
3c259fc552

+ 11 - 2
packages/tui/src/app.tsx

@@ -326,8 +326,17 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                             >
                               <TuiStartupProvider
                                 value={{
-                                  initialRoute: process.env.OPENCODE_SCRAP
-                                    ? { type: "plugin", id: "scrap", name: "scrap" }
+                                  initialRoute: process.env.OPENCODE_STORY
+                                    ? {
+                                        type: "plugin",
+                                        id: "opencode.storybook",
+                                        name: "storybook",
+                                        // OPENCODE_STORY=1 opens the index; any other value opens that story.
+                                        data:
+                                          process.env.OPENCODE_STORY === "1"
+                                            ? undefined
+                                            : { story: process.env.OPENCODE_STORY },
+                                      }
                                     : process.env.OPENCODE_ROUTE
                                       ? JSON.parse(process.env.OPENCODE_ROUTE)
                                       : undefined,

+ 25 - 19
packages/tui/src/component/session-tabs.tsx

@@ -14,7 +14,7 @@ import {
 import { createAnimatable, spring, tween } from "../ui/animation"
 import { Locale } from "../util/locale"
 import { stringWidth } from "../util/string-width"
-import { TabPulse } from "./tab-pulse"
+import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
 import { tint } from "../theme/color"
 
 // A long title fades out over its last cells instead of cutting hard.
@@ -182,6 +182,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
             return tint(base, theme.raise(theme.background.surface.offset), dragged() ? 1 : selection())
           })
           const pulseColor = () => 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}
               />
               <box zIndex={1} width="100%" flexDirection="row">
                 <text width={1} selectable={false}>
@@ -288,22 +307,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
                   selectable={false}
                   attributes={bold()}
                 >
-                  <Show when={titleFades()} fallback={displayedParts().join("")}>
-                    {displayedParts().slice(0, -FADE_WIDTH).join("")}
-                    <For each={fadedTitleParts()}>
-                      {(character, index) => (
-                        <span
-                          style={{
-                            fg: tint(
-                              foreground(),
-                              background(),
-                              0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
-                            ),
-                          }}
-                        >
-                          {character}
-                        </span>
-                      )}
+                  <Show when={glows() || titleFades()} fallback={displayedParts().join("")}>
+                    <For each={displayedParts()}>
+                      {(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
                     </For>
                   </Show>
                 </text>

+ 52 - 6
packages/tui/src/component/tab-pulse.tsx

@@ -9,8 +9,11 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
   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 (
     <tab_pulse
@@ -346,8 +390,10 @@ export function TabPulse(props: {
       breathe={props.breathe ?? false}
       color={props.color}
       glowColor={props.glowColor ?? props.color}
+      flashColor={props.flashColor ?? props.color}
       completionColor={props.completionColor ?? props.color}
       backgroundColor={props.backgroundColor}
+      onLevel={props.onLevel}
     />
   )
 }

+ 5 - 0
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<string, unknown>)
+        : undefined
+    if (data) return { type: "plugin", id: value.id, name: value.name, data }
     return { type: "plugin", id: value.id, name: value.name }
   }
 }

+ 0 - 186
packages/tui/src/feature-plugins/system/scrap.tsx

@@ -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<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(() => ({
-    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<SessionTab[]>(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
-    },
-    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 (
-    <box
-      width={dimensions().width}
-      height={dimensions().height}
-      flexDirection="column"
-      backgroundColor={theme.background.default}
-    >
-      <SessionTabs controller={controller} animations={animations()} />
-      <box
-        height={1}
-        flexShrink={0}
-        backgroundColor={elevatedTheme.background.default}
-        paddingLeft={1}
-        paddingRight={1}
-        flexDirection="row"
-      >
-        <text fg={elevatedTheme.text.subdued}>tab playground</text>
-        <box flexGrow={1} />
-        <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>
-  )
-}
-
-export default Plugin.define({
-  id: "opencode.scrap",
-  setup(context) {
-    context.ui.router.register({ name: "scrap", render: () => <Scrap context={context} /> })
-    context.ui.slot("app", () => <Commands context={context} />)
-  },
-})

+ 142 - 0
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 (
+    <box
+      width={dimensions().width}
+      height={dimensions().height}
+      flexDirection="column"
+      backgroundColor={theme.background.default}
+    >
+      <box paddingTop={2} paddingLeft={2} flexDirection="column">
+        <text fg={theme.text.default}>storybook</text>
+        <text fg={theme.text.subdued}>fixture-driven simulations of production components</text>
+        <box height={1} />
+        <For each={stories}>
+          {(story, index) => (
+            <text fg={index() === selected() ? theme.text.default : theme.text.subdued}>
+              {index() === selected() ? "› " : "  "}
+              {index() + 1} {story.title}
+            </text>
+          )}
+        </For>
+      </box>
+      <box flexGrow={1} />
+      <box
+        height={1}
+        flexShrink={0}
+        backgroundColor={elevatedTheme.background.default}
+        paddingLeft={1}
+        paddingRight={1}
+        flexDirection="row"
+      >
+        <text fg={elevatedTheme.text.subdued}>storybook</text>
+        <box flexGrow={1} />
+        <text fg={elevatedTheme.text.subdued}>↑/↓ select | enter open | esc home</text>
+      </box>
+    </box>
+  )
+}
+
+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 <StorybookIndex context={context} />
+      },
+    })
+    context.ui.slot("app", () => <Commands context={context} />)
+  },
+})

+ 368 - 0
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<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 }
+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<string | undefined>("fixture-1")
+  const [lastEvent, setLastEvent] = createSignal("press space to start a random tab")
+  const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({})
+  // Unread clears on select, so the transcript remembers how each session's last run ended.
+  const [outcomes, setOutcomes] = createSignal<Record<string, "completed" | "failed">>({})
+  const runs = new Map<string, ReturnType<typeof setTimeout>>()
+  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 (
+    <box
+      width={dimensions().width}
+      height={dimensions().height}
+      flexDirection="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 / session 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>
+  )
+}
+
+export const sessionTabsStory: Story = {
+  id: "session-tabs",
+  title: "Session tabs",
+  render: (context) => <SessionTabsStory context={context} />,
+}

+ 2 - 2
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,
 ]

+ 26 - 8
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,
+          )
+        }
       }
     }
   }