mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 00:16:22 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1001f90530 | ||
|
|
eeff73bfdf |
@@ -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. 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.
|
||||
- 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.
|
||||
- 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, Fiber, FileSystem, Option, Queue } from "effect"
|
||||
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -47,7 +47,6 @@ 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
|
||||
@@ -84,15 +83,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
updater: {
|
||||
remote: requestedServer !== undefined,
|
||||
subscribe: (notify, signal) =>
|
||||
monitor: (notify, signal) =>
|
||||
runPromise(
|
||||
Fiber.join(update).pipe(
|
||||
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
|
||||
),
|
||||
updater.monitor((version) => Effect.sync(() => notify(version))),
|
||||
{ signal },
|
||||
),
|
||||
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
packages: {
|
||||
|
||||
@@ -13,7 +13,6 @@ 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"
|
||||
@@ -164,21 +163,6 @@ 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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type Policy = "disable" | "notify" | "auto"
|
||||
export type Action = "none" | "notify" | "auto"
|
||||
export type Policy = "disable" | "notify"
|
||||
export type Action = "none" | "notify"
|
||||
|
||||
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 policy
|
||||
return "notify"
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(input: string) {
|
||||
|
||||
@@ -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("auto")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
|
||||
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("auto")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
|
||||
})
|
||||
|
||||
test("reports every available release", () => {
|
||||
@@ -23,11 +23,6 @@ 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")
|
||||
})
|
||||
|
||||
@@ -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, Ref, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, 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 run: () => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const pollUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly check: Effect.Effect<unknown>
|
||||
export const monitorUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly inspect: () => Effect.Effect<string | undefined, Error>
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly initialDelay?: Duration.Input
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
return yield* input.check.pipe(
|
||||
Effect.repeat(Schedule.spaced(interval)),
|
||||
Effect.delay(input.initialDelay ?? "1 minute"),
|
||||
)
|
||||
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))
|
||||
})
|
||||
|
||||
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" || value === "auto") return value
|
||||
if (value === "disable" || value === "notify") return value
|
||||
if (value === "auto") return "notify"
|
||||
return
|
||||
}
|
||||
if (!("autoupdate" in input)) return
|
||||
if (input.autoupdate === false) return "disable"
|
||||
if (input.autoupdate === "notify") return "notify"
|
||||
if (input.autoupdate === true) return "auto"
|
||||
if (input.autoupdate === true) return "notify"
|
||||
}
|
||||
|
||||
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) ?? "auto"
|
||||
return values.findLast((value) => value !== undefined) ?? "notify"
|
||||
})
|
||||
|
||||
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
const run = 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) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
(check) => run(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: (signal) =>
|
||||
try: () =>
|
||||
fetch(
|
||||
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
|
||||
{
|
||||
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
@@ -168,20 +168,17 @@ 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* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
return yield* run(["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* exec(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
"5 minutes",
|
||||
)
|
||||
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
|
||||
if (download.code !== 0) return download
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
return yield* run(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
if (result.code === 0) return
|
||||
@@ -203,19 +200,18 @@ const make = Effect.gen(function* () {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current,
|
||||
current: OPENCODE_VERSION,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(current, version, policy)
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
if (next === "none") {
|
||||
yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
return undefined
|
||||
}
|
||||
yield* Effect.logInfo("OpenCode update available", { current, latest: version, action: next })
|
||||
return { policy, version }
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return version
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (version: string) {
|
||||
@@ -224,10 +220,8 @@ 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* Ref.set(installedVersion, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
|
||||
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -235,36 +229,9 @@ const make = Effect.gen(function* () {
|
||||
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
})
|
||||
|
||||
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 }
|
||||
})
|
||||
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
|
||||
|
||||
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 })
|
||||
return Service.of({ monitor, apply, method, latest, upgrade })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -12,8 +12,7 @@ await Effect.runPromise(
|
||||
process.argv.slice(2),
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
run: () => Effect.die("Manual upgrades must not check for automatic updates"),
|
||||
check: () => Effect.die("Manual upgrades must not check for TUI updates"),
|
||||
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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)
|
||||
}),
|
||||
)
|
||||
@@ -1,24 +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("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" | "auto"
|
||||
update?: "disable" | "notify"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
|
||||
@@ -74,7 +74,9 @@ export function normalize(input: unknown): Result {
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const nativeUpdate = own(input, "update")
|
||||
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
? input.update === "auto"
|
||||
? "notify"
|
||||
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
|
||||
@@ -409,17 +409,19 @@ export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function*
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: Promotable,
|
||||
) {
|
||||
const steer = (yield* pendingSteers(db, sessionID))[0]
|
||||
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")
|
||||
if (steer) return fromRow(steer)
|
||||
if (promotable !== "input") return undefined
|
||||
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)
|
||||
const queued = yield* next("queue")
|
||||
return queued ? fromRow(queued) : undefined
|
||||
})
|
||||
|
||||
@@ -488,10 +490,9 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
|
||||
})
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
db: DatabaseService,
|
||||
@@ -505,7 +506,6 @@ 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,7 +518,6 @@ 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")
|
||||
@@ -537,14 +536,4 @@ 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,
|
||||
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
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -147,7 +147,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
|
||||
return DrainResult.Complete()
|
||||
const ready = yield* restore(
|
||||
return yield* restore(
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* prepareContext(sessionID)
|
||||
const promoted = yield* SessionInbox.promote(
|
||||
@@ -156,8 +156,6 @@ 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,
|
||||
@@ -166,7 +164,6 @@ const layer = Layer.effect(
|
||||
return { _tag: "Ready" as const, context: yield* context.load(selected) }
|
||||
}),
|
||||
)
|
||||
if (ready) return ready
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -273,9 +273,13 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
// A tool is hidden from the model only when no resource could get past `deny`. Each rule's resource
|
||||
// pattern matches itself as a literal, so the patterns for this action are a complete set of probes:
|
||||
// a later broader rule overrides a probe exactly when it also covers every resource the probe covers.
|
||||
// The extra "*" probe stands for resources no narrow rule covers, which fall back to the default `ask`.
|
||||
const whollyDisabled = (action: string, rules: Permission.Ruleset) => {
|
||||
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
|
||||
return rule?.resource === "*" && rule.effect === "deny"
|
||||
const probes = rules.filter((rule) => Wildcard.match(action, rule.action)).map((rule) => rule.resource)
|
||||
return [...probes, "*"].every((resource) => Permission.evaluate(action, resource, rules).effect === "deny")
|
||||
}
|
||||
|
||||
const formatSchemaIssue = SchemaIssue.makeFormatterDefault()
|
||||
|
||||
@@ -29,11 +29,9 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
update:
|
||||
info.autoupdate === false
|
||||
? "disable"
|
||||
: info.autoupdate === "notify"
|
||||
: info.autoupdate === "notify" || info.autoupdate === true
|
||||
? "notify"
|
||||
: info.autoupdate === true
|
||||
? "auto"
|
||||
: undefined,
|
||||
: undefined,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
|
||||
@@ -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("auto")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes the native auto update policy", () => {
|
||||
test("normalizes the previous native auto update policy", () => {
|
||||
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
|
||||
type: "normalized",
|
||||
encoded: { update: "auto" },
|
||||
encoded: { update: "notify" },
|
||||
diagnostics: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1925,185 +1925,6 @@ 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(
|
||||
|
||||
@@ -706,6 +706,55 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("hides tools whose narrower trailing rules cannot get past deny", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { bash: make() }, { codemode: false })
|
||||
const names = (permissions: Permission.Ruleset) =>
|
||||
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
||||
|
||||
// trailing narrow deny rules leave every call denied
|
||||
expect(
|
||||
yield* names([
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "bash", resource: "git *", effect: "deny" },
|
||||
]),
|
||||
).toEqual([])
|
||||
// without a catch-all, uncovered resources fall back to ask
|
||||
expect(yield* names([{ action: "bash", resource: "rm*", effect: "deny" }])).toEqual(["bash", "execute"])
|
||||
// a narrow ask superseded by the same narrow deny admits nothing
|
||||
expect(
|
||||
yield* names([
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "bash", resource: "rm*", effect: "ask" },
|
||||
{ action: "bash", resource: "rm*", effect: "deny" },
|
||||
]),
|
||||
).toEqual([])
|
||||
// a trailing narrow ask or allow still admits some calls
|
||||
expect(
|
||||
yield* names([
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "bash", resource: "rm*", effect: "ask" },
|
||||
]),
|
||||
).toEqual(["bash"])
|
||||
// a narrow allow that a later broader deny supersedes admits nothing
|
||||
expect(
|
||||
yield* names([
|
||||
{ action: "bash", resource: "git *", effect: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "deny" },
|
||||
]),
|
||||
).toEqual(["execute"])
|
||||
// a later narrower deny does not swallow the broader allow before it
|
||||
expect(
|
||||
yield* names([
|
||||
{ action: "bash", resource: "*", effect: "deny" },
|
||||
{ action: "bash", resource: "git *", effect: "allow" },
|
||||
{ action: "bash", resource: "git push*", effect: "deny" },
|
||||
]),
|
||||
).toEqual(["bash", "execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps permission options isolated between registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
|
||||
@@ -162,21 +162,6 @@ type PromptFooterInput = {
|
||||
readonly showDetails: boolean
|
||||
}
|
||||
|
||||
export type PanelPresentation = "panel" | "fullscreen"
|
||||
|
||||
/** Client-local state of the selected session panel. The host owns its layout and input scope. */
|
||||
export interface PanelInput {
|
||||
/** 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
|
||||
@@ -195,7 +180,6 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.status": PromptFooterInput
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "session.panel": PanelInput
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
}
|
||||
@@ -466,14 +450,6 @@ 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
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
"enum": ["disable", "notify"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
@@ -18221,12 +18221,6 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18454,9 +18448,6 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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", "auto"]).pipe(optional).annotate({
|
||||
description: "Disable updates, notify when one is available, or install updates automatically",
|
||||
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
|
||||
description: "Disable updates or notify when one is available",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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"
|
||||
@@ -116,14 +114,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
)
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
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),
|
||||
}
|
||||
return { address: bound.http.address, shutdown: shutdown.await }
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
|
||||
@@ -101,13 +101,7 @@ 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"))
|
||||
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())
|
||||
yield* Effect.promise(() => body.cancel())
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
|
||||
@@ -132,11 +126,3 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
+56
-52
@@ -64,6 +64,7 @@ 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"
|
||||
@@ -87,7 +88,6 @@ 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,7 +100,6 @@ import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider, useStorage } from "./context/storage"
|
||||
import { SessionTerminalsProvider } from "./context/session-terminals"
|
||||
import { PanelProvider, usePanel } from "./context/panel"
|
||||
import { SessionFrame } from "./component/session-frame"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
|
||||
@@ -155,7 +154,6 @@ const appBindingCommands = [
|
||||
"provider.connect",
|
||||
"opencode.settings",
|
||||
"opencode.status",
|
||||
"opencode.update",
|
||||
"server.pair",
|
||||
"service.restart",
|
||||
"opencode.debug",
|
||||
@@ -187,7 +185,10 @@ export type TuiInput = {
|
||||
}
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
updater?: UpdateSource
|
||||
updater?: {
|
||||
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
|
||||
apply: (version: string) => Promise<void>
|
||||
}
|
||||
packages: PackageSource
|
||||
environment?: Readonly<Record<string, string>>
|
||||
terminalHandoff?: () => Promise<
|
||||
@@ -396,27 +397,22 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<UpdateNotificationProvider
|
||||
updater={input.updater}
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<PanelProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</PanelProvider>
|
||||
</UpdateNotificationProvider>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
@@ -466,7 +462,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
})
|
||||
|
||||
function App(props: { pair?: DialogPairCredentials }) {
|
||||
function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"] }) {
|
||||
const log = useLog({ component: "app" })
|
||||
const app = useTuiApp()
|
||||
const startup = useTuiStartup()
|
||||
@@ -478,12 +474,10 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
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()
|
||||
@@ -507,6 +501,40 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
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,
|
||||
@@ -580,22 +608,9 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () =>
|
||||
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
|
||||
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const fullscreenPanel = () =>
|
||||
route.data.type === "session" &&
|
||||
panels.current()?.sessionID === route.data.sessionID &&
|
||||
panels.presentation() === "fullscreen"
|
||||
const tabsVisible = () => tabsAvailable() && !fullscreenPanel()
|
||||
const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
|
||||
|
||||
// Measure the prospective split layout, even while full-screen hides the tabs.
|
||||
createEffect(() => panels.setWidth(dimensions().width - (tabsAvailable() && tabsVertical() ? tabsResize.size() : 0)))
|
||||
createEffect(() => {
|
||||
const current = panels.current()
|
||||
if (!current || (route.data.type === "session" && route.data.sessionID === current.sessionID)) return
|
||||
panels.close()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = config.data.mouse
|
||||
})
|
||||
@@ -957,17 +972,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
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",
|
||||
|
||||
@@ -1,56 +1,67 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import type { UpdateState } from "../context/update-notification"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
type State =
|
||||
| { type: "ready"; active: "update" | "skip" }
|
||||
| { type: "installing" }
|
||||
| { type: "restarting" }
|
||||
| { type: "failed"; message: string }
|
||||
|
||||
export function DialogUpdate(props: {
|
||||
check?: (signal: AbortSignal) => Promise<string | undefined>
|
||||
state: () => UpdateState | undefined
|
||||
dialogKey: string
|
||||
version: string
|
||||
install: () => Promise<void>
|
||||
restart: () => void
|
||||
restart?: () => Promise<void>
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const [error, setError] = createSignal<string>()
|
||||
const [active, setActive] = createSignal(0)
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
const [state, setState] = createSignal<State>({ type: "ready", active: "update" })
|
||||
const close = () => {
|
||||
if (dialog.key === props.dialogKey) dialog.clear()
|
||||
}
|
||||
|
||||
dialog.setCentered(true)
|
||||
const install = async () => {
|
||||
setState({ type: "installing" })
|
||||
await props.install()
|
||||
if (props.restart) {
|
||||
setState({ type: "restarting" })
|
||||
await props.restart()
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
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 beginInstall = () => {
|
||||
if (state().type !== "ready") return
|
||||
void install().catch((error) => setState({ type: "failed", message: errorMessage(error) }))
|
||||
}
|
||||
|
||||
createEffect(() => setActive(Math.max(0, buttons().length - 1)))
|
||||
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 : ""
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
@@ -59,17 +70,20 @@ export function DialogUpdate(props: {
|
||||
bind: "return",
|
||||
title: "Confirm update action",
|
||||
group: "Dialog",
|
||||
run: () => void buttons()[active()]?.run(),
|
||||
run: () => (state().type === "failed" ? close() : run()),
|
||||
},
|
||||
...["left", "right", "tab", "shift+tab"].map((bind) => ({
|
||||
bind,
|
||||
title: bind === "left" || bind === "shift+tab" ? "Previous update action" : "Next update action",
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous update action",
|
||||
group: "Dialog",
|
||||
run: () => {
|
||||
const count = buttons().length
|
||||
if (count) setActive((value) => (value + 1) % count)
|
||||
},
|
||||
})),
|
||||
run: toggle,
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next update action",
|
||||
group: "Dialog",
|
||||
run: toggle,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -77,65 +91,64 @@ 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}>
|
||||
{state().type === "available" || state().type === "installing" || state().type === "failed"
|
||||
? "Update available"
|
||||
: "Update"}
|
||||
Update available
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={close}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<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>
|
||||
<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>
|
||||
</box>
|
||||
<Show when={buttons().length > 0}>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={buttons()}>
|
||||
{(button, index) => (
|
||||
<For each={["skip", "update"] as const}>
|
||||
{(action) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={active() === index() ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => void button.run()}
|
||||
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (action === "skip") return close()
|
||||
beginInstall()
|
||||
}}
|
||||
>
|
||||
<text fg={active() === index() ? theme.text.action.primary.focused : theme.text.subdued}>
|
||||
{button.label}
|
||||
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
|
||||
{action === "update" ? "Update" : "Skip"}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -54,7 +54,6 @@ 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"
|
||||
@@ -187,8 +186,6 @@ 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()
|
||||
@@ -260,7 +257,6 @@ export function Prompt(props: PromptProps) {
|
||||
const [pendingDirectory, setPendingDirectory] = createSignal<string>()
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: !disabled(),
|
||||
commands: [
|
||||
{
|
||||
id: "session.cd",
|
||||
@@ -350,7 +346,8 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
|
||||
if (props.disabled) input.cursorColor = theme.background.surface.offset
|
||||
if (!props.disabled) input.cursorColor = theme.text.default
|
||||
if (config.cursor) input.cursorStyle = config.cursor
|
||||
})
|
||||
|
||||
@@ -373,13 +370,12 @@ export function Prompt(props: PromptProps) {
|
||||
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
|
||||
pasteQueue = pasteQueue
|
||||
.then(async () => {
|
||||
if (disposed || input.isDestroyed || disabled()) return
|
||||
if (disposed || input.isDestroyed) return
|
||||
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
|
||||
await run(
|
||||
() =>
|
||||
disposed ||
|
||||
input.isDestroyed ||
|
||||
disabled() ||
|
||||
props.sessionID !== before.sessionID ||
|
||||
store.mode !== before.mode ||
|
||||
input.plainText !== before.text,
|
||||
@@ -638,18 +634,15 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: !disabled(),
|
||||
commands: promptCommands(),
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
enabled: !disabled(),
|
||||
bindings: ["prompt.queue"],
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: !disabled(),
|
||||
bindings: [
|
||||
"prompt.submit",
|
||||
"prompt.editor",
|
||||
@@ -667,13 +660,12 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const ref: PromptRef = {
|
||||
get focused() {
|
||||
return !disabled() && input.focused
|
||||
return input.focused
|
||||
},
|
||||
get current() {
|
||||
return store.prompt
|
||||
},
|
||||
focus() {
|
||||
if (disabled()) return
|
||||
input.focus()
|
||||
},
|
||||
blur() {
|
||||
@@ -727,13 +719,11 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.visible === false || disabled() || dialog.stack.length > 0) {
|
||||
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
|
||||
if (input.focused) input.blur()
|
||||
input.focusable = false
|
||||
return
|
||||
}
|
||||
|
||||
input.focusable = true
|
||||
// Slot/plugin updates can remount the background prompt while a dialog is open.
|
||||
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
|
||||
if (!input.focused) input.focus()
|
||||
@@ -929,14 +919,13 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: !disabled(),
|
||||
commands: stashCommands(),
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !disabled(),
|
||||
enabled: inputTarget() !== undefined && !props.disabled,
|
||||
bindings: ["prompt.paste"],
|
||||
}
|
||||
})
|
||||
@@ -944,7 +933,7 @@ export function Prompt(props: PromptProps) {
|
||||
Keymap.createLayer(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !disabled() && store.prompt.text !== "",
|
||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
|
||||
bindings: ["prompt.clear"],
|
||||
}
|
||||
})
|
||||
@@ -956,7 +945,7 @@ export function Prompt(props: PromptProps) {
|
||||
cursorVersion()
|
||||
return (
|
||||
inputTarget() !== undefined &&
|
||||
!disabled() &&
|
||||
!props.disabled &&
|
||||
store.mode === "normal" &&
|
||||
!auto()?.visible &&
|
||||
input?.visualCursor.offset === 0
|
||||
@@ -980,7 +969,7 @@ export function Prompt(props: PromptProps) {
|
||||
return {
|
||||
priority: 1,
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !disabled() && store.mode === "shell",
|
||||
enabled: inputTarget() !== undefined && store.mode === "shell",
|
||||
commands: [
|
||||
{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
|
||||
{
|
||||
@@ -999,7 +988,7 @@ export function Prompt(props: PromptProps) {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && !disabled() && store.mode === "shell" && input?.visualCursor.offset === 0
|
||||
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
|
||||
})(),
|
||||
commands: [
|
||||
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
|
||||
@@ -1013,7 +1002,7 @@ export function Prompt(props: PromptProps) {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
|
||||
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
@@ -1049,7 +1038,7 @@ export function Prompt(props: PromptProps) {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
|
||||
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
@@ -1084,7 +1073,6 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
let submitting = false
|
||||
async function submit(delivery: SessionInbox.Delivery = "steer") {
|
||||
if (disabled()) return false
|
||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||
// input's native onSubmit racing another dispatch). Without this guard,
|
||||
// a second call slips past the empty-input check before the first call
|
||||
@@ -1108,6 +1096,7 @@ export function Prompt(props: PromptProps) {
|
||||
setStore("prompt", "text", input.plainText)
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return false
|
||||
if (move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
const trimmed = store.prompt.text.trim()
|
||||
@@ -1775,19 +1764,18 @@ export function Prompt(props: PromptProps) {
|
||||
}}
|
||||
onCursorChange={() => setCursorVersion((value) => value + 1)}
|
||||
onKeyDown={(e: { preventDefault(): void }) => {
|
||||
if (disabled()) {
|
||||
if (props.disabled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}}
|
||||
onSubmit={() => {
|
||||
if (disabled()) return
|
||||
// IME: double-defer so the last composed character (e.g. Korean
|
||||
// hangul) is flushed to plainText before we read it for submission.
|
||||
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||
}}
|
||||
onPaste={(event: PasteEvent) => {
|
||||
if (disabled()) {
|
||||
if (props.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -1823,16 +1811,12 @@ export function Prompt(props: PromptProps) {
|
||||
setTimeout(() => {
|
||||
// setTimeout is a workaround and needs to be addressed properly
|
||||
if (!input || input.isDestroyed) return
|
||||
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
|
||||
input.cursorColor = theme.text.default
|
||||
if (config.cursor) input.cursorStyle = config.cursor
|
||||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => {
|
||||
if (disabled()) {
|
||||
r.preventDefault()
|
||||
return
|
||||
}
|
||||
if (r.button !== 0) return
|
||||
if (props.disabled || r.button !== 0) return
|
||||
r.target?.focus()
|
||||
const extmark = input.extmarks
|
||||
.getAtOffset(input.cursorOffset)
|
||||
@@ -1842,7 +1826,7 @@ export function Prompt(props: PromptProps) {
|
||||
r.stopPropagation()
|
||||
}}
|
||||
focusedBackgroundColor="transparent"
|
||||
cursorColor={disabled() ? theme.background.surface.offset : theme.text.default}
|
||||
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
import {
|
||||
CliRenderEvents,
|
||||
RGBA,
|
||||
MouseEvent,
|
||||
type BoxRenderable,
|
||||
type Renderable,
|
||||
type ScrollBoxRenderable,
|
||||
} from "@opentui/core"
|
||||
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createEffect, createMemo, createResource, createSignal, on, onCleanup, Show } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { 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 { clampSessionPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { createPaneResize } from "../ui/pane-resize"
|
||||
import { PaneResizeHandle } from "../ui/pane-resize-handle"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { TerminalPane } from "./terminal-pane"
|
||||
import { PanelHost } from "./panel-host"
|
||||
|
||||
export function SessionFrame(props: { sessionID: string; verticalTabsWidth: number }) {
|
||||
const sessions = useSessionTerminals()
|
||||
@@ -32,49 +21,40 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
const config = useConfig()
|
||||
const data = useData()
|
||||
const toast = useToast()
|
||||
const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" })
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const panels = usePanel()
|
||||
const dialog = useDialog()
|
||||
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
|
||||
const defaultPaneWidth = () => Math.max(1, Math.floor(panels.width() / 2))
|
||||
const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", {
|
||||
initial: {},
|
||||
})
|
||||
const paneResize = createPaneResize({
|
||||
value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(),
|
||||
defaultValue: defaultPaneWidth,
|
||||
clamp: (width) => clampSessionPaneWidth(width, panels.width()),
|
||||
const defaultTerminalWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
|
||||
const [layout, updateLayout] = useStorage().store<{ terminalWidth?: number }>("layout", { initial: {} })
|
||||
const terminalResize = createPaneResize({
|
||||
value: () => layout.terminalWidth ?? defaultTerminalWidth(),
|
||||
defaultValue: defaultTerminalWidth,
|
||||
clamp: (width) => clampTerminalPaneWidth(width, availableWidth()),
|
||||
fromMouse: (event) => dimensions().width - event.x - 1,
|
||||
contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width,
|
||||
onCommit: (width) => {
|
||||
void updateLayout((draft) => {
|
||||
draft.paneWidth = width
|
||||
draft.terminalWidth = width
|
||||
}).catch((error) => console.error("Failed to persist TUI layout", error))
|
||||
},
|
||||
})
|
||||
let resizeRelease = false
|
||||
const finishPaneResize = (event: MouseEvent) => {
|
||||
if (paneResize.resizing()) {
|
||||
const finishTerminalResize = (event: MouseEvent) => {
|
||||
if (terminalResize.resizing()) {
|
||||
// A captured drag-end can be followed by mouse-up on the focus overlay.
|
||||
resizeRelease = true
|
||||
queueMicrotask(() => {
|
||||
resizeRelease = false
|
||||
})
|
||||
}
|
||||
paneResize.onMouseUp(event)
|
||||
terminalResize.onMouseUp(event)
|
||||
}
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [sessionWidth, setSessionWidth] = createSignal<number>()
|
||||
const [activePane, setActivePane] = createSignal<"session" | "right">("session")
|
||||
const [terminalFocused, setTerminalFocused] = createSignal(false)
|
||||
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
|
||||
let focusTerminal: (() => void) | undefined
|
||||
let showTerminals: (() => void) | undefined
|
||||
let sessionScroll: ScrollBoxRenderable | undefined
|
||||
let sessionNode: BoxRenderable | undefined
|
||||
let rightNode: BoxRenderable | undefined
|
||||
let panelNode: BoxRenderable | undefined
|
||||
createResource(
|
||||
() => (config.data.session.terminal ? props.sessionID : undefined),
|
||||
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
|
||||
@@ -85,23 +65,14 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
const value = session()
|
||||
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
|
||||
}
|
||||
const activePanel = createMemo(() => {
|
||||
const current = panels.current()
|
||||
if (current?.sessionID === props.sessionID) return current
|
||||
})
|
||||
const fullscreen = () => activePanel() !== undefined && panels.presentation() === "fullscreen"
|
||||
createEffect(
|
||||
on([activePanel, () => selectedTerminal()?.id], ([panel, terminal], previous) => {
|
||||
if (panel && panel !== previous?.[0]) {
|
||||
setSidebarOpen(false)
|
||||
if (terminal) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
return
|
||||
}
|
||||
if (terminal && terminal !== previous?.[1]) {
|
||||
setSidebarOpen(false)
|
||||
if (panel) panels.close()
|
||||
}
|
||||
}),
|
||||
on(
|
||||
() => selectedTerminal()?.id,
|
||||
(id) => {
|
||||
if (id) setSidebarOpen(false)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
@@ -110,7 +81,6 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
|
||||
})
|
||||
const rightPane = createMemo(() => {
|
||||
if (activePanel()) return "panel"
|
||||
if (sidebarOpen() && sidebarVisible()) return "sidebar"
|
||||
if (selectedTerminal()) return "terminal"
|
||||
if (sidebarVisible()) return "sidebar"
|
||||
@@ -124,137 +94,34 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
})
|
||||
.catch(toast.error)
|
||||
setSidebarOpen(!visible)
|
||||
if (!visible && activePanel()) panels.close()
|
||||
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
})
|
||||
}
|
||||
const focusSession = () => {
|
||||
if (fullscreen()) return
|
||||
// Permission prompts replace the input, so returning focus must not depend on it.
|
||||
if (activePane() === "right") renderer.currentFocusedRenderable?.blur()
|
||||
setActivePane("session")
|
||||
if (terminalFocused()) renderer.currentFocusedRenderable?.blur()
|
||||
prompt.current?.focus()
|
||||
}
|
||||
const focusRightPane = () => {
|
||||
setActivePane("right")
|
||||
if (activePanel()) {
|
||||
panelNode?.focus()
|
||||
return
|
||||
}
|
||||
focusTerminal?.()
|
||||
}
|
||||
const onFocused = () => {
|
||||
const current = renderer.currentFocusedRenderable
|
||||
if (rightPane() !== "sidebar" && within(current, rightNode)) setActivePane("right")
|
||||
if (!fullscreen() && within(current, sessionNode)) setActivePane("session")
|
||||
}
|
||||
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused))
|
||||
createEffect(() => {
|
||||
if (fullscreen()) focusRightPane()
|
||||
})
|
||||
createEffect(() => {
|
||||
if (rightPane() !== "terminal" && rightPane() !== "panel") setActivePane("session")
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!restoreTerminalFocus() || selectedTerminal()) return
|
||||
setRestoreTerminalFocus(false)
|
||||
focusSession()
|
||||
})
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: () => (rightPane() === "terminal" || activePanel() !== undefined) && dialog.stack.length === 0,
|
||||
enabled: () => config.data.session.terminal === true,
|
||||
commands: [
|
||||
{
|
||||
id: "pane.focus.left",
|
||||
title: "Focus session pane",
|
||||
enabled: () => !fullscreen(),
|
||||
run: focusSession,
|
||||
},
|
||||
{
|
||||
id: "pane.focus.right",
|
||||
title: "Focus right pane",
|
||||
run: focusRightPane,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
// Pane management stays reachable from either input scope.
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
id: "session.sidebar.toggle",
|
||||
title: rightPane() === "sidebar" ? "Hide sidebar" : "Show sidebar",
|
||||
group: "Session",
|
||||
palette: true,
|
||||
title: "Focus terminal pane",
|
||||
run: () => {
|
||||
toggleSidebar()
|
||||
dialog.clear()
|
||||
focusTerminal?.()
|
||||
},
|
||||
},
|
||||
...(config.data.session.terminal
|
||||
? [
|
||||
{
|
||||
id: "terminal.toggle",
|
||||
title: rightPane() === "terminal" ? "Hide terminal pane" : "Show terminal pane",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
run: () => {
|
||||
dialog.clear()
|
||||
if (rightPane() === "terminal") {
|
||||
focusSession()
|
||||
void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
return
|
||||
}
|
||||
void sessions
|
||||
.refresh(props.sessionID)
|
||||
.then(async () => {
|
||||
const terminal = sessions.get(props.sessionID).terminals.at(-1)
|
||||
if (terminal) return sessions.selectTerminal(props.sessionID, terminal.id)
|
||||
await sessions.newTerminal(props.sessionID)
|
||||
})
|
||||
.catch(terminalError)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "terminal.select",
|
||||
title: "Select terminal",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
run: () => {
|
||||
dialog.clear()
|
||||
if (fullscreen()) panels.close()
|
||||
focusSession()
|
||||
showTerminals?.()
|
||||
void sessions.refresh(props.sessionID).catch(terminalError)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "terminal.close",
|
||||
title: "Close terminal pane",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
enabled: rightPane() === "terminal",
|
||||
run: () => {
|
||||
dialog.clear()
|
||||
focusSession()
|
||||
void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.terminal",
|
||||
title: "New terminal",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
slash: { name: "terminal" },
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
await sessions.newTerminal(props.sessionID).catch(terminalError)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -265,38 +132,30 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
minHeight={0}
|
||||
flexDirection="row"
|
||||
position="relative"
|
||||
onMouseDrag={paneResize.onMouseDrag}
|
||||
onMouseDragEnd={finishPaneResize}
|
||||
onMouseUp={finishPaneResize}
|
||||
onMouseDrag={terminalResize.onMouseDrag}
|
||||
onMouseDragEnd={finishTerminalResize}
|
||||
onMouseUp={finishTerminalResize}
|
||||
>
|
||||
<box
|
||||
id="session-pane"
|
||||
ref={(value: BoxRenderable) => (sessionNode = value)}
|
||||
flexGrow={1}
|
||||
flexBasis={0}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
position={fullscreen() ? "absolute" : "relative"}
|
||||
visible={!fullscreen()}
|
||||
width={fullscreen() ? Math.max(0, panels.width() - paneResize.size()) : undefined}
|
||||
height="100%"
|
||||
position="relative"
|
||||
onSizeChange={function () {
|
||||
setSessionWidth(this.width)
|
||||
}}
|
||||
>
|
||||
<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"}>
|
||||
<Session
|
||||
scrollRef={(value) => (sessionScroll = value)}
|
||||
verticalTabsWidth={props.verticalTabsWidth}
|
||||
promptMuted={terminalFocused()}
|
||||
sidebarVisible={rightPane() === "sidebar"}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
|
||||
width={sessionWidth()}
|
||||
/>
|
||||
<Show when={terminalFocused()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
@@ -315,60 +174,35 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
}}
|
||||
// Consume the release before revealing permission buttons underneath.
|
||||
onMouseUp={() => {
|
||||
if (paneResize.resizing() || resizeRelease) return
|
||||
if (terminalResize.resizing() || resizeRelease) return
|
||||
focusSession()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
|
||||
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
|
||||
<box
|
||||
ref={(value: BoxRenderable) => (rightNode = value)}
|
||||
flexShrink={0}
|
||||
width={
|
||||
fullscreen() ? availableWidth() : rightPane() === "sidebar" ? SESSION_SIDEBAR_WIDTH : paneResize.size()
|
||||
}
|
||||
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
>
|
||||
<Show
|
||||
when={rightPane() === "sidebar"}
|
||||
fallback={
|
||||
<Show
|
||||
keyed
|
||||
when={activePanel()}
|
||||
fallback={
|
||||
<Show keyed when={selectedTerminal()?.id}>
|
||||
{(ptyID) => (
|
||||
<TerminalPane
|
||||
ptyID={ptyID}
|
||||
resizing={paneResize.resizing()}
|
||||
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
|
||||
onAutoFocus={() => {
|
||||
sessions.clearFocus(ptyID)
|
||||
setRestoreTerminalFocus(false)
|
||||
}}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<PanelHost
|
||||
panel={item}
|
||||
width={fullscreen() ? availableWidth() : paneResize.size()}
|
||||
focused={activePane() === "right"}
|
||||
onFocus={focusRightPane}
|
||||
onTarget={(node) => {
|
||||
panelNode = node
|
||||
if (node) {
|
||||
focusRightPane()
|
||||
return
|
||||
}
|
||||
setActivePane("session")
|
||||
<Show keyed when={selectedTerminal()?.id}>
|
||||
{(ptyID) => (
|
||||
<TerminalPane
|
||||
ptyID={ptyID}
|
||||
resizing={terminalResize.resizing()}
|
||||
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
|
||||
onAutoFocus={() => {
|
||||
sessions.clearFocus(ptyID)
|
||||
setRestoreTerminalFocus(false)
|
||||
}}
|
||||
onFocusChange={setTerminalFocused}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
@@ -378,8 +212,12 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!fullscreen() && (rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
|
||||
<PaneResizeHandle resize={paneResize} left={availableWidth() - paneResize.size() - 1} highlight="right" />
|
||||
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
|
||||
<PaneResizeHandle
|
||||
resize={terminalResize}
|
||||
left={availableWidth() - terminalResize.size() - 1}
|
||||
highlight="right"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={rightPane() === "sidebar" && !wide()}>
|
||||
<box
|
||||
@@ -397,11 +235,3 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function within(node: Renderable | null | undefined, root: Renderable | undefined) {
|
||||
if (!root) return false
|
||||
for (let current = node; current; current = current.parent) {
|
||||
if (current === root) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { RGBA, type OptimizedBuffer, type RenderContext, type TextOptions } from "@opentui/core"
|
||||
import {
|
||||
OptimizedBuffer,
|
||||
RGBA,
|
||||
TargetChannel,
|
||||
TextRenderable,
|
||||
type RenderContext,
|
||||
type TextOptions,
|
||||
} from "@opentui/core"
|
||||
import { extend, type JSX } from "@opentui/solid"
|
||||
import { splitProps } from "solid-js"
|
||||
import { MaskedTextRenderable } from "./masked-text"
|
||||
import { coast, intensityAt } from "./tab-pulse"
|
||||
|
||||
type ShimmerTextOptions = TextOptions & {
|
||||
@@ -9,10 +15,15 @@ type ShimmerTextOptions = TextOptions & {
|
||||
}
|
||||
|
||||
const DURATION = 1200
|
||||
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
|
||||
const CONTINUATION = 0xc0000000 | 0
|
||||
|
||||
class ShimmerTextRenderable extends MaskedTextRenderable {
|
||||
class ShimmerTextRenderable extends TextRenderable {
|
||||
private _shimmer = RGBA.defaultForeground()
|
||||
private elapsed = 0
|
||||
private scratch: OptimizedBuffer | undefined
|
||||
private mask = new Float32Array(0)
|
||||
private matrix = new Float32Array(16)
|
||||
|
||||
constructor(ctx: RenderContext, options: ShimmerTextOptions) {
|
||||
super(ctx, options)
|
||||
@@ -36,10 +47,44 @@ class ShimmerTextRenderable extends MaskedTextRenderable {
|
||||
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
|
||||
this.renderMasked(buffer, 0, (end) => {
|
||||
const front = -4 + coast(this.elapsed / DURATION) * (end + 22)
|
||||
return (column) => intensityAt(column, front, 4, 18)
|
||||
})
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
|
||||
import { CliRenderEvents, EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
|
||||
import type { ResolvedThemeTokens } from "@opencode-ai/theme/tui"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
@@ -28,6 +28,7 @@ export function TerminalPane(props: {
|
||||
onAutoFocus?: () => void
|
||||
onFocusRequest?: (focus: (() => void) | undefined) => void
|
||||
onDisconnect?: () => void
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
}) {
|
||||
const client = useClient()
|
||||
const keymap = Keymap.use()
|
||||
@@ -147,6 +148,9 @@ export function TerminalPane(props: {
|
||||
},
|
||||
{ priority: 100 },
|
||||
)
|
||||
// Blur emits this event before updating the terminal's own focused flag.
|
||||
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === terminal)
|
||||
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
createEffect(() => {
|
||||
if (!props.autoFocus || !terminal) return
|
||||
terminal.focus()
|
||||
@@ -168,6 +172,8 @@ export function TerminalPane(props: {
|
||||
waitingSize?.resolve()
|
||||
socket?.close()
|
||||
offKeys()
|
||||
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
props.onFocusChange?.(false)
|
||||
props.onFocusRequest?.(undefined)
|
||||
})
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export const Definitions = {
|
||||
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
|
||||
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
|
||||
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
|
||||
"pane.focus.right": keybind("<leader>right", "Focus right pane"),
|
||||
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
|
||||
"terminal.select": keybind("<leader>down", "Select terminal"),
|
||||
"terminal.toggle": keybind("<leader>t", "Toggle terminal pane"),
|
||||
"terminal.close": keybind("<leader>up", "Close terminal pane"),
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -13,19 +13,9 @@ import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import {
|
||||
createComputed,
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { createContext, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { TuiKeybind } from "../config/keybind"
|
||||
import { resolveInteractivity, useInteractivity } from "./interactivity"
|
||||
|
||||
declare module "@opentui/keymap" {
|
||||
interface Command {
|
||||
@@ -185,17 +175,13 @@ 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: {
|
||||
current: value.mode.current,
|
||||
push: (mode) => value.mode.push(mode, resolveInteractivity(enabled)),
|
||||
},
|
||||
mode: value.mode,
|
||||
intercept: value.keymap.intercept.bind(value.keymap),
|
||||
isLeader,
|
||||
}
|
||||
@@ -203,7 +189,6 @@ function use(): Keymap {
|
||||
|
||||
function createLayer(input: () => KeymapLayer) {
|
||||
const value = useValue()
|
||||
const enabled = useInteractivity()
|
||||
useBindings(() => {
|
||||
const layer = input()
|
||||
const { commands, bindings, mode, ...options } = layer
|
||||
@@ -230,7 +215,6 @@ function createLayer(input: () => KeymapLayer) {
|
||||
)
|
||||
return {
|
||||
...options,
|
||||
enabled: enabled() ? options.enabled : false,
|
||||
...(mode === "global" ? {} : { mode: mode ?? MODE.base }),
|
||||
commands: grouped.named.map((command) => {
|
||||
const { id, description, group, palette, bind, run, ...definition } = command
|
||||
@@ -413,34 +397,37 @@ export const Keymap = {
|
||||
} as const
|
||||
|
||||
function createMode(keymap: OpenTuiKeymap) {
|
||||
const [stack, setStack] = createSignal<
|
||||
{ readonly id: symbol; readonly mode: string; readonly enabled: Accessor<boolean> }[]
|
||||
>([])
|
||||
const current = createMemo(() => stack().findLast((item) => item.enabled())?.mode ?? MODE.base)
|
||||
// Publish mode changes before another command can be dispatched in the same callback.
|
||||
createComputed(() => keymap.setData(MODE.key, current()))
|
||||
keymap.setData(MODE.key, MODE.base)
|
||||
const unregister = keymap.registerLayerFields({
|
||||
mode(value, context) {
|
||||
context.require(MODE.key, value)
|
||||
},
|
||||
})
|
||||
const stack: { readonly id: symbol; readonly mode: string }[] = []
|
||||
let disposed = false
|
||||
|
||||
const update = () => keymap.setData(MODE.key, stack.at(-1)?.mode ?? MODE.base)
|
||||
|
||||
return {
|
||||
current,
|
||||
push(mode: string, enabled: Accessor<boolean>) {
|
||||
current() {
|
||||
return stack.at(-1)?.mode ?? MODE.base
|
||||
},
|
||||
push(mode: string) {
|
||||
if (disposed) return () => {}
|
||||
const id = Symbol(mode)
|
||||
// Inactive scopes retain their stack position beneath any newer modes.
|
||||
setStack((items) => [...items, { id, mode, enabled }])
|
||||
stack.push({ id, mode })
|
||||
update()
|
||||
return () => {
|
||||
setStack((items) => items.filter((item) => item.id !== id))
|
||||
const index = stack.findIndex((item) => item.id === id)
|
||||
if (index < 0) return
|
||||
stack.splice(index, 1)
|
||||
update()
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
setStack([])
|
||||
stack.length = 0
|
||||
unregister()
|
||||
keymap.setData(MODE.key, undefined)
|
||||
},
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { PanelPresentation } from "@opencode-ai/plugin/tui/context"
|
||||
import { batch, createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
export type PanelTarget = {
|
||||
readonly plugin: string
|
||||
readonly name: string
|
||||
readonly sessionID: string
|
||||
}
|
||||
|
||||
export function createPanelState() {
|
||||
const [current, setCurrent] = createSignal<PanelTarget>()
|
||||
const [requested, setRequested] = createSignal<PanelPresentation>("panel")
|
||||
const [width, setWidth] = createSignal(0)
|
||||
const canSplit = () => width() > 80
|
||||
const presentation = createMemo(() => (canSplit() ? requested() : "fullscreen"))
|
||||
return {
|
||||
current,
|
||||
width,
|
||||
canSplit,
|
||||
presentation,
|
||||
setWidth,
|
||||
open(target: PanelTarget, presentation: PanelPresentation = "panel") {
|
||||
batch(() => {
|
||||
setRequested(presentation)
|
||||
setCurrent((current) =>
|
||||
current?.plugin === target.plugin && current.name === target.name && current.sessionID === target.sessionID
|
||||
? current
|
||||
: target,
|
||||
)
|
||||
})
|
||||
},
|
||||
close: () => setCurrent(),
|
||||
release(plugin: string) {
|
||||
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)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "../theme"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -379,15 +379,12 @@ export function useTheme(context?: ContextName) {
|
||||
}
|
||||
export const ThemeProvider = themeContext.provider
|
||||
|
||||
/** Switches context without remounting children; undefined inherits the enclosing view. */
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) {
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
|
||||
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, themes: value.themes, ready: value.ready }}>
|
||||
<themeContext.context.Provider
|
||||
value={{ current: value.themes.current.contextual[props.context], themes: value.themes, ready: value.ready }}
|
||||
>
|
||||
{props.children}
|
||||
</themeContext.context.Provider>
|
||||
)
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { BoxRenderable, MouseButton } from "@opentui/core"
|
||||
import { Portal, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export function DiffFileMenu(props: {
|
||||
context: Plugin.Context
|
||||
state: { fileIndex: number; x: number; y: number }
|
||||
reviewed: boolean
|
||||
onToggle: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.overlay
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
|
||||
const width = () => Math.min(19, dimensions().width)
|
||||
const run = () => {
|
||||
props.onClose()
|
||||
props.onToggle()
|
||||
}
|
||||
onCleanup(props.context.keymap.mode.push("menu"))
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "menu",
|
||||
commands: [
|
||||
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
|
||||
{ bind: "return", title: label(), group: "Diff", run },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<Portal
|
||||
ref={(container) => {
|
||||
if (!(container instanceof BoxRenderable)) return
|
||||
// Portal's wrapper must also escape root flow, not follow the full-height app.
|
||||
container.position = "absolute"
|
||||
container.left = 0
|
||||
container.top = 0
|
||||
container.zIndex = 2600
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="diff-file-menu-overlay"
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
zIndex={2600}
|
||||
onMouseDown={(event) => {
|
||||
props.onClose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="diff-file-menu"
|
||||
position="absolute"
|
||||
left={Math.max(0, Math.min(props.state.x, dimensions().width - width()))}
|
||||
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
|
||||
width={width()}
|
||||
height={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === MouseButton.RIGHT) props.onClose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.button === MouseButton.LEFT) run()
|
||||
}}
|
||||
>
|
||||
<text fg={theme.text.default} selectable={false} wrapMode="none" truncate>
|
||||
{label()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { filetype } from "../../util/filetype"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
||||
import { DiffFileMenu } from "./diff-viewer-file-menu"
|
||||
import { DiffViewerImage, isDiffImageFile } from "./diff-viewer-image"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { EmptyBorder } from "../../ui/border"
|
||||
@@ -1077,6 +1076,76 @@ export function DiffViewerContent(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function DiffFileMenu(props: {
|
||||
context: Plugin.Context
|
||||
state: FileMenuState
|
||||
reviewed: boolean
|
||||
onToggle: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.overlay
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
|
||||
const run = () => {
|
||||
props.onClose()
|
||||
props.onToggle()
|
||||
}
|
||||
onCleanup(props.context.keymap.mode.push("menu"))
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "menu",
|
||||
commands: [
|
||||
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
|
||||
{ bind: "return", title: label(), group: "Diff", run },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
id="diff-file-menu-overlay"
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
zIndex={2600}
|
||||
onMouseDown={(event) => {
|
||||
props.onClose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="diff-file-menu"
|
||||
position="absolute"
|
||||
left={Math.max(0, Math.min(props.state.x, dimensions().width - 19))}
|
||||
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
|
||||
width={19}
|
||||
height={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === MouseButton.RIGHT) props.onClose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.button === MouseButton.LEFT) run()
|
||||
}}
|
||||
>
|
||||
<text fg={theme.text.default} selectable={false}>
|
||||
{label()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useToast } from "../ui/toast"
|
||||
import { useAttention } from "../context/attention"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useOptionalPanel } from "../context/panel"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
|
||||
export type Dispose = () => Promise<void>
|
||||
@@ -69,7 +68,6 @@ export function usePluginHost() {
|
||||
attention: useAttention(),
|
||||
storage: useStorage(),
|
||||
sessionTabs: useSessionTabs(),
|
||||
panel: useOptionalPanel(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +82,6 @@ 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
|
||||
@@ -177,21 +174,6 @@ 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: () =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prompt, type PromptRef } from "../component/prompt"
|
||||
import { createEffect, createMemo, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useRouteData } from "../context/route"
|
||||
@@ -11,11 +11,6 @@ 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 = {
|
||||
@@ -86,16 +81,13 @@ export function Home() {
|
||||
paddingRight={dimensions().width < 44 ? 1 : 2}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={3} minHeight={0} flexShrink={1} />
|
||||
<box height={4} 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} position="relative">
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<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>
|
||||
@@ -117,88 +109,3 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ 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 {
|
||||
@@ -49,8 +48,6 @@ 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()
|
||||
@@ -71,7 +68,6 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
})
|
||||
|
||||
let textarea: TextareaRenderable | undefined
|
||||
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable>()
|
||||
let review: ScrollBoxRenderable | undefined
|
||||
let measureReview: (() => void) | undefined
|
||||
|
||||
@@ -220,22 +216,9 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
|
||||
})
|
||||
|
||||
// Refs publish after initialization so burst typing stays with the interceptor until the editor is ready.
|
||||
createEffect(() => {
|
||||
const target = inputTarget()
|
||||
if (!target || target.isDestroyed) return
|
||||
if (!active()) {
|
||||
target.blur()
|
||||
target.focusable = false
|
||||
return
|
||||
}
|
||||
target.focusable = true
|
||||
target.focus()
|
||||
})
|
||||
|
||||
onCleanup(
|
||||
keymap.intercept("key", ({ event, consume }) => {
|
||||
if (!active()) return
|
||||
if (keymap.mode.current() !== FORM_MODE) return
|
||||
if (textual() || !other() || (store.editing && renderer.currentFocusedEditor === textarea)) return
|
||||
if (event.ctrl || event.meta || event.option || event.super || event.hyper) return
|
||||
if ((!store.editing && event.sequence === " ") || !/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)) return
|
||||
@@ -345,7 +328,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
}
|
||||
|
||||
usePaste((event) => {
|
||||
if (!active()) return
|
||||
if (keymap.mode.current() !== FORM_MODE) return
|
||||
const value = stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n")
|
||||
if (store.editing && renderer.currentFocusedEditor === textarea) {
|
||||
textarea.insertText(value)
|
||||
@@ -360,7 +343,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
return clipboard
|
||||
.read()
|
||||
.then((content) => {
|
||||
if (!active() || content?.mime !== "text/plain") return
|
||||
if (content?.mime !== "text/plain") return
|
||||
const value = stripAnsiSequences(content.data).replace(/\r\n?/g, "\n")
|
||||
if (store.editing || textual()) {
|
||||
textarea?.insertText(value)
|
||||
@@ -895,9 +878,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
textarea = val
|
||||
val.traits = { status: "ANSWER" }
|
||||
queueMicrotask(() => {
|
||||
if (val.isDestroyed) return
|
||||
val.focus()
|
||||
val.gotoLineEnd()
|
||||
setInputTarget(val)
|
||||
})
|
||||
}}
|
||||
initialValue={
|
||||
@@ -1035,10 +1017,9 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
textarea = val
|
||||
val.traits = { status: "ANSWER" }
|
||||
queueMicrotask(() => {
|
||||
if (val.isDestroyed) return
|
||||
val.setText(input())
|
||||
val.focus()
|
||||
val.gotoLineEnd()
|
||||
setInputTarget(val)
|
||||
})
|
||||
}}
|
||||
initialValue={input()}
|
||||
|
||||
@@ -113,6 +113,7 @@ import { createDelayedPresence } from "../../util/delayed-presence"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
import { isRecord } from "../../util/record"
|
||||
import { createHistoryPrepend } from "./history"
|
||||
import { useSessionTerminals } from "../../context/session-terminals"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -160,7 +161,6 @@ export function Session(props: {
|
||||
sidebarVisible: boolean
|
||||
onToggleSidebar: () => void
|
||||
visibleTerminalID?: string
|
||||
onTerminalPicker?: (show: (() => void) | undefined) => void
|
||||
width?: number
|
||||
}) {
|
||||
const setEpilogue = useEpilogue()
|
||||
@@ -234,8 +234,6 @@ export function Session(props: {
|
||||
open: false,
|
||||
tab: undefined as string | undefined,
|
||||
})
|
||||
props.onTerminalPicker?.(() => setComposer({ open: true, tab: "terminals" }))
|
||||
onCleanup(() => props.onTerminalPicker?.(undefined))
|
||||
createEffect(() => {
|
||||
if (props.promptMuted && composer.open) setComposer("open", false)
|
||||
})
|
||||
@@ -262,6 +260,7 @@ export function Session(props: {
|
||||
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
const toast = useToast()
|
||||
const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" })
|
||||
const client = useClient()
|
||||
const autoApproved = new Set<string>()
|
||||
createEffect(() => {
|
||||
@@ -296,6 +295,7 @@ export function Session(props: {
|
||||
const [firstJump, setFirstJump] = createSignal<() => void>()
|
||||
const [synced, setSynced] = createSignal(false)
|
||||
const sessionTabs = useSessionTabs()
|
||||
const terminals = useSessionTerminals()
|
||||
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
|
||||
const [latestHovered, setLatestHovered] = createSignal(false)
|
||||
let ensureAllRowsPending: (() => void)[] | undefined
|
||||
@@ -949,21 +949,13 @@ export function Session(props: {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
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 }))
|
||||
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: [],
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -984,6 +976,73 @@ export function Session(props: {
|
||||
})()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: props.sidebarVisible ? "Hide sidebar" : "Show sidebar",
|
||||
id: "session.sidebar.toggle",
|
||||
group: "Session",
|
||||
run: () => {
|
||||
props.onToggleSidebar()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
...(config.session.terminal
|
||||
? [
|
||||
{
|
||||
title: props.visibleTerminalID ? "Hide terminal pane" : "Show terminal pane",
|
||||
id: "terminal.toggle",
|
||||
group: "Session",
|
||||
run: () => {
|
||||
const sessionID = route.sessionID
|
||||
if (props.visibleTerminalID) {
|
||||
promptRef.current?.focus()
|
||||
void terminals.selectTerminal(sessionID, null).catch(toast.error)
|
||||
} else {
|
||||
void terminals
|
||||
.refresh(sessionID)
|
||||
.then(async () => {
|
||||
const terminal = terminals.get(sessionID).terminals.at(-1)
|
||||
if (terminal) return terminals.selectTerminal(sessionID, terminal.id)
|
||||
await terminals.newTerminal(sessionID)
|
||||
})
|
||||
.catch(terminalError)
|
||||
}
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Select terminal",
|
||||
id: "terminal.select",
|
||||
group: "Session",
|
||||
run: () => {
|
||||
promptRef.current?.focus()
|
||||
setComposer({ open: true, tab: "terminals" })
|
||||
void terminals.refresh(route.sessionID).catch(terminalError)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Close terminal pane",
|
||||
id: "terminal.close",
|
||||
group: "Session",
|
||||
enabled: props.visibleTerminalID !== undefined,
|
||||
run: () => {
|
||||
promptRef.current?.focus()
|
||||
void terminals.selectTerminal(route.sessionID, null).catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "New terminal",
|
||||
id: "session.terminal",
|
||||
group: "Session",
|
||||
slash: { name: "terminal" },
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
await terminals.newTerminal(route.sessionID).catch(terminalError)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: (() => {
|
||||
const next = nextThinkingMode(thinkingMode())
|
||||
@@ -1448,6 +1507,7 @@ export function Session(props: {
|
||||
<Prompt
|
||||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
muted={props.promptMuted}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
|
||||
@@ -11,13 +11,12 @@ 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" | "reject"
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
const theme = useTheme()
|
||||
@@ -141,6 +140,27 @@ 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}
|
||||
@@ -165,7 +185,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" ? (
|
||||
@@ -220,15 +240,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
|
||||
instance={props.request.id}
|
||||
header={header()}
|
||||
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>
|
||||
)}
|
||||
body={presentationBody}
|
||||
options={
|
||||
props.request.save?.length
|
||||
? {
|
||||
@@ -242,7 +254,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
fullscreen
|
||||
onSelect={(option) => {
|
||||
if (option === "always") {
|
||||
reply("always")
|
||||
setStore("stage", "always")
|
||||
return
|
||||
}
|
||||
if (option === "reject") {
|
||||
@@ -276,7 +288,6 @@ function RejectPrompt(props: {
|
||||
onCancel: () => void
|
||||
}) {
|
||||
let input: TextareaRenderable
|
||||
const enabled = useInteractivity()
|
||||
const theme = useTheme("elevated")
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -353,7 +364,7 @@ function RejectPrompt(props: {
|
||||
}))(val)
|
||||
val.traits = { status: "REJECT" }
|
||||
}}
|
||||
focused={enabled()}
|
||||
focused
|
||||
textColor={theme.text.default}
|
||||
focusedTextColor={theme.text.default}
|
||||
cursorColor={theme.text.default}
|
||||
@@ -412,7 +423,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
group?: string
|
||||
choicesLabel?: string
|
||||
header?: JSX.Element
|
||||
body: JSX.Element | ((option: keyof T) => JSX.Element)
|
||||
body: JSX.Element
|
||||
options: T
|
||||
escapeKey?: keyof T
|
||||
fullscreen?: boolean
|
||||
@@ -536,7 +547,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
{props.header}
|
||||
</box>
|
||||
</Show>
|
||||
{typeof props.body === "function" ? props.body(store.selected) : props.body}
|
||||
{props.body}
|
||||
</box>
|
||||
<box
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
|
||||
@@ -3,16 +3,7 @@ import type { Accessor } from "solid-js"
|
||||
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode-ai/theme/tui"
|
||||
|
||||
export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
|
||||
return Object.assign(createComponentThemeView(current, mode), {
|
||||
contextual: {
|
||||
elevated: createComponentThemeView(() => current().contextual.elevated, mode),
|
||||
overlay: createComponentThemeView(() => current().contextual.overlay, mode),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mode: Accessor<Mode>) {
|
||||
return {
|
||||
const create = (view: Accessor<ResolvedThemeTokens>) => ({
|
||||
get hue() {
|
||||
return view().hue
|
||||
},
|
||||
@@ -44,7 +35,14 @@ export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mo
|
||||
increase: (color: RGBA, amount = 1) => view().increase(color, amount),
|
||||
decrease: (color: RGBA, amount = 1) => view().decrease(color, amount),
|
||||
raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)),
|
||||
}
|
||||
})
|
||||
|
||||
return Object.assign(create(current), {
|
||||
contextual: {
|
||||
elevated: create(() => current().contextual.elevated),
|
||||
overlay: create(() => current().contextual.overlay),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type ComponentTheme = ReturnType<typeof createComponentTheme>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function clampSessionTabsWidth(width: number, total: number) {
|
||||
)
|
||||
}
|
||||
|
||||
export function clampSessionPaneWidth(width: number, total: number) {
|
||||
export function clampTerminalPaneWidth(width: number, total: number) {
|
||||
const half = Math.max(1, Math.floor(total / 2))
|
||||
// Preserve the equal split when there is not enough room for both pane minima.
|
||||
return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH)))
|
||||
|
||||
@@ -1228,7 +1228,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
|
||||
})
|
||||
|
||||
test.each(["manual", "select"] as const)(
|
||||
"selection copy and pane management respect %s mode in the prompt and terminal pane",
|
||||
"selection copy and dismissal respect %s mode in the prompt and terminal pane",
|
||||
async (copy) => {
|
||||
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
@@ -1359,19 +1359,6 @@ test.each(["manual", "select"] as const)(
|
||||
expect(setup.renderer.hasSelection).toBeFalse()
|
||||
expect(setup.renderer.isDestroyed).toBeFalse()
|
||||
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressArrow("up")
|
||||
await setup.waitFor(() => terminal.isDestroyed)
|
||||
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressKey("t")
|
||||
await setup.waitForFrame((frame) => frame.includes("alpha beta gamma"))
|
||||
expect(setup.renderer.currentFocusedRenderable).toBeInstanceOf(EmbeddedTerminalRenderable)
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressArrow("down")
|
||||
await setup.waitForFrame((frame) => frame.includes("Subagents") && frame.includes("Terminals"))
|
||||
expect(setup.renderer.currentFocusedRenderable).not.toBeInstanceOf(EmbeddedTerminalRenderable)
|
||||
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
|
||||
@@ -1579,89 +1579,6 @@ 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"
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { PermissionRequest } from "@opencode-ai/client"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { testRender, type JSX } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData, type FormWithLocation } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { 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()
|
||||
}
|
||||
})
|
||||
@@ -2,7 +2,6 @@
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { createSignal } from "solid-js"
|
||||
import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { DEFAULT_THEMES } from "../../../src/theme"
|
||||
@@ -167,58 +166,10 @@ 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.text.default).toBe(explicit.text.default)
|
||||
expect(theme).toBe(explicit)
|
||||
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()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/** @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()
|
||||
}
|
||||
})
|
||||
@@ -1,271 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { Keymap } from "../src/context/keymap"
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createPanelState } from "../src/context/panel"
|
||||
|
||||
test("presentation changes preserve the selected panel identity", () => {
|
||||
createRoot((dispose) => {
|
||||
const panels = createPanelState()
|
||||
panels.setWidth(160)
|
||||
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
|
||||
const current = panels.current()
|
||||
expect(panels.presentation()).toBe("panel")
|
||||
panels.toggleFullscreen()
|
||||
expect(panels.presentation()).toBe("fullscreen")
|
||||
expect(panels.current()).toBe(current)
|
||||
panels.toggleFullscreen()
|
||||
expect(panels.presentation()).toBe("panel")
|
||||
expect(panels.current()).toBe(current)
|
||||
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
|
||||
expect(panels.current()).toBe(current)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("narrow geometry overrides presentation without discarding the user's choice", () => {
|
||||
createRoot((dispose) => {
|
||||
const panels = createPanelState()
|
||||
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
|
||||
panels.setWidth(80)
|
||||
expect(panels.canSplit()).toBe(false)
|
||||
expect(panels.presentation()).toBe("fullscreen")
|
||||
panels.toggleFullscreen()
|
||||
panels.setWidth(81)
|
||||
expect(panels.canSplit()).toBe(true)
|
||||
expect(panels.presentation()).toBe("panel")
|
||||
panels.toggleFullscreen()
|
||||
panels.setWidth(60)
|
||||
panels.setWidth(160)
|
||||
expect(panels.presentation()).toBe("fullscreen")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("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()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextName } from "@opencode-ai/theme/tui"
|
||||
import { createComponentTheme, createComponentThemeView } from "../../../src/theme/component"
|
||||
import { createComponentTheme } from "../../../src/theme/component"
|
||||
|
||||
test("provides reactive properties, states, contexts, and color operations", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
@@ -67,19 +67,3 @@ test("provides reactive properties, states, contexts, and color operations", ()
|
||||
expect(current().decrease(current().background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
|
||||
expect(current().raise(current().background.surface.offset)).toBe(resolved().hue.neutral[600])
|
||||
})
|
||||
|
||||
test("a stable component theme view follows presentation context changes", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||
const [context, setContext] = createSignal<ContextName>()
|
||||
const theme = createComponentThemeView(
|
||||
() => (context() ? resolved().contextual[context()!] : resolved()),
|
||||
() => "dark",
|
||||
)
|
||||
expect(theme.background.default).toBe(resolved().background.default)
|
||||
setContext("elevated")
|
||||
expect(theme.background.default).toBe(resolved().contextual.elevated.background.default)
|
||||
setContext(undefined)
|
||||
expect(theme.background.default).toBe(resolved().background.default)
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
expect(theme.text.default).toBe(resolved().text.default)
|
||||
})
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
"enum": ["disable", "notify"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
@@ -18221,12 +18221,6 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18454,9 +18448,6 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
"enum": ["disable", "notify"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
@@ -18221,12 +18221,6 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18454,9 +18448,6 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -236,7 +236,10 @@ function Status() {
|
||||
Register a fenced-code renderer by language; the returned function unregisters it.
|
||||
|
||||
```ts
|
||||
const unregister = context.markdown.registerCodeBlockRenderer("acme", (_token, render) => render.defaultRender())
|
||||
const unregister = context.markdown.registerCodeBlockRenderer(
|
||||
"acme",
|
||||
(_token, render) => render.defaultRender(),
|
||||
)
|
||||
return unregister
|
||||
```
|
||||
|
||||
@@ -337,14 +340,7 @@ Custom JSX dialogs can set their size and close themselves.
|
||||
|
||||
```tsx
|
||||
context.ui.dialog.set({ size: "large", centered: true })
|
||||
context.ui.dialog.show(
|
||||
() => (
|
||||
<box>
|
||||
<text>Acme</text>
|
||||
</box>
|
||||
),
|
||||
() => console.log("closed"),
|
||||
)
|
||||
context.ui.dialog.show(() => <box><text>Acme</text></box>, () => console.log("closed"))
|
||||
context.ui.dialog.clear()
|
||||
```
|
||||
|
||||
@@ -409,84 +405,6 @@ context.ui.slot({ after: "home.footer", render: () => <text>After footer slot</t
|
||||
context.ui.slot({ replace: "home.footer", render: () => <text>New footer</text> })
|
||||
```
|
||||
|
||||
### Session panels
|
||||
|
||||
Register a 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.
|
||||
|
||||
@@ -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. 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
|
||||
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
|
||||
coalesce into that pending request. Whether compaction completes or fails, the
|
||||
barrier is then settled so pending prompts can proceed.
|
||||
barrier is then settled so later 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
|
||||
|
||||
@@ -130,10 +130,7 @@ agents.
|
||||
### Updates
|
||||
|
||||
Control update checks from the global config. Set `update` to `"disable"` to
|
||||
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.
|
||||
skip them or `"notify"` to show available updates before installing them.
|
||||
Project-level values are ignored.
|
||||
|
||||
```jsonc
|
||||
|
||||
@@ -409,7 +409,8 @@ 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"`, `"notify"` maps to `"notify"`, and `true` maps to `"auto"`.
|
||||
- `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"`.
|
||||
- `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use
|
||||
`agents.title.model` instead.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user