Compare commits

...

11 Commits

Author SHA1 Message Date
Brendan Allan 65c96f914b fix(app): bind provider query client 2026-07-29 14:25:53 +08:00
Brendan Allan d0f1768b74 refactor(app): extract provider connection controller 2026-07-29 14:25:53 +08:00
Kit Langton b47cfbee7c fix(tui): reduce tab pulse allocations (#39433) 2026-07-28 22:43:46 -04:00
Kit Langton 5504245f7b feat(tui): add session tab playground (#39432) 2026-07-28 22:36:53 -04:00
Kit Langton f64b50d71b feat(tui): add unread tab glow (#39428) 2026-07-28 22:34:33 -04:00
Kit Langton a7b2ea94e5 fix(tui): always show session tab (#39429) 2026-07-28 22:33:34 -04:00
Kit Langton 7b775c2582 fix(cli): embed native watcher binding 2026-07-28 22:32:08 -04:00
Dax 12a931a220 feat(tui): filter subagents by activity 2026-07-28 22:13:46 -04:00
Dax Raad 90100c1365 docs: clarify side-by-side V1 and V2 installs 2026-07-28 22:02:19 -04:00
Dax Raad 139c9febe4 refactor(tui): group tab settings 2026-07-28 21:23:53 -04:00
Dax Raad 06290907a9 feat(tui): restore plugin manager dialog 2026-07-28 21:19:17 -04:00
39 changed files with 1050 additions and 1799 deletions
@@ -1,4 +1,3 @@
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -13,21 +12,9 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast"
import {
type Accessor,
type Component,
createEffect,
createMemo,
createResource,
createUniqueId,
For,
Match,
onCleanup,
onMount,
Show,
Switch,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useQueryClient } from "@tanstack/solid-query"
import { useParams } from "@solidjs/router"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
@@ -37,10 +24,10 @@ import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { pathKey } from "@/utils/path-key"
import { createProviderConnectionController } from "./provider-connection-controller"
const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const reset = () => setStore("selected", undefined)
@@ -384,118 +371,91 @@ function ProviderConnection(props: {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const queryClient = useQueryClient()
const params = useParams()
const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(() => props.directory?.())
const directory = () => props.directory?.() ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
onCleanup(() => {
alive.value = false
if (timer.current === undefined) return
clearTimeout(timer.current)
timer.current = undefined
})
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const fallback = createMemo<ConnectMethod[]>(() => [
{
type: "key" as const,
label: language.t("provider.connect.method.apiKey"),
const controller = createProviderConnectionController({
provider: props.provider,
directory,
fallbackKeyLabel: () => language.t("provider.connect.method.apiKey"),
requestFailed: () => language.t("common.requestFailed"),
invalidCode: () => language.t("provider.connect.oauth.code.invalid"),
services: {
integration: {
load: (integrationID, value) =>
serverSDK()
.api.integration.get({
integrationID,
location: value ? { directory: value } : undefined,
})
.then((result) => result.data),
},
connection: {
key: (integrationID, value, key) =>
serverSDK().api.integration.connect.key({
integrationID,
location: value ? { directory: value } : undefined,
key,
}),
oauth: (integrationID, value, methodID, inputs) =>
serverSDK()
.api.integration.oauth.connect({
integrationID,
methodID,
inputs,
location: value ? { directory: value } : undefined,
})
.then((result) => result.data),
status: (integrationID, value, attemptID) =>
serverSDK()
.api.integration.oauth.status({
integrationID,
attemptID,
location: value ? { directory: value } : undefined,
})
.then((result) => result.data),
complete: (integrationID, value, attemptID, code) =>
serverSDK().api.integration.oauth.complete({
integrationID,
attemptID,
location: value ? { directory: value } : undefined,
code,
}),
},
provider: {
refresh: () =>
queryClient.refetchQueries(serverSync().queryOptions.providers(directory() ? pathKey(directory()!) : null)),
},
completion: {
finish: () => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
})
},
},
},
])
const [integration] = createResource(
() => ({ provider: props.provider, directory: directory() }),
(input) =>
serverSDK()
.api.integration.get({
integrationID: input.provider,
location: input.directory ? { directory: input.directory } : undefined,
})
.then((result) => result.data),
)
const loading = createMemo(() => integration.loading)
const methods = createMemo<ConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
)
return values?.length ? values : fallback()
})
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined,
})
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.inputs") {
draft.promptInputs = action.inputs
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
return
}
draft.state = "error"
draft.error = action.error
}),
)
}
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
const loading = controller.data.loading
const methods = controller.data.methods
const method = controller.data.method
const methodIndex = controller.data.methodIndex
const authorization = controller.data.authorization
const state = controller.auth.state
const error = controller.auth.error
const connectKey = controller.auth.connectKey
const completeCode = controller.auth.completeCode
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
@@ -513,56 +473,7 @@ function ProviderConnection(props: {
}
}
function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data
if (typeof data?.message === "string" && data.message) return data.message
}
if (value && typeof value === "object" && "error" in value) {
const nested = formatError((value as { error?: unknown }).error, "")
if (nested) return nested
}
if (value && typeof value === "object" && "message" in value) {
const message = (value as { message?: unknown }).message
if (typeof message === "string" && message) return message
}
if (value instanceof Error && value.message) return value.message
if (typeof value === "string" && value) return value
return fallback
}
async function selectMethod(index: number, inputs?: Record<string, string>) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
}
const method = methods()[index]
dispatch({ type: "method.select", index })
if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
await serverSDK()
.api.integration.oauth.connect({
integrationID: props.provider,
methodID: method.id,
inputs: inputs ?? {},
location: location(),
})
.then((x) => {
if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data })
})
.catch((e) => {
if (!alive.value) return
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
})
}
}
const selectMethod = controller.auth.select
function AuthPromptsView() {
const [formStore, setFormStore] = createStore({
@@ -583,7 +494,7 @@ function ProviderConnection(props: {
const current = createMemo(() => {
const all = prompts()
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
if (index === -1) return
if (index === -1) return undefined
return {
index,
prompt: all[index],
@@ -597,13 +508,14 @@ function ProviderConnection(props: {
})
async function next(index: number, value: Record<string, string>) {
if (store.methodIndex === undefined) return
const selected = methodIndex()
if (selected === undefined) return
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
if (next !== -1) {
setFormStore("index", next)
return
}
await selectMethod(store.methodIndex, value)
await selectMethod(selected, value)
}
async function handleSubmit(e: SubmitEvent) {
@@ -617,12 +529,12 @@ function ProviderConnection(props: {
const item = () => current()
const text = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "text") return
if (!prompt || prompt.type !== "text") return undefined
return prompt
})
const select = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "select") return
if (!prompt || prompt.type !== "select") return undefined
return prompt
})
@@ -693,32 +605,9 @@ function ProviderConnection(props: {
listRef?.onKeyDown(e)
}
let auto = false
createEffect(() => {
if (auto) return
if (loading()) return
if (methods().length === 1) {
auto = true
void selectMethod(0)
}
})
async function complete() {
await serverSync()
.refreshProviders()
.catch(() => undefined)
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
})
}
function goBack() {
if (methods().length > 1 && store.methodIndex !== undefined) {
dispatch({ type: "method.reset" })
if (methods().length > 1 && methodIndex() !== undefined) {
controller.auth.reset()
return
}
props.onBack()
@@ -806,9 +695,9 @@ function ProviderConnection(props: {
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const apiKey = formData.get("apiKey") as string
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("apiKey")
const apiKey = typeof value === "string" ? value : ""
if (!apiKey?.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
@@ -816,12 +705,7 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
await serverSDK().api.integration.connect.key({
integrationID: props.provider,
location: location(),
key: apiKey,
})
await complete()
await connectKey(apiKey)
}
if (newLayout())
@@ -936,9 +820,9 @@ function ProviderConnection(props: {
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const code = formData.get("code") as string
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("code")
const code = typeof value === "string" ? value : ""
if (!code?.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
@@ -946,20 +830,7 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
const result = await serverSDK()
.api.integration.oauth.complete({
integrationID: props.provider,
attemptID: store.authorization!.attemptID,
location: location(),
code,
})
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
return
}
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
setFormStore("error", await completeCode(code))
}
if (newLayout())
@@ -967,7 +838,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url} class="text-v2-text-text-base">
<Link href={authorization()!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -1006,7 +877,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
<Link href={authorization()!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
@@ -1032,52 +903,18 @@ function ProviderConnection(props: {
function OAuthAutoView() {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
const instructions = authorization()?.instructions
if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim()
}
return instructions
})
onMount(() => {
const poll = async () => {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK()
.api.integration.oauth.status({
integrationID: props.provider,
attemptID: authorization.attemptID,
location: location(),
})
.then((value) => ({ ok: true as const, status: value.data }))
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
return
}
if (result.status.status === "complete") {
await complete()
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
timer.current = setTimeout(poll, 1_000)
}
void poll()
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
<Link href={authorization()!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
@@ -1121,7 +958,7 @@ function ProviderConnection(props: {
<div
onKeyDown={handleKey}
tabIndex={newLayout() ? undefined : 0}
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
autofocus={!newLayout() && methodIndex() === undefined ? true : undefined}
>
<Switch>
<Match when={loading()}>
@@ -1132,10 +969,10 @@ function ProviderConnection(props: {
</div>
</div>
</Match>
<Match when={store.methodIndex === undefined}>
<Match when={methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={store.state === "pending"}>
<Match when={state() === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
@@ -1143,14 +980,14 @@ function ProviderConnection(props: {
</div>
</div>
</Match>
<Match when={store.state === "prompt"}>
<Match when={state() === "prompt"}>
<AuthPromptsView />
</Match>
<Match when={store.state === "error"}>
<Match when={state() === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
<span>{language.t("provider.connect.status.failed", { error: error() ?? "" })}</span>
</div>
</div>
</Match>
@@ -1159,10 +996,10 @@ function ProviderConnection(props: {
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.mode === "code"}>
<Match when={authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={store.authorization?.mode === "auto"}>
<Match when={authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
@@ -0,0 +1,129 @@
import { describe, expect, test, vi } from "bun:test"
import { createRoot } from "solid-js"
import {
createProviderConnectionWorkflowController,
type ProviderConnectMethod,
} from "./provider-connection-controller"
const authorization = {
attemptID: "attempt-1",
url: "https://example.com/auth",
instructions: "Code: ABCD",
mode: "auto" as const,
time: { created: 0, expires: 1 },
}
function services(options: {
methods: readonly ProviderConnectMethod[]
status?: () => Promise<{ status: "pending" | "complete" }>
refresh?: () => void
finish?: () => void
}) {
return {
connection: {
key: async () => undefined,
oauth: async () => authorization,
status: options.status ?? (async () => ({ status: "pending" as const })),
complete: async () => undefined,
},
provider: { refresh: async () => options.refresh?.() },
completion: { finish: options.finish ?? (() => undefined) },
}
}
function create(options: Parameters<typeof services>[0]) {
return createRoot((dispose) => ({
dispose,
controller: createProviderConnectionWorkflowController({
provider: "example",
directory: () => "/project",
requestFailed: () => "Request failed",
invalidCode: () => "Invalid code",
loading: () => false,
methods: () => [...options.methods],
services: services(options),
pollInterval: 100,
}),
}))
}
async function settle() {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
}
describe("provider connection controller", () => {
test("moves prompted OAuth methods through prompt and authorization states", async () => {
const owned = create({
methods: [
{
id: "oauth",
type: "oauth",
label: "OAuth",
prompts: [{ type: "text", key: "account", message: "Account" }],
},
],
})
await owned.controller.auth.select(0)
expect(owned.controller.auth.state()).toBe("prompt")
await owned.controller.auth.select(0, { account: "team" })
expect(owned.controller.auth.state()).toBe("complete")
expect(owned.controller.data.authorization()).toEqual(authorization)
owned.dispose()
})
test("polls auto OAuth independently and completes after provider refresh", async () => {
vi.useFakeTimers()
try {
const calls: string[] = []
const statuses = [{ status: "pending" as const }, { status: "complete" as const }]
const owned = create({
methods: [{ id: "oauth", type: "oauth", label: "OAuth" }],
status: async () => statuses.shift() ?? { status: "complete" as const },
refresh: () => calls.push("refresh"),
finish: () => calls.push("finish"),
})
await owned.controller.auth.select(0)
await settle()
expect(owned.controller.data.authorization()).toEqual(authorization)
expect(calls).toEqual([])
vi.advanceTimersByTime(100)
await settle()
expect(calls).toEqual(["refresh", "finish"])
owned.dispose()
} finally {
vi.useRealTimers()
}
})
test("cancels an owned poll timer on reset and disposal", async () => {
vi.useFakeTimers()
try {
const status = vi.fn(async () => ({ status: "pending" as const }))
const owned = create({ methods: [{ id: "oauth", type: "oauth", label: "OAuth" }], status })
await owned.controller.auth.select(0)
expect(status).toHaveBeenCalledTimes(1)
owned.controller.auth.reset()
vi.advanceTimersByTime(1_000)
await settle()
expect(status).toHaveBeenCalledTimes(1)
await owned.controller.auth.select(0)
expect(status).toHaveBeenCalledTimes(2)
owned.dispose()
vi.advanceTimersByTime(1_000)
await settle()
expect(status).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,254 @@
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type Authorization = IntegrationOauthConnectOutput["data"]
type OAuthStatus = { status: "pending" | "complete" | "expired" } | { status: "failed"; message: string }
type ProviderConnectionServices = {
integration: {
load: (provider: string, directory?: string) => Promise<{ methods: readonly IntegrationMethod[] } | null>
}
connection: {
key: (provider: string, directory: string | undefined, key: string) => Promise<unknown>
oauth: (
provider: string,
directory: string | undefined,
method: string,
inputs: Record<string, string>,
) => Promise<Authorization>
status: (provider: string, directory: string | undefined, attempt: string) => Promise<OAuthStatus>
complete: (provider: string, directory: string | undefined, attempt: string, code: string) => Promise<unknown>
}
provider: { refresh: () => Promise<unknown> }
completion: { finish: () => void }
}
export function createProviderConnectionController(options: {
provider: string
directory: () => string | undefined
fallbackKeyLabel: () => string
requestFailed: () => string
invalidCode: () => string
services: ProviderConnectionServices
pollInterval?: number
}) {
const [integration] = createResource(
() => ({ provider: options.provider, directory: options.directory() }),
(input) => options.services.integration.load(input.provider, input.directory),
)
const methods = createMemo<ProviderConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
)
if (values?.length) return [...values]
return [{ type: "key", label: options.fallbackKeyLabel() }]
})
return createProviderConnectionWorkflowController({
...options,
loading: () => integration.loading,
methods,
})
}
export function createProviderConnectionWorkflowController(options: {
provider: string
directory: () => string | undefined
requestFailed: () => string
invalidCode: () => string
loading: () => boolean
methods: () => ProviderConnectMethod[]
services: Pick<ProviderConnectionServices, "connection" | "provider" | "completion">
pollInterval?: number
}) {
const [store, setStore] = createStore({
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
state: "pending" as "pending" | "complete" | "error" | "prompt" | undefined,
error: undefined as string | undefined,
})
const polling = {
generation: 0,
timer: undefined as ReturnType<typeof setTimeout> | undefined,
disposed: false,
}
const methods = options.methods
const method = createMemo(() => (store.methodIndex === undefined ? undefined : methods().at(store.methodIndex)))
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: Authorization }
| { type: "auth.error"; error: string }
const dispatch = (action: Action) => {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
return
}
draft.state = "error"
draft.error = action.error
}),
)
}
const cancelPolling = () => {
polling.generation++
if (polling.timer === undefined) return
clearTimeout(polling.timer)
polling.timer = undefined
}
const finish = async () => {
cancelPolling()
await options.services.provider.refresh().catch(() => undefined)
if (polling.disposed) return
options.services.completion.finish()
}
const poll = async (authorization: Authorization, generation: number) => {
const result = await options.services.connection
.status(options.provider, options.directory(), authorization.attemptID)
.then((status) => ({ ok: true as const, status }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({ type: "auth.error", error: formatProviderConnectionError(result.error, options.requestFailed()) })
return
}
if (result.status.status === "complete") {
await finish()
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: options.requestFailed() })
return
}
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
}
const select = async (index: number, inputs?: Record<string, string>) => {
cancelPolling()
const generation = polling.generation
const selected = methods()[index]
dispatch({ type: "method.select", index })
if (selected.type !== "oauth") return
if (selected.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
const result = await options.services.connection
.oauth(options.provider, options.directory(), selected.id, inputs ?? {})
.then((authorization) => ({ ok: true as const, authorization }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({ type: "auth.error", error: formatProviderConnectionError(result.error, options.requestFailed()) })
return
}
dispatch({ type: "auth.complete", authorization: result.authorization })
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
}
const reset = () => {
cancelPolling()
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
await options.services.connection.key(options.provider, options.directory(), key)
await finish()
}
const completeCode = async (code: string) => {
const authorization = store.authorization
if (!authorization) return options.invalidCode()
const result = await options.services.connection
.complete(options.provider, options.directory(), authorization.attemptID, code)
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!result.ok) return formatProviderConnectionError(result.error, options.invalidCode())
await finish()
return undefined
}
let auto = false
createEffect(() => {
if (auto || options.loading() || methods().length !== 1) return
auto = true
void select(0)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
})
return {
data: {
loading: options.loading,
methods,
method,
methodIndex: () => store.methodIndex,
authorization: () => store.authorization,
},
auth: {
state: () => store.state,
error: () => store.error,
select,
reset,
connectKey,
completeCode,
},
}
}
export type ProviderConnectionController = ReturnType<typeof createProviderConnectionController>
export function formatProviderConnectionError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = value.data
if (data && typeof data === "object" && "message" in data && typeof data.message === "string" && data.message)
return data.message
}
if (value && typeof value === "object" && "error" in value) {
const nested = formatProviderConnectionError(value.error, "")
if (nested) return nested
}
if (value && typeof value === "object" && "message" in value) {
const message = value.message
if (typeof message === "string" && message) return message
}
if (value instanceof Error && value.message) return value.message
if (typeof value === "string" && value) return value
return fallback
}
+13 -2
View File
@@ -5,6 +5,7 @@ import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { modelsData } from "./generate"
@@ -22,7 +23,7 @@ await rm(outdir, { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const plugin = createSolidTransformPlugin()
const solidPlugin = createSolidTransformPlugin()
const allTargets: {
os: string
@@ -55,6 +56,16 @@ const targets = singleFlag
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
for (const item of targets) {
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
const parcelWatcherPlugin: BunPlugin = {
name: "parcel-watcher-binding",
setup(build) {
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
loader: "js",
}))
},
}
const target = [
binary,
item.os === "win32" ? "windows" : item.os,
@@ -69,7 +80,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
plugins: [solidPlugin, parcelWatcherPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+36 -1
View File
@@ -13,7 +13,7 @@ const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["nod
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")))
const env = {
...process.env,
HOME: root,
@@ -29,6 +29,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
const errors: Array<Promise<string>> = []
let failure: unknown
try {
await fs.mkdir(path.join(root, ".opencode"))
spawnService()
spawnService()
const registration = await waitForRegistration()
@@ -49,6 +50,11 @@ try {
{ signal: AbortSignal.timeout(5_000) },
)
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
await fs.mkdir(path.dirname(plugin), { recursive: true })
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
signal: AbortSignal.timeout(5_000),
@@ -88,6 +94,7 @@ try {
} finally {
processes.forEach((process) => process.kill())
await Promise.all(processes.map((process) => process.exited))
if (failure) errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => ""))
}
const output = await Promise.all(errors)
@@ -133,3 +140,31 @@ async function waitForReady(url: string, headers: HeadersInit) {
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
}
function pluginSource() {
return 'export default { id: "smoke", setup: async () => {} }\n'
}
async function pluginIDs(url: string, headers: HeadersInit) {
const endpoint = new URL("/api/plugin", url)
endpoint.searchParams.set("location[directory]", root)
const response = await fetch(endpoint, { headers, signal: AbortSignal.timeout(5_000) })
const body: unknown = await response.json()
if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data)) {
throw new Error("Compiled service returned an invalid plugin list")
}
return body.data.flatMap((plugin) =>
typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string"
? [plugin.id]
: [],
)
}
async function waitForPlugin(url: string, headers: HeadersInit) {
const deadline = Date.now() + 10_000
while (Date.now() < deadline) {
if ((await pluginIDs(url, headers)).includes("smoke")) return
await Bun.sleep(25)
}
throw new Error("Compiled service did not discover the created plugin")
}
@@ -0,0 +1,13 @@
import { createRequire } from "node:module"
declare const OPENCODE_LIBC: string | undefined
const require = createRequire(import.meta.url)
export default function load() {
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
return require(
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
}
+2 -11
View File
@@ -9,23 +9,14 @@ import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "ef
import { lazy } from "../util/lazy"
import { watch as watchFileSystem } from "node:fs"
import path from "path"
import { createRequire } from "node:module"
declare const OPENCODE_LIBC: string | undefined
import loadBinding from "./watcher-binding"
const SUBSCRIBE_TIMEOUT_MS = 10_000
const require = createRequire(import.meta.url)
export const Event = { Updated: FileSystem.Event.Changed }
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
const binding = require(
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
return createWrapper(loadBinding()) as typeof import("@parcel/watcher")
} catch {
return
}
+4 -4
View File
@@ -230,11 +230,11 @@ export interface DialogSelectOptions<Value> {
}
export interface Dialog {
/** Shows a dialog and returns a function that closes it. */
show(render: () => JSX.Element, onClose?: () => void): () => void
/** Sets the presentation options for this plugin's active dialog. */
/** Shows a dialog. */
show(render: () => JSX.Element, onClose?: () => void): void
/** Sets the active dialog's presentation options. */
set(options: DialogOptions): void
/** Closes this plugin's active dialog. */
/** Closes the active dialog. */
clear(): void
alert(options: DialogAlertOptions): Promise<void>
confirm(options: DialogConfirmOptions): Promise<boolean | undefined>
-4
View File
@@ -11,7 +11,6 @@
},
"exports": {
".": "./src/index.tsx",
"./builtins": "./src/feature-plugins/builtins.ts",
"./config": "./src/config/index.tsx",
"./config/keybind": "./src/config/keybind.ts",
"./config/v1": "./src/config/v1/index.tsx",
@@ -37,9 +36,6 @@
"./context/keymap": "./src/context/keymap.tsx",
"./prompt/content": "./src/prompt/content.ts",
"./prompt/display": "./src/prompt/display.ts",
"./plugin/runtime": "./src/plugin/runtime.tsx",
"./plugin/slots": "./src/plugin/slots.tsx",
"./plugin/command-shim": "./src/plugin/command-shim.ts",
"./parsers-config": "./src/parsers-config.ts",
"./util/error": "./src/util/error.ts",
"./util/filetype": "./src/util/filetype.ts",
+42 -48
View File
@@ -81,7 +81,6 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
@@ -275,8 +274,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
)
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
const pluginRuntime = createPluginRuntime()
yield* Effect.tryPromise(async () => {
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
@@ -354,49 +351,47 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
: undefined
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
@@ -453,7 +448,6 @@ function App(props: { pair?: DialogPairCredentials }) {
const location = useLocation()
const exit = useExit()
const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime()
const plugins = usePlugin()
const clipboard = useClipboard()
@@ -1203,7 +1197,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<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 > 1 && route.data.type !== "plugin"}>
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
<SessionTabs />
</Show>
<Switch>
+3 -3
View File
@@ -94,9 +94,9 @@ export const settings: Setting[] = [
keywords: ["transcript", "messages"],
},
{
title: "Tabs",
category: "Session",
path: ["session", "tabs"],
title: "Enabled",
category: "Tabs",
path: ["tabs", "enabled"],
default: false,
values: [false, true],
labels: ["off", "on"],
+21 -5
View File
@@ -4,19 +4,33 @@ import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import { adaptiveSessionTabLayout, sessionTabComplete, SESSION_TAB_OVERFLOW_WIDTH } from "../context/session-tabs-model"
import {
adaptiveSessionTabLayout,
sessionTabComplete,
SESSION_TAB_OVERFLOW_WIDTH,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { createAnimatable, spring } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse } from "./tab-pulse"
import { tint } from "../theme/color"
export function SessionTabs() {
const tabs = useSessionTabs()
type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
unread: SessionTabUnread | undefined
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close"> & {
status(sessionID: string): SessionTabsStatus
}
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
const tabs = props.controller ?? useSessionTabs()
const dimensions = useTerminalDimensions()
const theme = useTheme()
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
@@ -48,7 +62,7 @@ export function SessionTabs() {
activities: layout().tabs.map((tab) => Number(statuses().get(tab.sessionID)!.complete)),
}))
const motion = createAnimatable(targets(), {
enabled: () => config.animations ?? true,
enabled: animations,
transition: spring({ visualDuration: 0.1 }),
})
const identity = createMemo(() =>
@@ -167,10 +181,12 @@ export function SessionTabs() {
onMouseUp={() => tabs.select(tab.sessionID)}
>
<TabPulse
enabled={config.animations ?? true}
enabled={animations()}
active={status().busy}
complete={status().complete}
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
color={pulseColor()}
glowColor={accent()}
completionColor={accent()}
backgroundColor={pulseBackground()}
/>
+75 -16
View File
@@ -1,12 +1,13 @@
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
import { extend } from "@opentui/solid"
import { tint } from "../theme/color"
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
enabled?: boolean
active?: boolean
complete?: boolean
glow?: boolean
color?: RGBA
glowColor?: RGBA
completionColor?: RGBA
backgroundColor?: RGBA
}
@@ -19,6 +20,9 @@ const RUN_TAIL = 18
const RUN_FADE_OUT = 500
const COMPLETION_DURATION = 900
const COMPLETION_ATTACK = 0.16
const GLOW_TAIL = 12
const GLOW_OPACITY = 0.16
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
const intensityAt = (index: number, front: number, head: number, tail: number) => {
const distance = front - index
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
@@ -33,17 +37,44 @@ export const completionPulseOpacity = (progress: number) =>
progress < COMPLETION_ATTACK
? smootherstep(clamp(progress / COMPLETION_ATTACK))
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
export const unreadGlowIntensity = (index: number, width: number) => {
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
}
export function blendTabPulseColor(
output: RGBA,
background: RGBA,
glowColor: RGBA,
runningColor: RGBA,
completionColor: RGBA,
glow: number,
running: number,
completion: number,
) {
output.r = background.r + (glowColor.r - background.r) * glow
output.g = background.g + (glowColor.g - background.g) * glow
output.b = background.b + (glowColor.b - background.b) * glow
output.r += (runningColor.r - output.r) * running
output.g += (runningColor.g - output.g) * running
output.b += (runningColor.b - output.b) * running
output.r += (completionColor.r - output.r) * completion
output.g += (completionColor.g - output.g) * completion
output.b += (completionColor.b - output.b) * completion
}
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _complete: boolean
private _glow: boolean
private _color: RGBA
private _glowColor: RGBA
private _completionColor: RGBA
private _backgroundColor: RGBA
private clock = 0
private fadeClock: number | undefined
private completionClock: number | undefined
private completionPending = false
private renderColor = RGBA.fromInts(0, 0, 0)
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
const enabled = options.enabled ?? true
@@ -52,7 +83,9 @@ class TabPulseRenderable extends Renderable {
this._enabled = enabled
this._active = active
this._complete = options.complete ?? false
this._glow = options.glow ?? false
this._color = options.color ?? RGBA.defaultForeground()
this._glowColor = options.glowColor ?? this._color
this._completionColor = options.completionColor ?? this._color
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
}
@@ -103,12 +136,24 @@ class TabPulseRenderable extends Renderable {
this.requestRender()
}
set glow(value: boolean) {
if (value === this._glow) return
this._glow = value
this.requestRender()
}
set color(value: RGBA) {
if (value.equals(this._color)) return
this._color = value
this.requestRender()
}
set glowColor(value: RGBA) {
if (value.equals(this._glowColor)) return
this._glowColor = value
this.requestRender()
}
set completionColor(value: RGBA) {
if (value.equals(this._completionColor)) return
this._completionColor = value
@@ -144,15 +189,19 @@ class TabPulseRenderable extends Renderable {
}
protected override renderSelf(buffer: OptimizedBuffer): void {
if (!this.visible || this.isDestroyed || !this._enabled || this.width <= 0) return
const runningOpacity = this._active
? 1
: this.fadeClock === undefined
? 0
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
if (!this.visible || this.isDestroyed || this.width <= 0) return
const runningOpacity = !this._enabled
? 0
: this._active
? 1
: this.fadeClock === undefined
? 0
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
const completionOpacity =
this.completionClock === undefined ? 0 : completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
if (runningOpacity === 0 && completionOpacity === 0) return
!this._enabled || this.completionClock === undefined
? 0
: completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
if (!this._glow && runningOpacity === 0 && completionOpacity === 0) return
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
const start = -RUN_HEAD
const end = this.width - 1 + RUN_TAIL
@@ -163,14 +212,20 @@ class TabPulseRenderable extends Renderable {
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
)
const running = tint(this._backgroundColor, this._color, intensity * 0.14 * runningOpacity)
buffer.setCell(
this.screenX + index,
this.screenY,
" ",
RGBA.defaultForeground(),
tint(running, this._completionColor, completionOpacity * 0.18),
const glow = this._glow ? unreadGlowIntensity(index, this.width) * GLOW_OPACITY : 0
const running = intensity * 0.14 * runningOpacity
const completion = completionOpacity * 0.18
blendTabPulseColor(
this.renderColor,
this._backgroundColor,
this._glowColor,
this._color,
this._completionColor,
glow,
running,
completion,
)
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
}
}
}
@@ -187,7 +242,9 @@ export function TabPulse(props: {
enabled?: boolean
active: boolean
complete?: boolean
glow?: boolean
color: RGBA
glowColor?: RGBA
completionColor?: RGBA
backgroundColor: RGBA
}) {
@@ -199,7 +256,9 @@ export function TabPulse(props: {
enabled={props.enabled ?? true}
active={props.active}
complete={props.complete ?? false}
glow={props.glow ?? false}
color={props.color}
glowColor={props.glowColor ?? props.color}
completionColor={props.completionColor ?? props.color}
backgroundColor={props.backgroundColor}
/>
+6 -2
View File
@@ -120,11 +120,15 @@ export const Info = Schema.Struct({
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
tabs: Schema.optional(Schema.Boolean).annotate({
}),
).annotate({ description: "Session transcript presentation settings" }),
tabs: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
}),
}),
).annotate({ description: "Session transcript presentation settings" }),
).annotate({ description: "Session tab settings" }),
mini: Schema.optional(
Schema.Struct({
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
-28
View File
@@ -230,24 +230,9 @@ export const Definitions = {
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
terminal_title_toggle: keybind("none", "Toggle terminal title"),
plugin_manager: keybind("none", "Open plugin manager dialog"),
plugin_install: keybind("none", "Install plugin"),
which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"),
which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"),
which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"),
which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"),
which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"),
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
} satisfies Record<string, Definition>
type KeybindName = keyof typeof Definitions
@@ -425,19 +410,6 @@ export const CommandMap = {
history_next: "prompt.history.next",
terminal_suspend: "terminal.suspend",
terminal_title_toggle: "terminal.title.toggle",
plugin_manager: "plugins.list",
plugin_install: "plugins.install",
which_key_toggle: "which-key.toggle",
which_key_layout_toggle: "which-key.layout.toggle",
which_key_pending_toggle: "which-key.pending.toggle",
which_key_group_previous: "which-key.group.previous",
which_key_group_next: "which-key.group.next",
which_key_scroll_up: "which-key.scroll.up",
which_key_scroll_down: "which-key.scroll.down",
which_key_page_up: "which-key.page.up",
which_key_page_down: "which-key.page.down",
which_key_home: "which-key.home",
which_key_end: "which-key.end",
} satisfies BindingCommandMap
const CommandDescriptions = Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
+1 -1
View File
@@ -34,7 +34,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const event = useEvent()
const config = useConfig().data
const filePath = path.join(useTuiPaths().state, "session-tabs.json")
const enabled = () => config.session?.tabs ?? false
const enabled = () => config.tabs?.enabled ?? false
const state: {
pending: boolean
saving: boolean
@@ -1,49 +0,0 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/v1/tui"
import type { PluginRuntime } from "../plugin/runtime"
import PluginManager from "./system/plugins"
import WhichKey from "./system/which-key"
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
id: string
tui: TuiPlugin
enabled?: boolean
}
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
return [PluginManager, WhichKey]
}
export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {
const slots = runtime.setupSlots(api)
const dispose: Array<() => void> = []
for (const plugin of createBuiltinPlugins()) {
if (plugin.enabled === false) continue
const scoped = Object.assign(Object.create(api), {
slots: {
register(input: Parameters<typeof slots.register>[0]) {
dispose.push(slots.register({ ...input, id: plugin.id }))
return plugin.id
},
},
}) as TuiPluginApi
const now = Date.now()
await plugin.tui(scoped, undefined, {
id: plugin.id,
source: "internal",
spec: plugin.id,
target: plugin.id,
first_time: now,
last_time: now,
time_changed: now,
load_count: 1,
fingerprint: plugin.id,
state: "first",
})
}
return () => {
for (const fn of dispose.reverse()) fn()
slots.dispose()
}
}
@@ -1,280 +1,90 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/v1/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url"
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, createSignal } from "solid-js"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { Show, createEffect, createMemo, createSignal } from "solid-js"
import { Keymap } from "../../context/keymap"
const id = "internal:plugin-manager"
const id = "opencode.plugins"
function state(api: TuiPluginApi, item: TuiPluginStatus) {
if (!item.enabled) {
return <span style={{ fg: api.theme.current.textMuted }}>disabled</span>
}
return (
<span style={{ fg: item.active ? api.theme.current.success : api.theme.current.error }}>
{item.active ? "active" : "inactive"}
</span>
)
}
function source(spec: string) {
if (!spec.startsWith("file://")) return
return fileURLToPath(spec)
}
function meta(item: TuiPluginStatus, width: number) {
if (item.source === "internal") {
if (width >= 120) return "Built-in plugin"
return "Built-in"
}
const next = source(item.spec)
if (next) return next
return item.spec
}
function Install(props: { api: TuiPluginApi }) {
const [global, setGlobal] = createSignal(false)
const [busy, setBusy] = createSignal(false)
Keymap.createLayer(() => ({
mode: "modal",
enabled: !busy(),
commands: [
{
bind: "tab",
title: "Toggle install scope",
group: "Plugins",
run: () => {
setGlobal((value) => !value)
},
},
],
}))
return (
<props.api.ui.DialogPrompt
title="Install plugin"
placeholder="npm package name"
busy={busy()}
busyText="Installing plugin..."
description={() => (
<box flexDirection="row" gap={1}>
<text fg={props.api.theme.current.textMuted}>scope:</text>
<text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
{global() ? "global" : "local"}
</text>
<Show when={!busy()}>
<text fg={props.api.theme.current.textMuted}>(tab toggle)</text>
</Show>
</box>
)}
onConfirm={(raw) => {
if (busy()) return
const mod = raw.trim()
if (!mod) {
props.api.ui.toast({
variant: "error",
message: "Plugin package name is required",
})
return
}
setBusy(true)
void props.api.plugins
.install(mod, { global: global() })
.then((out) => {
if (!out.ok) {
props.api.ui.toast({
variant: "error",
message: out.message,
})
if (out.missing) {
props.api.ui.toast({
variant: "info",
message: "Check npm registry/auth settings and try again.",
})
}
show(props.api)
return
}
props.api.ui.toast({
variant: "success",
message: `Installed ${mod} (${global() ? "global" : "local"}: ${out.dir})`,
})
if (!out.tui) {
props.api.ui.toast({
variant: "info",
message: "Package has no TUI target to load in this app.",
})
show(props.api)
return
}
return props.api.plugins.add(mod).then((ok) => {
if (!ok) {
props.api.ui.toast({
variant: "warning",
message: "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.",
})
show(props.api)
return
}
props.api.ui.toast({
variant: "success",
message: `Loaded ${mod} in current session.`,
})
show(props.api)
})
})
.finally(() => {
setBusy(false)
})
}}
onCancel={() => {
show(props.api)
}}
/>
)
}
function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption<string> {
return {
title: item.id,
value: item.id,
category: item.source === "internal" ? "Internal" : "External",
description: meta(item, width),
footer: state(api, item),
disabled: item.id === id,
}
}
function showInstall(api: TuiPluginApi) {
api.ui.dialog.replace(() => <Install api={api} />)
}
function View(props: { api: TuiPluginApi }) {
const size = useTerminalDimensions()
const [list, setList] = createSignal(props.api.plugins.list())
const [cur, setCur] = createSignal<string | undefined>()
const [lock, setLock] = createSignal(false)
createEffect(() => {
const width = size().width
if (width >= 128) {
props.api.ui.dialog.setSize("xlarge")
return
}
if (width >= 96) {
props.api.ui.dialog.setSize("large")
return
}
props.api.ui.dialog.setSize("medium")
})
const rows = createMemo(() =>
[...list()]
.sort((a, b) => {
const x = a.source === "internal" ? 1 : 0
const y = b.source === "internal" ? 1 : 0
if (x !== y) return x - y
return a.id.localeCompare(b.id)
})
.map((item) => row(props.api, item, size().width)),
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
const [locked, setLocked] = createSignal(false)
const options = createMemo(() =>
props.plugins
.registered()
.filter((plugin) => plugin.id !== id)
.sort((a, b) => a.id.localeCompare(b.id))
.map(
(plugin): DialogSelectOption<string> => ({
title: plugin.id,
value: plugin.id,
category: plugin.source === "builtin" ? "Built-in" : "External",
footer: (
<span
style={{
fg: plugin.active
? props.context.theme.text.feedback.success.default
: props.context.theme.text.subdued,
}}
>
{plugin.active ? "active" : "inactive"}
</span>
),
}),
),
)
const flip = (x: string) => {
if (lock()) return
const item = list().find((entry) => entry.id === x)
if (!item) return
setLock(true)
const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
void task
const toggle = (plugin: DialogSelectOption<string>) => {
if (locked()) return
const current = props.plugins.registered().find((item) => item.id === plugin.value)
if (!current) return
setLocked(true)
void (current.active ? props.plugins.deactivate(current.id) : props.plugins.activate(current.id))
.then((ok) => {
if (!ok) {
props.api.ui.toast({
variant: "error",
message: `Failed to update plugin ${item.id}`,
})
}
setList(props.api.plugins.list())
if (ok) return
props.context.ui.toast.show({ variant: "error", message: `Failed to update plugin ${current.id}` })
})
.finally(() => {
setLock(false)
.catch((error) => {
props.context.ui.toast.show({
variant: "error",
message: error instanceof Error ? error.message : String(error),
})
})
.finally(() => setLocked(false))
}
return (
<DialogSelect
title="Plugins"
options={rows()}
current={cur()}
onMove={(item) => setCur(item.value)}
actions={[
{
title: "toggle",
command: "plugins.toggle",
hidden: lock(),
onTrigger: (item) => {
setCur(item.value)
flip(item.value)
},
},
{
title: "install",
command: "dialog.plugins.install",
selection: "none",
hidden: lock(),
onTrigger: () => {
showInstall(props.api)
},
},
]}
onSelect={(item) => {
setCur(item.value)
flip(item.value)
}}
options={options()}
locked={locked()}
preserveSelection={true}
actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]}
onSelect={toggle}
/>
)
}
function show(api: TuiPluginApi) {
api.ui.dialog.replace(() => <View api={api} />)
}
const tui: TuiPlugin = async (api) => {
api.keymap.registerLayer({
function Commands(props: { context: Plugin.Context }) {
const plugins = usePlugin()
props.context.keymap.layer(() => ({
mode: "global",
commands: [
{
name: "plugins.list",
id: "plugins.list",
title: "Plugins",
category: "System",
namespace: "palette",
group: "System",
palette: true,
run() {
show(api)
},
},
{
name: "plugins.install",
title: "Install plugin",
category: "System",
namespace: "palette",
run() {
showInstall(api)
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
},
},
],
bindings: ["plugins.list", "plugins.install"].flatMap((command) => api.tuiConfig.keybinds.get(command)),
})
}))
return null
}
const plugin: BuiltinTuiPlugin = {
export default Plugin.define({
id,
tui,
}
export default plugin
setup(context) {
context.ui.slot("app", () => <Commands context={context} />)
},
})
@@ -1,5 +1,26 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal } from "solid-js"
import { SessionTabs, type SessionTabsController } from "../../component/session-tabs"
type FixtureStatus = ReturnType<SessionTabsController["status"]>
const FIXTURE_TABS = [
{ sessionID: "fixture-1", title: "Implement session tabs" },
{ sessionID: "fixture-2", title: "Investigate rendering" },
{ sessionID: "fixture-3", title: "A deliberately long session title for truncation" },
{ sessionID: "fixture-4", title: "Fix provider state" },
{ sessionID: "fixture-5", title: "Review animation" },
{ sessionID: "fixture-6", title: "Untitled behavior" },
{ sessionID: "fixture-7", title: "Queue follow-up work" },
{ sessionID: "fixture-8", title: "Check narrow layout" },
{ sessionID: "fixture-9", title: "Profile terminal output" },
{ sessionID: "fixture-10", title: "Handle permission" },
{ sessionID: "fixture-11", title: "Run focused tests" },
{ sessionID: "fixture-12", title: "Prepare review" },
]
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
@@ -24,6 +45,50 @@ function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const elevatedTheme = props.context.theme.contextual("elevated")
const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6))
const [active, setActive] = createSignal<string | undefined>("fixture-2")
const [animations, setAnimations] = createSignal(true)
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({
"fixture-2": { ...EMPTY_STATUS, busy: true },
"fixture-3": { ...EMPTY_STATUS, unread: "activity" },
"fixture-4": { ...EMPTY_STATUS, unread: "error" },
"fixture-5": { ...EMPTY_STATUS, attention: true },
"fixture-6": { ...EMPTY_STATUS, busy: true, attention: true },
})
const controller = {
tabs,
current: active,
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_STATUS
},
select(sessionID) {
setActive(sessionID)
},
close(sessionID?: string) {
const target = sessionID ?? active()
if (!target) return
const items = tabs()
const index = items.findIndex((tab) => tab.sessionID === target)
if (index === -1) return
const next = items.filter((tab) => tab.sessionID !== target)
batch(() => {
setTabs(next)
if (active() === target) setActive(next[index]?.sessionID ?? next[index - 1]?.sessionID)
})
},
} satisfies SessionTabsController
const cycle = (direction: 1 | -1) => {
const items = tabs()
if (items.length === 0) return
const index = items.findIndex((tab) => tab.sessionID === active())
controller.select(items[(index + direction + items.length) % items.length]!.sessionID)
}
const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => {
const sessionID = active()
if (!sessionID) return
setStatuses((current) => ({ ...current, [sessionID]: update(current[sessionID] ?? EMPTY_STATUS) }))
}
props.context.keymap.layer(() => ({
commands: [
@@ -35,12 +100,60 @@ function Scrap(props: { context: Plugin.Context }) {
props.context.ui.router.navigate({ type: "home" })
},
},
{ bind: "h", title: "Previous tab", group: "Scrap", run: () => cycle(-1) },
{ bind: "l", title: "Next tab", group: "Scrap", run: () => cycle(1) },
{
bind: "t",
title: "Add tab",
group: "Scrap",
run() {
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
if (next) setTabs((current) => [...current, next])
},
},
{ bind: "d", title: "Close tab", group: "Scrap", run: () => controller.close() },
{
bind: "b",
title: "Toggle busy",
group: "Scrap",
run: () =>
updateStatus((status) =>
status.busy ? { ...status, busy: false, unread: "activity" } : { ...status, busy: true, unread: undefined },
),
},
{
bind: "u",
title: "Cycle unread",
group: "Scrap",
run: () =>
updateStatus((status) => ({
...status,
unread: status.unread === undefined ? "activity" : status.unread === "activity" ? "error" : undefined,
})),
},
{
bind: "a",
title: "Toggle attention",
group: "Scrap",
run: () => updateStatus((status) => ({ ...status, attention: !status.attention })),
},
{
bind: "m",
title: "Toggle motion",
group: "Scrap",
run: () => setAnimations((enabled) => !enabled),
},
],
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
<box flexGrow={1} />
<box
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={theme.background.default}
>
<SessionTabs controller={controller} animations={animations()} />
<box
height={1}
flexShrink={0}
@@ -49,10 +162,13 @@ function Scrap(props: { context: Plugin.Context }) {
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>~/code/anomalyco/opencode</text>
<text fg={elevatedTheme.text.subdued}>tab playground</text>
<box flexGrow={1} />
<text fg={elevatedTheme.text.subdued}>esc home</text>
<text fg={elevatedTheme.text.subdued}>
h/l select | t add | d close | b busy | u unread | a attention | m motion | esc home
</text>
</box>
<box flexGrow={1} />
</box>
)
}
@@ -1,607 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { Keymap } from "../../context/keymap"
import type { ActiveKey } from "@opentui/keymap"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
import type { BuiltinTuiPlugin } from "../builtins"
const command = {
toggle: "which-key.toggle",
toggleLayout: "which-key.layout.toggle",
togglePending: "which-key.pending.toggle",
groupPrevious: "which-key.group.previous",
groupNext: "which-key.group.next",
scrollUp: "which-key.scroll.up",
scrollDown: "which-key.scroll.down",
pageUp: "which-key.page.up",
pageDown: "which-key.page.down",
home: "which-key.home",
end: "which-key.end",
} as const
const LAYER_PRIORITY = 900
const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const
const scrollCommands = [
command.scrollUp,
command.scrollDown,
command.pageUp,
command.pageDown,
command.home,
command.end,
] as const
const panelCommands = [command.groupPrevious, command.groupNext, ...scrollCommands] as const
const COLUMN_GAP = 4
const TAB_GAP = 3
const MIN_TAB_GAP = 1
const TAB_CONTENT_GAP = 1
const MIN_COLUMN_WIDTH = 28
const MAX_COLUMN_WIDTH = 44
const PANEL_HEIGHT_RATIO = 0.3
const MIN_PANEL_HEIGHT = 8
const MAX_PANEL_HEIGHT = 16
const PANEL_TOP_PADDING = 1
const FOOTER_HEIGHT = 1
const FOOTER_MARGIN = 1
const UNKNOWN = "Unknown"
type Layout = "dock" | "overlay"
type Color = RGBA | string
type Skin = {
panel: Color
text: Color
muted: Color
subtle: Color
key: Color
accent: Color
tab: Color
tabText: Color
}
type Entry = {
type: "entry"
key: string
label: string
group: string
continues: boolean
}
type Group = {
label: string
entries: Entry[]
}
type HeaderItem = { type: "tab"; group: Group } | { type: "scroll" }
type GroupHeader = {
type: "group"
label: string
}
type Item = Entry | GroupHeader
function text(value: unknown) {
if (typeof value !== "string") return undefined
const trimmed = value.trim()
return trimmed || undefined
}
function ink(api: TuiPluginApi, name: string, fallback: string): Color {
const value = Reflect.get(api.theme.current, name)
if (typeof value === "string") return value
if (value instanceof RGBA) return value
return fallback
}
function skin(api: TuiPluginApi): Skin {
return {
panel: ink(api, "backgroundMenu", "#1c1c1c"),
text: ink(api, "text", "#f0f0f0"),
muted: ink(api, "textMuted", "#a5a5a5"),
subtle: ink(api, "borderSubtle", "#6f6f6f"),
key: ink(api, "warning", "#ffd75f"),
accent: ink(api, "primary", "#5f87ff"),
tab: ink(api, "primary", "#5f87ff"),
tabText: ink(api, "selectedListItemText", "#ffffff"),
}
}
function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) {
if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN
return (
text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN
)
}
function activeKeyGroup(active: ActiveKey<Renderable, KeyEvent>) {
if (active.continues) return "System"
return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN
}
function activeKeyEntry(api: TuiPluginApi, active: ActiveKey<Renderable, KeyEvent>): Entry {
const key = api.keys.formatSequence([
{
stroke: active.stroke,
display: active.display,
tokenName: active.tokenName,
},
])
const label = activeKeyLabel(active)
return {
type: "entry",
key,
label: active.continues ? `+${label}` : label,
group: activeKeyGroup(active),
continues: active.continues,
}
}
function grouped(entries: Entry[]): Group[] {
const map = new Map<string, Entry[]>()
for (const entry of entries) map.set(entry.group, [...(map.get(entry.group) ?? []), entry])
return [...map]
.map(([label, entries]) => ({
label,
entries: entries.toSorted(
(a, b) =>
Number(b.continues) - Number(a.continues) || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
),
}))
.toSorted((a, b) => a.label.localeCompare(b.label))
}
function commandShortcut(_api: TuiPluginApi, name: string) {
const shortcuts = Keymap.useShortcuts()
return () => shortcuts.get(name) ?? ""
}
function layout(value: unknown): Layout {
if (value === "overlay") return "overlay"
return "dock"
}
function HomeHint(props: { api: TuiPluginApi }) {
const trigger = commandShortcut(props.api, command.toggle)
const look = createMemo(() => skin(props.api))
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
<text fg={look().muted} wrapMode="none">
Show keyboard shortcuts with <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
</text>
</box>
)
}
function WhichKeyPanel(props: {
api: TuiPluginApi
layout: Layout
mode: () => Layout
pendingPreview: () => boolean
pinned: () => boolean
}) {
const dimensions = useTerminalDimensions()
const [offset, setOffset] = createSignal(0)
const [activeGroup, setActiveGroup] = createSignal<string | undefined>()
const pending = Keymap.usePendingSequence()
const active = Keymap.useActiveKeys()
const pendingActive = createMemo(() => pending().length > 0 && active().length > 0)
const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive())
const visible = createMemo(() => props.pinned() || pendingAutoVisible())
const pendingMode = createMemo(() => visible() && pendingActive())
const left = 0
const width = createMemo(() => Math.max(1, dimensions().width))
const panelHeight = createMemo(() =>
Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, Math.floor(dimensions().height * PANEL_HEIGHT_RATIO))),
)
const contentWidth = createMemo(() => Math.max(1, width() - 2))
const columns = createMemo(() =>
Math.max(1, Math.min(3, Math.floor((contentWidth() + COLUMN_GAP) / (MAX_COLUMN_WIDTH + COLUMN_GAP)) || 1)),
)
const entries = createMemo(() => active().map((item) => activeKeyEntry(props.api, item)))
const groups = createMemo(() => grouped(entries()))
const tabsVisible = createMemo(() => !pendingMode() && groups().length > 0)
const headerVisible = createMemo(() => tabsVisible() || pendingMode())
const footerVisible = createMemo(() => !pendingMode())
const rows = createMemo(() =>
Math.max(
1,
panelHeight() -
PANEL_TOP_PADDING -
(headerVisible() ? 1 : 0) -
(tabsVisible() ? TAB_CONTENT_GAP : 0) -
(footerVisible() ? FOOTER_MARGIN + FOOTER_HEIGHT : 0),
),
)
const pageSize = createMemo(() => rows() * columns())
const currentGroup = createMemo(() => {
const group = activeGroup()
return groups().find((item) => item.label === group) ?? groups()[0]
})
const activeEntries = createMemo(() => currentGroup()?.entries ?? [])
const items = createMemo<Item[]>(() => {
if (!pendingMode()) return activeEntries()
return groups().flatMap((group) => [{ type: "group", label: group.label } satisfies GroupHeader, ...group.entries])
})
const maxOffset = createMemo(() => Math.max(0, items().length - pageSize()))
const shown = createMemo(() => {
const columnsItems: Item[][] = []
let index = offset()
for (let column = 0; column < columns() && index < items().length; column++) {
const list: Item[] = []
while (list.length < rows() && index < items().length) {
list.push(items()[index]!)
index += 1
}
columnsItems.push(list)
}
return columnsItems
})
const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index))
const trigger = commandShortcut(props.api, command.toggle)
const modeTrigger = commandShortcut(props.api, command.toggleLayout)
const upActive = createMemo(() => offset() > 0)
const downActive = createMemo(() => offset() < maxOffset())
const scrollable = createMemo(() => maxOffset() > 0)
const headerItems = createMemo<HeaderItem[]>(() => [
...(tabsVisible() ? groups().map((group) => ({ type: "tab" as const, group })) : []),
...(scrollable() ? [{ type: "scroll" as const }] : []),
])
const tabGap = createMemo(() => {
const itemCount = headerItems().length
if (itemCount <= 1) return 0
const itemWidth = headerItems().reduce(
(sum, item) => sum + (item.type === "tab" ? item.group.label.length + 2 : 3),
0,
)
return Math.max(MIN_TAB_GAP, Math.min(TAB_GAP, Math.floor((contentWidth() - itemWidth) / (itemCount - 1))))
})
const nextMode = createMemo(() => (props.mode() === "dock" ? "overlay" : "dock"))
const look = createMemo(() => skin(props.api))
const columnWidth = createMemo(() =>
Math.max(1, Math.min(MAX_COLUMN_WIDTH, Math.floor((contentWidth() - (columns() - 1) * COLUMN_GAP) / columns()))),
)
const clamp = (value: number) => Math.max(0, Math.min(maxOffset(), value))
const scroll = (delta: number) => setOffset((value) => clamp(value + delta))
const moveGroup = (delta: number) => {
if (pendingMode()) return
const list = groups()
if (!list.length) return
const index = Math.max(
0,
list.findIndex((item) => item.label === currentGroup()?.label),
)
setActiveGroup(list[(index + delta + list.length) % list.length]!.label)
setOffset(0)
}
Keymap.createLayer(() => ({
priority: 1000,
enabled: visible(),
commands: [
{
id: command.groupPrevious,
bind: false,
title: "Previous key binding group",
description: "Show the previous which-key group",
group: "System",
run() {
moveGroup(-1)
},
},
{
id: command.groupNext,
bind: false,
title: "Next key binding group",
description: "Show the next which-key group",
group: "System",
run() {
moveGroup(1)
},
},
{
id: command.scrollUp,
bind: false,
title: "Scroll key bindings up",
description: "Scroll the which-key panel up",
group: "System",
run() {
scroll(-columns())
},
},
{
id: command.scrollDown,
bind: false,
title: "Scroll key bindings down",
description: "Scroll the which-key panel down",
group: "System",
run() {
scroll(columns())
},
},
{
id: command.pageUp,
bind: false,
title: "Page key bindings up",
description: "Page the which-key panel up",
group: "System",
run() {
scroll(-pageSize())
},
},
{
id: command.pageDown,
bind: false,
title: "Page key bindings down",
description: "Page the which-key panel down",
group: "System",
run() {
scroll(pageSize())
},
},
{
id: command.home,
bind: false,
title: "First key binding",
description: "Jump to the first which-key binding",
group: "System",
run() {
setOffset(0)
},
},
{
id: command.end,
bind: false,
title: "Last key binding",
description: "Jump to the last which-key binding",
group: "System",
run() {
setOffset(maxOffset())
},
},
],
bindings: pendingMode() ? scrollCommands : panelCommands,
}))
createEffect(() => {
if (pendingMode()) return
const group = currentGroup()
if (group?.label === activeGroup()) return
setActiveGroup(group?.label)
})
createEffect(() => {
if (pendingMode()) return
activeGroup()
setOffset(0)
})
createEffect(() => {
if (!visible()) setOffset(0)
})
createEffect(() => {
pending()
setOffset(0)
})
createEffect(() => {
setOffset((value) => clamp(value))
})
return (
<Show when={visible()}>
<box
position={props.layout === "overlay" ? "absolute" : "relative"}
zIndex={3500}
left={left}
bottom={props.layout === "overlay" ? 0 : undefined}
width={dimensions().width}
height={panelHeight()}
backgroundColor={look().panel}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
flexShrink={0}
flexDirection="column"
>
<Show when={headerVisible()}>
<box width="100%" flexDirection="row" justifyContent="center" gap={tabGap()} flexShrink={0}>
<For each={headerItems()}>
{(item) => (
<Show
when={item.type === "tab" ? item.group : undefined}
fallback={
<box flexShrink={0}>
<text wrapMode="none">
<span style={{ fg: upActive() ? look().text : look().muted }}></span>
<span style={{ fg: look().muted }}> </span>
<span style={{ fg: downActive() ? look().text : look().muted }}></span>
</text>
</box>
}
>
{(group) => {
const selected = createMemo(() => currentGroup()?.label === group().label)
return (
<box
paddingLeft={1}
paddingRight={1}
flexShrink={0}
backgroundColor={selected() ? look().tab : undefined}
onMouseDown={() => {
setActiveGroup(group().label)
setOffset(0)
}}
>
<text
fg={selected() ? look().tabText : look().muted}
attributes={selected() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
{group().label}
</text>
</box>
)
}}
</Show>
)}
</For>
</box>
</Show>
<Show when={tabsVisible()}>
<box height={TAB_CONTENT_GAP} flexShrink={0} />
</Show>
<box height={rows()} flexShrink={0} flexDirection="column">
<Show when={shown().length > 0} fallback={<text fg={look().muted}>No reachable bindings</text>}>
<For each={rowIndexes()}>
{(row) => (
<box width="100%" flexDirection="row" justifyContent="center" gap={COLUMN_GAP}>
<For each={shown()}>
{(column) => {
const item = createMemo(() => column[row])
const entry = createMemo(() => {
const value = item()
if (value?.type !== "entry") return undefined
return value
})
return (
<box width={columnWidth()} flexDirection="row" gap={1} justifyContent="space-between">
<Show when={item()}>
{(value) => (
<Show
when={entry()}
fallback={
<text fg={look().accent} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
{value().label}
</text>
}
>
{(binding) => (
<>
<box flexGrow={1} minWidth={0}>
<text
fg={binding().continues ? look().accent : look().muted}
wrapMode="none"
truncate
>
{binding().label}
</text>
</box>
<box flexShrink={0}>
<text fg={look().text} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
{binding().key}
</text>
</box>
</>
)}
</Show>
)}
</Show>
</box>
)
}}
</For>
</box>
)}
</For>
</Show>
</box>
<Show when={footerVisible()}>
<box height={FOOTER_MARGIN} flexShrink={0} />
<box width="100%" flexDirection="row" justifyContent="space-between" flexShrink={0}>
<box>
<text fg={look().text} wrapMode="none">
toggle <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
</text>
</box>
<box>
<text fg={look().text} wrapMode="none">
{nextMode()} <span style={{ fg: look().subtle }}>{modeTrigger() || command.toggleLayout}</span>
</text>
</box>
</box>
</Show>
</box>
</Show>
)
}
const tui: TuiPlugin = async (api) => {
const [pinned, setPinned] = createSignal(false)
const [mode, setMode] = createSignal(layout("dock"))
const [pendingPreview, setPendingPreview] = createSignal(false)
api.keymap.registerLayer({
priority: LAYER_PRIORITY,
commands: [
{
name: command.toggle,
title: "Show key bindings",
desc: "Toggle which-key overlay",
category: "System",
run() {
setPinned((value) => !value)
},
},
{
name: command.toggleLayout,
title: "Toggle key bindings layout",
desc: "Switch which-key between dock and overlay mode",
category: "System",
run() {
setMode((value) => {
const next = value === "dock" ? "overlay" : "dock"
return next
})
},
},
{
name: command.togglePending,
title: "Toggle pending key preview",
desc: "Automatically show which-key for pending key sequences in overlay mode",
category: "System",
run() {
setPendingPreview((value) => {
return !value
})
},
},
],
bindings: toggleCommands.flatMap((command) => api.tuiConfig.keybinds.get(command)),
})
api.slots.register({
order: 200,
slots: {
home_bottom() {
return <HomeHint api={api} />
},
app() {
return (
<Show when={mode() === "overlay"}>
<WhichKeyPanel api={api} layout="overlay" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
</Show>
)
},
app_bottom() {
return (
<Show when={mode() === "dock"}>
<WhichKeyPanel api={api} layout="dock" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
</Show>
)
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id: "which-key",
enabled: false,
tui,
}
export default plugin
+23 -12
View File
@@ -767,19 +767,23 @@ export function RunSubagentSelectBody(props: {
onRows?: (rows: number) => void
mono?: boolean
}) {
const [active, setActive] = createSignal(true)
const entries = createMemo<SubagentEntry[]>(() =>
props.tabs().map((item) => {
const title = item.description || item.title || item.label
return {
category: "",
display: title,
description: title === item.label ? undefined : item.label,
footer: subagentStatusLabel(item.status),
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
sessionID: item.sessionID,
current: props.current() === item.sessionID,
}
}),
props
.tabs()
.filter((item) => (active() ? item.status === "running" : item.status !== "running"))
.map((item) => {
const title = item.description || item.title || item.label
return {
category: "",
display: title,
description: title === item.label ? undefined : item.label,
footer: subagentStatusLabel(item.status),
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
sessionID: item.sessionID,
current: props.current() === item.sessionID,
}
}),
)
const controller = createSearchablePanelController({
entries,
@@ -788,6 +792,12 @@ export function RunSubagentSelectBody(props: {
onSelect: (item) => props.onSelect(item.sessionID),
isCurrent: (item) => item.current,
closeOnFirstUp: true,
onKey(event) {
if (event.name.toLowerCase() !== "tab") return false
event.preventDefault()
setActive((value) => !value)
return true
},
onRows: props.onRows,
})
@@ -801,6 +811,7 @@ export function RunSubagentSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
hint={`tab show ${active() ? "inactive" : "active"}`}
mono={props.mono}
>
<RunFooterMenu
-40
View File
@@ -1,40 +0,0 @@
import type { TuiRouteDefinition } from "@opencode-ai/plugin/v1/tui"
import { createSignal } from "solid-js"
type RouteEntry = {
key: symbol
render: TuiRouteDefinition["render"]
}
export type RouteMap = Map<string, RouteEntry[]>
export function createPluginRoutes() {
const routes: RouteMap = new Map()
const [revision, setRevision] = createSignal(0)
return {
register(list: TuiRouteDefinition[]) {
const key = Symbol()
list.forEach((item) => routes.set(item.name, [...(routes.get(item.name) ?? []), { key, render: item.render }]))
setRevision((value) => value + 1)
return () => {
list.forEach((item) => {
const next = routes.get(item.name)?.filter((entry) => entry.key !== key) ?? []
if (next.length) {
routes.set(item.name, next)
return
}
routes.delete(item.name)
})
setRevision((value) => value + 1)
}
},
get(name: string) {
revision()
return routes.get(name)?.at(-1)?.render
},
}
}
export type PluginRoutes = ReturnType<typeof createPluginRoutes>
+2
View File
@@ -5,6 +5,7 @@ import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
import DiffViewer from "../feature-plugins/system/diff-viewer"
import Notifications from "../feature-plugins/system/notifications"
import Plugins from "../feature-plugins/system/plugins"
import Scrap from "../feature-plugins/system/scrap"
export const builtins = [
@@ -14,6 +15,7 @@ export const builtins = [
SidebarLsp,
SidebarFooter,
Notifications,
Plugins,
Scrap,
DiffViewer,
]
-108
View File
@@ -1,108 +0,0 @@
// Legacy `api.command` bridge for v1 plugins; remove in v2.
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
import { TuiKeybind } from "../config/keybind"
import type { DialogContext } from "../ui/dialog"
const COMMAND_PALETTE_SHOW = "command.palette.show"
const warned = new Set<string>()
type Warn = (api: string, replacement: string) => void
type LegacyDialog = TuiPluginApi["ui"]["dialog"]
type CommandShimDialog = DialogContext | LegacyDialog
type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
function warnCommandShim(api: string, replacement: string) {
// Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
}
function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
if (!("stack" in dialog)) return dialog
return {
replace(render, onClose) {
dialog.replace(render, onClose)
},
clear() {
dialog.clear()
},
setSize(size) {
dialog.setSize(size)
},
get size() {
return dialog.size
},
get depth() {
return dialog.stack.length
},
get open() {
return dialog.stack.length > 0
},
}
}
function warnOnce(api: string, replacement: string, warn: Warn) {
if (warned.has(api)) return
warned.add(api)
warn(api, replacement)
}
function toCommand(item: TuiCommand, dialog: LegacyDialog) {
return {
namespace: "palette",
name: item.value,
title: item.title,
desc: item.description,
category: item.category,
suggested: item.suggested,
hidden: item.hidden,
enabled: item.enabled,
slash: item.slash,
run() {
return item.onSelect?.(dialog)
},
}
}
function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
return commands.flatMap((item) =>
item.keybind
? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
? keybinds
.get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
.map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
: [
{
key: item.keybind,
cmd: item.value,
desc: item.title,
},
]
: [],
)
}
export function createCommandShim(
keymap: TuiPluginApi["keymap"],
dialog: CommandShimDialog,
keybinds: LegacyKeybinds,
): TuiPluginApi["command"] {
const shimDialog = createCommandShimDialog(dialog)
return {
register(cb) {
warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
const commands = cb()
return keymap.registerLayer({
commands: commands.map((item) => toCommand(item, shimDialog)),
bindings: toBindings(commands, keybinds),
})
},
trigger(value) {
warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
keymap.dispatchCommand(value)
},
show() {
warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
},
}
}
+20 -28
View File
@@ -48,9 +48,16 @@ type State =
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly status: "failed"; readonly error: string }
type RegisteredPlugin = {
readonly id: string
readonly source: "builtin" | "external"
readonly active: boolean
}
type Value = {
readonly ready: () => boolean
readonly list: () => ReadonlyArray<State>
readonly registered: () => ReadonlyArray<RegisteredPlugin>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
readonly activate: (id: string) => Promise<boolean>
@@ -59,8 +66,8 @@ type Value = {
type Dispose = () => Promise<void>
type Registration = {
target: string
plugin: Plugin.Definition
source: RegisteredPlugin["source"]
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@@ -107,28 +114,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
})
const owned: Dispose[] = []
let context: Context
let activeDialog: symbol | undefined
const dialogApi: Dialog = {
show(render, onClose) {
const token = Symbol()
let closed = false
activeDialog = token
dialog.replace(
() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>,
() => {
if (closed) return
closed = true
if (activeDialog === token) activeDialog = undefined
onClose?.()
},
)
return () => {
if (closed || activeDialog !== token) return
dialog.clear()
}
dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
},
set(options) {
if (!activeDialog) return
dialog.setSize(options.size ?? "medium")
dialog.setCentered(options.centered ?? false)
},
@@ -224,7 +214,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
toast.show({ ...options, variant: options.variant ?? "info" })
},
}
owned.push(async () => dialogApi.clear())
context = {
options: item.options ?? {},
get location() {
@@ -365,8 +354,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
for (const plugin of builtins) {
setStore("registrations", plugin.id, {
target: plugin.id,
plugin,
source: "builtin",
active: false,
routes: {},
slots: {},
@@ -410,23 +399,24 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
continue
}
const item = { target, plugin, options }
setStore("registrations", item.plugin.id, {
...item,
setStore("registrations", plugin.id, {
plugin,
source: "external",
options,
active: false,
routes: {},
slots: {},
cleanups: [],
})
const error = await activate(item.plugin.id).then(
const error = await activate(plugin.id).then(
() => undefined,
(error) => (error instanceof Error ? error.message : String(error)),
)
setStore("states", (items) => [
...items.filter((state) => state.target !== item.target && (!("id" in state) || state.id !== item.plugin.id)),
...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)),
error
? { target: item.target, status: "failed", error }
: { target: item.target, id: item.plugin.id, status: "active" },
? { target, status: "failed", error }
: { target, id: plugin.id, status: "active" },
])
}
}
@@ -460,6 +450,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
value={{
ready: () => store.ready,
list: () => store.states,
registered: () =>
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slot: (name) =>
Object.values(store.registrations).flatMap((registration) =>
-79
View File
@@ -1,79 +0,0 @@
import type {
TuiPluginApi,
TuiPluginInstallOptions,
TuiPluginInstallResult,
TuiPluginStatus,
} from "@opencode-ai/plugin/v1/tui"
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
import { createPluginRoutes } from "./api"
import { createSlots, type HostSlots } from "./slots"
export function createPluginRuntime() {
const [commands, setCommands] = createSignal<PluginRuntimeCommands>(emptyCommands)
const [status, setStatus] = createSignal<ReadonlyArray<TuiPluginStatus>>([])
const slots = createSlots()
return {
Slot: slots.Slot,
routes: createPluginRoutes(),
commands,
status,
update(input: { commands?: PluginRuntimeCommands; status?: ReadonlyArray<TuiPluginStatus> }) {
if (input.commands) setCommands(input.commands)
if (input.status) setStatus(input.status)
},
clear() {
setCommands(emptyCommands)
setStatus([])
slots.clear()
},
setupSlots(api: TuiPluginApi): HostSlots {
return slots.setup(api)
},
}
}
export type PluginRuntimeCommands = {
activate: (id: string) => Promise<boolean>
deactivate: (id: string) => Promise<boolean>
add: (spec: string) => Promise<boolean>
install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
}
const emptyCommands: PluginRuntimeCommands = {
async activate() {
return false
},
async deactivate() {
return false
},
async add() {
return false
},
async install() {
return { ok: false, message: "Plugin runtime is not available." }
},
}
export type PluginRuntime = ReturnType<typeof createPluginRuntime>
export type TuiPluginHost = {
start(input: {
api: TuiPluginApi
runtime: PluginRuntime
dispose?: () => void
}): Promise<void>
dispose(): Promise<void>
}
const Context = createContext<PluginRuntime>()
export function PluginRuntimeProvider(props: ParentProps<{ value: PluginRuntime }>): JSX.Element {
return <Context.Provider value={props.value}>{props.children}</Context.Provider>
}
export function usePluginRuntime() {
const runtime = useContext(Context)
if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider")
return runtime
}
-65
View File
@@ -1,65 +0,0 @@
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/v1/tui"
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
import { createSignal } from "solid-js"
import { isRecord } from "../util/record"
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
type SlotView = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
export type HostPluginApi = TuiPluginApi
export type HostSlots = {
register: {
(plugin: HostSlotPlugin): () => void
<Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
}
dispose: () => void
}
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
if (!isRecord(value)) return false
if (typeof value.id !== "string") return false
return isRecord(value.slots)
}
export function createSlots() {
const empty: SlotView = (props) => props.children ?? null
const [view, setView] = createSignal<SlotView>(empty)
const Slot: SlotView = (props) => view()(props)
return {
Slot,
setup(api: HostPluginApi): HostSlots {
const registry = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
api.renderer,
{ theme: api.theme },
{
onPluginError(event) {
console.error("[tui.slot] plugin error", {
plugin: event.pluginId,
slot: event.slot,
phase: event.phase,
source: event.source,
message: event.error.message,
})
},
},
)
const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(registry)
setView(() => (props: TuiSlotProps<string>) => slot(props))
return {
register(plugin: HostSlotPlugin) {
if (!isHostSlotPlugin(plugin)) return () => {}
return registry.register(plugin)
},
dispose() {
setView(() => empty)
},
}
},
clear() {
setView(() => empty)
},
}
}
+2 -13
View File
@@ -5,7 +5,6 @@ import { useArgs } from "../context/args"
import { useRouteData } from "../context/route"
import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { usePluginRuntime } from "../plugin/runtime"
import { useEditorContext } from "../context/editor"
import { useData } from "../context/data"
import { useLocation } from "../context/location"
@@ -19,7 +18,6 @@ const placeholder = {
}
export function Home() {
const pluginRuntime = usePluginRuntime()
const route = useRouteData("home")
const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>()
@@ -70,20 +68,11 @@ export function Home() {
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<pluginRuntime.Slot name="home_logo" mode="replace">
<Logo />
</pluginRuntime.Slot>
<Logo />
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
<Prompt
ref={bind}
right={<pluginRuntime.Slot name="home_prompt_right" />}
placeholders={placeholder}
disabled={forms().length > 0}
/>
</pluginRuntime.Slot>
<Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
</box>
<box flexGrow={1} minHeight={0} />
</box>
@@ -27,6 +27,7 @@ export function SubagentsTab(props: { sessionID: string }) {
const shortcuts = Keymap.useShortcuts()
const session = createMemo(() => data.session.get(props.sessionID))
const [store, setStore] = createStore({ selected: 0, active: true })
const entries = createMemo<SubagentEntry[]>(() => {
const current = session()
@@ -72,10 +73,9 @@ export function SubagentsTab(props: { sessionID: string }) {
}
}
return result
return result.filter((entry) => (store.active ? entry.status === "running" : entry.status !== "running"))
})
const [store, setStore] = createStore({ selected: 0 })
let selectedSessionID = ""
let wasActive = false
let scroll: ScrollBoxRenderable | undefined
@@ -90,7 +90,7 @@ export function SubagentsTab(props: { sessionID: string }) {
if (!active) {
if (wasActive) {
selectedSessionID = ""
setStore("selected", 0)
setStore({ selected: 0, active: true })
}
wasActive = false
return
@@ -140,8 +140,15 @@ export function SubagentsTab(props: { sessionID: string }) {
label: "Subagents",
hints: () => {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return []
return [{ label: "interrupt", shortcut: shortcuts.get("composer.subagent.interrupt") ?? "" }]
return [
...(entry?.status === "running"
? [{ label: "interrupt", shortcut: shortcuts.get("composer.subagent.interrupt") ?? "" }]
: []),
{
label: `show ${store.active ? "inactive" : "active"}`,
shortcut: shortcuts.get("composer.subagent.toggle-activity") ?? "",
},
]
},
onClose: () => {
const parentID = session()?.parentID
@@ -189,6 +196,16 @@ export function SubagentsTab(props: { sessionID: string }) {
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
},
},
{
id: "composer.subagent.toggle-activity",
title: "Toggle active subagents",
group: "Composer",
bind: "ctrl+a",
run() {
setStore({ selected: 0, active: !store.active })
scroll?.scrollTo(0)
},
},
{
id: "composer.subagent.interrupt",
title: "Interrupt subagent",
@@ -206,7 +223,10 @@ export function SubagentsTab(props: { sessionID: string }) {
return (
<Show when={composer.active("subagents")}>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={theme.text.subdued}> No subagents</text>}>
<Show
when={entries().length > 0}
fallback={<text fg={theme.text.subdued}> No {store.active ? "active" : "inactive"} subagents</text>}
>
<For each={entries()}>
{(entry, index) => {
const active = createMemo(() => index() === selected())
+7 -20
View File
@@ -76,7 +76,6 @@ import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { usePluginRuntime } from "../../plugin/runtime"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
@@ -124,7 +123,6 @@ export function Session() {
await mkdir(path.dirname(file), { recursive: true })
await writeFile(file, content)
}
const pluginRuntime = usePluginRuntime()
const route = useRouteData("session")
const { navigate } = useRoute()
const data = useData()
@@ -986,26 +984,15 @@ export function Session() {
</Show>
</Match>
<Match when={!disabled()}>
<pluginRuntime.Slot
name="session_prompt"
mode="replace"
session_id={route.sessionID}
<Prompt
visible={true}
disabled={false}
on_submit={toBottom}
ref={bind}
>
<Prompt
visible={true}
ref={bind}
disabled={false}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
right={<pluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
/>
</pluginRuntime.Slot>
disabled={false}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
/>
</Match>
</Switch>
</box>
+8 -17
View File
@@ -2,13 +2,11 @@ import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useThemes } from "../../context/theme"
import { useConfig } from "../../config"
import { usePluginRuntime } from "../../plugin/runtime"
import { PluginSlot } from "../../plugin/context"
import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const pluginRuntime = usePluginRuntime()
const data = useData()
const theme = useThemes().contextual("elevated")
const config = useConfig().data
@@ -38,21 +36,14 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
}}
>
<box flexShrink={0} gap={1} paddingRight={1}>
<pluginRuntime.Slot
name="sidebar_title"
mode="single_winner"
session_id={props.sessionID}
title={session()!.title}
>
<box paddingRight={1}>
<text fg={theme.text.default}>
<b>{session()!.title}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
</Show>
</box>
</pluginRuntime.Slot>
<box paddingRight={1}>
<text fg={theme.text.default}>
<b>{session()!.title}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
</Show>
</box>
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
</box>
</scrollbox>
+38 -1
View File
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test"
import { completionPulseOpacity } from "../../src/component/tab-pulse"
import { RGBA } from "@opentui/core"
import { blendTabPulseColor, completionPulseOpacity, unreadGlowIntensity } from "../../src/component/tab-pulse"
import { tint } from "../../src/theme/color"
test("completion pulse rises quickly and fades over the remaining duration", () => {
expect(completionPulseOpacity(0)).toBe(0)
@@ -8,3 +10,38 @@ test("completion pulse rises quickly and fades over the remaining duration", ()
expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5)
expect(completionPulseOpacity(1)).toBe(0)
})
test("unread glow peaks behind the tab number and fades to the normal background", () => {
const intensities = Array.from({ length: 22 }, (_, index) => unreadGlowIntensity(index, 22))
expect(intensities[0]).toBe(1)
expect(intensities[1]).toBe(1)
expect(intensities[2]).toBeLessThan(1)
expect(intensities.slice(1)).toEqual(intensities.slice(1).sort((a, b) => b - a))
expect(intensities[13]).toBe(0)
expect(intensities.at(-1)).toBe(0)
})
test("unread glow reaches the normal background on compact tabs", () => {
expect(unreadGlowIntensity(0, 8)).toBe(1)
expect(unreadGlowIntensity(7, 8)).toBe(0)
})
test("reuses a color while preserving the original glow and pulse blend stages", () => {
const output = RGBA.fromInts(0, 0, 0)
const background = RGBA.fromHex("#1a1b26")
const glowColor = RGBA.fromHex("#82aaff")
const runningColor = RGBA.fromHex("#c8d3f5")
const completionColor = RGBA.fromHex("#ff9e64")
for (const glow of [0, 0.08, 0.16]) {
for (const running of [0, 0.01, 0.07, 0.14]) {
for (const completion of [0, 0.03, 0.09, 0.18]) {
blendTabPulseColor(output, background, glowColor, runningColor, completionColor, glow, running, completion)
expect(output.buffer).toEqual(
tint(tint(tint(background, glowColor, glow), runningColor, running), completionColor, completion).buffer,
)
}
}
}
})
+2 -2
View File
@@ -17,8 +17,8 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ session: { tabs: true } })).toEqual({ session: { tabs: true } })
expect(() => decode({ session: { tabs: "on" } })).toThrow()
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
})
test("resolves nested config and keybind defaults", () => {
+14 -4
View File
@@ -820,7 +820,7 @@ test("direct command panel keeps completed subagents available", async () => {
}
})
test("direct subagent panel renders active subagents", async () => {
test("direct subagent panel toggles between active and inactive subagents", async () => {
const [tabs] = createSignal([
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
subagent({ sessionID: "s-2", label: "General", description: "Write migration plan", status: "completed" }),
@@ -856,12 +856,22 @@ test("direct subagent panel renders active subagents", async () => {
expect(frame).toContain("Select subagent")
expect(frame).toContain("Inspect auth flow")
expect(frame).toContain("Write migration plan")
expect(frame).toContain("done")
expect(frame).not.toContain("Write migration plan")
expect(frame).not.toContain("done")
expect(frame).toContain("tab show inactive")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
expectPaletteList(list, 0)
expect(rows).toBe(8)
expect(rows).toBe(7)
app.mockInput.pressKey("TAB")
await app.renderOnce()
const inactive = app.captureCharFrame()
expect(inactive).not.toContain("Inspect auth flow")
expect(inactive).toContain("Write migration plan")
expect(inactive).toContain("done")
expect(inactive).toContain("tab show active")
} finally {
app.renderer.destroy()
}
-50
View File
@@ -1,50 +0,0 @@
import { expect, test } from "bun:test"
import { createPluginRuntime } from "../../src/plugin/runtime"
test("routes use the latest registration and restore previous registrations", () => {
const runtime = createPluginRuntime()
const first = () => "first"
const second = () => "second"
runtime.routes.register([{ name: "demo", render: first }])
const dispose = runtime.routes.register([{ name: "demo", render: second }])
expect(runtime.routes.get("demo")).toBe(second)
dispose()
expect(runtime.routes.get("demo")).toBe(first)
})
test("facade publishes and clears presentation state", async () => {
const runtime = createPluginRuntime()
runtime.update({
commands: {
async activate() {
return true
},
async deactivate() {
return true
},
async add() {
return true
},
async install() {
return { ok: true, dir: "/tmp", tui: true }
},
},
status: [
{
id: "demo",
source: "internal",
spec: "demo",
target: "demo",
enabled: true,
active: true,
},
],
})
expect(await runtime.commands().activate("demo")).toBe(true)
expect(runtime.status()).toHaveLength(1)
runtime.clear()
expect(await runtime.commands().activate("demo")).toBe(false)
expect(runtime.status()).toEqual([])
})
-38
View File
@@ -1,38 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
type Slots = {
prompt: {}
}
test("replace slot mounts plugin content once", async () => {
let mounts = 0
const Probe = () => {
onMount(() => {
mounts += 1
})
return <box />
}
const App = () => {
const registry = createSolidSlotRegistry<Slots>(useRenderer(), {})
const Slot = createSlot(registry)
registry.register({ id: "plugin", slots: { prompt: () => <Probe /> } })
return (
<Slot name="prompt" mode="replace">
<box />
</Slot>
)
}
const app = await testRender(() => <App />)
try {
expect(mounts).toBe(1)
} finally {
app.renderer.destroy()
}
})
+7 -1
View File
@@ -7,6 +7,11 @@ import { Tabs, TabItem } from "@astrojs/starlight/components"
import config from "../../../config.mjs"
export const console = config.console
:::note
OpenCode 1 installs and runs as `opencode`. OpenCode 2 installs separately as `opencode2`, so you can keep both versions
installed and run them side by side. See the [OpenCode 2 docs](https://opencode.ai/v2/docs/) to install V2.
:::
[**OpenCode**](/) is an open source AI coding agent. It's available as a terminal-based interface, desktop app, or IDE extension.
![OpenCode TUI with the opencode theme](../../assets/lander/screenshot.png)
@@ -31,7 +36,8 @@ To use OpenCode in your terminal, you'll need:
## Install
The easiest way to install OpenCode is through the install script.
The easiest way to install OpenCode 1 is through the install script. These installation methods provide the `opencode`
binary and do not replace an `opencode2` installation.
```bash
curl -fsSL https://opencode.ai/install | bash
+7 -4
View File
@@ -8,6 +8,11 @@ description: "Get started with OpenCode."
wipe your data, things may break, and APIs, configuration, and plugin APIs may change.
</Callout>
<Callout type="note">
OpenCode 2 installs and runs as `opencode2`. It does not replace OpenCode 1's `opencode` binary, so you can keep both
versions installed and run them side by side.
</Callout>
## Install
<Callout type="note">The curl install script is not available in beta.</Callout>
@@ -37,10 +42,8 @@ You can also install it with the following package managers.
</Tab>
</Tabs>
The package uses a trusted postinstall script to select the native binary for your platform. The Bun and pnpm commands
above explicitly allow that script to run.
<Callout type="note">During beta, the binary is called `opencode2`.</Callout>
The package uses a trusted postinstall script to select the native `opencode2` binary for your platform. The Bun and pnpm
commands above explicitly allow that script to run.
### Homebrew
+5 -3
View File
@@ -3,6 +3,11 @@ title: "Migrate from V1"
description: "Move from OpenCode V1 to the OpenCode 2.0 beta."
---
<Callout type="note">
OpenCode 1 and OpenCode 2 can be installed side by side. V1 runs as `opencode`, while V2 installs and runs separately as
`opencode2`.
</Callout>
## Breaking changes
V2 has three intentional breaking changes:
@@ -27,9 +32,6 @@ an expected migration requirement.
continue to change.
</Callout>
During the beta, OpenCode V1 and V2 use different executable names. You can keep using `opencode` for V1 while trying V2
with `opencode2`.
## Install the beta
Install the beta from the `next` distribution tag: