Compare commits

...

3 Commits

Author SHA1 Message Date
Aiden Cline f7ea2fc346 test(cli): update ACP fork expectation (#39554) 2026-07-29 13:21:34 -05:00
Aiden Cline 5cb633a48e feat(core): support pinned Code Mode tools (#39550) 2026-07-29 13:16:49 -05:00
Kit Langton b985d2eb8e feat(tui): polish session tab animations (#39542) 2026-07-29 13:50:49 -04:00
16 changed files with 483 additions and 124 deletions
@@ -136,7 +136,7 @@ describe("acp service lifecycle", () => {
method: "POST",
path: "/api/session/ses_loaded/fork",
query: {},
body: {},
body: { boundary: { type: "through" } },
})
})
+19 -5
View File
@@ -6,6 +6,7 @@ export const Entry = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
})
export type Entry = typeof Entry.Type
@@ -56,26 +57,39 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
if (left.path > right.path) return 1
return 0
})
const ranked = rankListings(listings)
const pinned = new Set(
namespaceEntries
.filter((entry) => entry.pinned)
.map((entry) => listings.find((listing) => listing.path === entry.path))
.filter((listing) => listing !== undefined),
)
return {
name,
listings,
selectionOrder: rankListings(listings),
selectedListings: new Set<typeof Listing.Type>(),
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
selectedListings: pinned,
selectionIndex: 0,
}
})
const active = new Set(namespaces)
let remaining = budget
let remaining =
budget -
namespaces
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
while (active.size > 0) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
const candidate = namespace.selectionOrder[namespace.selectionIndex]
if (!candidate || candidate.cost > remaining) {
active.delete(namespace)
continue
}
namespace.selectedListings.add(candidate.listing)
namespace.selectionIndex += 1
remaining -= candidate.cost
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
if (namespace.selectionIndex === namespace.selectionOrder.length) active.delete(namespace)
}
}
+15 -6
View File
@@ -138,7 +138,14 @@ export const create = (
}
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
const pinned = new Set(
Array.from(registrations.values())
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
}
function runtime(
@@ -149,11 +156,7 @@ function runtime(
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(registration)
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
const path =
registration.options?.namespace === undefined
? normalized
: `${registration.options.namespace}.${normalized}`
const path = qualifiedName(registration)
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
@@ -164,6 +167,12 @@ function runtime(
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function qualifiedName(registration: Info) {
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
if (registration.options?.namespace === undefined) return normalized
return `${registration.options.namespace}.${normalized}`
}
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
if (input === null || input === undefined) return
+2
View File
@@ -16,6 +16,7 @@ describe("CodeMode", () => {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
options: { pinned: true },
execute: ({ text }) => Effect.succeed({ output: text }),
}),
)
@@ -27,6 +28,7 @@ describe("CodeMode", () => {
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
},
])
}).pipe(
+26 -1
View File
@@ -2,10 +2,11 @@ import { describe, expect, test } from "bun:test"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({
path,
description,
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
pinned,
})
const lookup = entry(
@@ -46,6 +47,30 @@ describe("CodeModeCatalog.summarize", () => {
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
})
test("always retains pinned tools beyond the inline budget", () => {
const pinned = [
entry("alpha.first", "First", undefined, true),
entry("beta.second", "Second", undefined, true),
]
const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
expect(catalog.shown).toBe(2)
expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([
"alpha.first",
"beta.second",
])
})
test("spends the budget remaining after pinned tools on unpinned tools", () => {
const pinned = entry("alpha.pinned", "Pinned", undefined, true)
const unpinned = entry("beta.unpinned", "Unpinned")
const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4)
const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1)
})
test("retains only the rendered portion of inline descriptions", () => {
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
@@ -67,7 +67,7 @@ const transform = (
) =>
service.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
draft.add({ ...tool, name, options: options ?? tool.options }),
),
)
+1 -1
View File
@@ -231,7 +231,7 @@ const permission = Layer.succeed(
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
registry.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
draft.add({ ...tool, name, options: options ?? tool.options }),
),
)
const echo = Layer.effectDiscard(
+13 -2
View File
@@ -19,12 +19,23 @@ export interface Context {
readonly progress: (update: Metadata) => Effect.Effect<void>
}
export interface Options {
interface BaseOptions {
readonly namespace?: string
readonly codemode?: boolean
readonly permission?: string
}
export type Options = BaseOptions &
(
| {
readonly codemode?: true
readonly pinned?: boolean
}
| {
readonly codemode: boolean
readonly pinned?: never
}
)
export type ValueSchema<A = unknown> =
| Schema.Codec<A, any>
| (StandardSchemaV1<any, A> & StandardJSONSchemaV1<any, A>)
+134 -48
View File
@@ -1,5 +1,5 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
@@ -7,20 +7,24 @@ import { useTheme, useThemes } from "../context/theme"
import {
adaptiveSessionTabLayout,
sessionTabComplete,
SESSION_TAB_OVERFLOW_WIDTH,
seedSessionTabMotion,
sessionTabOverflowWidth,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { createAnimatable, spring } from "../ui/animation"
import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse } from "./tab-pulse"
import { tint } from "../theme/color"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
unread: SessionTabUnread | undefined
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close"> & {
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
status(sessionID: string): SessionTabsStatus
}
@@ -32,9 +36,11 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [dragging, setDragging] = createSignal<string>()
let strip: { screenX: number } | undefined
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => tint(theme.hue.interactive[hueStep()], theme.background.default, 0.25)
const activeNumber = () => theme.hue.interactive[hueStep()]
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const activeID = createMemo(tabs.current)
const items = tabs.tabs
@@ -73,21 +79,38 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
let signature = ""
let total = 0
createEffect(() => {
// createComputed runs before render effects, so seeded widths are visible on the first frame
// of a membership change instead of flashing the final layout.
createComputed(() => {
const next = targets()
const nextSignature = identity()
const reset = (signature && signature !== nextSignature) || (total && total !== layout().total)
const changed = Boolean(signature) && signature !== nextSignature
const resized = Boolean(total) && total !== layout().total
const previous = signature
signature = nextSignature
total = layout().total
if (reset) return motion.jump(next)
if (!changed && !resized) return motion.animate(next)
// Identity-stable total changes are terminal resizes and still jump.
if (!changed) return motion.jump(next)
const seeded = seedSessionTabMotion(
previous.split(":"),
layout().tabs.map((tab) => tab.sessionID),
untrack(motion.value),
next,
)
if (!seeded) return motion.jump(next)
motion.jump(seeded)
motion.animate(next)
})
const activeIndex = createMemo(() => layout().tabs.findIndex((tab) => tab.sessionID === activeID()))
const visuals = createMemo(() => {
const current = signature === identity() && total === layout().total ? motion.value() : targets()
const widths = current.widths.map((width) => Math.max(1, Math.round(width)))
const active = layout().tabs.findIndex((tab) => tab.sessionID === activeID())
if (active !== -1) widths[active]! += layout().total - widths.reduce((sum, width) => sum + width, 0)
const active = activeIndex()
const remainder = layout().total - widths.reduce((sum, width) => sum + width, 0)
// Absorb only rounding slack; membership animations leave a real gap while widths grow into place.
if (active !== -1 && Math.abs(remainder) <= layout().tabs.length) widths[active]! += remainder
return new Map(
layout().tabs.map((tab, index) => [
tab.sessionID,
@@ -100,8 +123,21 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
)
})
// Map an absolute pointer column to the items index of the visible slot beneath it.
const slotAt = (x: number) => {
if (!strip) return undefined
const stripX = x - strip.screenX
let edge = layout().before > 0 ? sessionTabOverflowWidth(layout().before) : 0
for (const [index, width] of layout().widths.entries()) {
edge += width
if (stripX < edge) return layout().before + index
}
return layout().before + layout().widths.length - 1
}
return (
<box
ref={(element) => (strip = element)}
height={1}
flexShrink={0}
position="relative"
@@ -127,7 +163,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
}}
>
<Show when={layout().before > 0}>
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
<text width={sessionTabOverflowWidth(layout().before)} fg={theme.text.subdued} selectable={false}>
{layout().before}
</text>
</Show>
@@ -138,38 +174,79 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const width = () => visuals().get(tab.sessionID)?.width ?? 1
const selection = () => visuals().get(tab.sessionID)?.selection ?? Number(selected())
const activity = () => visuals().get(tab.sessionID)?.activity ?? Number(status().complete)
const background = () => {
const base =
hovered() === tab.sessionID && !selected()
? theme.background.action.primary.hovered
: theme.background.default
return tint(base, theme.raise(theme.background.surface.offset), selection())
const dragged = () => dragging() === tab.sessionID
const background = createMemo(() => {
const lifted = (hovered() === tab.sessionID || dragged()) && !selected()
const base = lifted ? theme.background.action.primary.hovered : theme.background.default
// A dragged tab lifts to full selected elevation while it is held.
return tint(base, theme.raise(theme.background.surface.offset), dragged() ? 1 : selection())
})
const pulseColor = () => tint(background(), theme.text.default, 0.45)
const feedbackColor = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
return undefined
}
const pulseBackground = () => background()
const pulseColor = () => tint(pulseBackground(), theme.text.default, 0.45)
const glowColor = () => feedbackColor() ?? accent()
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const availableTitleWidth = () => Math.max(1, width() - 3)
const [outgoingTitle, setOutgoingTitle] = createSignal<string>()
const wipe = createAnimatable({ front: 1 }, { enabled: animations, transition: tween({ duration: 0.3 }) })
createEffect((previous: string) => {
const next = title()
if (next === previous) return next
setOutgoingTitle(previous)
wipe.jump({ front: 0 })
wipe.animate({ front: 1 })
return next
}, title())
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
// The number cell keeps one trailing space, even for double-digit tabs.
const numberWidth = () => String(tabNumber()).length + 1
// Hovering reveals the close mark, so the title's right bound shifts left of it.
const availableTitleWidth = () =>
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const fadeWidth = () => (hovered() === tab.sessionID ? 6 : 4)
const fadedTitleParts = createMemo(() => visibleTitleParts().slice(-fadeWidth()))
const outgoingTitleParts = createMemo(() => {
const outgoing = outgoingTitle()
if (outgoing === undefined) return undefined
return Locale.graphemes(Locale.takeWidth(outgoing, availableTitleWidth()))
})
// A new title wipes in from the left over the previous one.
const displayedParts = createMemo(() => {
const front = wipe.value().front
const parts = visibleTitleParts()
const previous = outgoingTitleParts()
if (previous === undefined || front >= 1) return parts
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() > fadeWidth(),
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
)
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return tint(theme.text.subdued, theme.text.default, selection())
}
const numberColor = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
const feedback = feedbackColor()
if (feedback) return feedback
const base =
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), activeNumber(), selection())
return tint(base, accent(), activity())
}
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
// Releasing a drag (or a plain click) selects the tab, matching browser tab strips and
// keeping sloppy clicks indistinguishable from clean ones.
const release = () => {
setDragging(undefined)
tabs.select(tab.sessionID)
}
return (
<box
width={width()}
@@ -178,40 +255,48 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseUp={() => tabs.select(tab.sessionID)}
onMouseDown={() => setDragging(tab.sessionID)}
onMouseUp={release}
onMouseDrag={(event) => {
const slot = slotAt(event.x)
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot)
}}
onMouseDragEnd={release}
>
<TabPulse
enabled={animations()}
active={status().busy}
complete={status().complete}
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
active={status().busy && !status().attention}
complete={status().complete && !status().attention}
glow={glows()}
breathe={status().attention}
color={pulseColor()}
glowColor={accent()}
glowColor={glowColor()}
completionColor={accent()}
backgroundColor={pulseBackground()}
backgroundColor={background()}
/>
<box zIndex={1} width="100%" flexDirection="row">
<text width={1}> </text>
<text width={2} fg={numberColor()} attributes={selected() ? TextAttributes.BOLD : undefined}>
{items().findIndex((item) => item.sessionID === tab.sessionID) + 1}
<text width={1} selectable={false}>
{" "}
</text>
<Show
when={titleFades()}
fallback={
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
{visibleTitle()}
</text>
}
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{tabNumber()}
</text>
<text
width={availableTitleWidth()}
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={bold()}
>
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
{visibleTitleParts().slice(0, -fadeWidth()).join("")}
<Show when={titleFades()} fallback={displayedParts().join("")}>
{displayedParts().slice(0, -FADE_WIDTH).join("")}
<For each={fadedTitleParts()}>
{(character, index) => (
<span
style={{
fg: tint(
foreground(),
pulseBackground(),
background(),
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
),
}}
@@ -220,14 +305,15 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
</span>
)}
</For>
</text>
</Show>
</Show>
</text>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={closeColor()}
selectable={false}
onMouseUp={(event) => {
event.stopPropagation()
tabs.close(tab.sessionID)
@@ -241,8 +327,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
}}
</For>
<Show when={layout().after > 0}>
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
{layout().after}
<text width={sessionTabOverflowWidth(layout().after)} fg={theme.text.subdued} selectable={false}>
{" " + layout().after}
</text>
</Show>
</box>
+137 -50
View File
@@ -6,6 +6,7 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
active?: boolean
complete?: boolean
glow?: boolean
breathe?: boolean
color?: RGBA
glowColor?: RGBA
completionColor?: RGBA
@@ -20,6 +21,15 @@ const RUN_TAIL = 18
const RUN_FADE_OUT = 500
const COMPLETION_DURATION = 900
const COMPLETION_ATTACK = 0.16
const COMPLETION_OPACITY = 0.18
const EDGE_FLASH_DURATION = 500
const EDGE_FLASH_OPACITY = 0.1
const GLOW_IGNITION_DURATION = 600
const GLOW_IGNITION_PEAK = 1.5
const GLOW_IGNITION_ATTACK = 0.3
const GLOW_FADE_OUT = 200
const GLOW_BREATHE_PERIOD = 3_600
const GLOW_BREATHE_RISE = 0.25
const GLOW_TAIL = 12
const GLOW_OPACITY = 0.16
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
@@ -33,10 +43,15 @@ const coast = (value: number) => {
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
return (value - ramp / 2) / (1 - ramp)
}
export const completionPulseOpacity = (progress: number) =>
progress < COMPLETION_ATTACK
? smootherstep(clamp(progress / COMPLETION_ATTACK))
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
const fadeOut = (progress: number) => 1 - smootherstep(progress)
/** Rise to peak over the attack fraction, then settle to rest over the remainder. */
const attackDecay = (progress: number, attack: number, peak: number, rest: number) =>
progress < attack
? peak * smootherstep(clamp(progress / attack))
: peak - (peak - rest) * smootherstep(clamp((progress - attack) / (1 - attack)))
export const completionPulseOpacity = (progress: number) => attackDecay(progress, COMPLETION_ATTACK, 1, 0)
export const glowIgnitionLevel = (progress: number) =>
attackDecay(progress, GLOW_IGNITION_ATTACK, GLOW_IGNITION_PEAK, 1)
export const unreadGlowIntensity = (index: number, width: number) => {
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
@@ -61,19 +76,64 @@ export function blendTabPulseColor(
output.g += (completionColor.g - output.g) * completion
output.b += (completionColor.b - output.b) * completion
}
/** A one-shot animation clock: level() follows shape over duration, scaled by the value passed to start. */
class Envelope {
private clock: number | undefined
private scale = 1
constructor(
private duration: number,
private shape: (progress: number) => number,
) {}
start(scale = 1) {
if (this.clock !== undefined) return
this.clock = 0
this.scale = scale
}
stop() {
this.clock = undefined
}
advance(delta: number) {
if (this.clock === undefined) return
this.clock += delta
if (this.clock >= this.duration) this.clock = undefined
}
get active() {
return this.clock !== undefined
}
level() {
return this.clock === undefined ? 0 : this.scale * this.shape(this.clock / this.duration)
}
}
// Hoisted so the per-frame liveness check allocates no closure.
const envelopeActive = (envelope: Envelope) => envelope.active
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _complete: boolean
private _glow: boolean
private _breathe: boolean
private _color: RGBA
private _glowColor: RGBA
private _completionColor: RGBA
private _backgroundColor: RGBA
private clock = 0
private fadeClock: number | undefined
private completionClock: number | undefined
private breatheClock = 0
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 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)
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
@@ -84,21 +144,36 @@ class TabPulseRenderable extends Renderable {
this._active = active
this._complete = options.complete ?? false
this._glow = options.glow ?? false
this._breathe = options.breathe ?? false
this._color = options.color ?? RGBA.defaultForeground()
this._glowColor = options.glowColor ?? this._color
this._completionColor = options.completionColor ?? this._color
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
}
private get breathing() {
return this._enabled && this._glow && this._breathe
}
/** Resting glow is 1; ignition overshoots on arrival, breathing swells while pending, glowOff decays after. */
private glowLevel() {
if (!this._glow) return this.glowOff.level()
const base = this.ignition.active ? this.ignition.level() : 1
if (!this.breathing) return base
return (
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
)
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
if (!value) {
this.fadeClock = undefined
this.completionClock = undefined
for (const envelope of this.envelopes) envelope.stop()
this.completionPending = false
this.breatheClock = 0
this.live = false
} else if (this._active) {
} else if (this._active || this.breathing) {
this.live = true
}
this.requestRender()
@@ -109,15 +184,16 @@ class TabPulseRenderable extends Renderable {
this._active = value
if (!this._enabled) return
if (value) {
this.fadeClock = undefined
this.completionClock = undefined
this.runFade.stop()
this.completionPulse.stop()
this.completionPending = false
this.live = true
} else {
this.fadeClock = 0
this.runFade.start()
this.completionPending = true
this.live = true
}
// The same neutral edge flash marks both the start and the finish of a run.
this.edgeFlash.start()
this.live = true
this.requestRender()
}
@@ -125,20 +201,38 @@ class TabPulseRenderable extends Renderable {
if (value === this._complete) return
this._complete = value
if (!value) {
this.completionClock = undefined
this.completionPulse.stop()
this.completionPending = false
}
if (value && this.completionPending) {
this.completionClock = 0
this.completionPending = false
this.live = this._enabled
if (this._enabled) {
this.completionPulse.start()
this.live = true
}
}
this.requestRender()
}
set glow(value: boolean) {
if (value === this._glow) return
if (this._enabled && !value) this.glowOff.start(this.glowLevel())
this._glow = value
this.ignition.stop()
this.breatheClock = 0
if (this._enabled && value) {
this.glowOff.stop()
this.ignition.start()
this.live = true
}
this.requestRender()
}
set breathe(value: boolean) {
if (value === this._breathe) return
this._breathe = value
this.breatheClock = 0
if (this.breathing) this.live = true
this.requestRender()
}
@@ -168,61 +262,52 @@ class TabPulseRenderable extends Renderable {
protected override onUpdate(deltaTime: number): void {
if (!this._enabled) return
if (this._active || this.fadeClock !== undefined) this.clock += deltaTime
if (this.fadeClock !== undefined) {
this.fadeClock += deltaTime
if (this.fadeClock >= RUN_FADE_OUT) this.fadeClock = undefined
}
if (this._active || this.runFade.active) this.clock += deltaTime
if (this.breathing) this.breatheClock += deltaTime
for (const envelope of this.envelopes) envelope.advance(deltaTime)
if (this.completionPending) {
if (this._complete) {
this.completionClock = 0
this.completionPending = false
} else if (this.fadeClock === undefined) {
this.completionPulse.start()
} else if (!this.runFade.active) {
this.completionPending = false
}
}
if (this.completionClock !== undefined) {
this.completionClock += deltaTime
if (this.completionClock >= COMPLETION_DURATION) this.completionClock = undefined
}
this.live = this._active || this.fadeClock !== undefined || this.completionClock !== undefined
this.live = this._active || this.breathing || this.envelopes.some(envelopeActive)
}
protected override renderSelf(buffer: OptimizedBuffer): void {
if (!this.visible || this.isDestroyed || this.width <= 0) return
const runningOpacity = !this._enabled
? 0
: this._active
? 1
: this.fadeClock === undefined
? 0
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
const completionOpacity =
!this._enabled || this.completionClock === undefined
? 0
: completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
if (!this._glow && runningOpacity === 0 && completionOpacity === 0) return
const running = !this._enabled ? 0 : this._active ? 1 : this.runFade.level()
const completion = this.completionPulse.level() * COMPLETION_OPACITY
// 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
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)
for (let index = 0; index < this.width; index++) {
const intensity = Math.max(
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
)
const glow = this._glow ? unreadGlowIntensity(index, this.width) * GLOW_OPACITY : 0
const running = intensity * 0.14 * runningOpacity
const completion = completionOpacity * 0.18
// Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow.
const sweep =
running === 0
? 0
: Math.max(
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
) *
0.14 *
running
blendTabPulseColor(
this.renderColor,
this._backgroundColor,
this._glowColor,
this._color,
this._completionColor,
glow,
running,
glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
Math.max(sweep, flash),
completion,
)
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
@@ -243,6 +328,7 @@ export function TabPulse(props: {
active: boolean
complete?: boolean
glow?: boolean
breathe?: boolean
color: RGBA
glowColor?: RGBA
completionColor?: RGBA
@@ -257,6 +343,7 @@ export function TabPulse(props: {
active={props.active}
complete={props.complete ?? false}
glow={props.glow ?? false}
breathe={props.breathe ?? false}
color={props.color}
glowColor={props.glowColor ?? props.color}
completionColor={props.completionColor ?? props.color}
+46 -4
View File
@@ -17,7 +17,8 @@ export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: b
export const SESSION_TAB_WIDTH = 22
export const SESSION_TAB_MAX_WIDTH = 32
export const SESSION_TAB_MIN_WIDTH = 8
export const SESSION_TAB_OVERFLOW_WIDTH = 3
// Overflow markers reserve one gap cell beside the arrow and count, e.g. "12 " and " 12".
export const sessionTabOverflowWidth = (count: number) => String(count).length + 2
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
@@ -35,6 +36,15 @@ export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string)
}
}
export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: number): SessionTab[] {
const from = tabs.findIndex((tab) => tab.sessionID === sessionID)
const to = Math.max(0, Math.min(tabs.length - 1, index))
if (from === -1 || from === to) return tabs
const next = tabs.filter((tab) => tab.sessionID !== sessionID)
next.splice(to, 0, tabs[from])
return next
}
export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) {
if (tabs.length === 0) return
const index = tabs.findIndex((tab) => tab.sessionID === active)
@@ -67,6 +77,38 @@ export function moveSessionTabHistory(
return { history: { ...history, index: target.index }, sessionID: target.sessionID }
}
export type SessionTabMotionValues = {
widths: number[]
selections: number[]
activities: number[]
}
/**
* Seed width motion for a visible-tab membership change: retained tabs keep their current animated
* values and first-seen tabs grow in from zero width. Returns undefined when nothing is retained,
* meaning the window was fully replaced and the strip should jump.
*/
export function seedSessionTabMotion(
previous: readonly string[],
ids: readonly string[],
current: SessionTabMotionValues,
next: SessionTabMotionValues,
): SessionTabMotionValues | undefined {
const positions = ids.map((id) => previous.indexOf(id))
if (positions.every((position) => position === -1)) return undefined
return {
widths: positions.map((position, index) =>
position === -1 ? 0 : (current.widths[position] ?? next.widths[index] ?? 0),
),
selections: positions.map(
(position, index) => (position === -1 ? next.selections[index] : current.selections[position]) ?? 0,
),
activities: positions.map(
(position, index) => (position === -1 ? next.activities[index] : current.activities[position]) ?? 0,
),
}
}
export function adaptiveSessionTabLayout(
tabs: readonly SessionTab[],
active: string | undefined,
@@ -102,8 +144,8 @@ export function adaptiveSessionTabLayout(
tabs.length - count,
)
const markers =
(nextStart > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) +
(nextStart + count < tabs.length ? SESSION_TAB_OVERFLOW_WIDTH : 0)
(nextStart > 0 ? sessionTabOverflowWidth(nextStart) : 0) +
(nextStart + count < tabs.length ? sessionTabOverflowWidth(tabs.length - nextStart - count) : 0)
const nextCount = fit(available - markers)
if (nextCount === count || attempts === 0) return { count, start: nextStart }
return solve(nextCount, nextStart, attempts - 1)
@@ -114,7 +156,7 @@ export function adaptiveSessionTabLayout(
const after = tabs.length - solved.start - solved.count
const contentWidth = Math.max(
1,
available - (before > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) - (after > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0),
available - (before > 0 ? sessionTabOverflowWidth(before) : 0) - (after > 0 ? sessionTabOverflowWidth(after) : 0),
)
const roomy = contentWidth >= SESSION_TAB_WIDTH * visible.length
const total = roomy ? Math.min(contentWidth, SESSION_TAB_MAX_WIDTH * visible.length) : contentWidth
+12
View File
@@ -10,6 +10,7 @@ import { useTuiPaths } from "./runtime"
import {
closeSessionTab,
cycleSessionTab,
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
recordSessionTabHistory,
@@ -39,11 +40,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const config = useConfig().data
const paths = useTuiPaths()
const enabled = () => config.tabs?.enabled ?? false
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
// mutating in place, which per-row animations and drag state depend on.
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
initial: {
global: empty(),
cwd: {},
},
key: "sessionID",
})
const fallback = empty()
let history: SessionTabHistory = { entries: [], index: -1 }
@@ -177,6 +181,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
remove(target, true)
},
move(sessionID: string, index: number) {
if (!enabled()) return
const session = root(sessionID)
if (moveSessionTab(state().tabs, session, index) === state().tabs) return
update((draft) => {
draft.tabs = moveSessionTab(draft.tabs, session, index)
})
},
cycle(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction)
+5 -2
View File
@@ -8,6 +8,8 @@ import { useTuiApp, useTuiPaths } from "./runtime"
type Options<Value extends object> = {
readonly initial: Value
/** Reconcile key for arrays inside the stored value, preserving item identity across updates. Defaults to "id". */
readonly key?: string
}
type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
@@ -53,7 +55,8 @@ function createStorage(root: string, channel: string) {
}
}
const [store, setStore] = createStore(load())
const reload = () => batch(() => setStore(reconcile(load())))
const merge = (next: Value) => reconcile(next, { key: options.key })
const reload = () => batch(() => setStore(merge(load())))
const update = (mutation: (draft: Value) => void) =>
Flock.withLock(
file,
@@ -62,7 +65,7 @@ function createStorage(root: string, channel: string) {
mutation(draft)
const next = clone(draft)
await writeJsonAtomic(file, next)
batch(() => setStore(reconcile(next)))
batch(() => setStore(merge(next)))
},
{ dir: locks },
)
@@ -2,6 +2,7 @@ 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"]>
@@ -45,7 +46,7 @@ function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const elevatedTheme = theme.contextual.elevated
const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6))
const [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>>({
@@ -61,6 +62,9 @@ function Scrap(props: { context: Plugin.Context }) {
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_STATUS
},
move(sessionID, index) {
setTabs((current) => moveSessionTab(current, sessionID, index))
},
select(sessionID) {
setActive(sessionID)
},
@@ -82,7 +86,7 @@ function Scrap(props: { context: Plugin.Context }) {
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)
controller.select(items[(index + direction + items.length) % items.length].sessionID)
}
const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => {
const sessionID = active()
+13 -1
View File
@@ -1,6 +1,11 @@
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { blendTabPulseColor, completionPulseOpacity, unreadGlowIntensity } from "../../src/component/tab-pulse"
import {
blendTabPulseColor,
completionPulseOpacity,
glowIgnitionLevel,
unreadGlowIntensity,
} from "../../src/component/tab-pulse"
import { tint } from "../../src/theme/color"
test("completion pulse rises quickly and fades over the remaining duration", () => {
@@ -11,6 +16,13 @@ test("completion pulse rises quickly and fades over the remaining duration", ()
expect(completionPulseOpacity(1)).toBe(0)
})
test("glow ignition overshoots the resting level and settles back to it", () => {
expect(glowIgnitionLevel(0)).toBe(0)
expect(glowIgnitionLevel(0.3)).toBeCloseTo(1.5)
expect(glowIgnitionLevel(0.6)).toBeGreaterThan(1)
expect(glowIgnitionLevel(1)).toBe(1)
})
test("unread glow peaks behind the tab number and fades to the normal background", () => {
const intensities = Array.from({ length: 22 }, (_, index) => unreadGlowIntensity(index, 22))
@@ -3,13 +3,65 @@ import {
adaptiveSessionTabLayout,
closeSessionTab,
cycleSessionTab,
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
recordSessionTabHistory,
seedSessionTabMotion,
sessionTabComplete,
sessionTabOverflowWidth,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
expect(moveSessionTab(tabs, "c", -5).map((tab) => tab.sessionID)).toEqual(["c", "a", "b"])
expect(moveSessionTab(tabs, "b", 99).map((tab) => tab.sessionID)).toEqual(["a", "c", "b"])
expect(moveSessionTab(tabs, "b", 1)).toBe(tabs)
expect(moveSessionTab(tabs, "missing", 0)).toBe(tabs)
})
test("open seeding keeps survivors and grows the new tab from zero", () => {
const seeded = seedSessionTabMotion(
["a", "b"],
["a", "b", "c"],
{ widths: [35, 35], selections: [1, 0], activities: [0, 1] },
{ widths: [24, 23, 23], selections: [1, 0, 0], activities: [0, 1, 0] },
)
expect(seeded).toEqual({ widths: [35, 35, 0], selections: [1, 0, 0], activities: [0, 1, 0] })
})
test("close seeding keeps survivors at their current animated widths", () => {
const seeded = seedSessionTabMotion(
["a", "b", "c"],
["a", "c"],
{ widths: [24, 23, 23], selections: [1, 0, 0], activities: [0, 0, 1] },
{ widths: [35, 35], selections: [1, 0], activities: [0, 1] },
)
expect(seeded).toEqual({ widths: [24, 23], selections: [1, 0], activities: [0, 1] })
})
test("window shifts keep retained tabs and grow revealed ones", () => {
const seeded = seedSessionTabMotion(
["a", "b", "c"],
["b", "c", "d"],
{ widths: [22, 8, 8], selections: [1, 0, 0], activities: [0, 0, 0] },
{ widths: [8, 8, 22], selections: [0, 0, 1], activities: [0, 0, 0] },
)
expect(seeded).toEqual({ widths: [8, 8, 0], selections: [0, 0, 1], activities: [0, 0, 0] })
})
test("fully replaced windows jump instead of seeding", () => {
const values = { widths: [22], selections: [1], activities: [0] }
expect(seedSessionTabMotion(["a"], ["z"], values, values)).toBeUndefined()
})
test("overflow markers reserve room for a gap beside their digits", () => {
expect(sessionTabOverflowWidth(5)).toBe(3)
expect(sessionTabOverflowWidth(12)).toBe(4)
})
test("opens each session once and refreshes its title", () => {
const tabs = openSessionTab([{ sessionID: "a", title: "Old" }], { sessionID: "a", title: "New" })
expect(tabs).toEqual([{ sessionID: "a", title: "New" }])