Compare commits

...

6 Commits

Author SHA1 Message Date
Aiden Cline fbd7c55cd6 docs(core): minimize patch tool guidance 2026-07-29 21:08:35 +00:00
Aiden Cline f641186d56 docs(core): align patch grammar guidance 2026-07-29 20:59:05 +00:00
Aiden Cline edbe308787 fix(core): clarify patch context guidance 2026-07-29 20:35:50 +00:00
Kit Langton 3c259fc552 feat(tui): replace scrap screen with component storybook (#39548) 2026-07-29 15:51:15 -04:00
Aiden Cline 210be4b749 fix(core): preserve shell output on timeout (#39559) 2026-07-29 14:31:26 -05:00
James Long 464649e67e feat(tui): batch event delivery (#39551) 2026-07-29 15:20:39 -04:00
15 changed files with 675 additions and 900 deletions
+4 -28
View File
@@ -1,33 +1,9 @@
Use the `patch` tool to edit files. Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
Use the `patch` tool to edit files. Provide one patch in this format:
*** Begin Patch
[ one or more file sections ]
[one or more file operations]
*** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
Each operation starts with `*** Add File: <path>`, `*** Delete File: <path>`, or `*** Update File: <path>`. Add file contents use `+` lines; delete operations have no body.
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
Example patch:
```
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
```
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
An update may start with `*** Move to: <path>` and contains change lines, optionally separated by `@@` or `@@ <context>` markers. Change lines start with a space for unchanged context, `-` for removed content, or `+` for added content. An optional `*** End of File` marker anchors the final change to the end of the file.
+1 -1
View File
@@ -17,7 +17,7 @@ export const name = "patch"
export const Input = Schema.Struct({
patchText: Schema.String.annotate({
description: "The full patch text describing add, update, and delete operations",
description: "The complete patch text",
}),
})
+3 -3
View File
@@ -198,20 +198,20 @@ export const Plugin = {
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
if (final.status === "timeout") {
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: `Command exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: capture.truncated,
timeout: true,
status: "completed" as const,
}
}
const capture = yield* captureShell()
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: capture.output,
+15 -8
View File
@@ -157,6 +157,9 @@ const mixedOutputCommand = isWindows
? "[Console]::Out.Write('stdout'); Start-Sleep -Milliseconds 50; [Console]::Error.Write('stderr'); Start-Sleep -Milliseconds 100"
: "printf stdout; sleep 0.05; printf stderr >&2"
const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
const timeoutOutputCommand = isWindows
? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
: "printf 'before timeout'; sleep 60"
const steadyProgressCommand = isWindows
? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400"
: "printf steady; sleep 3.4"
@@ -461,14 +464,18 @@ describe("ShellTool", () => {
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: idleCommand, timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.content?.[1]).toMatchObject({
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: timeoutOutputCommand, timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining("before timeout"),
})
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command timed out"),
})
+11 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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}
/>
)
}
+21 -3
View File
@@ -1,6 +1,6 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup, onMount } from "solid-js"
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error"
import { createSimpleContext } from "./helper"
@@ -25,6 +25,7 @@ type ManagedService = {
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
const connectTimeout = 2_000
const connectionHistoryLimit = 50
const eventFlushInterval = 10
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
name: "Client",
@@ -34,6 +35,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
const history: ClientConnectionEvent[] = []
let api = props.api
const events = createGlobalEmitter<ClientEventMap>()
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
const [connection, setConnection] = createStore<{
status: ClientConnectionStatus
attempt: number
@@ -49,6 +52,19 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
if (history.length > connectionHistoryLimit) history.shift()
}
function flushEvents() {
flushTimer = undefined
const queued = pending
pending = []
batch(() => queued.forEach((event) => events.emit(event.type, event)))
}
function emit(event: OpenCodeEvent) {
pending.push(event)
if (flushTimer) return
flushTimer = setTimeout(flushEvents, eventFlushInterval)
}
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
@@ -80,7 +96,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
record("connected", attempt)
connectedAt = Date.now()
log.info("event stream connected")
events.emit(first.value.type, first.value)
emit(first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
// Forward events until the stream closes or this connection is cancelled.
@@ -97,7 +113,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
seq: event.value.durable.seq,
})
events.emit(event.value.type, event.value)
emit(event.value)
}
return { error: undefined, connectedAt }
@@ -154,6 +170,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
onCleanup(() => {
abort.abort()
stream?.abort()
if (flushTimer) clearTimeout(flushTimer)
pending = []
events.clear()
})
+5
View File
@@ -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 }
}
}
@@ -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} />)
},
})
@@ -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} />)
},
})
@@ -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
View File
@@ -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,
]
@@ -1,634 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { afterAll, describe, expect, test } from "bun:test"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import type { LogLevel, LogSink } from "../../../src/context/log"
import { createApi, createFetch } from "../../fixture/tui-client"
const packageRoot = process.env.OPENCODE_TUI_ROOT
const contextModule = packageRoot
? await import(`${packageRoot}/src/context/client.tsx`)
: await import("../../../src/context/client")
const environmentModule = packageRoot
? await import(`${packageRoot}/test/fixture/tui-environment.tsx`)
: await import("../../fixture/tui-environment")
const { ClientProvider, useClient } = contextModule as typeof import("../../../src/context/client")
const { TestTuiContexts } = environmentModule as typeof import("../../fixture/tui-environment")
type Client = ReturnType<typeof useClient>
type Service = {
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
restart: () => Promise<void>
}
type Observation = {
scenario: string
value: unknown
}
const observations: Observation[] = []
const connected = { id: "evt_connected", type: "server.connected", data: {} } as OpenCodeEvent
afterAll(async () => {
const output = process.env.CLIENT_BEHAVIOR_OUTPUT
if (output) await Bun.write(output, `${JSON.stringify(observations, null, 2)}\n`)
})
function observe(scenario: string, value: unknown) {
observations.push({ scenario, value })
}
function normalizeError(error: unknown) {
if (error instanceof Error) return `${error.name}:${error.message}`
return String(error)
}
function history(client: Client) {
return client.connection.internal.history().map((event) => ({
status: event.data.status,
attempt: event.data.attempt,
error: event.data.error,
}))
}
async function waitFor(check: () => boolean, timeout = 3_000) {
const started = Date.now()
while (!check()) {
if (Date.now() - started > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(5)
}
}
function event(type: "vcs" | "update" | "rename", suffix: string): OpenCodeEvent {
if (type === "vcs") {
return {
id: `evt_vcs_${suffix}`,
created: 1,
type: "vcs.branch.updated",
location: { directory: "/tmp/project" },
data: { branch: suffix },
}
}
if (type === "update") {
return {
id: `evt_update_${suffix}`,
created: 2,
type: "installation.update-available",
data: { version: suffix },
}
}
return {
id: `evt_rename_${suffix}`,
created: 3,
type: "session.renamed",
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
location: { directory: "/tmp/project" },
data: { sessionID: "ses_test", title: suffix },
}
}
function createStream(options?: { first?: OpenCodeEvent; closeBeforeHandshake?: boolean }) {
const encoder = new TextEncoder()
const controllers = new Set<ReadableStreamDefaultController<Uint8Array>>()
const requests: Request[] = []
const aborts: string[] = []
let cancellations = 0
function response(request: Request) {
requests.push(request)
request.signal.addEventListener("abort", () => aborts.push(normalizeError(request.signal.reason)), { once: true })
let current: ReadableStreamDefaultController<Uint8Array> | undefined
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
current = controller
controllers.add(controller)
if (options?.closeBeforeHandshake) {
controllers.delete(controller)
controller.close()
return
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(options?.first ?? connected)}\n\n`))
},
cancel() {
cancellations += 1
if (current) controllers.delete(current)
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
return {
response,
emit(value: OpenCodeEvent) {
const chunk = encoder.encode(`data: ${JSON.stringify(value)}\n\n`)
for (const controller of controllers) controller.enqueue(chunk)
},
raw(value: string) {
const chunk = encoder.encode(value)
for (const controller of controllers) controller.enqueue(chunk)
},
close() {
for (const controller of [...controllers]) {
controllers.delete(controller)
controller.close()
}
},
fail(message: string) {
for (const controller of [...controllers]) {
controllers.delete(controller)
controller.error(new Error(message))
}
},
snapshot() {
return {
requests: requests.length,
requestAborted: requests.map((request) => request.signal.aborted),
aborts,
cancellations,
active: controllers.size,
}
},
}
}
function apiFor(stream: ReturnType<typeof createStream>) {
return createApi(
createFetch((url, request) => {
if (url.pathname === "/api/event") return stream.response(request)
}).fetch,
)
}
async function mount(input: {
api: OpenCodeClient
service?: Service
throwOn?: OpenCodeEvent["type"]
}) {
const seen: Array<{ type: string; status: string }> = []
const typed: string[] = []
const logs: Array<{ level: LogLevel; message: string; tags: Record<string, unknown> }> = []
let initialStatus = ""
let client!: Client
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
const log: LogSink = (level, message, tags) => {
logs.push({ level, message, tags: { ...tags } })
}
const app = await testRender(() => (
<TestTuiContexts log={log}>
<ClientProvider api={input.api} service={input.service}>
<Probe
onReady={(value) => {
client = value
initialStatus = value.connection.status()
ready()
}}
onEvent={(value) => {
seen.push({ type: value.type, status: client.connection.status() })
if (value.type === input.throwOn) throw new Error(`listener failed for ${value.type}`)
}}
onBranch={(branch) => typed.push(branch)}
/>
</ClientProvider>
</TestTuiContexts>
))
await mounted
return { app, client, initialStatus, seen, typed, logs }
}
function Probe(props: {
onReady: (client: Client) => void
onEvent: (event: OpenCodeEvent) => void
onBranch: (branch: string) => void
}) {
const client = useClient()
onMount(() => {
client.event.listen(({ details }) => props.onEvent(details))
client.event.on("vcs.branch.updated", (value) => props.onBranch(value.data.branch ?? ""))
props.onReady(client)
})
return <box />
}
describe("ClientProvider connection characterization", () => {
test("records handshake ordering, event delivery, logging, and active-stream cleanup", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.emit(event("vcs", "main"))
stream.emit(event("rename", "renamed"))
stream.emit(event("update", "2.0.0"))
await waitFor(() => setup.seen.length === 4)
observe("healthy.connected", {
initialStatus: setup.initialStatus,
finalStatus: setup.client.connection.status(),
seen: setup.seen,
typed: setup.typed,
logs: setup.logs,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
await waitFor(() => stream.snapshot().requestAborted[0] === true)
await Bun.sleep(20)
observe("healthy.cleanup", {
history: history(setup.client),
stream: stream.snapshot(),
})
expect(setup.seen.map((item) => item.type)).toEqual([
"server.connected",
"vcs.branch.updated",
"session.renamed",
"installation.update-available",
])
expect(setup.seen.map((item) => item.status)).toEqual(["connecting", "connected", "connected", "connected"])
expect(setup.logs.filter((item) => item.message === "event")).toHaveLength(1)
})
test("records an invalid first event", async () => {
const stream = createStream({ first: event("vcs", "invalid-handshake") })
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("handshake.invalid", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Event stream did not start with server.connected")
})
test("records EOF before the handshake", async () => {
const stream = createStream({ closeBeforeHandshake: true })
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("handshake.eof", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Event stream disconnected")
})
test("records a fetch failure before the handshake", async () => {
const calls = createFetch((url) => {
if (url.pathname === "/api/event") throw new Error("network unavailable")
return undefined
})
const setup = await mount({ api: createApi(calls.fetch) })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("handshake.fetch-error", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
logs: setup.logs,
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Transport")
})
test("records the initial connection timeout and request cancellation", async () => {
const requests: Request[] = []
const calls = createFetch((url, request) => {
if (url.pathname !== "/api/event") return
requests.push(request)
return new Promise<Response>((_, reject) => {
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
})
})
const setup = await mount({ api: createApi(calls.fetch) })
await waitFor(() => setup.client.connection.status() === "reconnecting", 3_000)
observe("handshake.timeout", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
requestCount: requests.length,
requestAborted: requests.map((request) => request.signal.aborted),
abortReasons: requests.map((request) => normalizeError(request.signal.reason)),
history: history(setup.client),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Transport")
})
test("records static transport reconnection after a connected stream closes", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => stream.snapshot().requests === 2, 2_000)
await waitFor(() => setup.client.connection.status() === "connected")
observe("reconnect.static", {
status: setup.client.connection.status(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
logs: setup.logs.filter((item) => item.message !== "event"),
})
setup.app.renderer.destroy()
expect(setup.seen.map((item) => item.type)).toEqual(["server.connected", "server.connected"])
})
test("records immediate managed-service replacement", async () => {
const initial = createStream()
const replacement = createStream()
const replacementApi = apiFor(replacement)
const reconnectSignals: boolean[] = []
const service: Service = {
reconnect(signal) {
reconnectSignals.push(signal.aborted)
return Promise.resolve({ api: replacementApi })
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apiFor(initial), service })
await waitFor(() => setup.client.connection.status() === "connected")
initial.close()
await waitFor(() => replacement.snapshot().requests === 1)
await waitFor(() => setup.client.connection.status() === "connected")
replacement.emit(event("vcs", "replacement"))
await waitFor(() => setup.typed.includes("replacement"))
observe("reconnect.managed-replacement", {
status: setup.client.connection.status(),
apiReplaced: setup.client.api === replacementApi,
reconnectSignals,
seen: setup.seen,
typed: setup.typed,
history: history(setup.client),
initial: initial.snapshot(),
replacement: replacement.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.api).toBe(replacementApi)
})
test("records managed-service resolution failure and delayed retry", async () => {
const stream = createStream()
let reconnects = 0
const service: Service = {
reconnect() {
reconnects += 1
return Promise.reject(new Error("service unavailable"))
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apiFor(stream), service })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => stream.snapshot().requests === 2, 2_000)
await waitFor(() => setup.client.connection.status() === "connected")
observe("reconnect.managed-failure", {
reconnects,
status: setup.client.connection.status(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
resolutionLogs: setup.logs.filter((item) => item.message === "server resolution failed"),
})
setup.app.renderer.destroy()
expect(reconnects).toBe(1)
})
test("records cleanup while the initial fetch is pending", async () => {
const requests: Request[] = []
const aborts: string[] = []
const calls = createFetch((url, request) => {
if (url.pathname !== "/api/event") return
requests.push(request)
return new Promise<Response>((_, reject) => {
request.signal.addEventListener(
"abort",
() => {
aborts.push(normalizeError(request.signal.reason))
reject(request.signal.reason)
},
{ once: true },
)
})
})
const setup = await mount({ api: createApi(calls.fetch) })
await waitFor(() => requests.length === 1)
setup.app.renderer.destroy()
await waitFor(() => requests[0].signal.aborted)
await Bun.sleep(20)
observe("cleanup.pending-handshake", {
status: setup.client.connection.status(),
requestAborted: requests[0].signal.aborted,
aborts,
history: history(setup.client),
logs: setup.logs,
})
expect(history(setup.client).map((item) => item.status)).toEqual(["connecting"])
})
test("records an event listener failure as a connection failure", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream), throwOn: "vcs.branch.updated" })
await waitFor(() => setup.client.connection.status() === "connected")
stream.emit(event("vcs", "throws"))
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("listener.failure", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
typed: setup.typed,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("listener failed for vcs.branch.updated")
})
test("records stream reader failure after connection", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.fail("reader exploded")
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("stream.reader-failure", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Transport")
})
test("records malformed SSE data after connection", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.raw("data: not-json\n\n")
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("stream.malformed-data", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("MalformedResponse")
})
test("records a server.connected listener failure before connected state publication", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream), throwOn: "server.connected" })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("listener.connected-failure", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(history(setup.client).map((item) => item.status)).toEqual(["connecting", "connected", "disconnected"])
})
test("records cleanup during static reconnect backoff", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => setup.client.connection.status() === "reconnecting")
setup.app.renderer.destroy()
await Bun.sleep(1_050)
observe("cleanup.reconnect-backoff", {
status: setup.client.connection.status(),
history: history(setup.client),
stream: stream.snapshot(),
})
expect(stream.snapshot().requests).toBe(1)
})
test("records cleanup during managed-service resolution", async () => {
const stream = createStream()
let resolutionStarted = false
let resolutionAborted = false
const service: Service = {
reconnect(signal) {
resolutionStarted = true
return new Promise((_, reject) => {
signal.addEventListener(
"abort",
() => {
resolutionAborted = true
reject(signal.reason)
},
{ once: true },
)
})
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apiFor(stream), service })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => resolutionStarted)
setup.app.renderer.destroy()
await waitFor(() => resolutionAborted)
await Bun.sleep(20)
observe("cleanup.service-resolution", {
resolutionStarted,
resolutionAborted,
status: setup.client.connection.status(),
history: history(setup.client),
stream: stream.snapshot(),
logs: setup.logs,
})
expect(resolutionAborted).toBe(true)
})
test("records attempt reset after a stable connection", async () => {
const streams = [createStream(), createStream(), createStream()]
const apis = streams.map(apiFor)
let reconnects = 0
const service: Service = {
reconnect() {
const api = apis[Math.min(reconnects + 1, apis.length - 1)]
reconnects += 1
return Promise.resolve({ api })
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apis[0], service })
await waitFor(() => setup.client.connection.status() === "connected")
streams[0].close()
await waitFor(() => streams[1].snapshot().requests === 1)
streams[1].close()
await waitFor(() => streams[2].snapshot().requests === 1)
await Bun.sleep(1_050)
streams[2].close()
await waitFor(() => reconnects === 3)
observe("reconnect.stable-reset", {
reconnects,
status: setup.client.connection.status(),
history: history(setup.client),
streams: streams.map((stream) => stream.snapshot()),
})
setup.app.renderer.destroy()
expect(history(setup.client).filter((item) => item.status === "disconnected").map((item) => item.attempt)).toEqual([
1, 2, 1,
])
})
})
+26 -8
View File
@@ -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,
)
}
}
}
}