mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 23:46:16 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00580291b8 | ||
|
|
2e17a66530 |
@@ -47,6 +47,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -82,14 +83,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||
get: () => runPromise(config.get()),
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
updater: {
|
||||
monitor: (notify, signal) =>
|
||||
runPromise(
|
||||
updater.monitor((version) => Effect.sync(() => notify(version))),
|
||||
{ signal },
|
||||
),
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
updater: service
|
||||
? {
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
}
|
||||
: undefined,
|
||||
packages: {
|
||||
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
|
||||
},
|
||||
|
||||
@@ -7,12 +7,14 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { spawn } from "node:child_process"
|
||||
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
@@ -27,6 +29,7 @@ export type Options = {
|
||||
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
|
||||
export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
return yield* processEffect(options).pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
|
||||
replacements: [
|
||||
@@ -51,7 +54,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
return yield* Effect.scoped(
|
||||
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
|
||||
const next = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const foreground = options.mode === "default"
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
@@ -62,7 +66,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
if (incumbent !== undefined) return
|
||||
if (incumbent !== undefined) return Option.none<PersistentPty.Handoff | null>()
|
||||
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
|
||||
const environmentPassword = yield* Env.password
|
||||
// Keep the lease credential out of the environment inherited by tools.
|
||||
@@ -159,17 +163,62 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (server === undefined) return
|
||||
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater
|
||||
.monitor({
|
||||
url,
|
||||
password,
|
||||
managed: options.mode === "service",
|
||||
notify: server.updateAvailable,
|
||||
restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid),
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
return yield* options.mode === "service"
|
||||
? server.shutdown
|
||||
? Effect.raceFirst(
|
||||
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
|
||||
Deferred.await(replacement).pipe(Effect.map(Option.some)),
|
||||
)
|
||||
: options.mode === "stdio"
|
||||
? waitForStdinClose()
|
||||
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
|
||||
: Effect.never
|
||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
||||
)
|
||||
if (Option.isNone(next)) return
|
||||
yield* spawnReplacement(next.value)
|
||||
})
|
||||
|
||||
const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const [command, ...args] = options.command
|
||||
if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart"))
|
||||
// We do not monitor the replacement after spawn. A managed TUI
|
||||
// recovers with Service.ensure if startup fails; a future client
|
||||
// restart signal could coordinate that recovery instead.
|
||||
yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...options.env,
|
||||
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
|
||||
},
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
})
|
||||
child.once("error", reject)
|
||||
}),
|
||||
catch: (cause) => new Error("Failed to start replacement server", { cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type Policy = "disable" | "notify"
|
||||
export type Action = "none" | "notify"
|
||||
export type Policy = "disable" | "notify" | "auto"
|
||||
export type Action = "none" | "notify" | "upgrade"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
@@ -10,7 +10,10 @@ export function action(current: string, latest: string, policy: Policy): Action
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
return "notify"
|
||||
if (policy === "notify") return "notify"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (currentVersion.major !== latestVersion.major) return "notify"
|
||||
return "upgrade"
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(input: string) {
|
||||
|
||||
@@ -6,17 +6,22 @@ describe("updater", () => {
|
||||
test("reads update policy from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
|
||||
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps the v1 update policy", () => {
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
|
||||
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
|
||||
})
|
||||
|
||||
test("reports every available release", () => {
|
||||
test("automatically updates patches and minors", () => {
|
||||
expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("reports patches and minors without automatically installing them", () => {
|
||||
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
|
||||
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
|
||||
expect(action("1.2.3", "2.0.0", "notify")).toBe("notify")
|
||||
@@ -27,21 +32,25 @@ describe("updater", () => {
|
||||
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
|
||||
})
|
||||
|
||||
test("reports up-to-date only when versions match", () => {
|
||||
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
|
||||
test("reports majors instead of automatically installing them", () => {
|
||||
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
|
||||
})
|
||||
|
||||
test("reports when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", "notify")).toBe("notify")
|
||||
test("reports up-to-date only when versions match", () => {
|
||||
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
|
||||
})
|
||||
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("accepts strict release version variants", () => {
|
||||
expect(action("v1.2.3", " 1.2.4\n", "notify")).toBe("notify")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "notify")).toBe("notify")
|
||||
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "notify")).toBe("notify")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "notify")).toBe("notify")
|
||||
expect(action("1.2.3+old", "1.2.3+new", "notify")).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", "notify")).toBe("none")
|
||||
expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade")
|
||||
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none")
|
||||
})
|
||||
|
||||
test("preserves strict validity", () => {
|
||||
@@ -62,21 +71,21 @@ describe("updater", () => {
|
||||
"0.9007199254740992.0",
|
||||
"0.0.9007199254740992",
|
||||
]
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, "notify"), version).toBe("none"))
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
|
||||
})
|
||||
|
||||
test("handles numeric limits without losing precision", () => {
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "notify")).toBe("notify")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "notify")).toBe("notify")
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
|
||||
})
|
||||
|
||||
test("preserves equality for oversized numeric prerelease identifiers", () => {
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "notify")).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "notify")).toBe("notify")
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("rejects versions longer than semver's limit before trimming", () => {
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "notify")).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "notify")).toBe("notify")
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,36 +1,154 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
|
||||
export interface Interface {
|
||||
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
|
||||
readonly check: () => Effect.Effect<void>
|
||||
readonly monitor: (input: {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
}) => Effect.Effect<void>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const monitorUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly inspect: () => Effect.Effect<string | undefined, Error>
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly initialDelay?: Duration.Input
|
||||
export type Inspection =
|
||||
| { readonly action: "none" }
|
||||
| { readonly action: Exclude<Action, "none">; readonly version: string }
|
||||
|
||||
type State =
|
||||
| { readonly type: "current" }
|
||||
| { readonly type: "available"; readonly version: string; readonly availableSince: number }
|
||||
| { readonly type: "ready-to-restart"; readonly version: string }
|
||||
|
||||
export interface MonitorInput {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly inspect: () => Effect.Effect<Inspection, Error>
|
||||
readonly install: (version: string) => Effect.Effect<boolean, Error>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
const initialDelay = input.initialDelay ?? "90 seconds"
|
||||
const check = Effect.gen(function* () {
|
||||
const version = yield* input.inspect()
|
||||
if (version !== undefined) yield* input.notify(version)
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
|
||||
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
|
||||
readonly notificationThreshold?: Duration.Input
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) {
|
||||
const state = yield* Ref.make<State>({ type: "current" })
|
||||
const applyLock = yield* Semaphore.make(1)
|
||||
const client = OpenCode.make({
|
||||
baseUrl: input.url,
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` },
|
||||
})
|
||||
|
||||
const applyIfIdle = () =>
|
||||
applyLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* Ref.get(state)
|
||||
if (pending.type !== "available") return
|
||||
const active = yield* Effect.tryPromise({
|
||||
try: () => client.session.active(),
|
||||
catch: (cause) => new Error("Failed to read active sessions", { cause }),
|
||||
})
|
||||
if (Object.keys(active).length > 0) return
|
||||
const latest = yield* input.inspect()
|
||||
if (latest.action !== "upgrade") {
|
||||
yield* Ref.set(state, { type: "current" })
|
||||
return
|
||||
}
|
||||
const installed = yield* input
|
||||
.install(latest.version)
|
||||
.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)),
|
||||
),
|
||||
)
|
||||
if (!installed) return
|
||||
const handoff = input.managed
|
||||
? yield* Effect.tryPromise({
|
||||
try: () => client.experimental.persistentPty.handoff(),
|
||||
catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }),
|
||||
})
|
||||
: undefined
|
||||
yield* Ref.set(state, { type: "ready-to-restart", version: latest.version })
|
||||
if (handoff) yield* input.restart(handoff.handoff)
|
||||
}),
|
||||
)
|
||||
|
||||
const checkServer = Effect.gen(function* () {
|
||||
const result = yield* input.inspect()
|
||||
if (result.action === "notify") {
|
||||
yield* input.notify(result.version)
|
||||
return
|
||||
}
|
||||
if (result.action !== "upgrade") {
|
||||
yield* Ref.update(
|
||||
state,
|
||||
(current): State => (current.type === "ready-to-restart" ? current : { type: "current" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* Ref.update(state, (current): State => {
|
||||
if (current.type === "ready-to-restart" && current.version === result.version) return current
|
||||
return {
|
||||
type: "available",
|
||||
version: result.version,
|
||||
availableSince: current.type === "available" ? current.availableSince : Date.now(),
|
||||
}
|
||||
})
|
||||
yield* applyIfIdle()
|
||||
const pending = yield* Ref.get(state)
|
||||
if (
|
||||
pending.type === "available" &&
|
||||
Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days")
|
||||
)
|
||||
yield* input.notify(pending.version)
|
||||
}).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause })))
|
||||
|
||||
const subscribe = Effect.suspend(() =>
|
||||
Stream.fromAsyncIterable(
|
||||
client.event.subscribe(),
|
||||
(cause) => new Error("Update event stream failed", { cause }),
|
||||
).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (event.type === "server.connected") return applyIfIdle()
|
||||
if (
|
||||
event.type !== "session.execution.succeeded" &&
|
||||
event.type !== "session.execution.failed" &&
|
||||
event.type !== "session.execution.interrupted"
|
||||
)
|
||||
return Effect.void
|
||||
return Effect.tryPromise({
|
||||
try: () => client.session.wait({ sessionID: event.data.sessionID }),
|
||||
catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }),
|
||||
}).pipe(Effect.andThen(applyIfIdle()))
|
||||
}),
|
||||
Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })),
|
||||
),
|
||||
).pipe(Effect.repeat(Schedule.spaced("1 second")))
|
||||
|
||||
return yield* Effect.all(
|
||||
[checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -43,14 +161,13 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
if (errors.length || typeof input !== "object" || input === null) return
|
||||
if ("update" in input) {
|
||||
const value = input.update
|
||||
if (value === "disable" || value === "notify") return value
|
||||
if (value === "auto") return "notify"
|
||||
if (value === "disable" || value === "notify" || value === "auto") return value
|
||||
return
|
||||
}
|
||||
if (!("autoupdate" in input)) return
|
||||
if (input.autoupdate === false) return "disable"
|
||||
if (input.autoupdate === "notify") return "notify"
|
||||
if (input.autoupdate === true) return "notify"
|
||||
if (input.autoupdate === true) return "auto"
|
||||
}
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
@@ -75,7 +192,7 @@ const make = Effect.gen(function* () {
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? "notify"
|
||||
return values.findLast((value) => value !== undefined) ?? "auto"
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
@@ -185,19 +302,19 @@ const make = Effect.gen(function* () {
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
const inspect = Effect.fnUntraced(function* () {
|
||||
const inspect = Effect.fnUntraced(function* (): Effect.fn.Return<Inspection, Error> {
|
||||
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) {
|
||||
yield* Effect.logInfo("update check skipped", {
|
||||
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
})
|
||||
return undefined
|
||||
return { action: "none" }
|
||||
}
|
||||
const policy = yield* readPolicy()
|
||||
if (policy === "disable") {
|
||||
yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||
return undefined
|
||||
return { action: "none" }
|
||||
}
|
||||
|
||||
const version = yield* latest()
|
||||
@@ -208,16 +325,19 @@ const make = Effect.gen(function* () {
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
if (next === "none") {
|
||||
yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
return undefined
|
||||
return { action: "none" }
|
||||
}
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return version
|
||||
if (next === "notify") {
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return { action: next, version }
|
||||
}
|
||||
return { action: next, version }
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (version: string) {
|
||||
const detected = yield* method()
|
||||
if (!detected) {
|
||||
yield* Effect.logWarning("update skipped: installation method not found")
|
||||
yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
return false
|
||||
}
|
||||
yield* upgrade(detected, version)
|
||||
@@ -229,9 +349,26 @@ const make = Effect.gen(function* () {
|
||||
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
})
|
||||
|
||||
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
|
||||
const check = Effect.fn("cli.updater.check")(
|
||||
function* () {
|
||||
const result = yield* inspect()
|
||||
if (result.action !== "upgrade") return
|
||||
yield* install(result.version)
|
||||
},
|
||||
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
|
||||
)
|
||||
|
||||
return Service.of({ monitor, apply, method, latest, upgrade })
|
||||
const monitor = Effect.fn("cli.updater.monitor")(function* (input: {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
}) {
|
||||
return yield* monitorServer({ ...input, inspect, install })
|
||||
})
|
||||
|
||||
return Service.of({ check, monitor, apply, method, latest, upgrade })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -12,8 +12,9 @@ await Effect.runPromise(
|
||||
process.argv.slice(2),
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
|
||||
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply automatic updates"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
record("method")
|
||||
|
||||
@@ -1,40 +1,107 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Deferred, Effect, Layer, Option } from "effect"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
|
||||
it.live("installs and restarts after the final Session settles", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed("2.0.0"),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop()))
|
||||
const installed = yield* Deferred.make<string>()
|
||||
const restarted = yield* Deferred.make<void>()
|
||||
yield* Updater.monitorServer({
|
||||
url: fixture.url,
|
||||
password: "test",
|
||||
managed: true,
|
||||
inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }),
|
||||
install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)),
|
||||
restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid),
|
||||
notify: () => Effect.void,
|
||||
}).pipe(Effect.forkScoped)
|
||||
yield* wait(fixture.activeRead, () => "Updater did not check active Sessions")
|
||||
yield* wait(fixture.eventOpened, () => "Updater did not open the server event stream")
|
||||
expect(Option.isNone(yield* Deferred.poll(installed))).toBe(true)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("89 seconds")
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
fixture.settle()
|
||||
yield* wait(fixture.waited, () => "Updater did not receive the settlement event")
|
||||
expect(
|
||||
yield* Effect.raceFirst(
|
||||
Deferred.await(installed),
|
||||
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not install the update")))),
|
||||
),
|
||||
).toBe("1.1.0")
|
||||
yield* Effect.raceFirst(
|
||||
Deferred.await(restarted),
|
||||
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not restart the server")))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not notify when no update is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed(undefined),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
const wait = (promise: Promise<unknown>, message: () => string) =>
|
||||
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
}),
|
||||
)
|
||||
function makeServer() {
|
||||
const encoder = new TextEncoder()
|
||||
const activeRead = Promise.withResolvers<void>()
|
||||
const eventOpened = Promise.withResolvers<void>()
|
||||
const waited = Promise.withResolvers<void>()
|
||||
let active = true
|
||||
let events: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/session/active") {
|
||||
activeRead.resolve()
|
||||
return Response.json({ data: active ? { ses_test: { type: "running" } } : {} })
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") {
|
||||
waited.resolve()
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") {
|
||||
return Response.json({ handoff: null })
|
||||
}
|
||||
if (url.pathname === "/api/event") {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
events = controller
|
||||
eventOpened.resolve()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
url: server.url.origin,
|
||||
activeRead: activeRead.promise,
|
||||
eventOpened: eventOpened.promise,
|
||||
waited: waited.promise,
|
||||
settle() {
|
||||
active = false
|
||||
events?.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_settled",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "ses_test", seq: 0, version: 1 },
|
||||
data: { sessionID: "ses_test" },
|
||||
})}\n\n`,
|
||||
),
|
||||
)
|
||||
events?.close()
|
||||
events = undefined
|
||||
},
|
||||
stop() {
|
||||
server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1892,7 +1892,7 @@ export type ConfigEntry =
|
||||
shell?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
default_agent?: string
|
||||
update?: "disable" | "notify"
|
||||
update?: "disable" | "notify" | "auto"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -73,11 +73,6 @@ export function normalize(input: unknown): Result {
|
||||
const legacyUpdate = own(input, "autoupdate")
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const nativeUpdate = own(input, "update")
|
||||
? input.update === "auto"
|
||||
? "notify"
|
||||
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
? "auto"
|
||||
@@ -91,10 +86,7 @@ export function normalize(input: unknown): Result {
|
||||
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
||||
}
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
const migratedUpdate =
|
||||
legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
|
||||
const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics)
|
||||
if (update !== undefined) encoded.update = update
|
||||
if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
|
||||
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
|
||||
@@ -204,6 +196,7 @@ export function normalize(input: unknown): Result {
|
||||
shell: Info.fields.shell,
|
||||
model: Info.fields.model,
|
||||
default_agent: Info.fields.default_agent,
|
||||
update: Info.fields.update,
|
||||
share: Info.fields.share,
|
||||
enterprise: Info.fields.enterprise,
|
||||
username: Info.fields.username,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@ type Active = {
|
||||
done: Deferred.Deferred<Info>
|
||||
backgrounded: Deferred.Deferred<Info>
|
||||
scope: Scope.Closeable
|
||||
token: object
|
||||
blockingSessions: Map<SessionSchema.ID, number>
|
||||
isBackgrounded: boolean
|
||||
recovery?: Recovery
|
||||
@@ -76,7 +77,7 @@ type BackgroundResult = {
|
||||
backgrounded?: Deferred.Deferred<Info>
|
||||
}
|
||||
|
||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
|
||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
|
||||
|
||||
type BlockWait = {
|
||||
done: Deferred.Deferred<Info>
|
||||
@@ -183,14 +184,14 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
})
|
||||
|
||||
const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, exit: Exit.Exit<string, unknown>) {
|
||||
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.scope !== scope) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
@@ -240,6 +241,7 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(existing) }, jobs]
|
||||
}
|
||||
const scope = yield* Scope.fork(state.scope, "parallel")
|
||||
const token = {}
|
||||
const job = {
|
||||
info: {
|
||||
id,
|
||||
@@ -253,17 +255,18 @@ export const make = Effect.gen(function* () {
|
||||
done,
|
||||
backgrounded,
|
||||
scope,
|
||||
token,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
isBackgrounded: false,
|
||||
recovery: input.recovery,
|
||||
}
|
||||
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
|
||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
|
||||
}),
|
||||
)
|
||||
if ("scope" in result)
|
||||
yield* restore(input.run).pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => settle(id, result.scope, exit)),
|
||||
Effect.flatMap((exit) => settle(id, result.token, exit)),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(result.scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -28,9 +28,11 @@ const events = Metric.counter("opencode_session_websocket_events_total", {
|
||||
const metric = (event: string, attributes: Record<string, string> = {}) =>
|
||||
Metric.update(events.pipe(Metric.withAttributes({ event, ...attributes })), 1)
|
||||
|
||||
type Delivery = "queued" | "connecting" | "ready" | "send-attempted" | "provider-observed" | "terminal"
|
||||
|
||||
interface Active {
|
||||
readonly queue: Queue.Queue<string, AIError>
|
||||
delivery: "send-attempted" | "provider-observed" | "terminal"
|
||||
readonly lifecycle: { delivery: Delivery }
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
@@ -128,9 +130,14 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
code: "close",
|
||||
phase: "close",
|
||||
delivery:
|
||||
channel.active.delivery === "provider-observed" || channel.active.delivery === "terminal"
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
channel.active.lifecycle.delivery === "queued" ||
|
||||
channel.active.lifecycle.delivery === "connecting" ||
|
||||
channel.active.lifecycle.delivery === "ready"
|
||||
? "not-sent"
|
||||
: channel.active.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active.lifecycle.delivery === "terminal"
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -191,7 +198,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
code: "idle-data",
|
||||
phase: "receive",
|
||||
})
|
||||
active.delivery = "provider-observed"
|
||||
active.lifecycle.delivery = "provider-observed"
|
||||
if (typeof message !== "string")
|
||||
return yield* transportError("Unsupported binary WebSocket frame", {
|
||||
url: exchange.connect.url,
|
||||
@@ -219,8 +226,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
phase:
|
||||
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
||||
delivery:
|
||||
channel.active?.delivery === "provider-observed" ||
|
||||
channel.active?.delivery === "terminal" ||
|
||||
channel.active?.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active?.lifecycle.delivery === "terminal" ||
|
||||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
|
||||
? "accepted"
|
||||
: error.reason._tag === "Transport" && error.reason.code === "1009"
|
||||
@@ -249,6 +256,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const start = Effect.fn("SessionModelTransport.start")(function* (
|
||||
owner: State,
|
||||
exchange: WebSocketChannelExchange,
|
||||
lifecycle: { delivery: Delivery },
|
||||
) {
|
||||
if (owner.closed)
|
||||
return yield* transportError("Session WebSocket owner is closed", {
|
||||
@@ -280,6 +288,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
yield* closeChannel(owner, current)
|
||||
}
|
||||
|
||||
lifecycle.delivery = owner.channel ? "ready" : "connecting"
|
||||
if (owner.channel)
|
||||
yield* Effect.logDebug("session websocket reused", {
|
||||
sessionTransport: "websocket",
|
||||
@@ -305,6 +314,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
),
|
||||
)
|
||||
if (!channel) return fallback(exchange)
|
||||
lifecycle.delivery = "ready"
|
||||
|
||||
if (channel.pending) {
|
||||
channel.pending = undefined
|
||||
@@ -316,11 +326,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
)
|
||||
if (create.mode === "full") channel.checkpoint = undefined
|
||||
const active: Active = {
|
||||
queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY),
|
||||
delivery: "send-attempted",
|
||||
}
|
||||
const active: Active = { queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY), lifecycle }
|
||||
channel.active = active
|
||||
lifecycle.delivery = "send-attempted"
|
||||
const sent = yield* channel.connection.sendText(create.message).pipe(
|
||||
Effect.withSpan("SessionModelTransport.send"),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
@@ -358,7 +366,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
operation: "read",
|
||||
code: "idle-timeout",
|
||||
phase: "receive",
|
||||
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -367,7 +375,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.sync(() => {
|
||||
if (!observationTerminal(observation)) return
|
||||
terminal = observation
|
||||
active.delivery = "terminal"
|
||||
lifecycle.delivery = "terminal"
|
||||
const staged = observation.type === "completed" ? observation.checkpoint : undefined
|
||||
if (staged) channel.pending = { token, checkpoint: staged }
|
||||
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
|
||||
@@ -403,7 +411,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
operation: "read",
|
||||
code: "incomplete",
|
||||
phase: "receive",
|
||||
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
})
|
||||
yield* poison(owner, channel, error)
|
||||
}),
|
||||
@@ -440,6 +448,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) => {
|
||||
const owner = state(sessionID)
|
||||
const lifecycle = { delivery: "queued" as Delivery }
|
||||
let execution: WebSocketChannelExecution | undefined
|
||||
return Effect.succeed({
|
||||
get http() {
|
||||
@@ -447,7 +456,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
},
|
||||
frames: Stream.unwrap(
|
||||
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
|
||||
Effect.andThen(start(owner, exchange)),
|
||||
Effect.andThen(start(owner, exchange, lifecycle)),
|
||||
Effect.tap((started) =>
|
||||
Effect.sync(() => {
|
||||
execution = started
|
||||
|
||||
@@ -29,9 +29,11 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
update:
|
||||
info.autoupdate === false
|
||||
? "disable"
|
||||
: info.autoupdate === "notify" || info.autoupdate === true
|
||||
: info.autoupdate === "notify"
|
||||
? "notify"
|
||||
: undefined,
|
||||
: info.autoupdate === true
|
||||
? "auto"
|
||||
: undefined,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -13,7 +13,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -666,18 +665,10 @@ describe("Config", () => {
|
||||
test("migrates the v1 update policy", () => {
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
|
||||
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes the previous native auto update policy", () => {
|
||||
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
|
||||
type: "normalized",
|
||||
encoded: { update: "notify" },
|
||||
diagnostics: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates v1 provider lists to policies", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,71 +64,6 @@ describe("Job", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reuses running work when started again with the same ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const output = yield* Deferred.make<string>()
|
||||
const job = yield* jobs.start({ id: "job_reused", type: "test", run: Deferred.await(output) })
|
||||
|
||||
expect(
|
||||
yield* jobs.start({ id: job.id, type: "duplicate", run: Effect.die("Duplicate work must not run") }),
|
||||
).toEqual(job)
|
||||
|
||||
yield* Deferred.succeed(output, "original output")
|
||||
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
|
||||
type: "test",
|
||||
status: "completed",
|
||||
output: "original output",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores an obsolete callback after a cancellation waiter starts a same-ID replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const callback = yield* Deferred.make<() => void>()
|
||||
const output = yield* Deferred.make<string>()
|
||||
const finalized = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
id: "job_replaced",
|
||||
type: "test",
|
||||
run: Effect.callback<string>((resume) => {
|
||||
Deferred.doneUnsafe(
|
||||
callback,
|
||||
Effect.succeed(() => resume(Effect.succeed("obsolete output"))),
|
||||
)
|
||||
}),
|
||||
})
|
||||
const complete = yield* Deferred.await(callback)
|
||||
// Cancellation wakes waiters before closing the old scope, allowing the old callback to race replacement.
|
||||
const replacement = yield* jobs.wait({ id: job.id }).pipe(
|
||||
Effect.tap((result) => Effect.sync(() => expect(result.info?.status).toBe("cancelled"))),
|
||||
Effect.andThen(
|
||||
jobs.start({
|
||||
id: job.id,
|
||||
type: "replacement",
|
||||
run: Deferred.await(output).pipe(Effect.ensuring(Deferred.succeed(finalized, undefined))),
|
||||
}),
|
||||
),
|
||||
Effect.andThen(Effect.sync(complete)),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
|
||||
yield* jobs.cancel(job.id)
|
||||
yield* Fiber.join(replacement)
|
||||
expect(yield* jobs.get(job.id)).toMatchObject({ type: "replacement", status: "running" })
|
||||
expect(yield* Deferred.isDone(finalized)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(output, "replacement output")
|
||||
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
|
||||
type: "replacement",
|
||||
status: "completed",
|
||||
output: "replacement output",
|
||||
})
|
||||
expect(yield* Deferred.isDone(finalized)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns finished from a blocking wait when completion wins", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -445,17 +445,14 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.each([false, true])("classifies active and queued close (observed: %s)", async (observed) => {
|
||||
test("closes an active exchange without waiting for its Session permit", async () => {
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () =>
|
||||
observed
|
||||
? Queue.offer(messages, "frame").pipe(Effect.asVoid)
|
||||
: Deferred.succeed(started, undefined).pipe(Effect.asVoid),
|
||||
sendText: () => Deferred.succeed(started, undefined),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
@@ -465,30 +462,17 @@ describe("SessionModelTransport", () => {
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
const item = exchange("active")
|
||||
const running = yield* collect(executor, {
|
||||
...item,
|
||||
driver: {
|
||||
...item.driver,
|
||||
observe: (_create, frame) => Deferred.succeed(started, undefined).pipe(Effect.as({ type: "frame", frame })),
|
||||
},
|
||||
}).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
const queued = yield* collect(executor, exchange("queued")).pipe(
|
||||
Effect.result,
|
||||
const running = yield* collect(transport.bind(session), exchange("active")).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
yield* transport.close(session)
|
||||
const result = yield* Effect.result(Fiber.join(running))
|
||||
|
||||
expect(yield* Fiber.join(running)).toMatchObject({
|
||||
expect(result).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "close", delivery: observed ? "accepted" : "ambiguous" } },
|
||||
})
|
||||
expect(yield* Fiber.join(queued)).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "queue", delivery: "not-sent" } },
|
||||
failure: { reason: { _tag: "Transport", code: "close", delivery: "ambiguous" } },
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
@@ -561,43 +545,6 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("closes a connection returned after its owner closes during setup", async () => {
|
||||
const connecting = Deferred.makeUnsafe<void>()
|
||||
const release = Deferred.makeUnsafe<void>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Deferred.succeed(connecting, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as({
|
||||
sendText: () => Effect.die("Unexpected send after owner close"),
|
||||
messages: Stream.never,
|
||||
close: Effect.sync(() => closed++).pipe(Effect.asVoid),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const running = yield* collect(
|
||||
transport.bind(session),
|
||||
exchange("first", { fallback: () => Stream.die("Unexpected fallback after owner close") }),
|
||||
).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(connecting)
|
||||
yield* transport.close(session)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
|
||||
expect(yield* Fiber.join(running)).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "connect", delivery: "not-sent" } },
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back once when connection setup fails before send", async () => {
|
||||
let fallbacks = 0
|
||||
const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
|
||||
|
||||
@@ -162,20 +162,6 @@ type PromptFooterInput = {
|
||||
readonly showDetails: boolean
|
||||
}
|
||||
|
||||
export type PanelPresentation = "panel" | "fullscreen"
|
||||
|
||||
/** Client-local state of the selected session panel. The host owns its layout and input scope. */
|
||||
export interface PanelInput {
|
||||
readonly sessionID: string
|
||||
readonly width: number
|
||||
readonly presentation: PanelPresentation
|
||||
readonly focused: boolean
|
||||
readonly canSplit: boolean
|
||||
readonly focus: () => void
|
||||
readonly close: () => void
|
||||
readonly toggleFullscreen: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
|
||||
* may render around, inside, or take over. Paths are absolute and
|
||||
@@ -194,7 +180,6 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.status": PromptFooterInput
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "session.panel": PanelInput
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
}
|
||||
@@ -218,58 +203,45 @@ export type SlotPath = keyof SlotMap
|
||||
* `render` receives the target slot's input, reactively. The `?: never`
|
||||
* fields make the variants mutually exclusive: a claim with two placement
|
||||
* keys is a type error, not a silent priority pick.
|
||||
*
|
||||
* `session.panel` is an exclusive named replacement selected by ui.panel.open,
|
||||
* not by plugin enable order. Its instance survives presentation changes.
|
||||
*/
|
||||
export type SlotClaim<Path extends SlotPath = SlotPath> = Path extends SlotPath
|
||||
? { readonly render: (input: SlotMap[Path]) => JSX.Element } & (Path extends "session.panel"
|
||||
? { readonly name: string }
|
||||
: { readonly name?: string }) &
|
||||
(Path extends "session.panel"
|
||||
? {
|
||||
readonly replace: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
}
|
||||
:
|
||||
| {
|
||||
readonly prepend: Path
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly append: Path
|
||||
readonly prepend?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly before: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly after: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly replace: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
})
|
||||
? { readonly render: (input: SlotMap[Path]) => JSX.Element } & (
|
||||
| {
|
||||
readonly prepend: Path
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly append: Path
|
||||
readonly prepend?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly before: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly after: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly replace: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
}
|
||||
)
|
||||
: never
|
||||
|
||||
export interface App {
|
||||
@@ -478,14 +450,6 @@ export interface UI {
|
||||
navigate(destination: Destination): void
|
||||
current(): Route
|
||||
}
|
||||
readonly panel: {
|
||||
/** Opens a named session.panel contribution owned by this plugin in the current session. */
|
||||
open(name: string, options?: { readonly presentation?: PanelPresentation }): boolean
|
||||
/** Closes this plugin's active panel. Other plugins' panels are unaffected. */
|
||||
close(): void
|
||||
/** This plugin's active panel, if any. Reactive when read in a Solid computation. */
|
||||
current(): { readonly name: string; readonly sessionID: string } | undefined
|
||||
}
|
||||
readonly tabs: {
|
||||
/** Returns whether session tabs are enabled for this TUI. */
|
||||
enabled(): boolean
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify"]
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
|
||||
@@ -34,8 +34,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
default_agent: Schema.String.pipe(optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
|
||||
description: "Disable updates or notify when one is available",
|
||||
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
|
||||
description: "Disable updates, notify when one is available, or install automatically",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
|
||||
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
import {
|
||||
HttpMiddleware,
|
||||
@@ -114,7 +116,12 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
)
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
return { address: bound.http.address, shutdown: shutdown.await }
|
||||
return {
|
||||
address: bound.http.address,
|
||||
shutdown: shutdown.await,
|
||||
updateAvailable: (version: string) =>
|
||||
Context.get(context, Bus.Service).publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
@@ -99,9 +100,12 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
)
|
||||
expect(event.status).toBe(200)
|
||||
expect(event.headers.get("content-encoding")).toBeNull()
|
||||
const body = event.body
|
||||
if (!body) return yield* Effect.die(new Error("Event response has no body"))
|
||||
yield* Effect.promise(() => body.cancel())
|
||||
if (!event.body) return yield* Effect.die(new Error("Event response has no body"))
|
||||
const reader = event.body.getReader()
|
||||
yield* Effect.promise(() => readUntil(reader, "server.connected"))
|
||||
yield* server.updateAvailable("2.0.0")
|
||||
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
|
||||
yield* Effect.promise(() => reader.cancel())
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
|
||||
@@ -126,3 +130,11 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
|
||||
while (true) {
|
||||
const next = await reader.read()
|
||||
if (next.done) throw new Error(`Event stream ended before ${expected}`)
|
||||
if (new TextDecoder().decode(next.value).includes(expected)) return
|
||||
}
|
||||
}
|
||||
|
||||
+33
-65
@@ -100,7 +100,6 @@ import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider, useStorage } from "./context/storage"
|
||||
import { SessionTerminalsProvider } from "./context/session-terminals"
|
||||
import { PanelProvider, usePanel } from "./context/panel"
|
||||
import { SessionFrame } from "./component/session-frame"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
|
||||
@@ -187,7 +186,6 @@ export type TuiInput = {
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
updater?: {
|
||||
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
|
||||
apply: (version: string) => Promise<void>
|
||||
}
|
||||
packages: PackageSource
|
||||
@@ -222,6 +220,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const service = managed
|
||||
? {
|
||||
reconnect: async (signal: AbortSignal) => {
|
||||
// Give the server a chance to respawn itself before starting client-side recovery.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (signal.aborted) throw signal.reason ?? new Error("Server reconnect cancelled")
|
||||
const endpoint = await managed.reconnect(signal)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return { api: OpenCode.make(next), url: endpoint.url }
|
||||
@@ -398,24 +399,22 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PanelProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</PanelProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
@@ -477,7 +476,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const panels = usePanel()
|
||||
const keymap = Keymap.use()
|
||||
const event = useEvent()
|
||||
const client = useClient()
|
||||
@@ -509,36 +507,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
"update-notifications",
|
||||
{ initial: { versions: [] } },
|
||||
)
|
||||
const showUpdate = (version: string) => {
|
||||
const updater = props.updater
|
||||
if (!updater || updateNotifications.versions.includes(version)) return
|
||||
void markUpdateNotification((draft) => {
|
||||
draft.versions = [...draft.versions, version].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
const key = `update:${version}`
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogUpdate
|
||||
dialogKey={key}
|
||||
version={version}
|
||||
install={() => updater.apply(version)}
|
||||
restart={client.restart}
|
||||
/>
|
||||
),
|
||||
undefined,
|
||||
{ key },
|
||||
)
|
||||
dialog.setCentered(true)
|
||||
}
|
||||
onMount(() => {
|
||||
const updater = props.updater
|
||||
if (!updater) return
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
void updater.monitor(showUpdate, controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted) log.error("update monitor failed", { error })
|
||||
})
|
||||
})
|
||||
const tabsResize = createPaneResize({
|
||||
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
|
||||
defaultValue: () => SESSION_SIDEBAR_WIDTH,
|
||||
@@ -612,22 +580,9 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () =>
|
||||
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
|
||||
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const fullscreenPanel = () =>
|
||||
route.data.type === "session" &&
|
||||
panels.current()?.sessionID === route.data.sessionID &&
|
||||
panels.presentation() === "fullscreen"
|
||||
const tabsVisible = () => tabsAvailable() && !fullscreenPanel()
|
||||
const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
|
||||
|
||||
// Measure the prospective split layout, even while full-screen hides the tabs.
|
||||
createEffect(() => panels.setWidth(dimensions().width - (tabsAvailable() && tabsVertical() ? tabsResize.size() : 0)))
|
||||
createEffect(() => {
|
||||
const current = panels.current()
|
||||
if (!current || (route.data.type === "session" && route.data.sessionID === current.sessionID)) return
|
||||
panels.close()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = config.data.mouse
|
||||
})
|
||||
@@ -1260,6 +1215,19 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
})
|
||||
})
|
||||
|
||||
event.on("installation.update-available", (evt) => {
|
||||
const updater = props.updater
|
||||
const restart = client.restart
|
||||
if (!updater || !restart) return
|
||||
const version = evt.data.version
|
||||
if (updateNotifications.versions.includes(version)) return
|
||||
void markUpdateNotification((draft) => {
|
||||
draft.versions = [...draft.versions, version].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
dialog.replace(() => <DialogUpdate version={version} install={() => updater.apply(version)} restart={restart} />)
|
||||
dialog.setCentered(true)
|
||||
})
|
||||
|
||||
event.on("tui.session.select", (evt, { workspace }) => {
|
||||
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
|
||||
route.navigate({
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { isShellNotFoundError, type LocationRef, type ShellInfo } from "@opencode-ai/client"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show, untrack } from "solid-js"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
|
||||
const PAGE_BYTES = 64 * 1024
|
||||
|
||||
export function DialogShellOutput(props: { shell: ShellInfo; location: LocationRef }) {
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [info, setInfo] = createSignal(props.shell)
|
||||
const [output, setOutput] = createSignal<string>()
|
||||
const [omitted, setOmitted] = createSignal(false)
|
||||
const [error, setError] = createSignal("")
|
||||
const text = createMemo(() => stripAnsi(output() ?? "").replace(/\r\n?/g, "\n"))
|
||||
const height = () => Math.max(3, Math.floor(dimensions().height * 0.6) - 6)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
dialog.setSize("xlarge")
|
||||
dialog.setCentered(true)
|
||||
|
||||
createEffect(() => {
|
||||
// The running-shell inventory drops exited commands. Keep this view tied to
|
||||
// the opened ID and its original Location, not the list's current selection.
|
||||
const id = props.shell.id
|
||||
const location = { directory: props.location.directory, workspace: props.location.workspaceID }
|
||||
let cursor: number | undefined
|
||||
let disposed = false
|
||||
let missing = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const load = async () => {
|
||||
if (untrack(info).status === "running") {
|
||||
const current = await client.api.shell.get({ id, location })
|
||||
if (disposed) return false
|
||||
setInfo(current.data)
|
||||
}
|
||||
if (cursor === undefined) {
|
||||
const head = await client.api.shell.output({ id, location, cursor: Number.MAX_SAFE_INTEGER })
|
||||
if (disposed) return false
|
||||
cursor = Math.max(0, head.data.size - PAGE_BYTES)
|
||||
setOmitted(cursor > 0)
|
||||
}
|
||||
const page = await client.api.shell.output({ id, location, cursor, limit: PAGE_BYTES })
|
||||
if (disposed) return false
|
||||
cursor = page.data.cursor
|
||||
setOutput((previous) => {
|
||||
const next = (previous ?? "") + page.data.output
|
||||
if (next.length > PAGE_BYTES) setOmitted(true)
|
||||
return next.slice(-PAGE_BYTES)
|
||||
})
|
||||
setError("")
|
||||
return cursor < page.data.size
|
||||
}
|
||||
|
||||
const poll = () => {
|
||||
void load()
|
||||
.catch((cause: unknown) => {
|
||||
if (disposed) return
|
||||
missing = isShellNotFoundError(cause)
|
||||
setError(missing ? "Shell output is no longer available." : "Unable to read shell output. Retrying…")
|
||||
})
|
||||
.then((more) => {
|
||||
// Poll only while the viewer is open, including after exit so the final
|
||||
// file flush is observed. Never overlap reads or reload earlier pages.
|
||||
if (!disposed && !missing) timer = setTimeout(poll, more ? 0 : 1_000)
|
||||
})
|
||||
}
|
||||
poll()
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
clearTimeout(timer)
|
||||
})
|
||||
})
|
||||
|
||||
const status = () => {
|
||||
if (info().status === "running") return "Running"
|
||||
if (info().status === "timeout") return "Timed out"
|
||||
if (info().status === "killed") return "Killed"
|
||||
return info().exit === undefined ? "Exited" : `Exited · code ${info().exit}`
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{ bind: "up", title: "Scroll output up", group: "Shell", run: () => scroll?.scrollBy(-1) },
|
||||
{ bind: "down", title: "Scroll output down", group: "Shell", run: () => scroll?.scrollBy(1) },
|
||||
{ bind: "pageup", title: "Previous output page", group: "Shell", run: () => scroll?.scrollBy(-height()) },
|
||||
{ bind: "pagedown", title: "Next output page", group: "Shell", run: () => scroll?.scrollBy(height()) },
|
||||
{ bind: "home", title: "First loaded output", group: "Shell", run: () => scroll?.scrollTo(0) },
|
||||
{ bind: "end", title: "Follow shell output", group: "Shell", run: () => scroll?.scrollTo(Infinity) },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD} flexGrow={1}>
|
||||
Shell output
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>{status()}</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.subdued} maxHeight={3} wrapMode="word">
|
||||
{props.shell.command}
|
||||
</text>
|
||||
<Show when={omitted()}>
|
||||
<text fg={theme.text.subdued}>Earlier output omitted · showing recent output</text>
|
||||
</Show>
|
||||
<scrollbox
|
||||
id="shell-output-scroll"
|
||||
ref={(value: ScrollBoxRenderable) => (scroll = value)}
|
||||
height={height()}
|
||||
stickyScroll
|
||||
stickyStart="bottom"
|
||||
scrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
{text() ||
|
||||
(output() === undefined
|
||||
? "Loading output…"
|
||||
: "No captured output. Output redirected to files is not shown here.")}
|
||||
</text>
|
||||
</scrollbox>
|
||||
<Show when={error()}>
|
||||
<text fg={theme.text.feedback.error.default}>{error()}</text>
|
||||
</Show>
|
||||
<box flexDirection="row" gap={2} flexWrap="wrap">
|
||||
<text fg={theme.text.subdued}>↑/↓ scroll</text>
|
||||
<text fg={theme.text.subdued}>end follow</text>
|
||||
<text fg={theme.text.subdued}>esc back</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -8,32 +8,22 @@ import { useDialog } from "../ui/dialog"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
type State =
|
||||
| { type: "ready"; active: "update" | "skip" }
|
||||
| { type: "ready"; active: "update" | "ignore" }
|
||||
| { type: "installing" }
|
||||
| { type: "restarting" }
|
||||
| { type: "failed"; message: string }
|
||||
|
||||
export function DialogUpdate(props: {
|
||||
dialogKey: string
|
||||
version: string
|
||||
install: () => Promise<void>
|
||||
restart?: () => Promise<void>
|
||||
}) {
|
||||
export function DialogUpdate(props: { version: string; install: () => Promise<void>; restart: () => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const [state, setState] = createSignal<State>({ type: "ready", active: "update" })
|
||||
const close = () => {
|
||||
if (dialog.key === props.dialogKey) dialog.clear()
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
setState({ type: "installing" })
|
||||
await props.install()
|
||||
if (props.restart) {
|
||||
setState({ type: "restarting" })
|
||||
await props.restart()
|
||||
}
|
||||
close()
|
||||
setState({ type: "restarting" })
|
||||
await props.restart()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
const beginInstall = () => {
|
||||
@@ -44,16 +34,16 @@ export function DialogUpdate(props: {
|
||||
const run = () => {
|
||||
const current = state()
|
||||
if (current.type !== "ready") return
|
||||
if (current.active === "skip") return close()
|
||||
if (current.active === "ignore") return dialog.clear()
|
||||
beginInstall()
|
||||
}
|
||||
|
||||
const toggle = () =>
|
||||
setState((current) =>
|
||||
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
|
||||
current.type === "ready" ? { ...current, active: current.active === "update" ? "ignore" : "update" } : current,
|
||||
)
|
||||
|
||||
const selected = (action: "update" | "skip") => {
|
||||
const selected = (action: "update" | "ignore") => {
|
||||
const current = state()
|
||||
return current.type === "ready" && current.active === action
|
||||
}
|
||||
@@ -70,7 +60,7 @@ export function DialogUpdate(props: {
|
||||
bind: "return",
|
||||
title: "Confirm update action",
|
||||
group: "Dialog",
|
||||
run: () => (state().type === "failed" ? close() : run()),
|
||||
run: () => (state().type === "failed" ? dialog.clear() : run()),
|
||||
},
|
||||
{
|
||||
bind: "left",
|
||||
@@ -91,9 +81,9 @@ export function DialogUpdate(props: {
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Update available
|
||||
Update
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={close}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -101,17 +91,14 @@ export function DialogUpdate(props: {
|
||||
<Switch>
|
||||
<Match when={state().type === "ready"}>
|
||||
<text fg={theme.text.subdued}>
|
||||
An update is available. Applying will
|
||||
{props.restart
|
||||
? " restart the server and active sessions will be resumed."
|
||||
: " install the update but you will need to manually restart."}
|
||||
Update to v{props.version}? It will be applied in the background and active sessions will be restarted.
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={state().type === "installing"}>
|
||||
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}…</Spinner>
|
||||
<Spinner>Installing OpenCode {props.version}…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "restarting"}>
|
||||
<Spinner shimmer={theme.text.default}>Restarting the background service…</Spinner>
|
||||
<Spinner>Restarting the background service…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "failed"}>
|
||||
<text fg={theme.text.feedback.error.default}>{failure()}</text>
|
||||
@@ -127,7 +114,7 @@ export function DialogUpdate(props: {
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={theme.background.action.primary.focused}
|
||||
onMouseUp={close}
|
||||
onMouseUp={() => dialog.clear()}
|
||||
>
|
||||
<text fg={theme.text.action.primary.focused}>close</text>
|
||||
</box>
|
||||
@@ -136,19 +123,19 @@ export function DialogUpdate(props: {
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={["skip", "update"] as const}>
|
||||
<For each={["ignore", "update"] as const}>
|
||||
{(action) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (action === "skip") return close()
|
||||
if (action === "ignore") return dialog.clear()
|
||||
beginInstall()
|
||||
}}
|
||||
>
|
||||
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
|
||||
{action === "update" ? "Update" : "Skip"}
|
||||
{action === "update" ? "Update" : "Ignore"}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { BoxRenderable } from "@opentui/core"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { usePanel, type PanelTarget } from "../context/panel"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { ThemeContextProvider, useTheme } from "../context/theme"
|
||||
import { Slot } from "../plugin/render"
|
||||
|
||||
export function PanelHost(props: {
|
||||
panel: PanelTarget
|
||||
width: number
|
||||
focused: boolean
|
||||
onFocus: () => void
|
||||
onTarget: (node: BoxRenderable | undefined) => void
|
||||
}) {
|
||||
const panels = usePanel()
|
||||
let node: BoxRenderable
|
||||
onMount(() => props.onTarget(node))
|
||||
onCleanup(() => props.onTarget(undefined))
|
||||
|
||||
const Content = () => {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<box
|
||||
id="session-panel"
|
||||
ref={(value: BoxRenderable) => (node = value)}
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
focusable
|
||||
backgroundColor={theme.background.default}
|
||||
onMouseDown={props.onFocus}
|
||||
>
|
||||
<Slot
|
||||
path="session.panel"
|
||||
selection={props.panel}
|
||||
input={{
|
||||
sessionID: props.panel.sessionID,
|
||||
get width() {
|
||||
return props.width
|
||||
},
|
||||
get presentation() {
|
||||
return panels.presentation()
|
||||
},
|
||||
get canSplit() {
|
||||
return panels.canSplit()
|
||||
},
|
||||
get focused() {
|
||||
return props.focused
|
||||
},
|
||||
focus: props.onFocus,
|
||||
close: panels.close,
|
||||
toggleFullscreen: panels.toggleFullscreen,
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Keymap.Scope enabled={props.focused}>
|
||||
<ThemeContextProvider context={() => (panels.presentation() === "panel" ? "elevated" : undefined)}>
|
||||
<Content />
|
||||
</ThemeContextProvider>
|
||||
</Keymap.Scope>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import {
|
||||
CliRenderEvents,
|
||||
RGBA,
|
||||
MouseEvent,
|
||||
type BoxRenderable,
|
||||
type Renderable,
|
||||
type ScrollBoxRenderable,
|
||||
} from "@opentui/core"
|
||||
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createEffect, createMemo, createResource, createSignal, on, onCleanup, Show } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createResource, createSignal, on, 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 { usePanel } from "../context/panel"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { Session } from "../routes/session"
|
||||
import { Sidebar } from "../routes/session/sidebar"
|
||||
import { clampSessionPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { createPaneResize } from "../ui/pane-resize"
|
||||
import { PaneResizeHandle } from "../ui/pane-resize-handle"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { TerminalPane } from "./terminal-pane"
|
||||
import { PanelHost } from "./panel-host"
|
||||
|
||||
export function SessionFrame(props: { sessionID: string; verticalTabsWidth: number }) {
|
||||
const sessions = useSessionTerminals()
|
||||
@@ -31,49 +21,40 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
const config = useConfig()
|
||||
const data = useData()
|
||||
const toast = useToast()
|
||||
const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" })
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const panels = usePanel()
|
||||
const dialog = useDialog()
|
||||
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
|
||||
const defaultPaneWidth = () => Math.max(1, Math.floor(panels.width() / 2))
|
||||
const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", {
|
||||
initial: {},
|
||||
})
|
||||
const paneResize = createPaneResize({
|
||||
value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(),
|
||||
defaultValue: defaultPaneWidth,
|
||||
clamp: (width) => clampSessionPaneWidth(width, panels.width()),
|
||||
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()),
|
||||
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.paneWidth = width
|
||||
draft.terminalWidth = width
|
||||
}).catch((error) => console.error("Failed to persist TUI layout", error))
|
||||
},
|
||||
})
|
||||
let resizeRelease = false
|
||||
const finishPaneResize = (event: MouseEvent) => {
|
||||
if (paneResize.resizing()) {
|
||||
const finishTerminalResize = (event: MouseEvent) => {
|
||||
if (terminalResize.resizing()) {
|
||||
// A captured drag-end can be followed by mouse-up on the focus overlay.
|
||||
resizeRelease = true
|
||||
queueMicrotask(() => {
|
||||
resizeRelease = false
|
||||
})
|
||||
}
|
||||
paneResize.onMouseUp(event)
|
||||
terminalResize.onMouseUp(event)
|
||||
}
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [sessionWidth, setSessionWidth] = createSignal<number>()
|
||||
const [activePane, setActivePane] = createSignal<"session" | "right">("session")
|
||||
const [terminalFocused, setTerminalFocused] = createSignal(false)
|
||||
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
|
||||
let focusTerminal: (() => void) | undefined
|
||||
let showTerminals: (() => void) | undefined
|
||||
let sessionScroll: ScrollBoxRenderable | undefined
|
||||
let sessionNode: BoxRenderable | undefined
|
||||
let rightNode: BoxRenderable | undefined
|
||||
let panelNode: BoxRenderable | undefined
|
||||
createResource(
|
||||
() => (config.data.session.terminal ? props.sessionID : undefined),
|
||||
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
|
||||
@@ -84,23 +65,14 @@ 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 = panels.current()
|
||||
if (current?.sessionID === props.sessionID) return current
|
||||
})
|
||||
const fullscreen = () => activePanel() !== undefined && panels.presentation() === "fullscreen"
|
||||
createEffect(
|
||||
on([activePanel, () => selectedTerminal()?.id], ([panel, terminal], previous) => {
|
||||
if (panel && panel !== previous?.[0]) {
|
||||
setSidebarOpen(false)
|
||||
if (terminal) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
return
|
||||
}
|
||||
if (terminal && terminal !== previous?.[1]) {
|
||||
setSidebarOpen(false)
|
||||
if (panel) panels.close()
|
||||
}
|
||||
}),
|
||||
on(
|
||||
() => selectedTerminal()?.id,
|
||||
(id) => {
|
||||
if (id) setSidebarOpen(false)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
@@ -109,7 +81,6 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
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"
|
||||
@@ -123,137 +94,34 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
})
|
||||
.catch(toast.error)
|
||||
setSidebarOpen(!visible)
|
||||
if (!visible && activePanel()) panels.close()
|
||||
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
})
|
||||
}
|
||||
const focusSession = () => {
|
||||
if (fullscreen()) return
|
||||
// Permission prompts replace the input, so returning focus must not depend on it.
|
||||
if (activePane() === "right") renderer.currentFocusedRenderable?.blur()
|
||||
setActivePane("session")
|
||||
if (terminalFocused()) renderer.currentFocusedRenderable?.blur()
|
||||
prompt.current?.focus()
|
||||
}
|
||||
const focusRightPane = () => {
|
||||
setActivePane("right")
|
||||
if (activePanel()) {
|
||||
panelNode?.focus()
|
||||
return
|
||||
}
|
||||
focusTerminal?.()
|
||||
}
|
||||
const onFocused = () => {
|
||||
const current = renderer.currentFocusedRenderable
|
||||
if (rightPane() !== "sidebar" && within(current, rightNode)) setActivePane("right")
|
||||
if (!fullscreen() && within(current, sessionNode)) setActivePane("session")
|
||||
}
|
||||
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused))
|
||||
createEffect(() => {
|
||||
if (fullscreen()) focusRightPane()
|
||||
})
|
||||
createEffect(() => {
|
||||
if (rightPane() !== "terminal" && rightPane() !== "panel") setActivePane("session")
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!restoreTerminalFocus() || selectedTerminal()) return
|
||||
setRestoreTerminalFocus(false)
|
||||
focusSession()
|
||||
})
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: () => (rightPane() === "terminal" || activePanel() !== undefined) && dialog.stack.length === 0,
|
||||
enabled: () => config.data.session.terminal === true,
|
||||
commands: [
|
||||
{
|
||||
id: "pane.focus.left",
|
||||
title: "Focus session pane",
|
||||
enabled: () => !fullscreen(),
|
||||
run: focusSession,
|
||||
},
|
||||
{
|
||||
id: "pane.focus.right",
|
||||
title: "Focus right pane",
|
||||
run: focusRightPane,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
// Pane management stays reachable from either input scope.
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
id: "session.sidebar.toggle",
|
||||
title: rightPane() === "sidebar" ? "Hide sidebar" : "Show sidebar",
|
||||
group: "Session",
|
||||
palette: true,
|
||||
title: "Focus terminal pane",
|
||||
run: () => {
|
||||
toggleSidebar()
|
||||
dialog.clear()
|
||||
focusTerminal?.()
|
||||
},
|
||||
},
|
||||
...(config.data.session.terminal
|
||||
? [
|
||||
{
|
||||
id: "terminal.toggle",
|
||||
title: rightPane() === "terminal" ? "Hide terminal pane" : "Show terminal pane",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
run: () => {
|
||||
dialog.clear()
|
||||
if (rightPane() === "terminal") {
|
||||
focusSession()
|
||||
void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
return
|
||||
}
|
||||
void sessions
|
||||
.refresh(props.sessionID)
|
||||
.then(async () => {
|
||||
const terminal = sessions.get(props.sessionID).terminals.at(-1)
|
||||
if (terminal) return sessions.selectTerminal(props.sessionID, terminal.id)
|
||||
await sessions.newTerminal(props.sessionID)
|
||||
})
|
||||
.catch(terminalError)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "terminal.select",
|
||||
title: "Select terminal",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
run: () => {
|
||||
dialog.clear()
|
||||
if (fullscreen()) panels.close()
|
||||
focusSession()
|
||||
showTerminals?.()
|
||||
void sessions.refresh(props.sessionID).catch(terminalError)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "terminal.close",
|
||||
title: "Close terminal pane",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
enabled: rightPane() === "terminal",
|
||||
run: () => {
|
||||
dialog.clear()
|
||||
focusSession()
|
||||
void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.terminal",
|
||||
title: "New terminal",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
slash: { name: "terminal" },
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
await sessions.newTerminal(props.sessionID).catch(terminalError)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -264,38 +132,30 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
minHeight={0}
|
||||
flexDirection="row"
|
||||
position="relative"
|
||||
onMouseDrag={paneResize.onMouseDrag}
|
||||
onMouseDragEnd={finishPaneResize}
|
||||
onMouseUp={finishPaneResize}
|
||||
onMouseDrag={terminalResize.onMouseDrag}
|
||||
onMouseDragEnd={finishTerminalResize}
|
||||
onMouseUp={finishTerminalResize}
|
||||
>
|
||||
<box
|
||||
id="session-pane"
|
||||
ref={(value: BoxRenderable) => (sessionNode = value)}
|
||||
flexGrow={1}
|
||||
flexBasis={0}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
position={fullscreen() ? "absolute" : "relative"}
|
||||
visible={!fullscreen()}
|
||||
width={fullscreen() ? Math.max(0, panels.width() - paneResize.size()) : undefined}
|
||||
height="100%"
|
||||
position="relative"
|
||||
onSizeChange={function () {
|
||||
setSessionWidth(this.width)
|
||||
}}
|
||||
>
|
||||
<Keymap.Scope enabled={activePane() === "session" && !fullscreen()}>
|
||||
<Session
|
||||
scrollRef={(value) => (sessionScroll = value)}
|
||||
verticalTabsWidth={props.verticalTabsWidth}
|
||||
promptMuted={activePane() !== "session"}
|
||||
sidebarVisible={rightPane() === "sidebar"}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
|
||||
onTerminalPicker={(show) => (showTerminals = show)}
|
||||
width={sessionWidth()}
|
||||
/>
|
||||
</Keymap.Scope>
|
||||
<Show when={activePane() === "right"}>
|
||||
<Session
|
||||
scrollRef={(value) => (sessionScroll = value)}
|
||||
verticalTabsWidth={props.verticalTabsWidth}
|
||||
promptMuted={terminalFocused()}
|
||||
sidebarVisible={rightPane() === "sidebar"}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
|
||||
width={sessionWidth()}
|
||||
/>
|
||||
<Show when={terminalFocused()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
@@ -314,60 +174,35 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
}}
|
||||
// Consume the release before revealing permission buttons underneath.
|
||||
onMouseUp={() => {
|
||||
if (paneResize.resizing() || resizeRelease) return
|
||||
if (terminalResize.resizing() || resizeRelease) return
|
||||
focusSession()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
|
||||
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
|
||||
<box
|
||||
ref={(value: BoxRenderable) => (rightNode = value)}
|
||||
flexShrink={0}
|
||||
width={
|
||||
fullscreen() ? availableWidth() : rightPane() === "sidebar" ? SESSION_SIDEBAR_WIDTH : paneResize.size()
|
||||
}
|
||||
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
>
|
||||
<Show
|
||||
when={rightPane() === "sidebar"}
|
||||
fallback={
|
||||
<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)
|
||||
}}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<PanelHost
|
||||
panel={item}
|
||||
width={fullscreen() ? availableWidth() : paneResize.size()}
|
||||
focused={activePane() === "right"}
|
||||
onFocus={focusRightPane}
|
||||
onTarget={(node) => {
|
||||
panelNode = node
|
||||
if (node) {
|
||||
focusRightPane()
|
||||
return
|
||||
}
|
||||
setActivePane("session")
|
||||
<Show 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>
|
||||
@@ -377,8 +212,12 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!fullscreen() && (rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
|
||||
<PaneResizeHandle resize={paneResize} left={availableWidth() - paneResize.size() - 1} highlight="right" />
|
||||
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
|
||||
<PaneResizeHandle
|
||||
resize={terminalResize}
|
||||
left={availableWidth() - terminalResize.size() - 1}
|
||||
highlight="right"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={rightPane() === "sidebar" && !wide()}>
|
||||
<box
|
||||
@@ -396,11 +235,3 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function within(node: Renderable | null | undefined, root: Renderable | undefined) {
|
||||
if (!root) return false
|
||||
for (let current = node; current; current = current.parent) {
|
||||
if (current === root) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import {
|
||||
OptimizedBuffer,
|
||||
RGBA,
|
||||
TargetChannel,
|
||||
TextRenderable,
|
||||
type RenderContext,
|
||||
type TextOptions,
|
||||
} from "@opentui/core"
|
||||
import { extend, type JSX } from "@opentui/solid"
|
||||
import { splitProps } from "solid-js"
|
||||
import { coast, intensityAt } from "./tab-pulse"
|
||||
|
||||
type ShimmerTextOptions = TextOptions & {
|
||||
shimmer: RGBA
|
||||
}
|
||||
|
||||
const DURATION = 1200
|
||||
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
|
||||
const CONTINUATION = 0xc0000000 | 0
|
||||
|
||||
class ShimmerTextRenderable extends TextRenderable {
|
||||
private _shimmer = RGBA.defaultForeground()
|
||||
private elapsed = 0
|
||||
private scratch: OptimizedBuffer | undefined
|
||||
private mask = new Float32Array(0)
|
||||
private matrix = new Float32Array(16)
|
||||
|
||||
constructor(ctx: RenderContext, options: ShimmerTextOptions) {
|
||||
super(ctx, options)
|
||||
this.matrix[3] = this._shimmer.r
|
||||
this.matrix[7] = this._shimmer.g
|
||||
this.matrix[11] = this._shimmer.b
|
||||
this.matrix[15] = 1
|
||||
if (options.shimmer) this.shimmer = options.shimmer
|
||||
this.live = true
|
||||
}
|
||||
|
||||
set shimmer(value: RGBA) {
|
||||
if (value.equals(this._shimmer)) return
|
||||
this._shimmer = value
|
||||
this.matrix[3] = value.r
|
||||
this.matrix[7] = value.g
|
||||
this.matrix[11] = value.b
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
override render(buffer: OptimizedBuffer, deltaTime: number) {
|
||||
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
|
||||
this.elapsed = (this.elapsed + deltaTime) % DURATION
|
||||
if (!this.scratch)
|
||||
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
|
||||
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
|
||||
this.scratch.resize(this.width, this.height)
|
||||
|
||||
this.scratch.clear(TRANSPARENT)
|
||||
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
|
||||
const characters = this.scratch.buffers.char
|
||||
let end = 0
|
||||
for (let row = 0; row < this.height; row++) {
|
||||
let column = this.width
|
||||
while (
|
||||
column > 0 &&
|
||||
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
|
||||
)
|
||||
column--
|
||||
end = Math.max(end, column)
|
||||
}
|
||||
const front = -4 + coast(this.elapsed / DURATION) * (end + 22)
|
||||
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
|
||||
let strength = 0
|
||||
for (let cell = 0; cell < characters.length; cell++) {
|
||||
const column = cell % this.width
|
||||
if ((characters[cell] & CONTINUATION) !== CONTINUATION) strength = intensityAt(column, front, 4, 18)
|
||||
this.mask[cell * 3] = column
|
||||
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
|
||||
this.mask[cell * 3 + 2] = strength
|
||||
}
|
||||
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
|
||||
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
|
||||
this.markClean()
|
||||
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
this.scratch?.destroy()
|
||||
this.scratch = undefined
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
extend({ shimmer_text: ShimmerTextRenderable })
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
shimmer_text: typeof ShimmerTextRenderable
|
||||
}
|
||||
}
|
||||
|
||||
type Props = Omit<JSX.IntrinsicElements["text"], "ref"> & { shimmer: RGBA }
|
||||
|
||||
export function ShimmerText(props: Props) {
|
||||
const [local, text] = splitProps(props, ["shimmer"])
|
||||
return <shimmer_text {...text} shimmer={local.shimmer} />
|
||||
}
|
||||
@@ -1,48 +1,30 @@
|
||||
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useConfig } from "../config"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { registerOpencodeSpinner } from "./register-spinner"
|
||||
import { SPINNER_FRAMES } from "./spinner-frames"
|
||||
import { ShimmerText } from "./shimmer-text"
|
||||
|
||||
export { SPINNER_FRAMES } from "./spinner-frames"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA; shimmer?: RGBA }) {
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const theme = useTheme()
|
||||
const config = useConfig().data
|
||||
const color = () => props.color ?? theme.text.subdued
|
||||
const [frame, setFrame] = createSignal(0)
|
||||
createEffect(() => {
|
||||
if (!(config.animations ?? true) || !props.shimmer) return
|
||||
const timer = setInterval(() => setFrame((value) => (value + 1) % SPINNER_FRAMES.length), 80)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
return (
|
||||
<Show
|
||||
when={config.animations ?? true}
|
||||
fallback={<text fg={color()}>{props.children ? <>⋯ {props.children}</> : "⋯"}</text>}
|
||||
>
|
||||
<Show
|
||||
when={props.shimmer}
|
||||
fallback={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
<text fg={color()}>{props.children}</text>
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(shimmer) => (
|
||||
<ShimmerText fg={color()} shimmer={shimmer()}>
|
||||
{SPINNER_FRAMES[frame()]} {props.children}
|
||||
</ShimmerText>
|
||||
)}
|
||||
</Show>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
<text fg={color()}>{props.children}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
|
||||
import { CliRenderEvents, EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
|
||||
import type { ResolvedThemeTokens } from "@opencode-ai/theme/tui"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
@@ -28,6 +28,7 @@ export function TerminalPane(props: {
|
||||
onAutoFocus?: () => void
|
||||
onFocusRequest?: (focus: (() => void) | undefined) => void
|
||||
onDisconnect?: () => void
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
}) {
|
||||
const client = useClient()
|
||||
const keymap = Keymap.use()
|
||||
@@ -147,6 +148,9 @@ export function TerminalPane(props: {
|
||||
},
|
||||
{ priority: 100 },
|
||||
)
|
||||
// Blur emits this event before updating the terminal's own focused flag.
|
||||
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === terminal)
|
||||
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
createEffect(() => {
|
||||
if (!props.autoFocus || !terminal) return
|
||||
terminal.focus()
|
||||
@@ -168,6 +172,8 @@ export function TerminalPane(props: {
|
||||
waitingSize?.resolve()
|
||||
socket?.close()
|
||||
offKeys()
|
||||
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
props.onFocusChange?.(false)
|
||||
props.onFocusRequest?.(undefined)
|
||||
})
|
||||
|
||||
|
||||
@@ -93,7 +93,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 right pane"),
|
||||
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
|
||||
"terminal.select": keybind("<leader>down", "Select terminal"),
|
||||
"terminal.toggle": keybind("<leader>t", "Toggle terminal pane"),
|
||||
"terminal.close": keybind("<leader>up", "Close terminal pane"),
|
||||
@@ -244,6 +244,7 @@ export const Definitions = {
|
||||
"composer.subagent.interrupt": keybind("ctrl+d", "Interrupt subagent"),
|
||||
"composer.shell.up": keybind("up", "Previous shell"),
|
||||
"composer.shell.down": keybind("down", "Next shell"),
|
||||
"composer.shell.select": keybind("return", "View shell output"),
|
||||
"composer.shell.kill": keybind("ctrl+d", "Kill shell command"),
|
||||
"composer.terminal.up": keybind("up,k", "Previous terminal"),
|
||||
"composer.terminal.down": keybind("down,j", "Next terminal"),
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { PanelPresentation } from "@opencode-ai/plugin/tui/context"
|
||||
import { batch, createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
export type PanelTarget = {
|
||||
readonly plugin: string
|
||||
readonly name: string
|
||||
readonly sessionID: string
|
||||
}
|
||||
|
||||
export function createPanelState() {
|
||||
const [current, setCurrent] = createSignal<PanelTarget>()
|
||||
const [requested, setRequested] = createSignal<PanelPresentation>("panel")
|
||||
const [width, setWidth] = createSignal(0)
|
||||
const canSplit = () => width() > 80
|
||||
const presentation = createMemo(() => (canSplit() ? requested() : "fullscreen"))
|
||||
return {
|
||||
current,
|
||||
width,
|
||||
canSplit,
|
||||
presentation,
|
||||
setWidth,
|
||||
open(target: PanelTarget, presentation: PanelPresentation = "panel") {
|
||||
batch(() => {
|
||||
setRequested(presentation)
|
||||
setCurrent((current) =>
|
||||
current?.plugin === target.plugin && current.name === target.name && current.sessionID === target.sessionID
|
||||
? current
|
||||
: target,
|
||||
)
|
||||
})
|
||||
},
|
||||
close: () => setCurrent(),
|
||||
release(plugin: string, name?: string) {
|
||||
if (current()?.plugin !== plugin || (name !== undefined && current()?.name !== name)) return
|
||||
setCurrent()
|
||||
},
|
||||
toggleFullscreen() {
|
||||
if (!canSplit()) return
|
||||
setRequested((current) => (current === "panel" ? "fullscreen" : "panel"))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const Context = createContext<ReturnType<typeof createPanelState>>()
|
||||
|
||||
export function PanelProvider(props: ParentProps) {
|
||||
return <Context.Provider value={createPanelState()}>{props.children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function usePanel() {
|
||||
const value = useContext(Context)
|
||||
if (!value) throw new Error("usePanel must be used within a PanelProvider")
|
||||
return value
|
||||
}
|
||||
|
||||
export function useOptionalPanel() {
|
||||
return useContext(Context)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "../theme"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -379,19 +379,12 @@ export function useTheme(context?: ContextName) {
|
||||
}
|
||||
export const ThemeProvider = themeContext.provider
|
||||
|
||||
/** An accessor switches context without remounting children; undefined inherits the enclosing view. */
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | Accessor<ContextName | undefined> }>) {
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
|
||||
const value = themeContext.use()
|
||||
const context = props.context
|
||||
const current =
|
||||
typeof context === "function"
|
||||
? createComponentThemeView(() => {
|
||||
const name = context()
|
||||
return name ? value.themes.currentTokens().contextual[name] : value.current
|
||||
}, value.themes.mode)
|
||||
: value.themes.current.contextual[context]
|
||||
return (
|
||||
<themeContext.context.Provider value={{ current, themes: value.themes, ready: value.ready }}>
|
||||
<themeContext.context.Provider
|
||||
value={{ current: value.themes.current.contextual[props.context], themes: value.themes, ready: value.ready }}
|
||||
>
|
||||
{props.children}
|
||||
</themeContext.context.Provider>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,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"
|
||||
@@ -1077,6 +1076,76 @@ export function DiffViewerContent(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function DiffFileMenu(props: {
|
||||
context: Plugin.Context
|
||||
state: FileMenuState
|
||||
reviewed: boolean
|
||||
onToggle: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.overlay
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
|
||||
const run = () => {
|
||||
props.onClose()
|
||||
props.onToggle()
|
||||
}
|
||||
onCleanup(props.context.keymap.mode.push("menu"))
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "menu",
|
||||
commands: [
|
||||
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
|
||||
{ bind: "return", title: label(), group: "Diff", run },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
id="diff-file-menu-overlay"
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
zIndex={2600}
|
||||
onMouseDown={(event) => {
|
||||
props.onClose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="diff-file-menu"
|
||||
position="absolute"
|
||||
left={Math.max(0, Math.min(props.state.x, dimensions().width - 19))}
|
||||
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
|
||||
width={19}
|
||||
height={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === MouseButton.RIGHT) props.onClose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.button === MouseButton.LEFT) run()
|
||||
}}
|
||||
>
|
||||
<text fg={theme.text.default} selectable={false}>
|
||||
{label()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import { namedSlotKey, type Placement, type PlacementKind } from "./structure"
|
||||
import type { Placement, PlacementKind } from "./structure"
|
||||
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useClient } from "../context/client"
|
||||
@@ -20,7 +20,6 @@ import { useToast } from "../ui/toast"
|
||||
import { useAttention } from "../context/attention"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useOptionalPanel } from "../context/panel"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
|
||||
export type Dispose = () => Promise<void>
|
||||
@@ -31,7 +30,6 @@ export type SlotRender = (input: SlotMap[SlotPath]) => JSX.Element
|
||||
|
||||
// A registered claim as stored by the plugin provider's registry.
|
||||
export type RegisteredSlot = {
|
||||
readonly name?: string
|
||||
readonly placement: Placement
|
||||
readonly render: SlotRender
|
||||
}
|
||||
@@ -70,7 +68,6 @@ export function usePluginHost() {
|
||||
attention: useAttention(),
|
||||
storage: useStorage(),
|
||||
sessionTabs: useSessionTabs(),
|
||||
panel: useOptionalPanel(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,12 +97,11 @@ export function createPluginContext(input: {
|
||||
}
|
||||
// Unregistering after deactivation is a no-op: deactivate already resets
|
||||
// the registration's routes and slots wholesale.
|
||||
const registration = (kind: "routes" | "slots" | "markdown", name: string, onRemove?: () => void) => {
|
||||
const registration = (kind: "routes" | "slots" | "markdown", name: string) => {
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
registered = false
|
||||
onRemove?.()
|
||||
if (!input.registry.active()) return
|
||||
input.registry.remove(kind, name)
|
||||
}
|
||||
@@ -178,22 +174,6 @@ export function createPluginContext(input: {
|
||||
return host.route.data
|
||||
},
|
||||
},
|
||||
panel: {
|
||||
open(name, options) {
|
||||
if (!host.panel || !input.registry.active()) return false
|
||||
if (!input.registry.has("slots", namedSlotKey("session.panel", name))) return false
|
||||
const route = host.route.data
|
||||
if (route.type !== "session") return false
|
||||
host.panel.open({ plugin: input.id, name, sessionID: route.sessionID }, options?.presentation)
|
||||
return true
|
||||
},
|
||||
close: () => host.panel?.release(input.id),
|
||||
current() {
|
||||
const current = host.panel?.current()
|
||||
if (current?.plugin !== input.id) return
|
||||
return { name: current.name, sessionID: current.sessionID }
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
enabled: host.sessionTabs.enabled,
|
||||
list: () =>
|
||||
@@ -226,25 +206,19 @@ export function createPluginContext(input: {
|
||||
},
|
||||
},
|
||||
slot(value: SlotClaim) {
|
||||
// Keys are counter-suffixed so one plugin may claim several places;
|
||||
// order within the plugin is registration order.
|
||||
const key = `slot#${claims++}`
|
||||
// Exactly one placement kind, enforced at runtime for untyped plugins.
|
||||
const kinds = placements.filter((item) => value[item] !== undefined)
|
||||
if (kinds.length !== 1) throw new Error("Slot claim requires exactly one placement key")
|
||||
const kind = kinds[0]
|
||||
const target = value[kind] as string
|
||||
if (value.name !== undefined && !value.name) throw new Error("Slot names cannot be empty")
|
||||
if (target === "session.panel" && !value.name) throw new Error("Session panels require a slot name")
|
||||
if (target === "session.panel" && kind !== "replace") throw new Error("Session panels use replacement claims")
|
||||
const key = value.name ? namedSlotKey(target, value.name) : `slot#${claims++}`
|
||||
if (input.registry.has("slots", key)) throw new Error(`Slot already registered: ${value.name}`)
|
||||
input.registry.set("slots", key, {
|
||||
name: value.name,
|
||||
placement: { kind, target },
|
||||
placement: { kind, target: value[kind] as string },
|
||||
// The registration map erases the path-specific input type.
|
||||
render: (slotInput) => provide(() => (value.render as SlotRender)(slotInput)),
|
||||
})
|
||||
return registration("slots", key, () => {
|
||||
if (target === "session.panel") host.panel?.release(input.id, value.name)
|
||||
})
|
||||
return registration("slots", key)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { fileURLToPath } from "url"
|
||||
import type { Page } from "@opencode-ai/plugin/tui/context"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { namedSlotKey, resolveSlots, type Claim } from "./structure"
|
||||
import { resolveSlots, type Claim } from "./structure"
|
||||
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import "#runtime-plugin-support"
|
||||
@@ -60,7 +60,6 @@ type Value = {
|
||||
// A mounted <Slot> instance registers its path; the disposer unregisters.
|
||||
readonly register: (path: string) => () => void
|
||||
readonly resolved: () => ReturnType<typeof resolveSlots<SlotRender>>
|
||||
readonly named: (path: string, plugin: string, name: string) => RegisteredSlot | undefined
|
||||
}
|
||||
readonly markdown: () => MarkdownOptions["renderNode"]
|
||||
readonly activate: (id: string) => Promise<boolean>
|
||||
@@ -458,20 +457,18 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
// order within one plugin. The resolver's last-wins rules depend on it.
|
||||
const claims = createMemo(() =>
|
||||
Object.entries(store.registrations).flatMap(([id, registration]) =>
|
||||
Object.entries(registration.active ? registration.slots : {})
|
||||
.filter(([, slot]) => slot.placement.target !== "session.panel")
|
||||
.map(([key, slot]) => {
|
||||
// Rows downstream diff by reference; a stable claim per render
|
||||
// function keeps untouched plugins' slot rows (and their state)
|
||||
// alive across other plugins' reloads.
|
||||
const cached = slotItems.get(slot.render)
|
||||
if (cached) return cached
|
||||
// Placements are immutable once registered; unwrap the store proxy
|
||||
// so resolver reads don't subscribe tracked scopes.
|
||||
const item = { key: `${id}/${key}`, plugin: id, placement: unwrap(slot.placement), render: slot.render }
|
||||
slotItems.set(slot.render, item)
|
||||
return item
|
||||
}),
|
||||
Object.entries(registration.active ? registration.slots : {}).map(([key, slot]) => {
|
||||
// Rows downstream diff by reference; a stable claim per render
|
||||
// function keeps untouched plugins' slot rows (and their state)
|
||||
// alive across other plugins' reloads.
|
||||
const cached = slotItems.get(slot.render)
|
||||
if (cached) return cached
|
||||
// Placements are immutable once registered; unwrap the store proxy
|
||||
// so resolver reads don't subscribe tracked scopes.
|
||||
const item = { key: `${id}/${key}`, plugin: id, placement: unwrap(slot.placement), render: slot.render }
|
||||
slotItems.set(slot.render, item)
|
||||
return item
|
||||
}),
|
||||
),
|
||||
)
|
||||
// Object.keys tracks the store's keys node only: refcount changes on an
|
||||
@@ -558,15 +555,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
active: plugin.active,
|
||||
})),
|
||||
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
||||
slots: {
|
||||
register: registerSlot,
|
||||
resolved,
|
||||
named(path, plugin, name) {
|
||||
const registration = store.registrations[plugin]
|
||||
if (!registration?.active) return
|
||||
return registration.slots[namedSlotKey(path, name)]
|
||||
},
|
||||
},
|
||||
slots: { register: registerSlot, resolved },
|
||||
markdown,
|
||||
// Manual dialog toggles join the same chain as reconciles so a
|
||||
// toggle mid-reload cannot mix registrations across generations.
|
||||
|
||||
@@ -74,11 +74,7 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
|
||||
const SlotParent = createContext<string>()
|
||||
|
||||
// `input` is required exactly when the path publishes a non-empty input.
|
||||
type SlotProps<Path extends SlotPath> = ParentProps<{
|
||||
readonly path: Path
|
||||
readonly selection?: { readonly plugin: string; readonly name: string }
|
||||
}> &
|
||||
(Path extends "session.panel" ? { readonly selection: { readonly plugin: string; readonly name: string } } : {}) &
|
||||
type SlotProps<Path extends SlotPath> = ParentProps<{ readonly path: Path }> &
|
||||
({} extends SlotMap[Path] ? { readonly input?: SlotMap[Path] } : { readonly input: SlotMap[Path] })
|
||||
|
||||
// One named boundary of the host UI's slot tree. The host's own content are
|
||||
@@ -101,26 +97,6 @@ export function Slot<Path extends SlotPath>(props: SlotProps<Path>) {
|
||||
}
|
||||
onCleanup(plugins.slots.register(path))
|
||||
const input = () => (props as { readonly input?: SlotMap[Path] }).input ?? ({} as SlotMap[Path])
|
||||
// Selected panels use the same owned registrations and boundary as composed
|
||||
// slots, but choose a named contribution instead of last-enabled replacement.
|
||||
const selected = createMemo(() => {
|
||||
const selection = props.selection
|
||||
if (!selection) return
|
||||
return plugins.slots.named(path, selection.plugin, selection.name)
|
||||
})
|
||||
if (path === "session.panel") {
|
||||
return (
|
||||
<SlotParent.Provider value={path}>
|
||||
<Show keyed when={selected()}>
|
||||
{(claim) => (
|
||||
<PluginBoundary id={props.selection!.plugin} where={`slot ${path}`}>
|
||||
{createComponent(claim.render, mergeProps(input))}
|
||||
</PluginBoundary>
|
||||
)}
|
||||
</Show>
|
||||
</SlotParent.Provider>
|
||||
)
|
||||
}
|
||||
const slotted = createMemo(
|
||||
() => plugins.slots.resolved().slotted.get(path) ?? emptySlotted<SlotRender>(),
|
||||
emptySlotted<SlotRender>(),
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
|
||||
export type PlacementKind = "prepend" | "append" | "before" | "after" | "replace"
|
||||
|
||||
export function namedSlotKey(path: string, name: string) {
|
||||
return `slot:${path}:${name}`
|
||||
}
|
||||
|
||||
// Normalized from the public SlotClaim shape by the plugin API: exactly one
|
||||
// placement kind, the target path erased to a string so the resolver stays
|
||||
// independent of the slot map.
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useClient } from "../../../context/client"
|
||||
import { useTheme } from "../../../context/theme"
|
||||
import { Keymap } from "../../../context/keymap"
|
||||
import { useComposerTab } from "./index"
|
||||
import { useDialog } from "../../../ui/dialog"
|
||||
import { DialogShellOutput } from "../../../component/dialog-shell-output"
|
||||
|
||||
export function ShellTab(props: { sessionID: string }) {
|
||||
const data = useData()
|
||||
@@ -13,6 +15,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
const theme = useTheme()
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const dialog = useDialog()
|
||||
|
||||
const entries = createMemo(() =>
|
||||
data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"),
|
||||
@@ -23,6 +26,11 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
|
||||
const selectedEntry = createMemo(() => entries()[store.selected])
|
||||
|
||||
const open = () => {
|
||||
const entry = selectedEntry()
|
||||
if (entry) dialog.replace(() => <DialogShellOutput shell={entry} location={entry.location} />)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (store.selected >= entries().length) setStore("selected", Math.max(0, entries().length - 1))
|
||||
})
|
||||
@@ -42,7 +50,13 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
const cleanup = composer.register({
|
||||
id: "shell",
|
||||
label: "Shell",
|
||||
hints: () => (selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : []),
|
||||
hints: () =>
|
||||
selectedEntry()
|
||||
? [
|
||||
{ label: "output", shortcut: shortcuts.get("composer.shell.select") ?? "" },
|
||||
{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" },
|
||||
]
|
||||
: [],
|
||||
})
|
||||
onCleanup(cleanup)
|
||||
})
|
||||
@@ -74,6 +88,12 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
setStore("selected", (prev) => (prev + 1) % list.length)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "composer.shell.select",
|
||||
title: "View shell output",
|
||||
group: "Composer",
|
||||
run: open,
|
||||
},
|
||||
{
|
||||
id: "composer.shell.kill",
|
||||
title: "Kill shell command",
|
||||
@@ -106,6 +126,10 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
active() ? theme.background.action.primary.focused : theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
onMouseUp={() => {
|
||||
setStore("selected", index())
|
||||
open()
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={active() ? theme.text.action.primary.focused : theme.text.action.primary.default}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -113,6 +113,7 @@ import { createDelayedPresence } from "../../util/delayed-presence"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
import { isRecord } from "../../util/record"
|
||||
import { createHistoryPrepend } from "./history"
|
||||
import { useSessionTerminals } from "../../context/session-terminals"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -160,7 +161,6 @@ export function Session(props: {
|
||||
sidebarVisible: boolean
|
||||
onToggleSidebar: () => void
|
||||
visibleTerminalID?: string
|
||||
onTerminalPicker?: (show: (() => void) | undefined) => void
|
||||
width?: number
|
||||
}) {
|
||||
const setEpilogue = useEpilogue()
|
||||
@@ -234,8 +234,6 @@ export function Session(props: {
|
||||
open: false,
|
||||
tab: undefined as string | undefined,
|
||||
})
|
||||
props.onTerminalPicker?.(() => setComposer({ open: true, tab: "terminals" }))
|
||||
onCleanup(() => props.onTerminalPicker?.(undefined))
|
||||
createEffect(() => {
|
||||
if (props.promptMuted && composer.open) setComposer("open", false)
|
||||
})
|
||||
@@ -262,6 +260,7 @@ export function Session(props: {
|
||||
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
const toast = useToast()
|
||||
const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" })
|
||||
const client = useClient()
|
||||
const autoApproved = new Set<string>()
|
||||
createEffect(() => {
|
||||
@@ -296,6 +295,7 @@ export function Session(props: {
|
||||
const [firstJump, setFirstJump] = createSignal<() => void>()
|
||||
const [synced, setSynced] = createSignal(false)
|
||||
const sessionTabs = useSessionTabs()
|
||||
const terminals = useSessionTerminals()
|
||||
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
|
||||
const [latestHovered, setLatestHovered] = createSignal(false)
|
||||
let ensureAllRowsPending: (() => void)[] | undefined
|
||||
@@ -976,6 +976,73 @@ export function Session(props: {
|
||||
})()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: props.sidebarVisible ? "Hide sidebar" : "Show sidebar",
|
||||
id: "session.sidebar.toggle",
|
||||
group: "Session",
|
||||
run: () => {
|
||||
props.onToggleSidebar()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
...(config.session.terminal
|
||||
? [
|
||||
{
|
||||
title: props.visibleTerminalID ? "Hide terminal pane" : "Show terminal pane",
|
||||
id: "terminal.toggle",
|
||||
group: "Session",
|
||||
run: () => {
|
||||
const sessionID = route.sessionID
|
||||
if (props.visibleTerminalID) {
|
||||
promptRef.current?.focus()
|
||||
void terminals.selectTerminal(sessionID, null).catch(toast.error)
|
||||
} else {
|
||||
void terminals
|
||||
.refresh(sessionID)
|
||||
.then(async () => {
|
||||
const terminal = terminals.get(sessionID).terminals.at(-1)
|
||||
if (terminal) return terminals.selectTerminal(sessionID, terminal.id)
|
||||
await terminals.newTerminal(sessionID)
|
||||
})
|
||||
.catch(terminalError)
|
||||
}
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Select terminal",
|
||||
id: "terminal.select",
|
||||
group: "Session",
|
||||
run: () => {
|
||||
promptRef.current?.focus()
|
||||
setComposer({ open: true, tab: "terminals" })
|
||||
void terminals.refresh(route.sessionID).catch(terminalError)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Close terminal pane",
|
||||
id: "terminal.close",
|
||||
group: "Session",
|
||||
enabled: props.visibleTerminalID !== undefined,
|
||||
run: () => {
|
||||
promptRef.current?.focus()
|
||||
void terminals.selectTerminal(route.sessionID, null).catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "New terminal",
|
||||
id: "session.terminal",
|
||||
group: "Session",
|
||||
slash: { name: "terminal" },
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
await terminals.newTerminal(route.sessionID).catch(terminalError)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: (() => {
|
||||
const next = nextThinkingMode(thinkingMode())
|
||||
@@ -1440,6 +1507,7 @@ export function Session(props: {
|
||||
<Prompt
|
||||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
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}
|
||||
|
||||
@@ -3,16 +3,7 @@ import type { Accessor } from "solid-js"
|
||||
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode-ai/theme/tui"
|
||||
|
||||
export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
|
||||
return Object.assign(createComponentThemeView(current, mode), {
|
||||
contextual: {
|
||||
elevated: createComponentThemeView(() => current().contextual.elevated, mode),
|
||||
overlay: createComponentThemeView(() => current().contextual.overlay, mode),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mode: Accessor<Mode>) {
|
||||
return {
|
||||
const create = (view: Accessor<ResolvedThemeTokens>) => ({
|
||||
get hue() {
|
||||
return view().hue
|
||||
},
|
||||
@@ -44,7 +35,14 @@ export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mo
|
||||
increase: (color: RGBA, amount = 1) => view().increase(color, amount),
|
||||
decrease: (color: RGBA, amount = 1) => view().decrease(color, amount),
|
||||
raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)),
|
||||
}
|
||||
})
|
||||
|
||||
return Object.assign(create(current), {
|
||||
contextual: {
|
||||
elevated: create(() => current().contextual.elevated),
|
||||
overlay: create(() => current().contextual.overlay),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type ComponentTheme = ReturnType<typeof createComponentTheme>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function clampSessionTabsWidth(width: number, total: number) {
|
||||
)
|
||||
}
|
||||
|
||||
export function clampSessionPaneWidth(width: number, total: number) {
|
||||
export function clampTerminalPaneWidth(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)))
|
||||
|
||||
@@ -5,9 +5,10 @@ import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import path from "node:path"
|
||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
||||
import { createAppFixture } from "./fixture/tui-app"
|
||||
import { createEventStream, createFetch, directory, json, type FetchHandler } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
import type { TuiInput } from "../src/app"
|
||||
import type { Config } from "../src/config"
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
|
||||
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
|
||||
@@ -1228,7 +1229,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
|
||||
})
|
||||
|
||||
test.each(["manual", "select"] as const)(
|
||||
"selection copy and pane management respect %s mode in the prompt and terminal pane",
|
||||
"selection copy and dismissal respect %s mode in the prompt and terminal pane",
|
||||
async (copy) => {
|
||||
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
@@ -1359,19 +1360,6 @@ test.each(["manual", "select"] as const)(
|
||||
expect(setup.renderer.hasSelection).toBeFalse()
|
||||
expect(setup.renderer.isDestroyed).toBeFalse()
|
||||
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressArrow("up")
|
||||
await setup.waitFor(() => terminal.isDestroyed)
|
||||
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressKey("t")
|
||||
await setup.waitForFrame((frame) => frame.includes("alpha beta gamma"))
|
||||
expect(setup.renderer.currentFocusedRenderable).toBeInstanceOf(EmbeddedTerminalRenderable)
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressArrow("down")
|
||||
await setup.waitForFrame((frame) => frame.includes("Subagents") && frame.includes("Terminals"))
|
||||
expect(setup.renderer.currentFocusedRenderable).not.toBeInstanceOf(EmbeddedTerminalRenderable)
|
||||
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
@@ -1542,3 +1530,54 @@ test("server plugin failures share one notice and use source names before an ID
|
||||
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
|
||||
expect(setup.captureCharFrame()).toContain("Open plugins")
|
||||
})
|
||||
|
||||
async function createAppFixture(
|
||||
input: {
|
||||
width?: number
|
||||
height?: number
|
||||
state?: string
|
||||
config?: Config.Info
|
||||
args?: TuiInput["args"]
|
||||
fetch?: FetchHandler
|
||||
} = {},
|
||||
) {
|
||||
const { run } = await import("../src/app")
|
||||
const setup = await createTestRenderer({
|
||||
width: input.width ?? 100,
|
||||
height: input.height ?? 30,
|
||||
useThread: false,
|
||||
kittyKeyboard: true,
|
||||
})
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(input.fetch, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: input.args ?? {},
|
||||
log: () => {},
|
||||
}).pipe(
|
||||
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
|
||||
Effect.provide(FileSystem.layerNoop({})),
|
||||
),
|
||||
)
|
||||
return {
|
||||
...setup,
|
||||
events,
|
||||
ready: ready.promise,
|
||||
async [Symbol.asyncDispose]() {
|
||||
try {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { LocationProvider } from "../../../src/context/location"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
@@ -31,6 +33,7 @@ async function renderComposer(
|
||||
const events = createEventStream()
|
||||
const interrupted: string[] = []
|
||||
const removed: string[] = []
|
||||
const viewed: string[] = []
|
||||
const ready = Promise.withResolvers<void>()
|
||||
let closed = 0
|
||||
let dispatch!: ReturnType<typeof Keymap.use>["dispatch"]
|
||||
@@ -53,6 +56,13 @@ async function renderComposer(
|
||||
})
|
||||
}
|
||||
const shellID = url.pathname.match(/^\/api\/shell\/([^/]+)$/)?.[1]
|
||||
if (shellID && request.method === "GET") {
|
||||
viewed.push(shellID)
|
||||
return json({ location: { directory }, data: shells.find((shell) => shell.id === shellID) })
|
||||
}
|
||||
if (url.pathname.endsWith("/output")) {
|
||||
return json({ location: { directory }, data: { output: "", cursor: 0, size: 0, truncated: false } })
|
||||
}
|
||||
if (shellID && request.method === "DELETE") {
|
||||
removed.push(shellID)
|
||||
return new Response(null, { status: 204 })
|
||||
@@ -100,7 +110,11 @@ async function renderComposer(
|
||||
<LocationProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<Content />
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Content />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</RouteProvider>
|
||||
</LocationProvider>
|
||||
@@ -119,6 +133,7 @@ async function renderComposer(
|
||||
app,
|
||||
interrupted,
|
||||
removed,
|
||||
viewed,
|
||||
route: () => route.data,
|
||||
dispatch: (command: string) => dispatch(command),
|
||||
closed: () => closed,
|
||||
@@ -154,15 +169,18 @@ test("disabled shell bindings have no component fallbacks", async () => {
|
||||
const composer = await renderComposer("shell", {
|
||||
"composer.shell.up": "none",
|
||||
"composer.shell.down": "none",
|
||||
"composer.shell.select": "none",
|
||||
"composer.shell.kill": "none",
|
||||
})
|
||||
try {
|
||||
expect(composer.app.captureCharFrame()).toContain("bun test")
|
||||
composer.app.mockInput.pressArrow("up")
|
||||
composer.app.mockInput.pressEnter()
|
||||
composer.app.mockInput.pressKey("d", { ctrl: true })
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.closed()).toBe(0)
|
||||
expect(composer.removed).toEqual([])
|
||||
expect(composer.viewed).toEqual([])
|
||||
|
||||
composer.app.mockInput.pressArrow("down")
|
||||
composer.dispatch("composer.shell.kill")
|
||||
@@ -198,6 +216,22 @@ test("ctrl+c closes the active composer", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("shell output respects a configured binding with a focused textarea", async () => {
|
||||
const composer = await renderComposer("shell", { "composer.shell.select": "ctrl+o" }, true)
|
||||
try {
|
||||
composer.app.mockInput.pressEnter()
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.viewed).toEqual([])
|
||||
composer.app.mockInput.pressKey("o", { ctrl: true })
|
||||
await wait(() => composer.viewed.length > 0)
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.app.captureCharFrame()).toContain("Shell output")
|
||||
expect(composer.viewed).toEqual(["sh-a"])
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function session(id: string, title: string, parentID?: string) {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -15,6 +15,8 @@ import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { RouteProvider } from "../../../src/context/route"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
@@ -2020,7 +2022,11 @@ test("keeps shell state scoped to location", async () => {
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</RouteProvider>
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -2,7 +2,6 @@
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { createSignal } from "solid-js"
|
||||
import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { DEFAULT_THEMES } from "../../../src/theme"
|
||||
@@ -174,45 +173,3 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["dark", "light"] as const)(
|
||||
"reactive %s theme contexts change without remounting their contents",
|
||||
async (mode) => {
|
||||
const [context, setContext] = createSignal<"elevated" | undefined>("elevated")
|
||||
let theme: ReturnType<typeof useTheme> | undefined
|
||||
let themes: ReturnType<typeof useThemes> | undefined
|
||||
let mounts = 0
|
||||
function Probe() {
|
||||
mounts++
|
||||
theme = useTheme()
|
||||
themes = useThemes()
|
||||
return <text fg={theme.text.default}>probe</text>
|
||||
}
|
||||
const app = await testRender(() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode } })}>
|
||||
<ThemeProvider mode={mode} source={{ discover: async () => ({}) }}>
|
||||
<ThemeContextProvider context={context}>
|
||||
<Probe />
|
||||
</ThemeContextProvider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
))
|
||||
app.renderer.start()
|
||||
try {
|
||||
await wait(() => themes?.ready === true)
|
||||
if (!theme || !themes) throw new Error("Theme provider is not mounted")
|
||||
const view = theme
|
||||
expect(view.background.default).toBe(themes.current.contextual.elevated.background.default)
|
||||
setContext(undefined)
|
||||
await app.flush()
|
||||
expect(view.background.default).toBe(themes.current.background.default)
|
||||
setContext("elevated")
|
||||
await app.flush()
|
||||
expect(view.text.default).toBe(themes.current.contextual.elevated.text.default)
|
||||
expect(theme).toBe(view)
|
||||
expect(mounts).toBe(1)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { ShellInfo } from "@opencode-ai/client"
|
||||
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 } from "../../src/context/data"
|
||||
import { Keymap } from "../../src/context/keymap"
|
||||
import { RouteProvider } from "../../src/context/route"
|
||||
import { ThemeProvider } from "../../src/context/theme"
|
||||
import { Composer } from "../../src/routes/session/composer"
|
||||
import { DialogProvider } from "../../src/ui/dialog"
|
||||
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 setup(width: number, output = "") {
|
||||
const temporary = await tmpdir()
|
||||
const location = { directory: `${temporary.path}/original`, workspaceID: "workspace_fixture" }
|
||||
const shell: ShellInfo = {
|
||||
id: "sh_fixture",
|
||||
command: "render-scene --quality high",
|
||||
cwd: location.directory,
|
||||
shell: "/bin/sh",
|
||||
file: `${temporary.path}/capture.out`,
|
||||
status: "running",
|
||||
metadata: { sessionID: "ses_fixture" },
|
||||
time: { started: 0 },
|
||||
}
|
||||
const state = { output, missing: false, failure: false }
|
||||
const requests: { url: URL; method: string }[] = []
|
||||
const events = createEventStream()
|
||||
const envelope = (data: unknown) => json({ location, data })
|
||||
const api = createApi(
|
||||
createFetch((url, request) => {
|
||||
if (!url.pathname.startsWith("/api/shell")) return undefined
|
||||
requests.push({ url, method: request.method })
|
||||
if (url.pathname === "/api/shell") return envelope([shell])
|
||||
if (state.missing)
|
||||
return json({ _tag: "ShellNotFoundError", id: shell.id, message: "Shell not found" }, { status: 404 })
|
||||
if (state.failure) return new Response("Unavailable", { status: 503 })
|
||||
if (url.pathname === `/api/shell/${shell.id}`) return envelope(shell)
|
||||
const bytes = Buffer.from(state.output)
|
||||
const cursor = Math.min(Number(url.searchParams.get("cursor") ?? 0), bytes.length)
|
||||
const end = Math.min(cursor + Number(url.searchParams.get("limit") ?? 65536), bytes.length)
|
||||
return envelope({
|
||||
output: bytes.subarray(cursor, end).toString(),
|
||||
cursor: end,
|
||||
size: bytes.length,
|
||||
truncated: false,
|
||||
})
|
||||
}, events).fetch,
|
||||
)
|
||||
|
||||
function Shells() {
|
||||
const data = useData()
|
||||
const [open, setOpen] = createSignal(true)
|
||||
onMount(() => void data.shell.sync(location))
|
||||
return <Composer sessionID="ses_fixture" open={open()} defaultTab="shell" onClose={() => setOpen(false)} />
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={temporary.path} paths={{ state: temporary.path }}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ session: { terminal: false } })}>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_fixture" }}>
|
||||
<ClientProvider api={api}>
|
||||
<DataProvider directory={temporary.path}>
|
||||
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Shells />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width, height: 30, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes(shell.command))
|
||||
return {
|
||||
...app,
|
||||
state,
|
||||
shell,
|
||||
location,
|
||||
requests,
|
||||
events,
|
||||
async [Symbol.asyncDispose]() {
|
||||
app.renderer.destroy()
|
||||
await temporary[Symbol.asyncDispose]()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test.each([40, 100])("shell output opens, follows, scrolls, and survives exit at %s columns", async (width) => {
|
||||
await using app = await setup(width, Array.from({ length: 50 }, (_, i) => `Frame ${i + 1}\n`).join(""))
|
||||
expect(app.captureCharFrame()).toContain("output")
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitForFrame((frame) => frame.includes("Shell output") && frame.includes("Frame 50"))
|
||||
const scroll = app.renderer.root.findDescendantById("shell-output-scroll")
|
||||
if (!(scroll instanceof ScrollBoxRenderable)) throw new Error("Output scrollbox missing")
|
||||
expect(scroll.scrollTop).toBeGreaterThan(0)
|
||||
|
||||
app.mockInput.pressKey("HOME")
|
||||
await app.waitForFrame((frame) => frame.includes("Frame 1\n") || /Frame 1\s/.test(frame))
|
||||
expect(scroll.scrollTop).toBe(0)
|
||||
app.state.output += "Frame 51\n"
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.requests.some(
|
||||
(request) => request.url.searchParams.get("cursor") === String(Buffer.byteLength(app.state.output)),
|
||||
),
|
||||
{ maxPasses: 150 },
|
||||
)
|
||||
expect(scroll.scrollTop).toBe(0)
|
||||
app.mockInput.pressKey("END")
|
||||
await app.waitForFrame((frame) => frame.includes("Frame 51"))
|
||||
app.shell.status = "exited"
|
||||
app.shell.exit = 0
|
||||
app.events.emit({
|
||||
id: "evt_exit",
|
||||
created: 0,
|
||||
type: "shell.exited",
|
||||
location: app.location,
|
||||
data: { id: app.shell.id, exit: 0, status: "exited" },
|
||||
})
|
||||
await app.waitForFrame((frame) => frame.includes("code 0"), { maxPasses: 100 })
|
||||
const metadataReads = app.requests.filter((request) => request.url.pathname === `/api/shell/${app.shell.id}`).length
|
||||
// Terminal metadata can arrive before the capture's final flush.
|
||||
app.state.output += "\u001b[32mRender complete\u001b[0m\r\n"
|
||||
await app.waitForFrame((frame) => frame.includes("Render complete") && frame.includes("code 0"), { maxPasses: 100 })
|
||||
expect(app.requests.filter((request) => request.url.pathname === `/api/shell/${app.shell.id}`)).toHaveLength(
|
||||
metadataReads,
|
||||
)
|
||||
expect(app.captureCharFrame()).not.toContain("[32m")
|
||||
expect(app.requests.every((request) => request.method === "GET")).toBe(true)
|
||||
const reads = app.requests.filter((request) => request.url.pathname !== "/api/shell")
|
||||
expect(reads.every((request) => request.url.searchParams.get("location[directory]") === app.location.directory)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
reads.every((request) => request.url.searchParams.get("location[workspace]") === app.location.workspaceID),
|
||||
).toBe(true)
|
||||
|
||||
app.mockInput.pressEscape()
|
||||
await app.waitForFrame((frame) => !frame.includes("Shell output") && frame.includes("No shell commands"))
|
||||
const count = app.requests.length
|
||||
await Bun.sleep(1100)
|
||||
expect(app.requests).toHaveLength(count)
|
||||
})
|
||||
|
||||
test("empty output explains redirection, retries errors, and preserves output after removal", async () => {
|
||||
await using app = await setup(100)
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitForFrame((frame) => frame.includes("No captured output") && frame.includes("redirected"))
|
||||
app.state.failure = true
|
||||
await app.waitForFrame((frame) => frame.includes("Retrying"), { maxPasses: 100 })
|
||||
app.state.failure = false
|
||||
app.state.output = "Recovered output\n"
|
||||
await app.waitForFrame((frame) => frame.includes("Recovered output") && !frame.includes("Retrying"), {
|
||||
maxPasses: 100,
|
||||
})
|
||||
app.state.missing = true
|
||||
await app.waitForFrame((frame) => frame.includes("no longer available"), { maxPasses: 100 })
|
||||
expect(app.captureCharFrame()).toContain("Recovered output")
|
||||
const count = app.requests.length
|
||||
await Bun.sleep(1100)
|
||||
expect(app.requests).toHaveLength(count)
|
||||
})
|
||||
|
||||
test.each([40, 100])("mouse-wheel scrolling pauses and resumes output following at %s columns", async (width) => {
|
||||
await using app = await setup(width, Array.from({ length: 50 }, (_, i) => `Frame ${i + 1}\n`).join(""))
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitForFrame((frame) => frame.includes("Shell output") && frame.includes("Frame 50"))
|
||||
const scroll = app.renderer.root.findDescendantById("shell-output-scroll")
|
||||
if (!(scroll instanceof ScrollBoxRenderable)) throw new Error("Output scrollbox missing")
|
||||
const bottom = scroll.scrollTop
|
||||
await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "up")
|
||||
await app.waitFor(() => scroll.scrollTop < bottom)
|
||||
const paused = scroll.scrollTop
|
||||
const height = scroll.scrollHeight
|
||||
|
||||
app.state.output += "Frame 51\n"
|
||||
await app.waitFor(() => scroll.scrollHeight > height, { maxPasses: 100 })
|
||||
expect(scroll.scrollTop).toBe(paused)
|
||||
expect(app.captureCharFrame()).toContain("Shell output")
|
||||
|
||||
await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "down")
|
||||
await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "down")
|
||||
await app.waitFor(() => scroll.scrollTop === scroll.scrollHeight - scroll.viewport.height)
|
||||
const followed = scroll.scrollTop
|
||||
app.state.output += "Frame 52\n"
|
||||
await app.waitForFrame((frame) => frame.includes("Frame 52"), { maxPasses: 100 })
|
||||
expect(scroll.scrollTop).toBeGreaterThan(followed)
|
||||
expect(scroll.scrollTop).toBe(scroll.scrollHeight - scroll.viewport.height)
|
||||
})
|
||||
|
||||
test("large captures open at a bounded tail and clicking a shell opens the viewer", async () => {
|
||||
await using app = await setup(100, "old output\n".repeat(20000) + "Latest frame\n")
|
||||
const row = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.findIndex((line) => line.includes(app.shell.command))
|
||||
await app.mockMouse.click(6, row)
|
||||
await app.waitForFrame((frame) => frame.includes("Latest frame") && frame.includes("Earlier output omitted"))
|
||||
const reads = app.requests.filter((request) => request.url.pathname.endsWith("/output"))
|
||||
expect(reads[0]?.url.searchParams.get("cursor")).toBe(String(Number.MAX_SAFE_INTEGER))
|
||||
expect(reads[1]?.url.searchParams.get("cursor")).toBe(String(Buffer.byteLength(app.state.output) - 65536))
|
||||
expect(reads[1]?.url.searchParams.get("limit")).toBe("65536")
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import type { TuiInput } from "../../src/app"
|
||||
import type { Config } from "../../src/config"
|
||||
import { createEventStream, createFetch, type FetchHandler } from "./tui-client"
|
||||
|
||||
export async function createAppFixture(
|
||||
input: {
|
||||
width?: number
|
||||
height?: number
|
||||
state?: string
|
||||
config?: Config.Info
|
||||
args?: TuiInput["args"]
|
||||
fetch?: FetchHandler
|
||||
} = {},
|
||||
) {
|
||||
const { run } = await import("../../src/app")
|
||||
const setup = await createTestRenderer({
|
||||
width: input.width ?? 100,
|
||||
height: input.height ?? 30,
|
||||
useThread: false,
|
||||
kittyKeyboard: true,
|
||||
})
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(input.fetch, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: input.args ?? {},
|
||||
log: () => {},
|
||||
}).pipe(
|
||||
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
|
||||
Effect.provide(FileSystem.layerNoop({})),
|
||||
),
|
||||
)
|
||||
return {
|
||||
...setup,
|
||||
events,
|
||||
ready: ready.promise,
|
||||
async [Symbol.asyncDispose]() {
|
||||
try {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createPanelState } from "../src/context/panel"
|
||||
|
||||
test("presentation changes preserve the selected panel identity", () => {
|
||||
createRoot((dispose) => {
|
||||
const panels = createPanelState()
|
||||
panels.setWidth(160)
|
||||
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
|
||||
const current = panels.current()
|
||||
expect(panels.presentation()).toBe("panel")
|
||||
panels.toggleFullscreen()
|
||||
expect(panels.presentation()).toBe("fullscreen")
|
||||
expect(panels.current()).toBe(current)
|
||||
panels.toggleFullscreen()
|
||||
expect(panels.presentation()).toBe("panel")
|
||||
expect(panels.current()).toBe(current)
|
||||
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
|
||||
expect(panels.current()).toBe(current)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("narrow geometry overrides presentation without discarding the user's choice", () => {
|
||||
createRoot((dispose) => {
|
||||
const panels = createPanelState()
|
||||
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
|
||||
panels.setWidth(80)
|
||||
expect(panels.canSplit()).toBe(false)
|
||||
expect(panels.presentation()).toBe("fullscreen")
|
||||
panels.toggleFullscreen()
|
||||
panels.setWidth(81)
|
||||
expect(panels.canSplit()).toBe(true)
|
||||
expect(panels.presentation()).toBe("panel")
|
||||
panels.toggleFullscreen()
|
||||
panels.setWidth(60)
|
||||
panels.setWidth(160)
|
||||
expect(panels.presentation()).toBe("fullscreen")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("releasing a plugin contribution only closes its own selected panel", () => {
|
||||
createRoot((dispose) => {
|
||||
const panels = createPanelState()
|
||||
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
|
||||
const current = panels.current()
|
||||
panels.release("other")
|
||||
panels.release("review", "another-panel")
|
||||
expect(panels.current()).toBe(current)
|
||||
panels.release("review", "diff")
|
||||
expect(panels.current()).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextName } from "@opencode-ai/theme/tui"
|
||||
import { createComponentTheme, createComponentThemeView } from "../../../src/theme/component"
|
||||
import { createComponentTheme } from "../../../src/theme/component"
|
||||
|
||||
test("provides reactive properties, states, contexts, and color operations", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
@@ -67,19 +67,3 @@ test("provides reactive properties, states, contexts, and color operations", ()
|
||||
expect(current().decrease(current().background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
|
||||
expect(current().raise(current().background.surface.offset)).toBe(resolved().hue.neutral[600])
|
||||
})
|
||||
|
||||
test("a stable component theme view follows presentation context changes", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||
const [context, setContext] = createSignal<ContextName>()
|
||||
const theme = createComponentThemeView(
|
||||
() => (context() ? resolved().contextual[context()!] : resolved()),
|
||||
() => "dark",
|
||||
)
|
||||
expect(theme.background.default).toBe(resolved().background.default)
|
||||
setContext("elevated")
|
||||
expect(theme.background.default).toBe(resolved().contextual.elevated.background.default)
|
||||
setContext(undefined)
|
||||
expect(theme.background.default).toBe(resolved().background.default)
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
expect(theme.text.default).toBe(resolved().text.default)
|
||||
})
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify"]
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify"]
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
|
||||
@@ -236,7 +236,10 @@ function Status() {
|
||||
Register a fenced-code renderer by language; the returned function unregisters it.
|
||||
|
||||
```ts
|
||||
const unregister = context.markdown.registerCodeBlockRenderer("acme", (_token, render) => render.defaultRender())
|
||||
const unregister = context.markdown.registerCodeBlockRenderer(
|
||||
"acme",
|
||||
(_token, render) => render.defaultRender(),
|
||||
)
|
||||
return unregister
|
||||
```
|
||||
|
||||
@@ -337,14 +340,7 @@ Custom JSX dialogs can set their size and close themselves.
|
||||
|
||||
```tsx
|
||||
context.ui.dialog.set({ size: "large", centered: true })
|
||||
context.ui.dialog.show(
|
||||
() => (
|
||||
<box>
|
||||
<text>Acme</text>
|
||||
</box>
|
||||
),
|
||||
() => console.log("closed"),
|
||||
)
|
||||
context.ui.dialog.show(() => <box><text>Acme</text></box>, () => console.log("closed"))
|
||||
context.ui.dialog.clear()
|
||||
```
|
||||
|
||||
@@ -409,77 +405,6 @@ context.ui.slot({ after: "home.footer", render: () => <text>After footer slot</t
|
||||
context.ui.slot({ replace: "home.footer", render: () => <text>New footer</text> })
|
||||
```
|
||||
|
||||
### Session panels
|
||||
|
||||
Register a named replacement for `session.panel`, then open it from a command. The host owns sizing, focus, and
|
||||
full-screen presentation; the plugin owns its contents.
|
||||
|
||||
```tsx
|
||||
context.ui.slot({
|
||||
name: "review",
|
||||
replace: "session.panel",
|
||||
render: (panel) => <ReviewPanel panel={panel} />,
|
||||
})
|
||||
|
||||
context.ui.slot({
|
||||
append: "app",
|
||||
render: () => {
|
||||
context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
id: "acme.review",
|
||||
title: "Open review",
|
||||
slash: { name: "review" },
|
||||
run: () => {
|
||||
context.ui.panel.open("review")
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
return null
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Names are scoped to the plugin. Opening an unknown name or opening outside a session returns `false`.
|
||||
- Unlike composed slots, this slot renders only the explicitly selected named contribution.
|
||||
- Changing presentation preserves the mounted contribution. Closing it or unregistering its plugin disposes it.
|
||||
- Its keyboard layers and input modes are active only while the panel owns input.
|
||||
|
||||
The slot receives reactive `sessionID`, `width`, `presentation`, `focused`, and `canSplit` properties, plus `focus`,
|
||||
`close`, and `toggleFullscreen` actions. Use `canSplit` to gate the presentation shortcut rather than checking terminal
|
||||
width inside the plugin.
|
||||
|
||||
```tsx
|
||||
import type { PanelInput } from "@opencode-ai/plugin/tui/context"
|
||||
import { usePlugin } from "@opencode-ai/plugin/tui"
|
||||
|
||||
function ReviewPanel(props: { panel: PanelInput }) {
|
||||
const context = usePlugin()
|
||||
context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
id: "acme.review.fullscreen",
|
||||
bind: "f",
|
||||
enabled: () => props.panel.canSplit,
|
||||
run: props.panel.toggleFullscreen,
|
||||
},
|
||||
],
|
||||
}))
|
||||
return <text>Reviewing {props.panel.sessionID}</text>
|
||||
}
|
||||
```
|
||||
|
||||
You can request full-screen presentation initially, inspect your active panel, or close it without affecting another
|
||||
plugin's panel.
|
||||
|
||||
```ts
|
||||
context.ui.panel.open("review", { presentation: "fullscreen" })
|
||||
const current = context.ui.panel.current()
|
||||
context.ui.panel.close()
|
||||
```
|
||||
|
||||
## Formatting
|
||||
|
||||
Format filesystem paths for display, including home-directory abbreviation.
|
||||
|
||||
@@ -317,8 +317,13 @@ The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, an
|
||||
| `composer.subagent.interrupt` | `ctrl+d` | Interrupt subagent |
|
||||
| `composer.shell.up` | `up` | Previous shell |
|
||||
| `composer.shell.down` | `down` | Next shell |
|
||||
| `composer.shell.select` | `return` | View shell output |
|
||||
| `composer.shell.kill` | `ctrl+d` | Kill shell command |
|
||||
|
||||
Select a running command in the **Shell** tab and press **Enter**, or click it, to view captured stdout and stderr.
|
||||
Use **↑/↓**, **Page Up/Down**, or **Home** to scroll, **End** to follow new output, and **Esc** to return without stopping the command.
|
||||
The viewer shows recent output and stays open after exit; output redirected to a file is not included.
|
||||
|
||||
## Dialogs And Autocomplete
|
||||
|
||||
| ID | Default | Description |
|
||||
|
||||
@@ -129,13 +129,15 @@ agents.
|
||||
|
||||
### Updates
|
||||
|
||||
Control update checks from the global config. Set `update` to `"disable"` to
|
||||
skip them or `"notify"` to show available updates before installing them.
|
||||
Control updates from the global config. Set `update` to `"disable"` to skip
|
||||
updates, `"notify"` to report available updates without installing them, or
|
||||
`"auto"` to automatically install compatible non-major updates.
|
||||
Major updates are reported but never installed automatically.
|
||||
Project-level values are ignored.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"update": "notify",
|
||||
"update": "auto",
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -409,8 +409,7 @@ The V1 provider filters do not have one-to-one native V2 config fields, but thei
|
||||
|
||||
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
|
||||
- `disabled_providers` becomes internal deny policies for the listed providers.
|
||||
- `autoupdate` becomes `update`: `false` maps to `"disable"`, while `"notify"` and `true` map to `"notify"`.
|
||||
- The previous V2 value `update: "auto"` is treated as `update: "notify"`.
|
||||
- `autoupdate` becomes `update`: `false` maps to `"disable"`, `"notify"` remains `"notify"`, and `true` maps to `"auto"`.
|
||||
- `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use
|
||||
`agents.title.model` instead.
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Llm, OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
const label = process.env.DEMO_LABEL ?? "AFTER"
|
||||
|
||||
// Run from the repository root with `opencode-drive run script/drive/shell-output.ts`.
|
||||
// Set OPENCODE_DEV to an immutable base worktree and DEMO_LABEL=BEFORE for comparison.
|
||||
// Only the conversation is simulated; shell execution and output reads are real.
|
||||
export default OpenCodeDriver.use(
|
||||
{
|
||||
opencode: { dev: process.env.OPENCODE_DEV ?? process.cwd() },
|
||||
keepArtifacts: true,
|
||||
tui: { recording: true, keypressOverlay: true, viewport: { cols: 90, rows: 30 } },
|
||||
config: { autoupdate: false, username: "Demo" },
|
||||
tuiConfig: { theme: { name: "opencode", mode: "dark" }, animations: false, tabs: { enabled: false } },
|
||||
project: {
|
||||
git: true,
|
||||
files: {
|
||||
"README.md": "# Shell output demo\nDeterministic real shell output.\n",
|
||||
"render-scene.sh": [
|
||||
"#!/bin/sh",
|
||||
"i=1",
|
||||
'while [ "$i" -le 40 ]; do printf "Frame %02d: rendered successfully\\n" "$i"; i=$((i+1)); done',
|
||||
"while [ ! -f continue ]; do sleep 0.1; done",
|
||||
'while [ "$i" -le 48 ]; do printf "Frame %02d: rendered successfully\\n" "$i"; i=$((i+1)); sleep 0.25; done',
|
||||
"while [ ! -f finish ]; do sleep 0.1; done",
|
||||
"printf 'Diagnostics: no errors\\n' >&2",
|
||||
"printf 'Render complete: 48 frames saved.\\n'",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
({ ui, llm, tui, opencode, artifacts }) =>
|
||||
Effect.gen(function* () {
|
||||
const recording = tui.recording
|
||||
if (!recording) return yield* Effect.fail(new Error("Recording required"))
|
||||
yield* llm.serve(() => Stream.make(Llm.text("Ready to inspect the render job.")))
|
||||
yield* ui.submit("Inspect the render job.")
|
||||
yield* ui.waitFor("Ready to inspect the render job.")
|
||||
const sessions = yield* opencode.session.list({ limit: 1, order: "desc" })
|
||||
const session = sessions.data[0]
|
||||
if (!session) return yield* Effect.fail(new Error("Session missing"))
|
||||
yield* opencode.session.rename({ sessionID: session.id, title: "Shell output demo" })
|
||||
yield* opencode.shell.create({ command: "sh render-scene.sh", timeout: 0, metadata: { sessionID: session.id } })
|
||||
yield* ui.arrow("down")
|
||||
yield* ui.arrow("right")
|
||||
yield* ui.waitFor("sh render-scene.sh")
|
||||
yield* recording.mark(`${label}: select a running shell`)
|
||||
yield* Effect.sleep(1000)
|
||||
yield* ui.enter()
|
||||
yield* ui.waitFor(label === "AFTER" ? "Frame 40: rendered successfully" : "sh render-scene.sh")
|
||||
yield* Effect.sleep(1000)
|
||||
yield* recording.mark(`${label}: Enter ${label === "AFTER" ? "opens live output" : "does nothing"}`)
|
||||
console.log("opened:", yield* ui.screenshot(`${label.toLowerCase()}-opened`))
|
||||
yield* Effect.promise(() => Bun.write(`${artifacts}/files/continue`, "go"))
|
||||
if (label === "AFTER") yield* ui.waitFor("Frame 48: rendered successfully")
|
||||
yield* Effect.sleep(2800)
|
||||
yield* ui.press("home")
|
||||
yield* ui.waitFor(label === "AFTER" ? "Frame 01: rendered successfully" : "sh render-scene.sh")
|
||||
yield* recording.mark(`${label}: ${label === "AFTER" ? "Home scrolls to earlier output" : "no output to scroll"}`)
|
||||
yield* Effect.sleep(1500)
|
||||
console.log("scrolled:", yield* ui.screenshot(`${label.toLowerCase()}-scrolled`))
|
||||
yield* ui.press("end")
|
||||
yield* ui.waitFor(label === "AFTER" ? "Frame 48: rendered successfully" : "sh render-scene.sh")
|
||||
yield* recording.mark(`${label}: ${label === "AFTER" ? "End follows the latest output" : "no output to follow"}`)
|
||||
yield* Effect.sleep(1000)
|
||||
yield* Effect.promise(() => Bun.write(`${artifacts}/files/finish`, "go"))
|
||||
yield* ui.waitFor(label === "AFTER" ? "Render complete: 48 frames saved." : "No shell commands")
|
||||
if (label === "AFTER") yield* ui.waitFor("Exited · code 0")
|
||||
yield* Effect.sleep(1600)
|
||||
yield* recording.mark(
|
||||
`${label}: ${label === "AFTER" ? "result stays open after exit" : "finished shell disappears"}`,
|
||||
)
|
||||
console.log("exited:", yield* ui.screenshot(`${label.toLowerCase()}-exited`))
|
||||
yield* Effect.sleep(2000)
|
||||
yield* ui.resize({ cols: 40, rows: 24 })
|
||||
yield* Effect.sleep(500)
|
||||
console.log("narrow:", yield* ui.screenshot(`${label.toLowerCase()}-narrow`))
|
||||
yield* ui.resize({ cols: 90, rows: 30 })
|
||||
yield* Effect.sleep(500)
|
||||
yield* ui.press("escape")
|
||||
if (label === "AFTER") yield* ui.waitFor("No shell commands")
|
||||
yield* recording.mark(`${label}: Esc back`)
|
||||
yield* Effect.sleep(1000)
|
||||
console.log("back:", yield* ui.screenshot(`${label.toLowerCase()}-back`))
|
||||
return console.log("video:", yield* recording.finish())
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user