Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 55b2bb8a3a fix(tui): watch newly created plugin directory 2026-07-31 20:58:53 -04:00
41 changed files with 415 additions and 1358 deletions
-7
View File
@@ -4,13 +4,6 @@
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
## Live V2 TUI Testing
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
-1
View File
@@ -8,7 +8,6 @@
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+9 -8
View File
@@ -16,6 +16,7 @@ import {
const patterns = [
/prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i,
/exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
@@ -32,6 +33,7 @@ const patterns = [
/context window exceeds limit/i,
/exceeded model token limit/i,
/context[_ ]length[_ ]exceeded/i,
/request entity too large/i,
/context length is only \d+ tokens/i,
/input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i,
@@ -42,15 +44,11 @@ const patterns = [
/token limit exceeded/i,
]
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
@@ -102,8 +100,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
isContextOverflow(text))
)
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (input.status === 413 || isPayloadTooLarge(text))
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededReason(common)
@@ -146,7 +142,12 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
if (
input.status === 400 ||
input.status === 404 ||
input.status === 413 ||
input.status === 422
)
return new InvalidRequestReason(common)
return new UnknownProviderReason({ ...common, status: input.status })
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export const ProviderFailureClassification = Schema.Literal("context-overflow")
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
+3 -6
View File
@@ -85,17 +85,14 @@ describe("RequestExecutor", () => {
),
)
it.effect("classifies generic HTTP 413 payload errors", () =>
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "payload-too-large",
http: { response: { status: 413 } },
})
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
+4 -22
View File
@@ -6,6 +6,7 @@ describe("provider error classification", () => {
test("classifies provider token limit messages as context overflow", () => {
const messages = [
"tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
@@ -18,24 +19,6 @@ describe("provider error classification", () => {
expect(messages.every(isContextOverflow)).toBe(true)
})
test("classifies request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
classifyProviderFailure({
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
status: 400,
}),
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
]
expect(failures).toEqual(
failures.map((failure) =>
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
)
expect(isContextOverflow("413 status code (no body)")).toBe(false)
})
test("does not classify rate limits as context overflow", () => {
const messages = [
"Throttling error: Too many tokens, please wait before trying again.",
@@ -76,10 +59,9 @@ describe("provider error classification", () => {
})
test("classifies transient client statuses as provider internal", () => {
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
"ProviderInternal",
"ProviderInternal",
])
expect(
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -45,11 +45,7 @@ export default Runtime.handler(Commands, (input) =>
const runPromise = Effect.runPromiseWith(context)
const service = server.service
yield* run({
app: {
name: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
},
app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
server: {
endpoint: server.endpoint,
service: service
+5 -5
View File
@@ -398,7 +398,7 @@ export type Endpoint5_26Output =
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
}
}
| {
@@ -524,7 +524,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -686,7 +686,7 @@ export type Endpoint5_26Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
readonly content?:
| readonly [
(
@@ -726,7 +726,7 @@ export type Endpoint5_26Output =
readonly assistantMessageID: SessionMessage.ID
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
}
}
| {
@@ -776,7 +776,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
readonly inputID?: SessionMessage.ID | undefined
}
}
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type SessionStructuredError = { type: string; message: string; status?: number }
export type SessionStructuredError = { type: string; message: string }
export type SessionMessageCompactionRunning = {
type: "compaction"
+10 -16
View File
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof LLMError) {
switch (cause.reason._tag) {
case "RateLimit":
return providerError("provider.rate-limit", cause.reason)
return { type: "provider.rate-limit", message: cause.reason.message }
case "Authentication":
return providerError("provider.auth", cause.reason)
return { type: "provider.auth", message: cause.reason.message }
case "QuotaExceeded":
return providerError("provider.quota", cause.reason)
return { type: "provider.quota", message: cause.reason.message }
case "ContentPolicy":
return providerError("provider.content-filter", cause.reason)
return { type: "provider.content-filter", message: cause.reason.message }
case "Transport":
return providerError("provider.transport", cause.reason)
return { type: "provider.transport", message: cause.reason.message }
case "ProviderInternal":
return providerError("provider.internal", cause.reason)
return { type: "provider.internal", message: cause.reason.message }
case "InvalidProviderOutput":
return providerError("provider.invalid-output", cause.reason)
return { type: "provider.invalid-output", message: cause.reason.message }
case "InvalidRequest":
return providerError("provider.invalid-request", cause.reason)
return { type: "provider.invalid-request", message: cause.reason.message }
case "NoRoute":
return providerError("provider.no-route", cause.reason)
return { type: "provider.no-route", message: cause.reason.message }
case "UnknownProvider":
return providerError("provider.unknown", cause.reason)
return { type: "provider.unknown", message: cause.reason.message }
default: {
const exhaustive: never = cause.reason
return exhaustive
@@ -58,9 +58,3 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
}
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
const status =
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
}
-20
View File
@@ -14,9 +14,6 @@ import {
TransportReason,
UnknownProviderReason,
ToolFailure,
HttpContext,
HttpRequestDetails,
HttpResponseDetails,
} from "@opencode-ai/ai"
import { Permission } from "@opencode-ai/core/permission"
import { Tool } from "@opencode-ai/schema/tool"
@@ -74,23 +71,6 @@ describe("toSessionError", () => {
})
})
test("preserves provider HTTP status", () => {
const http = new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
response: new HttpResponseDetails({ status: 413, headers: {} }),
})
expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
type: "provider.invalid-request",
message: "too large",
status: 413,
})
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
type: "provider.internal",
message: "bad gateway",
status: 502,
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
-2
View File
@@ -1,11 +1,9 @@
export * as SessionError from "./session-error.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
export interface Error extends Schema.Schema.Type<typeof Error> {}
export const Error = Schema.Struct({
type: Schema.String,
message: Schema.String,
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
}).annotate({ identifier: "Session.StructuredError" })
+11 -20
View File
@@ -68,7 +68,6 @@ import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { sessionTabsFitVertically } from "./ui/layout"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
@@ -84,7 +83,6 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { tuiPluginDirectories } from "./plugin/discovery"
import { PluginRoute, PluginSlot } from "./plugin/render"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
@@ -210,13 +208,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
const api = OpenCode.make(options)
const location = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.map((response) => response.location),
Effect.catch(() => Effect.tryPromise(() => api.location.get())),
)
const directory = location.directory
const pluginDirectories = yield* Effect.promise(() =>
tuiPluginDirectories(process.cwd(), global.config),
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.map((response) => response.location.directory),
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
)
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
const managed = input.server.service
@@ -384,10 +378,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
@@ -528,9 +519,6 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
const tabsVisible = () =>
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
createEffect(() => {
renderer.useMouse = config.data.mouse
@@ -1226,13 +1214,16 @@ function App(props: { pair?: DialogPairCredentials }) {
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
>
<box flexGrow={1} minHeight={0} flexDirection="row">
<Show when={tabsVisible() && tabsVertical()}>
<SessionTabs orientation="vertical" />
</Show>
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show when={tabsVisible() && !tabsVertical()}>
<Show
when={
sessionTabs.enabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
>
<SessionTabs />
</Show>
<Switch>
@@ -100,15 +100,6 @@ export const settings: Setting[] = [
values: ["cwd", "global"],
labels: ["current directory", "global"],
},
{
title: "Vertical",
category: "Tabs",
path: ["tabs", "vertical"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["sidebar", "orientation", "left"],
},
{
title: "Layout",
category: "Diffs",
+4 -10
View File
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => {
const provider = providers().get(model.providerID)
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
return {
value: { providerID: model.providerID, modelID: model.id },
providerID: model.providerID,
providerName: provider?.name ?? model.providerID,
title: model.name,
releaseDate: model.time.released,
favorite,
description: favorite ? "(Favorite)" : undefined,
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
? "(Favorite)"
: undefined,
category: connected() ? (provider?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
onSelect() {
@@ -96,9 +96,7 @@ export function DialogModel(props: { providerID?: string }) {
)
if (needle) {
return prioritizeFavorites(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
)
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
}
return [...favoriteOptions, ...recentOptions, ...modelOptions]
@@ -162,10 +160,6 @@ export function DialogModel(props: { providerID?: string }) {
)
}
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
}
export function sortModelOptions<
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
>(options: T[]) {
+3 -3
View File
@@ -1,3 +1,4 @@
import path from "path"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
@@ -17,7 +18,6 @@ import { truncateFilePath } from "../ui/file-path"
import { stringWidth } from "../util/string-width"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { Spinner } from "./spinner"
import { projectName } from "../util/project"
const RECENT_LIMIT = 8
@@ -81,7 +81,7 @@ export function DialogOpen() {
.slice(0, RECENT_LIMIT)
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = projectName(project)
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const running =
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
@@ -109,7 +109,7 @@ export function DialogOpen() {
return true
})
.map((project) => {
const title = projectName(project) ?? project.canonical
const title = project.name ?? path.basename(project.canonical)
const footer = abbreviateHome(project.canonical, paths.home)
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
@@ -20,7 +20,6 @@ import { useSessionTabs } from "../context/session-tabs"
import { useStorage } from "../context/storage"
import { useConfig } from "../config"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { projectName } from "../util/project"
export function DialogSessionList() {
const dialog = useDialog()
@@ -124,7 +123,8 @@ export function DialogSessionList() {
const current = data.location.info()
if (!current) return ""
const project = data.project.get(current.project.id)
return projectName(project) ?? ""
if (!project) return ""
return project.name || path.basename(project.canonical)
})
const options = createMemo(() => {
@@ -141,7 +141,9 @@ export function DialogSessionList() {
const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory
const project = data.project.get(session.projectID)
const footer = allProjects() ? Locale.truncate(projectName(project, directory) ?? "", 20) : undefined
const footer = allProjects()
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
: undefined
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
+14 -358
View File
@@ -1,9 +1,8 @@
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { RGBA, TextAttributes } from "@opentui/core"
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"
import { useData } from "../context/data"
import { useTheme, useThemes } from "../context/theme"
import {
adaptiveSessionTabLayout,
@@ -20,8 +19,6 @@ import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { projectName } from "../util/project"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
@@ -42,349 +39,8 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
}
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) =>
tint(base, glow, 0.12 * unreadGlowIntensity(index, width))
export function SessionTabs(
props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
) {
if (props.orientation === "vertical")
return <VerticalSessionTabs controller={props.controller} animations={props.animations} />
return <HorizontalSessionTabs controller={props.controller} animations={props.animations} />
}
function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean }) {
const tabs = props.controller ?? useSessionTabs()
const data = useData()
const theme = useTheme("elevated")
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const width = () => SESSION_SIDEBAR_WIDTH
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const [hovered, setHovered] = createSignal<string>()
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const ordered = createMemo(() => {
const pending = preview()
if (!pending) return tabs.tabs()
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
const statuses = createMemo(
() =>
new Map(
items().map((tab) => {
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
return [
tab.sessionID,
{
...status,
complete: sessionTabComplete(status.unread, status.busy),
runs: status.busy && !status.attention,
glows:
tab.sessionID !== activeID() && (status.attention || (!status.busy && status.unread !== undefined)),
},
] as const
}),
),
)
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
let rail: { screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined
createEffect(() => {
const pending = preview()
if (!pending || dragging()) return
const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
})
createEffect(() => {
if (!scroll) return
const index = items().findIndex((tab) => tab.sessionID === activeID())
if (index === -1) return
const top = index * 3
if (top < scroll.scrollTop) return scroll.scrollTo(top)
if (top + 2 > scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(top + 2 - scroll.viewport.height)
}
})
return (
<box
ref={(element) => (rail = element)}
width={width()}
height="100%"
flexShrink={0}
flexDirection="column"
paddingTop={1}
backgroundColor={theme.background.default}
>
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
<box flexShrink={0} flexDirection="column" gap={1}>
<For each={items()}>
{(tab, index) => {
const selected = () => activeID() === tab.sessionID
const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0)
const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID)))
const project = createMemo(() => {
const value = session()
return value ? data.project.get(value.projectID) : undefined
})
const numberWidth = () => String(index() + 1).length + 1
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session"
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
const value = session()
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
})
const background = createMemo(() => {
if (selected()) return theme.background.action.primary.selected
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
return theme.background.action.primary.hovered
return theme.background.default
})
const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a))
const numberColor = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
const base =
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), activeNumber(), Number(selected()))
const color = tint(base, accent(), Number(complete()))
return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel())
}
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return selected() ? theme.text.default : theme.text.subdued
}
const complete = () => status().complete
const glowHue = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
return accent()
}
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45))
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const glows = () => status().glows
const previous = createMemo(() => items()[index() - 1])
const previousStatus = createMemo(() => {
const tab = previous()
return tab
? itemStatus(tab)
: { ...EMPTY_SESSION_TAB_STATUS, complete: false, runs: false, glows: false }
})
const previousGlows = () => previousStatus().glows
const runs = () => status().runs
const previousRuns = () => previousStatus().runs
const previousGlowHue = () => {
if (previousStatus().attention) return theme.text.feedback.warning.default
if (previousStatus().unread === "error") return theme.text.feedback.error.default
return accent()
}
const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
const separatorUpperPulseColor = createMemo(() =>
tint(theme.background.default, theme.text.default, 0.04),
)
const separatorLowerPulseColor = createMemo(() =>
tint(theme.background.default, theme.text.default, 0.05),
)
const titleColor = (index: number) => {
const color = glows()
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
: foreground()
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
const position = index - (visibleTitleParts().length - FADE_WIDTH)
return tint(color, pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
}
const release = () => {
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID)
}
return (
<box
height={2}
width="100%"
position="relative"
flexDirection="column"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseDown={() => setDragging(tab.sessionID)}
onMouseUp={release}
onMouseDrag={(event) => {
if (!rail || tab === NEW_SESSION_TAB) return
const target = Math.max(
0,
Math.min(
tabs.tabs().length - 1,
Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
),
)
if (target !== index() && preview()?.index !== target)
setPreview({ sessionID: tab.sessionID, index: target })
}}
onMouseDragEnd={release}
>
<TabPulse
top={-1}
edge="above"
enabled={animations()}
layer={{
active: runs(),
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: separatorLowerPulseColor(),
glowColor: separatorLowerColor(),
glowTail: 8,
completionColor: separatorLowerColor(),
}}
edgeLayer={{
active: previousRuns(),
promptPulse: previousStatus().promptPulse,
complete: previousStatus().complete && !previousStatus().attention,
glow: previousGlows(),
breathe: previousStatus().attention,
color: separatorUpperPulseColor(),
glowColor: separatorUpperColor(),
glowTail: 5,
completionColor: separatorUpperColor(),
}}
backgroundColor={theme.background.default}
/>
<Show when={index() === items().length - 1}>
<TabPulse
top={2}
edge="below"
enabled={animations()}
layer={{
active: runs(),
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: tint(theme.background.default, theme.text.default, 0.04),
glowColor: tint(theme.background.default, glowHue(), 0.1),
glowTail: 8,
completionColor: tint(theme.background.default, glowHue(), 0.1),
}}
edgeLayer={{
color: tint(theme.background.default, theme.text.default, 0.006),
glowColor: theme.background.default,
glowTail: 5,
completionColor: theme.background.default,
}}
backgroundColor={theme.background.default}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
<TabPulse
enabled={animations()}
layer={{
active: status().busy && !status().attention,
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: pulseColor(),
glowColor: glowColor(),
completionColor: glowColor(),
}}
backgroundColor={pulseBackground()}
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
<text
width={numberWidth()}
fg={numberColor()}
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
{index() + 1}
</text>
<text
width={titleWidth()}
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
<For each={visibleTitleParts()}>
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
</For>
</Show>
</text>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={theme.text.subdued}
selectable={false}
onMouseUp={(event) => {
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "×" : ""}
</text>
</box>
</box>
<box height={1} width="100%" position="relative" flexDirection="row">
<TabPulse
enabled={animations()}
layer={{
active: status().busy && !status().attention,
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: detailPulseColor(),
glowColor: detailGlowColor(),
glowTail: 10,
completionColor: detailGlowColor(),
}}
backgroundColor={pulseBackground()}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
{detail()}
</text>
</box>
</box>
</box>
)
}}
</For>
</box>
</scrollbox>
</box>
)
}
function HorizontalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
const tabs = props.controller ?? useSessionTabs()
const dimensions = useTerminalDimensions()
const theme = useTheme()
@@ -613,7 +269,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
const characterColor = (index: number) => {
const base = foreground()
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
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)))
@@ -662,17 +320,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
>
<TabPulse
enabled={animations()}
layer={{
active: status().busy && !status().attention,
promptPulse: status().promptPulse,
complete: status().complete && !status().attention,
glow: glows(),
breathe: status().attention,
color: pulseColor(),
glowColor: glowColor(),
flashColor: flashColor(),
completionColor: accent(),
}}
active={status().busy && !status().attention}
promptPulse={status().promptPulse}
complete={status().complete && !status().attention}
glow={glows()}
breathe={status().attention}
color={pulseColor()}
glowColor={glowColor()}
flashColor={flashColor()}
completionColor={accent()}
backgroundColor={background()}
onLevel={setSweepLevel}
/>
+194 -365
View File
@@ -1,24 +1,17 @@
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
import { extend } from "@opentui/solid"
export type TabPulseLayer = {
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
enabled?: boolean
active?: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
color: RGBA
color?: RGBA
glowColor?: RGBA
glowTail?: number
flashColor?: RGBA
completionColor?: RGBA
}
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
edge?: "above" | "below"
enabled?: boolean
layer?: TabPulseLayer
edgeLayer?: TabPulseLayer
backgroundColor?: RGBA
/** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */
onLevel?: (level: number) => void
@@ -27,7 +20,6 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
const clamp = (value: number) => Math.max(0, Math.min(1, value))
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
const RUN_DURATION = 2_800
const RUN_ATTACK = 450
const RUN_HEAD = 4
const RUN_TAIL = 18
const RUN_FADE_OUT = 500
@@ -66,10 +58,9 @@ const attackDecay = (progress: number, attack: number, peak: number, rest: numbe
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)
const glowIntensityAt = (index: number, tail: number) => smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
export const unreadGlowIntensity = (index: number, width: number, maximumTail = GLOW_TAIL) => {
const tail = Math.min(maximumTail, Math.max(1, width - 2))
return glowIntensityAt(index, tail)
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))
}
export function blendTabPulseColor(
output: RGBA,
@@ -140,259 +131,45 @@ class Envelope {
// Hoisted so the per-frame liveness check allocates no closure.
const envelopeActive = (envelope: Envelope) => envelope.active
type PulseStateOptions = {
enabled: boolean
active: boolean
promptPulse: number
complete: boolean
glow: boolean
breathe: boolean
}
class PulseState {
private enabled: boolean
private active: boolean
private promptPulse: number
private complete: boolean
private glow: boolean
private breathe: boolean
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _promptPulse: number
private _complete: boolean
private _glow: boolean
private _breathe: boolean
private _color: RGBA
private _glowColor: RGBA
private _flashColor: RGBA
private _completionColor: RGBA
private _backgroundColor: RGBA
private clock = 0
private breatheClock = 0
private completionPending = false
private runAttack = new Envelope(RUN_ATTACK, smootherstep)
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
private completionPulse = new Envelope(COMPLETION_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.runAttack, this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
constructor(options: PulseStateOptions) {
this.enabled = options.enabled
this.active = options.active
this.promptPulse = options.promptPulse
this.complete = options.complete
this.glow = options.glow
this.breathe = options.breathe
if (this.enabled && this.active) this.runAttack.start()
}
private get breathing() {
return this.enabled && this.glow && this.breathe
}
get live() {
return this.active || this.breathing || this.envelopes.some(envelopeActive)
}
get running() {
if (!this.enabled) return 0
return this.active ? (this.runAttack.active ? this.runAttack.level() : 1) : this.runFade.level()
}
get completion() {
return this.completionPulse.level() * COMPLETION_OPACITY
}
get flash() {
return this.edgeFlash.level() * EDGE_FLASH_OPACITY
}
get 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)))
)
}
setEnabled(value: boolean) {
if (value === this.enabled) return false
this.enabled = value
if (!value) {
for (const envelope of this.envelopes) envelope.stop()
this.completionPending = false
this.breatheClock = 0
} else if (this.active) {
this.runAttack.restart()
}
return true
}
setActive(value: boolean) {
if (value === this.active) return false
this.active = value
if (!this.enabled) return true
if (value) {
this.clock = 0
this.runAttack.restart()
this.runFade.stop()
this.completionPulse.stop()
this.completionPending = false
} else {
const level = this.runAttack.active ? this.runAttack.level() : 1
this.runAttack.stop()
this.runFade.start(level)
this.completionPending = true
}
this.edgeFlash.start()
return true
}
setPromptPulse(value: number) {
if (value === this.promptPulse) return false
this.promptPulse = value
if (this.enabled) this.edgeFlash.restart(PROMPT_FLASH_SCALE)
return true
}
setComplete(value: boolean) {
if (value === this.complete) return false
this.complete = value
if (!value) {
this.completionPulse.stop()
this.completionPending = false
}
if (value && this.completionPending) {
this.completionPending = false
if (this.enabled) this.completionPulse.start()
}
return true
}
setGlow(value: boolean) {
if (value === this.glow) return false
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()
}
return true
}
setBreathe(value: boolean) {
if (value === this.breathe) return false
this.breathe = value
this.breatheClock = 0
return true
}
advance(deltaTime: number) {
if (!this.enabled) return
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) return
if (this.complete) {
this.completionPending = false
this.completionPulse.start()
return
}
if (!this.runFade.active) this.completionPending = false
}
fronts(width: number) {
const cycles = this.clock / RUN_DURATION
const progress = cycles % 1
const start = -RUN_HEAD
const end = width - 1 + RUN_TAIL
const secondProgress = cycles < 0.5 ? 0 : (cycles + 0.5) % 1
return [start + coast(progress) * (end - start), start + coast(secondProgress) * (end - start)] as const
}
}
class PulseLayer {
readonly state: PulseState
color: RGBA
glowColor: RGBA
glowTail: number
flashColor: RGBA
completionColor: RGBA
constructor(options: TabPulseLayer, enabled: boolean) {
this.state = new PulseState({
enabled,
active: options.active ?? false,
promptPulse: options.promptPulse ?? 0,
complete: options.complete ?? false,
glow: options.glow ?? false,
breathe: options.breathe ?? false,
})
this.color = options.color
this.glowColor = options.glowColor ?? options.color
this.glowTail = options.glowTail ?? GLOW_TAIL
this.flashColor = options.flashColor ?? options.color
this.completionColor = options.completionColor ?? options.color
}
set(options: TabPulseLayer) {
const color = options.color
const glowColor = options.glowColor ?? color
const glowTail = options.glowTail ?? GLOW_TAIL
const flashColor = options.flashColor ?? color
const completionColor = options.completionColor ?? color
const changed = [
// Stopping a run arms completion; apply complete afterward so an atomic idle update can consume it.
this.state.setActive(options.active ?? false),
this.state.setPromptPulse(options.promptPulse ?? 0),
this.state.setComplete(options.complete ?? false),
this.state.setGlow(options.glow ?? false),
this.state.setBreathe(options.breathe ?? false),
!color.equals(this.color),
!glowColor.equals(this.glowColor),
glowTail !== this.glowTail,
!flashColor.equals(this.flashColor),
!completionColor.equals(this.completionColor),
].some(Boolean)
this.color = color
this.glowColor = glowColor
this.glowTail = glowTail
this.flashColor = flashColor
this.completionColor = completionColor
return changed
}
}
const DEFAULT_LAYER: TabPulseLayer = { color: RGBA.defaultForeground() }
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private primary: PulseLayer
private adjacent: PulseLayer
private primaryAssigned: boolean
private adjacentAssigned: boolean
private _edge: "above" | "below" | undefined
private _backgroundColor: RGBA
private primaryRenderColor = RGBA.fromInts(0, 0, 0)
private adjacentRenderColor = RGBA.fromInts(0, 0, 0)
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
const primary = options.layer ?? DEFAULT_LAYER
const adjacent = options.edgeLayer ?? primary
const edge = options.edge
super(ctx, {
...options,
height: 1,
live:
enabled &&
((primary.active ?? false) ||
((primary.glow ?? false) && (primary.breathe ?? false)) ||
(edge !== undefined &&
((adjacent.active ?? false) || ((adjacent.glow ?? false) && (adjacent.breathe ?? false))))),
})
const active = options.active ?? false
super(ctx, { ...options, height: 1, live: enabled && active })
this._enabled = enabled
this.primary = new PulseLayer(primary, enabled)
this.adjacent = new PulseLayer(adjacent, enabled && edge !== undefined)
this.primaryAssigned = options.layer !== undefined
this.adjacentAssigned = options.edgeLayer !== undefined
this._edge = edge
this._active = active
this._promptPulse = options.promptPulse ?? 0
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._flashColor = options.flashColor ?? this._color
this._completionColor = options.completionColor ?? this._color
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
this._onLevel = options.onLevel
}
@@ -402,46 +179,128 @@ class TabPulseRenderable extends Renderable {
}
private emitLevel(value: number) {
if (!this._onLevel) return
const quantized = Math.round(value * 32) / 32
if (quantized === this.lastLevel) return
this.lastLevel = quantized
this._onLevel(quantized)
this._onLevel?.(quantized)
}
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
this.primary.state.setEnabled(value)
this.adjacent.state.setEnabled(value && this._edge !== undefined)
this.changed()
}
set layer(value: TabPulseLayer) {
if (!this.primaryAssigned) {
this.primaryAssigned = true
this.primary = new PulseLayer(value, this._enabled)
this.changed()
return
if (!value) {
for (const envelope of this.envelopes) envelope.stop()
this.completionPending = false
this.breatheClock = 0
this.live = false
} else if (this._active || this.breathing) {
this.live = true
}
if (this.primary.set(value)) this.changed()
this.requestRender()
}
set edgeLayer(value: TabPulseLayer) {
if (!this.adjacentAssigned) {
this.adjacentAssigned = true
this.adjacent = new PulseLayer(value, this._enabled && this._edge !== undefined)
this.changed()
return
set active(value: boolean) {
if (value === this._active) return
this._active = value
if (!this._enabled) return
if (value) {
this.runFade.stop()
this.completionPulse.stop()
this.completionPending = false
} else {
this.runFade.start()
this.completionPending = true
}
if (this.adjacent.set(value)) this.changed()
// The same neutral edge flash marks both the start and the finish of a run.
this.edgeFlash.start()
this.live = true
this.requestRender()
}
set edge(value: "above" | "below" | undefined) {
if (value === this._edge) return
this._edge = value
this.adjacent.state.setEnabled(this._enabled && value !== undefined)
this.changed()
set promptPulse(value: number) {
if (value === this._promptPulse) return
this._promptPulse = value
if (!this._enabled) return
this.edgeFlash.restart(PROMPT_FLASH_SCALE)
this.live = true
this.requestRender()
}
set complete(value: boolean) {
if (value === this._complete) return
this._complete = value
if (!value) {
this.completionPulse.stop()
this.completionPending = false
}
if (value && this.completionPending) {
this.completionPending = false
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()
}
set color(value: RGBA) {
if (value.equals(this._color)) return
this._color = value
this.requestRender()
}
set glowColor(value: RGBA) {
if (value.equals(this._glowColor)) return
this._glowColor = value
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
this.requestRender()
}
set backgroundColor(value: RGBA) {
@@ -450,52 +309,44 @@ class TabPulseRenderable extends Renderable {
this.requestRender()
}
private changed() {
this.live = this.primary.state.live || this.adjacent.state.live
this.requestRender()
}
protected override onUpdate(deltaTime: number): void {
if (!this._enabled) return
this.primary.state.advance(deltaTime)
this.adjacent.state.advance(deltaTime)
this.live = this.primary.state.live || this.adjacent.state.live
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.completionPending = false
this.completionPulse.start()
} else if (!this.runFade.active) {
this.completionPending = false
}
}
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 running = this.primary.state.running
const completion = this.primary.state.completion
const flash = this.primary.state.flash
const glowLevel = this.primary.state.glowLevel
const adjacentRunning = this.adjacent.state.running
const adjacentCompletion = this.adjacent.state.completion
const adjacentFlash = this.adjacent.state.flash
const adjacentGlowLevel = this.adjacent.state.glowLevel
if (
glowLevel === 0 &&
running === 0 &&
completion === 0 &&
flash === 0 &&
adjacentGlowLevel === 0 &&
adjacentRunning === 0 &&
adjacentCompletion === 0 &&
adjacentFlash === 0
) {
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) {
this.emitLevel(0)
return
}
const [front, secondFront] = this.primary.state.fronts(this.width)
const [adjacentFront, adjacentSecondFront] = this.adjacent.state.fronts(this.width)
if (this._onLevel)
this.emitLevel(
running === 0
? 0
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
running,
)
const glowTail = Math.min(this.primary.glowTail, Math.max(1, this.width - 2))
const adjacentGlowTail = Math.min(this.adjacent.glowTail, Math.max(1, this.width - 2))
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 =
@@ -507,50 +358,19 @@ class TabPulseRenderable extends Renderable {
) *
0.14 *
running
const adjacentSweep =
adjacentRunning === 0
? 0
: Math.max(
intensityAt(index, adjacentFront, RUN_HEAD, RUN_TAIL),
intensityAt(index, adjacentSecondFront, RUN_HEAD, RUN_TAIL),
) *
0.14 *
adjacentRunning
blendTabPulseColor(
this.primaryRenderColor,
this.renderColor,
this._backgroundColor,
this.primary.glowColor,
this.primary.color,
this.primary.flashColor,
this.primary.completionColor,
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
this._glowColor,
this._color,
this._flashColor,
this._completionColor,
glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
sweep,
flash,
completion,
)
if (!this._edge) {
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.primaryRenderColor)
continue
}
blendTabPulseColor(
this.adjacentRenderColor,
this._backgroundColor,
this.adjacent.glowColor,
this.adjacent.color,
this.adjacent.flashColor,
this.adjacent.completionColor,
adjacentGlowLevel === 0 ? 0 : glowIntensityAt(index, adjacentGlowTail) * GLOW_OPACITY * adjacentGlowLevel,
adjacentSweep,
adjacentFlash,
adjacentCompletion,
)
buffer.setCell(
this.screenX + index,
this.screenY,
this._edge === "above" ? "▄" : "▀",
this.primaryRenderColor,
this.adjacentRenderColor,
)
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
}
}
}
@@ -564,25 +384,34 @@ declare module "@opentui/solid" {
extend({ tab_pulse: TabPulseRenderable })
export function TabPulse(props: {
top?: number
width?: number
edge?: "above" | "below"
enabled?: boolean
layer: TabPulseLayer
edgeLayer?: TabPulseLayer
active: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
color: RGBA
glowColor?: RGBA
flashColor?: RGBA
completionColor?: RGBA
backgroundColor: RGBA
onLevel?: (level: number) => void
}) {
return (
<tab_pulse
position="absolute"
top={props.top}
edge={props.edge}
zIndex={0}
width={props.width ?? "100%"}
width="100%"
enabled={props.enabled ?? true}
layer={props.layer}
edgeLayer={props.edgeLayer ?? props.layer}
active={props.active}
promptPulse={props.promptPulse ?? 0}
complete={props.complete ?? false}
glow={props.glow ?? false}
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}
/>
-3
View File
@@ -132,9 +132,6 @@ export const Info = Schema.Struct({
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory",
}),
vertical: Schema.optional(Schema.Boolean).annotate({
description: "Show tabs in a left sidebar instead of a horizontal strip",
}),
}),
).annotate({ description: "Tab strip settings" }),
mini: Schema.optional(
+2 -3
View File
@@ -22,7 +22,7 @@ import {
type ThemeDocumentSource,
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes } from "../theme/discovery"
import { discoverThemes, themeDirectories } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
@@ -30,7 +30,6 @@ import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
import { Global } from "@opencode-ai/util/global"
import { DevTools } from "../devtools"
import { configDirectories } from "../util/config-directories"
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
export type ThemeError = { name: string; error: Error }
@@ -71,7 +70,7 @@ export type ThemeSource = Readonly<{
const themeSource: ThemeSource = {
async discover() {
return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
return discoverThemes(themeDirectories(Global.Path.config, process.cwd()))
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)
@@ -7,7 +7,7 @@ const money = new Intl.NumberFormat("en-US", {
currency: "USD",
})
export function SidebarContext(props: { context: Plugin.Context; sessionID: string }) {
function View(props: { context: Plugin.Context; sessionID: string }) {
const theme = props.context.theme
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
const session = createMemo(() => props.context.data.session.get(props.sessionID))
@@ -18,32 +18,28 @@ export function SidebarContext(props: { context: Plugin.Context; sessionID: stri
)
return (
<Show when={state() || cost() > 0}>
<box>
<text fg={theme.text.default}>
<b>Context</b>
</text>
<Show when={state()}>
{(value) => (
<>
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={theme.text.subdued}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<Show when={cost() > 0}>
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
</Show>
</box>
</Show>
<box>
<text fg={theme.text.default}>
<b>Context</b>
</text>
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
{(value) => (
<>
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={theme.text.subdued}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
</box>
)
}
export default Plugin.define({
id: "internal:sidebar-context",
setup(context) {
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
},
})
+7 -9
View File
@@ -13,7 +13,7 @@ import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { createPluginContext, usePluginHost, type Dispose } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
@@ -57,7 +57,7 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
const PluginContext = createContext<Value>()
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
const host = usePluginHost()
const config = useConfig()
const lifecycle = useTuiLifecycle()
@@ -171,11 +171,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
void enqueue(reconcile).catch(() => undefined)
}, 100)
})
const stopWatching = () => {
onCleanup(() => {
clearTimeout(pending)
watcher.dispose()
}
onCleanup(stopWatching)
})
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
// core plugin registry: fold the ordered entries into a desired end state
@@ -187,8 +186,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// every watch event; remember them until the configuration changes.
const npmFailures = new Map<string, string>()
const reconcile = async () => {
await Promise.all(props.directories.map(watcher.wait))
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])]
watcher.add(tuiPluginDirectory(host.paths.cwd))
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
@@ -211,7 +210,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const options = typeof entry === "string" ? undefined : entry.options
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
const local = localSource(target, directory)
if (local) await watcher.add(fileURLToPath(local))
if (local) watcher.add(fileURLToPath(local))
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
@@ -364,7 +363,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
let disposing: Promise<void> | undefined
const dispose = () => {
if (disposing) return disposing
stopWatching()
disposing = loading
.catch(() => undefined)
.then(() =>
+13 -37
View File
@@ -1,47 +1,23 @@
import { readdir, stat } from "node:fs/promises"
import { readdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import {
isMissingPath,
localProjectDirectory,
projectConfigDirectories,
} from "../util/config-directories"
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
export async function tuiPluginDirectories(cwd: string, configDirectory: string) {
const projectDirectory = await localProjectDirectory(cwd)
const projectConfig = path.join(projectDirectory, ".opencode")
const directories = [configDirectory, ...projectConfigDirectories(projectDirectory, cwd)]
const exists = await Promise.all(
directories.map((directory) => {
if (directory === configDirectory || directory === projectConfig) return true
return stat(directory).then(
(info) => info.isDirectory(),
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
)
}),
)
return directories
.filter((_, index) => exists[index])
.map((directory) => path.join(directory, "plugins", "tui"))
export function tuiPluginDirectory(cwd: string) {
return path.join(cwd, ".opencode", "plugins", "tui")
}
export async function discoverTuiPlugins(directories: string[]) {
return (
await Promise.all(
directories.map(async (directory) => {
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
if (isMissingPath(error)) return []
return Promise.reject(error)
})
return entries
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
.map((entry) => path.join(directory, entry.name))
.sort()
}),
)
).flat()
export async function discoverTuiPlugins(cwd: string) {
const directory = tuiPluginDirectory(cwd)
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
return Promise.reject(error)
})
return entries
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
.map((entry) => path.join(directory, entry.name))
.sort()
}
export function localSource(spec: string, directory: string) {
+48 -51
View File
@@ -8,32 +8,46 @@ import { lstat, realpath, stat } from "fs/promises"
// directories stay quiet. Symlinked files are additionally watched at their
// resolved target, since edits there emit nothing at the link's location.
// Directory targets are watched at their root only: edits to nested helper
// files do not change the entrypoint mtime and are not detected. Watches are
// never torn down individually (a stale watch costs one fs handle and a
// spurious onChange); all die with dispose(). Missing retryable targets are
// polled until they can be armed without relying on a racy chain of ancestor
// watches.
// files do not change the entrypoint mtime and are not detected. A missing
// target temporarily watches its nearest existing parent, filtered to the
// first missing path segment, until the normal source watch can take over.
// Established source watches die with dispose(). Failed or vanished watches
// are forgotten so a later add() can re-arm once the path exists.
export function createSourceWatcher(onChange: () => void) {
const watchers = new Map<string, ReturnType<typeof watch>>()
const watched = new Map<string, Set<string> | null>()
const missing = new Set<string>()
const arming = new Map<string, Promise<void>>()
const missing = new Map<string, { dir: string; watcher: ReturnType<typeof watch> }>()
let disposed = false
const notify = () => {
if (!disposed) onChange()
}
const forget = (dir: string) => {
watchers.get(dir)?.close()
watchers.delete(dir)
watched.delete(dir)
}
const arm = (target: string, retry: boolean) => {
const active = arming.get(target)
if (active) return active
const result = stat(target)
const forgetMissing = (target: string) => {
missing.get(target)?.watcher.close()
missing.delete(target)
}
const armMissing = (target: string) => {
if (disposed) return
const dir = nearestExistingParent(target)
if (!dir) return
if (missing.get(target)?.dir === dir) return
forgetMissing(target)
const name = path.relative(dir, target).split(path.sep)[0]!
const watcher = watch(dir, (_event, filename) => {
if (filename && filename.toString().split(path.sep)[0] !== name) return
forgetMissing(target)
arm(target)
onChange()
})
watcher.on("error", () => forgetMissing(target))
missing.set(target, { dir, watcher })
}
const arm = (target: string) => {
stat(target)
.then((info) => {
if (disposed) return
const appeared = missing.delete(target)
forgetMissing(target)
const dir = info.isDirectory() ? target : path.dirname(target)
// Directories accept every filename (null); files accept their basename.
const name = info.isDirectory() ? null : path.basename(target)
@@ -41,69 +55,52 @@ export function createSourceWatcher(onChange: () => void) {
if (existing !== undefined) {
if (name === null) watched.set(dir, null)
else existing?.add(name)
if (appeared) notify()
return
}
watched.set(dir, name === null ? null : new Set([name]))
const watcher = watch(dir, (_event, filename) => {
// A replaced directory keeps this watcher on the dead inode (Linux
// emits rename, not error); forget it so a later add() re-arms on
// the recreated path, and still schedule so reconcile runs now.
if (!existsSync(dir)) {
forget(dir)
notify()
onChange()
return
}
// A null filename (platform-dependent) always schedules.
const accept = watched.get(dir)
if (filename && accept && !accept.has(filename.toString())) return
notify()
})
watched.set(dir, name === null ? null : new Set([name]))
// Reconcile after watcher errors so every source is re-added and any
// temporarily unavailable target moves into the polling set.
watcher.on("error", () => {
forget(dir)
notify()
onChange()
})
// A watched directory can disappear out from under us; without a
// listener the error event would crash the process. Forget the path
// so a later add can re-arm once it exists again.
watcher.on("error", () => forget(dir))
watchers.set(dir, watcher)
if (appeared) notify()
})
.catch((error: unknown) => {
if (!disposed && retry && isMissing(error)) missing.add(target)
})
.finally(() => arming.delete(target))
arming.set(target, result)
return result
.catch(() => armMissing(target))
}
const add = async (target: string, retry: boolean) => {
await arm(target, retry)
const add = (target: string) => {
arm(target)
// A symlinked source receives edits at its resolved target.
await lstat(target)
lstat(target)
.then((info) => {
if (!info.isSymbolicLink()) return
return realpath(target).then((target) => arm(target, retry))
return realpath(target).then(arm)
})
.catch(() => undefined)
}
const dispose = () => {
disposed = true
clearInterval(poll)
for (const watcher of watchers.values()) watcher.close()
watchers.clear()
watched.clear()
missing.clear()
}
const poll = setInterval(() => missing.forEach((target) => arm(target, true)), 500)
poll.unref()
return {
add: (target: string) => add(target, false),
wait: (target: string) => add(target, true),
dispose,
for (const item of missing.values()) item.watcher.close()
}
return { add, dispose }
}
function isMissing(error: unknown) {
if (!error || typeof error !== "object") return false
const code = Reflect.get(error, "code")
return code === "ENOENT" || code === "ENOTDIR"
function nearestExistingParent(target: string) {
const dir = path.dirname(target)
if (existsSync(dir)) return dir
if (dir === path.dirname(dir)) return
return nearestExistingParent(dir)
}
+2 -8
View File
@@ -64,7 +64,6 @@ import { errorMessage } from "../../util/error"
import { useToast } from "../../ui/toast"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
import { projectedPromptInput } from "../../prompt/codec"
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
@@ -200,19 +199,14 @@ export function Session() {
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
const tabRailWidth = createMemo(() =>
config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
? SESSION_SIDEBAR_WIDTH
: 0,
)
const wide = createMemo(() => dimensions().width - tabRailWidth() > 120)
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebarOpen()) return true
if (sidebar() === "auto" && wide()) return true
return false
})
const contentWidth = createMemo(() => dimensions().width - tabRailWidth() - (sidebarVisible() ? 42 : 0) - 4)
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const models = createMemo(() => data.location.model.list(location()) ?? [])
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
+1 -4
View File
@@ -6,7 +6,6 @@ import { PluginSlot } from "../../plugin/render"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { getScrollAcceleration } from "../../util/scroll"
import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const data = useData()
@@ -19,7 +18,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<Show when={session()}>
<box
backgroundColor={theme.background.default}
width={SESSION_SIDEBAR_WIDTH}
width={42}
height="100%"
paddingTop={1}
paddingBottom={1}
@@ -28,11 +27,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
position={props.overlay ? "absolute" : "relative"}
>
<scrollbox
ref={(scroll) => queueMicrotask(() => scroll.verticalScrollBar.resetVisibilityControl())}
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
visible: false,
trackOptions: {
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
+9
View File
@@ -1,6 +1,15 @@
import { readdir, readFile } from "node:fs/promises"
import path from "node:path"
export function themeDirectories(config: string, cwd: string) {
const directories: string[] = []
for (let current = cwd; ; current = path.dirname(current)) {
directories.push(path.join(current, ".opencode"))
if (path.dirname(current) === current) break
}
return [config, ...directories.reverse()]
}
export async function discoverThemes(directories: string[]) {
const result: Record<string, unknown> = {}
for (const directory of directories) {
-6
View File
@@ -1,6 +0,0 @@
export const SESSION_SIDEBAR_WIDTH = 42
const SESSION_CONTENT_MIN_WIDTH = 44
export function sessionTabsFitVertically(total: number) {
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
}
@@ -1,45 +0,0 @@
import path from "node:path"
import { stat } from "node:fs/promises"
export function configDirectories(config: string, cwd: string) {
return [...new Set([config, ...ancestors(cwd).map((directory) => path.join(directory, ".opencode"))])]
}
export function projectConfigDirectories(project: string, cwd: string) {
const directories = ancestors(cwd)
return directories
.slice(directories.indexOf(path.resolve(project)))
.map((directory) => path.join(directory, ".opencode"))
}
export async function localProjectDirectory(cwd: string) {
const directories = ancestors(cwd)
const repositories = await Promise.all(
directories.map((directory) =>
Promise.all(
[".git", ".hg"].map((name) =>
stat(path.join(directory, name)).then(
() => true,
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
),
),
).then((matches) => matches.some(Boolean)),
),
)
return directories.findLast((_, index) => repositories[index]) ?? path.resolve(cwd)
}
export function isMissingPath(error: unknown) {
if (!error || typeof error !== "object") return false
const code = Reflect.get(error, "code")
return code === "ENOENT" || code === "ENOTDIR"
}
function ancestors(cwd: string) {
const directories: string[] = []
for (let current = path.resolve(cwd); ; current = path.dirname(current)) {
directories.push(current)
if (path.dirname(current) === current) break
}
return directories.reverse()
}
-7
View File
@@ -1,7 +0,0 @@
import path from "path"
export function projectName(project?: { canonical: string; name?: string }, fallback = "") {
const canonical = project?.canonical ?? fallback
if (canonical === "/") return fallback ? path.basename(fallback) : undefined
return project?.name || path.basename(canonical)
}
@@ -1,23 +1,5 @@
import { describe, expect, test } from "bun:test"
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
describe("prioritizeFavorites", () => {
test("moves favorites first while preserving fuzzy result order", () => {
const prioritized = prioritizeFavorites([
{ title: "Best match", favorite: false },
{ title: "Favorite match", favorite: true },
{ title: "Second best match", favorite: false },
{ title: "Second favorite match", favorite: true },
])
expect(prioritized.map((model) => model.title)).toEqual([
"Favorite match",
"Second favorite match",
"Best match",
"Second best match",
])
})
})
import { sortModelOptions } from "../../../../src/component/dialog-model"
describe("sortModelOptions", () => {
test("orders opencode models before other providers", () => {
+4 -78
View File
@@ -20,12 +20,10 @@ test("a prompt pulse restarts the neutral edge flash while the tab remains busy"
() => (
<box width={8} height={1} backgroundColor={background}>
<TabPulse
layer={{
active: true,
promptPulse: promptPulse(),
color: background,
flashColor: flash,
}}
active={true}
promptPulse={promptPulse()}
color={background}
flashColor={flash}
backgroundColor={background}
/>
</box>
@@ -58,78 +56,6 @@ test("a prompt pulse restarts the neutral edge flash while the tab remains busy"
}
})
test("edge layers animate independently", async () => {
const background = RGBA.fromHex("#101010")
const lower = RGBA.fromHex("#ff0000")
const upper = RGBA.fromHex("#0000ff")
const [edgeGlow, setEdgeGlow] = createSignal(false)
const app = await testRender(
() => (
<box width={8} height={1} backgroundColor={background}>
<TabPulse
edge="above"
layer={{ color: background, glow: true, glowColor: lower }}
edgeLayer={{ color: background, glow: edgeGlow(), glowColor: upper }}
backgroundColor={background}
/>
</box>
),
{ width: 8, height: 1 },
)
try {
await app.renderOnce()
const initial = app.captureSpans().lines[0]?.spans[0]
expect(initial?.text.startsWith("▄")).toBeTrue()
expect(initial?.fg.r ?? 0).toBeGreaterThan(initial?.fg.b ?? 0)
expect(initial?.bg.equals(background)).toBeTrue()
setEdgeGlow(true)
await Bun.sleep(80)
await app.renderOnce()
const updated = app.captureSpans().lines[0]?.spans[0]
expect(updated?.fg.r ?? 0).toBeGreaterThan(updated?.fg.b ?? 0)
expect(updated?.bg.b ?? 0).toBeGreaterThan(updated?.bg.r ?? 0)
} finally {
app.renderer.destroy()
}
})
test("an atomic active-to-complete layer update starts the completion pulse", async () => {
const background = RGBA.fromHex("#101010")
const completion = RGBA.fromHex("#f0f0f0")
const [state, setState] = createSignal({ active: true, complete: false })
const app = await testRender(
() => (
<box width={8} height={1} backgroundColor={background}>
<TabPulse
layer={{
...state(),
color: background,
completionColor: completion,
}}
backgroundColor={background}
/>
</box>
),
{ width: 8, height: 1 },
)
const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg
try {
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeTrue()
setState({ active: false, complete: true })
await Bun.sleep(80)
await app.renderOnce()
expect(firstBackground()?.equals(background)).toBeFalse()
} finally {
app.renderer.destroy()
}
})
test("completion pulse rises quickly and fades over the remaining duration", () => {
expect(completionPulseOpacity(0)).toBe(0)
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
+1 -1
View File
@@ -17,7 +17,7 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
})
@@ -1,70 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { Context } from "@opencode-ai/plugin/tui/context"
import { SidebarContext } from "../../src/feature-plugins/sidebar/context"
function context(options?: { cost?: number; tokens?: number }) {
const color = RGBA.fromInts(200, 200, 200)
return {
theme: { text: { default: color, subdued: color } },
data: {
session: {
get: () => ({ location: { directory: "/workspace" } }),
cost: () => options?.cost ?? 0,
message: {
list: () =>
options?.tokens
? [
{
id: "message",
type: "assistant",
model: { providerID: "provider", id: "model" },
tokens: {
input: options.tokens,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
]
: [],
},
},
location: {
model: { list: () => [] },
},
},
} as unknown as Context
}
test("sidebar omits context before usage is available", async () => {
const app = await testRender(() => <SidebarContext context={context()} sessionID="session" />, {
width: 42,
height: 8,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("Context")
expect(app.captureCharFrame()).not.toContain("Not measured")
} finally {
app.renderer.destroy()
}
})
test("sidebar shows available context usage", async () => {
const app = await testRender(() => <SidebarContext context={context({ tokens: 1234 })} sessionID="session" />, {
width: 42,
height: 8,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Context")
expect(app.captureCharFrame()).toContain("1,234 tokens")
} finally {
app.renderer.destroy()
}
})
@@ -3,10 +3,6 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../s
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
const prompts = new Set<(input: RunPrompt) => void>()
const closes = new Set<() => void>()
let ready!: () => void
const promptReady = new Promise<void>((resolve) => {
ready = resolve
})
const events = input.events ?? []
const commits = input.commits ?? []
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
@@ -18,7 +14,6 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
},
onPrompt(fn) {
prompts.add(fn)
ready()
return () => prompts.delete(fn)
},
onClose(fn) {
@@ -55,12 +50,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
events,
commits,
calls,
promptReady,
submit(text: string, mode?: RunPrompt["mode"]) {
if (prompts.size === 0) return false
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
for (const fn of [...prompts]) fn(prompt)
return true
},
}
}
+5 -20
View File
@@ -55,9 +55,6 @@ describe("run interactive runtime", () => {
const api = ui.api
const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>()
const catalogLoaded = defer<void>()
const defaultModelReloaded = defer<void>()
const modelShown = defer<void>()
const turnStarted = defer<void>()
const model = catalogModel({
id: "resolved",
providerID: "test",
@@ -72,17 +69,7 @@ describe("run interactive runtime", () => {
providers: [catalogProvider("test", "Test Provider")],
models: [model],
})
let defaultModelCalls = 0
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => {
defaultModelCalls++
if (defaultModelCalls === 2) defaultModelReloaded.resolve()
return selected.promise
})
const emit = api.event.bind(api)
api.event = (event) => {
emit(event)
if (event.type === "model") modelShown.resolve()
}
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise)
const task = runInteractiveDeferredMode(
{
@@ -123,7 +110,6 @@ describe("run interactive runtime", () => {
runPromptTurn: async (input) => {
turnAgent = input.agent
turnModel = input.model
turnStarted.resolve()
api.close()
},
queuePromptTurn: async () => {},
@@ -147,8 +133,8 @@ describe("run interactive runtime", () => {
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
data: model,
})
await defaultModelReloaded.promise
await modelShown.promise
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
while (!events.some((event) => event.type === "model")) await Bun.sleep(0)
expect(events).toContainEqual({
type: "model",
model: "Resolved Model · Test Provider",
@@ -156,9 +142,8 @@ describe("run interactive runtime", () => {
})
expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
lifecycle.onAgentSelect?.("review")
await ui.promptReady
expect(ui.submit("hello")).toBe(true)
await turnStarted.promise
ui.submit("hello")
while (!turnModel) await Bun.sleep(0)
expect(turnAgent).toBe("review")
expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
await task
+6 -51
View File
@@ -1,8 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { expect, test } from "bun:test"
import { discoverTuiPlugins, tuiPluginDirectories } from "../src/plugin/discovery"
import { localProjectDirectory } from "../src/util/config-directories"
import { discoverTuiPlugins } from "../src/plugin/discovery"
import { tmpdir } from "./fixture/fixture"
test("discovers project TUI plugin files in stable order", async () => {
@@ -16,57 +15,13 @@ test("discovers project TUI plugin files in stable order", async () => {
writeFile(path.join(directory, "nested", "ignored.ts"), "export default {}"),
])
expect(
await discoverTuiPlugins(await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))),
).toEqual([path.join(directory, "first.js"), path.join(directory, "second.tsx")])
expect(await discoverTuiPlugins(tmp.path)).toEqual([
path.join(directory, "first.js"),
path.join(directory, "second.tsx"),
])
})
test("returns no project TUI plugins when the directory is absent", async () => {
await using tmp = await tmpdir()
const roots = await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))
expect(await discoverTuiPlugins(roots)).toEqual([])
expect(roots).toContain(path.join(tmp.path, ".opencode", "plugins", "tui"))
})
test("discovers global and ancestor plugin roots in precedence order", async () => {
await using tmp = await tmpdir()
const cwd = path.join(tmp.path, "repo", "packages", "app")
const project = path.join(tmp.path, "repo")
const config = path.join(tmp.path, "config")
const directories = [
path.join(config, "plugins", "tui"),
path.join(tmp.path, "repo", ".opencode", "plugins", "tui"),
path.join(tmp.path, "repo", "packages", ".opencode", "plugins", "tui"),
]
const outside = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(path.join(project, ".git"), { recursive: true })
await Promise.all([...directories, outside].map((directory) => mkdir(directory, { recursive: true })))
await Promise.all(
directories.map((directory, index) => writeFile(path.join(directory, `${index}.ts`), "export default {}")),
)
await writeFile(path.join(outside, "outside.ts"), "export default {}")
const roots = await tuiPluginDirectories(cwd, config)
expect(await discoverTuiPlugins(roots)).toEqual(
directories.map((directory, index) => path.join(directory, `${index}.ts`)),
)
expect(roots).not.toContain(path.join(cwd, ".opencode", "plugins", "tui"))
expect(roots).not.toContain(outside)
})
test("uses an Hg root for a missing project plugin directory", async () => {
await using tmp = await tmpdir()
const project = path.join(tmp.path, "repo")
const cwd = path.join(project, "package")
await mkdir(path.join(project, ".hg"), { recursive: true })
await mkdir(cwd, { recursive: true })
expect(await tuiPluginDirectories(cwd, path.join(tmp.path, "config"))).toContain(
path.join(project, ".opencode", "plugins", "tui"),
)
})
test("propagates non-missing filesystem errors", async () => {
await expect(localProjectDirectory("\0")).rejects.toBeInstanceOf(Error)
await expect(discoverTuiPlugins(["\0"])).rejects.toBeInstanceOf(Error)
expect(await discoverTuiPlugins(tmp.path)).toEqual([])
})
+31 -43
View File
@@ -1,10 +1,11 @@
import { expect, mock, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
import path from "node:path"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { createEventStream, createFetch } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
function lifecycleSource(marker: string, id: string, version: string) {
@@ -35,16 +36,7 @@ async function bootApp(directory: string) {
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== "/api/fs/list") return
return json({
location: {
directory,
project: { id: "proj_test", directory, canonical: directory },
},
data: [],
})
}, events)
const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const cwd = process.cwd()
process.chdir(directory)
@@ -57,13 +49,11 @@ async function bootApp(directory: string) {
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(
Effect.provide(Global.layerWith({ config: path.join(directory, ".global") })),
Effect.provide(FileSystem.layerNoop({})),
),
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
return {
task,
renderer: setup,
async [Symbol.asyncDispose]() {
process.chdir(cwd)
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
@@ -73,34 +63,6 @@ async function bootApp(directory: string) {
}
}
test("discovers an ancestor TUI plugin directory created after startup", async () => {
await using tmp = await tmpdir()
const cwd = path.join(tmp.path, "repo", "packages", "app")
await mkdir(cwd, { recursive: true })
await mkdir(path.join(tmp.path, "repo", ".git"))
const ready = path.join(tmp.path, "ready.txt")
const marker = path.join(tmp.path, "marker.txt")
const initial = path.join(cwd, ".opencode", "plugins", "tui")
await mkdir(initial, { recursive: true })
await writeFile(path.join(initial, "ready.ts"), lifecycleSource(ready, "test.ready", "ready"))
await using app = await bootApp(cwd)
expect(await until(() => readFile(ready, "utf8"), (value) => value === "ready:setup\n")).toBe("ready:setup\n")
const directory = path.join(tmp.path, "repo", ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1"))
expect(
await until(
() => readFile(marker, "utf8"),
(value) => value === "v1:setup\n",
),
).toBe("v1:setup\n")
process.emit("SIGHUP")
await app.task
})
test("editing a discovered TUI plugin hot-reloads its fresh module", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
@@ -120,6 +82,32 @@ test("editing a discovered TUI plugin hot-reloads its fresh module", async () =>
await app.task
})
test("creating the TUI plugin directory after startup discovers its first plugin", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(path.dirname(directory), { recursive: true })
const marker = path.join(tmp.path, "marker.txt")
const placeholders = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
await using app = await bootApp(tmp.path)
const frame = await until(
async () => {
await app.renderer.renderOnce()
return app.renderer.captureCharFrame()
},
(value) => placeholders.some((text) => value?.includes(text)),
)
expect(placeholders.some((text) => frame?.includes(text))).toBe(true)
await mkdir(directory)
await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1"))
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value === "v1:setup\n")).toBe("v1:setup\n")
process.emit("SIGHUP")
await app.task
})
test("a plugin whose slot render throws does not take down the TUI", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
+2 -3
View File
@@ -12,8 +12,7 @@ import {
setCustomThemes,
upsertTheme,
} from "../src/theme"
import { discoverThemes } from "../src/theme/discovery"
import { configDirectories } from "../src/util/config-directories"
import { discoverThemes, themeDirectories } from "../src/theme/discovery"
import { terminalMode } from "../src/theme/system"
import { tmpdir } from "./fixture/fixture"
@@ -188,7 +187,7 @@ test("theme directories include global config before project directories", async
await writeFile(path.join(global, "themes", "global.json"), JSON.stringify({ source: "global" }))
await writeFile(path.join(project, ".opencode", "themes", "project.json"), JSON.stringify({ source: "project" }))
await expect(discoverThemes(configDirectories(global, project))).resolves.toEqual({
await expect(discoverThemes(themeDirectories(global, project))).resolves.toEqual({
global: { source: "global" },
project: { source: "project" },
})
-8
View File
@@ -1,8 +0,0 @@
import { expect, test } from "bun:test"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout"
test("vertical tabs match the session sidebar and preserve compact content width", () => {
expect(SESSION_SIDEBAR_WIDTH).toBe(42)
expect(sessionTabsFitVertically(86)).toBe(true)
expect(sessionTabsFitVertically(85)).toBe(false)
})