Compare commits

..
23 changed files with 77 additions and 347 deletions
+6 -2
View File
@@ -137,6 +137,7 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
@@ -165,6 +166,7 @@
"@parcel/watcher-win32-x64": "2.5.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
@@ -711,12 +713,12 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/simulation": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -914,6 +916,7 @@
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/theme": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -933,7 +936,6 @@
"uqr": "0.1.3",
},
"devDependencies": {
"@opencode-ai/simulation": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -6428,6 +6430,8 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
"@opencode-ai/console-app/@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
+2
View File
@@ -38,6 +38,7 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
@@ -50,6 +51,7 @@
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
-33
View File
@@ -1,33 +0,0 @@
export const simulationExternals = ["@opencode-ai/simulation", "@opencode-ai/simulation/*"]
export const buildTargets: ReadonlyArray<{
readonly os: string
readonly arch: "arm64" | "x64"
readonly abi?: "musl"
readonly avx2?: false
}> = [
{ os: "linux", arch: "arm64" },
{ os: "linux", arch: "x64" },
{ os: "linux", arch: "x64", avx2: false },
{ os: "linux", arch: "arm64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl", avx2: false },
{ os: "darwin", arch: "arm64" },
{ os: "darwin", arch: "x64" },
{ os: "darwin", arch: "x64", avx2: false },
{ os: "win32", arch: "arm64" },
{ os: "win32", arch: "x64" },
{ os: "win32", arch: "x64", avx2: false },
]
export function targetName(item: (typeof buildTargets)[number], binary = "opencode2") {
return [
binary,
item.os === "win32" ? "windows" : item.os,
item.arch,
item.avx2 === false ? "baseline" : undefined,
item.abi,
]
.filter(Boolean)
.join("-")
}
+36 -6
View File
@@ -8,7 +8,6 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { buildAppArchive } from "./app-assets"
import { buildTargets, simulationExternals, targetName } from "./build-config"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -28,16 +27,36 @@ const skipInstall = process.argv.includes("--skip-install")
const skipWebUi = process.argv.includes("--skip-web-ui")
const solidPlugin = createSolidTransformPlugin()
const allTargets: {
os: string
arch: "arm64" | "x64"
abi?: "musl"
avx2?: false
}[] = [
{ os: "linux", arch: "arm64" },
{ os: "linux", arch: "x64" },
{ os: "linux", arch: "x64", avx2: false },
{ os: "linux", arch: "arm64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl", avx2: false },
{ os: "darwin", arch: "arm64" },
{ os: "darwin", arch: "x64" },
{ os: "darwin", arch: "x64", avx2: false },
{ os: "win32", arch: "arm64" },
{ os: "win32", arch: "x64" },
{ os: "win32", arch: "x64", avx2: false },
]
const targets =
requestedTarget !== undefined
? buildTargets.filter((item) => targetName(item) === requestedTarget)
? allTargets.filter((item) => targetName(item) === requestedTarget)
: singleFlag
? buildTargets.filter((item) => {
? allTargets.filter((item) => {
if (item.os !== process.platform || item.arch !== process.arch) return false
if (item.avx2 === false) return baselineFlag
return item.abi === undefined
})
: buildTargets
: allTargets
if (!targets.length) throw new Error(`Unknown build target: ${requestedTarget}`)
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
@@ -74,7 +93,7 @@ for (const item of targets) {
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
external: ["node-gyp", ...simulationExternals],
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: "inline",
@@ -93,7 +112,6 @@ for (const item of targets) {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_SIMULATION: "false",
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
@@ -122,3 +140,15 @@ for (const item of targets) {
),
)
}
function targetName(item: (typeof allTargets)[number]) {
return [
binary,
item.os === "win32" ? "windows" : item.os,
item.arch,
item.avx2 === false ? "baseline" : undefined,
item.abi,
]
.filter(Boolean)
.join("-")
}
-22
View File
@@ -6,14 +6,12 @@ import { Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { verifyArtifact } from "./verify-artifact"
const nodeBuild = process.argv.includes("--node")
const target = `cli${nodeBuild ? "-node" : ""}-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["node"] : []), target, "bin")
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
await verifyArtifact(binary)
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")))
const env = {
@@ -31,7 +29,6 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
const errors: Array<Promise<string>> = []
let failure: unknown
try {
await verifySimulationDiagnostic()
await fs.mkdir(path.join(root, ".opencode"))
spawnService()
spawnService()
@@ -107,25 +104,6 @@ if (failure)
cause: failure,
})
async function verifySimulationDiagnostic() {
const process = Bun.spawn([binary, "--version"], {
env: { ...env, OPENCODE_DRIVE: "artifact-smoke" },
stdout: "pipe",
stderr: "pipe",
})
const [exit, stdout, stderr] = await Promise.all([
process.exited,
new Response(process.stdout).text(),
new Response(process.stderr).text(),
])
if (exit === 0) throw new Error("Compiled CLI accepted a simulation request")
const output = stdout + stderr
if (!output.includes("Simulation is not included in production OpenCode builds"))
throw new Error(`Compiled CLI returned an opaque simulation error: ${output}`)
if (!output.includes("opencode-drive start --dev <checkout>"))
throw new Error(`Compiled CLI omitted the simulation migration command: ${output}`)
}
function spawnService() {
const process = Bun.spawn([binary, "serve", "--service"], { env, stdout: "ignore", stderr: "pipe" })
processes.push(process)
-25
View File
@@ -1,25 +0,0 @@
import path from "node:path"
export async function verifyArtifact(binary: string) {
const contents = Buffer.from(await Bun.file(binary).arrayBuffer())
const target = path.basename(path.dirname(path.dirname(binary)))
const platform = target.includes("darwin") ? "darwin" : target.includes("linux") ? "linux" : "win32"
const markers = [
"SimulationPng",
"Drive.create",
"@napi-rs/canvas",
"commit-mono-latin-400-normal",
"noto-sans-symbols-symbols-400-normal",
"noto-sans-math-math-400-normal",
"../simulation/src/",
`skia.${platform}-`,
]
const found = markers.filter((marker) => contents.includes(marker))
if (found.length > 0) throw new Error(`Compiled CLI contains simulation artifacts: ${found.join(", ")}`)
}
if (import.meta.main) {
const binary = process.argv[2]
if (!binary) throw new Error("Usage: bun run script/verify-artifact.ts <binary>")
await verifyArtifact(binary)
}
-6
View File
@@ -12,12 +12,6 @@ import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Npm } from "@opencode-ai/util/npm"
import { SimulationBuild } from "./simulation"
if (!SimulationBuild.enabled && (process.env.OPENCODE_DRIVE || process.env.OPENCODE_SIMULATE)) {
console.error(SimulationBuild.unavailable)
process.exit(1)
}
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@@ -1,50 +0,0 @@
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
const maximumComponent = "9007199254740991"
const versionPattern =
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "none"
return "upgrade"
}
function parseReleaseVersion(input: string) {
if (input.length > 256) return
const match = input.trim().match(versionPattern)
if (!match) return
if ([match[1], match[2], match[3]].some(invalidComponent)) return
if (
match[4]
?.split(".")
.some((identifier) => identifier.length > 1 && identifier.startsWith("0") && /^[0-9]+$/.test(identifier))
)
return
return {
major: match[1],
core: `${match[1]}.${match[2]}.${match[3]}`,
prerelease: match[4]?.split(".") ?? [],
}
}
function sameRelease(current: NonNullable<ReturnType<typeof parseReleaseVersion>>, latest: typeof current) {
if (current.core !== latest.core || current.prerelease.length !== latest.prerelease.length) return false
return current.prerelease.every((identifier, index) => {
const other = latest.prerelease[index]
if (identifier === other) return true
// semver compares oversized numeric prerelease identifiers after numeric coercion.
return /^[0-9]+$/.test(identifier) && /^[0-9]+$/.test(other) && Number(identifier) === Number(other)
})
}
function invalidComponent(value: string) {
if (value.length > 1 && value.startsWith("0")) return true
if (value.length !== maximumComponent.length) return value.length > maximumComponent.length
return value > maximumComponent
}
+1 -46
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import { action } from "./updater-action"
import { decodePolicy } from "./updater"
import { action, decodePolicy } from "./updater"
describe("updater", () => {
test("reads autoupdate from JSONC", () => {
@@ -31,48 +30,4 @@ describe("updater", () => {
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
})
test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-next-17403.2", true)).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
})
test("preserves strict validity", () => {
const invalid = [
"=1.2.3",
"V1.2.3",
"1.2",
"1.2.3.4",
"01.2.3",
"1.02.3",
"1.2.03",
"1.2.3-01",
"1.2.3-",
"1.2.3+",
"1.2.3-alpha..1",
"1.2.3_alpha",
"9007199254740992.0.0",
"0.9007199254740992.0",
"0.0.9007199254740992",
]
invalid.forEach((version) => expect(action("1.2.3", version, true), version).toBe("none"))
})
test("handles numeric limits without losing precision", () => {
expect(action("9007199254740991.0.0", "9007199254740991.0.1", true)).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", true)).toBe("none")
})
test("preserves equality for oversized numeric prerelease identifiers", () => {
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", true)).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", true)).toBe("upgrade")
})
test("rejects versions longer than semver's limit before trimming", () => {
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, true)).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, true)).toBe("upgrade")
})
})
+11 -2
View File
@@ -5,10 +5,12 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, type Policy } from "./updater-action"
import semver from "semver"
declare const OPENCODE_CLI_NAME: string | undefined
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
type Method = "npm" | "pnpm" | "bun" | "yarn"
const packageName =
@@ -32,6 +34,14 @@ export function decodePolicy(text: string): Policy | undefined {
if (typeof value === "boolean" || value === "notify") return value
}
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
// Major upgrades are never installed automatically.
if (semver.major(latest) !== semver.major(current)) return "none"
return "upgrade"
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -156,4 +166,3 @@ export const layer = Layer.effect(
)
export * as Updater from "./updater"
export { action, type Action, type Policy } from "./updater-action"
-6
View File
@@ -1,6 +0,0 @@
declare const OPENCODE_SIMULATION: boolean
export const SimulationBuild = {
enabled: typeof OPENCODE_SIMULATION === "boolean" ? OPENCODE_SIMULATION : true,
unavailable: "Simulation is not included in production OpenCode builds. Use opencode-drive start --dev <checkout>.",
}
-25
View File
@@ -1,25 +0,0 @@
import { describe, expect, test } from "bun:test"
import { buildTargets, simulationExternals, targetName } from "../script/build-config"
describe("published Bun build configuration", () => {
test("externalizes the simulation package and every subpath", () => {
expect(simulationExternals).toEqual(["@opencode-ai/simulation", "@opencode-ai/simulation/*"])
})
test("covers every configured target with the same production build", () => {
expect(buildTargets.map((target) => targetName(target))).toEqual([
"opencode2-linux-arm64",
"opencode2-linux-x64",
"opencode2-linux-x64-baseline",
"opencode2-linux-arm64-musl",
"opencode2-linux-x64-musl",
"opencode2-linux-x64-baseline-musl",
"opencode2-darwin-arm64",
"opencode2-darwin-x64",
"opencode2-darwin-x64-baseline",
"opencode2-windows-arm64",
"opencode2-windows-x64",
"opencode2-windows-x64-baseline",
])
})
})
-1
View File
@@ -253,7 +253,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
OPENCODE_VERSION: JSON.stringify(input.version),
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_SIMULATION: "false",
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
},
-4
View File
@@ -168,10 +168,6 @@ Use `opencode-drive check ./drive.ts` and `start --script` for the Effect-native
## OpenCode development
Simulation is available only from an OpenCode development checkout after its
normal workspace install. Published `opencode2` and `opencode2-node` binaries do
not include the simulation runtime.
Run this:
```sh
+7 -10
View File
@@ -100,10 +100,7 @@ const startCommand = Command.make(
record: Flag.boolean("record").pipe(
Flag.withDescription("Record the complete headless session and export it on stop"),
),
dev: Flag.string("dev").pipe(
Flag.optional,
Flag.withDescription("Path to the required OpenCode development checkout"),
),
dev: Flag.string("dev").pipe(Flag.optional, Flag.withDescription("Path to an OpenCode development checkout")),
},
(config) =>
executeEffect(
@@ -116,16 +113,16 @@ const startCommand = Command.make(
Command.withDescription("Launch a local simulated OpenCode instance"),
Command.withExamples([
{
command: "opencode-drive start --name demo --dev <checkout>",
description: "Launch a headless development checkout on the default ports",
command: "opencode-drive start --name demo",
description: "Launch headless OpenCode on the default ports",
},
{
command: "opencode-drive start --visible --dev <checkout>",
description: "Launch a visible development checkout on the default ports",
command: "opencode-drive start --visible",
description: "Launch visible OpenCode on the default ports",
},
{
command: "opencode-drive start --name demo --script ./drive.ts --dev <checkout>",
description: "Launch a headless development checkout and run a script",
command: "opencode-drive start --name demo --script ./drive.ts",
description: "Launch headless OpenCode and run a script",
},
]),
)
+1 -1
View File
@@ -18,12 +18,12 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/simulation": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
-8
View File
@@ -45,8 +45,6 @@ import { sessionLocationLayer } from "./middleware/session-location"
import { ServerInfo } from "./server-info"
import type { ServerOptions } from "./options"
declare const OPENCODE_SIMULATION: boolean
const applicationServiceNodes = [
Global.node,
Database.node,
@@ -136,12 +134,6 @@ function makeRoutes<AuthError, AuthServices>(
const serviceLayer = options.simulation
? Layer.unwrap(
Effect.gen(function* () {
if (typeof OPENCODE_SIMULATION === "boolean" && !OPENCODE_SIMULATION)
return yield* Effect.fail(
new Error(
"Simulation is not included in production OpenCode builds. Use opencode-drive start --dev <checkout>.",
),
)
const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
const simulation = yield* simulationReplacements({ version: App.make(options.app).version })
return AppNodeBuilder.build(embedded ? embeddedApplicationServices : applicationServices, [
+1 -1
View File
@@ -85,6 +85,7 @@
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/theme": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -104,7 +105,6 @@
"uqr": "0.1.3"
},
"devDependencies": {
"@opencode-ai/simulation": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
+2 -10
View File
@@ -101,8 +101,6 @@ import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage"
import { createTuiClipboard } from "./clipboard"
declare const OPENCODE_SIMULATION: boolean
registerOpencodeSpinner()
const appGlobalBindingCommands = ["session.list", "session.new", "open.menu"] as const
@@ -247,14 +245,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
)
}
if (process.env.OPENCODE_DRIVE) {
if (typeof OPENCODE_SIMULATION === "boolean" && !OPENCODE_SIMULATION)
return yield* Effect.fail(
new Error(
"Simulation is not included in production OpenCode builds. Use opencode-drive start --dev <checkout>.",
),
)
const { create } = yield* Effect.promise(() => import("@opencode-ai/simulation/frontend"))
return yield* create(options, input.app.version)
const { Drive } = yield* Effect.promise(() => import("@opencode-ai/simulation/frontend"))
return yield* Drive.create(options, input.app.version)
}
return yield* Effect.acquireRelease(
Effect.tryPromise({
+2 -6
View File
@@ -1530,13 +1530,9 @@ function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
return name === "shell" || name === "subagent"
})
if (!current || !part) return
return { key: `${current.id}:${part.id}`, started: part.time.ran ?? part.time.created }
return `${current.id}:${part.id}`
})
const visible = createDelayedPresence(
running,
(tool) => Math.max(0, BACKGROUND_TOOL_HINT_DELAY - (Date.now() - tool.started)),
(previous, next) => previous.key === next.key && previous.started === next.started,
)
const visible = createDelayedPresence(running, BACKGROUND_TOOL_HINT_DELAY)
return (
<Show when={visible() && shortcut()}>
{(value) => (
+5 -23
View File
@@ -1,32 +1,14 @@
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
export function createDelayedPresence<T>(
source: Accessor<T | undefined>,
delay: number | ((value: T) => number),
equals?: (previous: T, next: T) => boolean,
) {
export function createDelayedPresence<T>(source: Accessor<T | undefined>, delay: number) {
const [visible, setVisible] = createSignal(false)
const value = equals
? createMemo(source, undefined, {
equals: (previous, next) => {
if (previous === undefined || next === undefined) return previous === next
return equals(previous, next)
},
})
: source
createEffect(() => {
const current = value()
const value = source()
setVisible(false)
if (current === undefined) return
if (value === undefined) return
const remaining = typeof delay === "function" ? delay(current) : delay
if (remaining <= 0) {
setVisible(true)
return
}
const timer = setTimeout(() => setVisible(true), remaining)
const timer = setTimeout(() => setVisible(true), delay)
onCleanup(() => clearTimeout(timer))
})
@@ -219,7 +219,7 @@ test("only the foreground TUI mutates unread state", async () => {
let background: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
try {
foreground = await renderSessionTabs("first", { state: temporary.path })
foreground = await renderSessionTabs("first", { state: temporary.path, persisted: ["first", "second"] })
background = await renderSessionTabs("second", { state: temporary.path })
foreground.focus()
background.blur()
@@ -239,6 +239,7 @@ test("only the foreground TUI mutates unread state", async () => {
() =>
foreground?.tabs.status("second").unread === "activity" &&
background?.tabs.status("second").unread === "activity",
10_000,
)
foreground.tabs.select("second")
@@ -246,6 +247,7 @@ test("only the foreground TUI mutates unread state", async () => {
() =>
foreground?.tabs.status("second").unread === undefined &&
background?.tabs.status("second").unread === undefined,
10_000,
)
} finally {
if (foreground) await foreground.destroy()
@@ -53,62 +53,3 @@ test("cancels the delay when the value disappears or the owner is disposed", asy
jest.useRealTimers()
}
})
test("uses the remaining delay for the current value", async () => {
jest.useFakeTimers()
const scope = createRoot((dispose) => {
const [value, setValue] = createSignal<{ age: number }>()
return {
dispose,
setValue,
visible: createDelayedPresence(value, (current) => Math.max(0, 1_000 - current.age)),
}
})
try {
scope.setValue({ age: 400 })
await Promise.resolve()
jest.advanceTimersByTime(599)
expect(scope.visible()).toBe(false)
jest.advanceTimersByTime(1)
expect(scope.visible()).toBe(true)
scope.setValue({ age: 1_000 })
await Promise.resolve()
expect(scope.visible()).toBe(true)
} finally {
scope.dispose()
jest.useRealTimers()
}
})
test("does not restart the delay for an equivalent value", async () => {
jest.useFakeTimers()
const scope = createRoot((dispose) => {
const [value, setValue] = createSignal<{ id: string }>()
return {
dispose,
setValue,
visible: createDelayedPresence(value, 1_000, (previous, next) => previous.id === next.id),
}
})
try {
scope.setValue({ id: "first" })
await Promise.resolve()
jest.advanceTimersByTime(500)
scope.setValue({ id: "first" })
await Promise.resolve()
jest.advanceTimersByTime(499)
expect(scope.visible()).toBe(false)
jest.advanceTimersByTime(1)
expect(scope.visible()).toBe(true)
scope.setValue({ id: "second" })
await Promise.resolve()
expect(scope.visible()).toBe(false)
} finally {
scope.dispose()
jest.useRealTimers()
}
})