Compare commits

..
Author SHA1 Message Date
Kit Langton 00580291b8 refactor(tui): simplify shell output polling 2026-09-03 16:37:00 -04:00
Kit Langton 2e17a66530 feat(tui): inspect live shell output 2026-09-03 16:29:14 -04:00
42 changed files with 1130 additions and 952 deletions
@@ -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)),
},
+55 -6
View File
@@ -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) {
+6 -3
View File
@@ -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) {
+29 -20
View File
@@ -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")
})
})
+165 -28
View File
@@ -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)
+2 -1
View File
@@ -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")
+94 -27
View File
@@ -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
+2 -9
View File
@@ -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,
+8 -5
View File
@@ -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 }),
)
+24 -15
View File
@@ -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
+4 -2
View File
@@ -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,
+1 -10
View File
@@ -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({
-65
View File
@@ -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
@@ -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")) }
+1 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify"]
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",
+2 -2
View File
@@ -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",
+8 -1
View File
@@ -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)
+15 -3
View File
@@ -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
}
}
+32 -50
View File
@@ -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 { SessionPanelProvider } from "./context/session-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>
<SessionPanelProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</SessionPanelProvider>
<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>
@@ -508,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,
@@ -1246,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>
)
}
+18 -31
View File
@@ -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>
)}
+48 -128
View File
@@ -1,27 +1,15 @@
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import {
batch,
createComponent,
createEffect,
createMemo,
createResource,
createSignal,
on,
onCleanup,
Show,
} from "solid-js"
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 { useSessionPanel } from "../context/session-panel"
import { useStorage } from "../context/storage"
import { useTheme } from "../context/theme"
import { Session } from "../routes/session"
import { Sidebar } from "../routes/session/sidebar"
import { 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"
@@ -35,44 +23,38 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const toast = useToast()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const panel = useSessionPanel()
const elevated = useTheme("elevated")
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
const defaultPaneWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", {
initial: {},
})
const paneResize = createPaneResize({
value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(),
defaultValue: defaultPaneWidth,
clamp: (width) => clampSessionPaneWidth(width, availableWidth()),
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 [terminalFocused, setTerminalFocused] = createSignal(false)
const [panelFocused, setPanelFocused] = createSignal(false)
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
let focusTerminal: (() => void) | undefined
let sessionScroll: ScrollBoxRenderable | undefined
let focusPanel: (() => void) | undefined
createResource(
() => (config.data.session.terminal ? props.sessionID : undefined),
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
@@ -83,43 +65,22 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const value = session()
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
}
const activePanel = createMemo(() => {
const current = panel.current()
if (current?.sessionID === props.sessionID) return current
})
createEffect(
on(
() => selectedTerminal()?.id,
(id) => {
if (!id) return
setSidebarOpen(false)
if (activePanel()) panel.close()
if (id) setSidebarOpen(false)
},
{ defer: true },
),
)
const splitAvailable = createMemo(() => dimensions().width > 80)
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
createEffect(() => panel.setAvailable(props.sessionID, splitAvailable()))
onCleanup(() => panel.setAvailable(props.sessionID, false))
createEffect(() => {
const current = activePanel()
if (!current || splitAvailable()) return
panel.close()
current.onUnavailable?.()
})
createEffect(() => {
if (!activePanel()) return
setSidebarOpen(false)
if (selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
const sidebarVisible = createMemo(() => {
if (data.session.get(props.sessionID)?.parentID) return false
if (sidebarOpen()) return true
return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
})
const rightPane = createMemo(() => {
if (activePanel()) return "panel"
if (sidebarOpen() && sidebarVisible()) return "sidebar"
if (selectedTerminal()) return "terminal"
if (sidebarVisible()) return "sidebar"
@@ -133,44 +94,21 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
})
.catch(toast.error)
setSidebarOpen(!visible)
if (!visible && activePanel()) panel.close()
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
}
const focusSession = () => {
// Permission prompts replace the input, so returning focus must not depend on it.
if (terminalFocused() || panelFocused()) renderer.currentFocusedRenderable?.blur()
if (terminalFocused()) renderer.currentFocusedRenderable?.blur()
prompt.current?.focus()
}
const focusRightPane = () => {
if (activePanel()) {
focusPanel?.()
return
}
focusTerminal?.()
}
createEffect(
on(
() => activePanel()?.id,
(id) => {
if (!id) {
setPanelFocused(false)
return
}
requestAnimationFrame(() => {
if (activePanel()?.id !== id) return
focusPanel?.()
})
},
),
)
createEffect(() => {
if (!restoreTerminalFocus() || selectedTerminal()) return
setRestoreTerminalFocus(false)
focusSession()
})
Keymap.createLayer(() => ({
enabled: () => config.data.session.terminal === true || activePanel() !== undefined,
enabled: () => config.data.session.terminal === true,
commands: [
{
id: "pane.focus.left",
@@ -179,8 +117,10 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
},
{
id: "pane.focus.right",
title: "Focus right pane",
run: focusRightPane,
title: "Focus terminal pane",
run: () => {
focusTerminal?.()
},
},
],
}))
@@ -192,9 +132,9 @@ 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
flexGrow={1}
@@ -209,13 +149,13 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
<Session
scrollRef={(value) => (sessionScroll = value)}
verticalTabsWidth={props.verticalTabsWidth}
promptMuted={terminalFocused() || panelFocused()}
promptMuted={terminalFocused()}
sidebarVisible={rightPane() === "sidebar"}
onToggleSidebar={toggleSidebar}
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
width={sessionWidth()}
/>
<Show when={terminalFocused() || panelFocused()}>
<Show when={terminalFocused()}>
<box
position="absolute"
left={0}
@@ -234,61 +174,37 @@ 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
flexShrink={0}
width={rightPane() === "terminal" || rightPane() === "panel" ? paneResize.size() : SESSION_SIDEBAR_WIDTH}
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
minWidth={0}
minHeight={0}
backgroundColor={rightPane() === "panel" ? elevated.background.default : undefined}
>
<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)
}}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
</Show>
}
>
{(item) =>
createComponent(item.render, {
get width() {
return paneResize.size()
},
get resizing() {
return paneResize.resizing()
},
get focused() {
return panelFocused()
},
onFocusChange: setPanelFocused,
onFocusRequest: (value) => (focusPanel = value),
close: panel.close,
})
}
<Show 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>
}
>
@@ -296,8 +212,12 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</Show>
</box>
</Show>
<Show when={(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
-104
View File
@@ -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} />
}
+8 -26
View File
@@ -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>
)
}
+2 -2
View File
@@ -84,7 +84,6 @@ export const Definitions = {
"diff.single_patch": keybind("s", "Toggle single patch view"),
"diff.switch_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.toggle_fullscreen": keybind("f", "Toggle diff viewer full screen"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
@@ -94,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"),
@@ -245,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"),
@@ -1,54 +0,0 @@
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
export type SessionPanelRenderProps = {
readonly width: number
readonly resizing: boolean
readonly focused: boolean
readonly onFocusChange: (focused: boolean) => void
readonly onFocusRequest: (focus: (() => void) | undefined) => void
readonly close: () => void
}
type Panel = {
readonly id: string
readonly sessionID: string
readonly render: (props: SessionPanelRenderProps) => JSX.Element
readonly onUnavailable?: () => void
}
const Context = createContext<{
readonly current: () => Panel | undefined
readonly open: (panel: Panel) => void
readonly close: () => void
readonly available: (sessionID: string) => boolean
readonly setAvailable: (sessionID: string, available: boolean) => void
}>()
export function SessionPanelProvider(props: ParentProps) {
const [current, setCurrent] = createSignal<Panel>()
const [availableSessionID, setAvailableSessionID] = createSignal<string>()
return (
<Context.Provider
value={{
current,
open: setCurrent,
close: () => setCurrent(),
available: (sessionID) => availableSessionID() === sessionID,
setAvailable: (sessionID, available) =>
setAvailableSessionID((current) => (available ? sessionID : current === sessionID ? undefined : current)),
}}
>
{props.children}
</Context.Provider>
)
}
export function useSessionPanel() {
const value = useContext(Context)
if (!value) throw new Error("useSessionPanel must be used within a SessionPanelProvider")
return value
}
export function useOptionalSessionPanel() {
return useContext(Context)
}
@@ -4,7 +4,6 @@ import type { Vcs } from "@opencode-ai/schema/vcs"
import { Plugin } from "@opencode-ai/plugin/tui"
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
import {
CliRenderEvents,
MouseButton,
TextAttributes,
type BoxRenderable,
@@ -23,8 +22,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { createDebouncedSignal } from "../../util/signal"
import { useConfig } from "../../config"
import { locationKey } from "../../context/data"
import { useOptionalSessionPanel } from "../../context/session-panel"
import { useTheme, useThemes } from "../../context/theme"
import { useThemes } from "../../context/theme"
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
import {
allExpandedFileTreeDirectories,
@@ -77,55 +75,9 @@ function diffSourceLabel(mode: DiffMode) {
return "Uncommitted"
}
type PanelController = NonNullable<ReturnType<typeof useOptionalSessionPanel>>
function openDiffPanel(context: Plugin.Context, panel: PanelController, sessionID: string) {
panel.open({
id: ROUTE,
sessionID,
onUnavailable: () => openDiffFullscreen(context, panel, sessionID, false),
render: (input) => (
<DiffViewer
context={context}
sessionID={sessionID}
width={input.width}
embedded
focused={input.focused}
onFocusChange={input.onFocusChange}
onFocusRequest={input.onFocusRequest}
onClose={input.close}
/>
),
})
}
function openDiffFullscreen(context: Plugin.Context, panel: PanelController, sessionID: string, split: boolean) {
panel.close()
context.ui.router.navigate({
type: "plugin",
name: ROUTE,
data: {
sessionID,
returnRoute: { type: "session", sessionID },
...(split ? { split: true } : {}),
},
})
}
function DiffViewer(props: {
context: Plugin.Context
sessionID?: string
width?: number
embedded?: boolean
focused?: boolean
onFocusChange?: (focused: boolean) => void
onFocusRequest?: (focus: (() => void) | undefined) => void
onClose?: () => void
}) {
function DiffViewer(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const config = useConfig()
const panel = useOptionalSessionPanel()
const [memory, updateMemory] = props.context.storage.memory<{
source?: DiffMode
bases: Record<string, string>
@@ -137,14 +89,13 @@ function DiffViewer(props: {
mode?: DiffMode
sessionID?: string
returnRoute?: Route
split?: boolean
}
| undefined
}
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
const location = createMemo(
() => {
const sessionID = props.sessionID ?? params()?.sessionID
const sessionID = params()?.sessionID
return sessionID
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
: props.context.data.location.default()
@@ -206,103 +157,52 @@ function DiffViewer(props: {
if (!base) return "Base not reported"
return `vs ${base.name}`
}
const sessionID = () => props.sessionID ?? params()?.sessionID
const canToggleFullscreen = () =>
panel !== undefined &&
sessionID() !== undefined &&
(props.embedded === true || (params()?.split === true && dimensions().width > 80))
const toggleFullscreen = () => {
const id = sessionID()
if (!panel || !id) return
if (props.embedded) {
openDiffFullscreen(props.context, panel, id, true)
return
}
openDiffPanel(props.context, panel, id)
props.context.ui.router.navigate({ type: "session", sessionID: id })
}
let panelNode: BoxRenderable | undefined
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === panelNode)
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
onCleanup(() => {
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
props.onFocusChange?.(false)
props.onFocusRequest?.(undefined)
})
const content = () => (
<DiffViewerContent
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={props.embedded ? { ...config.data.diffs, tree: false } : config.data.diffs}
width={props.width}
fileTree={!props.embedded}
elevated={props.embedded}
focused={props.embedded ? props.focused === true : true}
onToggleFullscreen={canToggleFullscreen() ? toggleFullscreen : undefined}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={() => {
if (props.onClose) return props.onClose()
props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })
}}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
)
if (props.embedded)
return (
<box
ref={(node: BoxRenderable) => {
panelNode = node
props.onFocusRequest?.(() => node.focus())
}}
flexGrow={1}
minWidth={0}
minHeight={0}
focusable
onMouseDown={() => panelNode?.focus()}
>
{content()}
</box>
)
return (
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
{content()}
<DiffViewerContent
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={config.data.diffs}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
</box>
)
}
@@ -366,36 +266,28 @@ export function DiffViewerContent(props: {
navigation?: "tree" | "list"
loadImage?: (file: string, signal: AbortSignal) => Promise<Uint8Array>
preferences?: DiffPreferences
width?: number
fileTree?: boolean
elevated?: boolean
focused?: boolean
onPreferencesChange?: (value: DiffPreferences) => void
onClose: () => void
onSwitchSource: (mode: DiffMode) => void
onChooseBase?: () => void
onToggleFullscreen?: () => void
}) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig()
const dialog = props.context.ui.dialog
const themes = useThemes()
const elevated = useTheme("elevated")
const theme = props.elevated ? elevated : themes.current
const currentSyntax = themes.currentSyntax
const theme = useThemes().current
const currentSyntax = useThemes().currentSyntax
const files = () => props.files
const width = () => props.width ?? dimensions().width
const mode = () => props.mode
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.preferences?.tree ?? true)
const showFileTree = createMemo(
() => props.fileTree !== false && width() >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
() => dimensions().width >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
)
const [singlePatch, setSinglePatch] = createSignal(props.preferences?.single ?? false)
const fileTreeWidth = createMemo(() =>
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(width() / 4))),
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(dimensions().width / 4))),
)
const patchPaneWidth = createMemo(() => width() - (showFileTree() ? fileTreeWidth() : 0) - (props.elevated ? 2 : 4))
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? fileTreeWidth() : 0) - 4)
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.preferences?.view))
const view = createMemo(() =>
@@ -409,7 +301,6 @@ export function DiffViewerContent(props: {
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(config.data))
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
const helpShortcut = () => props.context.keymap.shortcuts("diff.help")[0]
const firstShortcut = (id: string, fallback: string) => props.context.keymap.shortcuts(id)[0] ?? fallback
let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const patchDiffByFileIndex = new Map<number, PatchDiffRef>()
@@ -792,13 +683,6 @@ export function DiffViewerContent(props: {
props.onPreferencesChange?.({ view: next })
},
},
{
id: "diff.toggle_fullscreen",
title: "Toggle diff viewer full screen",
group: "VCS",
enabled: () => props.onToggleFullscreen !== undefined,
run: () => props.onToggleFullscreen?.(),
},
{
id: "diff.help",
title: "Show more diff viewer shortcuts",
@@ -864,13 +748,7 @@ export function DiffViewerContent(props: {
}
const openHelpDialog = () => {
dialog.show(() => (
<DiffViewerHelpDialog
context={props.context}
single={singlePatch()}
fullscreen={props.onToggleFullscreen !== undefined}
/>
))
dialog.show(() => <DiffViewerHelpDialog context={props.context} single={singlePatch()} />)
dialog.set({ size: "medium", centered: true })
}
@@ -899,7 +777,6 @@ export function DiffViewerContent(props: {
)
props.context.keymap.layer(() => ({
enabled: () => props.focused !== false,
commands,
}))
@@ -997,13 +874,7 @@ export function DiffViewerContent(props: {
/>
</Show>
<box
flexGrow={1}
minWidth={0}
minHeight={0}
paddingLeft={props.elevated ? 1 : 2}
paddingRight={props.elevated ? 1 : 2}
>
<box flexGrow={1} minWidth={0} minHeight={0} paddingLeft={2} paddingRight={2}>
<box
id="diff-patch-top-edge"
ref={(edge: BoxRenderable) => {
@@ -1082,7 +953,7 @@ export function DiffViewerContent(props: {
zIndex={1}
backgroundColor={background()}
paddingLeft={1}
paddingRight={props.elevated ? 0 : 1}
paddingRight={1}
paddingBottom={1}
>
<box flexGrow={1} minWidth={0}>
@@ -1185,45 +1056,7 @@ export function DiffViewerContent(props: {
</Match>
</Switch>
</box>
<Show when={props.elevated}>
<box height={1} flexShrink={0} />
<box height={1} flexShrink={0} paddingLeft={2} paddingRight={2}>
<Show
when={props.focused}
fallback={
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.right", "ctrl+x →")}</span>
{" focus diff"}
</text>
}
>
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.left", "ctrl+x ←")}</span>
{" focus session "}
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.toggle_fullscreen", "f")}</span>
{" full screen "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.down", "j")}/{firstShortcut("diff.up", "k")}
</span>
{" scroll "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.next_file", "n")}/{firstShortcut("diff.previous_file", "p")}
</span>
{" files "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.next_hunk", "]")}/{firstShortcut("diff.previous_hunk", "[")}
</span>
{" hunks "}
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.close", "q")}</span>
{" close "}
<span style={{ fg: theme.text.default }}>{helpShortcut() ?? "?"}</span>
{" see all"}
</text>
</Show>
</box>
<box height={1} flexShrink={0} />
</Show>
<Show when={!showFileTree() && !props.elevated}>
<Show when={!showFileTree()}>
<box position="absolute" top={0} right={0} width={1} height={1}>
<HelpShortcut compact />
</box>
@@ -1313,7 +1146,7 @@ function DiffFileMenu(props: {
)
}
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean; fullscreen: boolean }) {
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const shortcut =
@@ -1353,9 +1186,6 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean;
{ shortcut: shortcut("diff.single_patch"), label: "All files / single file" },
{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" },
{ shortcut: shortcut("diff.switch_source"), label: "Switch diff source" },
...(props.fullscreen
? [{ shortcut: shortcut("diff.toggle_fullscreen"), label: "Full screen / split view" }]
: []),
{ shortcut: () => props.context.keymap.shortcuts("diff.close").join(" / "), label: "Close diff viewer" },
],
},
@@ -1413,7 +1243,6 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean;
}
function Commands(props: { context: Plugin.Context }) {
const panel = useOptionalSessionPanel()
props.context.keymap.layer(() => ({
mode: "global",
commands: [
@@ -1425,15 +1254,6 @@ function Commands(props: { context: Plugin.Context }) {
palette: true,
run() {
const route = props.context.ui.router.current()
if (route.type === "session" && panel?.available(route.sessionID)) {
if (panel.current()?.id === ROUTE && panel.current()?.sessionID === route.sessionID) {
panel.close()
} else {
openDiffPanel(props.context, panel, route.sessionID)
}
props.context.ui.dialog.clear()
return
}
const returnRoute: Route =
route.type === "home"
? { type: "home" }
@@ -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}
+1 -1
View File
@@ -1507,7 +1507,7 @@ export function Session(props: {
<Prompt
visible={true}
ref={bind}
disabled={props.promptMuted}
disabled={false}
muted={props.promptMuted}
onSubmit={() => {
toBottom()
+1 -1
View File
@@ -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)))
@@ -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,
+7 -1
View File
@@ -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 -41
View File
@@ -37,7 +37,6 @@ import { createApi, createEventStream, createFetch, json } from "../../fixture/t
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { createDialogApi } from "../../../src/plugin/api"
import { ToastProvider } from "../../../src/ui/toast"
import { SessionPanelProvider } from "../../../src/context/session-panel"
import { createSignal, Show } from "solid-js"
import { diffImageFixture } from "../../fixture/diff-image"
@@ -70,43 +69,6 @@ test("closing the diff viewer returns to the route it opened from", async () =>
}
})
test("full-screen diff only returns to split view when opened from an eligible panel", async () => {
const narrow = await renderDiffViewer([], {
width: 80,
initialRoute: {
type: "plugin",
id: "opencode.diffs",
name: "diff",
data: { sessionID: "session-1", returnRoute: startRoute },
},
})
try {
const command = narrow.commands.get("diff.toggle_fullscreen")
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(false)
} finally {
narrow.app.renderer.destroy()
}
const eligible = await renderDiffViewer([], {
width: 160,
initialRoute: {
type: "plugin",
id: "opencode.diffs",
name: "diff",
data: { sessionID: "session-1", returnRoute: startRoute, split: true },
},
})
try {
const command = eligible.commands.get("diff.toggle_fullscreen")
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(true)
command?.run()
await eligible.app.flush()
expect(eligible.current()).toEqual(startRoute)
} finally {
eligible.app.renderer.destroy()
}
})
test("ctrl+c closes the diff viewer without exiting the application", async () => {
const viewer = await renderDiffViewer([])
@@ -2010,9 +1972,7 @@ async function renderDiffViewer(
<ToastProvider>
<ThemeProvider mode={options.mode ?? "dark"} source={emptyThemeSource}>
<DialogProvider>
<SessionPanelProvider>
<Content />
</SessionPanelProvider>
<Content />
</DialogProvider>
</ThemeProvider>
</ToastProvider>
@@ -0,0 +1,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
View File
@@ -209,7 +209,6 @@ test("centralizes named command defaults and resolves explicit none", () => {
"diff.next_hunk": "]",
"diff.previous_hunk": "[",
"diff.mark_reviewed": "m",
"diff.toggle_fullscreen": "f",
"diff.help": "?,shift+?,shift+/",
}
const config = resolve({}, { terminalSuspend: true })
+1 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify"]
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",
+1 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify"]
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",
@@ -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 |
+5 -3
View File
@@ -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",
}
```
+1 -2
View File
@@ -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.
+88
View File
@@ -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())
}),
)