Compare commits

..
42 changed files with 2265 additions and 763 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ import { CodeModeCatalog } from "./catalog.js"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools callable inside \`execute\`. It does not affect tools exposed directly outside Code Mode.${hasMoreTools ? `
## Search
+6 -2
View File
@@ -18,7 +18,9 @@ const RemoteModel = Schema.Struct({
Schema.Struct({
batch_size: Schema.Number,
default: Schema.Struct({
cache_price: Schema.Number,
// API version 2026-08-01 renamed cache_price to cache_read_price.
cache_price: Schema.optional(Schema.Number),
cache_read_price: Schema.optional(Schema.Number),
input_price: Schema.Number,
output_price: Schema.Number,
}),
@@ -166,7 +168,9 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion),
output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion),
cache: {
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
read: Money.USDPerMillionTokens.make(
(prices?.default.cache_read_price ?? prices?.default.cache_price ?? 0) * usdPerMillion,
),
write: Money.USDPerMillionTokens.zero,
},
},
+141
View File
@@ -0,0 +1,141 @@
export * as ModalModels from "./models.js"
import { Money } from "@opencode-ai/schema/money"
import { Option, Schema } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
const providerID = Provider.ID.make("modal")
const ReasoningOption = Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
})
const RemoteModel = Schema.Struct({
id: Schema.String,
base_model_id: Schema.optional(Schema.String),
hugging_face_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
input_modalities: Schema.optional(Schema.Array(Schema.String)),
output_modalities: Schema.optional(Schema.Array(Schema.String)),
context_length: Schema.optional(Schema.Number),
max_output_length: Schema.optional(Schema.Number),
pricing: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
}),
),
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
supported_features: Schema.optional(Schema.Array(Schema.String)),
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
})
const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeResponse = Schema.decodeUnknownSync(Response)
const decodeModel = Schema.decodeUnknownOption(RemoteModel)
type RemoteModel = typeof RemoteModel.Type
export async function get(baseURL: string, apiKey: string, existing: readonly Model.Info[]) {
const response = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(3_000),
})
if (!response.ok) throw new Error(`Failed to fetch Modal models: ${response.status}`)
// Decode each item tolerantly so one malformed entry cannot discard the
// whole inventory. A malformed envelope still fails the fetch.
const remote = decodeResponse(await response.json()).data.flatMap((raw) => {
const model = Option.getOrUndefined(decodeModel(raw))
return model ? [model] : []
})
const templates = new Map(existing.map((model) => [model.id, model]))
const result = new Map<Model.ID, Model.Info>()
for (const item of remote) {
const template = templates.get(Model.ID.make(item.base_model_id ?? item.hugging_face_id ?? item.id))
const id = Model.ID.make(item.id)
result.set(id, build(id, item, baseURL, template))
}
return result
}
function price(value: string | number | undefined, fallback: Money.USDPerMillionTokens) {
if (value === undefined) return fallback
const parsed = Number(value) * 1_000_000
return Number.isFinite(parsed) ? Money.USDPerMillionTokens.make(parsed) : fallback
}
function limit(value: number | undefined, fallback: number) {
const parsed = value === undefined ? fallback : Math.trunc(value)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
}
function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Model.Info) {
const cost = previous?.cost[0]
const input = previous?.limit.input
return Model.Info.make({
...Model.Info.default(providerID, id),
id,
modelID: Model.ID.make(remote.id),
providerID,
name: remote.name ?? previous?.name ?? remote.id,
family: previous?.family,
compatibility:
remote.interleaved === undefined
? previous?.compatibility
: (Model.compatibility(remote.interleaved) ?? previous?.compatibility),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: Provider.mergeOverlay(previous?.settings, { baseURL }),
headers: previous?.headers,
body: previous?.body,
capabilities: {
tools: remote.supported_features?.includes("tools") ?? previous?.capabilities.tools ?? true,
input: remote.input_modalities ?? previous?.capabilities.input ?? ["text"],
output: remote.output_modalities ?? previous?.capabilities.output ?? ["text"],
},
variants: remote.reasoning_options === undefined ? (previous?.variants ?? []) : variants(remote),
time: previous?.time ?? { released: 0 },
cost: [
{
input: price(remote.pricing?.prompt, cost?.input ?? Money.USDPerMillionTokens.zero),
output: price(remote.pricing?.completion, cost?.output ?? Money.USDPerMillionTokens.zero),
cache: {
read: price(remote.pricing?.input_cache_read, cost?.cache.read ?? Money.USDPerMillionTokens.zero),
write: cost?.cache.write ?? Money.USDPerMillionTokens.zero,
},
},
],
status: previous?.status ?? "active",
enabled: previous?.enabled ?? true,
limit: {
context: limit(remote.context_length, previous?.limit.context ?? 0),
...(input === undefined ? {} : { input }),
output: limit(remote.max_output_length, previous?.limit.output ?? 0),
},
})
}
function variants(remote: RemoteModel): Model.Info["variants"] {
const seen = new Map<string, Model.Info["variants"][number]>()
for (const option of remote.reasoning_options ?? []) {
for (const value of option.values) {
const effort = value ?? "none"
if (!seen.has(effort))
seen.set(effort, { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } })
}
}
return [...seen.values()]
}
+2
View File
@@ -15,6 +15,7 @@ import { KiloPlugin } from "./provider/kilo.js"
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
import { LMStudioPlugin } from "./provider/lmstudio.js"
import { MistralPlugin } from "./provider/mistral.js"
import { ModalPlugin } from "./provider/modal.js"
import { NvidiaPlugin } from "./provider/nvidia.js"
import { OllamaPlugin } from "./provider/ollama.js"
import { OpenAIPlugin } from "./provider/openai.js"
@@ -48,6 +49,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
LLMGatewayPlugin,
LMStudioPlugin,
MistralPlugin,
ModalPlugin,
NvidiaPlugin,
OllamaPlugin,
OpencodePlugin,
@@ -13,7 +13,7 @@ import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const clientID = "Ov23li8tweQw6odWQebz"
const apiVersion = "2026-06-01"
const apiVersion = "2026-08-01"
const userApiVersion = "2025-04-01"
const pollingSafetyMargin = 3000
const methodID = Integration.MethodID.make("device")
@@ -0,0 +1,67 @@
import { Effect, Semaphore, Stream } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Bus } from "../../bus.js"
import { Catalog } from "../../catalog.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { ModalModels } from "../../modal/models.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const providerID = Provider.ID.make("modal")
export const ModalPlugin = define({
id: "opencode.provider.modal",
effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const loading = Semaphore.makeUnsafe(1)
const loaded: {
baseURL?: string
models?: Map<Model.ID, Model.Info>
} = {}
const load = Effect.fn("ModalPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("modal")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const apiKey = credential?.type === "key" ? credential.key : process.env.MODAL_PROXY_TOKEN
const provider = yield* catalog.provider.get(providerID)
const baseURL = typeof provider?.settings?.baseURL === "string" ? provider.settings.baseURL : undefined
if (!apiKey || !baseURL) {
loaded.baseURL = undefined
loaded.models = undefined
return
}
loaded.baseURL = baseURL
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === providerID)
loaded.models = yield* Effect.tryPromise({
try: () => ModalModels.get(baseURL, apiKey, existing),
catch: (cause) => cause,
}).pipe(
Effect.catch((cause) => Effect.logWarning("failed to sync Modal models", { cause }).pipe(Effect.as(undefined))),
)
})
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
if (!loaded.models) return
for (const id of item.models.keys()) {
if (!loaded.models.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
}
for (const [id, model] of loaded.models) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("modal")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
}),
} satisfies PluginInternal.InternalPlugin)
@@ -47,7 +47,7 @@ describe("CodeModeInstructions", () => {
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [echo] }))
expect(initialized.text).toContain(
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
"This catalog is the complete set of tools callable inside `execute`. It does not affect tools exposed directly outside Code Mode.",
)
expect(initialized.text).toContain("## Available tools")
expect(initialized.text).not.toContain("## Search")
@@ -118,3 +118,40 @@ test("defensively syncs advertised Copilot models", async () => {
await server.stop(true)
}
})
test("prices cache reads from either token price spelling", async () => {
// API version 2026-08-01 renamed cache_price to cache_read_price; older payloads still use cache_price.
const item = (id: string, prices: Record<string, number>) => ({
model_picker_enabled: true,
id,
name: id,
version: `${id}-2026-08-01`,
supported_endpoints: ["/chat/completions"],
billing: { token_prices: { batch_size: 1_000_000, default: { input_price: 250, output_price: 1500, ...prices } } },
capabilities: {
family: "gpt",
limits: { max_output_tokens: 1000, max_prompt_tokens: 8000 },
supports: { tool_calls: true },
},
})
const server = Bun.serve({
port: 0,
fetch: () =>
Response.json({
data: [
item("renamed", { cache_read_price: 25, cache_write_price: 0 }),
item("legacy", { cache_price: 25 }),
item("unpriced", {}),
],
}),
})
try {
const models = await CopilotModels.get(server.url.origin, {}, [])
expect(models.get(Model.ID.make("renamed"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
expect(models.get(Model.ID.make("legacy"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
expect(models.get(Model.ID.make("unpriced"))?.cost[0]).toMatchObject({ cache: { read: 0 } })
} finally {
await server.stop(true)
}
})
+137
View File
@@ -0,0 +1,137 @@
import { expect, test } from "bun:test"
import { ModalModels } from "@opencode-ai/core/modal/models"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Money } from "@opencode-ai/schema/money"
const providerID = Provider.ID.make("modal")
test("modal plugin is registered", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.modal")
})
function template(id: string, overrides: Partial<Model.Info> = {}) {
return Model.Info.make({
...Model.Info.default(providerID, Model.ID.make(id)),
name: `${id} catalog`,
family: Model.Family.make("catalog-family"),
...overrides,
})
}
test("maps live Modal models onto catalog templates", async () => {
const server = Bun.serve({
port: 0,
fetch: (request) => {
expect(request.headers.get("Authorization")).toBe("Bearer test-key")
expect(new URL(request.url).pathname).toBe("/v1/models")
return Response.json({
data: [
{
id: "live-model",
base_model_id: "base-model",
name: "Live Model",
input_modalities: ["text", "image"],
output_modalities: ["text"],
context_length: 128000,
max_output_length: 8192,
pricing: { prompt: "0.000001", completion: 0.000002, input_cache_read: "0.0000002" },
supported_sampling_parameters: ["temperature"],
supported_features: ["tools", "reasoning"],
reasoning_options: [{ type: "effort", values: ["low", "high", null] }],
interleaved: { field: "reasoning_content" },
},
{
id: "standalone",
context_length: 64000,
},
{ id: "malformed", context_length: "huge" },
],
})
},
})
try {
const base = template("base-model")
const stale = template("stale")
const models = await ModalModels.get(`${server.url.origin}/v1`, "test-key", [base, stale])
expect(models.has(Model.ID.make("stale"))).toBe(false)
expect(models.has(Model.ID.make("malformed"))).toBe(false)
const model = models.get(Model.ID.make("live-model"))
expect(model?.name).toBe("Live Model")
expect(model?.family).toBe(Model.Family.make("catalog-family"))
expect(model?.providerID).toBe(providerID)
expect(model?.modelID).toBe(Model.ID.make("live-model"))
expect(model?.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible"))
expect(model?.settings).toMatchObject({ baseURL: `${server.url.origin}/v1` })
expect(model?.compatibility).toMatchObject({ reasoningField: "reasoning_content" })
expect(model?.capabilities).toMatchObject({ tools: true, input: ["text", "image"], output: ["text"] })
expect(model?.cost[0]?.input).toBe(Money.USDPerMillionTokens.make(1))
expect(model?.cost[0]?.output).toBe(Money.USDPerMillionTokens.make(2))
expect(Number(model?.cost[0]?.cache.read)).toBeCloseTo(0.2, 10)
expect(model?.cost[0]?.cache.write).toBe(Money.USDPerMillionTokens.zero)
expect(model?.limit).toMatchObject({ context: 128000, output: 8192 })
expect(model?.variants.map((variant) => variant.id)).toEqual([
Model.VariantID.make("low"),
Model.VariantID.make("high"),
Model.VariantID.make("none"),
])
expect(model?.variants[0]?.settings).toMatchObject({ reasoningEffort: "low" })
expect(model?.status).toBe("active")
const fresh = models.get(Model.ID.make("standalone"))
expect(fresh?.name).toBe("standalone")
expect(fresh?.family).toBeUndefined()
expect(fresh?.capabilities).toMatchObject({ tools: true, input: ["text"], output: ["text"] })
expect(fresh?.variants).toEqual([])
expect(fresh?.limit).toMatchObject({ context: 64000, output: 0 })
} finally {
await server.stop(true)
}
})
test("keeps template cost and limits when the proxy omits them", async () => {
const server = Bun.serve({
port: 0,
fetch: () =>
Response.json({
data: [{ id: "sparse", hugging_face_id: "hf-base" }],
}),
})
try {
const base = template("hf-base", {
cost: [
{
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(10),
cache: { read: Money.USDPerMillionTokens.make(1), write: Money.USDPerMillionTokens.make(2) },
},
],
limit: { context: 1000, input: 500, output: 250 },
})
const models = await ModalModels.get(server.url.origin, "test-key", [base])
const model = models.get(Model.ID.make("sparse"))
expect(model?.name).toBe("hf-base catalog")
expect(model?.cost[0]).toMatchObject({ input: 5, output: 10, cache: { read: 1, write: 2 } })
expect(model?.limit).toMatchObject({ context: 1000, input: 500, output: 250 })
} finally {
await server.stop(true)
}
})
test("throws on proxy failure so the plugin can fail soft", async () => {
const server = Bun.serve({
port: 0,
fetch: () => new Response("nope", { status: 500 }),
})
try {
await expect(ModalModels.get(server.url.origin, "test-key", [])).rejects.toThrow()
} finally {
await server.stop(true)
}
})
@@ -122,7 +122,7 @@ describe("GithubCopilotPlugin", () => {
expect(requests[0]?.has("x-api-key")).toBe(false)
expect(requests[0]?.get("x-initiator")).toBe("user")
expect(requests[0]?.get("copilot-vision-request")).toBe("true")
expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
expect(requests[0]?.get("x-github-api-version")).toBe("2026-08-01")
expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test")
}),
)
@@ -145,7 +145,7 @@ describe("GithubCopilotPlugin", () => {
expect(event.request.headers.has("x-api-key")).toBe(false)
expect(event.request.headers.get("x-initiator")).toBe("user")
expect(event.request.headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14")
expect(event.request.headers.get("x-github-api-version")).toBe("2026-06-01")
expect(event.request.headers.get("x-github-api-version")).toBe("2026-08-01")
}),
)
+73 -37
View File
@@ -162,6 +162,20 @@ type PromptFooterInput = {
readonly showDetails: boolean
}
export type PanelPresentation = "panel" | "fullscreen"
/** Client-local state of the selected session panel. The host owns its layout and input scope. */
export interface PanelInput {
readonly sessionID: string
readonly width: number
readonly presentation: PanelPresentation
readonly focused: boolean
readonly canSplit: boolean
readonly focus: () => void
readonly close: () => void
readonly toggleFullscreen: () => void
}
/**
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
* may render around, inside, or take over. Paths are absolute and
@@ -180,6 +194,7 @@ export interface SlotMap {
readonly "prompt.footer.status": PromptFooterInput
readonly "prompt.footer.file": PromptFooterInput
readonly "session.composer.top": { readonly sessionID: string }
readonly "session.panel": PanelInput
readonly "sidebar.content": { readonly sessionID: string }
readonly "sidebar.footer": { readonly sessionID: string }
}
@@ -203,45 +218,58 @@ export type SlotPath = keyof SlotMap
* `render` receives the target slot's input, reactively. The `?: never`
* fields make the variants mutually exclusive: a claim with two placement
* keys is a type error, not a silent priority pick.
*
* `session.panel` is an exclusive named replacement selected by ui.panel.open,
* not by plugin enable order. Its instance survives presentation changes.
*/
export type SlotClaim<Path extends SlotPath = SlotPath> = Path extends SlotPath
? { readonly render: (input: SlotMap[Path]) => JSX.Element } & (
| {
readonly prepend: Path
readonly append?: never
readonly before?: never
readonly after?: never
readonly replace?: never
}
| {
readonly append: Path
readonly prepend?: never
readonly before?: never
readonly after?: never
readonly replace?: never
}
| {
readonly before: Path
readonly prepend?: never
readonly append?: never
readonly after?: never
readonly replace?: never
}
| {
readonly after: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly replace?: never
}
| {
readonly replace: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly after?: never
}
)
? { readonly render: (input: SlotMap[Path]) => JSX.Element } & (Path extends "session.panel"
? { readonly name: string }
: { readonly name?: string }) &
(Path extends "session.panel"
? {
readonly replace: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly after?: never
}
:
| {
readonly prepend: Path
readonly append?: never
readonly before?: never
readonly after?: never
readonly replace?: never
}
| {
readonly append: Path
readonly prepend?: never
readonly before?: never
readonly after?: never
readonly replace?: never
}
| {
readonly before: Path
readonly prepend?: never
readonly append?: never
readonly after?: never
readonly replace?: never
}
| {
readonly after: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly replace?: never
}
| {
readonly replace: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly after?: never
})
: never
export interface App {
@@ -450,6 +478,14 @@ export interface UI {
navigate(destination: Destination): void
current(): Route
}
readonly panel: {
/** Opens a named session.panel contribution owned by this plugin in the current session. */
open(name: string, options?: { readonly presentation?: PanelPresentation }): boolean
/** Closes this plugin's active panel. Other plugins' panels are unaffected. */
close(): void
/** This plugin's active panel, if any. Reactive when read in a Solid computation. */
current(): { readonly name: string; readonly sessionID: string } | undefined
}
readonly tabs: {
/** Returns whether session tabs are enabled for this TUI. */
enabled(): boolean
+18 -4
View File
@@ -100,7 +100,7 @@ import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider, useStorage } from "./context/storage"
import { SessionTerminalsProvider } from "./context/session-terminals"
import { SessionPanelProvider } from "./context/session-panel"
import { PanelProvider, usePanel } from "./context/panel"
import { SessionFrame } from "./component/session-frame"
import { createTuiClipboard } from "./clipboard"
@@ -398,7 +398,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<SessionPanelProvider>
<PanelProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
@@ -415,7 +415,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}
/>
</PluginProvider>
</SessionPanelProvider>
</PanelProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
@@ -477,6 +477,7 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
const dialog = useDialog()
const local = useLocal()
const sessionTabs = useSessionTabs()
const panels = usePanel()
const keymap = Keymap.use()
const event = useEvent()
const client = useClient()
@@ -611,9 +612,22 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () =>
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
const fullscreenPanel = () =>
route.data.type === "session" &&
panels.current()?.sessionID === route.data.sessionID &&
panels.presentation() === "fullscreen"
const tabsVisible = () => tabsAvailable() && !fullscreenPanel()
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
// Measure the prospective split layout, even while full-screen hides the tabs.
createEffect(() => panels.setWidth(dimensions().width - (tabsAvailable() && tabsVertical() ? tabsResize.size() : 0)))
createEffect(() => {
const current = panels.current()
if (!current || (route.data.type === "session" && route.data.sessionID === current.sessionID)) return
panels.close()
})
createEffect(() => {
renderer.useMouse = config.data.mouse
})
+66
View File
@@ -0,0 +1,66 @@
import type { BoxRenderable } from "@opentui/core"
import { onCleanup, onMount } from "solid-js"
import { usePanel, type PanelTarget } from "../context/panel"
import { Keymap } from "../context/keymap"
import { ThemeContextProvider, useTheme } from "../context/theme"
import { Slot } from "../plugin/render"
export function PanelHost(props: {
panel: PanelTarget
width: number
focused: boolean
onFocus: () => void
onTarget: (node: BoxRenderable | undefined) => void
}) {
const panels = usePanel()
let node: BoxRenderable
onMount(() => props.onTarget(node))
onCleanup(() => props.onTarget(undefined))
const Content = () => {
const theme = useTheme()
return (
<box
id="session-panel"
ref={(value: BoxRenderable) => (node = value)}
flexGrow={1}
minWidth={0}
minHeight={0}
focusable
backgroundColor={theme.background.default}
onMouseDown={props.onFocus}
>
<Slot
path="session.panel"
selection={props.panel}
input={{
sessionID: props.panel.sessionID,
get width() {
return props.width
},
get presentation() {
return panels.presentation()
},
get canSplit() {
return panels.canSplit()
},
get focused() {
return props.focused
},
focus: props.onFocus,
close: panels.close,
toggleFullscreen: panels.toggleFullscreen,
}}
/>
</box>
)
}
return (
<Keymap.Scope enabled={props.focused}>
<ThemeContextProvider context={() => (panels.presentation() === "panel" ? "elevated" : undefined)}>
<Content />
</ThemeContextProvider>
</Keymap.Scope>
)
}
+35 -20
View File
@@ -187,6 +187,8 @@ export function Prompt(props: PromptProps) {
let anchor: BoxRenderable
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const enabled = Keymap.useEnabled()
const disabled = () => props.disabled || !enabled()
const leader = Keymap.useLeaderActive()
const muted = () => leader() || props.muted
const local = useLocal()
@@ -259,6 +261,7 @@ export function Prompt(props: PromptProps) {
const [pendingDirectory, setPendingDirectory] = createSignal<string>()
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: [
{
id: "session.cd",
@@ -348,8 +351,7 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.background.surface.offset
if (!props.disabled) input.cursorColor = theme.text.default
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
if (config.cursor) input.cursorStyle = config.cursor
})
@@ -372,12 +374,13 @@ export function Prompt(props: PromptProps) {
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
pasteQueue = pasteQueue
.then(async () => {
if (disposed || input.isDestroyed) return
if (disposed || input.isDestroyed || disabled()) return
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
await run(
() =>
disposed ||
input.isDestroyed ||
disabled() ||
props.sessionID !== before.sessionID ||
store.mode !== before.mode ||
input.plainText !== before.text,
@@ -648,15 +651,18 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: promptCommands(),
}))
Keymap.createLayer(() => ({
priority: 1,
enabled: !disabled(),
bindings: ["prompt.queue"],
}))
Keymap.createLayer(() => ({
enabled: !disabled(),
bindings: [
"prompt.submit",
"prompt.editor",
@@ -674,12 +680,13 @@ export function Prompt(props: PromptProps) {
const ref: PromptRef = {
get focused() {
return input.focused
return !disabled() && input.focused
},
get current() {
return store.prompt
},
focus() {
if (disabled()) return
input.focus()
},
blur() {
@@ -733,11 +740,13 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
if (props.visible === false || disabled() || dialog.stack.length > 0) {
if (input.focused) input.blur()
input.focusable = false
return
}
input.focusable = true
// Slot/plugin updates can remount the background prompt while a dialog is open.
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
if (!input.focused) input.focus()
@@ -933,13 +942,14 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: stashCommands(),
}))
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
enabled: inputTarget() !== undefined && !disabled(),
bindings: ["prompt.paste"],
}
})
@@ -947,7 +957,7 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
enabled: inputTarget() !== undefined && !disabled() && store.prompt.text !== "",
bindings: ["prompt.clear"],
}
})
@@ -959,7 +969,7 @@ export function Prompt(props: PromptProps) {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!disabled() &&
store.mode === "normal" &&
!auto()?.visible &&
input?.visualCursor.offset === 0
@@ -983,7 +993,7 @@ export function Prompt(props: PromptProps) {
return {
priority: 1,
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
enabled: inputTarget() !== undefined && !disabled() && store.mode === "shell",
commands: [
{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
{
@@ -1002,7 +1012,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
return inputTarget() !== undefined && !disabled() && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
commands: [
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
@@ -1016,7 +1026,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -1052,7 +1062,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -1087,6 +1097,7 @@ export function Prompt(props: PromptProps) {
let submitting = false
async function submit(delivery: SessionInbox.Delivery = "steer") {
if (disabled()) return false
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call
@@ -1110,7 +1121,6 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "text", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (move.creating()) return false
if (auto()?.visible) return false
const trimmed = store.prompt.text.trim()
@@ -1663,7 +1673,7 @@ export function Prompt(props: PromptProps) {
const promptBg = createMemo(() => theme.raise(theme.background.surface.offset))
return (
<>
<Keymap.Scope enabled={!disabled()}>
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false} width="100%">
<box
width="100%"
@@ -1769,18 +1779,19 @@ export function Prompt(props: PromptProps) {
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
if (disabled()) {
e.preventDefault()
return
}
}}
onSubmit={() => {
if (disabled()) return
// IME: double-defer so the last composed character (e.g. Korean
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={(event: PasteEvent) => {
if (props.disabled) {
if (disabled()) {
event.preventDefault()
return
}
@@ -1816,12 +1827,16 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = theme.text.default
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
if (config.cursor) input.cursorStyle = config.cursor
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
if (props.disabled || r.button !== 0) return
if (disabled()) {
r.preventDefault()
return
}
if (r.button !== 0) return
r.target?.focus()
const extmark = input.extmarks
.getAtOffset(input.cursorOffset)
@@ -1831,7 +1846,7 @@ export function Prompt(props: PromptProps) {
r.stopPropagation()
}}
focusedBackgroundColor="transparent"
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
cursorColor={disabled() ? theme.background.surface.offset : theme.text.default}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -2016,6 +2031,6 @@ export function Prompt(props: PromptProps) {
hasSkill={(id) => store.prompt.skills?.some((skill) => skill.id === id) ?? false}
promptPartTypeId={() => promptPartTypeId}
/>
</>
</Keymap.Scope>
)
}
+183 -94
View File
@@ -1,24 +1,21 @@
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import {
batch,
createComponent,
createEffect,
createMemo,
createResource,
createSignal,
on,
onCleanup,
Show,
} from "solid-js"
CliRenderEvents,
RGBA,
MouseEvent,
type BoxRenderable,
type Renderable,
type ScrollBoxRenderable,
} from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createEffect, createMemo, createResource, createSignal, on, onCleanup, Show } from "solid-js"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useSessionTerminals } from "../context/session-terminals"
import { usePromptRef } from "../context/prompt"
import { useSessionPanel } from "../context/session-panel"
import { usePanel } from "../context/panel"
import { useStorage } from "../context/storage"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { Session } from "../routes/session"
import { Sidebar } from "../routes/session/sidebar"
import { clampSessionPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
@@ -26,6 +23,7 @@ import { createPaneResize } from "../ui/pane-resize"
import { PaneResizeHandle } from "../ui/pane-resize-handle"
import { useToast } from "../ui/toast"
import { TerminalPane } from "./terminal-pane"
import { PanelHost } from "./panel-host"
export function SessionFrame(props: { sessionID: string; verticalTabsWidth: number }) {
const sessions = useSessionTerminals()
@@ -33,19 +31,20 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const config = useConfig()
const data = useData()
const toast = useToast()
const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" })
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const panel = useSessionPanel()
const elevated = useTheme("elevated")
const panels = usePanel()
const dialog = useDialog()
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
const defaultPaneWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
const defaultPaneWidth = () => Math.max(1, Math.floor(panels.width() / 2))
const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", {
initial: {},
})
const paneResize = createPaneResize({
value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(),
defaultValue: defaultPaneWidth,
clamp: (width) => clampSessionPaneWidth(width, availableWidth()),
clamp: (width) => clampSessionPaneWidth(width, panels.width()),
fromMouse: (event) => dimensions().width - event.x - 1,
contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width,
onCommit: (width) => {
@@ -67,12 +66,14 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
}
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [sessionWidth, setSessionWidth] = createSignal<number>()
const [terminalFocused, setTerminalFocused] = createSignal(false)
const [panelFocused, setPanelFocused] = createSignal(false)
const [activePane, setActivePane] = createSignal<"session" | "right">("session")
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
let focusTerminal: (() => void) | undefined
let showTerminals: (() => void) | undefined
let sessionScroll: ScrollBoxRenderable | undefined
let focusPanel: (() => void) | undefined
let sessionNode: BoxRenderable | undefined
let rightNode: BoxRenderable | undefined
let panelNode: BoxRenderable | undefined
createResource(
() => (config.data.session.terminal ? props.sessionID : undefined),
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
@@ -84,35 +85,24 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
}
const activePanel = createMemo(() => {
const current = panel.current()
const current = panels.current()
if (current?.sessionID === props.sessionID) return current
})
const fullscreen = () => activePanel() !== undefined && panels.presentation() === "fullscreen"
createEffect(
on(
() => selectedTerminal()?.id,
(id) => {
if (!id) return
on([activePanel, () => selectedTerminal()?.id], ([panel, terminal], previous) => {
if (panel && panel !== previous?.[0]) {
setSidebarOpen(false)
if (activePanel()) panel.close()
},
{ defer: true },
),
if (terminal) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
return
}
if (terminal && terminal !== previous?.[1]) {
setSidebarOpen(false)
if (panel) panels.close()
}
}),
)
const splitAvailable = createMemo(() => dimensions().width > 80)
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
createEffect(() => panel.setAvailable(props.sessionID, splitAvailable()))
onCleanup(() => panel.setAvailable(props.sessionID, false))
createEffect(() => {
const current = activePanel()
if (!current || splitAvailable()) return
panel.close()
current.onUnavailable?.()
})
createEffect(() => {
if (!activePanel()) return
setSidebarOpen(false)
if (selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
const sidebarVisible = createMemo(() => {
if (data.session.get(props.sessionID)?.parentID) return false
if (sidebarOpen()) return true
@@ -133,48 +123,51 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
})
.catch(toast.error)
setSidebarOpen(!visible)
if (!visible && activePanel()) panel.close()
if (!visible && activePanel()) panels.close()
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
}
const focusSession = () => {
if (fullscreen()) return
// Permission prompts replace the input, so returning focus must not depend on it.
if (terminalFocused() || panelFocused()) renderer.currentFocusedRenderable?.blur()
if (activePane() === "right") renderer.currentFocusedRenderable?.blur()
setActivePane("session")
prompt.current?.focus()
}
const focusRightPane = () => {
setActivePane("right")
if (activePanel()) {
focusPanel?.()
panelNode?.focus()
return
}
focusTerminal?.()
}
createEffect(
on(
() => activePanel()?.id,
(id) => {
if (!id) {
setPanelFocused(false)
return
}
requestAnimationFrame(() => {
if (activePanel()?.id !== id) return
focusPanel?.()
})
},
),
)
const onFocused = () => {
const current = renderer.currentFocusedRenderable
if (rightPane() !== "sidebar" && within(current, rightNode)) setActivePane("right")
if (!fullscreen() && within(current, sessionNode)) setActivePane("session")
}
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused))
createEffect(() => {
if (fullscreen()) focusRightPane()
})
createEffect(() => {
if (rightPane() !== "terminal" && rightPane() !== "panel") setActivePane("session")
})
createEffect(() => {
if (!restoreTerminalFocus() || selectedTerminal()) return
setRestoreTerminalFocus(false)
focusSession()
})
Keymap.createLayer(() => ({
enabled: () => config.data.session.terminal === true || activePanel() !== undefined,
mode: "global",
enabled: () => (rightPane() === "terminal" || activePanel() !== undefined) && dialog.stack.length === 0,
commands: [
{
id: "pane.focus.left",
title: "Focus session pane",
enabled: () => !fullscreen(),
run: focusSession,
},
{
@@ -185,6 +178,85 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
],
}))
// Pane management stays reachable from either input scope.
Keymap.createLayer(() => ({
mode: "global",
commands: [
{
id: "session.sidebar.toggle",
title: rightPane() === "sidebar" ? "Hide sidebar" : "Show sidebar",
group: "Session",
palette: true,
run: () => {
toggleSidebar()
dialog.clear()
},
},
...(config.data.session.terminal
? [
{
id: "terminal.toggle",
title: rightPane() === "terminal" ? "Hide terminal pane" : "Show terminal pane",
group: "Session",
palette: true as const,
run: () => {
dialog.clear()
if (rightPane() === "terminal") {
focusSession()
void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
return
}
void sessions
.refresh(props.sessionID)
.then(async () => {
const terminal = sessions.get(props.sessionID).terminals.at(-1)
if (terminal) return sessions.selectTerminal(props.sessionID, terminal.id)
await sessions.newTerminal(props.sessionID)
})
.catch(terminalError)
},
},
{
id: "terminal.select",
title: "Select terminal",
group: "Session",
palette: true as const,
run: () => {
dialog.clear()
if (fullscreen()) panels.close()
focusSession()
showTerminals?.()
void sessions.refresh(props.sessionID).catch(terminalError)
},
},
{
id: "terminal.close",
title: "Close terminal pane",
group: "Session",
palette: true as const,
enabled: rightPane() === "terminal",
run: () => {
dialog.clear()
focusSession()
void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
},
},
{
id: "session.terminal",
title: "New terminal",
group: "Session",
palette: true as const,
slash: { name: "terminal" },
run: async () => {
dialog.clear()
await sessions.newTerminal(props.sessionID).catch(terminalError)
},
},
]
: []),
],
}))
return (
<box
flexGrow={1}
@@ -197,25 +269,33 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
onMouseUp={finishPaneResize}
>
<box
id="session-pane"
ref={(value: BoxRenderable) => (sessionNode = value)}
flexGrow={1}
flexBasis={0}
minWidth={0}
minHeight={0}
position="relative"
position={fullscreen() ? "absolute" : "relative"}
visible={!fullscreen()}
width={fullscreen() ? Math.max(0, panels.width() - paneResize.size()) : undefined}
height="100%"
onSizeChange={function () {
setSessionWidth(this.width)
}}
>
<Session
scrollRef={(value) => (sessionScroll = value)}
verticalTabsWidth={props.verticalTabsWidth}
promptMuted={terminalFocused() || panelFocused()}
sidebarVisible={rightPane() === "sidebar"}
onToggleSidebar={toggleSidebar}
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
width={sessionWidth()}
/>
<Show when={terminalFocused() || panelFocused()}>
<Keymap.Scope enabled={activePane() === "session" && !fullscreen()}>
<Session
scrollRef={(value) => (sessionScroll = value)}
verticalTabsWidth={props.verticalTabsWidth}
promptMuted={activePane() !== "session"}
sidebarVisible={rightPane() === "sidebar"}
onToggleSidebar={toggleSidebar}
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
onTerminalPicker={(show) => (showTerminals = show)}
width={sessionWidth()}
/>
</Keymap.Scope>
<Show when={activePane() === "right"}>
<box
position="absolute"
left={0}
@@ -242,11 +322,13 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</box>
<Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
<box
ref={(value: BoxRenderable) => (rightNode = value)}
flexShrink={0}
width={rightPane() === "terminal" || rightPane() === "panel" ? paneResize.size() : SESSION_SIDEBAR_WIDTH}
width={
fullscreen() ? availableWidth() : rightPane() === "sidebar" ? SESSION_SIDEBAR_WIDTH : paneResize.size()
}
minWidth={0}
minHeight={0}
backgroundColor={rightPane() === "panel" ? elevated.background.default : undefined}
>
<Show
when={rightPane() === "sidebar"}
@@ -265,7 +347,6 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
}}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
@@ -273,22 +354,22 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</Show>
}
>
{(item) =>
createComponent(item.render, {
get width() {
return paneResize.size()
},
get resizing() {
return paneResize.resizing()
},
get focused() {
return panelFocused()
},
onFocusChange: setPanelFocused,
onFocusRequest: (value) => (focusPanel = value),
close: panel.close,
})
}
{(item) => (
<PanelHost
panel={item}
width={fullscreen() ? availableWidth() : paneResize.size()}
focused={activePane() === "right"}
onFocus={focusRightPane}
onTarget={(node) => {
panelNode = node
if (node) {
focusRightPane()
return
}
setActivePane("session")
}}
/>
)}
</Show>
}
>
@@ -296,7 +377,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</Show>
</box>
</Show>
<Show when={(rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
<Show when={!fullscreen() && (rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
<PaneResizeHandle resize={paneResize} left={availableWidth() - paneResize.size() - 1} highlight="right" />
</Show>
<Show when={rightPane() === "sidebar" && !wide()}>
@@ -315,3 +396,11 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</box>
)
}
function within(node: Renderable | null | undefined, root: Renderable | undefined) {
if (!root) return false
for (let current = node; current; current = current.parent) {
if (current === root) return true
}
return false
}
+1 -7
View File
@@ -1,4 +1,4 @@
import { CliRenderEvents, EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
import { EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
import type { ResolvedThemeTokens } from "@opencode-ai/theme/tui"
import { extend, useRenderer } from "@opentui/solid"
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
@@ -28,7 +28,6 @@ export function TerminalPane(props: {
onAutoFocus?: () => void
onFocusRequest?: (focus: (() => void) | undefined) => void
onDisconnect?: () => void
onFocusChange?: (focused: boolean) => void
}) {
const client = useClient()
const keymap = Keymap.use()
@@ -148,9 +147,6 @@ export function TerminalPane(props: {
},
{ priority: 100 },
)
// Blur emits this event before updating the terminal's own focused flag.
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === terminal)
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
createEffect(() => {
if (!props.autoFocus || !terminal) return
terminal.focus()
@@ -172,8 +168,6 @@ export function TerminalPane(props: {
waitingSize?.resolve()
socket?.close()
offKeys()
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
props.onFocusChange?.(false)
props.onFocusRequest?.(undefined)
})
-1
View File
@@ -84,7 +84,6 @@ export const Definitions = {
"diff.single_patch": keybind("s", "Toggle single patch view"),
"diff.switch_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.toggle_fullscreen": keybind("f", "Toggle diff viewer full screen"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
+47 -17
View File
@@ -13,7 +13,17 @@ import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid"
import { useRenderer } from "@opentui/solid"
import { createContext, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js"
import {
createComputed,
createContext,
createMemo,
createSignal,
getOwner,
onCleanup,
useContext,
type Accessor,
type ParentProps,
} from "solid-js"
import { useConfig } from "../config"
import { TuiKeybind } from "../config/keybind"
@@ -50,6 +60,20 @@ const Context = createContext<{
readonly input: (id: string) => string | undefined
}>()
const EnabledContext = createContext<Accessor<boolean>>(() => true)
/** Gates descendant layers and modes, including layers that opt out of mode matching. */
function Scope(props: ParentProps<{ enabled: boolean }>) {
const parent = useEnabled()
const enabled = createMemo(() => parent() && props.enabled)
return <EnabledContext.Provider value={enabled}>{props.children}</EnabledContext.Provider>
}
/** Returns the combined activation of every enclosing scope. */
function useEnabled() {
return useContext(EnabledContext)
}
function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
const renderer = useRenderer()
const config: KeymapConfig = props.config ?? useConfig().data
@@ -175,13 +199,18 @@ export interface Keymap {
function use(): Keymap {
const value = useValue()
const enabled = useEnabled()
const leader = value.config.keybinds.get("leader")?.[0]?.key
const isLeader = leader ? value.keymap.createKeyMatcher(leader) : () => false
return {
dispatch(id, input) {
value.dispatch(id, input)
},
mode: value.mode,
mode: {
current: value.mode.current,
// Plugin APIs can forward a keymap captured above the calling component's scope.
push: (mode) => value.mode.push(mode, getOwner() ? useEnabled() : enabled),
},
intercept: value.keymap.intercept.bind(value.keymap),
isLeader,
}
@@ -189,6 +218,7 @@ function use(): Keymap {
function createLayer(input: () => KeymapLayer) {
const value = useValue()
const enabled = useEnabled()
useBindings(() => {
const layer = input()
const { commands, bindings, mode, ...options } = layer
@@ -215,6 +245,7 @@ function createLayer(input: () => KeymapLayer) {
)
return {
...options,
enabled: enabled() ? options.enabled : false,
...(mode === "global" ? {} : { mode: mode ?? MODE.base }),
commands: grouped.named.map((command) => {
const { id, description, group, palette, bind, run, ...definition } = command
@@ -385,7 +416,9 @@ function useValue() {
export const Keymap = {
Provider,
Scope,
use,
useEnabled,
createLayer,
useShortcuts,
useShortcut,
@@ -397,37 +430,34 @@ export const Keymap = {
} as const
function createMode(keymap: OpenTuiKeymap) {
keymap.setData(MODE.key, MODE.base)
const [stack, setStack] = createSignal<
{ readonly id: symbol; readonly mode: string; readonly enabled: Accessor<boolean> }[]
>([])
const current = createMemo(() => stack().findLast((item) => item.enabled())?.mode ?? MODE.base)
// Publish mode changes before another command can be dispatched in the same callback.
createComputed(() => keymap.setData(MODE.key, current()))
const unregister = keymap.registerLayerFields({
mode(value, context) {
context.require(MODE.key, value)
},
})
const stack: { readonly id: symbol; readonly mode: string }[] = []
let disposed = false
const update = () => keymap.setData(MODE.key, stack.at(-1)?.mode ?? MODE.base)
return {
current() {
return stack.at(-1)?.mode ?? MODE.base
},
push(mode: string) {
current,
push(mode: string, enabled: Accessor<boolean>) {
if (disposed) return () => {}
const id = Symbol(mode)
stack.push({ id, mode })
update()
// Inactive scopes retain their stack position beneath any newer modes.
setStack((items) => [...items, { id, mode, enabled }])
return () => {
const index = stack.findIndex((item) => item.id === id)
if (index < 0) return
stack.splice(index, 1)
update()
setStack((items) => items.filter((item) => item.id !== id))
}
},
dispose() {
if (disposed) return
disposed = true
stack.length = 0
setStack([])
unregister()
keymap.setData(MODE.key, undefined)
},
+58
View File
@@ -0,0 +1,58 @@
import type { PanelPresentation } from "@opencode-ai/plugin/tui/context"
import { batch, createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js"
export type PanelTarget = {
readonly plugin: string
readonly name: string
readonly sessionID: string
}
export function createPanelState() {
const [current, setCurrent] = createSignal<PanelTarget>()
const [requested, setRequested] = createSignal<PanelPresentation>("panel")
const [width, setWidth] = createSignal(0)
const canSplit = () => width() > 80
const presentation = createMemo(() => (canSplit() ? requested() : "fullscreen"))
return {
current,
width,
canSplit,
presentation,
setWidth,
open(target: PanelTarget, presentation: PanelPresentation = "panel") {
batch(() => {
setRequested(presentation)
setCurrent((current) =>
current?.plugin === target.plugin && current.name === target.name && current.sessionID === target.sessionID
? current
: target,
)
})
},
close: () => setCurrent(),
release(plugin: string, name?: string) {
if (current()?.plugin !== plugin || (name !== undefined && current()?.name !== name)) return
setCurrent()
},
toggleFullscreen() {
if (!canSplit()) return
setRequested((current) => (current === "panel" ? "fullscreen" : "panel"))
},
}
}
const Context = createContext<ReturnType<typeof createPanelState>>()
export function PanelProvider(props: ParentProps) {
return <Context.Provider value={createPanelState()}>{props.children}</Context.Provider>
}
export function usePanel() {
const value = useContext(Context)
if (!value) throw new Error("usePanel must be used within a PanelProvider")
return value
}
export function useOptionalPanel() {
return useContext(Context)
}
@@ -1,54 +0,0 @@
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
export type SessionPanelRenderProps = {
readonly width: number
readonly resizing: boolean
readonly focused: boolean
readonly onFocusChange: (focused: boolean) => void
readonly onFocusRequest: (focus: (() => void) | undefined) => void
readonly close: () => void
}
type Panel = {
readonly id: string
readonly sessionID: string
readonly render: (props: SessionPanelRenderProps) => JSX.Element
readonly onUnavailable?: () => void
}
const Context = createContext<{
readonly current: () => Panel | undefined
readonly open: (panel: Panel) => void
readonly close: () => void
readonly available: (sessionID: string) => boolean
readonly setAvailable: (sessionID: string, available: boolean) => void
}>()
export function SessionPanelProvider(props: ParentProps) {
const [current, setCurrent] = createSignal<Panel>()
const [availableSessionID, setAvailableSessionID] = createSignal<string>()
return (
<Context.Provider
value={{
current,
open: setCurrent,
close: () => setCurrent(),
available: (sessionID) => availableSessionID() === sessionID,
setAvailable: (sessionID, available) =>
setAvailableSessionID((current) => (available ? sessionID : current === sessionID ? undefined : current)),
}}
>
{props.children}
</Context.Provider>
)
}
export function useSessionPanel() {
const value = useContext(Context)
if (!value) throw new Error("useSessionPanel must be used within a SessionPanelProvider")
return value
}
export function useOptionalSessionPanel() {
return useContext(Context)
}
+12 -5
View File
@@ -23,7 +23,7 @@ import {
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/component"
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
@@ -379,12 +379,19 @@ export function useTheme(context?: ContextName) {
}
export const ThemeProvider = themeContext.provider
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
/** An accessor switches context without remounting children; undefined inherits the enclosing view. */
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | Accessor<ContextName | undefined> }>) {
const value = themeContext.use()
const context = props.context
const current =
typeof context === "function"
? createComponentThemeView(() => {
const name = context()
return name ? value.themes.currentTokens().contextual[name] : value.current
}, value.themes.mode)
: value.themes.current.contextual[context]
return (
<themeContext.context.Provider
value={{ current: value.themes.current.contextual[props.context], themes: value.themes, ready: value.ready }}
>
<themeContext.context.Provider value={{ current, themes: value.themes, ready: value.ready }}>
{props.children}
</themeContext.context.Provider>
)
@@ -0,0 +1,87 @@
/** @jsxImportSource @opentui/solid */
import type { Plugin } from "@opencode-ai/plugin/tui"
import { BoxRenderable, MouseButton } from "@opentui/core"
import { Portal, useTerminalDimensions } from "@opentui/solid"
import { createSignal, onCleanup } from "solid-js"
export function DiffFileMenu(props: {
context: Plugin.Context
state: { fileIndex: number; x: number; y: number }
reviewed: boolean
onToggle: () => void
onClose: () => void
}) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.overlay
const [hovered, setHovered] = createSignal(false)
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
const width = () => Math.min(19, dimensions().width)
const run = () => {
props.onClose()
props.onToggle()
}
onCleanup(props.context.keymap.mode.push("menu"))
props.context.keymap.layer(() => ({
mode: "menu",
commands: [
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
{ bind: "return", title: label(), group: "Diff", run },
],
}))
return (
<Portal
ref={(container) => {
if (!(container instanceof BoxRenderable)) return
// Portal's wrapper must also escape root flow, not follow the full-height app.
container.position = "absolute"
container.left = 0
container.top = 0
container.zIndex = 2600
}}
>
<box
id="diff-file-menu-overlay"
position="absolute"
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
zIndex={2600}
onMouseDown={(event) => {
props.onClose()
event.preventDefault()
event.stopPropagation()
}}
>
<box
id="diff-file-menu"
position="absolute"
left={Math.max(0, Math.min(props.state.x, dimensions().width - width()))}
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
width={width()}
height={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
if (event.button === MouseButton.RIGHT) props.onClose()
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
if (event.button === MouseButton.LEFT) run()
}}
>
<text fg={theme.text.default} selectable={false} wrapMode="none" truncate>
{label()}
</text>
</box>
</box>
</Portal>
)
}
@@ -4,7 +4,6 @@ import type { Vcs } from "@opencode-ai/schema/vcs"
import { Plugin } from "@opencode-ai/plugin/tui"
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
import {
CliRenderEvents,
MouseButton,
TextAttributes,
type BoxRenderable,
@@ -15,6 +14,7 @@ import { filetype } from "../../util/filetype"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { DiffFileMenu } from "./diff-viewer-file-menu"
import { DiffViewerImage, isDiffImageFile } from "./diff-viewer-image"
import { DialogSelect } from "../../ui/dialog-select"
import { EmptyBorder } from "../../ui/border"
@@ -23,8 +23,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { createDebouncedSignal } from "../../util/signal"
import { useConfig } from "../../config"
import { locationKey } from "../../context/data"
import { useOptionalSessionPanel } from "../../context/session-panel"
import { useTheme, useThemes } from "../../context/theme"
import { useThemes } from "../../context/theme"
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
import {
allExpandedFileTreeDirectories,
@@ -77,55 +76,9 @@ function diffSourceLabel(mode: DiffMode) {
return "Uncommitted"
}
type PanelController = NonNullable<ReturnType<typeof useOptionalSessionPanel>>
function openDiffPanel(context: Plugin.Context, panel: PanelController, sessionID: string) {
panel.open({
id: ROUTE,
sessionID,
onUnavailable: () => openDiffFullscreen(context, panel, sessionID, false),
render: (input) => (
<DiffViewer
context={context}
sessionID={sessionID}
width={input.width}
embedded
focused={input.focused}
onFocusChange={input.onFocusChange}
onFocusRequest={input.onFocusRequest}
onClose={input.close}
/>
),
})
}
function openDiffFullscreen(context: Plugin.Context, panel: PanelController, sessionID: string, split: boolean) {
panel.close()
context.ui.router.navigate({
type: "plugin",
name: ROUTE,
data: {
sessionID,
returnRoute: { type: "session", sessionID },
...(split ? { split: true } : {}),
},
})
}
function DiffViewer(props: {
context: Plugin.Context
sessionID?: string
width?: number
embedded?: boolean
focused?: boolean
onFocusChange?: (focused: boolean) => void
onFocusRequest?: (focus: (() => void) | undefined) => void
onClose?: () => void
}) {
function DiffViewer(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const config = useConfig()
const panel = useOptionalSessionPanel()
const [memory, updateMemory] = props.context.storage.memory<{
source?: DiffMode
bases: Record<string, string>
@@ -137,14 +90,13 @@ function DiffViewer(props: {
mode?: DiffMode
sessionID?: string
returnRoute?: Route
split?: boolean
}
| undefined
}
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
const location = createMemo(
() => {
const sessionID = props.sessionID ?? params()?.sessionID
const sessionID = params()?.sessionID
return sessionID
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
: props.context.data.location.default()
@@ -206,103 +158,52 @@ function DiffViewer(props: {
if (!base) return "Base not reported"
return `vs ${base.name}`
}
const sessionID = () => props.sessionID ?? params()?.sessionID
const canToggleFullscreen = () =>
panel !== undefined &&
sessionID() !== undefined &&
(props.embedded === true || (params()?.split === true && dimensions().width > 80))
const toggleFullscreen = () => {
const id = sessionID()
if (!panel || !id) return
if (props.embedded) {
openDiffFullscreen(props.context, panel, id, true)
return
}
openDiffPanel(props.context, panel, id)
props.context.ui.router.navigate({ type: "session", sessionID: id })
}
let panelNode: BoxRenderable | undefined
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === panelNode)
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
onCleanup(() => {
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
props.onFocusChange?.(false)
props.onFocusRequest?.(undefined)
})
const content = () => (
<DiffViewerContent
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={props.embedded ? { ...config.data.diffs, tree: false } : config.data.diffs}
width={props.width}
fileTree={!props.embedded}
elevated={props.embedded}
focused={props.embedded ? props.focused === true : true}
onToggleFullscreen={canToggleFullscreen() ? toggleFullscreen : undefined}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={() => {
if (props.onClose) return props.onClose()
props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })
}}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
)
if (props.embedded)
return (
<box
ref={(node: BoxRenderable) => {
panelNode = node
props.onFocusRequest?.(() => node.focus())
}}
flexGrow={1}
minWidth={0}
minHeight={0}
focusable
onMouseDown={() => panelNode?.focus()}
>
{content()}
</box>
)
return (
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
{content()}
<DiffViewerContent
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={config.data.diffs}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
</box>
)
}
@@ -366,36 +267,28 @@ export function DiffViewerContent(props: {
navigation?: "tree" | "list"
loadImage?: (file: string, signal: AbortSignal) => Promise<Uint8Array>
preferences?: DiffPreferences
width?: number
fileTree?: boolean
elevated?: boolean
focused?: boolean
onPreferencesChange?: (value: DiffPreferences) => void
onClose: () => void
onSwitchSource: (mode: DiffMode) => void
onChooseBase?: () => void
onToggleFullscreen?: () => void
}) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig()
const dialog = props.context.ui.dialog
const themes = useThemes()
const elevated = useTheme("elevated")
const theme = props.elevated ? elevated : themes.current
const currentSyntax = themes.currentSyntax
const theme = useThemes().current
const currentSyntax = useThemes().currentSyntax
const files = () => props.files
const width = () => props.width ?? dimensions().width
const mode = () => props.mode
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.preferences?.tree ?? true)
const showFileTree = createMemo(
() => props.fileTree !== false && width() >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
() => dimensions().width >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
)
const [singlePatch, setSinglePatch] = createSignal(props.preferences?.single ?? false)
const fileTreeWidth = createMemo(() =>
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(width() / 4))),
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(dimensions().width / 4))),
)
const patchPaneWidth = createMemo(() => width() - (showFileTree() ? fileTreeWidth() : 0) - (props.elevated ? 2 : 4))
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? fileTreeWidth() : 0) - 4)
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.preferences?.view))
const view = createMemo(() =>
@@ -409,7 +302,6 @@ export function DiffViewerContent(props: {
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(config.data))
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
const helpShortcut = () => props.context.keymap.shortcuts("diff.help")[0]
const firstShortcut = (id: string, fallback: string) => props.context.keymap.shortcuts(id)[0] ?? fallback
let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const patchDiffByFileIndex = new Map<number, PatchDiffRef>()
@@ -792,13 +684,6 @@ export function DiffViewerContent(props: {
props.onPreferencesChange?.({ view: next })
},
},
{
id: "diff.toggle_fullscreen",
title: "Toggle diff viewer full screen",
group: "VCS",
enabled: () => props.onToggleFullscreen !== undefined,
run: () => props.onToggleFullscreen?.(),
},
{
id: "diff.help",
title: "Show more diff viewer shortcuts",
@@ -864,13 +749,7 @@ export function DiffViewerContent(props: {
}
const openHelpDialog = () => {
dialog.show(() => (
<DiffViewerHelpDialog
context={props.context}
single={singlePatch()}
fullscreen={props.onToggleFullscreen !== undefined}
/>
))
dialog.show(() => <DiffViewerHelpDialog context={props.context} single={singlePatch()} />)
dialog.set({ size: "medium", centered: true })
}
@@ -899,7 +778,6 @@ export function DiffViewerContent(props: {
)
props.context.keymap.layer(() => ({
enabled: () => props.focused !== false,
commands,
}))
@@ -997,13 +875,7 @@ export function DiffViewerContent(props: {
/>
</Show>
<box
flexGrow={1}
minWidth={0}
minHeight={0}
paddingLeft={props.elevated ? 1 : 2}
paddingRight={props.elevated ? 1 : 2}
>
<box flexGrow={1} minWidth={0} minHeight={0} paddingLeft={2} paddingRight={2}>
<box
id="diff-patch-top-edge"
ref={(edge: BoxRenderable) => {
@@ -1082,7 +954,7 @@ export function DiffViewerContent(props: {
zIndex={1}
backgroundColor={background()}
paddingLeft={1}
paddingRight={props.elevated ? 0 : 1}
paddingRight={1}
paddingBottom={1}
>
<box flexGrow={1} minWidth={0}>
@@ -1185,45 +1057,7 @@ export function DiffViewerContent(props: {
</Match>
</Switch>
</box>
<Show when={props.elevated}>
<box height={1} flexShrink={0} />
<box height={1} flexShrink={0} paddingLeft={2} paddingRight={2}>
<Show
when={props.focused}
fallback={
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.right", "ctrl+x →")}</span>
{" focus diff"}
</text>
}
>
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.left", "ctrl+x ←")}</span>
{" focus session "}
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.toggle_fullscreen", "f")}</span>
{" full screen "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.down", "j")}/{firstShortcut("diff.up", "k")}
</span>
{" scroll "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.next_file", "n")}/{firstShortcut("diff.previous_file", "p")}
</span>
{" files "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.next_hunk", "]")}/{firstShortcut("diff.previous_hunk", "[")}
</span>
{" hunks "}
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.close", "q")}</span>
{" close "}
<span style={{ fg: theme.text.default }}>{helpShortcut() ?? "?"}</span>
{" see all"}
</text>
</Show>
</box>
<box height={1} flexShrink={0} />
</Show>
<Show when={!showFileTree() && !props.elevated}>
<Show when={!showFileTree()}>
<box position="absolute" top={0} right={0} width={1} height={1}>
<HelpShortcut compact />
</box>
@@ -1243,77 +1077,7 @@ export function DiffViewerContent(props: {
)
}
function DiffFileMenu(props: {
context: Plugin.Context
state: FileMenuState
reviewed: boolean
onToggle: () => void
onClose: () => void
}) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.overlay
const [hovered, setHovered] = createSignal(false)
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
const run = () => {
props.onClose()
props.onToggle()
}
onCleanup(props.context.keymap.mode.push("menu"))
props.context.keymap.layer(() => ({
mode: "menu",
commands: [
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
{ bind: "return", title: label(), group: "Diff", run },
],
}))
return (
<box
id="diff-file-menu-overlay"
position="absolute"
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
zIndex={2600}
onMouseDown={(event) => {
props.onClose()
event.preventDefault()
event.stopPropagation()
}}
>
<box
id="diff-file-menu"
position="absolute"
left={Math.max(0, Math.min(props.state.x, dimensions().width - 19))}
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
width={19}
height={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
if (event.button === MouseButton.RIGHT) props.onClose()
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
if (event.button === MouseButton.LEFT) run()
}}
>
<text fg={theme.text.default} selectable={false}>
{label()}
</text>
</box>
</box>
)
}
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean; fullscreen: boolean }) {
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const shortcut =
@@ -1353,9 +1117,6 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean;
{ shortcut: shortcut("diff.single_patch"), label: "All files / single file" },
{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" },
{ shortcut: shortcut("diff.switch_source"), label: "Switch diff source" },
...(props.fullscreen
? [{ shortcut: shortcut("diff.toggle_fullscreen"), label: "Full screen / split view" }]
: []),
{ shortcut: () => props.context.keymap.shortcuts("diff.close").join(" / "), label: "Close diff viewer" },
],
},
@@ -1413,7 +1174,6 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean;
}
function Commands(props: { context: Plugin.Context }) {
const panel = useOptionalSessionPanel()
props.context.keymap.layer(() => ({
mode: "global",
commands: [
@@ -1425,15 +1185,6 @@ function Commands(props: { context: Plugin.Context }) {
palette: true,
run() {
const route = props.context.ui.router.current()
if (route.type === "session" && panel?.available(route.sessionID)) {
if (panel.current()?.id === ROUTE && panel.current()?.sessionID === route.sessionID) {
panel.close()
} else {
openDiffPanel(props.context, panel, route.sessionID)
}
props.context.ui.dialog.clear()
return
}
const returnRoute: Route =
route.type === "home"
? { type: "home" }
+33 -7
View File
@@ -1,7 +1,7 @@
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
import type { JSX } from "solid-js"
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode-ai/plugin/tui/context"
import type { Placement, PlacementKind } from "./structure"
import { namedSlotKey, type Placement, type PlacementKind } from "./structure"
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { useClient } from "../context/client"
@@ -20,6 +20,7 @@ import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { useStorage } from "../context/storage"
import { useSessionTabs } from "../context/session-tabs"
import { useOptionalPanel } from "../context/panel"
import { abbreviateHome } from "../util/path-format"
export type Dispose = () => Promise<void>
@@ -30,6 +31,7 @@ export type SlotRender = (input: SlotMap[SlotPath]) => JSX.Element
// A registered claim as stored by the plugin provider's registry.
export type RegisteredSlot = {
readonly name?: string
readonly placement: Placement
readonly render: SlotRender
}
@@ -68,6 +70,7 @@ export function usePluginHost() {
attention: useAttention(),
storage: useStorage(),
sessionTabs: useSessionTabs(),
panel: useOptionalPanel(),
}
}
@@ -97,11 +100,12 @@ export function createPluginContext(input: {
}
// Unregistering after deactivation is a no-op: deactivate already resets
// the registration's routes and slots wholesale.
const registration = (kind: "routes" | "slots" | "markdown", name: string) => {
const registration = (kind: "routes" | "slots" | "markdown", name: string, onRemove?: () => void) => {
let registered = true
const unregister = () => {
if (!registered) return
registered = false
onRemove?.()
if (!input.registry.active()) return
input.registry.remove(kind, name)
}
@@ -174,6 +178,22 @@ export function createPluginContext(input: {
return host.route.data
},
},
panel: {
open(name, options) {
if (!host.panel || !input.registry.active()) return false
if (!input.registry.has("slots", namedSlotKey("session.panel", name))) return false
const route = host.route.data
if (route.type !== "session") return false
host.panel.open({ plugin: input.id, name, sessionID: route.sessionID }, options?.presentation)
return true
},
close: () => host.panel?.release(input.id),
current() {
const current = host.panel?.current()
if (current?.plugin !== input.id) return
return { name: current.name, sessionID: current.sessionID }
},
},
tabs: {
enabled: host.sessionTabs.enabled,
list: () =>
@@ -206,19 +226,25 @@ export function createPluginContext(input: {
},
},
slot(value: SlotClaim) {
// Keys are counter-suffixed so one plugin may claim several places;
// order within the plugin is registration order.
const key = `slot#${claims++}`
// Exactly one placement kind, enforced at runtime for untyped plugins.
const kinds = placements.filter((item) => value[item] !== undefined)
if (kinds.length !== 1) throw new Error("Slot claim requires exactly one placement key")
const kind = kinds[0]
const target = value[kind] as string
if (value.name !== undefined && !value.name) throw new Error("Slot names cannot be empty")
if (target === "session.panel" && !value.name) throw new Error("Session panels require a slot name")
if (target === "session.panel" && kind !== "replace") throw new Error("Session panels use replacement claims")
const key = value.name ? namedSlotKey(target, value.name) : `slot#${claims++}`
if (input.registry.has("slots", key)) throw new Error(`Slot already registered: ${value.name}`)
input.registry.set("slots", key, {
placement: { kind, target: value[kind] as string },
name: value.name,
placement: { kind, target },
// The registration map erases the path-specific input type.
render: (slotInput) => provide(() => (value.render as SlotRender)(slotInput)),
})
return registration("slots", key)
return registration("slots", key, () => {
if (target === "session.panel") host.panel?.release(input.id, value.name)
})
},
},
}
+25 -14
View File
@@ -19,7 +19,7 @@ import { fileURLToPath } from "url"
import type { Page } from "@opencode-ai/plugin/tui/context"
import { Hash } from "@opencode-ai/util/hash"
import { Host } from "@opencode-ai/plugin/host"
import { resolveSlots, type Claim } from "./structure"
import { namedSlotKey, resolveSlots, type Claim } from "./structure"
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
import { isDeepEqual } from "remeda"
import "#runtime-plugin-support"
@@ -60,6 +60,7 @@ type Value = {
// A mounted <Slot> instance registers its path; the disposer unregisters.
readonly register: (path: string) => () => void
readonly resolved: () => ReturnType<typeof resolveSlots<SlotRender>>
readonly named: (path: string, plugin: string, name: string) => RegisteredSlot | undefined
}
readonly markdown: () => MarkdownOptions["renderNode"]
readonly activate: (id: string) => Promise<boolean>
@@ -457,18 +458,20 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
// order within one plugin. The resolver's last-wins rules depend on it.
const claims = createMemo(() =>
Object.entries(store.registrations).flatMap(([id, registration]) =>
Object.entries(registration.active ? registration.slots : {}).map(([key, slot]) => {
// Rows downstream diff by reference; a stable claim per render
// function keeps untouched plugins' slot rows (and their state)
// alive across other plugins' reloads.
const cached = slotItems.get(slot.render)
if (cached) return cached
// Placements are immutable once registered; unwrap the store proxy
// so resolver reads don't subscribe tracked scopes.
const item = { key: `${id}/${key}`, plugin: id, placement: unwrap(slot.placement), render: slot.render }
slotItems.set(slot.render, item)
return item
}),
Object.entries(registration.active ? registration.slots : {})
.filter(([, slot]) => slot.placement.target !== "session.panel")
.map(([key, slot]) => {
// Rows downstream diff by reference; a stable claim per render
// function keeps untouched plugins' slot rows (and their state)
// alive across other plugins' reloads.
const cached = slotItems.get(slot.render)
if (cached) return cached
// Placements are immutable once registered; unwrap the store proxy
// so resolver reads don't subscribe tracked scopes.
const item = { key: `${id}/${key}`, plugin: id, placement: unwrap(slot.placement), render: slot.render }
slotItems.set(slot.render, item)
return item
}),
),
)
// Object.keys tracks the store's keys node only: refcount changes on an
@@ -555,7 +558,15 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
active: plugin.active,
})),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slots: { register: registerSlot, resolved },
slots: {
register: registerSlot,
resolved,
named(path, plugin, name) {
const registration = store.registrations[plugin]
if (!registration?.active) return
return registration.slots[namedSlotKey(path, name)]
},
},
markdown,
// Manual dialog toggles join the same chain as reconciles so a
// toggle mid-reload cannot mix registrations across generations.
+25 -1
View File
@@ -74,7 +74,11 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
const SlotParent = createContext<string>()
// `input` is required exactly when the path publishes a non-empty input.
type SlotProps<Path extends SlotPath> = ParentProps<{ readonly path: Path }> &
type SlotProps<Path extends SlotPath> = ParentProps<{
readonly path: Path
readonly selection?: { readonly plugin: string; readonly name: string }
}> &
(Path extends "session.panel" ? { readonly selection: { readonly plugin: string; readonly name: string } } : {}) &
({} extends SlotMap[Path] ? { readonly input?: SlotMap[Path] } : { readonly input: SlotMap[Path] })
// One named boundary of the host UI's slot tree. The host's own content are
@@ -97,6 +101,26 @@ export function Slot<Path extends SlotPath>(props: SlotProps<Path>) {
}
onCleanup(plugins.slots.register(path))
const input = () => (props as { readonly input?: SlotMap[Path] }).input ?? ({} as SlotMap[Path])
// Selected panels use the same owned registrations and boundary as composed
// slots, but choose a named contribution instead of last-enabled replacement.
const selected = createMemo(() => {
const selection = props.selection
if (!selection) return
return plugins.slots.named(path, selection.plugin, selection.name)
})
if (path === "session.panel") {
return (
<SlotParent.Provider value={path}>
<Show keyed when={selected()}>
{(claim) => (
<PluginBoundary id={props.selection!.plugin} where={`slot ${path}`}>
{createComponent(claim.render, mergeProps(input))}
</PluginBoundary>
)}
</Show>
</SlotParent.Provider>
)
}
const slotted = createMemo(
() => plugins.slots.resolved().slotted.get(path) ?? emptySlotted<SlotRender>(),
emptySlotted<SlotRender>(),
+4
View File
@@ -6,6 +6,10 @@
export type PlacementKind = "prepend" | "append" | "before" | "after" | "replace"
export function namedSlotKey(path: string, name: string) {
return `slot:${path}:${name}`
}
// Normalized from the public SlotClaim shape by the plugin API: exactly one
// placement kind, the target path erased to a string so the resolver stays
// independent of the slot map.
+23 -5
View File
@@ -48,6 +48,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const keymap = Keymap.use()
const enabled = Keymap.useEnabled()
const active = () => enabled() && keymap.mode.current() === FORM_MODE
const config = useConfig().data
const clipboard = useClipboard()
const toast = useToast()
@@ -68,6 +70,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
})
let textarea: TextareaRenderable | undefined
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable>()
let review: ScrollBoxRenderable | undefined
let measureReview: (() => void) | undefined
@@ -216,9 +219,22 @@ export function FormPrompt(props: { form: FormWithLocation }) {
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
})
// Refs publish after initialization so burst typing stays with the interceptor until the editor is ready.
createEffect(() => {
const target = inputTarget()
if (!target || target.isDestroyed) return
if (!active()) {
target.blur()
target.focusable = false
return
}
target.focusable = true
target.focus()
})
onCleanup(
keymap.intercept("key", ({ event, consume }) => {
if (keymap.mode.current() !== FORM_MODE) return
if (!active()) return
if (textual() || !other() || (store.editing && renderer.currentFocusedEditor === textarea)) return
if (event.ctrl || event.meta || event.option || event.super || event.hyper) return
if ((!store.editing && event.sequence === " ") || !/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)) return
@@ -328,7 +344,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}
usePaste((event) => {
if (keymap.mode.current() !== FORM_MODE) return
if (!active()) return
const value = stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n")
if (store.editing && renderer.currentFocusedEditor === textarea) {
textarea.insertText(value)
@@ -343,7 +359,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return clipboard
.read()
.then((content) => {
if (content?.mime !== "text/plain") return
if (!active() || content?.mime !== "text/plain") return
const value = stripAnsiSequences(content.data).replace(/\r\n?/g, "\n")
if (store.editing || textual()) {
textarea?.insertText(value)
@@ -878,8 +894,9 @@ export function FormPrompt(props: { form: FormWithLocation }) {
textarea = val
val.traits = { status: "ANSWER" }
queueMicrotask(() => {
val.focus()
if (val.isDestroyed) return
val.gotoLineEnd()
setInputTarget(val)
})
}}
initialValue={
@@ -1017,9 +1034,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
textarea = val
val.traits = { status: "ANSWER" }
queueMicrotask(() => {
if (val.isDestroyed) return
val.setText(input())
val.focus()
val.gotoLineEnd()
setInputTarget(val)
})
}}
initialValue={input()}
+3 -71
View File
@@ -113,7 +113,6 @@ import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { useSessionTerminals } from "../../context/session-terminals"
addDefaultParsers(parsers.parsers)
@@ -161,6 +160,7 @@ export function Session(props: {
sidebarVisible: boolean
onToggleSidebar: () => void
visibleTerminalID?: string
onTerminalPicker?: (show: (() => void) | undefined) => void
width?: number
}) {
const setEpilogue = useEpilogue()
@@ -234,6 +234,8 @@ export function Session(props: {
open: false,
tab: undefined as string | undefined,
})
props.onTerminalPicker?.(() => setComposer({ open: true, tab: "terminals" }))
onCleanup(() => props.onTerminalPicker?.(undefined))
createEffect(() => {
if (props.promptMuted && composer.open) setComposer("open", false)
})
@@ -260,7 +262,6 @@ export function Session(props: {
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const toast = useToast()
const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" })
const client = useClient()
const autoApproved = new Set<string>()
createEffect(() => {
@@ -295,7 +296,6 @@ export function Session(props: {
const [firstJump, setFirstJump] = createSignal<() => void>()
const [synced, setSynced] = createSignal(false)
const sessionTabs = useSessionTabs()
const terminals = useSessionTerminals()
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const [latestHovered, setLatestHovered] = createSignal(false)
let ensureAllRowsPending: (() => void)[] | undefined
@@ -976,73 +976,6 @@ export function Session(props: {
})()
},
},
{
title: props.sidebarVisible ? "Hide sidebar" : "Show sidebar",
id: "session.sidebar.toggle",
group: "Session",
run: () => {
props.onToggleSidebar()
dialog.clear()
},
},
...(config.session.terminal
? [
{
title: props.visibleTerminalID ? "Hide terminal pane" : "Show terminal pane",
id: "terminal.toggle",
group: "Session",
run: () => {
const sessionID = route.sessionID
if (props.visibleTerminalID) {
promptRef.current?.focus()
void terminals.selectTerminal(sessionID, null).catch(toast.error)
} else {
void terminals
.refresh(sessionID)
.then(async () => {
const terminal = terminals.get(sessionID).terminals.at(-1)
if (terminal) return terminals.selectTerminal(sessionID, terminal.id)
await terminals.newTerminal(sessionID)
})
.catch(terminalError)
}
dialog.clear()
},
},
{
title: "Select terminal",
id: "terminal.select",
group: "Session",
run: () => {
promptRef.current?.focus()
setComposer({ open: true, tab: "terminals" })
void terminals.refresh(route.sessionID).catch(terminalError)
dialog.clear()
},
},
{
title: "Close terminal pane",
id: "terminal.close",
group: "Session",
enabled: props.visibleTerminalID !== undefined,
run: () => {
promptRef.current?.focus()
void terminals.selectTerminal(route.sessionID, null).catch(toast.error)
dialog.clear()
},
},
{
title: "New terminal",
id: "session.terminal",
group: "Session",
slash: { name: "terminal" },
run: async () => {
dialog.clear()
await terminals.newTerminal(route.sessionID).catch(terminalError)
},
},
]
: []),
{
title: (() => {
const next = nextThinkingMode(thinkingMode())
@@ -1507,7 +1440,6 @@ export function Session(props: {
<Prompt
visible={true}
ref={bind}
disabled={props.promptMuted}
muted={props.promptMuted}
onSubmit={() => {
toBottom()
@@ -288,6 +288,7 @@ function RejectPrompt(props: {
onCancel: () => void
}) {
let input: TextareaRenderable
const enabled = Keymap.useEnabled()
const theme = useTheme("elevated")
const config = useConfig().data
const dimensions = useTerminalDimensions()
@@ -364,7 +365,7 @@ function RejectPrompt(props: {
}))(val)
val.traits = { status: "REJECT" }
}}
focused
focused={enabled()}
textColor={theme.text.default}
focusedTextColor={theme.text.default}
cursorColor={theme.text.default}
+11 -9
View File
@@ -3,7 +3,16 @@ import type { Accessor } from "solid-js"
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode-ai/theme/tui"
export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
const create = (view: Accessor<ResolvedThemeTokens>) => ({
return Object.assign(createComponentThemeView(current, mode), {
contextual: {
elevated: createComponentThemeView(() => current().contextual.elevated, mode),
overlay: createComponentThemeView(() => current().contextual.overlay, mode),
},
})
}
export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mode: Accessor<Mode>) {
return {
get hue() {
return view().hue
},
@@ -35,14 +44,7 @@ export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Acc
increase: (color: RGBA, amount = 1) => view().increase(color, amount),
decrease: (color: RGBA, amount = 1) => view().decrease(color, amount),
raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)),
})
return Object.assign(create(current), {
contextual: {
elevated: create(() => current().contextual.elevated),
overlay: create(() => current().contextual.overlay),
},
})
}
}
export type ComponentTheme = ReturnType<typeof createComponentTheme>
+16 -55
View File
@@ -5,10 +5,9 @@ import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import path from "node:path"
import { createEventStream, createFetch, directory, json, type FetchHandler } from "./fixture/tui-client"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { createAppFixture } from "./fixture/tui-app"
import { tmpdir } from "./fixture/fixture"
import type { TuiInput } from "../src/app"
import type { Config } from "../src/config"
import type { PluginInfo } from "@opencode-ai/client"
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
@@ -1229,7 +1228,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
})
test.each(["manual", "select"] as const)(
"selection copy and dismissal respect %s mode in the prompt and terminal pane",
"selection copy and pane management respect %s mode in the prompt and terminal pane",
async (copy) => {
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
@@ -1360,6 +1359,19 @@ test.each(["manual", "select"] as const)(
expect(setup.renderer.hasSelection).toBeFalse()
expect(setup.renderer.isDestroyed).toBeFalse()
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressArrow("up")
await setup.waitFor(() => terminal.isDestroyed)
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("")
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressKey("t")
await setup.waitForFrame((frame) => frame.includes("alpha beta gamma"))
expect(setup.renderer.currentFocusedRenderable).toBeInstanceOf(EmbeddedTerminalRenderable)
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressArrow("down")
await setup.waitForFrame((frame) => frame.includes("Subagents") && frame.includes("Terminals"))
expect(setup.renderer.currentFocusedRenderable).not.toBeInstanceOf(EmbeddedTerminalRenderable)
setup.renderer.destroy()
await task
} finally {
@@ -1530,54 +1542,3 @@ test("server plugin failures share one notice and use source names before an ID
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
expect(setup.captureCharFrame()).toContain("Open plugins")
})
async function createAppFixture(
input: {
width?: number
height?: number
state?: string
config?: Config.Info
args?: TuiInput["args"]
fetch?: FetchHandler
} = {},
) {
const { run } = await import("../src/app")
const setup = await createTestRenderer({
width: input.width ?? 100,
height: input.height ?? 30,
useThread: false,
kittyKeyboard: true,
})
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(input.fetch, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: input.args ?? {},
log: () => {},
}).pipe(
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
Effect.provide(FileSystem.layerNoop({})),
),
)
return {
...setup,
events,
ready: ready.promise,
async [Symbol.asyncDispose]() {
try {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task
} finally {
await server.stop()
}
},
}
}
@@ -0,0 +1,289 @@
/** @jsxImportSource @opentui/solid */
import type { Plugin } from "@opencode-ai/plugin/tui"
import { MouseButton } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal, Show } from "solid-js"
import { Keymap } from "../../../src/context/keymap"
import { DiffFileMenu } from "../../../src/feature-plugins/system/diff-viewer-file-menu"
import { DEFAULT_THEMES, parseTheme, resolveThemeDocument } from "../../../src/theme"
test.each(["dark", "light"] as const)(
"file menus escape an offset, narrower clipping pane in %s mode",
async (mode) => {
const menu = await renderFileMenu(mode)
try {
const pane = menu.app.renderer.root.findDescendantById("test-diff-pane")!
expect([pane.x, pane.y, pane.width]).toEqual([48, 4, 12])
expect(menu.app.renderer.currentFocusedRenderable).toBe(pane)
await menu.app.mockMouse.click(pane.x + 2, pane.y, MouseButton.RIGHT)
await menu.app.waitForFrame((frame) => frame.includes("Mark complete"))
const overlay = menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")!
const popup = menu.app.renderer.root.findDescendantById("diff-file-menu")!
expect([overlay.x, overlay.y, overlay.width, overlay.height]).toEqual([0, 0, 80, 20])
expect(overlay.parent?.parent).toBe(menu.app.renderer.root)
expect([popup.x, popup.y, popup.width, popup.height]).toEqual([50, 5, 19, 1])
expect(popup.x + popup.width).toBeGreaterThan(pane.x + pane.width)
expect(menu.app.captureCharFrame().split("\n")[5].indexOf("Mark complete")).toBe(51)
expect(menu.mode()).toBe("menu")
expect(menu.app.renderer.currentFocusedRenderable).toBe(pane)
const idle = menu.app.captureSpans().lines[popup.y].spans.find((span) => span.text.includes("Mark complete"))!
expect(idle.fg).toEqual(menu.theme.contextual.overlay.text.default)
expect(idle.bg).toEqual(menu.theme.contextual.overlay.background.default)
// The action remains clickable outside the clipping pane's right edge.
await menu.app.mockMouse.moveTo(pane.x + pane.width + 1, popup.y)
await menu.app.flush()
const hovered = menu.app.captureSpans().lines[popup.y].spans.find((span) => span.text.includes("Mark complete"))!
expect(hovered.bg).toEqual(menu.theme.contextual.overlay.background.action.primary.hovered)
await menu.app.mockMouse.moveTo(1, 1)
await menu.app.flush()
expect(
menu.app.captureSpans().lines[popup.y].spans.find((span) => span.text.includes("Mark complete"))!.bg,
).toEqual(idle.bg)
await menu.app.mockMouse.click(pane.x + pane.width + 1, popup.y)
await menu.app.flush()
expect(menu.calls).toEqual(["close", "toggle"])
expect(menu.reviewed()).toBe(true)
expect(menu.mode()).toBe("base")
expect(menu.app.renderer.currentFocusedRenderable).toBe(pane)
expect(menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")).toBeUndefined()
} finally {
menu.app.renderer.destroy()
}
},
)
test("file menus clamp to screen edges and follow terminal resizes rather than pane bounds", async () => {
const menu = await renderFileMenu()
try {
menu.open(79, 19)
await menu.app.flush()
const popup = menu.app.renderer.root.findDescendantById("diff-file-menu")!
const overlay = menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")!
expect([popup.x, popup.y, popup.width, popup.height]).toEqual([61, 19, 19, 1])
expect(menu.app.captureCharFrame().split("\n")[19]).toContain("Mark complete")
menu.app.resize(64, 14)
await menu.app.flush()
expect([overlay.x, overlay.y, overlay.width, overlay.height]).toEqual([0, 0, 64, 14])
expect([popup.x, popup.y, popup.width, popup.height]).toEqual([45, 13, 19, 1])
expect(menu.app.captureCharFrame().split("\n")[13]).toContain("Mark complete")
menu.app.resize(12, 8)
await menu.app.flush()
expect([overlay.width, overlay.height]).toEqual([12, 8])
expect([popup.x, popup.y, popup.width, popup.height]).toEqual([0, 7, 12, 1])
expect(menu.app.captureCharFrame().split("\n")[7]).toContain("...")
menu.open(-3, -2)
await menu.app.flush()
const clamped = menu.app.renderer.root.findDescendantById("diff-file-menu")!
expect([clamped.x, clamped.y]).toEqual([0, 0])
} finally {
menu.app.renderer.destroy()
}
})
test("clicking outside the pane dismisses its menu without activating the underlying control", async () => {
const menu = await renderFileMenu()
try {
menu.open(50, 4)
await menu.app.flush()
await menu.app.mockMouse.click(1, 1)
await menu.app.flush()
expect(menu.calls).toEqual(["close"])
expect(menu.mode()).toBe("base")
expect(menu.app.renderer.currentFocusedRenderable?.id).toBe("test-diff-pane")
expect(menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")).toBeUndefined()
await menu.app.mockMouse.click(1, 1)
expect(menu.calls).toEqual(["close", "outside"])
} finally {
menu.app.renderer.destroy()
}
})
test.each(["escape", "ctrl+c"] as const)("%s dismisses only the file menu and restores pane commands", async (key) => {
const menu = await renderFileMenu()
try {
menu.open(50, 4)
await menu.app.flush()
menu.app.mockInput.pressKey("j")
await menu.app.flush()
expect(menu.calls).toEqual([])
if (key === "escape") menu.app.mockInput.pressEscape()
if (key === "ctrl+c") menu.app.mockInput.pressKey("c", { ctrl: true })
await menu.app.flush()
expect(menu.calls).toEqual(["close"])
expect(menu.mode()).toBe("base")
expect(menu.app.renderer.currentFocusedRenderable?.id).toBe("test-diff-pane")
expect(menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")).toBeUndefined()
menu.app.mockInput.pressKey("j")
expect(menu.calls).toEqual(["close", "pane"])
} finally {
menu.app.renderer.destroy()
}
})
test("Enter toggles either review state after closing the menu and leaves no stale menu bindings", async () => {
const menu = await renderFileMenu()
try {
menu.open(50, 4)
await menu.app.waitForFrame((frame) => frame.includes("Mark complete"))
menu.app.mockInput.pressEnter()
await menu.app.flush()
expect(menu.reviewed()).toBe(true)
expect(menu.calls).toEqual(["close", "toggle"])
expect(menu.mode()).toBe("base")
menu.open(50, 4)
await menu.app.waitForFrame((frame) => frame.includes("Mark incomplete"))
menu.app.mockInput.pressEnter()
await menu.app.flush()
expect(menu.reviewed()).toBe(false)
expect(menu.calls).toEqual(["close", "toggle", "close", "toggle"])
expect(menu.mode()).toBe("base")
expect(menu.app.renderer.currentFocusedRenderable?.id).toBe("test-diff-pane")
expect(menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")).toBeUndefined()
menu.app.mockInput.pressEnter()
expect(menu.calls).toEqual(["close", "toggle", "close", "toggle", "pane"])
} finally {
menu.app.renderer.destroy()
}
})
test("right-clicking the menu dismisses without toggling", async () => {
const menu = await renderFileMenu()
try {
menu.open(50, 4)
await menu.app.flush()
await menu.app.mockMouse.click(51, 5, MouseButton.RIGHT)
await menu.app.flush()
expect(menu.calls).toEqual(["close"])
expect(menu.mode()).toBe("base")
expect(menu.reviewed()).toBe(false)
expect(menu.app.renderer.currentFocusedRenderable?.id).toBe("test-diff-pane")
} finally {
menu.app.renderer.destroy()
}
})
test("portaling the menu leaves its mode and commands owned by the pane's keymap scope", async () => {
const menu = await renderFileMenu()
try {
menu.open(50, 4)
await menu.app.flush()
expect(menu.mode()).toBe("menu")
menu.setEnabled(false)
await menu.app.flush()
expect(menu.mode()).toBe("base")
menu.app.mockInput.pressEnter()
menu.app.mockInput.pressEscape()
await menu.app.flush()
expect(menu.calls).toEqual([])
expect(menu.app.renderer.root.findDescendantById("diff-file-menu")).toBeDefined()
menu.setEnabled(true)
await menu.app.flush()
expect(menu.mode()).toBe("menu")
menu.app.mockInput.pressEnter()
await menu.app.flush()
expect(menu.calls).toEqual(["close", "toggle"])
expect(menu.mode()).toBe("base")
} finally {
menu.app.renderer.destroy()
}
})
async function renderFileMenu(mode: "dark" | "light" = "dark") {
const theme = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
const calls: string[] = []
const [state, setState] = createSignal<{ fileIndex: number; x: number; y: number }>()
const [reviewed, setReviewed] = createSignal(false)
const [enabled, setEnabled] = createSignal(true)
const open = (x: number, y: number) => setState({ fileIndex: 0, x, y })
let currentMode = () => "base"
function Harness() {
const keymap = Keymap.use()
currentMode = keymap.mode.current
const context: Pick<Plugin.Context, "theme" | "keymap"> = {
theme,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
shortcuts: Keymap.useShortcuts().list,
...Keymap.useState(),
mode: keymap.mode,
},
}
Keymap.createLayer(() => ({
commands: [{ bind: "escape,ctrl+c,return,j", title: "Pane command", run: () => void calls.push("pane") }],
}))
return (
<box width="100%" height="100%" backgroundColor={theme.background.default}>
<box position="absolute" left={0} top={0} width={20} height={3} onMouseDown={() => calls.push("outside")}>
<text>Other pane</text>
</box>
<box
id="test-diff-pane"
position="absolute"
left={48}
top={4}
width={12}
height={6}
overflow="hidden"
focusable
focused
>
<text
onMouseDown={(event) => {
if (event.button !== MouseButton.RIGHT) return
open(event.x, event.y)
event.preventDefault()
event.stopPropagation()
}}
>
file.txt
</text>
<Show when={state()} keyed>
{(state) => (
<DiffFileMenu
context={context as Plugin.Context}
state={state}
reviewed={reviewed()}
onClose={() => {
calls.push("close")
setState(undefined)
}}
onToggle={() => {
calls.push("toggle")
setReviewed((value) => !value)
}}
/>
)}
</Show>
</box>
</box>
)
}
const app = await testRender(
() => (
<Keymap.Provider config={{ keybinds: { get: () => [] } }}>
<Keymap.Scope enabled={enabled()}>
<Harness />
</Keymap.Scope>
</Keymap.Provider>
),
{ width: 80, height: 20, kittyKeyboard: true },
)
await app.flush()
return { app, calls, theme, open, reviewed, setEnabled, mode: () => currentMode() }
}
+1 -41
View File
@@ -37,7 +37,6 @@ import { createApi, createEventStream, createFetch, json } from "../../fixture/t
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { createDialogApi } from "../../../src/plugin/api"
import { ToastProvider } from "../../../src/ui/toast"
import { SessionPanelProvider } from "../../../src/context/session-panel"
import { createSignal, Show } from "solid-js"
import { diffImageFixture } from "../../fixture/diff-image"
@@ -70,43 +69,6 @@ test("closing the diff viewer returns to the route it opened from", async () =>
}
})
test("full-screen diff only returns to split view when opened from an eligible panel", async () => {
const narrow = await renderDiffViewer([], {
width: 80,
initialRoute: {
type: "plugin",
id: "opencode.diffs",
name: "diff",
data: { sessionID: "session-1", returnRoute: startRoute },
},
})
try {
const command = narrow.commands.get("diff.toggle_fullscreen")
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(false)
} finally {
narrow.app.renderer.destroy()
}
const eligible = await renderDiffViewer([], {
width: 160,
initialRoute: {
type: "plugin",
id: "opencode.diffs",
name: "diff",
data: { sessionID: "session-1", returnRoute: startRoute, split: true },
},
})
try {
const command = eligible.commands.get("diff.toggle_fullscreen")
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(true)
command?.run()
await eligible.app.flush()
expect(eligible.current()).toEqual(startRoute)
} finally {
eligible.app.renderer.destroy()
}
})
test("ctrl+c closes the diff viewer without exiting the application", async () => {
const viewer = await renderDiffViewer([])
@@ -2010,9 +1972,7 @@ async function renderDiffViewer(
<ToastProvider>
<ThemeProvider mode={options.mode ?? "dark"} source={emptyThemeSource}>
<DialogProvider>
<SessionPanelProvider>
<Content />
</SessionPanelProvider>
<Content />
</DialogProvider>
</ThemeProvider>
</ToastProvider>
@@ -0,0 +1,278 @@
/** @jsxImportSource @opentui/solid */
import type { PermissionRequest } from "@opencode-ai/client"
import type { TextareaRenderable } from "@opentui/core"
import { testRender, type JSX } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal, onMount } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData, type FormWithLocation } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider } from "../../../src/context/location"
import { ThemeProvider } from "../../../src/context/theme"
import { FormPrompt, FORM_MODE } from "../../../src/routes/session/form"
import { PermissionPrompt } from "../../../src/routes/session/permission"
import { ToastProvider } from "../../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
async function mountPanes(root: string, render: () => JSX.Element, parentID?: string) {
const [active, setActive] = createSignal(false)
const replies: unknown[] = []
const cancellations: string[] = []
const submissions: string[] = []
const ready = Promise.withResolvers<void>()
let peer!: TextareaRenderable
let keymap!: Keymap
const transport = createFetch((url, request) => {
if (url.pathname === "/api/session/ses_scoped")
return json({
data: {
id: "ses_scoped",
parentID,
title: "Scoped session",
projectID: "proj_test",
location: { directory: root },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
if (url.pathname.endsWith("/reply"))
return request.json().then((body) => {
replies.push(body)
return new Response(null, { status: 204 })
})
if (url.pathname.endsWith("/cancel")) {
cancellations.push(url.pathname)
return new Response(null, { status: 204 })
}
}, createEventStream())
function Panes() {
const data = useData()
keymap = Keymap.use()
onMount(() => void data.session.sync("ses_scoped").then(ready.resolve, ready.reject))
return (
<box>
<Keymap.Scope enabled={!active()}>
<textarea
ref={(value) => (peer = value)}
focused={!active()}
initialValue="peer"
onSubmit={() => submissions.push(peer.plainText)}
/>
</Keymap.Scope>
<Keymap.Scope enabled={active()}>{render()}</Keymap.Scope>
</box>
)
}
const app = await testRender(
() => (
<TestTuiContexts directory={root} paths={{ home: root, state: root, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig({ animations: false })}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<DataProvider directory={root}>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<Panes />
</ToastProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 90, height: 24, kittyKeyboard: true },
)
app.renderer.start()
await ready.promise
await app.renderOnce()
return { app, setActive, replies, cancellations, submissions, peer, keymap }
}
function form(fields: FormWithLocation["fields"]): FormWithLocation {
return { id: "frm_scoped", sessionID: "ses_scoped", title: "Scoped form", fields }
}
const request = {
id: "per_scoped",
sessionID: "ses_scoped",
action: "shell",
resources: ["echo scoped"],
} satisfies PermissionRequest
test("an inactive form leaves Enter, navigation, and paste with the focused peer", async () => {
await using tmp = await tmpdir()
const panes = await mountPanes(tmp.path, () => (
<FormPrompt
form={form([
{
key: "target",
type: "string",
options: [
{ value: "staging", label: "Staging" },
{ value: "production", label: "Production" },
],
},
])}
/>
))
try {
expect(panes.keymap.mode.current()).toBe("base")
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
panes.app.mockInput.pressEnter()
panes.app.mockInput.pressArrow("down")
panes.app.mockInput.pressKey("2")
panes.app.mockInput.pressEscape()
await panes.app.mockInput.pasteBracketedText(" pasted")
expect(panes.submissions).toEqual(["peer"])
expect(panes.peer.plainText).toContain("pasted")
expect(panes.replies).toEqual([])
expect(panes.cancellations).toEqual([])
panes.setActive(true)
expect(panes.keymap.mode.current()).toBe(FORM_MODE)
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ answer: { target: "staging" } }])
} finally {
panes.app.renderer.destroy()
}
})
test("a form textarea mounts inactive and restores its draft focus after scope and modal changes", async () => {
await using tmp = await tmpdir()
const panes = await mountPanes(tmp.path, () => <FormPrompt form={form([{ key: "notes", type: "string" }])} />)
try {
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
panes.setActive(true)
const input = panes.app.renderer.currentFocusedEditor
expect(input).not.toBeNull()
expect(input?.id).not.toBe(panes.peer.id)
await panes.app.mockInput.typeText("draft answer")
const pop = panes.keymap.mode.push("modal")
expect(panes.app.renderer.currentFocusedEditor).toBeNull()
panes.setActive(false)
panes.setActive(true)
expect(panes.keymap.mode.current()).toBe("modal")
expect(panes.app.renderer.currentFocusedEditor).toBeNull()
pop()
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
panes.setActive(false)
input?.focus()
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
await panes.app.mockInput.typeText(" other")
await panes.app.mockInput.pasteBracketedText(" pane")
panes.app.mockInput.pressEnter()
expect(panes.submissions).toHaveLength(1)
expect(input?.plainText).toBe("draft answer")
expect(panes.replies).toEqual([])
panes.setActive(true)
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
panes.app.mockInput.pressEnter()
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ answer: { notes: "draft answer" } }])
} finally {
panes.app.renderer.destroy()
}
})
test("inactive custom forms cannot intercept a peer using the same form mode", async () => {
await using tmp = await tmpdir()
const panes = await mountPanes(tmp.path, () => (
<FormPrompt
form={form([{ key: "target", type: "string", options: [{ value: "staging", label: "Staging" }], custom: true }])}
/>
))
try {
panes.setActive(true)
panes.app.mockInput.pressArrow("down")
panes.setActive(false)
const pop = panes.keymap.mode.push(FORM_MODE)
await panes.app.mockInput.typeText(" typed")
await panes.app.mockInput.pasteBracketedText(" pasted")
panes.app.mockInput.pressEnter()
await panes.app.renderOnce()
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
expect(panes.submissions).toHaveLength(1)
expect(panes.peer.plainText).toContain("typed")
expect(panes.peer.plainText).toContain("pasted")
expect(panes.app.captureCharFrame()).toContain("Type your own answer")
expect(panes.replies).toEqual([])
pop()
panes.setActive(true)
await panes.app.mockInput.typeText("production target")
await panes.app.waitFor(() => panes.app.renderer.currentFocusedEditor?.plainText === "production target")
panes.setActive(false)
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
panes.setActive(true)
expect(panes.app.renderer.currentFocusedEditor?.plainText).toBe("production target")
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ answer: { target: "production target" } }])
} finally {
panes.app.renderer.destroy()
}
})
test("permission layers leave the focused peer's Enter and navigation alone until activated", async () => {
await using tmp = await tmpdir()
const panes = await mountPanes(tmp.path, () => <PermissionPrompt request={request} />)
try {
panes.app.mockInput.pressEnter()
panes.app.mockInput.pressArrow("right")
panes.app.mockInput.pressEscape()
expect(panes.submissions).toEqual(["peer"])
expect(panes.replies).toEqual([])
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
panes.setActive(true)
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ reply: "once" }])
expect(panes.submissions).toHaveLength(1)
} finally {
panes.app.renderer.destroy()
}
})
test("permission rejection text keeps its draft and regains focus when its scope resumes", async () => {
await using tmp = await tmpdir()
const panes = await mountPanes(tmp.path, () => <PermissionPrompt request={request} />, "ses_parent")
try {
panes.setActive(true)
panes.app.mockInput.pressEscape()
await panes.app.waitForFrame((frame) => frame.includes("Reject permission"))
const input = panes.app.renderer.currentFocusedEditor
expect(input).not.toBeNull()
await panes.app.mockInput.typeText("choose another command")
panes.setActive(false)
panes.app.mockInput.pressEnter()
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
expect(panes.submissions).toEqual(["peer"])
expect(panes.replies).toEqual([])
expect(input?.plainText).toBe("choose another command")
panes.setActive(true)
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ reply: "reject", message: "choose another command" }])
} finally {
panes.app.renderer.destroy()
}
})
@@ -2,6 +2,7 @@
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { createSignal } from "solid-js"
import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
@@ -173,3 +174,45 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
app.renderer.destroy()
}
})
test.each(["dark", "light"] as const)(
"reactive %s theme contexts change without remounting their contents",
async (mode) => {
const [context, setContext] = createSignal<"elevated" | undefined>("elevated")
let theme: ReturnType<typeof useTheme> | undefined
let themes: ReturnType<typeof useThemes> | undefined
let mounts = 0
function Probe() {
mounts++
theme = useTheme()
themes = useThemes()
return <text fg={theme.text.default}>probe</text>
}
const app = await testRender(() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode } })}>
<ThemeProvider mode={mode} source={{ discover: async () => ({}) }}>
<ThemeContextProvider context={context}>
<Probe />
</ThemeContextProvider>
</ThemeProvider>
</ConfigProvider>
))
app.renderer.start()
try {
await wait(() => themes?.ready === true)
if (!theme || !themes) throw new Error("Theme provider is not mounted")
const view = theme
expect(view.background.default).toBe(themes.current.contextual.elevated.background.default)
setContext(undefined)
await app.flush()
expect(view.background.default).toBe(themes.current.background.default)
setContext("elevated")
await app.flush()
expect(view.text.default).toBe(themes.current.contextual.elevated.text.default)
expect(theme).toBe(view)
expect(mounts).toBe(1)
} finally {
app.renderer.destroy()
}
},
)
-1
View File
@@ -209,7 +209,6 @@ test("centralizes named command defaults and resolves explicit none", () => {
"diff.next_hunk": "]",
"diff.previous_hunk": "[",
"diff.mark_reviewed": "m",
"diff.toggle_fullscreen": "f",
"diff.help": "?,shift+?,shift+/",
}
const config = resolve({}, { terminalSuspend: true })
+58
View File
@@ -0,0 +1,58 @@
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import type { TuiInput } from "../../src/app"
import type { Config } from "../../src/config"
import { createEventStream, createFetch, type FetchHandler } from "./tui-client"
export async function createAppFixture(
input: {
width?: number
height?: number
state?: string
config?: Config.Info
args?: TuiInput["args"]
fetch?: FetchHandler
} = {},
) {
const { run } = await import("../../src/app")
const setup = await createTestRenderer({
width: input.width ?? 100,
height: input.height ?? 30,
useThread: false,
kittyKeyboard: true,
})
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(input.fetch, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: input.args ?? {},
log: () => {},
}).pipe(
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
Effect.provide(FileSystem.layerNoop({})),
),
)
return {
...setup,
events,
ready: ready.promise,
async [Symbol.asyncDispose]() {
try {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task
} finally {
await server.stop()
}
},
}
}
+270
View File
@@ -0,0 +1,270 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal, onCleanup, onMount, Show } from "solid-js"
import { Keymap } from "../src/context/keymap"
const config = { keybinds: { get: () => [] } }
test("disabled scopes isolate named, inline, and global layers without disabling application commands", async () => {
const calls: string[] = []
const [enabled, setEnabled] = createSignal(false)
let keymap!: Keymap
function Scoped() {
Keymap.createLayer(() => ({
commands: [
{ id: "scoped.submit", bind: "return", run: () => void calls.push("submit") },
{ bind: "j", run: () => void calls.push("inline") },
],
}))
Keymap.createLayer(() => ({
mode: "global",
commands: [{ id: "scoped.global", bind: "g", run: () => void calls.push("scoped global") }],
}))
return null
}
function Harness() {
keymap = Keymap.use()
Keymap.createLayer(() => ({
mode: "global",
commands: [{ id: "app.global", bind: "x", run: () => void calls.push("app global") }],
}))
return (
<Keymap.Scope enabled={enabled()}>
<Scoped />
</Keymap.Scope>
)
}
const app = await testRender(() => (
<Keymap.Provider config={config}>
<Harness />
</Keymap.Provider>
))
try {
app.mockInput.pressEnter()
app.mockInput.pressKey("j")
app.mockInput.pressKey("g")
keymap.dispatch("scoped.submit")
keymap.dispatch("scoped.global")
app.mockInput.pressKey("x")
expect(calls).toEqual(["app global"])
setEnabled(true)
app.mockInput.pressEnter()
app.mockInput.pressKey("j")
app.mockInput.pressKey("g")
expect(calls).toEqual(["app global", "submit", "inline", "scoped global"])
const pop = keymap.mode.push("modal")
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
app.mockInput.pressKey("x")
expect(calls.slice(4)).toEqual(["scoped global", "app global"])
setEnabled(false)
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
app.mockInput.pressKey("x")
expect(calls.slice(6)).toEqual(["app global"])
pop()
} finally {
app.renderer.destroy()
}
})
test("nested scopes conjoin ancestors and retain dispatch-time layer predicates", async () => {
const calls: string[] = []
const [parent, setParent] = createSignal(false)
const [child, setChild] = createSignal(true)
const [layer, setLayer] = createSignal(true)
let allowed = true
let read!: () => boolean
let unscoped!: () => boolean
function Scoped() {
read = Keymap.useEnabled()
Keymap.createLayer(() => ({
enabled: layer(),
commands: [{ bind: "return", run: () => void calls.push("boolean") }],
}))
Keymap.createLayer(() => ({
mode: "global",
enabled: () => allowed,
commands: [{ bind: "g", run: () => void calls.push("predicate") }],
}))
return null
}
function Harness() {
unscoped = Keymap.useEnabled()
return (
<Keymap.Scope enabled={parent()}>
<Keymap.Scope enabled={child()}>
<Scoped />
</Keymap.Scope>
</Keymap.Scope>
)
}
const app = await testRender(() => (
<Keymap.Provider config={config}>
<Harness />
</Keymap.Provider>
))
try {
expect(unscoped()).toBe(true)
expect(read()).toBe(false)
app.mockInput.pressEnter()
setParent(true)
expect(read()).toBe(true)
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
allowed = false
app.mockInput.pressKey("g")
setLayer(false)
app.mockInput.pressEnter()
expect(calls).toEqual(["boolean", "predicate"])
setChild(false)
setLayer(true)
allowed = true
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
expect(read()).toBe(false)
setParent(false)
setChild(true)
expect(read()).toBe(false)
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
expect(calls).toEqual(["boolean", "predicate"])
setParent(true)
expect(read()).toBe(true)
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
expect(calls).toEqual(["boolean", "predicate", "boolean", "predicate"])
} finally {
app.renderer.destroy()
}
})
test("ownerless mode pushes suspend and resume in their captured scope without changing stack order", async () => {
const [enabled, setEnabled] = createSignal(false)
const calls: string[] = []
let scoped!: Keymap
let global!: Keymap
function Scoped() {
scoped = Keymap.use()
Keymap.createLayer(() => ({
mode: "form",
commands: [{ bind: "return", run: () => void calls.push("form") }],
}))
Keymap.createLayer(() => ({
mode: "menu",
commands: [{ bind: "return", run: () => void calls.push("menu") }],
}))
return null
}
function Harness() {
global = Keymap.use()
return (
<Keymap.Scope enabled={enabled()}>
<Scoped />
</Keymap.Scope>
)
}
const app = await testRender(() => (
<Keymap.Provider config={config}>
<Harness />
</Keymap.Provider>
))
try {
const form = scoped.mode.push("form")
expect(global.mode.current()).toBe("base")
app.mockInput.pressEnter()
const modal = global.mode.push("modal")
setEnabled(true)
expect(global.mode.current()).toBe("modal")
app.mockInput.pressEnter()
modal()
expect(global.mode.current()).toBe("form")
app.mockInput.pressEnter()
expect(calls).toEqual(["form"])
setEnabled(false)
expect(global.mode.current()).toBe("base")
const menu = scoped.mode.push("menu")
form()
expect(global.mode.current()).toBe("base")
setEnabled(true)
expect(global.mode.current()).toBe("menu")
app.mockInput.pressEnter()
expect(calls).toEqual(["form", "menu"])
menu()
expect(global.mode.current()).toBe("base")
setEnabled(false)
setEnabled(true)
expect(global.mode.current()).toBe("base")
app.mockInput.pressEnter()
expect(calls).toEqual(["form", "menu"])
} finally {
app.renderer.destroy()
}
})
test("forwarded keymaps push modes in the calling component's nested scope and clean up while inactive", async () => {
const [enabled, setEnabled] = createSignal(false)
const [nested, setNested] = createSignal(true)
const [mounted, setMounted] = createSignal(true)
let global!: Keymap
function Scoped(props: { keymap: Keymap }) {
onMount(() => onCleanup(props.keymap.mode.push("menu")))
return null
}
function Harness() {
global = Keymap.use()
return (
<Keymap.Scope enabled={enabled()}>
<Keymap.Scope enabled={nested()}>
<Show when={mounted()}>
<Scoped keymap={global} />
</Show>
</Keymap.Scope>
</Keymap.Scope>
)
}
const app = await testRender(() => (
<Keymap.Provider config={config}>
<Harness />
</Keymap.Provider>
))
try {
expect(global.mode.current()).toBe("base")
setEnabled(true)
expect(global.mode.current()).toBe("menu")
setNested(false)
expect(global.mode.current()).toBe("base")
setNested(true)
expect(global.mode.current()).toBe("menu")
setEnabled(false)
setMounted(false)
setEnabled(true)
expect(global.mode.current()).toBe("base")
setMounted(true)
expect(global.mode.current()).toBe("menu")
setMounted(false)
expect(global.mode.current()).toBe("base")
} finally {
app.renderer.destroy()
}
})
+55
View File
@@ -0,0 +1,55 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createPanelState } from "../src/context/panel"
test("presentation changes preserve the selected panel identity", () => {
createRoot((dispose) => {
const panels = createPanelState()
panels.setWidth(160)
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
const current = panels.current()
expect(panels.presentation()).toBe("panel")
panels.toggleFullscreen()
expect(panels.presentation()).toBe("fullscreen")
expect(panels.current()).toBe(current)
panels.toggleFullscreen()
expect(panels.presentation()).toBe("panel")
expect(panels.current()).toBe(current)
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
expect(panels.current()).toBe(current)
dispose()
})
})
test("narrow geometry overrides presentation without discarding the user's choice", () => {
createRoot((dispose) => {
const panels = createPanelState()
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
panels.setWidth(80)
expect(panels.canSplit()).toBe(false)
expect(panels.presentation()).toBe("fullscreen")
panels.toggleFullscreen()
panels.setWidth(81)
expect(panels.canSplit()).toBe(true)
expect(panels.presentation()).toBe("panel")
panels.toggleFullscreen()
panels.setWidth(60)
panels.setWidth(160)
expect(panels.presentation()).toBe("fullscreen")
dispose()
})
})
test("releasing a plugin contribution only closes its own selected panel", () => {
createRoot((dispose) => {
const panels = createPanelState()
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
const current = panels.current()
panels.release("other")
panels.release("review", "another-panel")
expect(panels.current()).toBe(current)
panels.release("review", "diff")
expect(panels.current()).toBeUndefined()
dispose()
})
})
+17 -1
View File
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { RGBA } from "@opentui/core"
import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextName } from "@opencode-ai/theme/tui"
import { createComponentTheme } from "../../../src/theme/component"
import { createComponentTheme, createComponentThemeView } from "../../../src/theme/component"
test("provides reactive properties, states, contexts, and color operations", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
@@ -67,3 +67,19 @@ test("provides reactive properties, states, contexts, and color operations", ()
expect(current().decrease(current().background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
expect(current().raise(current().background.surface.offset)).toBe(resolved().hue.neutral[600])
})
test("a stable component theme view follows presentation context changes", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
const [context, setContext] = createSignal<ContextName>()
const theme = createComponentThemeView(
() => (context() ? resolved().contextual[context()!] : resolved()),
() => "dark",
)
expect(theme.background.default).toBe(resolved().background.default)
setContext("elevated")
expect(theme.background.default).toBe(resolved().contextual.elevated.background.default)
setContext(undefined)
expect(theme.background.default).toBe(resolved().background.default)
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
expect(theme.text.default).toBe(resolved().text.default)
})
@@ -236,10 +236,7 @@ function Status() {
Register a fenced-code renderer by language; the returned function unregisters it.
```ts
const unregister = context.markdown.registerCodeBlockRenderer(
"acme",
(_token, render) => render.defaultRender(),
)
const unregister = context.markdown.registerCodeBlockRenderer("acme", (_token, render) => render.defaultRender())
return unregister
```
@@ -340,7 +337,14 @@ Custom JSX dialogs can set their size and close themselves.
```tsx
context.ui.dialog.set({ size: "large", centered: true })
context.ui.dialog.show(() => <box><text>Acme</text></box>, () => console.log("closed"))
context.ui.dialog.show(
() => (
<box>
<text>Acme</text>
</box>
),
() => console.log("closed"),
)
context.ui.dialog.clear()
```
@@ -405,6 +409,77 @@ context.ui.slot({ after: "home.footer", render: () => <text>After footer slot</t
context.ui.slot({ replace: "home.footer", render: () => <text>New footer</text> })
```
### Session panels
Register a named replacement for `session.panel`, then open it from a command. The host owns sizing, focus, and
full-screen presentation; the plugin owns its contents.
```tsx
context.ui.slot({
name: "review",
replace: "session.panel",
render: (panel) => <ReviewPanel panel={panel} />,
})
context.ui.slot({
append: "app",
render: () => {
context.keymap.layer(() => ({
mode: "global",
commands: [
{
id: "acme.review",
title: "Open review",
slash: { name: "review" },
run: () => {
context.ui.panel.open("review")
},
},
],
}))
return null
},
})
```
- Names are scoped to the plugin. Opening an unknown name or opening outside a session returns `false`.
- Unlike composed slots, this slot renders only the explicitly selected named contribution.
- Changing presentation preserves the mounted contribution. Closing it or unregistering its plugin disposes it.
- Its keyboard layers and input modes are active only while the panel owns input.
The slot receives reactive `sessionID`, `width`, `presentation`, `focused`, and `canSplit` properties, plus `focus`,
`close`, and `toggleFullscreen` actions. Use `canSplit` to gate the presentation shortcut rather than checking terminal
width inside the plugin.
```tsx
import type { PanelInput } from "@opencode-ai/plugin/tui/context"
import { usePlugin } from "@opencode-ai/plugin/tui"
function ReviewPanel(props: { panel: PanelInput }) {
const context = usePlugin()
context.keymap.layer(() => ({
commands: [
{
id: "acme.review.fullscreen",
bind: "f",
enabled: () => props.panel.canSplit,
run: props.panel.toggleFullscreen,
},
],
}))
return <text>Reviewing {props.panel.sessionID}</text>
}
```
You can request full-screen presentation initially, inspect your active panel, or close it without affecting another
plugin's panel.
```ts
context.ui.panel.open("review", { presentation: "fullscreen" })
const current = context.ui.panel.current()
context.ui.panel.close()
```
## Formatting
Format filesystem paths for display, including home-directory abbreviation.