Compare commits

...
11 Commits
59 changed files with 2639 additions and 647 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ const table = sqliteTable("session", {
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep event replay ownership separate from clustered Session execution ownership.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Context, Effect, FileSystem, Option, Queue } from "effect"
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
@@ -47,6 +47,7 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
const update = yield* updater.run().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -83,11 +84,15 @@ export default Runtime.handler(Commands, (input) =>
update: (update) => runPromise(config.update(update)),
},
updater: {
monitor: (notify, signal) =>
remote: requestedServer !== undefined,
subscribe: (notify, signal) =>
runPromise(
updater.monitor((version) => Effect.sync(() => notify(version))),
Fiber.join(update).pipe(
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
),
{ signal },
),
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
apply: (version) => runPromise(updater.apply(version)),
},
packages: {
+16
View File
@@ -13,6 +13,7 @@ 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"
@@ -163,6 +164,21 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
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}`)
yield* Updater.Service.pipe(
Effect.flatMap((updater) =>
Updater.pollUpdates({
check: updater.run().pipe(
Effect.flatMap((result) => {
if (!result) return Effect.void
if (result.type === "available") return server.updateAvailable(result.version)
return server.updated(result.version)
}),
),
}),
),
Effect.provide(Updater.layer),
Effect.forkScoped,
)
return yield* options.mode === "service"
? server.shutdown
: options.mode === "stdio"
+3 -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" | "auto"
const maximumComponent = "9007199254740991"
const versionPattern =
@@ -10,7 +10,7 @@ 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"
return policy
}
export function parseReleaseVersion(input: string) {
+7 -2
View File
@@ -6,14 +6,14 @@ 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", () => {
@@ -23,6 +23,11 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
})
test("automatically installs every available release when enabled", () => {
expect(action("1.2.3", "1.2.4", "auto")).toBe("auto")
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
})
test("skips when updates are disabled", () => {
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
})
+63 -30
View File
@@ -1,7 +1,7 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
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 } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
@@ -9,28 +9,28 @@ import { action, parseReleaseVersion, type Policy } from "./updater-action"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
export interface Interface {
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
readonly run: () => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
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>
export const pollUpdates = Effect.fnUntraced(function* (input: {
readonly check: Effect.Effect<unknown>
readonly initialDelay?: Duration.Input
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))
return yield* input.check.pipe(
Effect.repeat(Schedule.spaced(interval)),
Effect.delay(input.initialDelay ?? "1 minute"),
)
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -43,20 +43,20 @@ 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* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
@@ -75,10 +75,10 @@ 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") {
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
return yield* appProcess
.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
@@ -113,7 +113,7 @@ const make = Effect.gen(function* () {
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
@@ -121,12 +121,12 @@ const make = Effect.gen(function* () {
const release = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: () =>
try: (signal) =>
fetch(
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
{
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
signal: AbortSignal.timeout(10_000),
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
},
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
@@ -168,17 +168,20 @@ const make = Effect.gen(function* () {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const installer = path.join(directory, "install")
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
const download = yield* exec(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
"5 minutes",
)
if (download.code !== 0) return download
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
return yield* run(commands[method], "5 minutes")
return yield* exec(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
@@ -200,18 +203,19 @@ const make = Effect.gen(function* () {
return undefined
}
const current = yield* Ref.get(installedVersion)
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
current,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
const next = action(current, version, policy)
if (next === "none") {
yield* Effect.logInfo("update check done", { action: "up-to-date" })
return undefined
}
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return version
yield* Effect.logInfo("OpenCode update available", { current, latest: version, action: next })
return { policy, version }
})
const install = Effect.fnUntraced(function* (version: string) {
@@ -220,8 +224,10 @@ const make = Effect.gen(function* () {
yield* Effect.logWarning("update skipped: installation method not found")
return false
}
const current = yield* Ref.get(installedVersion)
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
yield* Ref.set(installedVersion, version)
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
return true
})
@@ -229,9 +235,36 @@ 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* () {
if (OPENCODE_LOCAL)
return {
type: "unavailable" as const,
message: "This build runs from a source checkout. Use an installed OpenCode release to check for updates.",
}
const version = yield* latest()
if (!parseReleaseVersion(version)) return yield* Effect.fail(new Error(`Invalid version: ${version}`))
const current = yield* Ref.get(installedVersion)
if (action(current, version, "auto") === "none") {
// An earlier check may have installed the update while this client is still running.
return action(OPENCODE_VERSION, current, "auto") === "none"
? undefined
: { type: "installed" as const, version: current }
}
return { type: "available" as const, version }
})
return Service.of({ monitor, apply, method, latest, upgrade })
const run = Effect.fn("cli.updater.run")(
function* () {
const result = yield* inspect()
if (!result) return undefined
if (result.policy === "notify") return { type: "available" as const, version: result.version }
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
return { type: "installed" as const, version: result.version }
},
Effect.catch((error) => Effect.logWarning("update check failed", { error }).pipe(Effect.as(undefined))),
)
return Service.of({ run, check, apply, method, latest, upgrade })
})
export const layer = Layer.effect(Service, make)
+2 -1
View File
@@ -12,7 +12,8 @@ await Effect.runPromise(
process.argv.slice(2),
).pipe(
Effect.provideService(Updater.Service, {
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
run: () => Effect.die("Manual upgrades must not check for automatic updates"),
check: () => Effect.die("Manual upgrades must not check for TUI updates"),
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
method: () =>
Effect.sync(() => {
-40
View File
@@ -1,40 +0,0 @@
import { expect } from "bun:test"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
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", () =>
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),
}).pipe(Effect.forkScoped)
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")
}),
)
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)
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
}),
)
+24
View File
@@ -0,0 +1,24 @@
import { expect } from "bun:test"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.effect("polls after 1 minute and every 10 minutes after that", () =>
Effect.gen(function* () {
const checks = yield* Queue.unbounded<void>()
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("59 seconds")
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("1 second")
yield* Queue.take(checks)
yield* Effect.yieldNow
yield* TestClock.adjust("10 minutes")
yield* Queue.take(checks)
}),
)
@@ -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
+1 -3
View File
@@ -74,9 +74,7 @@ export function normalize(input: unknown): Result {
? 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)
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
+26 -15
View File
@@ -409,19 +409,17 @@ export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function*
sessionID: SessionSchema.ID,
promotable: Promotable,
) {
const next = (delivery: Delivery) =>
db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, delivery)))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
const steer = yield* next("steer")
const steer = (yield* pendingSteers(db, sessionID))[0]
if (steer) return fromRow(steer)
if (promotable !== "input") return undefined
const queued = yield* next("queue")
const queued = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return queued ? fromRow(queued) : undefined
})
@@ -490,9 +488,10 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
})
/**
* Promotes pending input into visible messages and returns the promoted count.
* Steers always go first; only the "input" scope may fall through to one queued
* input, and it then collects steers that arrived during promotion.
* Promotes pending input into visible messages and returns the promoted count,
* or undefined when the runner must first handle a pending control.
* Steered compaction takes priority over pending prompts, without crossing a move.
* Only the "input" scope may fall through to one queued input.
*/
export const promote = Effect.fn("SessionInbox.promote")(function* (
db: DatabaseService,
@@ -506,6 +505,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
const steers = yield* pendingSteers(db, sessionID)
if (steers.length > 0 || scope === "steer") {
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
if (control === 0) return undefined
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
}
@@ -518,6 +518,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
.get()
.pipe(Effect.orDie)
if (!queued) return 0
if (queued.type === "compaction" || queued.type === "move") return undefined
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* pendingSteers(db, sessionID)
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
@@ -536,4 +537,14 @@ const pendingSteers = (db: DatabaseService, sessionID: SessionSchema.ID) =>
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
.pipe(
Effect.orDie,
Effect.map((rows) => {
// A move changes the context's Location: never pull compaction across it.
// Within that boundary, compact before promoting even earlier steers so
// their text stays verbatim after the checkpoint, not inside its summary.
const control = rows.findIndex((row) => row.type === "compaction" || row.type === "move")
if (control > 0 && rows[control].type === "compaction") rows.unshift(...rows.splice(control, 1))
return rows
}),
)
+4 -1
View File
@@ -147,7 +147,7 @@ const layer = Layer.effect(
}
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
return DrainResult.Complete()
return yield* restore(
const ready = yield* restore(
Effect.gen(function* () {
const selected = yield* prepareContext(sessionID)
const promoted = yield* SessionInbox.promote(
@@ -156,6 +156,8 @@ const layer = Layer.effect(
sessionID,
entering && !continuing ? promotable : "steer",
)
// A control admitted during context preparation owns this boundary.
if (promoted === undefined) return undefined
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
onlyIfMissing: true,
@@ -164,6 +166,7 @@ const layer = Layer.effect(
return { _tag: "Ready" as const, context: yield* context.load(selected) }
}),
)
if (ready) return ready
}
}),
),
+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,
+3 -3
View File
@@ -666,14 +666,14 @@ 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", () => {
test("normalizes the native auto update policy", () => {
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
type: "normalized",
encoded: { update: "notify" },
encoded: { update: "auto" },
diagnostics: [],
})
})
+179
View File
@@ -1925,6 +1925,185 @@ describe("SessionRunnerLLM", () => {
).toEqual(["Replacement context"])
})
for (const order of ["before", "between", "after"] as const) {
scenario(`prioritizes manual compaction admitted ${order} two steers at the safe boundary`, function* (s) {
s.currentModel = recoveryModel
yield* s.llm.push(
TestLLM.text("Active complete", "active"),
TestLLM.text("## Objective\n- Active work checkpoint", "summary"),
TestLLM.text("Steers complete", "steers"),
)
yield* s.admit("Active work")
const active = yield* s.resumePaused
const compactID = SessionMessage.ID.create()
if (order === "before") yield* s.session.compact({ sessionID, id: compactID })
const first = yield* s.admit("STEER_A")
if (order === "between") yield* s.session.compact({ sessionID, id: compactID })
const second = yield* s.admit("STEER_B")
if (order === "after") yield* s.session.compact({ sessionID, id: compactID })
expect((yield* s.session.compact({ sessionID })).id).toBe(compactID)
expect(s.requests).toHaveLength(1)
expect(yield* s.inbox).toHaveLength(3)
expect((yield* s.messages).some((message) => message.type === "compaction")).toBe(false)
yield* active.finish
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1])).not.toContain("STEER_A")
expect(userTexts(s.requests[1])).not.toContain("STEER_B")
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
expect(yield* s.inbox).toEqual([])
expect((yield* s.messages).filter((message) => message.id === compactID)).toMatchObject([
{ type: "compaction", status: "completed" },
])
expect((yield* s.context).filter((message) => message.type === "user").map((message) => message.id)).toEqual([
first.id,
second.id,
])
// An advisory drain must not redeliver either steer or rerun compaction.
const runner = yield* SessionRunner.Service
yield* runner.drain({ sessionID, force: false })
expect(s.requests).toHaveLength(3)
})
}
scenario("waits for active tools before prioritizing compaction over pending steers", function* (s) {
yield* s.llm.push(
TestLLM.tool("call-active", "echo", { text: "active" }),
TestLLM.text("## Objective\n- Tool work checkpoint", "summary"),
TestLLM.text("Steers complete", "steers"),
)
yield* s.admit("Active work")
const tools = yield* s.blockTools()
const run = yield* s.resume.pipe(Effect.forkChild)
yield* tools.started
yield* s.admit("STEER_A")
yield* s.admit("STEER_B")
const compact = yield* s.session.compact({ sessionID })
expect(s.requests).toHaveLength(1)
expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
yield* tools.release
yield* Fiber.join(run)
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect(s.requests[1].messages.some((message) => message.role === "tool")).toBe(true)
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
expect(yield* s.inbox).toEqual([])
})
scenario("rechecks compaction admitted during boundary context preparation", function* (s) {
yield* s.runPrompt("Earlier work")
yield* s.admit("STEER_A")
yield* s.admit("STEER_B")
const preparing = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
s.systemLoadHook = Deferred.succeed(preparing, undefined).pipe(Effect.andThen(Deferred.await(release)))
yield* s.llm.push(
TestLLM.text("## Objective\n- Earlier work checkpoint", "summary"),
TestLLM.text("Steers complete", "steers"),
)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Deferred.await(preparing)
yield* s.session.compact({ sessionID })
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(run)
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
expect(yield* s.inbox).toEqual([])
})
for (const outcome of ["cancelled", "failed"] as const) {
scenario(`preserves both earlier steers when prioritized compaction is ${outcome}`, function* (s) {
yield* s.llm.push(TestLLM.text("Active complete", "active"))
yield* s.admit("Active work")
const active = yield* s.resumePaused
const first = yield* s.admit("STEER_A")
const second = yield* s.admit("STEER_B")
const compact = yield* s.session.compact({ sessionID })
if (outcome === "cancelled") yield* s.session.cancelInbox({ sessionID, inboxID: compact.id })
if (outcome === "failed") yield* s.llm.push([LLMEvent.providerError({ message: "summary unavailable" })])
yield* s.llm.push(TestLLM.text("Steers complete", "steers"))
yield* active.finish
expect(s.requests).toHaveLength(outcome === "cancelled" ? 2 : 3)
if (outcome === "failed") {
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({
status: "failed",
error: { type: "provider.error", message: "summary unavailable" },
})
}
if (outcome === "cancelled") expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
expect(userTexts(s.requests[s.requests.length - 1]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
expect(
(yield* s.context)
.filter((message) => message.id === first.id || message.id === second.id)
.map((message) => message.id),
).toEqual([first.id, second.id])
expect(yield* s.inbox).toEqual([])
})
}
scenario("keeps steers durable across interrupted priority compaction and replay", function* (s) {
yield* s.runPrompt("Earlier work")
const first = yield* s.admit("STEER_A")
const second = yield* s.admit("STEER_B")
yield* s.llm.push(TestLLM.text("## Objective\n- Interrupted checkpoint", "summary"))
const summary = yield* s.llm.gate
const compact = yield* s.session.compact({ sessionID })
yield* summary.started
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
yield* s.session.interrupt(sessionID)
yield* s.session.wait(sessionID)
yield* summary.release
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({ status: "failed" })
yield* replaySessionProjection(sessionID)
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
yield* s.llm.push(TestLLM.text("Recovered steers", "steers"))
yield* s.resume
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
expect(yield* s.inbox).toEqual([])
})
scenario("does not pull compaction across an earlier move", function* (s) {
yield* s.admit("STEER_A")
yield* s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery: "steer",
},
})
yield* s.admit("STEER_B")
yield* s.sessionInbox.admitCompaction({ id: SessionMessage.ID.create(), sessionID, delivery: "steer" })
yield* s.llm.push(
TestLLM.text("First steer complete", "first"),
TestLLM.text("## Objective\n- Source work checkpoint", "summary"),
TestLLM.text("Second steer complete", "second"),
)
yield* s.resume
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[0])).toEqual(["STEER_A"])
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect(userTexts(s.requests[2]).at(-1)).toBe("STEER_B")
expect(
(yield* recordedEventTypes(sessionID)).filter(
(type) => type === "session.moved.1" || type === "session.compaction.started.1",
),
).toEqual(["session.moved.1", "session.compaction.started.1"])
})
scenario("runs steers before queued compaction and later queued input", function* (s) {
s.currentModel = recoveryModel
yield* s.llm.push(
+24
View File
@@ -162,6 +162,21 @@ type PromptFooterInput = {
readonly showDetails: boolean
}
export type PanelPresentation = "panel" | "fullscreen"
/** Client-local state of the selected session panel. The host owns its layout and input scope. */
export interface PanelInput {
/** Selected content name, set by ui.panel.open. Contributions decide whether to render it. */
readonly name: string
readonly sessionID: string
readonly width: number
readonly presentation: PanelPresentation
readonly focused: boolean
readonly focus: () => void
readonly close: () => void
readonly toggleFullscreen: () => void
}
/**
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
* may render around, inside, or take over. Paths are absolute and
@@ -180,6 +195,7 @@ export interface SlotMap {
readonly "prompt.footer.status": PromptFooterInput
readonly "prompt.footer.file": PromptFooterInput
readonly "session.composer.top": { readonly sessionID: string }
readonly "session.panel": PanelInput
readonly "sidebar.content": { readonly sessionID: string }
readonly "sidebar.footer": { readonly sessionID: string }
}
@@ -450,6 +466,14 @@ export interface UI {
navigate(destination: Destination): void
current(): Route
}
readonly panel: {
/** Opens the session.panel slot in the current session. */
open(name: string, options?: { readonly presentation?: PanelPresentation }): boolean
/** Closes this plugin's active panel. Other plugins' panels are unaffected. */
close(): void
/** This plugin's active panel, if any. Reactive when read in a Solid computation. */
current(): { readonly name: string; readonly sessionID: string } | undefined
}
readonly tabs: {
/** Returns whether session tabs are enabled for this TUI. */
enabled(): boolean
+10 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify"]
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",
@@ -18221,6 +18221,12 @@
"type": "string",
"enum": ["auto", "manual"]
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
"type": "string"
},
@@ -18448,6 +18454,9 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {
+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 updates automatically",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
+10 -1
View File
@@ -1,7 +1,9 @@
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 { InstallationEvent } from "@opencode-ai/schema/installation-event"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
@@ -114,7 +116,14 @@ 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 }
const bus = Context.get(context, Bus.Service)
return {
address: bound.http.address,
shutdown: shutdown.await,
updateAvailable: (version: string) =>
bus.publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
updated: (version: string) => bus.publish(InstallationEvent.Updated, { version }).pipe(Effect.asVoid),
}
}).pipe(
Effect.catchCause((cause) => {
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
+15 -1
View File
@@ -101,7 +101,13 @@ it.live("allows browser preflight requests without credentials", () =>
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())
const reader = body.getReader()
yield* Effect.promise(() => readUntil(reader, "server.connected"))
yield* server.updateAvailable("2.0.0")
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
yield* server.updated("2.0.0")
yield* Effect.promise(() => readUntil(reader, "installation.updated"))
yield* Effect.promise(() => reader.cancel())
const missing = yield* Effect.promise(() =>
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
@@ -126,3 +132,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
}
}
+52 -56
View File
@@ -64,7 +64,6 @@ import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { DialogUpdate } from "./component/dialog-update"
import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
@@ -88,6 +87,7 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { newSessionLocation } from "./config/new-session-location"
import { UpdateNotificationProvider, useUpdateNotification, type UpdateSource } from "./context/update-notification"
import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context"
import { localPluginDirectories } from "./plugin/discovery"
import { PluginRoute, Slot } from "./plugin/render"
@@ -100,6 +100,7 @@ import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider, useStorage } from "./context/storage"
import { SessionTerminalsProvider } from "./context/session-terminals"
import { PanelProvider, usePanel } from "./context/panel"
import { SessionFrame } from "./component/session-frame"
import { createTuiClipboard } from "./clipboard"
@@ -154,6 +155,7 @@ const appBindingCommands = [
"provider.connect",
"opencode.settings",
"opencode.status",
"opencode.update",
"server.pair",
"service.restart",
"opencode.debug",
@@ -185,10 +187,7 @@ export type TuiInput = {
}
args: Args
config: Config.Interface
updater?: {
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
apply: (version: string) => Promise<void>
}
updater?: UpdateSource
packages: PackageSource
environment?: Readonly<Record<string, string>>
terminalHandoff?: () => Promise<
@@ -397,22 +396,27 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
<UpdateNotificationProvider
updater={input.updater}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
<PanelProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</PanelProvider>
</UpdateNotificationProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
@@ -462,7 +466,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"] }) {
function App(props: { pair?: DialogPairCredentials }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
@@ -474,10 +478,12 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
const dialog = useDialog()
const local = useLocal()
const sessionTabs = useSessionTabs()
const panels = usePanel()
const keymap = Keymap.use()
const event = useEvent()
const client = useClient()
const toast = useToast()
const updater = useUpdateNotification()
const theme = useTheme()
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
const data = useData()
@@ -501,40 +507,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", {
initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH },
})
const [updateNotifications, markUpdateNotification] = useStorage().store<{ versions: string[] }>(
"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,
@@ -608,9 +580,22 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () =>
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
const fullscreenPanel = () =>
route.data.type === "session" &&
panels.current()?.sessionID === route.data.sessionID &&
panels.presentation() === "fullscreen"
const tabsVisible = () => tabsAvailable() && !fullscreenPanel()
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
// Measure the prospective split layout, even while full-screen hides the tabs.
createEffect(() => panels.setWidth(dimensions().width - (tabsAvailable() && tabsVertical() ? tabsResize.size() : 0)))
createEffect(() => {
const current = panels.current()
if (!current || (route.data.type === "session" && route.data.sessionID === current.sessionID)) return
panels.close()
})
createEffect(() => {
renderer.useMouse = config.data.mouse
})
@@ -972,6 +957,17 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
},
category: "System",
},
...(updater.open
? [
{
name: "opencode.update",
title: "Update OpenCode",
slash: { name: "update" },
run: () => updater.open?.("manual"),
category: "System",
},
]
: []),
{
name: "server.pair",
title: "Pair device",
+96 -109
View File
@@ -1,67 +1,56 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes } from "@opentui/core"
import { createSignal, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { errorMessage } from "../util/error"
import type { UpdateState } from "../context/update-notification"
import { useDialog } from "../ui/dialog"
import { errorMessage } from "../util/error"
import { Spinner } from "./spinner"
type State =
| { type: "ready"; active: "update" | "skip" }
| { type: "installing" }
| { type: "restarting" }
| { type: "failed"; message: string }
export function DialogUpdate(props: {
dialogKey: string
version: string
check?: (signal: AbortSignal) => Promise<string | undefined>
state: () => UpdateState | undefined
install: () => Promise<void>
restart?: () => Promise<void>
restart: () => 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 [error, setError] = createSignal<string>()
const [active, setActive] = createSignal(0)
const controller = new AbortController()
onCleanup(() => controller.abort())
const install = async () => {
setState({ type: "installing" })
await props.install()
if (props.restart) {
setState({ type: "restarting" })
await props.restart()
}
close()
}
dialog.setCentered(true)
const beginInstall = () => {
if (state().type !== "ready") return
void install().catch((error) => setState({ type: "failed", message: errorMessage(error) }))
}
const [check] = createResource(
() => props.check,
(check) =>
check(controller.signal).catch((error) => {
if (!controller.signal.aborted) setError(errorMessage(error))
return undefined
}),
)
const state = createMemo(() => {
if (check.loading) return { type: "checking" as const }
const unavailable = check()
if (unavailable) return { type: "unavailable" as const, message: unavailable }
const message = error()
if (message) return { type: "check-failed" as const, message }
return props.state() ?? { type: "current" as const }
})
const buttons = createMemo(() => {
const type = state().type
if (type === "installing") return []
const confirm =
type === "available"
? { label: "Update", run: props.install }
: type === "installed"
? { label: "Restart", run: props.restart }
: undefined
return [{ label: "Skip", run: () => dialog.clear() }, ...(confirm ? [confirm] : [])]
})
const run = () => {
const current = state()
if (current.type !== "ready") return
if (current.active === "skip") return close()
beginInstall()
}
const toggle = () =>
setState((current) =>
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
)
const selected = (action: "update" | "skip") => {
const current = state()
return current.type === "ready" && current.active === action
}
const failure = () => {
const current = state()
return current.type === "failed" ? current.message : ""
}
createEffect(() => setActive(Math.max(0, buttons().length - 1)))
Keymap.createLayer(() => ({
mode: "modal",
@@ -70,20 +59,17 @@ export function DialogUpdate(props: {
bind: "return",
title: "Confirm update action",
group: "Dialog",
run: () => (state().type === "failed" ? close() : run()),
run: () => void buttons()[active()]?.run(),
},
{
bind: "left",
title: "Previous update action",
...["left", "right", "tab", "shift+tab"].map((bind) => ({
bind,
title: bind === "left" || bind === "shift+tab" ? "Previous update action" : "Next update action",
group: "Dialog",
run: toggle,
},
{
bind: "right",
title: "Next update action",
group: "Dialog",
run: toggle,
},
run: () => {
const count = buttons().length
if (count) setActive((value) => (value + 1) % count)
},
})),
],
}))
@@ -91,64 +77,65 @@ 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
{state().type === "available" || state().type === "installing" || state().type === "failed"
? "Update available"
: "Update"}
</text>
<text fg={theme.text.subdued} onMouseUp={close}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingBottom={1}>
<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."}
</text>
</Match>
<Match when={state().type === "installing"}>
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}</Spinner>
</Match>
<Match when={state().type === "restarting"}>
<Spinner shimmer={theme.text.default}>Restarting the background service</Spinner>
</Match>
<Match when={state().type === "failed"}>
<text fg={theme.text.feedback.error.default}>{failure()}</text>
</Match>
</Switch>
<Show when={state()} keyed>
{(current) => (
<Switch>
<Match when={current.type === "checking"}>
<Spinner shimmer={theme.text.default}>Checking for updates</Spinner>
</Match>
<Match when={current.type === "available"}>
<text fg={theme.text.subdued}>
An update is available. After installing, you'll be prompted to restart OpenCode.
</text>
</Match>
<Match when={current.type === "installing"}>
<Spinner shimmer={theme.text.default}>
{current.type === "installing" ? `Installing OpenCode ${current.version}` : ""}
</Spinner>
</Match>
<Match when={current.type === "installed"}>
<text fg={theme.text.subdued} wrapMode="word">
Update successful! A restart is required. Any active sessions will be resumed automatically.
</text>
</Match>
<Match when={current.type === "current"}>
<text fg={theme.text.subdued}>OpenCode is already up to date.</text>
</Match>
<Match when={current.type === "unavailable"}>
<text fg={theme.text.subdued} wrapMode="word">
{current.type === "unavailable" ? current.message : ""}
</text>
</Match>
<Match when={current.type === "failed" || current.type === "check-failed"}>
<text fg={theme.text.feedback.error.default}>
{current.type === "failed" || current.type === "check-failed" ? current.message : ""}
</text>
</Match>
</Switch>
)}
</Show>
</box>
<Show
when={state().type === "ready"}
fallback={
<Show when={state().type === "failed"}>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={close}
>
<text fg={theme.text.action.primary.focused}>close</text>
</box>
</box>
</Show>
}
>
<Show when={buttons().length > 0}>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["skip", "update"] as const}>
{(action) => (
<For each={buttons()}>
{(button, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
if (action === "skip") return close()
beginInstall()
}}
backgroundColor={active() === index() ? theme.background.action.primary.focused : undefined}
onMouseUp={() => void button.run()}
>
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
{action === "update" ? "Update" : "Skip"}
<text fg={active() === index() ? theme.text.action.primary.focused : theme.text.subdued}>
{button.label}
</text>
</box>
)}
+105
View File
@@ -0,0 +1,105 @@
import { RGBA, type OptimizedBuffer, type RenderContext, type TextOptions } from "@opentui/core"
import { extend, type JSX } from "@opentui/solid"
import { splitProps } from "solid-js"
import { useConfig } from "../config"
import { MaskedTextRenderable } from "./masked-text"
import { coast, smootherstep } from "./tab-pulse"
type FadeInTextOptions = TextOptions & {
backdrop?: RGBA
enabled?: boolean
sweepOffset?: number
sweepWidth?: number
}
const DURATION = 200
const FEATHER = 8
const clamp = (value: number) => Math.max(0, Math.min(1, value))
class FadeInTextRenderable extends MaskedTextRenderable {
private _backdrop = RGBA.defaultBackground()
private _enabled = true
private _sweepOffset = 0
private _sweepWidth: number | undefined
private elapsed = 0
constructor(ctx: RenderContext, options: FadeInTextOptions) {
super(ctx, options)
this.matrix[15] = 1
this.updateBackdrop()
if (options.backdrop) this.backdrop = options.backdrop
if (options.enabled === false) this.enabled = false
this.live = this._enabled
}
set backdrop(value: RGBA) {
if (value.equals(this._backdrop)) return
this._backdrop = value
this.updateBackdrop()
this.requestRender()
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
this.live = value && this.elapsed < DURATION
this.requestRender()
}
set sweepOffset(value: number | undefined) {
this._sweepOffset = value ?? 0
this.requestRender()
}
set sweepWidth(value: number | undefined) {
this._sweepWidth = value
this.requestRender()
}
private updateBackdrop() {
this.matrix[3] = this._backdrop.r
this.matrix[7] = this._backdrop.g
this.matrix[11] = this._backdrop.b
}
override render(buffer: OptimizedBuffer, deltaTime: number) {
if (!this._enabled || this.elapsed >= DURATION) return super.render(buffer, deltaTime)
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
this.elapsed = Math.min(DURATION, this.elapsed + deltaTime)
this.renderMasked(buffer, 1, (end) => {
const progress = this.elapsed / DURATION
const front = -FEATHER + coast(progress) * ((this._sweepWidth ?? end) + FEATHER * 2)
return (column) => 1 - smootherstep(clamp((front - (this._sweepOffset + column)) / FEATHER))
})
if (this.elapsed >= DURATION) this.live = false
}
}
extend({ fade_in_text: FadeInTextRenderable })
declare module "@opentui/solid" {
interface OpenTUIComponents {
fade_in_text: typeof FadeInTextRenderable
}
}
type Props = Omit<JSX.IntrinsicElements["text"], "ref"> & {
animate?: boolean
backdrop?: RGBA
sweepOffset?: number
sweepWidth?: number
}
export function FadeInText(props: Props) {
const config = useConfig().data
const [local, text] = splitProps(props, ["animate", "backdrop", "sweepOffset", "sweepWidth"])
return (
<fade_in_text
{...text}
backdrop={local.backdrop}
enabled={(local.animate ?? true) && (config.animations ?? true)}
sweepOffset={local.sweepOffset}
sweepWidth={local.sweepWidth}
/>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { OptimizedBuffer, RGBA, TargetChannel, TextRenderable } from "@opentui/core"
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
const CONTINUATION = 0xc0000000 | 0
export class MaskedTextRenderable extends TextRenderable {
protected readonly matrix = new Float32Array(16)
private scratch: OptimizedBuffer | undefined
private mask = new Float32Array(0)
protected renderMasked(
buffer: OptimizedBuffer,
initialStrength: number,
shade: (width: number) => (column: number) => number,
) {
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 intensity = shade(end)
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
let strength = initialStrength
for (let cell = 0; cell < characters.length; cell++) {
const column = cell % this.width
// Wide glyph continuation cells retain the head cell's intensity.
if ((characters[cell] & CONTINUATION) !== CONTINUATION) strength = intensity(column)
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()
}
}
+63
View File
@@ -0,0 +1,63 @@
import type { BoxRenderable } from "@opentui/core"
import { onCleanup, onMount } from "solid-js"
import { usePanel, type PanelTarget } from "../context/panel"
import { InteractivityProvider } from "../context/interactivity"
import { ThemeContextProvider, useTheme } from "../context/theme"
import { Slot } from "../plugin/render"
export function PanelHost(props: {
panel: PanelTarget
width: number
focused: boolean
onFocus: () => void
onTarget: (node: BoxRenderable | undefined) => void
}) {
const panels = usePanel()
let node: BoxRenderable
onMount(() => props.onTarget(node))
onCleanup(() => props.onTarget(undefined))
const Content = () => {
const theme = useTheme()
return (
<box
id="session-panel"
ref={(value: BoxRenderable) => (node = value)}
flexGrow={1}
minWidth={0}
minHeight={0}
focusable
backgroundColor={theme.background.default}
onMouseDown={props.onFocus}
>
<Slot
path="session.panel"
input={{
name: props.panel.name,
sessionID: props.panel.sessionID,
get width() {
return props.width
},
get presentation() {
return panels.presentation()
},
get focused() {
return props.focused
},
focus: props.onFocus,
close: panels.close,
toggleFullscreen: panels.toggleFullscreen,
}}
/>
</box>
)
}
return (
<InteractivityProvider enabled={props.focused}>
<ThemeContextProvider context={panels.presentation() === "panel" ? "elevated" : undefined}>
<Content />
</ThemeContextProvider>
</InteractivityProvider>
)
}
+34 -18
View File
@@ -54,6 +54,7 @@ import { resolvePastedAttachments } from "./local-attachment"
import { locationKey, useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { useInteractivity } from "../../context/interactivity"
import { abbreviateHome } from "../../runtime"
import { Slot } from "../../plugin/render"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
@@ -186,6 +187,8 @@ export function Prompt(props: PromptProps) {
let anchor: BoxRenderable
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const enabled = useInteractivity()
const disabled = () => props.disabled || !enabled()
const leader = Keymap.useLeaderActive()
const muted = () => leader() || props.muted
const local = useLocal()
@@ -257,6 +260,7 @@ export function Prompt(props: PromptProps) {
const [pendingDirectory, setPendingDirectory] = createSignal<string>()
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: [
{
id: "session.cd",
@@ -346,8 +350,7 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.background.surface.offset
if (!props.disabled) input.cursorColor = theme.text.default
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
if (config.cursor) input.cursorStyle = config.cursor
})
@@ -370,12 +373,13 @@ export function Prompt(props: PromptProps) {
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
pasteQueue = pasteQueue
.then(async () => {
if (disposed || input.isDestroyed) return
if (disposed || input.isDestroyed || disabled()) return
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
await run(
() =>
disposed ||
input.isDestroyed ||
disabled() ||
props.sessionID !== before.sessionID ||
store.mode !== before.mode ||
input.plainText !== before.text,
@@ -634,15 +638,18 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: promptCommands(),
}))
Keymap.createLayer(() => ({
priority: 1,
enabled: !disabled(),
bindings: ["prompt.queue"],
}))
Keymap.createLayer(() => ({
enabled: !disabled(),
bindings: [
"prompt.submit",
"prompt.editor",
@@ -660,12 +667,13 @@ export function Prompt(props: PromptProps) {
const ref: PromptRef = {
get focused() {
return input.focused
return !disabled() && input.focused
},
get current() {
return store.prompt
},
focus() {
if (disabled()) return
input.focus()
},
blur() {
@@ -719,11 +727,13 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
if (props.visible === false || disabled() || dialog.stack.length > 0) {
if (input.focused) input.blur()
input.focusable = false
return
}
input.focusable = true
// Slot/plugin updates can remount the background prompt while a dialog is open.
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
if (!input.focused) input.focus()
@@ -919,13 +929,14 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: stashCommands(),
}))
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
enabled: inputTarget() !== undefined && !disabled(),
bindings: ["prompt.paste"],
}
})
@@ -933,7 +944,7 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
enabled: inputTarget() !== undefined && !disabled() && store.prompt.text !== "",
bindings: ["prompt.clear"],
}
})
@@ -945,7 +956,7 @@ export function Prompt(props: PromptProps) {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!disabled() &&
store.mode === "normal" &&
!auto()?.visible &&
input?.visualCursor.offset === 0
@@ -969,7 +980,7 @@ export function Prompt(props: PromptProps) {
return {
priority: 1,
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
enabled: inputTarget() !== undefined && !disabled() && store.mode === "shell",
commands: [
{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
{
@@ -988,7 +999,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
return inputTarget() !== undefined && !disabled() && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
commands: [
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
@@ -1002,7 +1013,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -1038,7 +1049,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -1073,6 +1084,7 @@ export function Prompt(props: PromptProps) {
let submitting = false
async function submit(delivery: SessionInbox.Delivery = "steer") {
if (disabled()) return false
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call
@@ -1096,7 +1108,6 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "text", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (move.creating()) return false
if (auto()?.visible) return false
const trimmed = store.prompt.text.trim()
@@ -1764,18 +1775,19 @@ export function Prompt(props: PromptProps) {
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
if (disabled()) {
e.preventDefault()
return
}
}}
onSubmit={() => {
if (disabled()) return
// IME: double-defer so the last composed character (e.g. Korean
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={(event: PasteEvent) => {
if (props.disabled) {
if (disabled()) {
event.preventDefault()
return
}
@@ -1811,12 +1823,16 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = theme.text.default
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
if (config.cursor) input.cursorStyle = config.cursor
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
if (props.disabled || r.button !== 0) return
if (disabled()) {
r.preventDefault()
return
}
if (r.button !== 0) return
r.target?.focus()
const extmark = input.extmarks
.getAtOffset(input.cursorOffset)
@@ -1826,7 +1842,7 @@ export function Prompt(props: PromptProps) {
r.stopPropagation()
}}
focusedBackgroundColor="transparent"
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
cursorColor={disabled() ? theme.background.surface.offset : theme.text.default}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
+230 -60
View File
@@ -1,19 +1,30 @@
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
import {
CliRenderEvents,
RGBA,
MouseEvent,
type BoxRenderable,
type Renderable,
type ScrollBoxRenderable,
} from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
import { batch, createEffect, createMemo, createResource, createSignal, on, onCleanup, Show } from "solid-js"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { InteractivityProvider } from "../context/interactivity"
import { useSessionTerminals } from "../context/session-terminals"
import { usePromptRef } from "../context/prompt"
import { usePanel } from "../context/panel"
import { useStorage } from "../context/storage"
import { useDialog } from "../ui/dialog"
import { Session } from "../routes/session"
import { Sidebar } from "../routes/session/sidebar"
import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { clampSessionPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { createPaneResize } from "../ui/pane-resize"
import { PaneResizeHandle } from "../ui/pane-resize-handle"
import { useToast } from "../ui/toast"
import { TerminalPane } from "./terminal-pane"
import { PanelHost } from "./panel-host"
export function SessionFrame(props: { sessionID: string; verticalTabsWidth: number }) {
const sessions = useSessionTerminals()
@@ -21,40 +32,49 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const config = useConfig()
const data = useData()
const toast = useToast()
const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" })
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const panels = usePanel()
const dialog = useDialog()
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
const defaultTerminalWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
const [layout, updateLayout] = useStorage().store<{ terminalWidth?: number }>("layout", { initial: {} })
const terminalResize = createPaneResize({
value: () => layout.terminalWidth ?? defaultTerminalWidth(),
defaultValue: defaultTerminalWidth,
clamp: (width) => clampTerminalPaneWidth(width, availableWidth()),
const defaultPaneWidth = () => Math.max(1, Math.floor(panels.width() / 2))
const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", {
initial: {},
})
const paneResize = createPaneResize({
value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(),
defaultValue: defaultPaneWidth,
clamp: (width) => clampSessionPaneWidth(width, panels.width()),
fromMouse: (event) => dimensions().width - event.x - 1,
contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width,
onCommit: (width) => {
void updateLayout((draft) => {
draft.terminalWidth = width
draft.paneWidth = width
}).catch((error) => console.error("Failed to persist TUI layout", error))
},
})
let resizeRelease = false
const finishTerminalResize = (event: MouseEvent) => {
if (terminalResize.resizing()) {
const finishPaneResize = (event: MouseEvent) => {
if (paneResize.resizing()) {
// A captured drag-end can be followed by mouse-up on the focus overlay.
resizeRelease = true
queueMicrotask(() => {
resizeRelease = false
})
}
terminalResize.onMouseUp(event)
paneResize.onMouseUp(event)
}
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [sessionWidth, setSessionWidth] = createSignal<number>()
const [terminalFocused, setTerminalFocused] = createSignal(false)
const [activePane, setActivePane] = createSignal<"session" | "right">("session")
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
let focusTerminal: (() => void) | undefined
let showTerminals: (() => void) | undefined
let sessionScroll: ScrollBoxRenderable | undefined
let sessionNode: BoxRenderable | undefined
let rightNode: BoxRenderable | undefined
let panelNode: BoxRenderable | undefined
createResource(
() => (config.data.session.terminal ? props.sessionID : undefined),
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
@@ -65,14 +85,23 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const value = session()
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
}
const activePanel = createMemo(() => {
const current = panels.current()
if (current?.sessionID === props.sessionID) return current
})
const fullscreen = () => activePanel() !== undefined && panels.presentation() === "fullscreen"
createEffect(
on(
() => selectedTerminal()?.id,
(id) => {
if (id) setSidebarOpen(false)
},
{ defer: true },
),
on([activePanel, () => selectedTerminal()?.id], ([panel, terminal], previous) => {
if (panel && panel !== previous?.[0]) {
setSidebarOpen(false)
if (terminal) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
return
}
if (terminal && terminal !== previous?.[1]) {
setSidebarOpen(false)
if (panel) panels.close()
}
}),
)
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
const sidebarVisible = createMemo(() => {
@@ -81,6 +110,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
})
const rightPane = createMemo(() => {
if (activePanel()) return "panel"
if (sidebarOpen() && sidebarVisible()) return "sidebar"
if (selectedTerminal()) return "terminal"
if (sidebarVisible()) return "sidebar"
@@ -94,34 +124,137 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
})
.catch(toast.error)
setSidebarOpen(!visible)
if (!visible && activePanel()) panels.close()
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
}
const focusSession = () => {
if (fullscreen()) return
// Permission prompts replace the input, so returning focus must not depend on it.
if (terminalFocused()) renderer.currentFocusedRenderable?.blur()
if (activePane() === "right") renderer.currentFocusedRenderable?.blur()
setActivePane("session")
prompt.current?.focus()
}
const focusRightPane = () => {
setActivePane("right")
if (activePanel()) {
panelNode?.focus()
return
}
focusTerminal?.()
}
const onFocused = () => {
const current = renderer.currentFocusedRenderable
if (rightPane() !== "sidebar" && within(current, rightNode)) setActivePane("right")
if (!fullscreen() && within(current, sessionNode)) setActivePane("session")
}
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused))
createEffect(() => {
if (fullscreen()) focusRightPane()
})
createEffect(() => {
if (rightPane() !== "terminal" && rightPane() !== "panel") setActivePane("session")
})
createEffect(() => {
if (!restoreTerminalFocus() || selectedTerminal()) return
setRestoreTerminalFocus(false)
focusSession()
})
Keymap.createLayer(() => ({
enabled: () => config.data.session.terminal === true,
mode: "global",
enabled: () => (rightPane() === "terminal" || activePanel() !== undefined) && dialog.stack.length === 0,
commands: [
{
id: "pane.focus.left",
title: "Focus session pane",
enabled: () => !fullscreen(),
run: focusSession,
},
{
id: "pane.focus.right",
title: "Focus terminal pane",
title: "Focus right pane",
run: focusRightPane,
},
],
}))
// Pane management stays reachable from either input scope.
Keymap.createLayer(() => ({
mode: "global",
commands: [
{
id: "session.sidebar.toggle",
title: rightPane() === "sidebar" ? "Hide sidebar" : "Show sidebar",
group: "Session",
palette: true,
run: () => {
focusTerminal?.()
toggleSidebar()
dialog.clear()
},
},
...(config.data.session.terminal
? [
{
id: "terminal.toggle",
title: rightPane() === "terminal" ? "Hide terminal pane" : "Show terminal pane",
group: "Session",
palette: true as const,
run: () => {
dialog.clear()
if (rightPane() === "terminal") {
focusSession()
void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
return
}
void sessions
.refresh(props.sessionID)
.then(async () => {
const terminal = sessions.get(props.sessionID).terminals.at(-1)
if (terminal) return sessions.selectTerminal(props.sessionID, terminal.id)
await sessions.newTerminal(props.sessionID)
})
.catch(terminalError)
},
},
{
id: "terminal.select",
title: "Select terminal",
group: "Session",
palette: true as const,
run: () => {
dialog.clear()
if (fullscreen()) panels.close()
focusSession()
showTerminals?.()
void sessions.refresh(props.sessionID).catch(terminalError)
},
},
{
id: "terminal.close",
title: "Close terminal pane",
group: "Session",
palette: true as const,
enabled: rightPane() === "terminal",
run: () => {
dialog.clear()
focusSession()
void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
},
},
{
id: "session.terminal",
title: "New terminal",
group: "Session",
palette: true as const,
slash: { name: "terminal" },
run: async () => {
dialog.clear()
await sessions.newTerminal(props.sessionID).catch(terminalError)
},
},
]
: []),
],
}))
@@ -132,30 +265,38 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
minHeight={0}
flexDirection="row"
position="relative"
onMouseDrag={terminalResize.onMouseDrag}
onMouseDragEnd={finishTerminalResize}
onMouseUp={finishTerminalResize}
onMouseDrag={paneResize.onMouseDrag}
onMouseDragEnd={finishPaneResize}
onMouseUp={finishPaneResize}
>
<box
id="session-pane"
ref={(value: BoxRenderable) => (sessionNode = value)}
flexGrow={1}
flexBasis={0}
minWidth={0}
minHeight={0}
position="relative"
position={fullscreen() ? "absolute" : "relative"}
visible={!fullscreen()}
width={fullscreen() ? Math.max(0, panels.width() - paneResize.size()) : undefined}
height="100%"
onSizeChange={function () {
setSessionWidth(this.width)
}}
>
<Session
scrollRef={(value) => (sessionScroll = value)}
verticalTabsWidth={props.verticalTabsWidth}
promptMuted={terminalFocused()}
sidebarVisible={rightPane() === "sidebar"}
onToggleSidebar={toggleSidebar}
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
width={sessionWidth()}
/>
<Show when={terminalFocused()}>
<InteractivityProvider enabled={activePane() === "session" && !fullscreen()}>
<Session
scrollRef={(value) => (sessionScroll = value)}
verticalTabsWidth={props.verticalTabsWidth}
promptMuted={activePane() !== "session"}
sidebarVisible={rightPane() === "sidebar"}
onToggleSidebar={toggleSidebar}
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
onTerminalPicker={(show) => (showTerminals = show)}
width={sessionWidth()}
/>
</InteractivityProvider>
<Show when={activePane() === "right"}>
<box
position="absolute"
left={0}
@@ -174,35 +315,60 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
}}
// Consume the release before revealing permission buttons underneath.
onMouseUp={() => {
if (terminalResize.resizing() || resizeRelease) return
if (paneResize.resizing() || resizeRelease) return
focusSession()
}}
/>
</Show>
</box>
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
<Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
<box
ref={(value: BoxRenderable) => (rightNode = value)}
flexShrink={0}
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
width={
fullscreen() ? availableWidth() : rightPane() === "sidebar" ? SESSION_SIDEBAR_WIDTH : paneResize.size()
}
minWidth={0}
minHeight={0}
>
<Show
when={rightPane() === "sidebar"}
fallback={
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={terminalResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
<Show
keyed
when={activePanel()}
fallback={
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={paneResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
}}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
</Show>
}
>
{(item) => (
<PanelHost
panel={item}
width={fullscreen() ? availableWidth() : paneResize.size()}
focused={activePane() === "right"}
onFocus={focusRightPane}
onTarget={(node) => {
panelNode = node
if (node) {
focusRightPane()
return
}
setActivePane("session")
}}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
</Show>
@@ -212,12 +378,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</Show>
</box>
</Show>
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
<PaneResizeHandle
resize={terminalResize}
left={availableWidth() - terminalResize.size() - 1}
highlight="right"
/>
<Show when={!fullscreen() && (rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
<PaneResizeHandle resize={paneResize} left={availableWidth() - paneResize.size() - 1} highlight="right" />
</Show>
<Show when={rightPane() === "sidebar" && !wide()}>
<box
@@ -235,3 +397,11 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</box>
)
}
function within(node: Renderable | null | undefined, root: Renderable | undefined) {
if (!root) return false
for (let current = node; current; current = current.parent) {
if (current === root) return true
}
return false
}
+7 -52
View File
@@ -1,13 +1,7 @@
import {
OptimizedBuffer,
RGBA,
TargetChannel,
TextRenderable,
type RenderContext,
type TextOptions,
} from "@opentui/core"
import { RGBA, type OptimizedBuffer, type RenderContext, type TextOptions } from "@opentui/core"
import { extend, type JSX } from "@opentui/solid"
import { splitProps } from "solid-js"
import { MaskedTextRenderable } from "./masked-text"
import { coast, intensityAt } from "./tab-pulse"
type ShimmerTextOptions = TextOptions & {
@@ -15,15 +9,10 @@ type ShimmerTextOptions = TextOptions & {
}
const DURATION = 1200
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
const CONTINUATION = 0xc0000000 | 0
class ShimmerTextRenderable extends TextRenderable {
class ShimmerTextRenderable extends MaskedTextRenderable {
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)
@@ -47,44 +36,10 @@ class ShimmerTextRenderable extends TextRenderable {
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()
this.renderMasked(buffer, 0, (end) => {
const front = -4 + coast(this.elapsed / DURATION) * (end + 22)
return (column) => intensityAt(column, front, 4, 18)
})
}
}
+1 -7
View File
@@ -1,4 +1,4 @@
import { CliRenderEvents, EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
import { EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
import type { ResolvedThemeTokens } from "@opencode-ai/theme/tui"
import { extend, useRenderer } from "@opentui/solid"
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
@@ -28,7 +28,6 @@ export function TerminalPane(props: {
onAutoFocus?: () => void
onFocusRequest?: (focus: (() => void) | undefined) => void
onDisconnect?: () => void
onFocusChange?: (focused: boolean) => void
}) {
const client = useClient()
const keymap = Keymap.use()
@@ -148,9 +147,6 @@ export function TerminalPane(props: {
},
{ priority: 100 },
)
// Blur emits this event before updating the terminal's own focused flag.
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === terminal)
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
createEffect(() => {
if (!props.autoFocus || !terminal) return
terminal.focus()
@@ -172,8 +168,6 @@ export function TerminalPane(props: {
waitingSize?.resolve()
socket?.close()
offKeys()
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
props.onFocusChange?.(false)
props.onFocusRequest?.(undefined)
})
+1 -1
View File
@@ -93,7 +93,7 @@ export const Definitions = {
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
"pane.focus.right": keybind("<leader>right", "Focus right pane"),
"terminal.select": keybind("<leader>down", "Select terminal"),
"terminal.toggle": keybind("<leader>t", "Toggle terminal pane"),
"terminal.close": keybind("<leader>up", "Close terminal pane"),
@@ -0,0 +1,20 @@
import { createContext, createMemo, getOwner, useContext, type Accessor, type ParentProps } from "solid-js"
const Context = createContext<Accessor<boolean>>(() => true)
/** Disabling a subtree also disables every nested interactivity provider. */
export function InteractivityProvider(props: ParentProps<{ enabled: boolean }>) {
const parent = useInteractivity()
const enabled = createMemo(() => parent() && props.enabled)
return <Context.Provider value={enabled}>{props.children}</Context.Provider>
}
/** Shared by keymap consumers and native input/focus handlers. Defaults to enabled. */
export function useInteractivity() {
return useContext(Context)
}
/** Forwarded APIs use the calling component's context, or their captured context outside a Solid owner. */
export function resolveInteractivity(fallback: Accessor<boolean>) {
return getOwner() ? useInteractivity() : fallback
}
+30 -17
View File
@@ -13,9 +13,19 @@ import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid"
import { useRenderer } from "@opentui/solid"
import { createContext, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js"
import {
createComputed,
createContext,
createMemo,
createSignal,
onCleanup,
useContext,
type Accessor,
type ParentProps,
} from "solid-js"
import { useConfig } from "../config"
import { TuiKeybind } from "../config/keybind"
import { resolveInteractivity, useInteractivity } from "./interactivity"
declare module "@opentui/keymap" {
interface Command {
@@ -175,13 +185,17 @@ export interface Keymap {
function use(): Keymap {
const value = useValue()
const enabled = useInteractivity()
const leader = value.config.keybinds.get("leader")?.[0]?.key
const isLeader = leader ? value.keymap.createKeyMatcher(leader) : () => false
return {
dispatch(id, input) {
value.dispatch(id, input)
},
mode: value.mode,
mode: {
current: value.mode.current,
push: (mode) => value.mode.push(mode, resolveInteractivity(enabled)),
},
intercept: value.keymap.intercept.bind(value.keymap),
isLeader,
}
@@ -189,6 +203,7 @@ function use(): Keymap {
function createLayer(input: () => KeymapLayer) {
const value = useValue()
const enabled = useInteractivity()
useBindings(() => {
const layer = input()
const { commands, bindings, mode, ...options } = layer
@@ -215,6 +230,7 @@ function createLayer(input: () => KeymapLayer) {
)
return {
...options,
enabled: enabled() ? options.enabled : false,
...(mode === "global" ? {} : { mode: mode ?? MODE.base }),
commands: grouped.named.map((command) => {
const { id, description, group, palette, bind, run, ...definition } = command
@@ -397,37 +413,34 @@ export const Keymap = {
} as const
function createMode(keymap: OpenTuiKeymap) {
keymap.setData(MODE.key, MODE.base)
const [stack, setStack] = createSignal<
{ readonly id: symbol; readonly mode: string; readonly enabled: Accessor<boolean> }[]
>([])
const current = createMemo(() => stack().findLast((item) => item.enabled())?.mode ?? MODE.base)
// Publish mode changes before another command can be dispatched in the same callback.
createComputed(() => keymap.setData(MODE.key, current()))
const unregister = keymap.registerLayerFields({
mode(value, context) {
context.require(MODE.key, value)
},
})
const stack: { readonly id: symbol; readonly mode: string }[] = []
let disposed = false
const update = () => keymap.setData(MODE.key, stack.at(-1)?.mode ?? MODE.base)
return {
current() {
return stack.at(-1)?.mode ?? MODE.base
},
push(mode: string) {
current,
push(mode: string, enabled: Accessor<boolean>) {
if (disposed) return () => {}
const id = Symbol(mode)
stack.push({ id, mode })
update()
// Inactive scopes retain their stack position beneath any newer modes.
setStack((items) => [...items, { id, mode, enabled }])
return () => {
const index = stack.findIndex((item) => item.id === id)
if (index < 0) return
stack.splice(index, 1)
update()
setStack((items) => items.filter((item) => item.id !== id))
}
},
dispose() {
if (disposed) return
disposed = true
stack.length = 0
setStack([])
unregister()
keymap.setData(MODE.key, undefined)
},
+58
View File
@@ -0,0 +1,58 @@
import type { PanelPresentation } from "@opencode-ai/plugin/tui/context"
import { batch, createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js"
export type PanelTarget = {
readonly plugin: string
readonly name: string
readonly sessionID: string
}
export function createPanelState() {
const [current, setCurrent] = createSignal<PanelTarget>()
const [requested, setRequested] = createSignal<PanelPresentation>("panel")
const [width, setWidth] = createSignal(0)
const canSplit = () => width() > 80
const presentation = createMemo(() => (canSplit() ? requested() : "fullscreen"))
return {
current,
width,
canSplit,
presentation,
setWidth,
open(target: PanelTarget, presentation: PanelPresentation = "panel") {
batch(() => {
setRequested(presentation)
setCurrent((current) =>
current?.plugin === target.plugin && current.name === target.name && current.sessionID === target.sessionID
? current
: target,
)
})
},
close: () => setCurrent(),
release(plugin: string) {
if (current()?.plugin !== plugin) return
setCurrent()
},
toggleFullscreen() {
if (!canSplit()) return
setRequested((current) => (current === "panel" ? "fullscreen" : "panel"))
},
}
}
const Context = createContext<ReturnType<typeof createPanelState>>()
export function PanelProvider(props: ParentProps) {
return <Context.Provider value={createPanelState()}>{props.children}</Context.Provider>
}
export function usePanel() {
const value = useContext(Context)
if (!value) throw new Error("usePanel must be used within a PanelProvider")
return value
}
export function useOptionalPanel() {
return useContext(Context)
}
+8 -5
View File
@@ -23,7 +23,7 @@ import {
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/component"
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
@@ -379,12 +379,15 @@ export function useTheme(context?: ContextName) {
}
export const ThemeProvider = themeContext.provider
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
/** Switches context without remounting children; undefined inherits the enclosing view. */
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) {
const value = themeContext.use()
const current = createComponentThemeView(() => {
const name = props.context
return name ? value.themes.currentTokens().contextual[name] : value.current
}, value.themes.mode)
return (
<themeContext.context.Provider
value={{ current: value.themes.current.contextual[props.context], themes: value.themes, ready: value.ready }}
>
<themeContext.context.Provider value={{ current, themes: value.themes, ready: value.ready }}>
{props.children}
</themeContext.context.Provider>
)
@@ -0,0 +1,151 @@
import { createSignal, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
import { useStorage } from "./storage"
import { useEvent } from "./event"
import { errorMessage } from "../util/error"
import { useExit } from "./exit"
import { useDialog } from "../ui/dialog"
import { DialogUpdate } from "../component/dialog-update"
type ClientNotice = { readonly type: "available" | "installed"; readonly version: string }
type Notice = ClientNotice & ({ readonly source: "client" } | { readonly source: "server"; readonly remote: boolean })
export type UpdateState =
| ClientNotice
| { readonly type: "installing"; readonly version: string }
| { readonly type: "failed"; readonly message: string }
export type UpdateSource = {
readonly remote: boolean
readonly subscribe: (notify: (notice: ClientNotice) => void, signal: AbortSignal) => Promise<void>
readonly check: (
signal: AbortSignal,
) => Promise<ClientNotice | { readonly type: "unavailable"; readonly message: string } | undefined>
readonly apply: (version: string) => Promise<void>
}
export const { use: useUpdateNotification, provider: UpdateNotificationProvider } = createSimpleContext({
name: "UpdateNotification",
init: (props: { updater?: UpdateSource }) => {
const event = useEvent()
const exit = useExit()
const dialog = useDialog()
const log = useLog({ component: "update-notification" })
const [state, setState] = createSignal<UpdateState>()
const [notification, setNotification] = createSignal<Notice>()
const [notifications, markNotification] = useStorage().store<{ versions: string[] }>("update-notifications", {
initial: { versions: [] },
})
const notify = (notice: Notice) => {
if (!props.updater) return
if (
notifications.versions.includes(`${notice.source}:${notice.version}`) ||
(notice.source === "client" && notifications.versions.includes(notice.version))
)
return
setNotification((current) => {
if (notice.source === "server" && current?.source === "client") return current
return notice
})
}
const dismiss = () => {
const current = notification()
if (!current) return
setNotification(undefined)
// Only interactions with the automatic notification update its history.
void markNotification((draft) => {
draft.versions = [...draft.versions, `${current.source}:${current.version}`].slice(-100)
}).catch((error) => log.error("failed to persist update notification", { error }))
}
const install = async () => {
const updater = props.updater
const current = state()
if (!updater || !current || current.type !== "available") return
setState({ type: "installing", version: current.version })
await updater.apply(current.version).then(
() => setState({ type: "installed", version: current.version }),
(error) => setState({ type: "failed", message: errorMessage(error) }),
)
}
const check = async (signal: AbortSignal) => {
const updater = props.updater
if (!updater || state()?.type === "installing") return
const result = await updater.check(signal)
if (signal.aborted) return
if (result?.type === "unavailable") return result.message
setState(result)
}
const restart = () => {
const current = state()
if (current?.type !== "installed") return
exit()
}
const open = (origin: "manual" | "notification") => {
if (!props.updater) return
const current = notification()
const known = current && (current.source === "client" || !current.remote) ? current : undefined
if (origin === "notification" && !known) return
const active = state()
// The notification can predate an installation through /update.
if (known && active?.type !== "installing" && !(active?.type === "installed" && active.version === known.version))
setState({ type: known.type, version: known.version })
// Manual checks hide the current notice without marking the version as seen.
if (origin === "manual") setNotification(undefined)
if (origin === "notification") dismiss()
const status = state()?.type
dialog.replace(() => (
<DialogUpdate
check={status === undefined || status === "failed" ? check : undefined}
state={state}
install={install}
restart={restart}
/>
))
}
onMount(() => {
const updater = props.updater
if (!updater) return
const controller = new AbortController()
onCleanup(() => controller.abort())
void updater
.subscribe((notice) => notify({ ...notice, source: "client" }), controller.signal)
.catch((error) => {
if (!controller.signal.aborted) log.error("update check failed", { error })
})
})
onCleanup(
event.on("installation.update-available", (event) =>
notify({
source: "server",
remote: props.updater?.remote ?? false,
type: "available",
version: event.data.version,
}),
),
)
onCleanup(
event.on("installation.updated", (event) =>
notify({
source: "server",
remote: props.updater?.remote ?? false,
type: "installed",
version: event.data.version,
}),
),
)
return {
notification,
dismiss,
open: props.updater ? open : undefined,
}
},
})
@@ -0,0 +1,87 @@
/** @jsxImportSource @opentui/solid */
import type { Plugin } from "@opencode-ai/plugin/tui"
import { BoxRenderable, MouseButton } from "@opentui/core"
import { Portal, useTerminalDimensions } from "@opentui/solid"
import { createSignal, onCleanup } from "solid-js"
export function DiffFileMenu(props: {
context: Plugin.Context
state: { fileIndex: number; x: number; y: number }
reviewed: boolean
onToggle: () => void
onClose: () => void
}) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.overlay
const [hovered, setHovered] = createSignal(false)
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
const width = () => Math.min(19, dimensions().width)
const run = () => {
props.onClose()
props.onToggle()
}
onCleanup(props.context.keymap.mode.push("menu"))
props.context.keymap.layer(() => ({
mode: "menu",
commands: [
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
{ bind: "return", title: label(), group: "Diff", run },
],
}))
return (
<Portal
ref={(container) => {
if (!(container instanceof BoxRenderable)) return
// Portal's wrapper must also escape root flow, not follow the full-height app.
container.position = "absolute"
container.left = 0
container.top = 0
container.zIndex = 2600
}}
>
<box
id="diff-file-menu-overlay"
position="absolute"
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
zIndex={2600}
onMouseDown={(event) => {
props.onClose()
event.preventDefault()
event.stopPropagation()
}}
>
<box
id="diff-file-menu"
position="absolute"
left={Math.max(0, Math.min(props.state.x, dimensions().width - width()))}
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
width={width()}
height={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
if (event.button === MouseButton.RIGHT) props.onClose()
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
if (event.button === MouseButton.LEFT) run()
}}
>
<text fg={theme.text.default} selectable={false} wrapMode="none" truncate>
{label()}
</text>
</box>
</box>
</Portal>
)
}
@@ -14,6 +14,7 @@ import { filetype } from "../../util/filetype"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { DiffFileMenu } from "./diff-viewer-file-menu"
import { DiffViewerImage, isDiffImageFile } from "./diff-viewer-image"
import { DialogSelect } from "../../ui/dialog-select"
import { EmptyBorder } from "../../ui/border"
@@ -1076,76 +1077,6 @@ export function DiffViewerContent(props: {
)
}
function DiffFileMenu(props: {
context: Plugin.Context
state: FileMenuState
reviewed: boolean
onToggle: () => void
onClose: () => void
}) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.overlay
const [hovered, setHovered] = createSignal(false)
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
const run = () => {
props.onClose()
props.onToggle()
}
onCleanup(props.context.keymap.mode.push("menu"))
props.context.keymap.layer(() => ({
mode: "menu",
commands: [
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
{ bind: "return", title: label(), group: "Diff", run },
],
}))
return (
<box
id="diff-file-menu-overlay"
position="absolute"
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
zIndex={2600}
onMouseDown={(event) => {
props.onClose()
event.preventDefault()
event.stopPropagation()
}}
>
<box
id="diff-file-menu"
position="absolute"
left={Math.max(0, Math.min(props.state.x, dimensions().width - 19))}
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
width={19}
height={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
if (event.button === MouseButton.RIGHT) props.onClose()
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
if (event.button === MouseButton.LEFT) run()
}}
>
<text fg={theme.text.default} selectable={false}>
{label()}
</text>
</box>
</box>
)
}
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
+18
View File
@@ -20,6 +20,7 @@ import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { useStorage } from "../context/storage"
import { useSessionTabs } from "../context/session-tabs"
import { useOptionalPanel } from "../context/panel"
import { abbreviateHome } from "../util/path-format"
export type Dispose = () => Promise<void>
@@ -68,6 +69,7 @@ export function usePluginHost() {
attention: useAttention(),
storage: useStorage(),
sessionTabs: useSessionTabs(),
panel: useOptionalPanel(),
}
}
@@ -82,6 +84,7 @@ export function createPluginContext(input: {
registry: Registry
}): Context {
const host = input.host
input.owned.push(async () => host.panel?.release(input.id))
let context: Context
let claims = 0
// Every dialog and registered render is wrapped so plugin components can
@@ -174,6 +177,21 @@ export function createPluginContext(input: {
return host.route.data
},
},
panel: {
open(name, options) {
if (!host.panel || !input.registry.active()) return false
const route = host.route.data
if (route.type !== "session") return false
host.panel.open({ plugin: input.id, name, sessionID: route.sessionID }, options?.presentation)
return true
},
close: () => host.panel?.release(input.id),
current() {
const current = host.panel?.current()
if (current?.plugin !== input.id) return
return { name: current.name, sessionID: current.sessionID }
},
},
tabs: {
enabled: host.sessionTabs.enabled,
list: () =>
+96 -3
View File
@@ -1,5 +1,5 @@
import { Prompt, type PromptRef } from "../component/prompt"
import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "solid-js"
import { createEffect, createMemo, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
import { Logo } from "../component/logo"
import { useArgs } from "../context/args"
import { useRouteData } from "../context/route"
@@ -11,6 +11,11 @@ import { useLocation } from "../context/location"
import { FormPrompt } from "./session/form"
import { Slot } from "../plugin/render"
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes, type RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useUpdateNotification } from "../context/update-notification"
import { FadeInText } from "../component/fade-in-text"
import { stringWidth } from "../util/string-width"
let once = false
const placeholder = {
@@ -81,13 +86,16 @@ export function Home() {
paddingRight={dimensions().width < 44 ? 1 : 2}
>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box height={3} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<Logo />
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0} position="relative">
<Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
<box position="absolute" top="100%" left={0} right={0} alignItems="center">
<UpdateNotification />
</box>
</box>
<box flexGrow={1} minHeight={0} />
</box>
@@ -109,3 +117,88 @@ export function Home() {
</>
)
}
function UpdateNotification() {
const update = useUpdateNotification()
const theme = useTheme()
const remoteMessage = "A remote server cannot be updated from here. Updating it is recommended."
const [hovered, setHovered] = createSignal<"primary" | "close">()
createEffect(() => {
update.notification()
setHovered(undefined)
})
return (
<Show when={update.notification()} keyed>
{(state) => (
<box flexShrink={0} marginTop={4} alignItems="center">
<Switch>
<Match when={state.source === "client" || !state.remote}>
<box
alignItems="center"
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() === "primary" ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setHovered("primary")}
onMouseOut={() => setHovered(undefined)}
onMouseUp={() => update.open?.("notification")}
>
<UpdateMessage
title="Update available"
description={`Version ${state.version} is available. Click for more details`}
backdrop={
hovered() === "primary" ? theme.background.action.primary.hovered : theme.background.default
}
/>
</box>
</Match>
<Match when={state.type === "available" && state.source === "server" && state.remote}>
<box alignItems="center">
<UpdateMessage
title="Server update available"
description={remoteMessage}
backdrop={theme.background.default}
/>
<FadeInText
fg={theme.text.subdued}
backdrop={hovered() === "close" ? theme.background.action.primary.hovered : theme.background.default}
sweepWidth={stringWidth(remoteMessage)}
sweepOffset={Math.floor((stringWidth(remoteMessage) - stringWidth("Close")) / 2)}
marginTop={1}
paddingLeft={1}
paddingRight={1}
bg={hovered() === "close" ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setHovered("close")}
onMouseOut={() => setHovered(undefined)}
onMouseUp={update.dismiss}
>
Close
</FadeInText>
</box>
</Match>
</Switch>
</box>
)}
</Show>
)
}
function UpdateMessage(props: { title: string; description: string; backdrop: RGBA }) {
const theme = useTheme()
const titleWidth = stringWidth(props.title)
const descriptionWidth = stringWidth(props.description)
const width = Math.max(titleWidth, descriptionWidth)
return (
<FadeInText width={width} height={2} wrapMode="none" fg={theme.text.default} backdrop={props.backdrop}>
<span style={{ fg: theme.text.action.primary.selected, attributes: TextAttributes.BOLD }}>
{" ".repeat(Math.floor((width - titleWidth) / 2))}
{props.title}
</span>
{"\n"}
<span style={{ fg: theme.text.subdued }}>
{" ".repeat(Math.floor((width - descriptionWidth) / 2))}
{props.description}
</span>
</FadeInText>
)
}
+24 -5
View File
@@ -17,6 +17,7 @@ import { useClipboard } from "../../context/clipboard"
import { SplitBorder } from "../../ui/border"
import { useToast } from "../../ui/toast"
import { Keymap } from "../../context/keymap"
import { useInteractivity } from "../../context/interactivity"
import { useConfig } from "../../config"
import { errorMessage } from "../../util/error"
import {
@@ -48,6 +49,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const keymap = Keymap.use()
const enabled = useInteractivity()
const active = () => enabled() && keymap.mode.current() === FORM_MODE
const config = useConfig().data
const clipboard = useClipboard()
const toast = useToast()
@@ -68,6 +71,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
})
let textarea: TextareaRenderable | undefined
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable>()
let review: ScrollBoxRenderable | undefined
let measureReview: (() => void) | undefined
@@ -216,9 +220,22 @@ export function FormPrompt(props: { form: FormWithLocation }) {
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
})
// Refs publish after initialization so burst typing stays with the interceptor until the editor is ready.
createEffect(() => {
const target = inputTarget()
if (!target || target.isDestroyed) return
if (!active()) {
target.blur()
target.focusable = false
return
}
target.focusable = true
target.focus()
})
onCleanup(
keymap.intercept("key", ({ event, consume }) => {
if (keymap.mode.current() !== FORM_MODE) return
if (!active()) return
if (textual() || !other() || (store.editing && renderer.currentFocusedEditor === textarea)) return
if (event.ctrl || event.meta || event.option || event.super || event.hyper) return
if ((!store.editing && event.sequence === " ") || !/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)) return
@@ -328,7 +345,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}
usePaste((event) => {
if (keymap.mode.current() !== FORM_MODE) return
if (!active()) return
const value = stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n")
if (store.editing && renderer.currentFocusedEditor === textarea) {
textarea.insertText(value)
@@ -343,7 +360,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return clipboard
.read()
.then((content) => {
if (content?.mime !== "text/plain") return
if (!active() || content?.mime !== "text/plain") return
const value = stripAnsiSequences(content.data).replace(/\r\n?/g, "\n")
if (store.editing || textual()) {
textarea?.insertText(value)
@@ -878,8 +895,9 @@ export function FormPrompt(props: { form: FormWithLocation }) {
textarea = val
val.traits = { status: "ANSWER" }
queueMicrotask(() => {
val.focus()
if (val.isDestroyed) return
val.gotoLineEnd()
setInputTarget(val)
})
}}
initialValue={
@@ -1017,9 +1035,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
textarea = val
val.traits = { status: "ANSWER" }
queueMicrotask(() => {
if (val.isDestroyed) return
val.setText(input())
val.focus()
val.gotoLineEnd()
setInputTarget(val)
})
}}
initialValue={input()}
+31 -79
View File
@@ -113,7 +113,6 @@ import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { useSessionTerminals } from "../../context/session-terminals"
addDefaultParsers(parsers.parsers)
@@ -161,6 +160,7 @@ export function Session(props: {
sidebarVisible: boolean
onToggleSidebar: () => void
visibleTerminalID?: string
onTerminalPicker?: (show: (() => void) | undefined) => void
width?: number
}) {
const setEpilogue = useEpilogue()
@@ -234,6 +234,8 @@ export function Session(props: {
open: false,
tab: undefined as string | undefined,
})
props.onTerminalPicker?.(() => setComposer({ open: true, tab: "terminals" }))
onCleanup(() => props.onTerminalPicker?.(undefined))
createEffect(() => {
if (props.promptMuted && composer.open) setComposer("open", false)
})
@@ -260,7 +262,6 @@ export function Session(props: {
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const toast = useToast()
const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" })
const client = useClient()
const autoApproved = new Set<string>()
createEffect(() => {
@@ -295,7 +296,6 @@ export function Session(props: {
const [firstJump, setFirstJump] = createSignal<() => void>()
const [synced, setSynced] = createSignal(false)
const sessionTabs = useSessionTabs()
const terminals = useSessionTerminals()
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const [latestHovered, setLatestHovered] = createSignal(false)
let ensureAllRowsPending: (() => void)[] | undefined
@@ -949,13 +949,21 @@ export function Session(props: {
dialog.clear()
return
}
void client.api.session.revert
.stage({ sessionID: route.sessionID, messageID: message.id })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
prompt()?.set({
...projectedPromptInput(message),
pasted: [],
})
const sessionID = route.sessionID
const target = prompt()
void (async () => {
if (pendingDeliveries().has(message.id)) {
if (!(await mutatePending("cancel", message.id))) return
} else {
await client.api.session.interrupt({ sessionID })
await client.api.session.wait({ sessionID })
await client.api.session.revert.stage({ sessionID, messageID: message.id })
}
target?.set({
...projectedPromptInput(message),
pasted: [],
})
})().catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
dialog.clear()
},
},
@@ -976,73 +984,6 @@ export function Session(props: {
})()
},
},
{
title: props.sidebarVisible ? "Hide sidebar" : "Show sidebar",
id: "session.sidebar.toggle",
group: "Session",
run: () => {
props.onToggleSidebar()
dialog.clear()
},
},
...(config.session.terminal
? [
{
title: props.visibleTerminalID ? "Hide terminal pane" : "Show terminal pane",
id: "terminal.toggle",
group: "Session",
run: () => {
const sessionID = route.sessionID
if (props.visibleTerminalID) {
promptRef.current?.focus()
void terminals.selectTerminal(sessionID, null).catch(toast.error)
} else {
void terminals
.refresh(sessionID)
.then(async () => {
const terminal = terminals.get(sessionID).terminals.at(-1)
if (terminal) return terminals.selectTerminal(sessionID, terminal.id)
await terminals.newTerminal(sessionID)
})
.catch(terminalError)
}
dialog.clear()
},
},
{
title: "Select terminal",
id: "terminal.select",
group: "Session",
run: () => {
promptRef.current?.focus()
setComposer({ open: true, tab: "terminals" })
void terminals.refresh(route.sessionID).catch(terminalError)
dialog.clear()
},
},
{
title: "Close terminal pane",
id: "terminal.close",
group: "Session",
enabled: props.visibleTerminalID !== undefined,
run: () => {
promptRef.current?.focus()
void terminals.selectTerminal(route.sessionID, null).catch(toast.error)
dialog.clear()
},
},
{
title: "New terminal",
id: "session.terminal",
group: "Session",
slash: { name: "terminal" },
run: async () => {
dialog.clear()
await terminals.newTerminal(route.sessionID).catch(terminalError)
},
},
]
: []),
{
title: (() => {
const next = nextThinkingMode(thinkingMode())
@@ -1507,7 +1448,6 @@ export function Session(props: {
<Prompt
visible={true}
ref={bind}
disabled={false}
muted={props.promptMuted}
onSubmit={() => {
toBottom()
@@ -2522,12 +2462,24 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme()
const [seconds, setSeconds] = createSignal(0)
createEffect(() => {
const at = props.retry?.at
if (at === undefined) return
const update = () => setSeconds(Math.max(0, Math.ceil((at - Date.now()) / 1_000)))
if (update() === 0) return
const timer = setInterval(() => {
if (update() === 0) clearInterval(timer)
}, 1_000)
onCleanup(() => clearInterval(timer))
})
return (
<Show when={props.retry}>
{(retry) => (
<box paddingLeft={3}>
<text fg={theme.text.feedback.warning.default}>
Retry attempt {retry().attempt} scheduled: {retry().error.message}
{seconds() > 0 ? `Retrying in ${seconds()}s` : "Retry due"} · attempt {retry().attempt} ·{" "}
{retry().error.message}
</text>
</box>
)}
+17 -28
View File
@@ -11,12 +11,13 @@ import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation }
import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config"
import { Keymap } from "../../context/keymap"
import { useInteractivity } from "../../context/interactivity"
import { usePathFormatter } from "../../context/path-format"
import { SimulationSemantics } from "../../simulation/semantics"
import { PatchDiff } from "../../component/patch-diff"
import { useToast } from "../../ui/toast"
type PermissionStage = "permission" | "always" | "reject"
type PermissionStage = "permission" | "reject"
function EditBody(props: { file?: string; diff?: string; patch?: string }) {
const theme = useTheme()
@@ -140,27 +141,6 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
return (
<Switch>
<Match when={store.stage === "always"}>
<SessionQuestion
title="Always allow"
semanticLabel={`Always allow ${props.request.action}`}
instance={props.request.id}
body={
<box paddingLeft={1} gap={1}>
<For each={permissionAlwaysLines(props.request)}>
{(line, index) => <text fg={index() === 0 ? theme.text.subdued : theme.text.default}>{line}</text>}
</For>
</box>
}
options={{ confirm: permissionOptionLabel("confirm"), cancel: permissionOptionLabel("cancel") }}
escapeKey="cancel"
onSelect={(option) => {
setStore("stage", "permission")
if (option === "cancel") return
reply("always")
}}
/>
</Match>
<Match when={store.stage === "reject"}>
<RejectPrompt
action={props.request.action}
@@ -185,7 +165,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
},
pathFormatter.format,
)
const presentationBody =
const presentationBody = () =>
props.request.action === "edit" ? (
<EditBody file={current.file} diff={current.diff} patch={current.patch} />
) : props.request.action === "external_directory" ? (
@@ -240,7 +220,15 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
instance={props.request.id}
header={header()}
body={presentationBody}
body={(option) => (
<Show when={option === "always"} fallback={presentationBody()}>
<box paddingLeft={1} gap={1}>
<For each={permissionAlwaysLines(props.request)}>
{(line, index) => <text fg={index() === 0 ? theme.text.subdued : theme.text.default}>{line}</text>}
</For>
</box>
</Show>
)}
options={
props.request.save?.length
? {
@@ -254,7 +242,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
fullscreen
onSelect={(option) => {
if (option === "always") {
setStore("stage", "always")
reply("always")
return
}
if (option === "reject") {
@@ -288,6 +276,7 @@ function RejectPrompt(props: {
onCancel: () => void
}) {
let input: TextareaRenderable
const enabled = useInteractivity()
const theme = useTheme("elevated")
const config = useConfig().data
const dimensions = useTerminalDimensions()
@@ -364,7 +353,7 @@ function RejectPrompt(props: {
}))(val)
val.traits = { status: "REJECT" }
}}
focused
focused={enabled()}
textColor={theme.text.default}
focusedTextColor={theme.text.default}
cursorColor={theme.text.default}
@@ -423,7 +412,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
group?: string
choicesLabel?: string
header?: JSX.Element
body: JSX.Element
body: JSX.Element | ((option: keyof T) => JSX.Element)
options: T
escapeKey?: keyof T
fullscreen?: boolean
@@ -547,7 +536,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
{props.header}
</box>
</Show>
{props.body}
{typeof props.body === "function" ? props.body(store.selected) : props.body}
</box>
<box
flexDirection={narrow() ? "column" : "row"}
+11 -9
View File
@@ -3,7 +3,16 @@ import type { Accessor } from "solid-js"
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode-ai/theme/tui"
export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
const create = (view: Accessor<ResolvedThemeTokens>) => ({
return Object.assign(createComponentThemeView(current, mode), {
contextual: {
elevated: createComponentThemeView(() => current().contextual.elevated, mode),
overlay: createComponentThemeView(() => current().contextual.overlay, mode),
},
})
}
export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mode: Accessor<Mode>) {
return {
get hue() {
return view().hue
},
@@ -35,14 +44,7 @@ export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Acc
increase: (color: RGBA, amount = 1) => view().increase(color, amount),
decrease: (color: RGBA, amount = 1) => view().decrease(color, amount),
raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)),
})
return Object.assign(create(current), {
contextual: {
elevated: create(() => current().contextual.elevated),
overlay: create(() => current().contextual.overlay),
},
})
}
}
export type ComponentTheme = ReturnType<typeof createComponentTheme>
+1 -1
View File
@@ -14,7 +14,7 @@ export function clampSessionTabsWidth(width: number, total: number) {
)
}
export function clampTerminalPaneWidth(width: number, total: number) {
export function clampSessionPaneWidth(width: number, total: number) {
const half = Math.max(1, Math.floor(total / 2))
// Preserve the equal split when there is not enough room for both pane minima.
return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH)))
+106 -1
View File
@@ -1228,7 +1228,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
})
test.each(["manual", "select"] as const)(
"selection copy and dismissal respect %s mode in the prompt and terminal pane",
"selection copy and pane management respect %s mode in the prompt and terminal pane",
async (copy) => {
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
@@ -1359,6 +1359,19 @@ test.each(["manual", "select"] as const)(
expect(setup.renderer.hasSelection).toBeFalse()
expect(setup.renderer.isDestroyed).toBeFalse()
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressArrow("up")
await setup.waitFor(() => terminal.isDestroyed)
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("")
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressKey("t")
await setup.waitForFrame((frame) => frame.includes("alpha beta gamma"))
expect(setup.renderer.currentFocusedRenderable).toBeInstanceOf(EmbeddedTerminalRenderable)
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressArrow("down")
await setup.waitForFrame((frame) => frame.includes("Subagents") && frame.includes("Terminals"))
expect(setup.renderer.currentFocusedRenderable).not.toBeInstanceOf(EmbeddedTerminalRenderable)
setup.renderer.destroy()
await task
} finally {
@@ -1529,3 +1542,95 @@ test("server plugin failures share one notice and use source names before an ID
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
expect(setup.captureCharFrame()).toContain("Open plugins")
})
test.each([44, 100])(
"retry countdown updates and clears with the retry lifecycle at width %s",
async (width) => {
await using state = await tmpdir()
const session = {
id: "ses_countdown",
projectID: "proj_test",
location: { directory },
title: "Retry countdown",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
const model = { id: "model", providerID: "provider" }
const error = { type: "provider.transport" as const, message: "Provider unavailable" }
await using setup = await createAppFixture({
width,
state: state.path,
args: { sessionID: session.id },
config: { animations: false, tabs: { enabled: false } },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{
id: "msg_countdown",
type: "assistant",
agent: "build",
model,
content: [],
error,
retry: { attempt: 2, at: Date.now() + 2_500, error },
time: { created: 1 },
},
],
cursor: {},
})
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
return json({ data: [] })
return undefined
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Retrying in 3s"))
expect(setup.captureCharFrame()).toContain("attempt 2")
expect(setup.captureCharFrame()).toContain("Provider unavailable")
expect(setup.captureCharFrame()).not.toContain("Error:")
await setup.waitForFrame((frame) => frame.includes("Retrying in 2s"), { maxPasses: 200 })
await setup.waitForFrame((frame) => frame.includes("Retrying in 1s"), { maxPasses: 200 })
await setup.waitForFrame((frame) => frame.includes("Retry due"), { maxPasses: 200 })
expect(setup.captureCharFrame()).not.toContain("in 0s")
setup.events.emit({
id: "evt_countdown_rescheduled",
created: 2,
type: "session.retry.scheduled",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 3, at: Date.now() + 10_500, error },
})
await setup.waitForFrame((frame) => frame.includes("Retrying in 11s") && frame.includes("attempt 3"))
setup.events.emit({
id: "evt_countdown_started",
created: 3,
type: "session.step.started",
durable: { aggregateID: session.id, seq: 2, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", agent: "build", model },
})
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
setup.events.emit({
id: "evt_countdown_expired",
created: 4,
type: "session.retry.scheduled",
durable: { aggregateID: session.id, seq: 3, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 4, at: Date.now() - 1_000, error },
})
await setup.waitForFrame((frame) => frame.includes("Retry due") && frame.includes("attempt 4"))
expect(setup.captureCharFrame()).not.toContain("in -")
setup.events.emit({
id: "evt_countdown_interrupted",
created: 5,
type: "session.execution.interrupted",
durable: { aggregateID: session.id, seq: 4, version: 1 },
data: { sessionID: session.id, reason: "shutdown" },
})
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
},
15_000,
)
+83
View File
@@ -1579,6 +1579,89 @@ test("tracks session status from active sessions and execution events", async ()
}
})
test.each(["before", "between", "after"])("shows compaction admitted %s steers in execution order", async (order) => {
const events = createEventStream()
const sessionID = "session-compaction-priority"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
return undefined
}, events)
let rows: SessionRow[] = []
let client: ReturnType<typeof useClient> | undefined
function Probe() {
client = useClient()
rows = createSessionRows(() => sessionID)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
const admissions =
order === "before" ? ["compact", "a", "b"] : order === "between" ? ["a", "compact", "b"] : ["a", "b", "compact"]
try {
await wait(() => client?.connection.status() === "connected")
admissions.forEach((id, index) =>
emitEvent(events, {
id: `evt_admit_${id}`,
created: index + 1,
type: "session.inbox.enqueued",
durable: durable(sessionID, index + 1),
data: {
sessionID,
inboxID: id,
item:
id === "compact"
? { type: "compaction", payload: {}, delivery: "steer" }
: { type: "user", payload: { text: `STEER_${id.toUpperCase()}` }, delivery: "steer" },
},
}),
)
await wait(() => rows.length === 3)
expect(rows).toEqual([
{ type: "compaction-queued", inboxID: "compact" },
{ type: "message", messageID: "a" },
{ type: "message", messageID: "b" },
])
emitEvent(events, {
id: "evt_compaction_started",
created: 4,
type: "session.compaction.started",
durable: durable(sessionID, 4),
data: { sessionID, reason: "manual", recent: "", inputID: "compact" },
})
await wait(() => rows[0]?.type === "message")
expect(rows).toEqual(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
emitEvent(events, {
id: "evt_compaction_ended",
created: 5,
type: "session.compaction.ended",
durable: durable(sessionID, 5),
data: { sessionID, reason: "manual", text: "## Objective\n- Checkpoint", recent: "" },
})
for (const [index, id] of ["a", "b"].entries()) {
emitEvent(events, {
id: `evt_deliver_${id}`,
created: index + 6,
type: "session.inbox.delivered",
durable: durable(sessionID, index + 6),
data: { sessionID, inboxID: id },
})
}
await app.renderOnce()
expect(rows).toEqual(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
} finally {
app.renderer.destroy()
}
})
test("restores queued compaction from durable pending input", async () => {
const events = createEventStream()
const sessionID = "session-compaction-queued"
@@ -0,0 +1,279 @@
/** @jsxImportSource @opentui/solid */
import type { PermissionRequest } from "@opencode-ai/client"
import type { TextareaRenderable } from "@opentui/core"
import { testRender, type JSX } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal, onMount } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData, type FormWithLocation } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { InteractivityProvider } from "../../../src/context/interactivity"
import { LocationProvider } from "../../../src/context/location"
import { ThemeProvider } from "../../../src/context/theme"
import { FormPrompt, FORM_MODE } from "../../../src/routes/session/form"
import { PermissionPrompt } from "../../../src/routes/session/permission"
import { ToastProvider } from "../../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
async function mountPanes(root: string, render: () => JSX.Element, parentID?: string) {
const [active, setActive] = createSignal(false)
const replies: unknown[] = []
const cancellations: string[] = []
const submissions: string[] = []
const ready = Promise.withResolvers<void>()
let peer!: TextareaRenderable
let keymap!: Keymap
const transport = createFetch((url, request) => {
if (url.pathname === "/api/session/ses_scoped")
return json({
data: {
id: "ses_scoped",
parentID,
title: "Scoped session",
projectID: "proj_test",
location: { directory: root },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
if (url.pathname.endsWith("/reply"))
return request.json().then((body) => {
replies.push(body)
return new Response(null, { status: 204 })
})
if (url.pathname.endsWith("/cancel")) {
cancellations.push(url.pathname)
return new Response(null, { status: 204 })
}
}, createEventStream())
function Panes() {
const data = useData()
keymap = Keymap.use()
onMount(() => void data.session.sync("ses_scoped").then(ready.resolve, ready.reject))
return (
<box>
<InteractivityProvider enabled={!active()}>
<textarea
ref={(value) => (peer = value)}
focused={!active()}
initialValue="peer"
onSubmit={() => submissions.push(peer.plainText)}
/>
</InteractivityProvider>
<InteractivityProvider enabled={active()}>{render()}</InteractivityProvider>
</box>
)
}
const app = await testRender(
() => (
<TestTuiContexts directory={root} paths={{ home: root, state: root, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig({ animations: false })}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<DataProvider directory={root}>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<Panes />
</ToastProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 90, height: 24, kittyKeyboard: true },
)
app.renderer.start()
await ready.promise
await app.renderOnce()
return { app, setActive, replies, cancellations, submissions, peer, keymap }
}
function form(fields: FormWithLocation["fields"]): FormWithLocation {
return { id: "frm_scoped", sessionID: "ses_scoped", title: "Scoped form", fields }
}
const request = {
id: "per_scoped",
sessionID: "ses_scoped",
action: "shell",
resources: ["echo scoped"],
} satisfies PermissionRequest
test("an inactive form leaves Enter, navigation, and paste with the focused peer", async () => {
await using tmp = await tmpdir()
const panes = await mountPanes(tmp.path, () => (
<FormPrompt
form={form([
{
key: "target",
type: "string",
options: [
{ value: "staging", label: "Staging" },
{ value: "production", label: "Production" },
],
},
])}
/>
))
try {
expect(panes.keymap.mode.current()).toBe("base")
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
panes.app.mockInput.pressEnter()
panes.app.mockInput.pressArrow("down")
panes.app.mockInput.pressKey("2")
panes.app.mockInput.pressEscape()
await panes.app.mockInput.pasteBracketedText(" pasted")
expect(panes.submissions).toEqual(["peer"])
expect(panes.peer.plainText).toContain("pasted")
expect(panes.replies).toEqual([])
expect(panes.cancellations).toEqual([])
panes.setActive(true)
expect(panes.keymap.mode.current()).toBe(FORM_MODE)
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ answer: { target: "staging" } }])
} finally {
panes.app.renderer.destroy()
}
})
test("a form textarea mounts inactive and restores its draft focus after scope and modal changes", async () => {
await using tmp = await tmpdir()
const panes = await mountPanes(tmp.path, () => <FormPrompt form={form([{ key: "notes", type: "string" }])} />)
try {
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
panes.setActive(true)
const input = panes.app.renderer.currentFocusedEditor
expect(input).not.toBeNull()
expect(input?.id).not.toBe(panes.peer.id)
await panes.app.mockInput.typeText("draft answer")
const pop = panes.keymap.mode.push("modal")
expect(panes.app.renderer.currentFocusedEditor).toBeNull()
panes.setActive(false)
panes.setActive(true)
expect(panes.keymap.mode.current()).toBe("modal")
expect(panes.app.renderer.currentFocusedEditor).toBeNull()
pop()
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
panes.setActive(false)
input?.focus()
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
await panes.app.mockInput.typeText(" other")
await panes.app.mockInput.pasteBracketedText(" pane")
panes.app.mockInput.pressEnter()
expect(panes.submissions).toHaveLength(1)
expect(input?.plainText).toBe("draft answer")
expect(panes.replies).toEqual([])
panes.setActive(true)
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
panes.app.mockInput.pressEnter()
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ answer: { notes: "draft answer" } }])
} finally {
panes.app.renderer.destroy()
}
})
test("inactive custom forms cannot intercept a peer using the same form mode", async () => {
await using tmp = await tmpdir()
const panes = await mountPanes(tmp.path, () => (
<FormPrompt
form={form([{ key: "target", type: "string", options: [{ value: "staging", label: "Staging" }], custom: true }])}
/>
))
try {
panes.setActive(true)
panes.app.mockInput.pressArrow("down")
panes.setActive(false)
const pop = panes.keymap.mode.push(FORM_MODE)
await panes.app.mockInput.typeText(" typed")
await panes.app.mockInput.pasteBracketedText(" pasted")
panes.app.mockInput.pressEnter()
await panes.app.renderOnce()
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
expect(panes.submissions).toHaveLength(1)
expect(panes.peer.plainText).toContain("typed")
expect(panes.peer.plainText).toContain("pasted")
expect(panes.app.captureCharFrame()).toContain("Type your own answer")
expect(panes.replies).toEqual([])
pop()
panes.setActive(true)
await panes.app.mockInput.typeText("production target")
await panes.app.waitFor(() => panes.app.renderer.currentFocusedEditor?.plainText === "production target")
panes.setActive(false)
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
panes.setActive(true)
expect(panes.app.renderer.currentFocusedEditor?.plainText).toBe("production target")
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ answer: { target: "production target" } }])
} finally {
panes.app.renderer.destroy()
}
})
test("permission layers leave the focused peer's Enter and navigation alone until activated", async () => {
await using tmp = await tmpdir()
const panes = await mountPanes(tmp.path, () => <PermissionPrompt request={request} />)
try {
panes.app.mockInput.pressEnter()
panes.app.mockInput.pressArrow("right")
panes.app.mockInput.pressEscape()
expect(panes.submissions).toEqual(["peer"])
expect(panes.replies).toEqual([])
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
panes.setActive(true)
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ reply: "once" }])
expect(panes.submissions).toHaveLength(1)
} finally {
panes.app.renderer.destroy()
}
})
test("permission rejection text keeps its draft and regains focus when its scope resumes", async () => {
await using tmp = await tmpdir()
const panes = await mountPanes(tmp.path, () => <PermissionPrompt request={request} />, "ses_parent")
try {
panes.setActive(true)
panes.app.mockInput.pressEscape()
await panes.app.waitForFrame((frame) => frame.includes("Reject permission"))
const input = panes.app.renderer.currentFocusedEditor
expect(input).not.toBeNull()
await panes.app.mockInput.typeText("choose another command")
panes.setActive(false)
panes.app.mockInput.pressEnter()
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
expect(panes.submissions).toEqual(["peer"])
expect(panes.replies).toEqual([])
expect(input?.plainText).toBe("choose another command")
panes.setActive(true)
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ reply: "reject", message: "choose another command" }])
} finally {
panes.app.renderer.destroy()
}
})
+50 -1
View File
@@ -2,6 +2,7 @@
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { createSignal } from "solid-js"
import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
@@ -166,10 +167,58 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
if (!theme) throw new Error("Contextual theme is not mounted")
if (!explicit) throw new Error("Explicit contextual theme is not mounted")
expect(theme.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(theme).toBe(explicit)
expect(theme.text.default).toBe(explicit.text.default)
expect(theme.text.default).toBe(themes.current.contextual.elevated.text.default)
expect(themes.current.contextual.overlay.background.default).toBe(themes.current.background.default)
} finally {
app.renderer.destroy()
}
})
test.each(["dark", "light"] as const)(
"reactive %s theme contexts change without remounting their contents",
async (mode) => {
const [context, setContext] = createSignal<"elevated" | undefined>("elevated")
const [parent, setParent] = createSignal<"overlay" | undefined>()
let theme: ReturnType<typeof useTheme> | undefined
let themes: ReturnType<typeof useThemes> | undefined
let mounts = 0
function Probe() {
mounts++
theme = useTheme()
themes = useThemes()
return <text fg={theme.text.default}>probe</text>
}
const app = await testRender(() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode } })}>
<ThemeProvider mode={mode} source={{ discover: async () => ({}) }}>
<ThemeContextProvider context={parent()}>
<ThemeContextProvider context={context()}>
<Probe />
</ThemeContextProvider>
</ThemeContextProvider>
</ThemeProvider>
</ConfigProvider>
))
app.renderer.start()
try {
await wait(() => themes?.ready === true)
if (!theme || !themes) throw new Error("Theme provider is not mounted")
const view = theme
expect(view.background.default).toBe(themes.current.contextual.elevated.background.default)
setContext(undefined)
await app.flush()
expect(view.background.default).toBe(themes.current.background.default)
setParent("overlay")
await app.flush()
expect(view.background.default).toBe(themes.current.contextual.overlay.background.default)
setContext("elevated")
await app.flush()
expect(view.text.default).toBe(themes.current.contextual.elevated.text.default)
expect(theme).toBe(view)
expect(mounts).toBe(1)
} finally {
app.renderer.destroy()
}
},
)
+46
View File
@@ -0,0 +1,46 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { InteractivityProvider, useInteractivity } from "../src/context/interactivity"
test("interactivity is independent of the keymap and cannot re-enable a disabled ancestor", async () => {
const [parent, setParent] = createSignal(true)
const [child, setChild] = createSignal(true)
let defaults!: () => boolean
let enabled!: () => boolean
function Probe() {
enabled = useInteractivity()
return null
}
function Harness() {
defaults = useInteractivity()
return (
<InteractivityProvider enabled={parent()}>
<InteractivityProvider enabled={child()}>
<Probe />
</InteractivityProvider>
</InteractivityProvider>
)
}
const app = await testRender(() => <Harness />)
try {
expect(defaults()).toBe(true)
expect(enabled()).toBe(true)
setParent(false)
expect(enabled()).toBe(false)
setChild(false)
setChild(true)
expect(enabled()).toBe(false)
setParent(true)
expect(enabled()).toBe(true)
setChild(false)
expect(enabled()).toBe(false)
expect(defaults()).toBe(true)
} finally {
app.renderer.destroy()
}
})
+271
View File
@@ -0,0 +1,271 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal, onCleanup, onMount, Show } from "solid-js"
import { Keymap } from "../src/context/keymap"
import { InteractivityProvider, useInteractivity } from "../src/context/interactivity"
const config = { keybinds: { get: () => [] } }
test("disabled scopes isolate named, inline, and global layers without disabling application commands", async () => {
const calls: string[] = []
const [enabled, setEnabled] = createSignal(false)
let keymap!: Keymap
function Scoped() {
Keymap.createLayer(() => ({
commands: [
{ id: "scoped.submit", bind: "return", run: () => void calls.push("submit") },
{ bind: "j", run: () => void calls.push("inline") },
],
}))
Keymap.createLayer(() => ({
mode: "global",
commands: [{ id: "scoped.global", bind: "g", run: () => void calls.push("scoped global") }],
}))
return null
}
function Harness() {
keymap = Keymap.use()
Keymap.createLayer(() => ({
mode: "global",
commands: [{ id: "app.global", bind: "x", run: () => void calls.push("app global") }],
}))
return (
<InteractivityProvider enabled={enabled()}>
<Scoped />
</InteractivityProvider>
)
}
const app = await testRender(() => (
<Keymap.Provider config={config}>
<Harness />
</Keymap.Provider>
))
try {
app.mockInput.pressEnter()
app.mockInput.pressKey("j")
app.mockInput.pressKey("g")
keymap.dispatch("scoped.submit")
keymap.dispatch("scoped.global")
app.mockInput.pressKey("x")
expect(calls).toEqual(["app global"])
setEnabled(true)
app.mockInput.pressEnter()
app.mockInput.pressKey("j")
app.mockInput.pressKey("g")
expect(calls).toEqual(["app global", "submit", "inline", "scoped global"])
const pop = keymap.mode.push("modal")
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
app.mockInput.pressKey("x")
expect(calls.slice(4)).toEqual(["scoped global", "app global"])
setEnabled(false)
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
app.mockInput.pressKey("x")
expect(calls.slice(6)).toEqual(["app global"])
pop()
} finally {
app.renderer.destroy()
}
})
test("nested scopes conjoin ancestors and retain dispatch-time layer predicates", async () => {
const calls: string[] = []
const [parent, setParent] = createSignal(false)
const [child, setChild] = createSignal(true)
const [layer, setLayer] = createSignal(true)
let allowed = true
let read!: () => boolean
let unscoped!: () => boolean
function Scoped() {
read = useInteractivity()
Keymap.createLayer(() => ({
enabled: layer(),
commands: [{ bind: "return", run: () => void calls.push("boolean") }],
}))
Keymap.createLayer(() => ({
mode: "global",
enabled: () => allowed,
commands: [{ bind: "g", run: () => void calls.push("predicate") }],
}))
return null
}
function Harness() {
unscoped = useInteractivity()
return (
<InteractivityProvider enabled={parent()}>
<InteractivityProvider enabled={child()}>
<Scoped />
</InteractivityProvider>
</InteractivityProvider>
)
}
const app = await testRender(() => (
<Keymap.Provider config={config}>
<Harness />
</Keymap.Provider>
))
try {
expect(unscoped()).toBe(true)
expect(read()).toBe(false)
app.mockInput.pressEnter()
setParent(true)
expect(read()).toBe(true)
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
allowed = false
app.mockInput.pressKey("g")
setLayer(false)
app.mockInput.pressEnter()
expect(calls).toEqual(["boolean", "predicate"])
setChild(false)
setLayer(true)
allowed = true
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
expect(read()).toBe(false)
setParent(false)
setChild(true)
expect(read()).toBe(false)
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
expect(calls).toEqual(["boolean", "predicate"])
setParent(true)
expect(read()).toBe(true)
app.mockInput.pressEnter()
app.mockInput.pressKey("g")
expect(calls).toEqual(["boolean", "predicate", "boolean", "predicate"])
} finally {
app.renderer.destroy()
}
})
test("ownerless mode pushes suspend and resume in their captured scope without changing stack order", async () => {
const [enabled, setEnabled] = createSignal(false)
const calls: string[] = []
let scoped!: Keymap
let global!: Keymap
function Scoped() {
scoped = Keymap.use()
Keymap.createLayer(() => ({
mode: "form",
commands: [{ bind: "return", run: () => void calls.push("form") }],
}))
Keymap.createLayer(() => ({
mode: "menu",
commands: [{ bind: "return", run: () => void calls.push("menu") }],
}))
return null
}
function Harness() {
global = Keymap.use()
return (
<InteractivityProvider enabled={enabled()}>
<Scoped />
</InteractivityProvider>
)
}
const app = await testRender(() => (
<Keymap.Provider config={config}>
<Harness />
</Keymap.Provider>
))
try {
const form = scoped.mode.push("form")
expect(global.mode.current()).toBe("base")
app.mockInput.pressEnter()
const modal = global.mode.push("modal")
setEnabled(true)
expect(global.mode.current()).toBe("modal")
app.mockInput.pressEnter()
modal()
expect(global.mode.current()).toBe("form")
app.mockInput.pressEnter()
expect(calls).toEqual(["form"])
setEnabled(false)
expect(global.mode.current()).toBe("base")
const menu = scoped.mode.push("menu")
form()
expect(global.mode.current()).toBe("base")
setEnabled(true)
expect(global.mode.current()).toBe("menu")
app.mockInput.pressEnter()
expect(calls).toEqual(["form", "menu"])
menu()
expect(global.mode.current()).toBe("base")
setEnabled(false)
setEnabled(true)
expect(global.mode.current()).toBe("base")
app.mockInput.pressEnter()
expect(calls).toEqual(["form", "menu"])
} finally {
app.renderer.destroy()
}
})
test("forwarded keymaps push modes in the calling component's nested scope and clean up while inactive", async () => {
const [enabled, setEnabled] = createSignal(false)
const [nested, setNested] = createSignal(true)
const [mounted, setMounted] = createSignal(true)
let global!: Keymap
function Scoped(props: { keymap: Keymap }) {
onMount(() => onCleanup(props.keymap.mode.push("menu")))
return null
}
function Harness() {
global = Keymap.use()
return (
<InteractivityProvider enabled={enabled()}>
<InteractivityProvider enabled={nested()}>
<Show when={mounted()}>
<Scoped keymap={global} />
</Show>
</InteractivityProvider>
</InteractivityProvider>
)
}
const app = await testRender(() => (
<Keymap.Provider config={config}>
<Harness />
</Keymap.Provider>
))
try {
expect(global.mode.current()).toBe("base")
setEnabled(true)
expect(global.mode.current()).toBe("menu")
setNested(false)
expect(global.mode.current()).toBe("base")
setNested(true)
expect(global.mode.current()).toBe("menu")
setEnabled(false)
setMounted(false)
setEnabled(true)
expect(global.mode.current()).toBe("base")
setMounted(true)
expect(global.mode.current()).toBe("menu")
setMounted(false)
expect(global.mode.current()).toBe("base")
} finally {
app.renderer.destroy()
}
})
+70
View File
@@ -0,0 +1,70 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createPanelState } from "../src/context/panel"
test("presentation changes preserve the selected panel identity", () => {
createRoot((dispose) => {
const panels = createPanelState()
panels.setWidth(160)
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
const current = panels.current()
expect(panels.presentation()).toBe("panel")
panels.toggleFullscreen()
expect(panels.presentation()).toBe("fullscreen")
expect(panels.current()).toBe(current)
panels.toggleFullscreen()
expect(panels.presentation()).toBe("panel")
expect(panels.current()).toBe(current)
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
expect(panels.current()).toBe(current)
dispose()
})
})
test("narrow geometry overrides presentation without discarding the user's choice", () => {
createRoot((dispose) => {
const panels = createPanelState()
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
panels.setWidth(80)
expect(panels.canSplit()).toBe(false)
expect(panels.presentation()).toBe("fullscreen")
panels.toggleFullscreen()
panels.setWidth(81)
expect(panels.canSplit()).toBe(true)
expect(panels.presentation()).toBe("panel")
panels.toggleFullscreen()
panels.setWidth(60)
panels.setWidth(160)
expect(panels.presentation()).toBe("fullscreen")
dispose()
})
})
test("opening a different name changes the panel selection", () => {
createRoot((dispose) => {
const panels = createPanelState()
panels.open({ plugin: "review", name: "review.diff", sessionID: "session" })
const current = panels.current()
panels.open({ plugin: "review", name: "review.history", sessionID: "session" })
expect(panels.current()).not.toBe(current)
expect(panels.current()?.name).toBe("review.history")
panels.open({ plugin: "tasks", name: "tasks.list", sessionID: "session" })
expect(panels.current()).toEqual({ plugin: "tasks", name: "tasks.list", sessionID: "session" })
panels.release("review")
expect(panels.current()?.name).toBe("tasks.list")
dispose()
})
})
test("releasing a plugin contribution only closes its own selected panel", () => {
createRoot((dispose) => {
const panels = createPanelState()
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
const current = panels.current()
panels.release("other")
expect(panels.current()).toBe(current)
panels.release("review")
expect(panels.current()).toBeUndefined()
dispose()
})
})
+17 -1
View File
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { RGBA } from "@opentui/core"
import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextName } from "@opencode-ai/theme/tui"
import { createComponentTheme } from "../../../src/theme/component"
import { createComponentTheme, createComponentThemeView } from "../../../src/theme/component"
test("provides reactive properties, states, contexts, and color operations", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
@@ -67,3 +67,19 @@ test("provides reactive properties, states, contexts, and color operations", ()
expect(current().decrease(current().background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
expect(current().raise(current().background.surface.offset)).toBe(resolved().hue.neutral[600])
})
test("a stable component theme view follows presentation context changes", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
const [context, setContext] = createSignal<ContextName>()
const theme = createComponentThemeView(
() => (context() ? resolved().contextual[context()!] : resolved()),
() => "dark",
)
expect(theme.background.default).toBe(resolved().background.default)
setContext("elevated")
expect(theme.background.default).toBe(resolved().contextual.elevated.background.default)
setContext(undefined)
expect(theme.background.default).toBe(resolved().background.default)
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
expect(theme.text.default).toBe(resolved().text.default)
})
+10 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify"]
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",
@@ -18221,6 +18221,12 @@
"type": "string",
"enum": ["auto", "manual"]
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
"type": "string"
},
@@ -18448,6 +18454,9 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {
+10 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify"]
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",
@@ -18221,6 +18221,12 @@
"type": "string",
"enum": ["auto", "manual"]
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
"type": "string"
},
@@ -18448,6 +18454,9 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {
@@ -236,10 +236,7 @@ function Status() {
Register a fenced-code renderer by language; the returned function unregisters it.
```ts
const unregister = context.markdown.registerCodeBlockRenderer(
"acme",
(_token, render) => render.defaultRender(),
)
const unregister = context.markdown.registerCodeBlockRenderer("acme", (_token, render) => render.defaultRender())
return unregister
```
@@ -340,7 +337,14 @@ Custom JSX dialogs can set their size and close themselves.
```tsx
context.ui.dialog.set({ size: "large", centered: true })
context.ui.dialog.show(() => <box><text>Acme</text></box>, () => console.log("closed"))
context.ui.dialog.show(
() => (
<box>
<text>Acme</text>
</box>
),
() => console.log("closed"),
)
context.ui.dialog.clear()
```
@@ -405,6 +409,84 @@ context.ui.slot({ after: "home.footer", render: () => <text>After footer slot</t
context.ui.slot({ replace: "home.footer", render: () => <text>New footer</text> })
```
### Session panels
Register a contribution to `session.panel`, then open the panel from a command. The host owns sizing, focus, and
full-screen presentation; the plugin owns its contents. The selected name is passed to every contribution as
`panel.name`, and each contribution decides whether to render.
```tsx
import { Show } from "solid-js"
context.ui.slot({
append: "session.panel",
render: (panel) => (
<Show when={panel.name === "acme.review"}>
<ReviewPanel panel={panel} />
</Show>
),
})
context.ui.slot({
append: "app",
render: () => {
context.keymap.layer(() => ({
mode: "global",
commands: [
{
id: "acme.review",
title: "Open review",
slash: { name: "review" },
run: () => {
context.ui.panel.open("acme.review")
},
},
],
}))
return null
},
})
```
- Opening outside a session returns `false`.
- This is an ordinary slot: all five placements and the existing replacement ordering rules apply.
- Use `append` for independently selectable contributions so they can coexist. `replace` still takes over the slot.
- Names are shared selection values, not registered claims. Use a plugin-prefixed name such as `acme.review` to avoid collisions. Opening a name with no matching renderer leaves the slot empty.
- Changing presentation preserves the mounted contributions. Closing the panel disposes them; disabling a plugin removes its contributions through normal slot cleanup.
- Its keyboard layers and input modes are active only while the panel owns input.
The slot receives reactive `name`, `sessionID`, `width`, `presentation`, and `focused` properties, plus `focus`,
`close`, and `toggleFullscreen` actions. The host keeps narrow terminals full-screen; `toggleFullscreen` has no effect
until there is enough room for a side panel.
```tsx
import type { PanelInput } from "@opencode-ai/plugin/tui/context"
import { usePlugin } from "@opencode-ai/plugin/tui"
function ReviewPanel(props: { panel: PanelInput }) {
const context = usePlugin()
context.keymap.layer(() => ({
commands: [
{
id: "acme.review.fullscreen",
bind: "f",
run: props.panel.toggleFullscreen,
},
],
}))
return <text>Reviewing {props.panel.sessionID}</text>
}
```
You can request full-screen presentation initially, inspect your active panel, or close it without affecting another
plugin's panel.
```ts
context.ui.panel.open("acme.review", { presentation: "fullscreen" })
const current = context.ui.panel.current()
context.ui.panel.close()
```
## Formatting
Format filesystem paths for display, including home-directory abbreviation.
+4 -4
View File
@@ -37,11 +37,11 @@ Manual compaction is available through session interfaces. See the generated [AP
operation.
A manual request is durably admitted and wakes the session runner. It can
compact short histories that would not trigger automatic compaction. If the
session is busy, compaction runs at the next safe drain boundary before later
steered or queued prompts are promoted. Repeated requests while one is pending
compact short histories that would not trigger automatic compaction. By default,
compaction runs at the next safe step boundary before pending steered or queued
prompts, even if they were submitted first. Repeated requests while one is pending
coalesce into that pending request. Whether compaction completes or fails, the
barrier is then settled so later prompts can proceed.
barrier is then settled so pending prompts can proceed.
The server operation returns the admitted compaction input; it does not wait
for summary generation. Clients can then wait for the session or follow the
+4 -1
View File
@@ -130,7 +130,10 @@ 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.
skip them, `"notify"` to show available updates before installing them, or
`"auto"` to install updates automatically. When omitted, `update` defaults to `"auto"`.
Automatic installation does not restart a running server. Restart it manually to activate the installed update.
Project-level values are ignored.
```jsonc
+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"` maps to `"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.