mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 14:41:48 -04:00
Compare commits
3 Commits
shell-create-hook
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| f7ea2fc346 | |||
| 5cb633a48e | |||
| b985d2eb8e |
@@ -136,7 +136,7 @@ describe("acp service lifecycle", () => {
|
||||
method: "POST",
|
||||
path: "/api/session/ses_loaded/fork",
|
||||
query: {},
|
||||
body: {},
|
||||
body: { boundary: { type: "through" } },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" }])
|
||||
|
||||
Reference in New Issue
Block a user