Compare commits

...

8 Commits

Author SHA1 Message Date
Kit Langton 5d729521d3 refactor(tui): remove redundant code (#40081) 2026-08-01 14:22:48 -04:00
Kit Langton c391983ae0 fix(tui): discover plugins across config roots (#39988) 2026-08-01 14:06:58 -04:00
Kit Langton f158abd694 fix(tui): make vertical tabs responsive (#40080) 2026-08-01 14:06:13 -04:00
Kit Langton 0e3da4b4f1 feat(tui): add vertical session tabs (#40072) 2026-08-01 13:10:08 -04:00
Aiden Cline 003b22edda fix: preserve provider error status (#39976) 2026-08-01 11:06:00 -05:00
Kit Langton d5f6c088f0 fix(tui): prevent sidebar scrollbar flash (#40056) 2026-08-01 15:12:37 +00:00
Kit Langton e872bd3c8f feat(tui): prioritize favorite model search results (#40049) 2026-08-01 14:54:46 +00:00
opencode-agent[bot] 85ea15e56d test(tui): wait for mini prompt readiness (#39980)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-01 01:05:18 +00:00
47 changed files with 1317 additions and 324 deletions
+7
View File
@@ -4,6 +4,13 @@
- 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,6 +8,7 @@
"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",
+8 -9
View File
@@ -16,7 +16,6 @@ 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,
@@ -33,7 +32,6 @@ 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,
@@ -44,11 +42,15 @@ 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)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.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))
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
@@ -100,6 +102,8 @@ 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)
@@ -142,12 +146,7 @@ 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.Literal("context-overflow")
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
+6 -3
View File
@@ -85,14 +85,17 @@ describe("RequestExecutor", () => {
),
)
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
it.effect("classifies generic HTTP 413 payload errors", () =>
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" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "payload-too-large",
http: { response: { status: 413 } },
})
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
+22 -4
View File
@@ -6,7 +6,6 @@ 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.",
@@ -19,6 +18,24 @@ 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.",
@@ -59,9 +76,10 @@ 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,7 +45,11 @@ 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: OPENCODE_CHANNEL },
app: {
name: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: process.env.OPENCODE_TUI_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 error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
}
}
| {
@@ -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 error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
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 error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
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 error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
}
}
| {
@@ -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 error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
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 }
export type SessionStructuredError = { type: string; message: string; status?: number }
export type SessionMessageCompactionRunning = {
type: "compaction"
+16 -10
View File
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof LLMError) {
switch (cause.reason._tag) {
case "RateLimit":
return { type: "provider.rate-limit", message: cause.reason.message }
return providerError("provider.rate-limit", cause.reason)
case "Authentication":
return { type: "provider.auth", message: cause.reason.message }
return providerError("provider.auth", cause.reason)
case "QuotaExceeded":
return { type: "provider.quota", message: cause.reason.message }
return providerError("provider.quota", cause.reason)
case "ContentPolicy":
return { type: "provider.content-filter", message: cause.reason.message }
return providerError("provider.content-filter", cause.reason)
case "Transport":
return { type: "provider.transport", message: cause.reason.message }
return providerError("provider.transport", cause.reason)
case "ProviderInternal":
return { type: "provider.internal", message: cause.reason.message }
return providerError("provider.internal", cause.reason)
case "InvalidProviderOutput":
return { type: "provider.invalid-output", message: cause.reason.message }
return providerError("provider.invalid-output", cause.reason)
case "InvalidRequest":
return { type: "provider.invalid-request", message: cause.reason.message }
return providerError("provider.invalid-request", cause.reason)
case "NoRoute":
return { type: "provider.no-route", message: cause.reason.message }
return providerError("provider.no-route", cause.reason)
case "UnknownProvider":
return { type: "provider.unknown", message: cause.reason.message }
return providerError("provider.unknown", cause.reason)
default: {
const exhaustive: never = cause.reason
return exhaustive
@@ -58,3 +58,9 @@ 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,6 +14,9 @@ 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"
@@ -71,6 +74,23 @@ 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,9 +1,11 @@
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" })
+20 -11
View File
@@ -68,6 +68,7 @@ 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"
@@ -83,6 +84,7 @@ 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"
@@ -208,9 +210,13 @@ 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 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 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 handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
const managed = input.server.service
@@ -378,7 +384,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
@@ -519,6 +528,9 @@ 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
@@ -1214,16 +1226,13 @@ 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={
sessionTabs.enabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
>
<Show when={tabsVisible() && !tabsVertical()}>
<SessionTabs />
</Show>
<Switch>
@@ -100,6 +100,15 @@ 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",
+10 -4
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,
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
? "(Favorite)"
: undefined,
favorite,
description: favorite ? "(Favorite)" : undefined,
category: connected() ? (provider?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
onSelect() {
@@ -96,7 +96,9 @@ export function DialogModel(props: { providerID?: string }) {
)
if (needle) {
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
return prioritizeFavorites(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
)
}
return [...favoriteOptions, ...recentOptions, ...modelOptions]
@@ -160,6 +162,10 @@ 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,4 +1,3 @@
import path from "path"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
@@ -18,6 +17,7 @@ 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 = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const name = projectName(project)
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 = project.name ?? path.basename(project.canonical)
const title = projectName(project) ?? project.canonical
const footer = abbreviateHome(project.canonical, paths.home)
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
@@ -20,6 +20,7 @@ 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()
@@ -123,8 +124,7 @@ export function DialogSessionList() {
const current = data.location.info()
if (!current) return ""
const project = data.project.get(current.project.id)
if (!project) return ""
return project.name || path.basename(project.canonical)
return projectName(project) ?? ""
})
const options = createMemo(() => {
@@ -141,9 +141,7 @@ 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(project?.name || path.basename(project?.canonical ?? directory), 20)
: undefined
const footer = allProjects() ? Locale.truncate(projectName(project, directory) ?? "", 20) : undefined
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
@@ -4,8 +4,6 @@ import { useDialog } from "../ui/dialog"
import { useData } from "../context/data"
import { For, Match, Switch, Show, createMemo } from "solid-js"
export type DialogStatusProps = {}
export function DialogStatus() {
const data = useData()
const theme = useTheme("elevated")
@@ -23,6 +23,7 @@ import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client"
import { stringWidth } from "../../util/string-width"
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
import { moveSelection, revealSelectionOffset } from "../../ui/select-controller"
export type AutocompleteRef = {
onInput: (value: string) => void
@@ -501,22 +502,19 @@ export function Autocomplete(props: {
function move(direction: -1 | 1) {
if (!store.visible) return
if (!options().length) return
let next = store.selected + direction
if (next < 0) next = options().length - 1
if (next >= options().length) next = 0
moveTo(next)
moveTo(moveSelection(store.selected, { count: options().length, delta: direction, policy: "wrap" }))
}
function moveTo(next: number) {
setStore("selected", next)
if (!scroll) return
const viewportHeight = Math.min(height(), options().length)
const scrollBottom = scroll.scrollTop + viewportHeight
if (next < scroll.scrollTop) {
scroll.scrollBy(next - scroll.scrollTop)
} else if (next + 1 > scrollBottom) {
scroll.scrollBy(next + 1 - scrollBottom)
}
const offset = revealSelectionOffset(scroll.scrollTop, {
count: options().length,
limit: Math.min(height(), options().length),
selected: next,
})
if (offset === scroll.scrollTop) return
scroll.scrollBy(offset - scroll.scrollTop)
}
function select() {
@@ -8,7 +8,6 @@ import {
type KeyEvent,
} from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import { registerOpencodeSpinner } from "../register-spinner"
import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
@@ -55,8 +54,6 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
registerOpencodeSpinner()
export type PromptProps = {
sessionID?: string
visible?: boolean
+336 -5
View File
@@ -1,8 +1,9 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { RGBA, ScrollBoxRenderable, 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,
@@ -19,6 +20,8 @@ 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
@@ -39,8 +42,338 @@ 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 } = {}) {
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 separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
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 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()}
active={runs()}
outerActive={previousRuns()}
promptPulse={status().promptPulse}
outerPromptPulse={previousStatus().promptPulse}
complete={complete() && !status().attention}
outerComplete={previousStatus().complete && !previousStatus().attention}
glow={glows()}
outerGlow={previousGlows()}
breathe={status().attention}
outerBreathe={previousStatus().attention}
color={separatorLowerPulseColor()}
outerColor={separatorUpperPulseColor()}
glowColor={separatorLowerColor()}
outerGlowColor={separatorUpperColor()}
glowTail={8}
outerGlowTail={5}
completionColor={separatorLowerColor()}
outerCompletionColor={separatorUpperColor()}
backgroundColor={theme.background.default}
/>
<Show when={index() === items().length - 1}>
<TabPulse
top={2}
edge="below"
enabled={animations()}
active={runs()}
outerActive={false}
promptPulse={status().promptPulse}
outerPromptPulse={0}
complete={complete() && !status().attention}
outerComplete={false}
glow={glows()}
outerGlow={false}
breathe={status().attention}
outerBreathe={false}
color={tint(theme.background.default, theme.text.default, 0.04)}
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
glowColor={tint(theme.background.default, glowHue(), 0.1)}
outerGlowColor={theme.background.default}
glowTail={8}
outerGlowTail={5}
completionColor={tint(theme.background.default, glowHue(), 0.1)}
outerCompletionColor={theme.background.default}
backgroundColor={theme.background.default}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
<TabPulse
enabled={animations()}
active={runs()}
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()}
active={runs()}
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 } = {}) {
const tabs = props.controller ?? useSessionTabs()
const dimensions = useTerminalDimensions()
const theme = useTheme()
@@ -269,9 +602,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
const characterColor = (index: number) => {
const base = foreground()
const color = glows()
? tint(base, glowColor(), 0.12 * unreadGlowIntensity(1 + numberWidth() + index, width()))
: base
const color = glows() ? glowTextColor(base, glowColor(), 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)))
+397 -126
View File
@@ -2,16 +2,28 @@ import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderC
import { extend } from "@opentui/solid"
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
edge?: "above" | "below"
enabled?: boolean
active?: boolean
outerActive?: boolean
promptPulse?: number
outerPromptPulse?: number
complete?: boolean
outerComplete?: boolean
glow?: boolean
outerGlow?: boolean
breathe?: boolean
outerBreathe?: boolean
color?: RGBA
outerColor?: RGBA
glowColor?: RGBA
outerGlowColor?: RGBA
glowTail?: number
outerGlowTail?: number
flashColor?: RGBA
outerFlashColor?: RGBA
completionColor?: RGBA
outerCompletionColor?: RGBA
backgroundColor?: RGBA
/** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */
onLevel?: (level: number) => void
@@ -20,6 +32,7 @@ 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
@@ -58,9 +71,10 @@ 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)
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))
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 function blendTabPulseColor(
output: RGBA,
@@ -131,45 +145,236 @@ class Envelope {
// Hoisted so the per-frame liveness check allocates no closure.
const envelopeActive = (envelope: Envelope) => envelope.active
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _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
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
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.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
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 TabPulseRenderable extends Renderable {
private _enabled: boolean
private inner: PulseState
private outer: PulseState
private _color: RGBA
private _outerColor: RGBA
private _glowColor: RGBA
private _outerGlowColor: RGBA
private _glowTail: number
private _outerGlowTail: number
private _edge: "above" | "below" | undefined
private _flashColor: RGBA
private _outerFlashColor: RGBA
private _completionColor: RGBA
private _outerCompletionColor: RGBA
private _backgroundColor: RGBA
private renderColor = RGBA.fromInts(0, 0, 0)
private outerRenderColor = 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 active = options.active ?? false
super(ctx, { ...options, height: 1, live: enabled && active })
const glow = options.glow ?? false
const breathe = options.breathe ?? false
const edge = options.edge
const outerActive = options.outerActive ?? active
const outerGlow = options.outerGlow ?? glow
const outerBreathe = options.outerBreathe ?? breathe
super(ctx, {
...options,
height: 1,
live:
enabled &&
(active || (glow && breathe) || (edge !== undefined && (outerActive || (outerGlow && outerBreathe)))),
})
this._enabled = enabled
this._active = active
this._promptPulse = options.promptPulse ?? 0
this._complete = options.complete ?? false
this._glow = options.glow ?? false
this._breathe = options.breathe ?? false
this.inner = new PulseState({
enabled,
active,
promptPulse: options.promptPulse ?? 0,
complete: options.complete ?? false,
glow,
breathe,
})
this.outer = new PulseState({
enabled: enabled && edge !== undefined,
active: outerActive,
promptPulse: options.outerPromptPulse ?? options.promptPulse ?? 0,
complete: options.outerComplete ?? options.complete ?? false,
glow: outerGlow,
breathe: outerBreathe,
})
this._color = options.color ?? RGBA.defaultForeground()
this._outerColor = options.outerColor ?? this._color
this._glowColor = options.glowColor ?? this._color
this._outerGlowColor = options.outerGlowColor ?? this._glowColor
this._glowTail = options.glowTail ?? GLOW_TAIL
this._outerGlowTail = options.outerGlowTail ?? this._glowTail
this._edge = edge
this._flashColor = options.flashColor ?? this._color
this._outerFlashColor = options.outerFlashColor ?? options.flashColor ?? this._outerColor
this._completionColor = options.completionColor ?? this._color
this._outerCompletionColor = options.outerCompletionColor ?? options.completionColor ?? this._outerColor
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
this._onLevel = options.onLevel
}
@@ -179,103 +384,64 @@ 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)
}
private get breathing() {
return this._enabled && this._glow && this._breathe
}
/** Resting glow is 1; ignition overshoots on arrival, breathing swells while pending, glowOff decays after. */
private glowLevel() {
if (!this._glow) return this.glowOff.level()
const base = this.ignition.active ? this.ignition.level() : 1
if (!this.breathing) return base
return (
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
)
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
if (!value) {
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
}
this.inner.setEnabled(value)
this.outer.setEnabled(value && this._edge !== undefined)
this.live = this.inner.live || this.outer.live
this.requestRender()
}
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
}
// The same neutral edge flash marks both the start and the finish of a run.
this.edgeFlash.start()
this.live = true
this.requestRender()
if (this.inner.setActive(value)) this.changed()
}
set outerActive(value: boolean) {
if (this.outer.setActive(value)) 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()
if (this.inner.setPromptPulse(value)) this.changed()
}
set outerPromptPulse(value: number) {
if (this.outer.setPromptPulse(value)) this.changed()
}
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()
if (this.inner.setComplete(value)) this.changed()
}
set outerComplete(value: boolean) {
if (this.outer.setComplete(value)) this.changed()
}
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()
if (this.inner.setGlow(value)) this.changed()
}
set outerGlow(value: boolean) {
if (this.outer.setGlow(value)) this.changed()
}
set breathe(value: boolean) {
if (value === this._breathe) return
this._breathe = value
this.breatheClock = 0
if (this.breathing) this.live = true
if (this.inner.setBreathe(value)) this.changed()
}
set outerBreathe(value: boolean) {
if (this.outer.setBreathe(value)) this.changed()
}
private changed() {
this.live = this.inner.live || this.outer.live
this.requestRender()
}
@@ -291,18 +457,62 @@ class TabPulseRenderable extends Renderable {
this.requestRender()
}
set outerColor(value: RGBA) {
if (value.equals(this._outerColor)) return
this._outerColor = value
this.requestRender()
}
set outerGlowColor(value: RGBA) {
if (value.equals(this._outerGlowColor)) return
this._outerGlowColor = value
this.requestRender()
}
set glowTail(value: number) {
if (value === this._glowTail) return
this._glowTail = value
this.requestRender()
}
set outerGlowTail(value: number) {
if (value === this._outerGlowTail) return
this._outerGlowTail = value
this.requestRender()
}
set edge(value: "above" | "below" | undefined) {
if (value === this._edge) return
this._edge = value
this.outer.setEnabled(this._enabled && value !== undefined)
this.live = this.inner.live || this.outer.live
this.requestRender()
}
set flashColor(value: RGBA) {
if (value.equals(this._flashColor)) return
this._flashColor = value
this.requestRender()
}
set outerFlashColor(value: RGBA) {
if (value.equals(this._outerFlashColor)) return
this._outerFlashColor = value
this.requestRender()
}
set completionColor(value: RGBA) {
if (value.equals(this._completionColor)) return
this._completionColor = value
this.requestRender()
}
set outerCompletionColor(value: RGBA) {
if (value.equals(this._outerCompletionColor)) return
this._outerCompletionColor = value
this.requestRender()
}
set backgroundColor(value: RGBA) {
if (value.equals(this._backgroundColor)) return
this._backgroundColor = value
@@ -311,42 +521,45 @@ class TabPulseRenderable extends Renderable {
protected override onUpdate(deltaTime: number): void {
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) {
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)
this.inner.advance(deltaTime)
this.outer.advance(deltaTime)
this.live = this.inner.live || this.outer.live
}
protected override renderSelf(buffer: OptimizedBuffer): void {
if (!this.visible || this.isDestroyed || this.width <= 0) return
const running = !this._enabled ? 0 : this._active ? 1 : this.runFade.level()
const completion = this.completionPulse.level() * COMPLETION_OPACITY
// The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results.
const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY
const glowLevel = this.glowLevel()
if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) {
const running = this.inner.running
const completion = this.inner.completion
const flash = this.inner.flash
const glowLevel = this.inner.glowLevel
const outerRunning = this.outer.running
const outerCompletion = this.outer.completion
const outerFlash = this.outer.flash
const outerGlowLevel = this.outer.glowLevel
if (
glowLevel === 0 &&
running === 0 &&
completion === 0 &&
flash === 0 &&
outerGlowLevel === 0 &&
outerRunning === 0 &&
outerCompletion === 0 &&
outerFlash === 0
) {
this.emitLevel(0)
return
}
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
const start = -RUN_HEAD
const end = this.width - 1 + RUN_TAIL
const front = start + coast(progress) * (end - start)
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
this.emitLevel(
running === 0
? 0
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
running,
)
const [front, secondFront] = this.inner.fronts(this.width)
const [outerFront, outerSecondFront] = this.outer.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._glowTail, Math.max(1, this.width - 2))
const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2))
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 =
@@ -358,6 +571,15 @@ class TabPulseRenderable extends Renderable {
) *
0.14 *
running
const outerSweep =
outerRunning === 0
? 0
: Math.max(
intensityAt(index, outerFront, RUN_HEAD, RUN_TAIL),
intensityAt(index, outerSecondFront, RUN_HEAD, RUN_TAIL),
) *
0.14 *
outerRunning
blendTabPulseColor(
this.renderColor,
this._backgroundColor,
@@ -365,12 +587,34 @@ class TabPulseRenderable extends Renderable {
this._color,
this._flashColor,
this._completionColor,
glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
sweep,
flash,
completion,
)
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
if (!this._edge) {
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
continue
}
blendTabPulseColor(
this.outerRenderColor,
this._backgroundColor,
this._outerGlowColor,
this._outerColor,
this._outerFlashColor,
this._outerCompletionColor,
outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel,
outerSweep,
outerFlash,
outerCompletion,
)
buffer.setCell(
this.screenX + index,
this.screenY,
this._edge === "above" ? "▄" : "▀",
this.renderColor,
this.outerRenderColor,
)
}
}
}
@@ -384,34 +628,61 @@ declare module "@opentui/solid" {
extend({ tab_pulse: TabPulseRenderable })
export function TabPulse(props: {
top?: number
width?: number
edge?: "above" | "below"
enabled?: boolean
active: boolean
outerActive?: boolean
promptPulse?: number
outerPromptPulse?: number
complete?: boolean
outerComplete?: boolean
glow?: boolean
outerGlow?: boolean
breathe?: boolean
outerBreathe?: boolean
color: RGBA
outerColor?: RGBA
glowColor?: RGBA
outerGlowColor?: RGBA
glowTail?: number
outerGlowTail?: number
flashColor?: RGBA
outerFlashColor?: RGBA
completionColor?: RGBA
outerCompletionColor?: RGBA
backgroundColor: RGBA
onLevel?: (level: number) => void
}) {
return (
<tab_pulse
position="absolute"
top={props.top}
edge={props.edge}
zIndex={0}
width="100%"
width={props.width ?? "100%"}
enabled={props.enabled ?? true}
active={props.active}
outerActive={props.outerActive ?? props.active}
promptPulse={props.promptPulse ?? 0}
outerPromptPulse={props.outerPromptPulse ?? props.promptPulse ?? 0}
complete={props.complete ?? false}
outerComplete={props.outerComplete ?? props.complete ?? false}
glow={props.glow ?? false}
outerGlow={props.outerGlow ?? props.glow ?? false}
breathe={props.breathe ?? false}
outerBreathe={props.outerBreathe ?? props.breathe ?? false}
color={props.color}
outerColor={props.outerColor ?? props.color}
glowColor={props.glowColor ?? props.color}
outerGlowColor={props.outerGlowColor ?? props.glowColor ?? props.color}
glowTail={props.glowTail ?? GLOW_TAIL}
outerGlowTail={props.outerGlowTail ?? props.glowTail ?? GLOW_TAIL}
flashColor={props.flashColor ?? props.color}
outerFlashColor={props.outerFlashColor ?? props.flashColor ?? props.outerColor ?? props.color}
completionColor={props.completionColor ?? props.color}
outerCompletionColor={props.outerCompletionColor ?? props.completionColor ?? props.outerColor ?? props.color}
backgroundColor={props.backgroundColor}
onLevel={props.onLevel}
/>
+3
View File
@@ -132,6 +132,9 @@ 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
View File
@@ -67,8 +67,6 @@ function initialRoute(value: unknown): Route | undefined {
}
}
export type RouteContext = ReturnType<typeof useRoute>
export function useRouteData<T extends Route["type"]>(type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: typeof type }>
+3 -2
View File
@@ -22,7 +22,7 @@ import {
type ThemeDocumentSource,
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes, themeDirectories } from "../theme/discovery"
import { discoverThemes } 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,6 +30,7 @@ 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 }
@@ -70,7 +71,7 @@ export type ThemeSource = Readonly<{
const themeSource: ThemeSource = {
async discover() {
return discoverThemes(themeDirectories(Global.Path.config, process.cwd()))
return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)
@@ -10,24 +10,18 @@ const money = new Intl.NumberFormat("en-US", {
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const dimensions = useTerminalDimensions()
const activeSubagents = createMemo(() => {
const subagents = createMemo(() => {
if (!props.sessionID) return 0
return props.context.data.session
const count = props.context.data.session
.family(props.sessionID)
.filter((id) => id !== props.sessionID && props.context.data.session.status(id) === "running").length
})
const runningShells = createMemo(() => {
if (!props.sessionID) return 0
return props.context.data.shell
.list(props.context.location)
.filter((shell) => shell.metadata.sessionID === props.sessionID).length
})
const subagents = createMemo(() => {
const count = activeSubagents()
return count ? `${count} subagent${count === 1 ? "" : "s"}` : undefined
})
const shells = createMemo(() => {
const count = runningShells()
if (!props.sessionID) return 0
const count = props.context.data.shell
.list(props.context.location)
.filter((shell) => shell.metadata.sessionID === props.sessionID).length
return count ? `${count} shell${count === 1 ? "" : "s"}` : undefined
})
const status = createMemo(() => {
@@ -40,8 +34,10 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
session.revert?.messageID,
)
const cost = props.context.data.session.cost(props.sessionID)
return [usage ? formatContextUsage(usage.tokens, usage.percent) : undefined, cost > 0 ? money.format(cost) : undefined]
.filter((item): item is string => Boolean(item))
return [
usage ? formatContextUsage(usage.tokens, usage.percent) : undefined,
cost > 0 ? money.format(cost) : undefined,
].filter((item): item is string => Boolean(item))
})
const live = createMemo(() => Boolean(subagents() || shells()))
const shortcut = (id: string) => props.context.keymap.shortcuts(id)[0]
@@ -7,7 +7,7 @@ const money = new Intl.NumberFormat("en-US", {
currency: "USD",
})
function View(props: { context: Plugin.Context; sessionID: string }) {
export function SidebarContext(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,28 +18,32 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
)
return (
<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>
<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>
)
}
export default Plugin.define({
id: "internal:sidebar-context",
setup(context) {
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
},
})
+1 -4
View File
@@ -90,10 +90,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
draining = (async () => {
try {
while (!state.closed && state.queue.length > 0) {
const prompt = state.queue.shift()
if (!prompt) {
continue
}
const prompt = state.queue.shift()!
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
if (!input.onNewSession) {
+9 -7
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, tuiPluginDirectory } from "./discovery"
import { discoverTuiPlugins, freshSpecifier, localSource } 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 }>) {
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
const host = usePluginHost()
const config = useConfig()
const lifecycle = useTuiLifecycle()
@@ -171,10 +171,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
void enqueue(reconcile).catch(() => undefined)
}, 100)
})
onCleanup(() => {
const stopWatching = () => {
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
@@ -186,8 +187,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
// every watch event; remember them until the configuration changes.
const npmFailures = new Map<string, string>()
const reconcile = async () => {
const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])]
watcher.add(tuiPluginDirectory(host.paths.cwd))
await Promise.all(props.directories.map(watcher.wait))
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
@@ -210,7 +211,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
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) watcher.add(fileURLToPath(local))
if (local) await 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
@@ -363,6 +364,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
let disposing: Promise<void> | undefined
const dispose = () => {
if (disposing) return disposing
stopWatching()
disposing = loading
.catch(() => undefined)
.then(() =>
+37 -13
View File
@@ -1,23 +1,47 @@
import { readdir } from "node:fs/promises"
import { readdir, stat } 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 function tuiPluginDirectory(cwd: string) {
return path.join(cwd, ".opencode", "plugins", "tui")
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 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 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 function localSource(spec: string, directory: string) {
+51 -17
View File
@@ -10,21 +10,30 @@ import { lstat, realpath, stat } from "fs/promises"
// 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(). Failed or vanished watches are
// forgotten so a later add() can re-arm once the path exists.
// 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.
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>>()
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) => {
stat(target)
const arm = (target: string, retry: boolean) => {
const active = arming.get(target)
if (active) return active
const result = stat(target)
.then((info) => {
if (disposed) return
const appeared = missing.delete(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)
@@ -32,44 +41,69 @@ 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)
onChange()
notify()
return
}
// A null filename (platform-dependent) always schedules.
const accept = watched.get(dir)
if (filename && accept && !accept.has(filename.toString())) return
onChange()
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()
})
// 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(() => undefined)
.catch((error: unknown) => {
if (!disposed && retry && isMissing(error)) missing.add(target)
})
.finally(() => arming.delete(target))
arming.set(target, result)
return result
}
const add = (target: string) => {
arm(target)
const add = async (target: string, retry: boolean) => {
await arm(target, retry)
// A symlinked source receives edits at its resolved target.
lstat(target)
await lstat(target)
.then((info) => {
if (!info.isSymbolicLink()) return
return realpath(target).then(arm)
return realpath(target).then((target) => arm(target, retry))
})
.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,
}
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"
}
+10 -2
View File
@@ -64,6 +64,7 @@ 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"
@@ -199,14 +200,21 @@ export function Session() {
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
const wide = createMemo(() => dimensions().width > 120)
const availableWidth = createMemo(
() =>
dimensions().width -
(config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
? SESSION_SIDEBAR_WIDTH
: 0),
)
const wide = createMemo(() => availableWidth() > 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 - (sidebarVisible() ? 42 : 0) - 4)
const contentWidth = createMemo(() => availableWidth() - (sidebarVisible() ? 42 : 0) - 4)
const models = createMemo(() => data.location.model.list(location()) ?? [])
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
+8 -1
View File
@@ -6,6 +6,7 @@ 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()
@@ -18,7 +19,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<Show when={session()}>
<box
backgroundColor={theme.background.default}
width={42}
width={SESSION_SIDEBAR_WIDTH}
height="100%"
paddingTop={1}
paddingBottom={1}
@@ -27,9 +28,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
position={props.overlay ? "absolute" : "relative"}
>
<scrollbox
ref={(scroll) =>
queueMicrotask(() => {
if (!scroll.isDestroyed) scroll.verticalScrollBar.resetVisibilityControl()
})
}
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
visible: false,
trackOptions: {
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
-9
View File
@@ -1,15 +1,6 @@
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) {
+1 -12
View File
@@ -1,7 +1,7 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { useDialog } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
@@ -112,14 +112,3 @@ export function DialogPrompt(props: DialogPromptProps) {
</box>
)
}
DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => {
return new Promise<string | null>((resolve) => {
dialog.replace(
() => (
<DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
),
() => resolve(null),
)
})
}
+6
View File
@@ -0,0 +1,6 @@
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
}
@@ -0,0 +1,45 @@
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
@@ -0,0 +1,7 @@
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,5 +1,23 @@
import { describe, expect, test } from "bun:test"
import { sortModelOptions } from "../../../../src/component/dialog-model"
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",
])
})
})
describe("sortModelOptions", () => {
test("orders opencode models before other providers", () => {
+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 } })).toEqual({ tabs: { enabled: true } })
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
})
@@ -0,0 +1,70 @@
/** @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,6 +3,10 @@ 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 }> = []
@@ -14,6 +18,7 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
},
onPrompt(fn) {
prompts.add(fn)
ready()
return () => prompts.delete(fn)
},
onClose(fn) {
@@ -50,9 +55,12 @@ 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
},
}
}
+20 -5
View File
@@ -55,6 +55,9 @@ 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",
@@ -69,7 +72,17 @@ describe("run interactive runtime", () => {
providers: [catalogProvider("test", "Test Provider")],
models: [model],
})
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise)
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 task = runInteractiveDeferredMode(
{
@@ -110,6 +123,7 @@ describe("run interactive runtime", () => {
runPromptTurn: async (input) => {
turnAgent = input.agent
turnModel = input.model
turnStarted.resolve()
api.close()
},
queuePromptTurn: async () => {},
@@ -133,8 +147,8 @@ describe("run interactive runtime", () => {
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
data: model,
})
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
while (!events.some((event) => event.type === "model")) await Bun.sleep(0)
await defaultModelReloaded.promise
await modelShown.promise
expect(events).toContainEqual({
type: "model",
model: "Resolved Model · Test Provider",
@@ -142,8 +156,9 @@ describe("run interactive runtime", () => {
})
expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
lifecycle.onAgentSelect?.("review")
ui.submit("hello")
while (!turnModel) await Bun.sleep(0)
await ui.promptReady
expect(ui.submit("hello")).toBe(true)
await turnStarted.promise
expect(turnAgent).toBe("review")
expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
await task
+51 -6
View File
@@ -1,7 +1,8 @@
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { expect, test } from "bun:test"
import { discoverTuiPlugins } from "../src/plugin/discovery"
import { discoverTuiPlugins, tuiPluginDirectories } from "../src/plugin/discovery"
import { localProjectDirectory } from "../src/util/config-directories"
import { tmpdir } from "./fixture/fixture"
test("discovers project TUI plugin files in stable order", async () => {
@@ -15,13 +16,57 @@ test("discovers project TUI plugin files in stable order", async () => {
writeFile(path.join(directory, "nested", "ignored.ts"), "export default {}"),
])
expect(await discoverTuiPlugins(tmp.path)).toEqual([
path.join(directory, "first.js"),
path.join(directory, "second.tsx"),
])
expect(
await discoverTuiPlugins(await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))),
).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()
expect(await discoverTuiPlugins(tmp.path)).toEqual([])
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)
})
+43 -4
View File
@@ -1,11 +1,10 @@
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 } from "./fixture/tui-client"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
function lifecycleSource(marker: string, id: string, version: string) {
@@ -36,7 +35,16 @@ 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(undefined, events)
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 server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const cwd = process.cwd()
process.chdir(directory)
@@ -49,7 +57,10 @@ async function bootApp(directory: string) {
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
}).pipe(
Effect.provide(Global.layerWith({ config: path.join(directory, ".global") })),
Effect.provide(FileSystem.layerNoop({})),
),
)
return {
task,
@@ -62,6 +73,34 @@ 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")
+3 -2
View File
@@ -12,7 +12,8 @@ import {
setCustomThemes,
upsertTheme,
} from "../src/theme"
import { discoverThemes, themeDirectories } from "../src/theme/discovery"
import { discoverThemes } from "../src/theme/discovery"
import { configDirectories } from "../src/util/config-directories"
import { terminalMode } from "../src/theme/system"
import { tmpdir } from "./fixture/fixture"
@@ -187,7 +188,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(themeDirectories(global, project))).resolves.toEqual({
await expect(discoverThemes(configDirectories(global, project))).resolves.toEqual({
global: { source: "global" },
project: { source: "project" },
})
+8
View File
@@ -0,0 +1,8 @@
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)
})