Compare commits

..
Author SHA1 Message Date
James Long dea9125004 feat(tui): add split diff viewer 2026-09-03 20:15:29 +00:00
27 changed files with 603 additions and 1551 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 callable inside \`execute\`. It does not affect tools exposed directly outside Code Mode.${hasMoreTools ? `
${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 ? `
## Search
+2 -6
View File
@@ -18,9 +18,7 @@ const RemoteModel = Schema.Struct({
Schema.Struct({
batch_size: Schema.Number,
default: Schema.Struct({
// 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),
cache_price: Schema.Number,
input_price: Schema.Number,
output_price: Schema.Number,
}),
@@ -168,9 +166,7 @@ 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_read_price ?? prices?.default.cache_price ?? 0) * usdPerMillion,
),
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
write: Money.USDPerMillionTokens.zero,
},
},
-141
View File
@@ -1,141 +0,0 @@
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,7 +15,6 @@ 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"
@@ -49,7 +48,6 @@ 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-08-01"
const apiVersion = "2026-06-01"
const userApiVersion = "2025-04-01"
const pollingSafetyMargin = 3000
const methodID = Integration.MethodID.make("device")
@@ -1,67 +0,0 @@
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 callable inside `execute`. It does not affect tools exposed directly outside Code Mode.",
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
)
expect(initialized.text).toContain("## Available tools")
expect(initialized.text).not.toContain("## Search")
@@ -118,40 +118,3 @@ 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
@@ -1,137 +0,0 @@
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-08-01")
expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-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-08-01")
expect(event.request.headers.get("x-github-api-version")).toBe("2026-06-01")
}),
)
+19 -16
View File
@@ -100,6 +100,7 @@ import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider, useStorage } from "./context/storage"
import { SessionTerminalsProvider } from "./context/session-terminals"
import { SessionPanelProvider } from "./context/session-panel"
import { SessionFrame } from "./component/session-frame"
import { createTuiClipboard } from "./clipboard"
@@ -397,22 +398,24 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
<SessionPanelProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</SessionPanelProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
+20 -35
View File
@@ -187,8 +187,6 @@ 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()
@@ -261,7 +259,6 @@ export function Prompt(props: PromptProps) {
const [pendingDirectory, setPendingDirectory] = createSignal<string>()
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: [
{
id: "session.cd",
@@ -351,7 +348,8 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
if (props.disabled) input.cursorColor = theme.background.surface.offset
if (!props.disabled) input.cursorColor = theme.text.default
if (config.cursor) input.cursorStyle = config.cursor
})
@@ -374,13 +372,12 @@ export function Prompt(props: PromptProps) {
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
pasteQueue = pasteQueue
.then(async () => {
if (disposed || input.isDestroyed || disabled()) return
if (disposed || input.isDestroyed) 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,
@@ -651,18 +648,15 @@ 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",
@@ -680,13 +674,12 @@ export function Prompt(props: PromptProps) {
const ref: PromptRef = {
get focused() {
return !disabled() && input.focused
return input.focused
},
get current() {
return store.prompt
},
focus() {
if (disabled()) return
input.focus()
},
blur() {
@@ -740,13 +733,11 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || disabled() || dialog.stack.length > 0) {
if (props.visible === false || props.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()
@@ -942,14 +933,13 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: stashCommands(),
}))
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !disabled(),
enabled: inputTarget() !== undefined && !props.disabled,
bindings: ["prompt.paste"],
}
})
@@ -957,7 +947,7 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !disabled() && store.prompt.text !== "",
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
bindings: ["prompt.clear"],
}
})
@@ -969,7 +959,7 @@ export function Prompt(props: PromptProps) {
cursorVersion()
return (
inputTarget() !== undefined &&
!disabled() &&
!props.disabled &&
store.mode === "normal" &&
!auto()?.visible &&
input?.visualCursor.offset === 0
@@ -993,7 +983,7 @@ export function Prompt(props: PromptProps) {
return {
priority: 1,
target: inputTarget,
enabled: inputTarget() !== undefined && !disabled() && store.mode === "shell",
enabled: inputTarget() !== undefined && store.mode === "shell",
commands: [
{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
{
@@ -1012,7 +1002,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !disabled() && store.mode === "shell" && input?.visualCursor.offset === 0
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
commands: [
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
@@ -1026,7 +1016,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -1062,7 +1052,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -1097,7 +1087,6 @@ 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
@@ -1121,6 +1110,7 @@ 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()
@@ -1673,7 +1663,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%"
@@ -1779,19 +1769,18 @@ export function Prompt(props: PromptProps) {
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (disabled()) {
if (props.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 (disabled()) {
if (props.disabled) {
event.preventDefault()
return
}
@@ -1827,16 +1816,12 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
input.cursorColor = theme.text.default
if (config.cursor) input.cursorStyle = config.cursor
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
if (disabled()) {
r.preventDefault()
return
}
if (r.button !== 0) return
if (props.disabled || r.button !== 0) return
r.target?.focus()
const extmark = input.extmarks
.getAtOffset(input.cursorOffset)
@@ -1846,7 +1831,7 @@ export function Prompt(props: PromptProps) {
r.stopPropagation()
}}
focusedBackgroundColor="transparent"
cursorColor={disabled() ? theme.background.surface.offset : theme.text.default}
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -2031,6 +2016,6 @@ export function Prompt(props: PromptProps) {
hasSkill={(id) => store.prompt.skills?.some((skill) => skill.id === id) ?? false}
promptPartTypeId={() => promptPartTypeId}
/>
</Keymap.Scope>
</>
)
}
+128 -48
View File
@@ -1,15 +1,27 @@
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
import {
batch,
createComponent,
createEffect,
createMemo,
createResource,
createSignal,
on,
onCleanup,
Show,
} from "solid-js"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useSessionTerminals } from "../context/session-terminals"
import { usePromptRef } from "../context/prompt"
import { useSessionPanel } from "../context/session-panel"
import { useStorage } from "../context/storage"
import { useTheme } from "../context/theme"
import { Session } from "../routes/session"
import { Sidebar } from "../routes/session/sidebar"
import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { clampSessionPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { createPaneResize } from "../ui/pane-resize"
import { PaneResizeHandle } from "../ui/pane-resize-handle"
import { useToast } from "../ui/toast"
@@ -23,38 +35,44 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const toast = useToast()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const panel = useSessionPanel()
const elevated = useTheme("elevated")
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
const defaultTerminalWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
const [layout, updateLayout] = useStorage().store<{ terminalWidth?: number }>("layout", { initial: {} })
const terminalResize = createPaneResize({
value: () => layout.terminalWidth ?? defaultTerminalWidth(),
defaultValue: defaultTerminalWidth,
clamp: (width) => clampTerminalPaneWidth(width, availableWidth()),
const defaultPaneWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", {
initial: {},
})
const paneResize = createPaneResize({
value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(),
defaultValue: defaultPaneWidth,
clamp: (width) => clampSessionPaneWidth(width, availableWidth()),
fromMouse: (event) => dimensions().width - event.x - 1,
contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width,
onCommit: (width) => {
void updateLayout((draft) => {
draft.terminalWidth = width
draft.paneWidth = width
}).catch((error) => console.error("Failed to persist TUI layout", error))
},
})
let resizeRelease = false
const finishTerminalResize = (event: MouseEvent) => {
if (terminalResize.resizing()) {
const finishPaneResize = (event: MouseEvent) => {
if (paneResize.resizing()) {
// A captured drag-end can be followed by mouse-up on the focus overlay.
resizeRelease = true
queueMicrotask(() => {
resizeRelease = false
})
}
terminalResize.onMouseUp(event)
paneResize.onMouseUp(event)
}
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [sessionWidth, setSessionWidth] = createSignal<number>()
const [terminalFocused, setTerminalFocused] = createSignal(false)
const [panelFocused, setPanelFocused] = createSignal(false)
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
let focusTerminal: (() => void) | undefined
let sessionScroll: ScrollBoxRenderable | undefined
let focusPanel: (() => void) | undefined
createResource(
() => (config.data.session.terminal ? props.sessionID : undefined),
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
@@ -65,22 +83,43 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const value = session()
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
}
const activePanel = createMemo(() => {
const current = panel.current()
if (current?.sessionID === props.sessionID) return current
})
createEffect(
on(
() => selectedTerminal()?.id,
(id) => {
if (id) setSidebarOpen(false)
if (!id) return
setSidebarOpen(false)
if (activePanel()) panel.close()
},
{ defer: true },
),
)
const splitAvailable = createMemo(() => dimensions().width > 80)
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
createEffect(() => panel.setAvailable(props.sessionID, splitAvailable()))
onCleanup(() => panel.setAvailable(props.sessionID, false))
createEffect(() => {
const current = activePanel()
if (!current || splitAvailable()) return
panel.close()
current.onUnavailable?.()
})
createEffect(() => {
if (!activePanel()) return
setSidebarOpen(false)
if (selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
const sidebarVisible = createMemo(() => {
if (data.session.get(props.sessionID)?.parentID) return false
if (sidebarOpen()) return true
return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
})
const rightPane = createMemo(() => {
if (activePanel()) return "panel"
if (sidebarOpen() && sidebarVisible()) return "sidebar"
if (selectedTerminal()) return "terminal"
if (sidebarVisible()) return "sidebar"
@@ -94,21 +133,44 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
})
.catch(toast.error)
setSidebarOpen(!visible)
if (!visible && activePanel()) panel.close()
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
}
const focusSession = () => {
// Permission prompts replace the input, so returning focus must not depend on it.
if (terminalFocused()) renderer.currentFocusedRenderable?.blur()
if (terminalFocused() || panelFocused()) renderer.currentFocusedRenderable?.blur()
prompt.current?.focus()
}
const focusRightPane = () => {
if (activePanel()) {
focusPanel?.()
return
}
focusTerminal?.()
}
createEffect(
on(
() => activePanel()?.id,
(id) => {
if (!id) {
setPanelFocused(false)
return
}
requestAnimationFrame(() => {
if (activePanel()?.id !== id) return
focusPanel?.()
})
},
),
)
createEffect(() => {
if (!restoreTerminalFocus() || selectedTerminal()) return
setRestoreTerminalFocus(false)
focusSession()
})
Keymap.createLayer(() => ({
enabled: () => config.data.session.terminal === true,
enabled: () => config.data.session.terminal === true || activePanel() !== undefined,
commands: [
{
id: "pane.focus.left",
@@ -117,10 +179,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
},
{
id: "pane.focus.right",
title: "Focus terminal pane",
run: () => {
focusTerminal?.()
},
title: "Focus right pane",
run: focusRightPane,
},
],
}))
@@ -132,9 +192,9 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
minHeight={0}
flexDirection="row"
position="relative"
onMouseDrag={terminalResize.onMouseDrag}
onMouseDragEnd={finishTerminalResize}
onMouseUp={finishTerminalResize}
onMouseDrag={paneResize.onMouseDrag}
onMouseDragEnd={finishPaneResize}
onMouseUp={finishPaneResize}
>
<box
flexGrow={1}
@@ -149,13 +209,13 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
<Session
scrollRef={(value) => (sessionScroll = value)}
verticalTabsWidth={props.verticalTabsWidth}
promptMuted={terminalFocused()}
promptMuted={terminalFocused() || panelFocused()}
sidebarVisible={rightPane() === "sidebar"}
onToggleSidebar={toggleSidebar}
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
width={sessionWidth()}
/>
<Show when={terminalFocused()}>
<Show when={terminalFocused() || panelFocused()}>
<box
position="absolute"
left={0}
@@ -174,37 +234,61 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
}}
// Consume the release before revealing permission buttons underneath.
onMouseUp={() => {
if (terminalResize.resizing() || resizeRelease) return
if (paneResize.resizing() || resizeRelease) return
focusSession()
}}
/>
</Show>
</box>
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
<Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
<box
flexShrink={0}
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
width={rightPane() === "terminal" || rightPane() === "panel" ? paneResize.size() : SESSION_SIDEBAR_WIDTH}
minWidth={0}
minHeight={0}
backgroundColor={rightPane() === "panel" ? elevated.background.default : undefined}
>
<Show
when={rightPane() === "sidebar"}
fallback={
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={terminalResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
}}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
<Show
keyed
when={activePanel()}
fallback={
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={paneResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
}}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
</Show>
}
>
{(item) =>
createComponent(item.render, {
get width() {
return paneResize.size()
},
get resizing() {
return paneResize.resizing()
},
get focused() {
return panelFocused()
},
onFocusChange: setPanelFocused,
onFocusRequest: (value) => (focusPanel = value),
close: panel.close,
})
}
</Show>
}
>
@@ -212,12 +296,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</Show>
</box>
</Show>
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
<PaneResizeHandle
resize={terminalResize}
left={availableWidth() - terminalResize.size() - 1}
highlight="right"
/>
<Show when={(rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
<PaneResizeHandle resize={paneResize} left={availableWidth() - paneResize.size() - 1} highlight="right" />
</Show>
<Show when={rightPane() === "sidebar" && !wide()}>
<box
+2 -1
View File
@@ -84,6 +84,7 @@ export const Definitions = {
"diff.single_patch": keybind("s", "Toggle single patch view"),
"diff.switch_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.toggle_fullscreen": keybind("f", "Toggle diff viewer full screen"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
@@ -93,7 +94,7 @@ export const Definitions = {
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
"pane.focus.right": keybind("<leader>right", "Focus right pane"),
"terminal.select": keybind("<leader>down", "Select terminal"),
"terminal.toggle": keybind("<leader>t", "Toggle terminal pane"),
"terminal.close": keybind("<leader>up", "Close terminal pane"),
+17 -47
View File
@@ -13,17 +13,7 @@ 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 {
createComputed,
createContext,
createMemo,
createSignal,
getOwner,
onCleanup,
useContext,
type Accessor,
type ParentProps,
} from "solid-js"
import { createContext, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js"
import { useConfig } from "../config"
import { TuiKeybind } from "../config/keybind"
@@ -60,20 +50,6 @@ 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
@@ -199,18 +175,13 @@ 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: {
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),
},
mode: value.mode,
intercept: value.keymap.intercept.bind(value.keymap),
isLeader,
}
@@ -218,7 +189,6 @@ function use(): Keymap {
function createLayer(input: () => KeymapLayer) {
const value = useValue()
const enabled = useEnabled()
useBindings(() => {
const layer = input()
const { commands, bindings, mode, ...options } = layer
@@ -245,7 +215,6 @@ 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
@@ -416,9 +385,7 @@ function useValue() {
export const Keymap = {
Provider,
Scope,
use,
useEnabled,
createLayer,
useShortcuts,
useShortcut,
@@ -430,34 +397,37 @@ export const Keymap = {
} as const
function createMode(keymap: OpenTuiKeymap) {
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()))
keymap.setData(MODE.key, MODE.base)
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,
push(mode: string, enabled: Accessor<boolean>) {
current() {
return stack.at(-1)?.mode ?? MODE.base
},
push(mode: string) {
if (disposed) return () => {}
const id = Symbol(mode)
// Inactive scopes retain their stack position beneath any newer modes.
setStack((items) => [...items, { id, mode, enabled }])
stack.push({ id, mode })
update()
return () => {
setStack((items) => items.filter((item) => item.id !== id))
const index = stack.findIndex((item) => item.id === id)
if (index < 0) return
stack.splice(index, 1)
update()
}
},
dispose() {
if (disposed) return
disposed = true
setStack([])
stack.length = 0
unregister()
keymap.setData(MODE.key, undefined)
},
@@ -0,0 +1,54 @@
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
export type SessionPanelRenderProps = {
readonly width: number
readonly resizing: boolean
readonly focused: boolean
readonly onFocusChange: (focused: boolean) => void
readonly onFocusRequest: (focus: (() => void) | undefined) => void
readonly close: () => void
}
type Panel = {
readonly id: string
readonly sessionID: string
readonly render: (props: SessionPanelRenderProps) => JSX.Element
readonly onUnavailable?: () => void
}
const Context = createContext<{
readonly current: () => Panel | undefined
readonly open: (panel: Panel) => void
readonly close: () => void
readonly available: (sessionID: string) => boolean
readonly setAvailable: (sessionID: string, available: boolean) => void
}>()
export function SessionPanelProvider(props: ParentProps) {
const [current, setCurrent] = createSignal<Panel>()
const [availableSessionID, setAvailableSessionID] = createSignal<string>()
return (
<Context.Provider
value={{
current,
open: setCurrent,
close: () => setCurrent(),
available: (sessionID) => availableSessionID() === sessionID,
setAvailable: (sessionID, available) =>
setAvailableSessionID((current) => (available ? sessionID : current === sessionID ? undefined : current)),
}}
>
{props.children}
</Context.Provider>
)
}
export function useSessionPanel() {
const value = useContext(Context)
if (!value) throw new Error("useSessionPanel must be used within a SessionPanelProvider")
return value
}
export function useOptionalSessionPanel() {
return useContext(Context)
}
@@ -1,87 +0,0 @@
/** @jsxImportSource @opentui/solid */
import type { Plugin } from "@opencode-ai/plugin/tui"
import { BoxRenderable, MouseButton } from "@opentui/core"
import { Portal, useTerminalDimensions } from "@opentui/solid"
import { createSignal, onCleanup } from "solid-js"
export function DiffFileMenu(props: {
context: Plugin.Context
state: { fileIndex: number; x: number; y: number }
reviewed: boolean
onToggle: () => void
onClose: () => void
}) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.overlay
const [hovered, setHovered] = createSignal(false)
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
const width = () => Math.min(19, dimensions().width)
const run = () => {
props.onClose()
props.onToggle()
}
onCleanup(props.context.keymap.mode.push("menu"))
props.context.keymap.layer(() => ({
mode: "menu",
commands: [
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
{ bind: "return", title: label(), group: "Diff", run },
],
}))
return (
<Portal
ref={(container) => {
if (!(container instanceof BoxRenderable)) return
// Portal's wrapper must also escape root flow, not follow the full-height app.
container.position = "absolute"
container.left = 0
container.top = 0
container.zIndex = 2600
}}
>
<box
id="diff-file-menu-overlay"
position="absolute"
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
zIndex={2600}
onMouseDown={(event) => {
props.onClose()
event.preventDefault()
event.stopPropagation()
}}
>
<box
id="diff-file-menu"
position="absolute"
left={Math.max(0, Math.min(props.state.x, dimensions().width - width()))}
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
width={width()}
height={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
if (event.button === MouseButton.RIGHT) props.onClose()
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
if (event.button === MouseButton.LEFT) run()
}}
>
<text fg={theme.text.default} selectable={false} wrapMode="none" truncate>
{label()}
</text>
</box>
</box>
</Portal>
)
}
@@ -4,6 +4,7 @@ import type { Vcs } from "@opencode-ai/schema/vcs"
import { Plugin } from "@opencode-ai/plugin/tui"
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
import {
CliRenderEvents,
MouseButton,
TextAttributes,
type BoxRenderable,
@@ -14,7 +15,6 @@ import { filetype } from "../../util/filetype"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { DiffFileMenu } from "./diff-viewer-file-menu"
import { DiffViewerImage, isDiffImageFile } from "./diff-viewer-image"
import { DialogSelect } from "../../ui/dialog-select"
import { EmptyBorder } from "../../ui/border"
@@ -23,7 +23,8 @@ import { getScrollAcceleration } from "../../util/scroll"
import { createDebouncedSignal } from "../../util/signal"
import { useConfig } from "../../config"
import { locationKey } from "../../context/data"
import { useThemes } from "../../context/theme"
import { useOptionalSessionPanel } from "../../context/session-panel"
import { useTheme, useThemes } from "../../context/theme"
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
import {
allExpandedFileTreeDirectories,
@@ -76,9 +77,55 @@ function diffSourceLabel(mode: DiffMode) {
return "Uncommitted"
}
function DiffViewer(props: { context: Plugin.Context }) {
type PanelController = NonNullable<ReturnType<typeof useOptionalSessionPanel>>
function openDiffPanel(context: Plugin.Context, panel: PanelController, sessionID: string) {
panel.open({
id: ROUTE,
sessionID,
onUnavailable: () => openDiffFullscreen(context, panel, sessionID, false),
render: (input) => (
<DiffViewer
context={context}
sessionID={sessionID}
width={input.width}
embedded
focused={input.focused}
onFocusChange={input.onFocusChange}
onFocusRequest={input.onFocusRequest}
onClose={input.close}
/>
),
})
}
function openDiffFullscreen(context: Plugin.Context, panel: PanelController, sessionID: string, split: boolean) {
panel.close()
context.ui.router.navigate({
type: "plugin",
name: ROUTE,
data: {
sessionID,
returnRoute: { type: "session", sessionID },
...(split ? { split: true } : {}),
},
})
}
function DiffViewer(props: {
context: Plugin.Context
sessionID?: string
width?: number
embedded?: boolean
focused?: boolean
onFocusChange?: (focused: boolean) => void
onFocusRequest?: (focus: (() => void) | undefined) => void
onClose?: () => void
}) {
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const config = useConfig()
const panel = useOptionalSessionPanel()
const [memory, updateMemory] = props.context.storage.memory<{
source?: DiffMode
bases: Record<string, string>
@@ -90,13 +137,14 @@ function DiffViewer(props: { context: Plugin.Context }) {
mode?: DiffMode
sessionID?: string
returnRoute?: Route
split?: boolean
}
| undefined
}
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
const location = createMemo(
() => {
const sessionID = params()?.sessionID
const sessionID = props.sessionID ?? params()?.sessionID
return sessionID
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
: props.context.data.location.default()
@@ -158,52 +206,103 @@ function DiffViewer(props: { context: Plugin.Context }) {
if (!base) return "Base not reported"
return `vs ${base.name}`
}
const sessionID = () => props.sessionID ?? params()?.sessionID
const canToggleFullscreen = () =>
panel !== undefined &&
sessionID() !== undefined &&
(props.embedded === true || (params()?.split === true && dimensions().width > 80))
const toggleFullscreen = () => {
const id = sessionID()
if (!panel || !id) return
if (props.embedded) {
openDiffFullscreen(props.context, panel, id, true)
return
}
openDiffPanel(props.context, panel, id)
props.context.ui.router.navigate({ type: "session", sessionID: id })
}
let panelNode: BoxRenderable | undefined
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === panelNode)
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
onCleanup(() => {
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
props.onFocusChange?.(false)
props.onFocusRequest?.(undefined)
})
const content = () => (
<DiffViewerContent
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={props.embedded ? { ...config.data.diffs, tree: false } : config.data.diffs}
width={props.width}
fileTree={!props.embedded}
elevated={props.embedded}
focused={props.embedded ? props.focused === true : true}
onToggleFullscreen={canToggleFullscreen() ? toggleFullscreen : undefined}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={() => {
if (props.onClose) return props.onClose()
props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })
}}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
)
if (props.embedded)
return (
<box
ref={(node: BoxRenderable) => {
panelNode = node
props.onFocusRequest?.(() => node.focus())
}}
flexGrow={1}
minWidth={0}
minHeight={0}
focusable
onMouseDown={() => panelNode?.focus()}
>
{content()}
</box>
)
return (
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
<DiffViewerContent
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={config.data.diffs}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
{content()}
</box>
)
}
@@ -267,28 +366,36 @@ export function DiffViewerContent(props: {
navigation?: "tree" | "list"
loadImage?: (file: string, signal: AbortSignal) => Promise<Uint8Array>
preferences?: DiffPreferences
width?: number
fileTree?: boolean
elevated?: boolean
focused?: boolean
onPreferencesChange?: (value: DiffPreferences) => void
onClose: () => void
onSwitchSource: (mode: DiffMode) => void
onChooseBase?: () => void
onToggleFullscreen?: () => void
}) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig()
const dialog = props.context.ui.dialog
const theme = useThemes().current
const currentSyntax = useThemes().currentSyntax
const themes = useThemes()
const elevated = useTheme("elevated")
const theme = props.elevated ? elevated : themes.current
const currentSyntax = themes.currentSyntax
const files = () => props.files
const width = () => props.width ?? dimensions().width
const mode = () => props.mode
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.preferences?.tree ?? true)
const showFileTree = createMemo(
() => dimensions().width >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
() => props.fileTree !== false && width() >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
)
const [singlePatch, setSinglePatch] = createSignal(props.preferences?.single ?? false)
const fileTreeWidth = createMemo(() =>
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(dimensions().width / 4))),
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(width() / 4))),
)
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? fileTreeWidth() : 0) - 4)
const patchPaneWidth = createMemo(() => width() - (showFileTree() ? fileTreeWidth() : 0) - (props.elevated ? 2 : 4))
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.preferences?.view))
const view = createMemo(() =>
@@ -302,6 +409,7 @@ export function DiffViewerContent(props: {
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(config.data))
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
const helpShortcut = () => props.context.keymap.shortcuts("diff.help")[0]
const firstShortcut = (id: string, fallback: string) => props.context.keymap.shortcuts(id)[0] ?? fallback
let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const patchDiffByFileIndex = new Map<number, PatchDiffRef>()
@@ -684,6 +792,13 @@ export function DiffViewerContent(props: {
props.onPreferencesChange?.({ view: next })
},
},
{
id: "diff.toggle_fullscreen",
title: "Toggle diff viewer full screen",
group: "VCS",
enabled: () => props.onToggleFullscreen !== undefined,
run: () => props.onToggleFullscreen?.(),
},
{
id: "diff.help",
title: "Show more diff viewer shortcuts",
@@ -749,7 +864,13 @@ export function DiffViewerContent(props: {
}
const openHelpDialog = () => {
dialog.show(() => <DiffViewerHelpDialog context={props.context} single={singlePatch()} />)
dialog.show(() => (
<DiffViewerHelpDialog
context={props.context}
single={singlePatch()}
fullscreen={props.onToggleFullscreen !== undefined}
/>
))
dialog.set({ size: "medium", centered: true })
}
@@ -778,6 +899,7 @@ export function DiffViewerContent(props: {
)
props.context.keymap.layer(() => ({
enabled: () => props.focused !== false,
commands,
}))
@@ -875,7 +997,13 @@ export function DiffViewerContent(props: {
/>
</Show>
<box flexGrow={1} minWidth={0} minHeight={0} paddingLeft={2} paddingRight={2}>
<box
flexGrow={1}
minWidth={0}
minHeight={0}
paddingLeft={props.elevated ? 1 : 2}
paddingRight={props.elevated ? 1 : 2}
>
<box
id="diff-patch-top-edge"
ref={(edge: BoxRenderable) => {
@@ -954,7 +1082,7 @@ export function DiffViewerContent(props: {
zIndex={1}
backgroundColor={background()}
paddingLeft={1}
paddingRight={1}
paddingRight={props.elevated ? 0 : 1}
paddingBottom={1}
>
<box flexGrow={1} minWidth={0}>
@@ -1057,7 +1185,45 @@ export function DiffViewerContent(props: {
</Match>
</Switch>
</box>
<Show when={!showFileTree()}>
<Show when={props.elevated}>
<box height={1} flexShrink={0} />
<box height={1} flexShrink={0} paddingLeft={2} paddingRight={2}>
<Show
when={props.focused}
fallback={
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.right", "ctrl+x →")}</span>
{" focus diff"}
</text>
}
>
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.left", "ctrl+x ←")}</span>
{" focus session "}
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.toggle_fullscreen", "f")}</span>
{" full screen "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.down", "j")}/{firstShortcut("diff.up", "k")}
</span>
{" scroll "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.next_file", "n")}/{firstShortcut("diff.previous_file", "p")}
</span>
{" files "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.next_hunk", "]")}/{firstShortcut("diff.previous_hunk", "[")}
</span>
{" hunks "}
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.close", "q")}</span>
{" close "}
<span style={{ fg: theme.text.default }}>{helpShortcut() ?? "?"}</span>
{" see all"}
</text>
</Show>
</box>
<box height={1} flexShrink={0} />
</Show>
<Show when={!showFileTree() && !props.elevated}>
<box position="absolute" top={0} right={0} width={1} height={1}>
<HelpShortcut compact />
</box>
@@ -1077,7 +1243,77 @@ export function DiffViewerContent(props: {
)
}
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
function DiffFileMenu(props: {
context: Plugin.Context
state: FileMenuState
reviewed: boolean
onToggle: () => void
onClose: () => void
}) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.overlay
const [hovered, setHovered] = createSignal(false)
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
const run = () => {
props.onClose()
props.onToggle()
}
onCleanup(props.context.keymap.mode.push("menu"))
props.context.keymap.layer(() => ({
mode: "menu",
commands: [
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
{ bind: "return", title: label(), group: "Diff", run },
],
}))
return (
<box
id="diff-file-menu-overlay"
position="absolute"
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
zIndex={2600}
onMouseDown={(event) => {
props.onClose()
event.preventDefault()
event.stopPropagation()
}}
>
<box
id="diff-file-menu"
position="absolute"
left={Math.max(0, Math.min(props.state.x, dimensions().width - 19))}
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
width={19}
height={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
if (event.button === MouseButton.RIGHT) props.onClose()
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
if (event.button === MouseButton.LEFT) run()
}}
>
<text fg={theme.text.default} selectable={false}>
{label()}
</text>
</box>
</box>
)
}
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean; fullscreen: boolean }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const shortcut =
@@ -1117,6 +1353,9 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
{ shortcut: shortcut("diff.single_patch"), label: "All files / single file" },
{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" },
{ shortcut: shortcut("diff.switch_source"), label: "Switch diff source" },
...(props.fullscreen
? [{ shortcut: shortcut("diff.toggle_fullscreen"), label: "Full screen / split view" }]
: []),
{ shortcut: () => props.context.keymap.shortcuts("diff.close").join(" / "), label: "Close diff viewer" },
],
},
@@ -1174,6 +1413,7 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
}
function Commands(props: { context: Plugin.Context }) {
const panel = useOptionalSessionPanel()
props.context.keymap.layer(() => ({
mode: "global",
commands: [
@@ -1185,6 +1425,15 @@ function Commands(props: { context: Plugin.Context }) {
palette: true,
run() {
const route = props.context.ui.router.current()
if (route.type === "session" && panel?.available(route.sessionID)) {
if (panel.current()?.id === ROUTE && panel.current()?.sessionID === route.sessionID) {
panel.close()
} else {
openDiffPanel(props.context, panel, route.sessionID)
}
props.context.ui.dialog.clear()
return
}
const returnRoute: Route =
route.type === "home"
? { type: "home" }
+5 -23
View File
@@ -48,8 +48,6 @@ 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()
@@ -70,7 +68,6 @@ export function FormPrompt(props: { form: FormWithLocation }) {
})
let textarea: TextareaRenderable | undefined
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable>()
let review: ScrollBoxRenderable | undefined
let measureReview: (() => void) | undefined
@@ -219,22 +216,9 @@ 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 (!active()) return
if (keymap.mode.current() !== FORM_MODE) 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
@@ -344,7 +328,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}
usePaste((event) => {
if (!active()) return
if (keymap.mode.current() !== FORM_MODE) return
const value = stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n")
if (store.editing && renderer.currentFocusedEditor === textarea) {
textarea.insertText(value)
@@ -359,7 +343,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return clipboard
.read()
.then((content) => {
if (!active() || content?.mime !== "text/plain") return
if (content?.mime !== "text/plain") return
const value = stripAnsiSequences(content.data).replace(/\r\n?/g, "\n")
if (store.editing || textual()) {
textarea?.insertText(value)
@@ -894,9 +878,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
textarea = val
val.traits = { status: "ANSWER" }
queueMicrotask(() => {
if (val.isDestroyed) return
val.focus()
val.gotoLineEnd()
setInputTarget(val)
})
}}
initialValue={
@@ -1034,10 +1017,9 @@ 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()}
+1 -1
View File
@@ -1507,7 +1507,7 @@ export function Session(props: {
<Prompt
visible={true}
ref={bind}
disabled={false}
disabled={props.promptMuted}
muted={props.promptMuted}
onSubmit={() => {
toBottom()
@@ -288,7 +288,6 @@ function RejectPrompt(props: {
onCancel: () => void
}) {
let input: TextareaRenderable
const enabled = Keymap.useEnabled()
const theme = useTheme("elevated")
const config = useConfig().data
const dimensions = useTerminalDimensions()
@@ -365,7 +364,7 @@ function RejectPrompt(props: {
}))(val)
val.traits = { status: "REJECT" }
}}
focused={enabled()}
focused
textColor={theme.text.default}
focusedTextColor={theme.text.default}
cursorColor={theme.text.default}
+1 -1
View File
@@ -14,7 +14,7 @@ export function clampSessionTabsWidth(width: number, total: number) {
)
}
export function clampTerminalPaneWidth(width: number, total: number) {
export function clampSessionPaneWidth(width: number, total: number) {
const half = Math.max(1, Math.floor(total / 2))
// Preserve the equal split when there is not enough room for both pane minima.
return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH)))
@@ -1,289 +0,0 @@
/** @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() }
}
+41 -1
View File
@@ -37,6 +37,7 @@ import { createApi, createEventStream, createFetch, json } from "../../fixture/t
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { createDialogApi } from "../../../src/plugin/api"
import { ToastProvider } from "../../../src/ui/toast"
import { SessionPanelProvider } from "../../../src/context/session-panel"
import { createSignal, Show } from "solid-js"
import { diffImageFixture } from "../../fixture/diff-image"
@@ -69,6 +70,43 @@ test("closing the diff viewer returns to the route it opened from", async () =>
}
})
test("full-screen diff only returns to split view when opened from an eligible panel", async () => {
const narrow = await renderDiffViewer([], {
width: 80,
initialRoute: {
type: "plugin",
id: "opencode.diffs",
name: "diff",
data: { sessionID: "session-1", returnRoute: startRoute },
},
})
try {
const command = narrow.commands.get("diff.toggle_fullscreen")
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(false)
} finally {
narrow.app.renderer.destroy()
}
const eligible = await renderDiffViewer([], {
width: 160,
initialRoute: {
type: "plugin",
id: "opencode.diffs",
name: "diff",
data: { sessionID: "session-1", returnRoute: startRoute, split: true },
},
})
try {
const command = eligible.commands.get("diff.toggle_fullscreen")
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(true)
command?.run()
await eligible.app.flush()
expect(eligible.current()).toEqual(startRoute)
} finally {
eligible.app.renderer.destroy()
}
})
test("ctrl+c closes the diff viewer without exiting the application", async () => {
const viewer = await renderDiffViewer([])
@@ -1972,7 +2010,9 @@ async function renderDiffViewer(
<ToastProvider>
<ThemeProvider mode={options.mode ?? "dark"} source={emptyThemeSource}>
<DialogProvider>
<Content />
<SessionPanelProvider>
<Content />
</SessionPanelProvider>
</DialogProvider>
</ThemeProvider>
</ToastProvider>
@@ -1,278 +0,0 @@
/** @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()
}
})
+1
View File
@@ -209,6 +209,7 @@ test("centralizes named command defaults and resolves explicit none", () => {
"diff.next_hunk": "]",
"diff.previous_hunk": "[",
"diff.mark_reviewed": "m",
"diff.toggle_fullscreen": "f",
"diff.help": "?,shift+?,shift+/",
}
const config = resolve({}, { terminalSuspend: true })
-270
View File
@@ -1,270 +0,0 @@
/** @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()
}
})