Compare commits

...
19 changed files with 1427 additions and 120 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")
}),
)
+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>
)
}
+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)
},
@@ -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>
)
}
@@ -14,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"
@@ -1076,76 +1077,6 @@ 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 }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
+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()}
@@ -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}
@@ -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() }
}
@@ -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()
}
})
+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()
}
})