Compare commits

..

10 Commits

Author SHA1 Message Date
LukeParkerDev 07abdeef1f fix(desktop): use matching v2 CLI in WSL 2026-08-13 10:45:22 +10:00
Luke Parker af127a643b refactor(desktop): run local server from source (#42194) 2026-08-13 10:44:45 +10:00
Kit Langton 77aa1cfede fix(tui): truncate queued prompt preview (#42196) 2026-08-12 20:42:41 -04:00
opencode-agent[bot] a2ed936283 chore: generate 2026-08-13 00:30:58 +00:00
Kit Langton b17fbf41e3 feat(catalog): click-to-annotate captures with GitHub issue handoff (#42183) 2026-08-12 20:29:32 -04:00
Kit Langton 9d6e05b6e4 fix(cli): inset update footer (#42189) 2026-08-12 20:15:56 -04:00
Kit Langton 9b805c140f feat(tui): render Mermaid GitGraph diagrams (#42179) 2026-08-12 20:10:52 -04:00
Kit Langton d31a994c27 feat(tui): render Mermaid timelines (#42130) 2026-08-12 19:55:39 -04:00
Kit Langton 76640a5c9c feat(sdk): re-export Event schema from sdk-next (#42175) 2026-08-12 19:44:33 -04:00
Kit Langton 76dbaf20ad fix(catalog): serve app shell at lab route (#42159) 2026-08-12 18:18:24 -04:00
48 changed files with 2527 additions and 620 deletions
+24 -16
View File
@@ -22,6 +22,7 @@ await rm(outdir, { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const requestedTarget = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
const skipInstall = process.argv.includes("--skip-install")
const skipWebUi = process.argv.includes("--skip-web-ui")
const solidPlugin = createSolidTransformPlugin()
@@ -46,13 +47,16 @@ const allTargets: {
{ os: "win32", arch: "x64", avx2: false },
]
const targets = singleFlag
? allTargets.filter((item) => {
if (item.os !== process.platform || item.arch !== process.arch) return false
if (item.avx2 === false) return baselineFlag
return item.abi === undefined
})
: allTargets
const targets = requestedTarget
? allTargets.filter((item) => targetName(item) === requestedTarget)
: singleFlag
? allTargets.filter((item) => {
if (item.os !== process.platform || item.arch !== process.arch) return false
if (item.avx2 === false) return baselineFlag
return item.abi === undefined
})
: allTargets
if (!targets.length) throw new Error(`Unknown build target: ${requestedTarget}`)
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const appArchive = await buildAppArchive(Script.channel, { skipBuild: skipWebUi })
@@ -81,15 +85,7 @@ for (const item of targets) {
}))
},
}
const target = [
binary,
item.os === "win32" ? "windows" : item.os,
item.arch,
item.avx2 === false ? "baseline" : undefined,
item.abi,
]
.filter(Boolean)
.join("-")
const target = targetName(item)
const name = target.replace(binary, "cli")
console.log(`building ${name}`)
const result = await Bun.build({
@@ -143,3 +139,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("-")
}
@@ -447,7 +447,7 @@ function UpdateFooter(props: {
})
return (
<box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
<box width="100%" height={4} flexDirection="row" gap={1} paddingLeft={1} live={props.animating()}>
<Monogram ink={monogramInk} />
<box flexDirection="column" flexGrow={1} overflow="hidden">
<CellLine cells={header()} />
+2 -9
View File
@@ -1,6 +1,6 @@
export * as ShellParse from "./parse.js"
import { Effect, Exit } from "effect"
import { Effect } from "effect"
import { fileURLToPath } from "url"
import os from "os"
import path from "path"
@@ -153,15 +153,8 @@ const ARITY: Record<string, number> = {
}
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
const parsers = yield* Effect.promise(load)
const powershell = ShellSelect.ps(shell)
const loaded = yield* Effect.promise(load).pipe(Effect.exit)
// Workerd has no filesystem-backed tree-sitter assets. Preserve execution
// with one conservative permission resource instead of disabling shell.
if (Exit.isFailure(loaded)) {
const tokens = command.trim().split(/\s+/)
return { commands: [{ resource: command, save: `${prefix(tokens).join(" ")} *` }], directories: [] }
}
const parsers = loaded.value
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
+7 -14
View File
@@ -1,7 +1,6 @@
import { $ } from "bun"
import { homedir } from "node:os"
import { join } from "node:path"
import { buildCliToResources, downloadCliToResources, windowsify } from "./utils"
import { downloadCliToResources, windowsify } from "./utils"
type ServerSource = { type: "build" } | { type: "download"; version: string }
type DevOptions = { server: ServerSource; electron: string[] }
@@ -38,18 +37,12 @@ function selectOptions(): DevOptions {
}
async function prepareServer(source: ServerSource) {
const destination = windowsify("resources/opencode-cli-dev")
if (source.type === "download") return downloadCliToResources(source.version, destination)
return buildCliToResources(destination, developmentStateHome())
}
function developmentStateHome() {
const appData = (() => {
if (process.platform === "darwin") return join(homedir(), "Library", "Application Support")
if (process.platform === "win32") return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")
return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config")
})()
return join(appData, "ai.opencode.desktop.dev")
if (source.type === "download")
return downloadCliToResources(source.version, windowsify("resources/opencode-cli-dev"))
process.env.OPENCODE_DESKTOP_CLI_DEV = join(import.meta.dirname, "../../cli")
if (process.platform !== "win32") return
process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD = join(import.meta.dirname, "../../cli/script/build.ts")
process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT = join(import.meta.dirname, "../resources/opencode-cli-wsl")
}
async function startDesktop(args: string[]) {
-28
View File
@@ -86,34 +86,6 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
console.log(`Copied ${cli.package}@${version} to ${dest}`)
}
export async function buildCliToResources(dest = windowsify("resources/opencode-cli"), stateHome?: string) {
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
try {
await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --skip-web-ui --outdir=${directory}`.env(
{
...process.env,
OPENCODE_VERSION: process.env.OPENCODE_VERSION,
},
)
if (stateHome && (await Bun.file(dest).exists())) {
const child = Bun.spawn([dest, "service", "stop"], {
env: { ...process.env, XDG_STATE_HOME: stateHome },
stdout: "inherit",
stderr: "inherit",
})
const exitCode = await child.exited
if (exitCode !== 0) throw new Error(`Failed to stop development service: ${exitCode}`)
}
await copyFile(join(directory, target, "bin", windowsify("opencode2")), dest)
} finally {
await rm(directory, { recursive: true, force: true })
}
await prepareCli(dest)
console.log(`Built local CLI at ${dest}`)
}
async function prepareCli(dest: string) {
if (process.platform !== "win32") await chmod(dest, 0o755)
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
+31 -17
View File
@@ -6,6 +6,7 @@ import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
import { app } from "electron"
import { parseCliVersion } from "./cli-version"
const execFileAsync = promisify(execFile)
const root = dirname(fileURLToPath(import.meta.url))
@@ -17,36 +18,56 @@ type Logger = {
export async function startBackgroundCli(logger: Logger) {
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
const development = !app.isPackaged && process.env.OPENCODE_DESKTOP_CLI_DEV
const cli = development
? {
version: "local",
command: ["bun", "run", "--cwd", development, "dev", "--"],
binary: undefined,
}
: await resolveBundledCli(isolated, logger)
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const service = await Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version,
command: [binary, "serve", "--service"],
version: cli.version,
command: [...cli.command, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
logger.log("v2 CLI background service ready", {
username: service.auth.username,
version,
version: cli.version,
...endpoint(service.url),
})
if (isolated) await cleanCliStages(binary, logger)
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
return {
url: service.url,
username: service.auth.username,
password: service.auth.password,
version: cli.version,
wslBuild:
app.isPackaged || !process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD || !process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT
? undefined
: {
script: process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD,
output: process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT,
},
}
}
async function resolveBundledCli(isolated: boolean, logger: Logger) {
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseCliVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
return { version, binary, command: [binary] }
}
async function cleanCliStages(binary: string, logger: Logger) {
const current = dirname(binary)
const root = dirname(current)
@@ -103,13 +124,6 @@ async function run(binary: string, args: string[], logger: Logger) {
)
}
function parseVersion(output: string) {
const marker = output.lastIndexOf(" v")
const version = marker === -1 ? output : output.slice(marker + 2)
if (!version) throw new Error("V2 CLI did not provide a version")
return version
}
function endpoint(url: string | undefined) {
if (!url || !URL.canParse(url)) return {}
const parsed = new URL(url)
+6
View File
@@ -0,0 +1,6 @@
export function parseCliVersion(output: string) {
const marker = output.lastIndexOf(" v")
const version = marker === -1 ? output : output.slice(marker + 2)
if (!version) throw new Error("V2 CLI did not provide a version")
return version
}
+50 -33
View File
@@ -36,9 +36,7 @@ import {
setDockIcon,
restoreMainWindows,
} from "./windows"
import { createWslServersController } from "./wsl/servers"
import { registerWslIpcHandlers } from "./wsl/ipc"
import { spawnWslSidecar } from "./wsl/sidecar"
import { migrate } from "./migrate"
import { cleanupStoreFiles } from "./store-cleanup"
import { startBackgroundCli } from "./background-cli"
@@ -134,25 +132,10 @@ const main = Effect.gen(function* () {
logger = initLogging()
initCrashReporter()
const wslServers = createWslServersController(
VERSION,
async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
{
logger: {
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
},
)
const stopSidecars = async () => wslServers.stopAll()
let stopWslServers = async () => {}
const relaunch = () => {
setAppQuitting()
void stopSidecars().finally(() => {
void stopWslServers().finally(() => {
app.relaunch()
app.quit()
})
@@ -205,12 +188,12 @@ const main = Effect.gen(function* () {
app.on("before-quit", () => {
setAppQuitting()
void stopSidecars()
void stopWslServers()
})
app.on("will-quit", () => {
setAppQuitting()
void stopSidecars()
void stopWslServers()
})
app.on("child-process-gone", (_event, details) => {
@@ -228,7 +211,7 @@ const main = Effect.gen(function* () {
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
setAppQuitting()
void stopSidecars().finally(() => app.quit())
void stopWslServers().finally(() => app.quit())
})
}
@@ -253,7 +236,7 @@ const main = Effect.gen(function* () {
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
const updater = setupAutoUpdater(stopSidecars)
const updater = setupAutoUpdater(() => stopWslServers())
const menuDeps = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
@@ -293,7 +276,6 @@ const main = Effect.gen(function* () {
},
})
registerUpdaterIpc(updater)
registerWslIpcHandlers(wslServers)
void updater.start()
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
updateTimer.unref()
@@ -311,16 +293,14 @@ const main = Effect.gen(function* () {
useEnvProxy()
logger.log("starting v2 background service")
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
yield* Deferred.succeed(serverReady, {
url: sidecar.url,
username: sidecar.username,
password: sidecar.password,
})
const background = yield* Effect.promise(() => startBackgroundCli(logger))
stopWslServers = yield* Effect.promise(() => startWslServers(background))
if (process.platform === "win32") {
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
}
yield* Deferred.succeed(serverReady, {
url: background.url,
username: background.username,
password: background.password,
})
logger.log("loading task finished")
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
@@ -340,4 +320,41 @@ const main = Effect.gen(function* () {
if (windows.length) createMenu(menuDeps)
})
async function startWslServers(cli: { version: string; wslBuild?: { script: string; output: string } }) {
if (process.platform !== "win32") {
registerWslIpcHandlers()
return async () => {}
}
const { createWslServersController } = await import("./wsl/servers")
const { spawnWslSidecar } = await import("./wsl/sidecar")
const local = cli.wslBuild
const controller = createWslServersController({
cli: { version: cli.version },
installCli: local
? async (distro) => {
const { buildLocalWslCli } = await import("./wsl/local")
const { installWslCli } = await import("./wsl/runtime")
await installWslCli(distro, {
version: cli.version,
binary: await buildLocalWslCli({ ...local, version: cli.version }),
})
}
: undefined,
spawnSidecar: async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
logger: {
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
})
registerWslIpcHandlers(controller)
controller.startConfiguredServers()
return async () => controller.stopServers()
}
Effect.runFork(main)
+14 -3
View File
@@ -1,12 +1,11 @@
import { app, ipcMain } from "electron"
import type { IpcMainInvokeEvent } from "electron"
import type { WslServersController } from "./servers"
import { requireWslIpcString, requireWslIpcStrings } from "./policy"
import type { WslServersState } from "../../preload/types"
import { nativeT } from "../native-translations"
export function registerWslIpcHandlers(controller: WslServersController) {
if (process.platform !== "win32") {
export function registerWslIpcHandlers(controller?: WslServersController) {
if (!controller) {
registerUnavailableWslIpcHandlers()
return
}
@@ -67,6 +66,18 @@ export function registerWslIpcHandlers(controller: WslServersController) {
)
}
function requireWslIpcString(name: string, value: unknown) {
if (typeof value === "string" && value.length > 0) return value
throw new Error(`Invalid ${name}`)
}
function requireWslIpcStrings(name: string, value: unknown) {
if (!Array.isArray(value)) throw new Error(`Invalid ${name}`)
const values = value.map((item) => requireWslIpcString(name, item))
if (values.length) return values
throw new Error(`Invalid ${name}`)
}
function registerUnavailableWslIpcHandlers() {
const unavailable = () => {
throw new Error(nativeT("desktop.wsl.error.windowsOnly"))
+38
View File
@@ -0,0 +1,38 @@
import { execFile } from "node:child_process"
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { promisify } from "node:util"
const execFileAsync = promisify(execFile)
export async function buildLocalWslCli(input: { version: string; script: string; output: string }) {
const directory = await mkdtemp(join(tmpdir(), "opencode-wsl-cli-"))
const root = join(dirname(input.script), "../../..")
const packageManager = (JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { packageManager: string })
.packageManager
const target = `linux-${process.arch}`
try {
await execFileAsync("bunx", [packageManager, "install", "--os=*", "--cpu=*", "--frozen-lockfile"], {
cwd: root,
env: process.env,
windowsHide: true,
})
await execFileAsync(
"bunx",
[
packageManager,
input.script,
`--target=opencode2-${target}`,
"--skip-install",
"--skip-web-ui",
`--outdir=${directory}`,
],
{ cwd: root, env: { ...process.env, OPENCODE_VERSION: input.version }, windowsHide: true },
)
await copyFile(join(directory, `cli-${target}`, "bin", "opencode2"), input.output)
return input.output
} finally {
await rm(directory, { recursive: true, force: true })
}
}
-33
View File
@@ -1,33 +0,0 @@
import type { WslDistroProbe, WslOpencodeCheck, WslServerItem } from "../../preload/types"
export function wslServerIdToRestart(servers: WslServerItem[], distro: string) {
return servers.find((item) => item.config.distro === distro)?.config.id
}
export function clearWslDistroState(
distroProbes: Record<string, WslDistroProbe>,
opencodeChecks: Record<string, WslOpencodeCheck>,
distro: string,
) {
const nextDistroProbes = { ...distroProbes }
const nextOpencodeChecks = { ...opencodeChecks }
delete nextDistroProbes[distro]
delete nextOpencodeChecks[distro]
return { distroProbes: nextDistroProbes, opencodeChecks: nextOpencodeChecks }
}
export function wslTerminalArgs(distro?: string | null) {
return ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])]
}
export function requireWslIpcString(name: string, value: unknown) {
if (typeof value === "string" && value.length > 0) return value
throw new Error(`Invalid ${name}`)
}
export function requireWslIpcStrings(name: string, value: unknown) {
if (!Array.isArray(value)) throw new Error(`Invalid ${name}`)
const values = value.map((item) => requireWslIpcString(name, item))
if (values.length > 0) return values
throw new Error(`Invalid ${name}`)
}
+33 -16
View File
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs"
import { join } from "node:path"
import * as pty from "@lydell/node-pty"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types"
import { wslTerminalArgs } from "./policy"
import { parseCliVersion } from "../cli-version"
import { nativeT } from "../native-translations"
export type WslCommandLine = {
@@ -31,6 +31,11 @@ export type RunWslOptions = {
timeoutMs?: number
}
export type WslCliBuild = {
version: string
binary?: string
}
const DEFAULT_WSL_TIMEOUT_MS = 20_000
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
@@ -252,28 +257,34 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) {
"$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
"if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
].join("; ")
return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
const result = await runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
requireSuccess(result, nativeT("desktop.wsl.error.installWsl"))
}
export async function installWslDistro(name: string, opts?: RunWslOptions) {
return runInteractiveCommand(
export async function installWslDistro(distro: string, opts?: RunWslOptions) {
const result = await runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
["--install", "-d", name, "--web-download", "--no-launch"],
["--install", "-d", distro, "--web-download", "--no-launch"],
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
requireSuccess(result, nativeT("desktop.wsl.error.installDistro", { distro }))
}
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
return runInteractiveCommand(
export async function installWslCli(distro: string, cli: WslCliBuild, opts?: RunWslOptions) {
const result = await runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
wslArgs(
["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`],
distro,
),
wslArgs(["bash", "-lc", wslCliInstallCommand(cli)], distro),
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
requireSuccess(result, nativeT("desktop.wsl.error.installOpencode"))
}
function wslCliInstallCommand(cli: WslCliBuild) {
const installer = "curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s --"
if (!cli.binary) return `${installer} --version ${shellEscape(cli.version)}`
return `binary=$(wslpath -a ${shellEscape(cli.binary)}) && ${installer} --binary "$binary"`
}
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
@@ -307,11 +318,11 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis
}
}
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
export async function resolveWslCli(distro: string, opts?: RunWslOptions) {
return firstLine(
(
await runWslSh(
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
'if [ -x "$HOME/.opencode/bin/opencode2" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode2"; fi',
distro,
opts,
)
@@ -319,14 +330,15 @@ export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
)
}
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
export async function readWslCliVersion(command: string, distro: string, opts?: RunWslOptions) {
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
return firstLine(result.stdout)
const output = firstLine(result.stdout)
return output ? parseCliVersion(output) : null
}
export function openWslTerminal(distro?: string | null) {
return new Promise<void>((resolve, reject) => {
const child = spawn("cmd.exe", wslTerminalArgs(distro), {
const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], {
detached: true,
stdio: "ignore",
windowsHide: true,
@@ -386,6 +398,11 @@ export function summarize(value: string) {
.join("\n")
}
function requireSuccess(result: WslCommandResult, fallback: string) {
if (result.code === 0) return
throw new Error(summarize(result.stderr || result.stdout) || fallback)
}
export function shellEscape(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}
+66 -167
View File
@@ -1,153 +1,42 @@
import { expect, test } from "bun:test"
import {
clearWslDistroState,
requireWslIpcString,
requireWslIpcStrings,
wslServerIdToRestart,
wslTerminalArgs,
} from "./policy"
import {
expectOpencodeVersion,
pendingRestartAfterWslInstall,
pollWslHealth,
wslServerIdsToStartOnInitialize,
} from "./startup"
import { createWslServersController, type WslServerConfig } from "./servers"
import type { WslServerConfig } from "../../preload/types"
import { createWslServersController } from "./servers"
type ControllerOptions = Parameters<typeof createWslServersController>[0]
let persistedServers: WslServerConfig[] = []
let releaseOpencodeResolve: (() => void) | undefined
test("starts every configured WSL server on initialization", () => {
expect(
wslServerIdsToStartOnInitialize([
{ id: "wsl:Debian", distro: "Debian" },
{ id: "wsl:Ubuntu-24.04", distro: "Ubuntu-24.04" },
]),
).toEqual(["wsl:Debian", "wsl:Ubuntu-24.04"])
})
test("rejects an update that did not install the desktop version", () => {
expect(() => expectOpencodeVersion("1.16.2", "1.16.2")).not.toThrow()
expect(() => expectOpencodeVersion("1.14.35", "1.16.2")).toThrow(
"OpenCode update finished but Debian still reports 1.14.35; expected 1.16.2",
)
})
test("restarts an existing distro server after updating OpenCode", () => {
expect(
wslServerIdToRestart(
[
{
config: { id: "wsl:Debian", distro: "Debian" },
runtime: { kind: "ready", url: "", username: null, password: null },
},
],
"Debian",
),
).toBe("wsl:Debian")
expect(wslServerIdToRestart([], "Debian")).toBeUndefined()
})
test("clears cached distro probes when removing a WSL server", () => {
expect(
clearWslDistroState(
{ Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null } },
{
Debian: {
distro: "Debian",
resolvedPath: "/home/luke/.opencode/bin/opencode",
version: "1.16.2",
expectedVersion: "1.16.2",
matchesDesktop: true,
error: null,
},
},
"Debian",
),
).toEqual({ distroProbes: {}, opencodeChecks: {} })
})
test("opens terminals for distro names containing spaces", () => {
expect(wslTerminalArgs("Ubuntu Preview")).toEqual(["/c", "start", "", "wsl", "-d", "Ubuntu Preview"])
})
test("stops health polling when sidecar startup settles", async () => {
const abort = new AbortController()
let checks = 0
const polling = pollWslHealth(
async () => {
checks++
return false
},
abort.signal,
1,
)
await new Promise((resolve) => setTimeout(resolve, 5))
abort.abort()
await polling
const settled = checks
await new Promise((resolve) => setTimeout(resolve, 5))
expect(checks).toBe(settled)
})
test("validates WSL IPC identifiers at the module boundary", () => {
expect(requireWslIpcString("distro", "Debian")).toBe("Debian")
expect(requireWslIpcStrings("distro", ["Debian", "Ubuntu"])).toEqual(["Debian", "Ubuntu"])
expect(() => requireWslIpcString("distro", "")).toThrow("Invalid distro")
expect(() => requireWslIpcString("server id", undefined)).toThrow("Invalid server id")
expect(() => requireWslIpcStrings("distro", [])).toThrow("Invalid distro")
})
test("derives a required Windows restart from the post-install runtime probe", () => {
expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true)
expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false)
})
test("ignores stale background OpenCode checks after removing a WSL server", async () => {
test("installs and verifies the bundled CLI version", async () => {
persistedServers = []
releaseOpencodeResolve = undefined
const installs: string[][] = []
const controller = createWslServersController(
"1.16.2",
async () => ({
listener: {
stop: () => undefined,
onExit: () => undefined,
testControllerOptions({
installCli: async (distro, cli) => {
installs.push([distro, cli.version])
},
url: "http://127.0.0.1:4096",
username: "opencode",
password: "secret",
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
}),
testControllerOptions(),
)
await controller.addServer("Debian")
await waitFor(() => !!releaseOpencodeResolve)
await controller.removeServer("wsl:Debian")
releaseOpencodeResolve?.()
await new Promise((resolve) => setTimeout(resolve, 0))
await controller.installOpencode("Debian")
expect(controller.getState().servers).toEqual([])
expect(controller.getState().opencodeChecks).toEqual({})
expect(installs).toEqual([["Debian", "0.0.0-next-16365"]])
expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true)
})
test("ignores stale startup OpenCode checks after removing a WSL server", async () => {
persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
releaseOpencodeResolve = undefined
test("rejects a WSL CLI version that differs from the bundled version", async () => {
persistedServers = []
const controller = createWslServersController(
"1.16.2",
async () => new Promise<never>(() => undefined),
testControllerOptions(),
testControllerOptions({
installCli: async () => undefined,
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
readCliVersion: async () => "0.0.0-next-older",
}),
)
await controller.initialize()
await waitFor(() => !!releaseOpencodeResolve)
await controller.removeServer("wsl:Debian")
releaseOpencodeResolve?.()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(controller.getState().servers).toEqual([])
expect(controller.getState().opencodeChecks).toEqual({})
await expect(controller.installOpencode("Debian")).rejects.toThrow(
"OpenCode update finished but Debian still reports 0.0.0-next-older; expected 0.0.0-next-16365",
)
})
test("probes addable distros in parallel before checking OpenCode", async () => {
@@ -155,18 +44,20 @@ test("probes addable distros in parallel before checking OpenCode", async () =>
const started: string[] = []
const release = new Map<string, () => void>()
const opencode: string[] = []
const controller = createWslServersController("1.16.2", async () => new Promise<never>(() => undefined), {
...testControllerOptions(),
probeDistro: async (distro) => {
started.push(distro)
await new Promise<void>((resolve) => release.set(distro, resolve))
return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null }
},
resolveOpencode: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode"
},
})
const controller = createWslServersController(
testControllerOptions({
spawnSidecar: pendingSidecar,
probeDistro: async (distro) => {
started.push(distro)
await new Promise<void>((resolve) => release.set(distro, resolve))
return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null }
},
resolveCli: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode2"
},
}),
)
const task = controller.probeAddable(["Debian", "Ubuntu"])
await waitFor(() => started.length === 2)
@@ -184,20 +75,22 @@ test("probes addable distros in parallel before checking OpenCode", async () =>
test("does not check OpenCode in addable distros that cannot execute commands", async () => {
persistedServers = []
const opencode: string[] = []
const controller = createWslServersController("1.16.2", async () => new Promise<never>(() => undefined), {
...testControllerOptions(),
probeDistro: async (distro) => ({
name: distro,
canExecute: distro === "Debian",
hasBash: distro === "Debian",
hasCurl: distro === "Debian",
error: distro === "Debian" ? null : "Open Ubuntu once to finish setup",
const controller = createWslServersController(
testControllerOptions({
spawnSidecar: pendingSidecar,
probeDistro: async (distro) => ({
name: distro,
canExecute: distro === "Debian",
hasBash: distro === "Debian",
hasCurl: distro === "Debian",
error: distro === "Debian" ? null : "Open Ubuntu once to finish setup",
}),
resolveCli: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode2"
},
}),
resolveOpencode: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode"
},
})
)
await controller.probeAddable(["Debian", "Ubuntu"])
@@ -214,18 +107,24 @@ async function waitFor(check: () => boolean) {
throw new Error("Timed out waiting for condition")
}
function testControllerOptions() {
function testControllerOptions(overrides: Partial<ControllerOptions> = {}): ControllerOptions {
return {
cli: { version: "0.0.0-next-16365" },
spawnSidecar: async () => ({
stop: () => undefined,
onExit: () => undefined,
url: "http://127.0.0.1:4096",
username: "opencode",
password: "secret",
}),
readServers: () => persistedServers,
writeServers: (servers: WslServerConfig[]) => {
persistedServers = servers
},
readCommandVersion: async () => "1.16.2",
resolveOpencode: async () => {
await new Promise<void>((resolve) => {
releaseOpencodeResolve = resolve
})
return "/home/me/.opencode/bin/opencode"
},
readCliVersion: async () => "0.0.0-next-16365",
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
...overrides,
}
}
const pendingSidecar = async () => new Promise<never>(() => undefined)
+102 -211
View File
@@ -1,10 +1,7 @@
import type {
WslDistroProbe,
WslInstalledDistro,
WslJob,
WslOnlineDistro,
WslOpencodeCheck,
WslRuntimeCheck,
WslServerConfig,
WslServerItem,
WslServerRuntime,
@@ -13,25 +10,24 @@ import type {
} from "../../preload/types"
import { WSL_SERVERS_KEY } from "../store-keys"
import { getStore } from "../store"
import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
import { clearWslDistroState, wslServerIdToRestart } from "./policy"
import { nativeT } from "../native-translations"
import {
installWslCli,
installWslDistro,
installWslOpencode,
installWslRuntimeElevated,
listInstalledWslDistros,
listOnlineWslDistros,
openWslTerminal,
probeWslDistro,
probeWslRuntime,
readWslCommandVersion,
resolveWslOpencode,
summarize,
readWslCliVersion,
resolveWslCli,
type WslCliBuild,
} from "./runtime"
type RunningSidecar = {
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
stop: () => void
onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void
url: string
username: string | null
password: string
@@ -45,12 +41,15 @@ type ControllerLogger = {
}
type WslServersControllerOptions = {
cli: WslCliBuild
spawnSidecar: SpawnSidecar
logger?: ControllerLogger
readServers?: () => WslServerConfig[]
writeServers?: (servers: WslServerConfig[]) => void
installCli?: typeof installWslCli
probeDistro?: typeof probeWslDistro
resolveOpencode?: typeof resolveWslOpencode
readCommandVersion?: typeof readWslCommandVersion
resolveCli?: typeof resolveWslCli
readCliVersion?: typeof readWslCliVersion
}
export type WslServersController = ReturnType<typeof createWslServersController>
@@ -59,20 +58,13 @@ export function wslServerIdForDistro(distro: string) {
return `wsl:${distro}`
}
export function createWslServersController(
appVersion: string,
spawnSidecar: SpawnSidecar,
options?: WslServersControllerOptions,
) {
export function createWslServersController(options: WslServersControllerOptions) {
let state: WslServersState = initialState()
const listeners = new Set<(event: WslServersEvent) => void>()
const sidecars = new Map<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
const logger = options?.logger
const readServers = options?.readServers ?? readPersistedServers
const writeServers = options?.writeServers ?? writePersistedServers
const probeDistro = options?.probeDistro ?? probeWslDistro
const readServers = options.readServers ?? readPersistedServers
const writeServers = options.writeServers ?? writePersistedServers
const probeDistro = options.probeDistro ?? probeWslDistro
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
@@ -83,29 +75,11 @@ export function createWslServersController(
emit()
}
const persistServers = (servers: WslServerConfig[]) => {
writeServers(servers)
}
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
setState({ servers: next })
}
const beginJob = (job: WslJob): AbortController => {
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
setState({ job })
return abort
}
const endJob = (abort: AbortController) => {
if (jobAbort !== abort) return
jobAbort = undefined
setState({ job: null })
}
const refreshFromStore = () => {
const persisted = readServers()
const items: WslServerItem[] = persisted.map((config) => {
@@ -122,7 +96,7 @@ export function createWslServersController(
updateServer(id, (item) => ({ ...item, runtime }))
}
const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
const setCliCheck = (distro: string, check: WslOpencodeCheck) => {
setState({
opencodeChecks: {
...state.opencodeChecks,
@@ -131,24 +105,24 @@ export function createWslServersController(
})
}
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
const version = resolved
? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
: null
return opencodeCheck(distro, resolved, version, appVersion)
const inspectCli = async (distro: string) => {
const resolved = await (options.resolveCli ?? resolveWslCli)(distro)
const version = resolved ? await (options.readCliVersion ?? readWslCliVersion)(resolved, distro) : null
return cliCheck(distro, resolved, version, options.cli.version)
}
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
setOpencodeCheck(distro, await checkOpencode(distro, opts))
const refreshCliCheck = async (distro: string) => {
const check = await inspectCli(distro)
setCliCheck(distro, check)
return check
}
const probeAddableDistros = async (distros: string[], opts?: { signal?: AbortSignal }) => {
const probeAddableDistros = async (distros: string[]) => {
const unique = [...new Set(distros)]
const distroProbes = await Promise.all(
unique
.filter((distro) => !state.distroProbes[distro])
.map(async (distro) => [distro, await probeDistro(distro, opts)] as const),
.map(async (distro) => [distro, await probeDistro(distro)] as const),
)
if (distroProbes.length) {
setState({ distroProbes: { ...state.distroProbes, ...Object.fromEntries(distroProbes) } })
@@ -158,86 +132,37 @@ export function createWslServersController(
unique
.filter((distro) => distroProbeReady(state.distroProbes[distro]))
.filter((distro) => !state.opencodeChecks[distro])
.map(async (distro) => [distro, await checkOpencode(distro, opts)] as const),
.map(async (distro) => [distro, await inspectCli(distro)] as const),
)
if (opencodeChecks.length) {
setState({ opencodeChecks: { ...state.opencodeChecks, ...Object.fromEntries(opencodeChecks) } })
}
}
const hasServer = (id: string, distro: string) => {
return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
const refreshCliCheckSafely = (id: string, distro: string) => {
return refreshCliCheck(distro).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
options.logger?.error("wsl CLI check failed", { id, distro, message })
})
}
const refreshOpencodeCheckBackground = (id: string, distro: string) => {
void checkOpencode(distro)
.then((check) => {
if (!hasServer(id, distro)) return
setOpencodeCheck(distro, check)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", { id, distro, message })
})
const refreshCliChecks = async () => {
await Promise.all(state.servers.map((item) => refreshCliCheckSafely(item.config.id, item.config.distro)))
}
const refreshOpencodeChecks = async () => {
await Promise.all(
state.servers.map((item) =>
checkOpencode(item.config.distro)
.then((check) => {
if (!hasServer(item.config.id, item.config.distro)) return
setOpencodeCheck(item.config.distro, check)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", {
id: item.config.id,
distro: item.config.distro,
message,
})
}),
),
)
}
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
const [installed, online] = await Promise.all([listInstalledWslDistros(opts), listOnlineWslDistros(opts)])
const refreshDistroLists = async () => {
const [installed, online] = await Promise.all([listInstalledWslDistros(), listOnlineWslDistros()])
return { installed, online }
}
const nextStartAttempt = (id: string) => {
const next = (startAttempts.get(id) ?? 0) + 1
startAttempts.set(id, next)
return next
}
const invalidateStartAttempt = (id: string) => {
startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
}
const isCurrentStartAttempt = (id: string, attempt: number) => {
return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
}
const startServer = async (id: string) => {
const item = state.servers.find((x) => x.config.id === id)
if (!item) return
const attempt = nextStartAttempt(id)
await stopServerInternal(id)
if (!isCurrentStartAttempt(id, attempt)) return
stopServer(id)
setRuntime(id, { kind: "starting" })
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
options.logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
try {
const sidecar = await spawnSidecar(item.config.distro)
if (!isCurrentStartAttempt(id, attempt)) {
try {
sidecar.listener.stop()
} catch {
// ignore stop errors for stale sidecars
}
return
}
const sidecar = await options.spawnSidecar(item.config.distro)
sidecars.set(id, sidecar)
setRuntime(id, {
kind: "ready",
@@ -245,51 +170,35 @@ export function createWslServersController(
username: sidecar.username,
password: sidecar.password,
})
sidecar.listener.onExit((code, signal) => {
sidecar.onExit((code, signal) => {
if (sidecars.get(id) !== sidecar) return
sidecars.delete(id)
const message = startupFailure(code, signal)
setRuntime(id, { kind: "failed", message })
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
options.logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
})
refreshOpencodeCheckBackground(id, item.config.distro)
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
void refreshCliCheckSafely(id, item.config.distro)
options.logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!isCurrentStartAttempt(id, attempt)) return
setRuntime(id, { kind: "failed", message })
// Without this, an Ubuntu-style silent failure leaves no trace in
// main.log — the controller captures the message in its state but
// nothing surfaces unless the user opens the WSL servers dialog.
logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
options.logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
}
}
const stopServerInternal = async (id: string) => {
const stopServer = (id: string) => {
const existing = sidecars.get(id)
if (!existing) return
sidecars.delete(id)
try {
existing.listener.stop()
} catch {
// ignore stop errors
}
existing.stop()
}
const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
const abort = beginJob(job)
const runJob = async <T>(job: WslJob, runner: () => Promise<T>) => {
setState({ job })
try {
const value = await runner(abort)
endJob(abort)
return value
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
endJob(abort)
return undefined
}
const err = error instanceof Error ? error : new Error(String(error))
endJob(abort)
throw err
return await runner()
} finally {
setState({ job: null })
}
}
@@ -302,15 +211,15 @@ export function createWslServersController(
return () => listeners.delete(listener)
},
async initialize() {
startConfiguredServers() {
refreshFromStore()
void refreshOpencodeChecks()
for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
void refreshCliChecks()
state.servers.forEach((item) => void startServer(item.config.id))
},
async probeRuntime() {
await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
const runtime = await probeWslRuntime({ signal: abort.signal })
await runJob({ kind: "runtime", startedAt: Date.now() }, async () => {
const runtime = await probeWslRuntime()
setState({
runtime,
pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
@@ -319,62 +228,47 @@ export function createWslServersController(
},
async refreshDistros() {
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
setState(await refreshDistroLists({ signal: abort.signal }))
await runJob({ kind: "distros", startedAt: Date.now() }, async () => {
setState(await refreshDistroLists())
})
},
async installWsl() {
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
const result = await installWslRuntimeElevated({ signal: abort.signal })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installWsl")
throw new Error(message)
}
const runtime = await probeWslRuntime({ signal: abort.signal })
setState({ runtime, pendingRestart: pendingRestartAfterWslInstall(runtime) })
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async () => {
await installWslRuntimeElevated()
const runtime = await probeWslRuntime()
setState({ runtime, pendingRestart: !runtime.available })
})
},
async installDistro(name: string) {
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
const result = await installWslDistro(name, { signal: abort.signal })
if (result.code !== 0) {
const message =
summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installDistro", { distro: name })
throw new Error(message)
}
const distros = await refreshDistroLists({ signal: abort.signal })
const probe = await probeDistro(name, { signal: abort.signal })
async installDistro(distro: string) {
await runJob({ kind: "install-distro", distro, startedAt: Date.now() }, async () => {
await installWslDistro(distro)
const distros = await refreshDistroLists()
const probe = await probeDistro(distro)
setState({
...distros,
distroProbes: { ...state.distroProbes, [name]: probe },
distroProbes: { ...state.distroProbes, [distro]: probe },
})
})
},
async probeAddable(distros: string[]) {
if (!distros.length) return
await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, async (abort) => {
await probeAddableDistros(distros, { signal: abort.signal })
})
await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, () => probeAddableDistros(distros))
},
async installOpencode(name: string) {
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
const result = await installWslOpencode(appVersion, name, { signal: abort.signal })
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installOpencode"))
}
await refreshOpencodeCheck(name, { signal: abort.signal })
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name)
const id = wslServerIdToRestart(state.servers, name)
async installOpencode(distro: string) {
await runJob({ kind: "install-opencode", distro, startedAt: Date.now() }, async () => {
await (options.installCli ?? installWslCli)(distro, options.cli)
requireMatchingCli(await refreshCliCheck(distro), options.cli.version)
const id = state.servers.find((item) => item.config.distro === distro)?.config.id
if (id) await startServer(id)
})
},
async openTerminal(name: string) {
await openWslTerminal(name)
async openTerminal(distro: string) {
await openWslTerminal(distro)
},
async addServer(distro: string): Promise<WslServerConfig> {
@@ -386,7 +280,7 @@ export function createWslServersController(
id,
distro,
}
persistServers([...readServers(), config])
writeServers([...readServers(), config])
setState({
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
})
@@ -396,27 +290,19 @@ export function createWslServersController(
async removeServer(id: string) {
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
invalidateStartAttempt(id)
await stopServerInternal(id)
stopServer(id)
const remaining = readServers().filter((item) => item.id !== id)
persistServers(remaining)
writeServers(remaining)
setState({
servers: state.servers.filter((item) => item.config.id !== id),
...(distro ? clearWslDistroState(state.distroProbes, state.opencodeChecks, distro) : {}),
...(distro ? removeDistroState(state, distro) : {}),
})
},
startServer,
stopAll() {
for (const item of state.servers) invalidateStartAttempt(item.config.id)
for (const existing of sidecars.values()) {
try {
existing.listener.stop()
} catch {
// ignore
}
}
stopServers() {
sidecars.forEach((sidecar) => sidecar.stop())
sidecars.clear()
},
}
@@ -464,7 +350,7 @@ function normalizePersistedServer(value: unknown): WslServerConfig[] {
]
}
function opencodeCheck(
function cliCheck(
distro: string,
resolvedPath: string | null,
version: string | null,
@@ -500,6 +386,25 @@ function opencodeCheck(
}
}
function requireMatchingCli(check: WslOpencodeCheck, expected: string) {
if (check.version === expected) return
throw new Error(
nativeT("desktop.wsl.error.updateVersion", {
distro: check.distro,
installed: check.version ?? nativeT("desktop.wsl.error.noVersion"),
expected,
}),
)
}
function removeDistroState(state: WslServersState, distro: string) {
const distroProbes = { ...state.distroProbes }
const opencodeChecks = { ...state.opencodeChecks }
delete distroProbes[distro]
delete opencodeChecks[distro]
return { distroProbes, opencodeChecks }
}
function distroProbeReady(probe: WslDistroProbe | undefined) {
return !!probe?.canExecute && probe.hasBash && probe.hasCurl
}
@@ -507,17 +412,3 @@ function distroProbeReady(probe: WslDistroProbe | undefined) {
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
return nativeT("desktop.wsl.error.serverExited", { code: code ?? "null", signal: signal ?? "null" })
}
// Re-export types used by callers
export type {
WslInstalledDistro,
WslOnlineDistro,
WslRuntimeCheck,
WslDistroProbe,
WslOpencodeCheck,
WslServerConfig,
WslServerItem,
WslServerRuntime,
WslServersEvent,
WslServersState,
}
+13 -8
View File
@@ -3,12 +3,12 @@ import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import { app } from "electron"
import { checkHealth } from "../server"
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
import { pollWslHealth } from "./startup"
import { type WslCommandLine, resolveWslCli, shellEscape, wslArgs } from "./runtime"
import { nativeT } from "../native-translations"
export type WslSidecar = {
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
stop: () => void
onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void
url: string
username: string | null
password: string
@@ -18,7 +18,7 @@ export async function spawnWslSidecar(
distro: string,
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
): Promise<WslSidecar> {
const opencode = await resolveWslOpencode(distro)
const opencode = await resolveWslCli(distro)
if (!opencode) throw new Error(nativeT("desktop.wsl.error.opencodeNotInstalled", { distro }))
const port = await allocatePort()
@@ -80,16 +80,21 @@ export async function spawnWslSidecar(
startup.abort()
})
return {
listener: {
stop: () => child.kill(),
onExit: (cb) => child.once("exit", cb),
},
stop: () => child.kill(),
onExit: (cb) => child.once("exit", cb),
url,
username,
password,
}
}
async function pollWslHealth(check: () => Promise<boolean>, signal: AbortSignal) {
while (!signal.aborted) {
if (await check()) return
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
function allocatePort() {
return new Promise<number>((resolve, reject) => {
const server = createServer()
-37
View File
@@ -1,37 +0,0 @@
import { nativeT } from "../native-translations"
export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) {
return servers.map((server) => server.id)
}
export function expectOpencodeVersion(installed: string | null, expected: string, distro = "Debian") {
if (installed === expected) return
throw new Error(
nativeT("desktop.wsl.error.updateVersion", {
distro,
installed: installed ?? nativeT("desktop.wsl.error.noVersion"),
expected,
}),
)
}
export const pendingRestartAfterWslInstall = (runtime: { available: boolean }) => !runtime.available
export async function pollWslHealth(check: () => Promise<boolean>, signal: AbortSignal, interval = 100) {
while (!signal.aborted) {
if (await check()) return
await abortableDelay(interval, signal)
}
}
function abortableDelay(duration: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const done = () => {
clearTimeout(timeout)
signal.removeEventListener("abort", done)
resolve()
}
const timeout = setTimeout(done, duration)
signal.addEventListener("abort", done, { once: true })
})
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { feedbackIssueUrl } from "../src/feedback"
import { annotationUrl, readAnnotations } from "../src/annotations"
describe("catalog feedback", () => {
test("opens a prefilled issue for an exact capture", () => {
@@ -18,4 +19,39 @@ describe("catalog feedback", () => {
expect(url.searchParams.get("body")).toContain("`skill-picker`")
expect(url.searchParams.get("body")).toContain("screen=skill-picker&set=opencode")
})
test("round-trips a capture annotation document through the URL fragment", () => {
const document = {
version: 1 as const,
identifier: "skill-picker",
variant: "opencode",
annotations: [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }],
}
const url = new URL(annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document))
expect(url.hash).toStartWith("#annotations=")
expect(readAnnotations(url, "skill-picker", "opencode")).toEqual(document.annotations)
expect(readAnnotations(url, "other-screen", "opencode")).toEqual([])
})
test("includes human and machine-readable annotations in the issue", () => {
const annotations = [{ id: "one", row: 4, column: 12, note: "This label needs more contrast." }]
const document = { version: 1 as const, identifier: "skill-picker", variant: "opencode", annotations }
const url = new URL(
feedbackIssueUrl({
title: "Skill picker",
identifier: "skill-picker",
deepLink: annotationUrl("https://dev.opencode.ai/lab/catalog?screen=skill-picker", document),
variant: "opencode",
annotations,
document,
}),
)
const body = url.searchParams.get("body") ?? ""
expect(body).toContain("## 1. Row 5, column 13")
expect(body).toContain("This label needs more contrast.")
expect(body).toContain("<summary>Annotation data</summary>")
expect(body).toContain('"row": 4')
})
})
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import wrangler from "../wrangler.jsonc"
import { assetPath } from "../worker"
describe("catalog worker", () => {
@@ -12,4 +13,8 @@ describe("catalog worker", () => {
expect(assetPath("/lab/catalog/catalog.json")).toBe("/catalog.json")
expect(assetPath("/lab/catalog/captures/opencode/home.frame.json")).toBe("/captures/opencode/home.frame.json")
})
test("leaves HTML routing to the worker", () => {
expect(wrangler.assets.html_handling).toBe("none")
})
})
+6 -4
View File
@@ -334,6 +334,7 @@ export function App({ catalog }: AppProps) {
}, [])
useEffect(() => {
if (ui.viewerOpen) return
window.history.replaceState(
null,
"",
@@ -349,18 +350,18 @@ export function App({ catalog }: AppProps) {
states: ui.facets.state,
}),
)
}, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements])
}, [activeVariant.id, ui.facets, ui.mode, ui.query, ui.screenLabels, ui.uiElements, ui.viewerOpen])
useEffect(() => {
if (!ui.viewerOpen || !selectedScreen) return
window.history.replaceState(
null,
"",
const url = new URL(
catalogDeepLink(selectedScreen.id, {
flowId: ui.mode === "flows" ? activeFlow?.id : undefined,
variantId: activeVariant.id,
}),
)
if (window.location.hash.startsWith("#annotations=")) url.hash = window.location.hash
window.history.replaceState(null, "", url)
}, [activeVariant.id, activeFlow?.id, selectedScreen, ui.mode, ui.viewerOpen])
useEffect(() => {
@@ -483,6 +484,7 @@ export function App({ catalog }: AppProps) {
</main>
{ui.viewerOpen && selectedScreen ? (
<Viewer
key={`${selectedScreen.id}:${activeVariant.id}`}
screen={selectedScreen}
identifier={
ui.mode === "flows" && activeFlow?.replayable ? `${activeFlow.id}/${selectedScreen.id}` : selectedScreen.id
+82
View File
@@ -0,0 +1,82 @@
export interface Annotation {
readonly id: string
readonly row: number
readonly column: number
readonly note: string
}
export interface AnnotationDocument {
readonly version: 1
readonly identifier: string
readonly variant: string
readonly annotations: ReadonlyArray<Annotation>
}
const FragmentKey = "annotations"
const MaxAnnotations = 24
const MaxNoteLength = 2_000
export function annotationUrl(deepLink: string, document: AnnotationDocument) {
const url = new URL(deepLink)
url.hash = `${FragmentKey}=${encode(document)}`
return url.href
}
export function readAnnotations(url: URL, identifier: string, variant: string): ReadonlyArray<Annotation> {
const params = new URLSearchParams(url.hash.slice(1))
const encoded = params.get(FragmentKey)
if (!encoded) return []
const value = decode(encoded)
if (!isDocument(value) || value.identifier !== identifier || value.variant !== variant) return []
return value.annotations
}
export function readAnnotationDraft(value: string): ReadonlyArray<Annotation> {
try {
const annotations: unknown = JSON.parse(value)
return isAnnotations(annotations) ? annotations : []
} catch {
return []
}
}
function encode(value: AnnotationDocument) {
const bytes = new TextEncoder().encode(JSON.stringify(value))
return btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""))
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/, "")
}
function decode(value: string): unknown {
try {
const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/"))
return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))))
} catch {
return undefined
}
}
function isDocument(value: unknown): value is AnnotationDocument {
if (!value || typeof value !== "object") return false
const document = value as Partial<AnnotationDocument>
if (document.version !== 1 || typeof document.identifier !== "string" || typeof document.variant !== "string")
return false
return isAnnotations(document.annotations)
}
function isAnnotations(value: unknown): value is ReadonlyArray<Annotation> {
if (!Array.isArray(value) || value.length > MaxAnnotations) return false
return value.every(
(annotation) =>
annotation &&
typeof annotation === "object" &&
typeof annotation.id === "string" &&
Number.isInteger(annotation.row) &&
annotation.row >= 0 &&
Number.isInteger(annotation.column) &&
annotation.column >= 0 &&
typeof annotation.note === "string" &&
annotation.note.length <= MaxNoteLength,
)
}
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from "react"
import type { Annotation } from "../annotations"
interface AnnotationEditorProps {
readonly cols: number
readonly rows: number
readonly annotations: ReadonlyArray<Annotation>
readonly onAdd: (row: number, column: number, note: string) => void
readonly onChange: (id: string, note: string) => void
readonly onDelete: (id: string) => void
readonly issueLink: string
readonly onDone: () => void
}
interface Draft {
readonly id?: string
readonly row: number
readonly column: number
readonly note: string
}
export function AnnotationEditor(props: AnnotationEditorProps) {
const [draft, setDraft] = useState<Draft>()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const complete = props.annotations.filter((annotation) => annotation.note.trim() !== "")
useEffect(() => {
if (!draft) return
const frame = requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(draft.note.length, draft.note.length)
})
return () => cancelAnimationFrame(frame)
}, [draft?.id, draft?.row, draft?.column])
const save = () => {
if (!draft?.note.trim()) return
if (draft.id) props.onChange(draft.id, draft.note.trim())
else props.onAdd(draft.row, draft.column, draft.note.trim())
setDraft(undefined)
}
const edit = (annotation: Annotation) =>
setDraft({ id: annotation.id, row: annotation.row, column: annotation.column, note: annotation.note })
return (
<>
<div
className="annotation-layer"
aria-label="Click the terminal to add an annotation"
onPointerDown={(event) => {
if (event.target !== event.currentTarget) return
const bounds = event.currentTarget.getBoundingClientRect()
const column = Math.min(
props.cols - 1,
Math.max(0, Math.floor(((event.clientX - bounds.left) / bounds.width) * props.cols)),
)
const row = Math.min(
props.rows - 1,
Math.max(0, Math.floor(((event.clientY - bounds.top) / bounds.height) * props.rows)),
)
setDraft({ row, column, note: "" })
}}
>
{props.annotations.map((annotation, index) => (
<button
key={annotation.id}
type="button"
className={`annotation-pin${annotation.id === draft?.id ? " selected" : ""}`}
style={{
left: `${((annotation.column + 0.5) / props.cols) * 100}%`,
top: `${((annotation.row + 0.5) / props.rows) * 100}%`,
}}
aria-label={`Edit annotation ${index + 1}, row ${annotation.row + 1}, column ${annotation.column + 1}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={() => edit(annotation)}
>
{index + 1}
</button>
))}
{draft ? (
<div
className={`annotation-composer${draft.row > props.rows / 2 ? " above" : ""}`}
style={{
left: `clamp(9rem, ${((draft.column + 0.5) / props.cols) * 100}%, calc(100% - 9rem))`,
top: `${((draft.row + 0.5) / props.rows) * 100}%`,
}}
onPointerDown={(event) => event.stopPropagation()}
>
<header>
<span>{draft.id ? "Edit annotation" : "New annotation"}</span>
<small>
R{draft.row + 1} · C{draft.column + 1}
</small>
</header>
<textarea
ref={textareaRef}
name="annotation-note"
aria-label="Annotation note"
value={draft.note}
maxLength={2_000}
rows={2}
placeholder="What should change?"
onChange={(event) => setDraft({ ...draft, note: event.target.value })}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
save()
}
if (event.key === "Escape") {
setDraft(undefined)
}
}}
/>
<footer>
{draft.id ? (
<button
type="button"
className="annotation-composer-delete"
onClick={() => {
if (draft.id) props.onDelete(draft.id)
setDraft(undefined)
}}
>
Delete
</button>
) : (
<span />
)}
<button type="button" onClick={() => setDraft(undefined)}>
Cancel
</button>
<button type="button" className="annotation-composer-save" disabled={!draft.note.trim()} onClick={save}>
{draft.id ? "Save" : "Add"}
</button>
</footer>
</div>
) : undefined}
</div>
<aside className="annotation-panel" aria-label="Capture annotations">
<header>
<div>
<strong>Annotations</strong>
<span>
{props.annotations.length === 0 ? "Click anywhere on the terminal" : `${props.annotations.length} placed`}
</span>
</div>
<button type="button" onClick={props.onDone}>
Done
</button>
</header>
<div className="annotation-list">
{props.annotations.map((annotation, index) => (
<button key={annotation.id} type="button" className="annotation-list-row" onClick={() => edit(annotation)}>
<span className="annotation-list-pin">{index + 1}</span>
<span>
<small>
Row {annotation.row + 1} · Column {annotation.column + 1}
</small>
<strong>{annotation.note}</strong>
</span>
</button>
))}
</div>
<footer>
<a
className="annotation-issue"
href={complete.length === 0 ? undefined : props.issueLink}
target="_blank"
rel="noreferrer"
aria-disabled={complete.length === 0}
>
Open GitHub issue
</a>
<span>
{complete.length === 0
? "Add a note to continue"
: `${complete.length} note${complete.length === 1 ? "" : "s"} will be included`}
</span>
</footer>
</aside>
</>
)
}
@@ -8,8 +8,7 @@ interface CaptureSetSwitcherProps {
export function CaptureSetSwitcher({ sets, active, onSelect }: CaptureSetSwitcherProps) {
return (
<label className="variant-switcher" title={active.label}>
<span className="sr-only">Theme</span>
<label className="variant-switcher" title="Switch theme">
<select aria-label="Select theme" value={active.id} onChange={(event) => onSelect(event.target.value)}>
{sets.map((set) => (
<option key={set.id} value={set.id}>
@@ -17,8 +16,14 @@ export function CaptureSetSwitcher({ sets, active, onSelect }: CaptureSetSwitche
</option>
))}
</select>
<span className="variant-hint" aria-hidden="true">
Theme
</span>
<span className="variant-name" aria-hidden="true">
{active.label}
</span>
<span className="variant-chevron" aria-hidden="true">
</span>
</label>
)
+88 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useEffectEvent, useRef } from "react"
import { useEffect, useEffectEvent, useRef, useState } from "react"
import type { Facet, Filter, Screen, Taxonomy, TaxonomyGroup, Variant } from "../catalog"
import { facetValues, frameFor, label, taxonomyLabel } from "../catalog"
import { TerminalFrame } from "./TerminalFrame"
@@ -6,6 +6,14 @@ import { CaptureSetSwitcher } from "./CaptureSetSwitcher"
import { CaptureContextMenu } from "./CaptureContextMenu"
import { feedbackIssueUrl } from "../feedback"
import { CaptureActionsMenu } from "./CaptureActionsMenu"
import {
annotationUrl,
readAnnotationDraft,
readAnnotations,
type Annotation,
type AnnotationDocument,
} from "../annotations"
import { AnnotationEditor } from "./AnnotationEditor"
interface ViewerProps {
readonly screen: Screen
@@ -50,17 +58,65 @@ export function Viewer({
const frame = frameFor(screen, variant.id)
if (!frame) throw new Error(`Capture ${screen.id} is unavailable in set ${variant.id}`)
const issueLink = feedbackIssueUrl({ title: screen.title, identifier, deepLink, variant: variant.id })
const storageKey = `catalog-annotations:${identifier}:${variant.id}`
const [annotating, setAnnotating] = useState(() => window.location.hash.startsWith("#annotations="))
const [annotations, setAnnotations] = useState<ReadonlyArray<Annotation>>(() => {
const linked = readAnnotations(new URL(window.location.href), identifier, variant.id)
if (linked.length > 0)
return linked.filter((annotation) => annotation.row < frame.rows && annotation.column < frame.cols)
try {
const stored = localStorage.getItem(storageKey)
if (!stored) return []
return readAnnotationDraft(stored).filter(
(annotation) => annotation.row < frame.rows && annotation.column < frame.cols,
)
} catch {
return []
}
})
const document: AnnotationDocument = { version: 1, identifier, variant: variant.id, annotations }
const annotatedLink = annotationUrl(deepLink, document)
const completeAnnotations = annotations.filter((annotation) => annotation.note.trim() !== "")
const issueDocument = { ...document, annotations: completeAnnotations }
const annotationIssueLink = feedbackIssueUrl({
title: screen.title,
identifier,
deepLink: annotationUrl(deepLink, issueDocument),
variant: variant.id,
annotations: completeAnnotations,
document: issueDocument,
})
useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify(annotations))
if (annotating) window.history.replaceState(null, "", annotations.length > 0 ? annotatedLink : deepLink)
}, [annotatedLink, annotating, annotations, deepLink, storageKey])
useEffect(() => {
dialogRef.current?.showModal()
}, [])
const handleKeyDown = useEffectEvent((event: KeyboardEvent) => {
const editing =
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target instanceof HTMLElement && event.target.isContentEditable)
if (editing && event.key !== "Escape") return
if (event.key === "Escape") {
event.preventDefault()
if (annotating) {
setAnnotating(false)
return
}
onClose()
return
}
if (event.key.toLowerCase() === "a" && !event.metaKey && !event.ctrlKey && !event.altKey) {
event.preventDefault()
setAnnotating((value) => !value)
return
}
if (annotating) return
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault()
onNavigate(event.key === "ArrowLeft" ? -1 : 1)
@@ -107,6 +163,15 @@ export function Viewer({
</button>
</span>
<div className="viewer-actions">
<button
type="button"
className={`viewer-button${annotating ? " active" : ""}`}
onClick={() => setAnnotating((value) => !value)}
title="Toggle annotation mode (A)"
>
Annotate
{annotations.length > 0 ? <span className="viewer-button-count">{annotations.length}</span> : undefined}
</button>
<CaptureActionsMenu identifier={identifier} deepLink={deepLink} issueLink={issueLink} />
<CaptureSetSwitcher sets={variants} active={variant} onSelect={onVariantSelect} />
</div>
@@ -117,6 +182,28 @@ export function Viewer({
<CaptureContextMenu identifier={identifier} deepLink={deepLink} issueLink={issueLink}>
<div className="viewer-image-wrap">
<TerminalFrame frame={frame} label={`${screen.title}, ${variant.label}`} />
{annotating ? (
<AnnotationEditor
cols={frame.cols}
rows={frame.rows}
annotations={annotations}
onAdd={(row, column, note) => {
if (annotations.length >= 24) return
const annotation = { id: crypto.randomUUID(), row, column, note }
setAnnotations([...annotations, annotation])
}}
onChange={(id, note) =>
setAnnotations(
annotations.map((annotation) => (annotation.id === id ? { ...annotation, note } : annotation)),
)
}
onDelete={(id) => setAnnotations(annotations.filter((annotation) => annotation.id !== id))}
onDone={() => {
setAnnotating(false)
}}
issueLink={annotationIssueLink}
/>
) : undefined}
</div>
</CaptureContextMenu>
<figcaption className="viewer-caption">
+24 -3
View File
@@ -1,8 +1,12 @@
import type { Annotation, AnnotationDocument } from "./annotations"
interface FeedbackIssue {
readonly title: string
readonly identifier: string
readonly deepLink: string
readonly variant: string
readonly annotations?: ReadonlyArray<Annotation>
readonly document?: AnnotationDocument
}
export function feedbackIssueUrl(issue: FeedbackIssue) {
@@ -12,15 +16,32 @@ export function feedbackIssueUrl(issue: FeedbackIssue) {
url.searchParams.set(
"body",
[
"## Feedback",
"",
"<!-- What looks wrong, confusing, or could be improved? -->",
...(issue.annotations?.length
? issue.annotations.flatMap((annotation, index) => [
`## ${index + 1}. Row ${annotation.row + 1}, column ${annotation.column + 1}`,
"",
annotation.note.trim(),
"",
])
: ["## Feedback", "", "<!-- What looks wrong, confusing, or could be improved? -->", ""]),
"",
"## Catalog state",
"",
`- Screen: \`${issue.identifier}\``,
`- Theme: \`${issue.variant}\``,
`- Link: ${issue.deepLink}`,
...(issue.document
? [
"",
"<details>",
"<summary>Annotation data</summary>",
"",
"```json",
JSON.stringify(issue.document, null, 2),
"```",
"</details>",
]
: []),
].join("\n"),
)
return url.href
+340 -14
View File
@@ -114,7 +114,7 @@ a {
}
:focus-visible {
outline: 1px solid var(--kit-accent);
outline: 1px solid var(--kit-fg-faint);
outline-offset: -1px;
}
@@ -222,12 +222,17 @@ kbd {
}
.catalog-tabs button:focus-visible,
.viewer-button:focus-visible,
.command-trigger:focus-visible {
outline: 1px solid var(--catalog-accent);
outline: 1px solid var(--kit-fg-faint);
outline-offset: -1px;
}
.viewer-button:focus-visible {
outline: 0;
background: var(--kit-bg-hover);
color: var(--kit-fg-strong);
}
.catalog-tools {
display: flex;
height: 100%;
@@ -692,20 +697,35 @@ kbd {
}
.variant-switcher select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
appearance: none;
border: 0;
padding: 0;
background: transparent;
color: inherit;
font-family: inherit;
font-size: inherit;
opacity: 0;
outline: 0;
cursor: pointer;
}
.variant-switcher .variant-chevron {
.variant-switcher:has(select:focus-visible) {
outline: 1px solid var(--kit-fg-faint);
outline-offset: -1px;
}
.variant-switcher .variant-hint {
color: var(--kit-fg-faint);
font-size: 0.58rem;
}
.variant-switcher .variant-name {
color: inherit;
}
.variant-switcher .variant-chevron {
margin-left: -0.15rem;
color: var(--kit-fg-faint);
font-size: 0.55rem;
}
.capture-open:hover .capture-frame,
@@ -718,7 +738,7 @@ kbd {
}
.capture-open:focus-visible .capture-frame {
outline: 1px solid var(--catalog-accent);
outline: 1px solid var(--kit-fg-faint);
outline-offset: 0.3rem;
}
@@ -746,6 +766,11 @@ kbd {
letter-spacing: 0.08em;
list-style: none;
cursor: pointer;
user-select: none;
}
.capture-actions > summary:focus:not(:focus-visible) {
outline: 0;
}
.capture-actions > summary::-webkit-details-marker {
@@ -1071,7 +1096,7 @@ kbd {
}
.flow-open:focus-visible .flow-frame {
outline: 1px solid var(--catalog-accent);
outline: 1px solid var(--kit-fg-faint);
outline-offset: 0.3rem;
}
@@ -1160,7 +1185,7 @@ kbd {
.viewer-header > .viewer-button:first-child {
justify-self: start;
border-right: 1px solid var(--kit-line);
padding-inline: 1.1rem;
}
.viewer-position {
@@ -1175,11 +1200,12 @@ kbd {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
padding-right: 0.75rem;
}
.viewer-actions .capture-actions {
align-self: center;
margin-inline: 0.4rem;
}
.viewer-button {
@@ -1188,8 +1214,36 @@ kbd {
gap: 0.5rem;
}
.viewer-actions .variant-switcher {
border-left: 0;
min-height: 1.9rem;
padding: 0 0.8rem;
}
.viewer-button kbd {
padding: 0;
border: 0;
background: transparent;
color: var(--kit-fg-faint);
}
.viewer-button-count {
display: inline-grid;
min-width: 1rem;
height: 1rem;
padding: 0 0.28rem;
border-radius: 999px;
place-items: center;
background: var(--catalog-mark);
color: var(--catalog-mark-ink);
font-size: 0.56rem;
font-weight: 700;
line-height: 1;
}
.viewer-actions .viewer-button {
border-left: 1px solid var(--kit-line);
align-self: center;
min-height: 1.9rem;
}
.viewer-body {
@@ -1235,6 +1289,259 @@ kbd {
-webkit-user-drag: none;
}
.annotation-layer {
position: absolute;
inset: 0;
cursor: crosshair;
}
.annotation-pin,
.annotation-list-pin {
display: grid;
place-items: center;
border: 2px solid #17120a;
border-radius: 999px;
background: var(--catalog-mark);
color: var(--catalog-mark-ink);
font-family: var(--kit-mono);
font-size: 0.65rem;
font-weight: 750;
line-height: 1;
box-shadow: 0 2px 10px rgb(0 0 0 / 60%);
}
.annotation-pin {
position: absolute;
width: 1.55rem;
height: 1.55rem;
transform: translate(-50%, -50%);
}
.annotation-pin:hover,
.annotation-pin:focus-visible,
.annotation-pin.selected {
outline: 2px solid #fff2d8;
outline-offset: 2px;
}
.annotation-composer {
position: absolute;
z-index: 6;
display: grid;
width: 18rem;
gap: 0.55rem;
padding: 0.75rem;
transform: translate(-50%, 1.2rem);
border-radius: 0.85rem;
background: #1a1a1a;
box-shadow:
0 12px 40px rgb(0 0 0 / 55%),
0 0 0 1px rgb(255 255 255 / 9%);
cursor: default;
animation: annotation-composer-in 150ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.annotation-composer.above {
transform: translate(-50%, calc(-100% - 1.2rem));
}
.annotation-composer header,
.annotation-composer footer {
display: flex;
align-items: center;
gap: 0.35rem;
}
.annotation-composer header {
justify-content: space-between;
color: var(--kit-fg-faint);
font-family: var(--kit-mono);
font-size: 0.62rem;
}
.annotation-composer header small {
font-size: 0.54rem;
}
.annotation-composer textarea {
width: 100%;
resize: none;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 0.5rem;
outline: none;
padding: 0.55rem 0.65rem;
background: rgb(255 255 255 / 5%);
color: var(--kit-fg-strong);
font-family: var(--kit-sans);
font-size: 0.8rem;
line-height: 1.45;
}
.annotation-composer textarea:focus {
border-color: var(--catalog-mark);
}
.annotation-composer footer {
justify-content: flex-end;
}
.annotation-composer footer button {
min-height: 1.8rem;
padding: 0 0.65rem;
border-radius: 0.45rem;
color: var(--kit-fg-muted);
font-family: var(--kit-sans);
font-size: 0.7rem;
}
.annotation-composer footer > :first-child {
margin-right: auto;
}
.annotation-composer .annotation-composer-delete {
color: #ff8585;
}
.annotation-composer .annotation-composer-save {
background: var(--catalog-mark);
color: var(--catalog-mark-ink);
font-weight: 700;
}
.annotation-composer .annotation-composer-save:disabled {
opacity: 0.4;
}
@keyframes annotation-composer-in {
from {
opacity: 0;
scale: 0.96;
}
}
.annotation-panel {
position: fixed;
z-index: 4;
top: 3rem;
right: 0;
bottom: 0;
display: grid;
width: min(22rem, 34vw);
grid-template-rows: auto minmax(0, 1fr) auto;
border-left: 1px solid var(--kit-line-strong);
background: #0b0b0b;
box-shadow: -24px 0 64px rgb(0 0 0 / 35%);
}
.annotation-panel > header,
.annotation-panel > footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem;
}
.annotation-panel > header {
border-bottom: 1px solid var(--kit-line);
}
.annotation-panel > header div {
display: grid;
gap: 0.18rem;
}
.annotation-panel strong,
.annotation-panel > header button,
.annotation-panel > footer button {
font-family: var(--kit-mono);
font-size: 0.68rem;
}
.annotation-panel > header span,
.annotation-panel > footer span,
.annotation-list section > div > span {
color: var(--kit-fg-faint);
font-family: var(--kit-mono);
font-size: 0.56rem;
}
.annotation-panel > header button {
padding: 0.4rem 0.55rem;
color: var(--kit-fg-muted);
}
.annotation-list {
overflow-y: auto;
}
.annotation-list-row {
display: grid;
width: 100%;
grid-template-columns: 1.65rem minmax(0, 1fr);
align-items: center;
gap: 0.65rem;
padding: 0.9rem 1rem;
border-bottom: 1px solid var(--kit-line);
text-align: left;
}
.annotation-list-pin {
width: 1.55rem;
height: 1.55rem;
}
.annotation-list-row > span:last-child {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.annotation-list-row small {
color: var(--kit-fg-faint);
font-family: var(--kit-mono);
font-size: 0.53rem;
}
.annotation-list-row strong {
overflow: hidden;
color: var(--kit-fg-muted);
font-size: 0.72rem;
font-weight: 450;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.annotation-list-row:hover,
.annotation-list-row:focus-visible {
background: rgb(255 255 255 / 3%);
}
.annotation-panel > footer {
align-items: stretch;
flex-direction: column;
gap: 0.45rem;
border-top: 1px solid var(--kit-line);
}
.annotation-issue {
display: grid;
place-items: center;
min-height: 2.3rem;
padding: 0 0.85rem;
background: var(--catalog-mark);
color: var(--catalog-mark-ink);
font-weight: 700;
}
.annotation-issue[aria-disabled="true"] {
background: var(--kit-bg-hover);
color: var(--kit-fg-faint);
cursor: not-allowed;
pointer-events: none;
}
.viewer-variant {
display: flex;
align-items: center;
@@ -1494,6 +1801,25 @@ kbd {
}
@media (max-width: 760px) {
.annotation-panel {
top: auto;
width: 100%;
height: min(48dvh, 25rem);
border-top: 1px solid var(--kit-line-strong);
border-left: 0;
box-shadow: 0 -24px 64px rgb(0 0 0 / 45%);
}
.annotation-pin {
width: 1.9rem;
height: 1.9rem;
font-size: 0.75rem;
}
.annotation-composer textarea {
font-size: 1rem;
}
:root {
--catalog-header-height: 10.5rem;
}
+1
View File
@@ -8,6 +8,7 @@
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"html_handling": "none",
},
"routes": [
{
+4
View File
@@ -1,11 +1,15 @@
import type { MermaidDiagramKind } from "./diagnostics.js"
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
import { isMermaidGitGraphDiagram } from "./gitgraph/parser.js"
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
import { isMermaidStateDiagram } from "./state/parser.js"
import { isMermaidTimelineDiagram } from "./timeline/parser.js"
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
if (isMermaidFlowchartDiagram(content)) return "flowchart"
if (isMermaidGitGraphDiagram(content)) return "gitGraph"
if (isMermaidSequenceDiagram(content)) return "sequence"
if (isMermaidStateDiagram(content)) return "state"
if (isMermaidTimelineDiagram(content)) return "timeline"
return undefined
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type MermaidDiagramKind = "flowchart" | "sequence" | "state"
export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" | "gitGraph"
/** An otherwise valid diagram contains syntax that this renderer does not support. */
export class MermaidSyntaxError extends Error {
@@ -0,0 +1,183 @@
import { describe, expect, test } from "bun:test"
import { MermaidSyntaxError } from "../diagnostics.js"
import { renderGitGraphDiagram } from "./diagram.js"
import { drawGitGraphDiagramGrid } from "./drawing.js"
import { isMermaidGitGraphDiagram, parseMermaidGitGraphDiagram } from "./parser.js"
import { renderGitGraphGridText } from "./render-grid.js"
import { resolveGitGraphStyleColors } from "./style.js"
describe("GitGraphDiagram", () => {
test("detects and parses commits, branches, checkout, tags, types, and merges", () => {
const diagram = parseMermaidGitGraphDiagram(`gitGraph TB:
commit id: "init"
branch feature order: 1
commit id: "api" msg: "Add API" tag: "ready"
checkout main
commit id: "docs" type: HIGHLIGHT
merge feature id: "merge-feature"`)
expect(diagram).toEqual({
direction: "TB",
branches: [
{ name: "main", order: 0, head: "merge-feature" },
{ name: "feature", order: 1, head: "api" },
],
commits: [
{ id: "init", tags: [], type: "NORMAL", branch: "main", parents: [] },
{ id: "api", message: "Add API", tags: ["ready"], type: "NORMAL", branch: "feature", parents: ["init"] },
{ id: "docs", tags: [], type: "HIGHLIGHT", branch: "main", parents: ["init"] },
{
id: "merge-feature",
tags: [],
type: "NORMAL",
branch: "main",
parents: ["docs", "api"],
},
],
})
})
test("renders branch and merge transitions beside compact labels", () => {
const source = `gitGraph
commit id: "baseline"
branch refactor
commit id: "extract-seam" msg: "Extract seam"
commit id: "add-tests" tag: "ready"
checkout main
commit id: "unrelated-fix"
merge refactor id: "land-refactor" tag: "v2"`
expect(renderGitGraphDiagram(source)).toBe(`● baseline
├─╮
│ ● Extract seam
│ ● add-tests [refactor] [ready]
● │ unrelated-fix
◎─╯ land-refactor [main] [v2]`)
})
test("uses deterministic generated ids", () => {
expect(parseMermaidGitGraphDiagram("gitGraph\n commit\n commit").commits.map((commit) => commit.id)).toEqual([
"commit-1",
"commit-2",
])
})
test("supports shorthand messages and preserves branch heads without direct commits", () => {
const diagram = parseMermaidGitGraphDiagram(`gitGraph
commit "Initial release"
branch feature
checkout main
commit id: next`)
expect(diagram.commits[0]?.message).toBe("Initial release")
expect(diagram.branches).toEqual([
{ name: "main", order: 0, head: "next" },
{ name: "feature", head: "commit-1" },
])
expect(
renderGitGraphDiagram(`gitGraph
commit id: base
branch feature
checkout main
commit id: next`),
).toContain("base [feature]")
})
test("places unordered branches before explicitly ordered branches", () => {
const diagram = parseMermaidGitGraphDiagram(`gitGraph
commit id: base
branch later order: 2
checkout main
branch ordinary
checkout main
branch earlier order: 1`)
expect(diagram.branches.map((branch) => branch.name)).toEqual(["main", "ordinary", "earlier", "later"])
})
test("keeps comment markers inside quoted labels", () => {
expect(parseMermaidGitGraphDiagram('gitGraph\n commit id: "release%%candidate" %% comment').commits[0]?.id).toBe(
"release%%candidate",
)
})
test("uses rounded routing for wide lane transitions", () => {
expect(
renderGitGraphDiagram(`gitGraph
commit id: base
branch one
branch two
commit id: work`),
).toBe(`● base [main] [one]
├───╮
● work [two]`)
})
test("preserves direction semantics while rendering vertically", () => {
const source = "gitGraph BT:\n commit id: one"
const diagram = parseMermaidGitGraphDiagram(source)
expect(diagram.direction).toBe("BT")
expect(renderGitGraphGridText(drawGitGraphDiagramGrid(diagram, { direction: "LR" }))).toBe(
renderGitGraphDiagram(source),
)
})
test("reports semantic failures with source diagnostics", () => {
expect(() => parseMermaidGitGraphDiagram("gitGraph\n checkout missing")).toThrow(
new MermaidSyntaxError("gitGraph", 2, "checkout missing", 'Unknown branch "missing"'),
)
expect(() => parseMermaidGitGraphDiagram("gitGraph\n cherry-pick id: one")).toThrow(
new MermaidSyntaxError("gitGraph", 2, "cherry-pick id: one", "Cherry-pick is not supported"),
)
expect(() => parseMermaidGitGraphDiagram("gitGraph\n commit id: same\n commit id: same")).toThrow(
'Duplicate commit id "same"',
)
expect(() => parseMermaidGitGraphDiagram("gitGraph\n branch feature\n checkout main\n branch feature")).toThrow(
'Duplicate branch "feature"',
)
expect(() =>
parseMermaidGitGraphDiagram("gitGraph\n branch feature\n commit id: work\n checkout main\n merge feature"),
).toThrow('Branch "main" has no commits')
})
test("draws semantic styles for rails, commit types, merges, and labels", () => {
const grid = drawGitGraphDiagramGrid(
parseMermaidGitGraphDiagram(`gitGraph
commit id: base
branch feature
commit id: work type: REVERSE
checkout main
commit id: checkpoint type: HIGHLIGHT
merge feature id: done`),
)
const styles = new Set(grid.rows.flatMap((row) => row.map((cell) => cell.style).filter(Boolean)))
expect(styles).toEqual(new Set(["branch0", "branch1", "commit", "reverse", "highlight", "merge", "label"]))
expect(Object.keys(resolveGitGraphStyleColors()).sort()).toEqual(
[
"branch0",
"branch1",
"branch2",
"branch3",
"branch4",
"branch5",
"branch6",
"branch7",
"commit",
"highlight",
"label",
"merge",
"reverse",
].sort(),
)
})
test("recognizes only GitGraph headers", () => {
expect(isMermaidGitGraphDiagram("%% comment\ngitGraph LR:\n commit")).toBe(true)
expect(isMermaidGitGraphDiagram("graph LR\n A --> B")).toBe(false)
expect(() => parseMermaidGitGraphDiagram("commit id: missing-header")).toThrow("GitGraph header is required")
expect(() => parseMermaidGitGraphDiagram("gitGraph\n commit\n gitGraph")).toThrow(
"GitGraph header can only appear once",
)
})
})
+8
View File
@@ -0,0 +1,8 @@
import { drawGitGraphDiagramGrid } from "./drawing.js"
import { parseMermaidGitGraphDiagram } from "./parser.js"
import { renderGitGraphGridText } from "./render-grid.js"
import type { GitGraphDiagramRenderOptions } from "./types.js"
export function renderGitGraphDiagram(content: string, options: GitGraphDiagramRenderOptions = {}): string {
return renderGitGraphGridText(drawGitGraphDiagramGrid(parseMermaidGitGraphDiagram(content), options))
}
+234
View File
@@ -0,0 +1,234 @@
import { DiagramCanvas } from "../core/canvas.js"
import { diagramTextWidth } from "../core/text.js"
import type { GitGraphGrid } from "./render-grid.js"
import type { GitGraphCellStyle, GitGraphCommit, GitGraphDiagram, GitGraphDiagramRenderOptions } from "./types.js"
interface BranchSpan {
first: number
last: number
}
interface Connections {
up?: boolean
down?: boolean
left?: boolean
right?: boolean
style: GitGraphCellStyle
}
const LANE_WIDTH = 2
const LABEL_GAP = 2
export function drawGitGraphDiagramGrid(
diagram: GitGraphDiagram,
_options: GitGraphDiagramRenderOptions = {},
): GitGraphGrid {
if (diagram.commits.length === 0) return new DiagramCanvas(0, 0)
const laneByBranch = new Map(diagram.branches.map((branch, index) => [branch.name, index]))
const commitById = new Map(diagram.commits.map((commit) => [commit.id, commit]))
const spans = branchSpans(diagram, commitById)
const heads = branchHeads(diagram)
const graphWidth = (diagram.branches.length - 1) * LANE_WIDTH + 1
let labelWidth = 0
for (const commit of diagram.commits) labelWidth = Math.max(labelWidth, diagramTextWidth(commitLabel(commit, heads)))
const forks = diagram.commits.map((commit) => isFork(commit, laneByBranch, commitById))
const height = diagram.commits.length + forks.filter(Boolean).length
const grid: GitGraphGrid = new DiagramCanvas(graphWidth + LABEL_GAP + labelWidth, height)
let row = 0
diagram.commits.forEach((commit, index) => {
if (forks[index]) {
drawTransitionRow(grid, spans, laneByBranch, commitById, commit, index, row)
row += 1
}
drawCommitRow(grid, diagram, spans, laneByBranch, commitById, commit, index, row)
grid.setText(graphWidth + LABEL_GAP, row, commitLabel(commit, heads), "label")
row += 1
})
return grid
}
function drawTransitionRow(
grid: GitGraphGrid,
spans: Map<string, BranchSpan>,
laneByBranch: Map<string, number>,
commitById: Map<string, GitGraphCommit>,
commit: GitGraphCommit,
index: number,
y: number,
): void {
const cells = new Map<number, Connections>()
for (const [branch, span] of spans) {
if (span.first >= index || span.last < index) continue
const lane = laneByBranch.get(branch)!
connect(cells, lane * LANE_WIDTH, { up: true, down: true }, branchStyle(lane))
}
const lane = laneByBranch.get(commit.branch)!
const firstParent = commit.parents[0] === undefined ? undefined : commitById.get(commit.parents[0])
if (firstParent && firstParent.branch !== commit.branch) {
const parentLane = laneByBranch.get(firstParent.branch)!
connectHorizontal(
cells,
parentLane,
lane,
{ sourceUp: true, sourceDown: true, targetDown: true },
branchStyle(lane),
)
}
paintConnections(grid, cells, y)
}
function drawCommitRow(
grid: GitGraphGrid,
diagram: GitGraphDiagram,
spans: Map<string, BranchSpan>,
laneByBranch: Map<string, number>,
commitById: Map<string, GitGraphCommit>,
commit: GitGraphCommit,
index: number,
y: number,
): void {
const cells = new Map<number, Connections>()
for (const branch of diagram.branches) {
const span = spans.get(branch.name)
if (!span || span.first > index || (span.last <= index && branch.name !== commit.branch)) continue
const lane = laneByBranch.get(branch.name)!
connect(cells, lane * LANE_WIDTH, { up: index > 0, down: span.last > index }, branchStyle(lane))
}
const lane = laneByBranch.get(commit.branch)!
const secondParent = commit.parents[1] === undefined ? undefined : commitById.get(commit.parents[1])
if (secondParent) {
const parentLane = laneByBranch.get(secondParent.branch)!
connectHorizontal(cells, lane, parentLane, { sourceUp: true, targetUp: true }, branchStyle(parentLane))
}
paintConnections(grid, cells, y)
grid.setCell(lane * LANE_WIDTH, y, commitGlyph(commit), commitStyle(commit))
}
function connectHorizontal(
cells: Map<number, Connections>,
sourceLane: number,
targetLane: number,
vertical: { sourceUp?: boolean; sourceDown?: boolean; targetUp?: boolean; targetDown?: boolean },
style: GitGraphCellStyle,
): void {
if (sourceLane === targetLane) return
const source = sourceLane * LANE_WIDTH
const target = targetLane * LANE_WIDTH
const direction = Math.sign(target - source)
connect(
cells,
source,
{ ...verticalAt(vertical.sourceUp, vertical.sourceDown), ...(direction > 0 ? { right: true } : { left: true }) },
style,
)
for (let x = source + direction; x !== target; x += direction) {
connect(cells, x, { left: true, right: true }, style)
}
connect(
cells,
target,
{ ...verticalAt(vertical.targetUp, vertical.targetDown), ...(direction > 0 ? { left: true } : { right: true }) },
style,
)
}
function verticalAt(up: boolean | undefined, down: boolean | undefined): Pick<Connections, "up" | "down"> {
return { ...(up ? { up: true } : {}), ...(down ? { down: true } : {}) }
}
function connect(
cells: Map<number, Connections>,
x: number,
additions: Omit<Connections, "style">,
style: GitGraphCellStyle,
): void {
const current = cells.get(x)
cells.set(x, { ...current, ...additions, style: current?.style ?? style })
}
function paintConnections(grid: GitGraphGrid, cells: Map<number, Connections>, y: number): void {
for (const [x, connections] of cells) grid.setCell(x, y, connectionGlyph(connections), connections.style)
}
function connectionGlyph({ up, down, left, right }: Connections): string {
const mask = `${up ? 1 : 0}${down ? 1 : 0}${left ? 1 : 0}${right ? 1 : 0}`
const glyphs: Record<string, string> = {
"1100": "│",
"0011": "─",
"0101": "╭",
"0110": "╮",
"1001": "╰",
"1010": "╯",
"1101": "├",
"1110": "┤",
"0111": "┬",
"1011": "┴",
"1111": "┼",
"1000": "│",
"0100": "│",
"0010": "─",
"0001": "─",
}
return glyphs[mask] ?? " "
}
function branchSpans(diagram: GitGraphDiagram, commitById: Map<string, GitGraphCommit>): Map<string, BranchSpan> {
const spans = new Map<string, BranchSpan>()
diagram.commits.forEach((commit, index) => {
const span = spans.get(commit.branch)
if (span) span.last = index
else spans.set(commit.branch, { first: index, last: index })
for (const parentId of commit.parents) {
const parent = commitById.get(parentId)
if (!parent || parent.branch === commit.branch) continue
const parentSpan = spans.get(parent.branch)
if (parentSpan) parentSpan.last = Math.max(parentSpan.last, index)
}
})
return spans
}
function branchHeads(diagram: GitGraphDiagram): Map<string, string[]> {
const heads = new Map<string, string[]>()
for (const branch of diagram.branches) {
if (branch.head === undefined) continue
const names = heads.get(branch.head) ?? []
names.push(branch.name)
heads.set(branch.head, names)
}
return heads
}
function isFork(
commit: GitGraphCommit,
laneByBranch: Map<string, number>,
commitById: Map<string, GitGraphCommit>,
): boolean {
const parent = commit.parents[0] === undefined ? undefined : commitById.get(commit.parents[0])
return parent !== undefined && laneByBranch.get(parent.branch) !== laneByBranch.get(commit.branch)
}
function commitGlyph(commit: GitGraphCommit): string {
if (commit.type === "REVERSE") return "⊗"
if (commit.type === "HIGHLIGHT") return "◆"
return commit.parents.length > 1 ? "◎" : "●"
}
function commitStyle(commit: GitGraphCommit): GitGraphCellStyle {
if (commit.type === "REVERSE") return "reverse"
if (commit.type === "HIGHLIGHT") return "highlight"
return commit.parents.length > 1 ? "merge" : "commit"
}
function commitLabel(commit: GitGraphCommit, heads: Map<string, string[]>): string {
const subject = commit.message ?? commit.id
const decorations = [...(heads.get(commit.id) ?? []), ...commit.tags].map((value) => `[${value}]`)
return decorations.length === 0 ? subject : `${subject} ${decorations.join(" ")}`
}
function branchStyle(lane: number): GitGraphCellStyle {
return `branch${lane % 8}` as GitGraphCellStyle
}
+219
View File
@@ -0,0 +1,219 @@
import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js"
import { MermaidSyntaxError } from "../diagnostics.js"
import type { GitGraphBranch, GitGraphCommit, GitGraphCommitType, GitGraphDiagram, GitGraphDirection } from "./types.js"
const HEADER_RE = /^gitGraph(?:\s+(LR|TB|BT))?\s*:?$/i
const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i
export function isMermaidGitGraphDiagram(content: string): boolean {
return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
}
export function parseMermaidGitGraphDiagram(content: string): GitGraphDiagram {
const firstLine = firstMeaningfulMermaidLine(content)
if (!HEADER_RE.test(firstLine ?? "")) throw syntaxError(1, firstLine ?? "", "GitGraph header is required")
const branches: GitGraphBranch[] = [{ name: "main", order: 0 }]
const commits: GitGraphCommit[] = []
const heads = new Map<string, string | undefined>([["main", undefined]])
const ids = new Set<string>()
let direction: GitGraphDirection = "LR"
let currentBranch = "main"
let generatedId = 1
let inAccessibilityDescription = false
let headerSeen = false
for (const source of meaningfulNumberedMermaidLines(content)) {
const line = stripComment(source.text)
if (inAccessibilityDescription) {
if (line === "}") inAccessibilityDescription = false
continue
}
if (/^accDescr\s*\{$/i.test(line)) {
inAccessibilityDescription = true
continue
}
if (!line || ACCESSIBILITY_RE.test(line) || /^title(?:\s|$)/i.test(line)) continue
const header = line.match(HEADER_RE)
if (header) {
if (headerSeen) throw syntaxError(source.lineNumber, line, "GitGraph header can only appear once")
headerSeen = true
direction = (header[1]?.toUpperCase() as GitGraphDirection | undefined) ?? "LR"
continue
}
const [command = "", ...rest] = tokenize(line)
const operation = command.toLowerCase()
if (operation === "commit") {
const shorthandMessage = rest[0]?.match(/^(["']).*\1$/) ? stripMermaidQuotes(rest.shift()!) : undefined
const attributes = parseAttributes(rest, source.lineNumber, line, ["id", "msg", "tag", "type"])
const id = single(attributes, "id", source.lineNumber, line) ?? `commit-${generatedId++}`
if (!id) throw syntaxError(source.lineNumber, line, "GitGraph commit id cannot be empty")
if (ids.has(id)) throw syntaxError(source.lineNumber, line, `Duplicate commit id "${id}"`)
const type = parseCommitType(single(attributes, "type", source.lineNumber, line), source.lineNumber, line)
const parent = heads.get(currentBranch)
const message = single(attributes, "msg", source.lineNumber, line) ?? shorthandMessage
const commit: GitGraphCommit = {
id,
...(message === undefined ? {} : { message }),
tags: attributes.get("tag") ?? [],
type,
branch: currentBranch,
parents: parent === undefined ? [] : [parent],
}
commits.push(commit)
ids.add(id)
heads.set(currentBranch, id)
continue
}
if (operation === "branch") {
if (rest.length === 0) throw syntaxError(source.lineNumber, line, "GitGraph branch name cannot be empty")
const name = stripMermaidQuotes(rest[0]!)
if (!name) throw syntaxError(source.lineNumber, line, "GitGraph branch name cannot be empty")
if (heads.has(name)) throw syntaxError(source.lineNumber, line, `Duplicate branch "${name}"`)
const attributes = parseAttributes(rest.slice(1), source.lineNumber, line, ["order"])
const orderValue = single(attributes, "order", source.lineNumber, line)
const order = orderValue === undefined ? undefined : Number(orderValue)
if (order !== undefined && (!Number.isInteger(order) || order < 0)) {
throw syntaxError(source.lineNumber, line, "GitGraph branch order must be a non-negative integer")
}
branches.push({ name, ...(order === undefined ? {} : { order }) })
heads.set(name, heads.get(currentBranch))
currentBranch = name
continue
}
if (operation === "checkout" || operation === "switch") {
if (rest.length !== 1) throw syntaxError(source.lineNumber, line, `GitGraph ${operation} requires one branch`)
const name = stripMermaidQuotes(rest[0]!)
if (!heads.has(name)) throw syntaxError(source.lineNumber, line, `Unknown branch "${name}"`)
currentBranch = name
continue
}
if (operation === "merge") {
if (rest.length === 0) throw syntaxError(source.lineNumber, line, "GitGraph merge requires a branch")
const branch = stripMermaidQuotes(rest[0]!)
if (!heads.has(branch)) throw syntaxError(source.lineNumber, line, `Unknown branch "${branch}"`)
if (branch === currentBranch)
throw syntaxError(source.lineNumber, line, "GitGraph cannot merge a branch into itself")
const currentHead = heads.get(currentBranch)
const mergedHead = heads.get(branch)
if (currentHead === undefined)
throw syntaxError(source.lineNumber, line, `Branch "${currentBranch}" has no commits`)
if (mergedHead === undefined) throw syntaxError(source.lineNumber, line, `Branch "${branch}" has no commits`)
if (currentHead === mergedHead)
throw syntaxError(source.lineNumber, line, `Branches already share head "${mergedHead}"`)
const attributes = parseAttributes(rest.slice(1), source.lineNumber, line, ["id", "tag", "type"])
const id = single(attributes, "id", source.lineNumber, line) ?? `commit-${generatedId++}`
if (ids.has(id)) throw syntaxError(source.lineNumber, line, `Duplicate commit id "${id}"`)
const commit: GitGraphCommit = {
id,
tags: attributes.get("tag") ?? [],
type: parseCommitType(single(attributes, "type", source.lineNumber, line), source.lineNumber, line),
branch: currentBranch,
parents: [currentHead, mergedHead],
}
commits.push(commit)
ids.add(id)
heads.set(currentBranch, id)
continue
}
if (operation === "cherry-pick") {
throw syntaxError(source.lineNumber, line, "Cherry-pick is not supported")
}
throw syntaxError(source.lineNumber, line)
}
const resolvedBranches = branches.map((branch) => {
const head = heads.get(branch.name)
return { ...branch, ...(head === undefined ? {} : { head }) }
})
return { direction, branches: orderBranches(resolvedBranches), commits }
}
function tokenize(line: string): string[] {
const tokens: string[] = []
let token = ""
let quote: '"' | "'" | undefined
for (const char of line) {
if ((char === '"' || char === "'") && (quote === undefined || quote === char)) {
quote = quote === char ? undefined : char
token += char
continue
}
if (/\s/.test(char) && quote === undefined) {
if (token) tokens.push(token)
token = ""
continue
}
token += char
}
if (quote !== undefined) return [line]
if (token) tokens.push(token)
return tokens
}
function parseAttributes(
tokens: string[],
lineNumber: number,
line: string,
allowed: readonly string[],
): Map<string, string[]> {
const result = new Map<string, string[]>()
for (let index = 0; index < tokens.length; index += 1) {
const keyToken = tokens[index]!
const separator = keyToken.indexOf(":")
const key = (separator < 0 ? keyToken : keyToken.slice(0, separator)).toLowerCase()
if (!allowed.includes(key)) throw syntaxError(lineNumber, line, `Unsupported GitGraph attribute "${key}"`)
const inline = separator < 0 ? "" : keyToken.slice(separator + 1)
const valueToken = inline || tokens[++index]
if (valueToken === undefined) throw syntaxError(lineNumber, line, `GitGraph attribute "${key}" requires a value`)
const values = result.get(key) ?? []
values.push(stripMermaidQuotes(valueToken))
result.set(key, values)
}
return result
}
function single(attributes: Map<string, string[]>, key: string, lineNumber: number, line: string): string | undefined {
const values = attributes.get(key)
if (values && values.length > 1) throw syntaxError(lineNumber, line, `GitGraph attribute "${key}" cannot repeat`)
return values?.[0]
}
function parseCommitType(value: string | undefined, lineNumber: number, line: string): GitGraphCommitType {
if (value === undefined) return "NORMAL"
const type = value.toUpperCase()
if (type === "NORMAL" || type === "REVERSE" || type === "HIGHLIGHT") return type
throw syntaxError(lineNumber, line, `Unknown GitGraph commit type "${value}"`)
}
function orderBranches(branches: GitGraphBranch[]): GitGraphBranch[] {
const main = branches[0]!
const rest = branches.slice(1).map((branch, index) => ({ branch, index }))
const unordered = rest.filter(({ branch }) => branch.order === undefined)
const ordered = rest
.filter(({ branch }) => branch.order !== undefined)
.sort((left, right) => left.branch.order! - right.branch.order! || left.index - right.index)
return [main, ...unordered.map(({ branch }) => branch), ...ordered.map(({ branch }) => branch)]
}
function stripComment(value: string): string {
let quote: '"' | "'" | undefined
for (let index = 0; index < value.length - 1; index += 1) {
const char = value[index]
if ((char === '"' || char === "'") && (quote === undefined || quote === char)) {
quote = quote === char ? undefined : char
continue
}
if (quote === undefined && char === "%" && value[index + 1] === "%") return value.slice(0, index).trim()
}
return value.trim()
}
function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError {
return new MermaidSyntaxError("gitGraph", lineNumber, sourceLine, reason)
}
@@ -0,0 +1,17 @@
import type { StyledText } from "@opentui/core"
import type { DiagramCanvas } from "../core/canvas.js"
import { renderDiagramGridStyledText } from "../core/render-grid.js"
import type { GitGraphStyleColors } from "./style.js"
import type { GitGraphCellStyle } from "./types.js"
export type GitGraphGrid = DiagramCanvas<GitGraphCellStyle>
export function renderGitGraphGridText(grid: GitGraphGrid): string {
return grid.toString({ trimBottom: true })
}
export function renderGitGraphGridStyledText(grid: GitGraphGrid, colors: GitGraphStyleColors): StyledText {
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
trimBottom: true,
})
}
+37
View File
@@ -0,0 +1,37 @@
import { RGBA } from "@opentui/core"
import { rgba, type DiagramRgb } from "../core/color/style.js"
import type { GitGraphCellStyle } from "./types.js"
const BRANCH_RGB = [
[134, 225, 200],
[230, 177, 126],
[154, 184, 169],
[198, 160, 246],
[126, 189, 230],
[225, 134, 166],
[190, 210, 120],
[180, 180, 210],
] as const satisfies readonly DiagramRgb[]
export type GitGraphStyleColors = Required<Record<GitGraphCellStyle, RGBA>>
export function resolveGitGraphStyleColors(
colors: Partial<Record<"primary" | "secondary" | "muted" | "warning" | "text", RGBA | undefined>> = {},
): GitGraphStyleColors {
const rail = colors.muted ?? rgba([111, 138, 126])
return {
branch0: rail,
branch1: rail,
branch2: rail,
branch3: rail,
branch4: rail,
branch5: rail,
branch6: rail,
branch7: rail,
commit: colors.primary ?? rgba(BRANCH_RGB[0]),
merge: colors.secondary ?? rgba(BRANCH_RGB[2]),
highlight: colors.warning ?? rgba(BRANCH_RGB[1]),
reverse: colors.warning ?? rgba(BRANCH_RGB[5]),
label: colors.text ?? rgba([228, 239, 232]),
}
}
+36
View File
@@ -0,0 +1,36 @@
export type GitGraphDirection = "LR" | "TB" | "BT"
export type GitGraphCommitType = "NORMAL" | "REVERSE" | "HIGHLIGHT"
export interface GitGraphBranch {
name: string
order?: number
head?: string
}
export interface GitGraphCommit {
id: string
message?: string
tags: string[]
type: GitGraphCommitType
branch: string
parents: string[]
}
export interface GitGraphDiagram {
direction: GitGraphDirection
branches: GitGraphBranch[]
commits: GitGraphCommit[]
}
export interface GitGraphDiagramRenderOptions {
/** Parsed for Mermaid compatibility. Git graphs always use a vertical terminal layout. */
direction?: GitGraphDirection
}
export type GitGraphCellStyle =
| `branch${0 | 1 | 2 | 3 | 4 | 5 | 6 | 7}`
| "commit"
| "merge"
| "highlight"
| "reverse"
| "label"
+46
View File
@@ -17,6 +17,10 @@ import { detectMermaidDiagram } from "./detect.js"
import { drawFlowchartDiagramGrid } from "./flowchart/drawing.js"
import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js"
import { renderGridStyledText, resolveFlowchartStyleColors } from "./flowchart/style.js"
import { drawGitGraphDiagramGrid } from "./gitgraph/drawing.js"
import { parseMermaidGitGraphDiagram } from "./gitgraph/parser.js"
import { renderGitGraphGridStyledText } from "./gitgraph/render-grid.js"
import { resolveGitGraphStyleColors } from "./gitgraph/style.js"
import { drawSequenceDiagramGrid } from "./sequence/drawing.js"
import { parseMermaidSequenceDiagram } from "./sequence/parser.js"
import { renderSequenceGridStyledText } from "./sequence/render-grid.js"
@@ -25,6 +29,10 @@ import { drawStateDiagramGrid } from "./state/drawing.js"
import { parseMermaidStateDiagram } from "./state/parser.js"
import { renderStateGridStyledText } from "./state/render-grid.js"
import { resolveStateStyleColors } from "./state/style.js"
import { drawTimelineDiagramGrid } from "./timeline/drawing.js"
import { parseMermaidTimelineDiagram } from "./timeline/parser.js"
import { renderTimelineGridStyledText } from "./timeline/render-grid.js"
import { resolveTimelineStyleColors } from "./timeline/style.js"
type DiagramKind = NonNullable<ReturnType<typeof detectMermaidDiagram>>
@@ -133,6 +141,25 @@ function prepareDiagram(
height: size.height,
}
}
case "gitGraph": {
const grid = drawGitGraphDiagramGrid(parseMermaidGitGraphDiagram(source))
const size = grid.getTextSize({ trimBottom: true })
return {
kind,
source,
text: renderGitGraphGridStyledText(
grid,
resolveGitGraphStyleColors({
primary: color(colors.primary),
secondary: color(colors.secondary),
muted: color(colors.muted),
warning: color(colors.warning),
text: color(colors.text),
}),
),
height: size.height,
}
}
case "sequence": {
const grid = drawSequenceDiagramGrid(parseMermaidSequenceDiagram(source), { compact: options.compact })
const size = grid.getTextSize()
@@ -180,6 +207,25 @@ function prepareDiagram(
height: size.height,
}
}
case "timeline": {
const grid = drawTimelineDiagramGrid(parseMermaidTimelineDiagram(source))
const size = grid.getTextSize({ trimBottom: true })
return {
kind,
source,
text: renderTimelineGridStyledText(
grid,
resolveTimelineStyleColors({
title: color(colors.text),
section: color(colors.secondary),
period: color(colors.warning),
spine: color(colors.muted),
event: color(colors.primary),
}),
),
height: size.height,
}
}
}
}
@@ -1,8 +1,10 @@
import { describe, expect, test } from "bun:test"
import { MermaidSyntaxError } from "../diagnostics.js"
import { renderGitGraphDiagram } from "../gitgraph/diagram.js"
import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js"
import { parseMermaidSequenceDiagram } from "../sequence/parser.js"
import { parseMermaidStateDiagram } from "../state/parser.js"
import { renderTimelineDiagram } from "../timeline/diagram.js"
import { renderSequenceDiagram } from "../sequence/diagram.js"
describe("parser diagnostics", () => {
@@ -104,6 +106,18 @@ describe("parser diagnostics", () => {
).toThrow('Unexpected "end" without an open block in sequence diagram at line 2: "end"')
})
test("reports malformed timeline continuations with timeline diagnostics", () => {
expect(() => renderTimelineDiagram("timeline\n : orphan event")).toThrow(
'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"',
)
})
test("reports unsupported GitGraph operations with source diagnostics", () => {
expect(() => renderGitGraphDiagram("gitGraph\n cherry-pick id: missing")).toThrow(
'Cherry-pick is not supported in gitGraph diagram at line 2: "cherry-pick id: missing"',
)
})
test("does not attach else through an unclosed nested sequence block", () => {
expect(() =>
parseMermaidSequenceDiagram(`sequenceDiagram
+53
View File
@@ -333,3 +333,56 @@ stateDiagram-v2
expect(frame).toContain("Idle")
expect(frame).not.toContain("stateDiagram-v2")
})
test("renders a Mermaid timeline fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 18 })
renderer = testRenderer.renderer
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-timeline",
content: `\`\`\`mermaid
timeline
title Product history
section Foundation
2024 : Prototype
: First release
\`\`\``,
syntaxStyle,
treeSitterClient,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, renderOnce)
const frame = captureCharFrame()
expect(frame).toContain("Product history")
expect(frame).toContain("Foundation")
expect(frame).toContain("First release")
expect(frame).not.toContain("timeline")
})
test("renders a Mermaid GitGraph fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 18 })
renderer = testRenderer.renderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-gitgraph",
content: `\`\`\`mermaid
gitGraph
commit id: "baseline"
branch feature
commit id: "ship"
\`\`\``,
syntaxStyle,
treeSitterClient,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, testRenderer.renderOnce)
const frame = testRenderer.captureCharFrame()
expect(frame).toContain("baseline")
expect(frame).toContain("ship")
expect(frame).not.toContain("gitGraph")
})
@@ -0,0 +1,188 @@
import { describe, expect, test } from "bun:test"
import { renderTimelineDiagram } from "./diagram.js"
import { drawTimelineDiagramGrid } from "./drawing.js"
import { parseMermaidTimelineDiagram } from "./parser.js"
import { renderTimelineGridText } from "./render-grid.js"
import { resolveTimelineStyleColors } from "./style.js"
describe("TimelineDiagram", () => {
test("detects and parses titles, sections, periods, inline events, and continuations", () => {
const diagram = parseMermaidTimelineDiagram(`
%% product history
timeline LR
title Product &amp;<br/>Platform
section Foundation
2024 : Prototype : First release
: Public beta
section Growth
2025 : "Scale: &#x2265; 10k"
`)
expect(diagram.direction).toBe("LR")
expect(diagram.title).toBe("Product &<br/>Platform")
expect(diagram.sections).toEqual([{ label: "Foundation" }, { label: "Growth" }])
expect(diagram.periods).toEqual([
{ period: "2024", events: ["Prototype", "First release", "Public beta"] },
{ period: "2025", events: ["Scale: ≥ 10k"] },
])
expect(diagram.entries.map((entry) => entry.type)).toEqual(["section", "period", "section", "period"])
})
test("renders a vertical spine with title, section, periods, events, entities, and line breaks", () => {
const output = renderTimelineDiagram(`timeline
title Product &amp;<br/>Platform
section Foundation<br/>phase
2024 : Prototype<br/>ready : First release
: Scale &#x2265; 10k`)
expect(output).toBe(
[
" Product &",
" Platform",
"",
"Foundation ───┐",
" phase │",
" │",
" 2024 ───● Prototype",
" │ ready",
" │ First release",
" │ Scale ≥ 10k",
" │",
].join("\n"),
)
})
test.each(["timeline", "timeline TD", "timeline LR"])("uses the vertical terminal layout for %s", (header) => {
const output = renderTimelineDiagram(`${header}\n 2024 : One\n 2025 : Two`)
const lines = output.split("\n")
expect(lines.findIndex((line) => line.includes("2024"))).toBeLessThan(
lines.findIndex((line) => line.includes("2025")),
)
expect(output).toContain("│")
expect(output).toContain("●")
})
test("preserves Mermaid direction semantics while using vertical terminal layout", () => {
expect(parseMermaidTimelineDiagram("timeline\n 2024 : One").direction).toBe("LR")
expect(parseMermaidTimelineDiagram("timeline TD\n 2024 : One").direction).toBe("TD")
})
test("keeps ordinary colons in event text", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
2024 : https://example.com : event:detail : next event`)
expect(diagram.periods[0]?.events).toEqual(["https://example.com", "event:detail", "next event"])
})
test("does not treat apostrophes in event prose as quotes", () => {
const diagram = parseMermaidTimelineDiagram("timeline\n 2024 : Kit's launch : Public beta")
expect(diagram.periods[0]?.events).toEqual(["Kit's launch", "Public beta"])
})
test("supports standalone periods followed by continuation events", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
2024
: First release
: Public beta`)
expect(diagram.periods).toEqual([{ period: "2024", events: ["First release", "Public beta"] }])
})
test("ignores timeline comments and accessibility directives", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
# product history
accTitle: Product timeline
accDescr Product release history
2024 : Prototype %% internal note`)
expect(diagram.periods).toEqual([{ period: "2024", events: ["Prototype"] }])
})
test("ignores multiline accessibility descriptions", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
accDescr {
Product milestones by year.
Includes launch and growth.
}
2024 : Ship`)
expect(diagram.periods).toEqual([{ period: "2024", events: ["Ship"] }])
})
test("rejects a continuation without a period with source diagnostics", () => {
expect(() => parseMermaidTimelineDiagram("timeline\n : orphan event")).toThrow(
'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"',
)
})
test("rejects unsupported and empty syntax", () => {
expect(() => parseMermaidTimelineDiagram("timeline\n section")).toThrow("Timeline section cannot be empty")
expect(() => parseMermaidTimelineDiagram("timeline\n 2024 :")).toThrow("Timeline event cannot be empty")
expect(() => parseMermaidTimelineDiagram("timeline\n : unsupported")).toThrow("requires a preceding period")
})
test("draws semantic styles for every timeline role", () => {
const grid = drawTimelineDiagramGrid(
parseMermaidTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"),
)
const styles = new Set(grid.rows.flatMap((row) => row.map((cell) => cell.style).filter(Boolean)))
expect(styles).toEqual(
new Set([
"title",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
"period",
"periodFade1",
"periodFade2",
"periodFade3",
"event",
]),
)
expect(Object.keys(resolveTimelineStyleColors()).sort()).toEqual([
"event",
"period",
"periodFade1",
"periodFade2",
"periodFade3",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
"title",
])
expect(renderTimelineGridText(grid)).toBe(
renderTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"),
)
})
test("uses section starts and joins with ordered color ramps", () => {
const grid = drawTimelineDiagramGrid(
parseMermaidTimelineDiagram("timeline\n section Morning\n 09:00 : Start\n section Midday\n 12:00 : Continue"),
)
const text = renderTimelineGridText(grid)
expect(text).toContain("Morning ───┐")
expect(text).toContain("Midday ───┤")
expect(grid.rows[0]?.map((cell) => cell.style).filter(Boolean)).toEqual([
"section",
"section",
"section",
"section",
"section",
"section",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
])
})
})
+8
View File
@@ -0,0 +1,8 @@
import { drawTimelineDiagramGrid } from "./drawing.js"
import { parseMermaidTimelineDiagram } from "./parser.js"
import { renderTimelineGridText } from "./render-grid.js"
import type { TimelineDiagramRenderOptions } from "./types.js"
export function renderTimelineDiagram(content: string, options: TimelineDiagramRenderOptions = {}): string {
return renderTimelineGridText(drawTimelineDiagramGrid(parseMermaidTimelineDiagram(content), options))
}
+109
View File
@@ -0,0 +1,109 @@
import { DiagramCanvas } from "../core/canvas.js"
import { splitDiagramLines } from "../core/text-lines.js"
import { diagramTextWidth } from "../core/text.js"
import type { TimelineGrid } from "./render-grid.js"
import { TIMELINE_PERIOD_FADE_STYLES, TIMELINE_SECTION_FADE_STYLES } from "./style.js"
import type { TimelineCellStyle, TimelineDiagram, TimelineDiagramRenderOptions, TimelinePeriod } from "./types.js"
interface PeriodLayout {
period: TimelinePeriod
periodLines: string[]
eventLines: string[][]
height: number
}
const JOIN_WIDTH = TIMELINE_SECTION_FADE_STYLES.length
const SPINE_OFFSET = JOIN_WIDTH + 1
const EVENT_OFFSET = 3
export function drawTimelineDiagramGrid(
diagram: TimelineDiagram,
_options: TimelineDiagramRenderOptions = {},
): TimelineGrid {
const periodLayouts = new Map<TimelinePeriod, PeriodLayout>()
let leftWidth = 0
let rightWidth = 0
let bodyHeight = 0
for (const entry of diagram.entries) {
if (entry.type === "section") {
const lines = splitDiagramLines(entry.section.label)
bodyHeight += lines.length + 1
for (const line of lines) leftWidth = Math.max(leftWidth, diagramTextWidth(line))
continue
}
const periodLines = splitDiagramLines(entry.period.period)
const eventLines = entry.period.events.map(splitDiagramLines)
const eventHeight = eventLines.reduce((height, lines) => height + lines.length, 0)
const height = Math.max(periodLines.length, eventHeight)
periodLayouts.set(entry.period, { period: entry.period, periodLines, eventLines, height })
for (const line of periodLines) leftWidth = Math.max(leftWidth, diagramTextWidth(line))
for (const lines of eventLines) {
for (const line of lines) rightWidth = Math.max(rightWidth, diagramTextWidth(line))
}
bodyHeight += height + 1
}
const titleLines = diagram.title ? splitDiagramLines(diagram.title) : []
const bodyWidth = diagram.entries.length === 0 ? 0 : leftWidth + SPINE_OFFSET + EVENT_OFFSET + rightWidth + 1
let titleWidth = 0
for (const line of titleLines) titleWidth = Math.max(titleWidth, diagramTextWidth(line))
const width = Math.max(bodyWidth, titleWidth)
const titleHeight = titleLines.length === 0 ? 0 : titleLines.length + (diagram.entries.length === 0 ? 0 : 1)
if (width === 0) return new DiagramCanvas(0, 0)
const grid: TimelineGrid = new DiagramCanvas(width, titleHeight + bodyHeight)
titleLines.forEach((line, index) =>
setText(grid, Math.floor((width - diagramTextWidth(line)) / 2), index, line, "title"),
)
if (diagram.entries.length === 0) return grid
const spineX = leftWidth + SPINE_OFFSET
let y = titleHeight
let railStarted = false
for (const entry of diagram.entries) {
if (entry.type === "section") {
const lines = splitDiagramLines(entry.section.label)
lines.forEach((line, index) => {
setText(grid, leftWidth - diagramTextWidth(line), y + index, line, "section")
if (index > 0) setCell(grid, spineX, y + index, "│", "spine")
})
drawJoin(grid, leftWidth, y, TIMELINE_SECTION_FADE_STYLES)
setCell(grid, spineX, y, railStarted ? "┤" : "┐", "spine")
setCell(grid, spineX, y + lines.length, "│", "spine")
railStarted = true
y += lines.length + 1
continue
}
const layout = periodLayouts.get(entry.period)!
for (let row = 0; row < layout.height + 1; row++) setCell(grid, spineX, y + row, "│", "spine")
railStarted = true
setCell(grid, spineX, y, "●", "spine")
layout.periodLines.forEach((line, index) => {
const lineWidth = diagramTextWidth(line)
setText(grid, leftWidth - lineWidth, y + index, line, "period")
})
drawJoin(grid, leftWidth, y, TIMELINE_PERIOD_FADE_STYLES)
let eventY = y
for (const lines of layout.eventLines) {
lines.forEach((line, index) => setText(grid, spineX + EVENT_OFFSET, eventY + index, line, "event"))
eventY += lines.length
}
y += layout.height + 1
}
return grid
}
function drawJoin(grid: TimelineGrid, x: number, y: number, styles: readonly TimelineCellStyle[]): void {
styles.forEach((style, index) => setCell(grid, x + index + 1, y, "─", style))
}
function setCell(grid: TimelineGrid, x: number, y: number, char: string, style: TimelineCellStyle): void {
grid.setCell(x, y, char, style)
}
function setText(grid: TimelineGrid, x: number, y: number, text: string, style: TimelineCellStyle): void {
grid.setText(x, y, text, style)
}
+119
View File
@@ -0,0 +1,119 @@
import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js"
import { MermaidSyntaxError } from "../diagnostics.js"
import type { TimelineDiagram, TimelineDirection, TimelineEntry, TimelinePeriod, TimelineSection } from "./types.js"
const HEADER_RE = /^timeline(?:\s+(TD|LR))?$/i
const TITLE_RE = /^title(?:\s+(.+))?$/i
const SECTION_RE = /^section(?:\s+(.+))?$/i
const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i
export function isMermaidTimelineDiagram(content: string): boolean {
return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
}
export function parseMermaidTimelineDiagram(content: string): TimelineDiagram {
const sections: TimelineSection[] = []
const periods: TimelinePeriod[] = []
const entries: TimelineEntry[] = []
let direction: TimelineDirection = "LR"
let title: string | undefined
let currentPeriod: TimelinePeriod | undefined
let inAccessibilityDescription = false
for (const source of meaningfulNumberedMermaidLines(content)) {
const line = stripTimelineComment(source.text)
if (inAccessibilityDescription) {
if (line === "}") inAccessibilityDescription = false
continue
}
if (/^accDescr\s*\{$/i.test(line)) {
inAccessibilityDescription = true
continue
}
if (!line || line.startsWith("#") || ACCESSIBILITY_RE.test(line)) continue
const header = line.match(HEADER_RE)
if (header) {
direction = (header[1]?.toUpperCase() as TimelineDirection | undefined) ?? "LR"
continue
}
const titleMatch = line.match(TITLE_RE)
if (titleMatch) {
if (!titleMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline title cannot be empty")
title = stripMermaidQuotes(titleMatch[1])
continue
}
const sectionMatch = line.match(SECTION_RE)
if (sectionMatch) {
if (!sectionMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline section cannot be empty")
const section = { label: stripMermaidQuotes(sectionMatch[1]) }
sections.push(section)
entries.push({ type: "section", section })
currentPeriod = undefined
continue
}
if (line.startsWith(":")) {
if (!currentPeriod) {
throw syntaxError(source.lineNumber, line, "Timeline continuation requires a preceding period")
}
currentPeriod.events.push(...parseEvents(line.slice(1), source.lineNumber, line))
continue
}
const fields = splitEventFields(line)
const periodLabel = stripMermaidQuotes(fields.shift()!)
if (!periodLabel) throw syntaxError(source.lineNumber, line, "Timeline period cannot be empty")
const period = {
period: periodLabel,
events: fields.length === 0 ? [] : parseEventFields(fields, source.lineNumber, line),
}
periods.push(period)
entries.push({ type: "period", period })
currentPeriod = period
}
return { direction, ...(title === undefined ? {} : { title }), sections, periods, entries }
}
function parseEvents(value: string, lineNumber: number, sourceLine: string): string[] {
return parseEventFields(splitEventFields(value), lineNumber, sourceLine)
}
function parseEventFields(fields: string[], lineNumber: number, sourceLine: string): string[] {
const events = fields.map(stripMermaidQuotes)
if (events.length === 0 || events.some((event) => event.length === 0)) {
throw syntaxError(lineNumber, sourceLine, "Timeline event cannot be empty")
}
return events
}
function splitEventFields(value: string): string[] {
const fields: string[] = []
let quote: '"' | "'" | undefined
let start = 0
for (let index = 0; index < value.length; index++) {
const char = value[index]
if (char === '"' || char === "'") {
if (quote === char) quote = undefined
else if (quote === undefined && value.slice(start, index).trim() === "") quote = char
continue
}
const next = value[index + 1]
if (char !== ":" || quote !== undefined || (next !== undefined && !/\s/.test(next))) continue
fields.push(value.slice(start, index))
start = index + 1
}
fields.push(value.slice(start))
return fields
}
function stripTimelineComment(value: string): string {
const comment = value.indexOf("%%")
return (comment < 0 ? value : value.slice(0, comment)).trim()
}
function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError {
return new MermaidSyntaxError("timeline", lineNumber, sourceLine, reason)
}
@@ -0,0 +1,17 @@
import type { StyledText } from "@opentui/core"
import type { DiagramCanvas } from "../core/canvas.js"
import { renderDiagramGridStyledText } from "../core/render-grid.js"
import type { TimelineStyleColors } from "./style.js"
import type { TimelineCellStyle } from "./types.js"
export type TimelineGrid = DiagramCanvas<TimelineCellStyle>
export function renderTimelineGridText(grid: TimelineGrid): string {
return grid.toString({ trimBottom: true })
}
export function renderTimelineGridStyledText(grid: TimelineGrid, colors: TimelineStyleColors): StyledText {
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
trimBottom: true,
})
}
+36
View File
@@ -0,0 +1,36 @@
import { RGBA } from "@opentui/core"
import { blendColor, numberedStyleKeys, rgba, type DiagramRgb } from "../core/color/style.js"
import type { TimelineBaseCellStyle, TimelineCellStyle } from "./types.js"
const DEFAULT_THEME_RGB = {
title: [228, 239, 232],
section: [154, 184, 169],
period: [230, 177, 126],
spine: [111, 138, 126],
event: [134, 225, 200],
} as const satisfies Record<TimelineBaseCellStyle, DiagramRgb>
export type TimelineStyleColors = Required<Record<TimelineCellStyle, RGBA>>
export const TIMELINE_SECTION_FADE_STYLES = numberedStyleKeys("sectionFade", [1, 2, 3] as const)
export const TIMELINE_PERIOD_FADE_STYLES = numberedStyleKeys("periodFade", [1, 2, 3] as const)
export function resolveTimelineStyleColors(
colors: Partial<Record<TimelineBaseCellStyle, RGBA | undefined>> = {},
): TimelineStyleColors {
const section = colors.section ?? rgba(DEFAULT_THEME_RGB.section)
const period = colors.period ?? rgba(DEFAULT_THEME_RGB.period)
const spine = colors.spine ?? rgba(DEFAULT_THEME_RGB.spine)
return {
title: colors.title ?? rgba(DEFAULT_THEME_RGB.title),
section,
period,
spine,
event: colors.event ?? rgba(DEFAULT_THEME_RGB.event),
sectionFade1: blendColor(section, spine, 0.5),
sectionFade2: blendColor(section, spine, 0.67),
sectionFade3: blendColor(section, spine, 0.83),
periodFade1: blendColor(period, spine, 0.5),
periodFade2: blendColor(period, spine, 0.67),
periodFade3: blendColor(period, spine, 0.83),
}
}
+31
View File
@@ -0,0 +1,31 @@
export type TimelineDirection = "TD" | "LR"
export interface TimelineSection {
label: string
}
export interface TimelinePeriod {
period: string
events: string[]
}
export type TimelineEntry = { type: "section"; section: TimelineSection } | { type: "period"; period: TimelinePeriod }
export interface TimelineDiagram {
direction: TimelineDirection
title?: string
sections: TimelineSection[]
periods: TimelinePeriod[]
entries: TimelineEntry[]
}
export interface TimelineDiagramRenderOptions {
/** Parsed for Mermaid compatibility. Timeline diagrams always use a vertical terminal layout. */
direction?: TimelineDirection
}
export type TimelineBaseCellStyle = "title" | "section" | "period" | "spine" | "event"
export type TimelineFadeStep = 1 | 2 | 3
export type TimelineSectionFadeStyle = `sectionFade${TimelineFadeStep}`
export type TimelinePeriodFadeStyle = `periodFade${TimelineFadeStep}`
export type TimelineCellStyle = TimelineBaseCellStyle | TimelineSectionFadeStyle | TimelinePeriodFadeStyle
+1
View File
@@ -7,6 +7,7 @@ export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Config } from "@opencode-ai/schema/config"
export { Credential } from "@opencode-ai/schema/credential"
export { Event } from "@opencode-ai/schema/event"
export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location"
@@ -4,6 +4,7 @@ import { SessionInbox as CoreSessionInbox } from "@opencode-ai/core/session/inbo
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
import { Agent } from "@opencode-ai/schema/agent"
import { Config } from "@opencode-ai/schema/config"
import { Event } from "@opencode-ai/schema/event"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
@@ -26,6 +27,7 @@ const CoreSession = await import("@opencode-ai/core/session")
test("re-exports canonical contracts directly from Schema", () => {
expect(SDK.Agent).toBe(Agent)
expect(SDK.Config).toBe(Config)
expect(SDK.Event).toBe(Event)
expect(SDK.Model).toBe(Model)
expect(SDK.WebSearch).toBe(WebSearch)
expect(SDK.Session).toBe(Session)
@@ -37,6 +39,7 @@ test("re-exports canonical contracts directly from Schema", () => {
"Command",
"Config",
"Credential",
"Event",
"FileSystem",
"Integration",
"Location",
+1 -1
View File
@@ -2089,7 +2089,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
const theme = useTheme("elevated")
const next = createMemo(() => props.prompts[0]?.text)
const next = createMemo(() => props.prompts[0]?.text.replaceAll("\n", " "))
return (
<box