mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 16:06:23 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aef5a67400 | ||
|
|
ae8be08906 | ||
|
|
2084c52952 | ||
|
|
5a9931280e | ||
|
|
b2fb2c5e36 |
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user