refactor(tui): extract standalone package (#31193)

This commit is contained in:
Dax
2026-06-07 05:24:27 +00:00
committed by GitHub
parent 7a2c49e762
commit 106f8e94d6
84 changed files with 1148 additions and 1919 deletions
+3 -2
View File
@@ -536,7 +536,6 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*", "@opencode-ai/tui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0", "@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/context-async-hooks": "2.6.1",
@@ -558,7 +557,6 @@
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0", "bonjour-service": "1.3.0",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
"diff": "catalog:", "diff": "catalog:",
@@ -796,12 +794,15 @@
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:", "@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@solid-primitives/scheduled": "1.5.2", "@solid-primitives/scheduled": "1.5.2",
"clipboardy": "4.0.0",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
@@ -8,6 +8,6 @@ export default Runtime.handler(Commands, () =>
const daemon = yield* Daemon.Service const daemon = yield* Daemon.Service
const transport = yield* daemon.transport() const transport = yield* daemon.transport()
const { runTui } = yield* Effect.promise(() => import("../../tui")) const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* Effect.promise(() => runTui(transport)) yield* runTui(transport)
}), }),
) )
+6 -88
View File
@@ -1,102 +1,20 @@
import { createTuiBuildInfo, createTuiEnvironment, createTuiRenderer, run, type TuiHost } from "@opencode-ai/tui" import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config" import { TuiConfig } from "@opencode-ai/tui/config"
import type { TuiPlatform } from "@opencode-ai/tui/platform" import { Effect } from "effect"
import os from "node:os" import { Global } from "@opencode-ai/core/global"
import path from "node:path"
declare const OPENCODE_VERSION: string | undefined export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
declare const OPENCODE_CHANNEL: string | undefined
export async function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false }) const config = TuiConfig.resolve({}, { terminalSuspend: false })
const state = path.join(os.homedir(), ".local", "state", "opencode") return run({
const environment = createTuiEnvironment({
cwd: process.cwd(),
platform: process.platform,
paths: {
home: os.homedir(),
state,
worktree: path.join(state, "worktree"),
},
capabilities: {
mouse: config.mouse,
copyOnSelect: true,
terminalTitle: true,
terminalSuspend: false,
workspaces: false,
showTimeToFirstDraw: false,
},
terminal: {
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
},
editor: { zedTerminal: false },
skipInitialLoading: false,
})
const build = createTuiBuildInfo({
version: typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local",
channel: typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local",
})
const renderer = await createTuiRenderer(config, { environment, build })
const handle = run({
...transport, ...transport,
args: {}, args: {},
config, config,
environment,
build,
renderer,
fetch: gracefulFetch, fetch: gracefulFetch,
pluginHost: { pluginHost: {
async start() {}, async start() {},
async dispose() {}, async dispose() {},
}, },
host: createHost(), }).pipe(Effect.provide(Global.defaultLayer))
})
await handle.done
}
function createHost(): TuiHost {
return {
platform,
attention() {
return {
async notify() {
return { ok: false, notification: false, sound: false, skipped: "attention_disabled" }
},
soundboard: {
registerPack: () => () => {},
activate: () => false,
current: () => "",
list: () => [],
},
dispose() {},
}
},
logger: { error: (message, extra) => console.error(message, extra ?? "") },
lifecycle: {
onSighup(handler) {
process.on("SIGHUP", handler)
return () => process.off("SIGHUP", handler)
},
writeStdout: (text) => process.stdout.write(text),
writeStderr: (text) => process.stderr.write(text),
},
formatError: () => undefined,
formatUnknownError(error) {
if (error instanceof Error) return error.message
return String(error)
},
}
}
const platform: TuiPlatform = {
files: {
readText: (file) => Bun.file(file).text(),
readBytes: async (file) => new Uint8Array(await Bun.file(file).arrayBuffer()),
async mime(file) {
return Bun.file(file).type || "application/octet-stream"
},
},
} }
const legacyDefaults: Record<string, unknown> = { const legacyDefaults: Record<string, unknown> = {
-2
View File
@@ -90,7 +90,6 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*", "@opencode-ai/tui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0", "@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/context-async-hooks": "2.6.1",
@@ -112,7 +111,6 @@
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0", "bonjour-service": "1.3.0",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
"diff": "catalog:", "diff": "catalog:",
+35 -48
View File
@@ -1,10 +1,8 @@
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { UI } from "@/cli/ui" import { UI } from "@/cli/ui"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
import { errorMessage } from "@opencode-ai/tui/util/error" import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session" import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth" import { ServerAuth } from "@/server/auth"
import { resolveTuiRuntime } from "../tui/runtime"
export const AttachCommand = cmd({ export const AttachCommand = cmd({
command: "attach <url>", command: "attach <url>",
@@ -46,54 +44,46 @@ export const AttachCommand = cmd({
}), }),
handler: async (args) => { handler: async (args) => {
const { TuiConfig } = await import("@/config/tui") const { TuiConfig } = await import("@/config/tui")
const unguard = win32InstallCtrlCGuard() if (args.fork && !args.continue && !args.session) {
try { UI.error("--fork requires --continue or --session")
win32DisableProcessedInput() process.exitCode = 1
return
if (args.fork && !args.continue && !args.session) { }
UI.error("--fork requires --continue or --session")
process.exitCode = 1
return
}
const directory = (() => {
if (!args.dir) return undefined
try {
process.chdir(args.dir)
return process.cwd()
} catch {
// If the directory doesn't exist locally (remote attach), pass it through.
return args.dir
}
})()
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get()
const runtime = resolveTuiRuntime(config)
const directory = (() => {
if (!args.dir) return undefined
try { try {
await validateSession({ process.chdir(args.dir)
url: args.url, return process.cwd()
sessionID: args.session, } catch {
directory, // If the directory doesn't exist locally (remote attach), pass it through.
headers, return args.dir
})
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
return
} }
})()
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get()
const { createTuiRenderer, tui } = await import("@opencode-ai/tui") try {
const { createLegacyTuiHost } = await import("../tui/host") await validateSession({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") url: args.url,
const renderer = await createTuiRenderer(config, runtime) sessionID: args.session,
const handle = tui({ directory,
...runtime, headers,
})
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
return
}
const { Effect } = await import("effect")
const { run } = await import("../tui/layer")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
url: args.url, url: args.url,
config, config,
host: createLegacyTuiHost(renderer),
pluginHost: createLegacyTuiPluginHost(), pluginHost: createLegacyTuiPluginHost(),
renderer,
args: { args: {
continue: args.continue, continue: args.continue,
sessionID: args.session, sessionID: args.session,
@@ -101,10 +91,7 @@ export const AttachCommand = cmd({
}, },
directory, directory,
headers, headers,
}) }),
await handle.done )
} finally {
unguard?.()
}
}, },
}) })
+33 -40
View File
@@ -11,7 +11,6 @@ import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk" import type { EventSource } from "@opencode-ai/tui/context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import { import {
OPENCODE_PROCESS_ROLE, OPENCODE_PROCESS_ROLE,
@@ -20,7 +19,7 @@ import {
sanitizedProcessEnv, sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process" } from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "../tui/validate-session" import { validateSession } from "../tui/validate-session"
import { resolveTuiRuntime } from "../tui/runtime" import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
@@ -113,15 +112,9 @@ export const TuiThreadCommand = cmd({
describe: "agent to use", describe: "agent to use",
}), }),
handler: async (args) => { handler: async (args) => {
const { TuiConfig } = await import("@/config/tui")
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
// (Important when running under `bun run` wrappers on Windows.)
const unguard = win32InstallCtrlCGuard() const unguard = win32InstallCtrlCGuard()
try { try {
// Must be the very first thing — disables CTRL_C_EVENT before any Worker const { TuiConfig } = await import("@/config/tui")
// spawn or async work so the OS cannot kill the process group.
win32DisableProcessedInput()
if (args.fork && !args.continue && !args.session) { if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session") UI.error("--fork requires --continue or --session")
process.exitCode = 1 process.exitCode = 1
@@ -189,7 +182,6 @@ export const TuiThreadCommand = cmd({
const prompt = await input(args.prompt) const prompt = await input(args.prompt)
const config = await TuiConfig.get() const config = await TuiConfig.get()
const runtime = resolveTuiRuntime(config)
const network = resolveNetworkOptionsNoConfig(args) const network = resolveNetworkOptionsNoConfig(args)
const external = const external =
@@ -230,42 +222,43 @@ export const TuiThreadCommand = cmd({
}, 1000).unref?.() }, 1000).unref?.()
try { try {
const { createTuiRenderer, tui } = await import("@opencode-ai/tui") const { Effect } = await import("effect")
const { createLegacyTuiHost } = await import("../tui/host") const { run } = await import("../tui/layer")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
const renderer = await createTuiRenderer(config, runtime) await Effect.runPromise(
const handle = tui({ run({
...runtime, url: transport.url,
url: transport.url, async onSnapshot() {
renderer, const tui = writeHeapSnapshot("tui.heapsnapshot")
async onSnapshot() { const server = await client.call("snapshot", undefined)
const tui = writeHeapSnapshot("tui.heapsnapshot") return [tui, server]
const server = await client.call("snapshot", undefined) },
return [tui, server] config,
}, pluginHost: createLegacyTuiPluginHost(),
config, directory: cwd,
host: createLegacyTuiHost(renderer), fetch: transport.fetch,
pluginHost: createLegacyTuiPluginHost(), events: transport.events,
directory: cwd, args: {
fetch: transport.fetch, continue: args.continue,
events: transport.events, sessionID: args.session,
args: { agent: args.agent,
continue: args.continue, model: args.model,
sessionID: args.session, prompt,
agent: args.agent, fork: args.fork,
model: args.model, },
prompt, }),
fork: args.fork, )
},
})
await handle.done
} finally { } finally {
await stop() await stop()
} }
process.exit(0)
} finally { } finally {
unguard?.() try {
unguard?.()
} catch (error) {
Log.Default.warn("failed to restore terminal guard", { error: errorMessage(error) })
}
} }
process.exit(0)
}, },
}) })
// scratch // scratch
-181
View File
@@ -1,181 +0,0 @@
import { platform, release } from "os"
import { lazy } from "../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import fs from "fs/promises"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import * as Filesystem from "../../util/filesystem"
import * as Process from "../../util/process"
const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
Effect.runPromise(
AppProcess.Service.use((svc) => svc.run(ChildProcess.make(cmd[0]!, cmd.slice(1)), { stdin: text })).pipe(
Effect.provide(AppProcess.defaultLayer),
Effect.catch(() => Effect.void),
Effect.asVoid,
),
).catch(() => undefined)
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
const getWhich = lazy(async () => {
const { which } = await import("@opencode-ai/core/util/which")
return which
})
const getClipboardy = lazy(async () => {
const { default: clipboardy } = await import("clipboardy")
return clipboardy
})
/**
* Writes text to clipboard via OSC 52 escape sequence.
* This allows clipboard operations to work over SSH by having
* the terminal emulator handle the clipboard locally.
*/
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
export interface Content {
data: string
mime: string
}
// Checks clipboard for images first, then falls back to text.
//
// On Windows prompt/ can call this from multiple paste signals because
// terminals surface image paste differently:
// 1. A forwarded Ctrl+V keypress
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
// Terminal <1.25
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
export async function read(): Promise<Content | undefined> {
const os = platform()
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await Process.run(
[
"osascript",
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
],
{ nothrow: true },
)
const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" }
} catch {
} finally {
await fs.rm(tmpfile, { force: true }).catch(() => {})
}
}
// Windows/WSL: probe clipboard for images via PowerShell.
// Bracketed paste can't carry image data so we read it directly.
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
if (os === "linux") {
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
}
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
}
}
const clipboardy = await getClipboardy()
const text = await clipboardy.read().catch(() => {})
if (text) {
return { data: text, mime: "text/plain" }
}
}
const getCopyMethod = lazy(async () => {
const os = platform()
const which = await getWhich()
if (os === "darwin" && which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return (text: string) => writeWithStdin(["wl-copy"], text)
}
if (which("xclip")) {
console.log("clipboard: using xclip")
return (text: string) => writeWithStdin(["xclip", "-selection", "clipboard"], text)
}
if (which("xsel")) {
console.log("clipboard: using xsel")
return (text: string) => writeWithStdin(["xsel", "--clipboard", "--input"], text)
}
}
if (os === "win32") {
console.log("clipboard: using powershell")
return (text: string) =>
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
writeWithStdin(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
text,
)
}
console.log("clipboard: no native support")
return async (text: string) => {
const clipboardy = await getClipboardy()
await clipboardy.write(text).catch(() => {})
}
})
export async function copy(text: string): Promise<void> {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
}
export * as Clipboard from "./clipboard"
-43
View File
@@ -1,43 +0,0 @@
import { defer } from "@/util/defer"
import { existsSync } from "node:fs"
import { rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
export async function open(opts: { value: string; renderer: CliRenderer; cwd?: string }): Promise<string | undefined> {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
if (!editor) return
const filepath = join(tmpdir(), `${Date.now()}.md`)
await using _ = defer(async () => rm(filepath, { force: true }))
// In attach mode the server's project directory may not exist locally.
// Fall back to the local process cwd so the editor can still spawn.
const cwd = opts.cwd && existsSync(opts.cwd) ? opts.cwd : process.cwd()
await Filesystem.write(filepath, opts.value)
opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear()
try {
const parts = editor.split(" ")
const proc = Process.spawn([...parts, filepath], {
cwd,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
shell: process.platform === "win32",
})
await proc.exited
const content = await Filesystem.readText(filepath)
return content || undefined
} finally {
opts.renderer.currentRenderBuffer.clear()
opts.renderer.resume()
opts.renderer.requestRender()
}
}
export * as Editor from "./editor"
-36
View File
@@ -1,36 +0,0 @@
import type { TuiHost, TuiInput } from "@opencode-ai/tui"
import { Log } from "@opencode-ai/core/util/log"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { createTuiAttention } from "./attention"
import { createLegacyTuiPlatform } from "./platform"
import * as TuiAudio from "./audio"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
export function createLegacyTuiHost(renderer: TuiInput["renderer"]): TuiHost {
return {
platform: createLegacyTuiPlatform(renderer),
attention: createTuiAttention,
logger: Log.Default,
disposeAudio: TuiAudio.dispose,
formatError: FormatError,
formatUnknownError: FormatUnknownError,
lifecycle: {
prepare() {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
return unguard
},
flushInput: win32FlushInputBuffer,
onSighup(handler) {
process.on("SIGHUP", handler)
return () => process.off("SIGHUP", handler)
},
writeStdout: (text) => process.stdout.write(text),
writeStderr: (text) => process.stderr.write(text),
suspend(resume) {
process.once("SIGCONT", resume)
process.kill(0, "SIGTSTP")
},
},
}
}
+7
View File
@@ -0,0 +1,7 @@
import { run as runTui, type TuiInput } from "@opencode-ai/tui"
import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect"
export function run(input: TuiInput) {
return runTui(input).pipe(Effect.provide(Global.defaultLayer))
}
-124
View File
@@ -1,124 +0,0 @@
import type { CliRenderer } from "@opentui/core"
import type { TuiPlatform } from "@opencode-ai/tui/platform"
import { Filesystem } from "@/util/filesystem"
import { Clipboard } from "./clipboard"
import { Editor } from "./editor"
import { Flock } from "@opencode-ai/core/util/flock"
import { Glob } from "@opencode-ai/core/util/glob"
import { Global } from "@opencode-ai/core/global"
import { readJson, writeJsonAtomic } from "@opencode-ai/tui/util/persistence"
import path from "path"
import os from "node:os"
import { readdirSync, readFileSync, statSync } from "node:fs"
import { resolveZedSelection } from "./editor-zed"
export function createLegacyTuiPlatform(renderer: CliRenderer): TuiPlatform {
const statePath = path.join(Global.Path.state, "kv.json")
const stateLock = `tui-kv:${statePath}`
return {
files: {
readText: Filesystem.readText,
readBytes: Filesystem.readBytes,
mime: Filesystem.mimeType,
},
state: {
read: () => Flock.withLock(stateLock, () => readJson<Record<string, unknown>>(statePath)),
write: (value) => Flock.withLock(stateLock, () => writeJsonAtomic(statePath, value)),
},
themes: {
async discover() {
const directories = [
Global.Path.config,
...(await Array.fromAsync(Filesystem.up({ targets: [".opencode"], start: process.cwd() }))),
]
const result: Record<string, unknown> = {}
for (const dir of directories) {
for (const item of await Glob.scan("themes/*.json", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
result[path.basename(item, ".json")] = await Filesystem.readJson(item)
}
}
return result
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)
return () => process.off("SIGUSR2", refresh)
},
},
clipboard: {
read: Clipboard.read,
write: Clipboard.copy,
},
editor: {
open: (input) => Editor.open({ ...input, renderer }),
connection: discoverEditorConnection,
selection: (directory) => resolveZedSelection(resolveZedDbPath(), directory),
},
export: {
write: Filesystem.write,
},
}
}
export function discoverEditorConnection(directory: string) {
const root = path.join(os.homedir(), ".claude", "ide")
const contains = (parent: string) => {
const resolved = path.resolve(parent)
const relative = path.relative(resolved, path.resolve(directory))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
}
try {
return readdirSync(root)
.filter((entry) => entry.endsWith(".lock"))
.flatMap((entry) => {
const file = path.join(root, entry)
const port = Number.parseInt(path.basename(file, ".lock"), 10)
if (!Number.isInteger(port) || port <= 0 || port > 65535) return []
try {
const value = JSON.parse(readFileSync(file, "utf-8")) as Record<string, unknown>
if (value.transport !== undefined && value.transport !== "ws") return []
const folders = Array.isArray(value.workspaceFolders)
? value.workspaceFolders.filter((item): item is string => typeof item === "string")
: []
const score = Math.max(0, ...folders.map(contains))
if (!score) return []
return [
{
url: `ws://127.0.0.1:${port}`,
authToken: typeof value.authToken === "string" ? value.authToken : undefined,
source: `lock:${port}`,
score,
mtime: statSync(file).mtimeMs,
},
]
} catch {
return []
}
})
.sort((left, right) => right.score - left.score || right.mtime - left.mtime)
.map(({ url, authToken, source }) => ({ url, authToken, source }))[0]
} catch {
return undefined
}
}
function resolveZedDbPath() {
const candidates = [
process.env.OPENCODE_ZED_DB,
path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
].filter((item): item is string => Boolean(item))
return (
candidates.find((item) => {
try {
return statSync(item).isFile()
} catch {
return false
}
}) ?? ""
)
}
-57
View File
@@ -1,57 +0,0 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import type { TuiConfig } from "@opencode-ai/tui/config"
import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime"
import path from "path"
import { isZedTerminal, resolveZedDbPath } from "./editor-zed"
export function resolveTuiRuntime(config: TuiConfig.Resolved) {
return {
environment: createTuiEnvironment({
cwd: process.cwd(),
platform: process.platform,
initialRoute: parseInitialRoute(process.env.OPENCODE_ROUTE),
paths: {
home: Global.Path.home,
state: Global.Path.state,
worktree: path.join(Global.Path.data, "worktree"),
},
capabilities: {
mouse: !Flag.OPENCODE_DISABLE_MOUSE && (config.mouse ?? true),
copyOnSelect: !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT,
terminalTitle: !Flag.OPENCODE_DISABLE_TERMINAL_TITLE,
terminalSuspend: process.platform !== "win32",
workspaces: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
showTimeToFirstDraw: Flag.OPENCODE_SHOW_TTFD,
},
terminal: {
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
},
editor: {
command: process.env.VISUAL || process.env.EDITOR,
port: parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT),
zedTerminal: isZedTerminal(),
zedDatabase: resolveZedDbPath(),
},
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}),
build: createTuiBuildInfo({
version: InstallationVersion,
channel: InstallationChannel,
}),
}
}
function parsePort(value: string | undefined) {
if (!value) return
const port = Number.parseInt(value, 10)
if (!Number.isInteger(port) || port <= 0 || port > 65535) return
return port
}
function parseInitialRoute(value: string | undefined) {
if (!value) return
return JSON.parse(value) as unknown
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { AudioPlayOptions, AudioSound } from "@opentui/core" import type { AudioPlayOptions, AudioSound } from "@opentui/core"
import { createTuiAttention } from "@/cli/tui/attention" import { createTuiAttention } from "@opencode-ai/tui/attention"
import type { TuiConfig } from "@opencode-ai/tui/config" import type { TuiConfig } from "@opencode-ai/tui/config"
type FocusEvent = "focus" | "blur" type FocusEvent = "focus" | "blur"
@@ -1,330 +0,0 @@
import { afterEach, expect, spyOn, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { tui, type TuiHandle } from "@opencode-ai/tui"
import { createLegacyTuiHost } from "../../../src/cli/tui/host"
import { Global } from "@opencode-ai/core/global"
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
import * as TuiAudio from "../../../src/cli/tui/audio"
import * as TuiKeymap from "@opencode-ai/tui/keymap"
import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime"
type TestRendererSetup = Awaited<ReturnType<typeof createTestRenderer>>
type TmpDir = Awaited<ReturnType<typeof tmpdir>>
const disabledInternalPlugins = {
"internal:home-footer": false,
"internal:home-tips": false,
"internal:sidebar-context": false,
"internal:sidebar-mcp": false,
"internal:sidebar-lsp": false,
"internal:sidebar-todo": false,
"internal:sidebar-files": false,
"internal:sidebar-footer": false,
"internal:plugin-manager": false,
"internal:session-v2-debug": false,
"which-key": false,
}
let active: { handle?: TuiHandle; setup?: TestRendererSetup; restore?: () => void; tmp?: TmpDir } | undefined
afterEach(async () => {
const current = active
active = undefined
await current?.handle?.exit().catch(() => {})
await current?.handle?.done.catch(() => {})
await current?.handle?.ready.catch(() => {})
if (current?.setup && !current.setup.renderer.isDestroyed) current.setup.renderer.destroy()
current?.restore?.()
await Bun.sleep(20)
await current?.tmp?.[Symbol.asyncDispose]()
})
test("returns a handle immediately and resolves ready after async mount setup", async () => {
const app = await startTui()
expect(await promiseState(app.handle.ready)).toBe("pending")
app.theme.resolve("dark")
await app.handle.ready
expect(app.setup.renderer.isDestroyed).toBe(false)
expect(await promiseState(app.handle.done)).toBe("pending")
})
test("production can await done only and still receives mount failures", async () => {
const app = await startTui({ rejectTheme: new Error("theme failed") })
await expect(app.handle.done).rejects.toThrow("theme failed")
expect(app.setup.renderer.isDestroyed).toBe(true)
})
test("plugin startup failure does not fail the app", async () => {
const error = spyOn(console, "error").mockImplementation(() => {})
try {
const app = await startTui({ rejectPlugins: new Error("plugins failed") })
app.theme.resolve("dark")
await expect(app.handle.ready).resolves.toBeUndefined()
await app.pluginHost.started
expect(app.setup.renderer.isDestroyed).toBe(false)
expect(app.pluginHost.starts).toBe(1)
await app.handle.exit()
await app.handle.done
} finally {
error.mockRestore()
}
})
test("exit destroys the renderer, resolves done, and runs cleanup once", async () => {
const beforeSighup = process.listenerCount("SIGHUP")
const app = await startTui()
app.theme.resolve("dark")
await app.handle.ready
expect(process.listenerCount("SIGHUP")).toBeGreaterThan(beforeSighup)
await Promise.all([app.handle.exit(), app.handle.exit()])
await app.handle.done
expect(app.setup.renderer.isDestroyed).toBe(true)
expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
})
test("exit preserves reason formatting and exit messages", async () => {
const stdout: string[] = []
const stderr: string[] = []
const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
stdout.push(String(chunk))
return true
})
const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
stderr.push(String(chunk))
return true
})
try {
const app = await startTui()
app.theme.resolve("dark")
await app.handle.ready
app.handle.exit.message.set("goodbye")
await app.handle.exit(new Error("boom"))
await app.handle.done
expect(stderr.join("")).toContain("boom")
expect(stdout.join("")).toBe("goodbye\n")
} finally {
stdoutWrite.mockRestore()
stderrWrite.mockRestore()
}
})
test("exit before ready cancels mount and resolves done", async () => {
const app = await startTui()
await app.handle.exit()
await app.handle.done
expect(app.setup.renderer.isDestroyed).toBe(true)
await expect(app.handle.ready).resolves.toBeUndefined()
})
test("direct renderer destruction still cleans up and resolves done", async () => {
const beforeSighup = process.listenerCount("SIGHUP")
const app = await startTui()
app.theme.resolve("dark")
await app.handle.ready
app.setup.renderer.destroy()
await app.handle.done
expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
})
test("SIGHUP exits before ready and removes its listener", async () => {
const beforeSighup = process.listenerCount("SIGHUP")
const app = await startTui()
process.emit("SIGHUP")
await app.handle.done
expect(app.setup.renderer.isDestroyed).toBe(true)
expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
})
test("SIGHUP exits after ready and removes its listener", async () => {
const beforeSighup = process.listenerCount("SIGHUP")
const app = await startTui()
app.theme.resolve("dark")
await app.handle.ready
process.emit("SIGHUP")
await app.handle.done
expect(app.setup.renderer.isDestroyed).toBe(true)
expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
})
test("plugin, audio, and keymap cleanup run exactly once", async () => {
const originalRegister = TuiKeymap.registerOpencodeKeymap
let unregisterKeymapCalls = 0
const registerKeymap = spyOn(TuiKeymap, "registerOpencodeKeymap").mockImplementation((...args) => {
const unregister = originalRegister(...args)
return () => {
unregisterKeymapCalls++
unregister()
}
})
const disposeAudio = spyOn(TuiAudio, "dispose")
try {
const app = await startTui()
app.theme.resolve("dark")
await app.handle.ready
app.setup.renderer.destroy()
await Promise.all([app.handle.exit(), app.handle.exit()])
await app.handle.done
expect(registerKeymap).toHaveBeenCalledTimes(1)
expect(unregisterKeymapCalls).toBe(1)
expect(app.pluginHost.disposes).toBe(1)
expect(disposeAudio).toHaveBeenCalledTimes(1)
} finally {
registerKeymap.mockRestore()
disposeAudio.mockRestore()
}
})
test("plugin disposal failure does not stop remaining cleanup", async () => {
const error = spyOn(console, "error").mockImplementation(() => {})
const disposeAudio = spyOn(TuiAudio, "dispose")
try {
const app = await startTui({ rejectPluginDispose: new Error("dispose failed") })
app.theme.resolve("dark")
await app.handle.ready
await app.handle.exit()
await app.handle.done
expect(app.pluginHost.disposes).toBe(1)
expect(disposeAudio).toHaveBeenCalledTimes(1)
expect(app.setup.renderer.isDestroyed).toBe(true)
} finally {
error.mockRestore()
disposeAudio.mockRestore()
}
})
async function startTui(options: { rejectTheme?: Error; rejectPlugins?: Error; rejectPluginDispose?: Error } = {}) {
const tmp = await tmpdir()
const isolated = await isolateGlobalPaths(tmp.path)
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, maxFps: Number.POSITIVE_INFINITY })
const theme = deferred<"dark" | "light" | null>()
const waitForThemeMode = spyOn(setup.renderer, "waitForThemeMode").mockImplementation(() => {
if (options.rejectTheme) return Promise.reject(options.rejectTheme)
return theme.promise
})
setup.renderer.once("destroy", () => theme.resolve(null))
const calls = createFetch()
const events = createEventSource()
const pluginStarted = deferred<void>()
const pluginHost = {
starts: 0,
disposes: 0,
started: pluginStarted.promise,
async start() {
pluginHost.starts++
pluginStarted.resolve()
if (options.rejectPlugins) throw options.rejectPlugins
},
async dispose() {
pluginHost.disposes++
if (options.rejectPluginDispose) throw options.rejectPluginDispose
},
}
const environment = createTuiEnvironment({
cwd: tmp.path,
platform: "linux",
paths: { home: tmp.path, state: isolated.state, worktree: path.join(tmp.path, "worktree") },
capabilities: {
mouse: true,
copyOnSelect: true,
terminalTitle: false,
terminalSuspend: false,
workspaces: false,
showTimeToFirstDraw: false,
},
terminal: {},
editor: { zedTerminal: false },
skipInitialLoading: false,
})
const handle = tui({
environment,
build: createTuiBuildInfo({ version: "test", channel: "test" }),
url: "http://test",
renderer: setup.renderer,
host: createLegacyTuiHost(setup.renderer),
config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }),
directory,
fetch: calls.fetch,
events: events.source,
pluginHost,
args: {},
})
active = {
handle,
setup,
tmp,
restore: () => {
waitForThemeMode.mockRestore()
isolated.restore()
},
}
return { handle, setup, theme, pluginHost }
}
async function isolateGlobalPaths(root: string) {
const previous = Global.Path.config
Global.Path.config = path.join(root, "config")
const state = path.join(root, "state")
await mkdir(Global.Path.config, { recursive: true })
await mkdir(state, { recursive: true })
await Bun.write(path.join(state, "kv.json"), JSON.stringify({ animations_enabled: false }))
return {
state,
restore() {
Global.Path.config = previous
},
}
}
async function promiseState(promise: Promise<unknown>) {
let state: "pending" | "resolved" | "rejected" = "pending"
promise.then(
() => {
state = "resolved"
},
() => {
state = "rejected"
},
)
await Promise.resolve()
return state
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
@@ -1,11 +1,10 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
describe("tui attach", () => { describe("tui attach", () => {
test("loads the public TUI API and legacy hosts lazily", async () => { test("loads the TUI integration lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text() const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text()
expect(source).toMatch(/await import\(["']@opencode-ai\/tui["']\)/) expect(source).toContain('await import("../tui/layer")')
expect(source).toContain('await import("../tui/host")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/) expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")') expect(source).not.toContain('import("./app")')
}) })
@@ -3,7 +3,12 @@ import { mkdir, symlink } from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { afterEach, expect, spyOn, test } from "bun:test" import { afterEach, expect, spyOn, test } from "bun:test"
import { isZedTerminal, offsetToPosition, resolveZedDbPath, resolveZedSelection } from "../../../src/cli/tui/editor-zed" import {
isZedTerminal,
offsetToPosition,
resolveZedDbPath,
resolveZedSelection,
} from "@opencode-ai/tui/editor-zed"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
const originalZedTerm = process.env.ZED_TERM const originalZedTerm = process.env.ZED_TERM
@@ -3,12 +3,11 @@ import os from "node:os"
import path from "node:path" import path from "node:path"
import { afterEach, expect, spyOn, test } from "bun:test" import { afterEach, expect, spyOn, test } from "bun:test"
import { createRoot } from "solid-js" import { createRoot } from "solid-js"
import { EditorContextProvider, useEditorContext } from "@opencode-ai/tui/context/editor" import { EditorContextProvider, useEditorContext, type EditorIntegration } from "@opencode-ai/tui/context/editor"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { FakeWebSocket } from "../../lib/websocket" import { FakeWebSocket } from "../../lib/websocket"
import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
import { TuiPlatformProvider, type TuiPlatform } from "@opencode-ai/tui/platform" import { discoverEditorConnection } from "@opencode-ai/tui/editor"
import { discoverEditorConnection } from "../../../src/cli/tui/platform"
const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT
const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT
@@ -36,17 +35,14 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
return ( return (
<TestTuiEnvironmentProvider <TestTuiContexts
cwd={process.cwd()} cwd={process.cwd()}
paths={{ home: os.homedir() }} paths={{ home: os.homedir() }}
editor={{ port: value ? Number.parseInt(value, 10) : undefined }}
> >
<TuiPlatformProvider value={platform}> <EditorContextProvider integration={editorService} WebSocketImpl={WebSocketImpl}>
<EditorContextProvider WebSocketImpl={WebSocketImpl}>
<Consumer /> <Consumer />
</EditorContextProvider> </EditorContextProvider>
</TuiPlatformProvider> </TestTuiContexts>
</TestTuiEnvironmentProvider>
) )
}) })
@@ -56,16 +52,8 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
} }
} }
const platform: TuiPlatform = { const editorService: EditorIntegration = {
files: { connection: discoverEditorConnection,
readText: (file) => Bun.file(file).text(),
readBytes: (file) => Bun.file(file).bytes(),
mime: () => Promise.resolve("application/octet-stream"),
},
editor: {
open: () => Promise.resolve(undefined),
connection: discoverEditorConnection,
},
} }
function createWebSocketImpl(...sockets: FakeWebSocket[]) { function createWebSocketImpl(...sockets: FakeWebSocket[]) {
@@ -5,11 +5,10 @@ import { tmpdir } from "../../fixture/fixture"
import { resolveThreadDirectory } from "../../../src/cli/cmd/tui" import { resolveThreadDirectory } from "../../../src/cli/cmd/tui"
describe("tui thread", () => { describe("tui thread", () => {
test("loads the public TUI API and legacy hosts lazily", async () => { test("loads the TUI integration lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text() const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
expect(source).toMatch(/await import\(["']@opencode-ai\/tui["']\)/) expect(source).toContain('await import("../tui/layer")')
expect(source).toContain('await import("../tui/host")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/) expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")') expect(source).not.toContain('import("./app")')
}) })
@@ -1,42 +1,32 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { createTuiEnvironment, TuiEnvironmentProvider, type TuiEnvironment } from "@opencode-ai/tui/runtime" import {
TuiPathsProvider,
TuiStartupProvider,
TuiTerminalEnvironmentProvider,
type TuiPaths,
} from "@opencode-ai/tui/context/runtime"
import type { ParentProps } from "solid-js" import type { ParentProps } from "solid-js"
export function TestTuiEnvironmentProvider( export function TestTuiContexts(
props: ParentProps<{ props: ParentProps<{
cwd?: string cwd?: string
directory?: string directory?: string
paths?: Partial<TuiEnvironment["paths"]> paths?: Partial<TuiPaths>
capabilities?: Partial<TuiEnvironment["capabilities"]>
editor?: Partial<TuiEnvironment["editor"]>
}>, }>,
) { ) {
return ( return (
<TuiEnvironmentProvider <TuiPathsProvider
value={createTuiEnvironment({ value={{
cwd: props.cwd ?? props.directory ?? "/tmp/opencode/packages/opencode", cwd: props.cwd ?? props.directory ?? "/tmp/opencode/packages/opencode",
platform: "linux", home: "/tmp/opencode/home",
paths: { state: "/tmp/opencode/state",
home: "/tmp/opencode/home", worktree: "/tmp/opencode",
state: "/tmp/opencode/state", ...props.paths,
worktree: "/tmp/opencode", }}
...props.paths,
},
capabilities: {
mouse: true,
copyOnSelect: true,
terminalTitle: false,
terminalSuspend: false,
workspaces: false,
showTimeToFirstDraw: false,
...props.capabilities,
},
terminal: {},
editor: { zedTerminal: false, ...props.editor },
skipInitialLoading: false,
})}
> >
{props.children} <TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
</TuiEnvironmentProvider> <TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
) )
} }
+10 -2
View File
@@ -14,19 +14,24 @@
"./builtins": "./src/feature-plugins/builtins.ts", "./builtins": "./src/feature-plugins/builtins.ts",
"./config": "./src/config/index.tsx", "./config": "./src/config/index.tsx",
"./context/args": "./src/context/args.tsx", "./context/args": "./src/context/args.tsx",
"./context/exit": "./src/context/exit.tsx", "./context/epilogue": "./src/context/epilogue.tsx",
"./context/kv": "./src/context/kv.tsx", "./context/kv": "./src/context/kv.tsx",
"./context/project": "./src/context/project.tsx", "./context/project": "./src/context/project.tsx",
"./context/runtime": "./src/context/runtime.tsx",
"./context/sdk": "./src/context/sdk.tsx", "./context/sdk": "./src/context/sdk.tsx",
"./context/sync": "./src/context/sync.tsx", "./context/sync": "./src/context/sync.tsx",
"./context/theme": "./src/context/theme.tsx", "./context/theme": "./src/context/theme.tsx",
"./context/editor": "./src/context/editor.ts", "./context/editor": "./src/context/editor.ts",
"./context/clipboard": "./src/context/clipboard.tsx",
"./attention": "./src/attention.ts",
"./editor": "./src/editor.ts",
"./editor-zed": "./src/editor-zed.ts",
"./context/aggregate-failures": "./src/context/aggregate-failures.ts", "./context/aggregate-failures": "./src/context/aggregate-failures.ts",
"./runtime": "./src/runtime.tsx", "./runtime": "./src/runtime.tsx",
"./terminal-win32": "./src/terminal-win32.ts",
"./config/keybind": "./src/config/keybind.ts", "./config/keybind": "./src/config/keybind.ts",
"./keymap": "./src/keymap.tsx", "./keymap": "./src/keymap.tsx",
"./prompt/display": "./src/prompt/display.ts", "./prompt/display": "./src/prompt/display.ts",
"./platform": "./src/platform.tsx",
"./plugin/runtime": "./src/plugin/runtime.tsx", "./plugin/runtime": "./src/plugin/runtime.tsx",
"./plugin/slots": "./src/plugin/slots.tsx", "./plugin/slots": "./src/plugin/slots.tsx",
"./plugin/command-shim": "./src/plugin/command-shim.ts", "./plugin/command-shim": "./src/plugin/command-shim.ts",
@@ -42,12 +47,15 @@
"./component/spinner": "./src/component/spinner.tsx" "./component/spinner": "./src/component/spinner.tsx"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:", "@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@solid-primitives/scheduled": "1.5.2", "@solid-primitives/scheduled": "1.5.2",
"clipboardy": "4.0.0",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
+193 -319
View File
@@ -1,7 +1,13 @@
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Deferred, Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ClipboardProvider, useClipboard } from "./context/clipboard"
import { EpilogueProvider } from "./context/epilogue"
import * as Selection from "./util/selection" import * as Selection from "./util/selection"
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core" import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route" import { RouteProvider, useRoute } from "./context/route"
import { import {
Switch, Switch,
@@ -15,17 +21,8 @@ import {
batch, batch,
Show, Show,
on, on,
type ParentProps,
} from "solid-js" } from "solid-js"
import { import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime"
TuiBuildInfoProvider,
TuiEnvironmentProvider,
useTuiBuildInfo,
useTuiEnvironment,
type TuiBuildInfo,
type TuiEnvironment,
} from "./runtime"
import { TuiPlatformProvider, useTuiPlatform, type TuiPlatform } from "./platform"
import { DialogProvider, useDialog } from "./ui/dialog" import { DialogProvider, useDialog } from "./ui/dialog"
import { DialogProvider as DialogProviderList } from "./component/dialog-provider" import { DialogProvider as DialogProviderList } from "./component/dialog-provider"
import { ErrorComponent } from "./component/error-component" import { ErrorComponent } from "./component/error-component"
@@ -57,7 +54,6 @@ import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert" import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm" import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast" import { ToastProvider, useToast } from "./ui/toast"
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
import { isDefaultTitle } from "./util/session" import { isDefaultTitle } from "./util/session"
import { KVProvider, useKV } from "./context/kv" import { KVProvider, useKV } from "./context/kv"
import * as Model from "./util/model" import * as Model from "./util/model"
@@ -67,14 +63,7 @@ import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config" import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config"
import { createTuiApiAdapters } from "./plugin/adapters" import { createTuiApiAdapters } from "./plugin/adapters"
import { createTuiApi } from "./plugin/api" import { createTuiApi } from "./plugin/api"
import { import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
createPluginRuntime,
PluginRuntimeProvider,
usePluginRuntime,
type PluginRuntime,
type TuiPluginHost,
} from "./plugin/runtime"
import type { TuiAttention } from "@opencode-ai/plugin/tui"
import { CommandPaletteDialog } from "./component/command-palette" import { CommandPaletteDialog } from "./component/command-palette"
import { import {
COMMAND_PALETTE_COMMAND, COMMAND_PALETTE_COMMAND,
@@ -87,6 +76,10 @@ import {
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant" import { DialogVariant } from "./component/dialog-variant"
import { createTuiAttention } from "./attention"
import * as TuiAudio from "./audio"
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
import { destroyRenderer } from "./util/renderer"
const appGlobalBindingCommands = [ const appGlobalBindingCommands = [
"session.list", "session.list",
@@ -136,81 +129,19 @@ const appBindingCommands = [
"app.toggle.session_directory_filter", "app.toggle.session_directory_filter",
] as const ] as const
export type TuiRuntimeInput = { export type TuiInput = {
environment: TuiEnvironment
build: TuiBuildInfo
}
export type TuiHost = Readonly<{
platform: TuiPlatform
attention(input: {
renderer: CliRenderer
config: TuiConfig.Resolved
kv: ReturnType<typeof useKV>
}): TuiAttention & { dispose(): void }
logger: { error(message: string, extra?: Record<string, unknown>): void }
lifecycle: Readonly<{
prepare?(): (() => void) | undefined
flushInput?(): void
onSighup?(handler: () => void): () => void
writeStdout?(text: string): void
writeStderr?(text: string): void
suspend?(resume: () => void): void
}>
disposeAudio?(): void
formatError(error: unknown): string | undefined
formatUnknownError(error: unknown): string
}>
export function tuiRendererConfig(_config: TuiConfig.Resolved, runtime: TuiRuntimeInput): CliRendererConfig {
return {
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
useMouse: runtime.environment.capabilities.mouse,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
}
}
export function createTuiRenderer(config: TuiConfig.Resolved, runtime: TuiRuntimeInput) {
return createCliRenderer(tuiRendererConfig(config, runtime))
}
export type TuiHandle = {
ready: Promise<void>
done: Promise<void>
exit: Exit
}
export type TuiInput = TuiRuntimeInput & {
url: string url: string
args: Args args: Args
config: TuiConfig.Resolved config: TuiConfig.Resolved
renderer: CliRenderer
onSnapshot?: () => Promise<string[]> onSnapshot?: () => Promise<string[]>
directory?: string directory?: string
fetch?: typeof fetch fetch?: typeof fetch
headers?: RequestInit["headers"] headers?: RequestInit["headers"]
events?: EventSource events?: EventSource
pluginHost: TuiPluginHost pluginHost: TuiPluginHost
host: TuiHost
} }
type TuiLifecycle = { function errorMessage(error: unknown) {
exit: Exit
exited: Promise<void>
fail(error: unknown): Promise<never>
}
function errorMessage(error: unknown, host: TuiHost) {
const formatted = host.formatError(error)
if (formatted !== undefined) return formatted
if ( if (
typeof error === "object" && typeof error === "object" &&
error !== null && error !== null &&
@@ -222,7 +153,7 @@ function errorMessage(error: unknown, host: TuiHost) {
) { ) {
return error.data.message return error.data.message
} }
return host.formatUnknownError(error) return error instanceof Error ? error.message : String(error)
} }
function isVersionGreater(left: string, right: string) { function isVersionGreater(left: string, right: string) {
@@ -242,223 +173,164 @@ function isVersionGreater(left: string, right: string) {
return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0 return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0
} }
export function tui(input: TuiInput): TuiHandle { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const unguard = input.host.lifecycle.prepare?.() const global = yield* Global.Service
const epilogue = { value: undefined as string | undefined }
const output = yield* Effect.scoped(
Effect.gen(function* () {
const renderer = yield* Effect.acquireRelease(
Effect.tryPromise(() =>
createCliRenderer({
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
}),
),
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
)
win32DisableProcessedInput()
const keymap = createDefaultOpenTuiKeymap(renderer)
yield* Effect.acquireRelease(
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)),
(unregister) => Effect.sync(unregister),
)
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
try {
await input.pluginHost.dispose()
} catch (error) {
console.error("Failed to dispose TUI plugins", error)
}
}),
)
yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose))
const shutdown = yield* Deferred.make<void>()
const onSighup = () => destroyRenderer(renderer)
yield* Effect.acquireRelease(
Effect.sync(() => process.on("SIGHUP", onSighup)),
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
)
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
const pluginRuntime = createPluginRuntime()
const renderer = input.renderer yield* Effect.tryPromise(async () => {
const keymap = createDefaultOpenTuiKeymap(renderer) // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config) void renderer.getPalette({ size: 16 }).catch(() => undefined)
const pluginRuntime = createPluginRuntime() const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
const lifecycle = createTuiLifecycle({ if (renderer.isDestroyed) return
renderer,
unguard,
host: input.host,
cleanup: async () => {
unregisterKeymap()
try {
await input.pluginHost.dispose()
} catch (error) {
console.error("Failed to dispose TUI plugins", error)
} finally {
input.host.disposeAudio?.()
}
},
})
const ready = mount({ ...input, keymap, pluginRuntime, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error))
const done = waitUntilDone(ready, lifecycle.exited)
return { ready, done, exit: lifecycle.exit } await render(() => {
} return (
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
export async function mount( <TuiPathsProvider
input: TuiInput & { keymap: ReturnType<typeof createDefaultOpenTuiKeymap>; pluginRuntime: PluginRuntime; exit: Exit }, value={{
) { cwd: process.cwd(),
const renderer = input.renderer home: global.home,
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. state: global.state,
void renderer.getPalette({ size: 16 }).catch(() => undefined) worktree: global.data + "/worktree",
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" }}
if (renderer.isDestroyed) return >
<TuiTerminalEnvironmentProvider
await render(() => { value={{
return ( platform: process.platform,
<ErrorBoundary multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
fallback={(error, reset) => ( displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
<ErrorComponent error={error} reset={reset} exit={input.exit} version={input.build.version} mode={mode} /> }}
)} >
> <TuiStartupProvider
<TuiEnvironmentProvider value={input.environment}> value={{
<TuiPlatformProvider value={input.host.platform}> initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
<TuiBuildInfoProvider value={input.build}> skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
<OpencodeKeymapProvider keymap={input.keymap}> }}
<ArgsProvider {...input.args}> >
<ExitProvider exit={input.exit}> <ClipboardProvider>
<KVProvider> <EpilogueProvider set={(value) => (epilogue.value = value)}>
<ToastProvider> <OpencodeKeymapProvider keymap={keymap}>
<RouteProvider <ArgsProvider {...input.args}>
initialRoute={ <KVProvider>
input.args.continue <ToastProvider>
? { <RouteProvider
type: "session", initialRoute={
sessionID: "dummy", input.args.continue
} ? {
: undefined type: "session",
} sessionID: "dummy",
> }
<TuiConfigProvider config={input.config}> : undefined
<PluginRuntimeProvider value={input.pluginRuntime}> }
<SDKProvider >
url={input.url} <TuiConfigProvider config={input.config}>
directory={input.directory} <PluginRuntimeProvider value={pluginRuntime}>
fetch={input.fetch} <SDKProvider
headers={input.headers} url={input.url}
events={input.events} directory={input.directory}
> fetch={input.fetch}
<ProjectProvider> headers={input.headers}
<LegacySyncProvider logger={input.host.logger}> events={input.events}
<SyncProviderV2> >
<ThemeProvider mode={mode}> <ProjectProvider>
<LocalBridge> <SyncProvider>
<PromptStashProvider> <SyncProviderV2>
<DialogProvider> <ThemeProvider mode={mode}>
<FrecencyProvider> <LocalProvider>
<PromptHistoryProvider> <PromptStashProvider>
<PromptRefProvider> <DialogProvider>
<EditorContextProvider> <FrecencyProvider>
<App <PromptHistoryProvider>
onSnapshot={input.onSnapshot} <PromptRefProvider>
pluginHost={input.pluginHost} <EditorContextProvider>
host={input.host} <App
/> onSnapshot={input.onSnapshot}
</EditorContextProvider> pluginHost={input.pluginHost}
</PromptRefProvider> />
</PromptHistoryProvider> </EditorContextProvider>
</FrecencyProvider> </PromptRefProvider>
</DialogProvider> </PromptHistoryProvider>
</PromptStashProvider> </FrecencyProvider>
</LocalBridge> </DialogProvider>
</ThemeProvider> </PromptStashProvider>
</SyncProviderV2> </LocalProvider>
</LegacySyncProvider> </ThemeProvider>
</ProjectProvider> </SyncProviderV2>
</SDKProvider> </SyncProvider>
</PluginRuntimeProvider> </ProjectProvider>
</TuiConfigProvider> </SDKProvider>
</RouteProvider> </PluginRuntimeProvider>
</ToastProvider> </TuiConfigProvider>
</KVProvider> </RouteProvider>
</ExitProvider> </ToastProvider>
</ArgsProvider> </KVProvider>
</OpencodeKeymapProvider> </ArgsProvider>
</TuiBuildInfoProvider> </OpencodeKeymapProvider>
</TuiPlatformProvider> </EpilogueProvider>
</TuiEnvironmentProvider> </ClipboardProvider>
</ErrorBoundary> </TuiStartupProvider>
) </TuiTerminalEnvironmentProvider>
}, renderer) </TuiPathsProvider>
} </ErrorBoundary>
)
function LegacySyncProvider(props: ParentProps & { logger: TuiHost["logger"] }) { }, renderer)
const kv = useKV() })
return ( yield* Deferred.await(shutdown)
<SyncProvider kv={kv} logger={props.logger}> return epilogue.value
{props.children} }),
</SyncProvider>
) )
} yield* Effect.sync(() => {
win32FlushInputBuffer()
function LocalBridge(props: ParentProps) { if (output) process.stdout.write(output + "\n")
const theme = useTheme()
const toast = useToast()
const route = useRoute()
return (
<LocalProvider theme={theme.theme} toast={toast} route={route}>
{props.children}
</LocalProvider>
)
}
function createTuiLifecycle(input: {
renderer: CliRenderer
unguard?: () => void
cleanup: () => Promise<void>
host: TuiHost
}): TuiLifecycle {
let resolveExited!: () => void
const exited = new Promise<void>((resolve) => {
resolveExited = resolve
}) })
let exitCompleted = false })
let exiting = false
let cleanupTask: Promise<void> | undefined
const completeExit = () => { function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost }) {
if (exitCompleted) return const startup = useTuiStartup()
exitCompleted = true
resolveExited()
}
const cleanup = () => {
cleanupTask ??= (async () => {
offSighup?.()
try {
await input.cleanup()
} finally {
input.unguard?.()
}
})()
return cleanupTask
}
const exit = createExit(async (reason, message) => {
exiting = true
await cleanup()
if (!input.renderer.isDestroyed) {
input.renderer.setTerminalTitle("")
input.renderer.destroy()
}
input.host.lifecycle.flushInput?.()
if (reason) {
const formatted = input.host.formatError(reason) ?? input.host.formatUnknownError(reason)
if (formatted) input.host.lifecycle.writeStderr?.(formatted + "\n")
}
const text = message()
if (text) input.host.lifecycle.writeStdout?.(text + "\n")
completeExit()
})
const onSighup = () => {
void exit()
}
input.renderer.once("destroy", () => {
if (exiting) return
void cleanup().finally(() => {
input.host.lifecycle.flushInput?.()
completeExit()
})
})
const offSighup = input.host.lifecycle.onSighup?.(onSighup)
return {
exit,
exited,
async fail(error) {
exiting = true
await cleanup().catch(() => {})
if (!input.renderer.isDestroyed) input.renderer.destroy()
completeExit()
throw error
},
}
}
async function waitUntilDone(ready: Promise<void>, exited: Promise<void>) {
await ready
await exited
}
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost; host: TuiHost }) {
const environment = useTuiEnvironment()
const build = useTuiBuildInfo()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const route = useRoute() const route = useRoute()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
@@ -474,15 +346,14 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const { theme, mode, setMode, locked, lock, unlock } = themeState const { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync() const sync = useSync()
const project = useProject() const project = useProject()
const exit = useExit()
const promptRef = usePromptRef() const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime() const pluginRuntime = usePluginRuntime()
const attention = props.host.attention({ renderer, config: tuiConfig, kv }) const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
const platform = useTuiPlatform() const clipboard = useClipboard()
const api = createTuiApi( const api = createTuiApi(
createTuiApiAdapters({ createTuiApiAdapters({
version: build.version, version: InstallationVersion,
tuiConfig, tuiConfig,
dialog, dialog,
keymap, keymap,
@@ -518,8 +389,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const offSelectionKeys = keymap.intercept( const offSelectionKeys = keymap.intercept(
"key", "key",
({ event }) => { ({ event }) => {
if (environment.capabilities.copyOnSelect) return if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
Selection.handleSelectionKey(renderer, toast, event, platform.clipboard) Selection.handleSelectionKey(renderer, toast, event, clipboard)
}, },
{ priority: 1 }, { priority: 1 },
) )
@@ -532,8 +403,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
renderer.console.onCopySelection = async (text: string) => { renderer.console.onCopySelection = async (text: string) => {
if (!text || text.length === 0) return if (!text || text.length === 0) return
await platform.clipboard await clipboard
?.write?.(text) .write?.(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error) .catch(toast.error)
@@ -546,7 +417,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
// Update terminal window title based on current route and session // Update terminal window title based on current route and session
createEffect(() => { createEffect(() => {
if (!terminalTitleEnabled() || !environment.capabilities.terminalTitle) return if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
if (route.data.type === "home") { if (route.data.type === "home") {
renderer.setTerminalTitle("OpenCode") renderer.setTerminalTitle("OpenCode")
@@ -695,8 +566,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
run: async () => { run: async () => {
const workspace = currentWorktreeWorkspace() const workspace = currentWorktreeWorkspace()
if (!workspace?.directory) return if (!workspace?.directory) return
await platform.clipboard await clipboard
?.write?.(workspace.directory) .write?.(workspace.directory)
.then(() => toast.show({ message: "Copied worktree path", variant: "info" })) .then(() => toast.show({ message: "Copied worktree path", variant: "info" }))
.catch(toast.error) .catch(toast.error)
dialog.clear() dialog.clear()
@@ -706,7 +577,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
name: "workspace.list", name: "workspace.list",
title: "Manage workspaces", title: "Manage workspaces",
category: "Workspace", category: "Workspace",
hidden: !environment.capabilities.workspaces, hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slashName: "workspaces", slashName: "workspaces",
run: () => { run: () => {
dialog.replace(() => <DialogWorkspaceList />) dialog.replace(() => <DialogWorkspaceList />)
@@ -915,7 +786,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
title: "Exit the app", title: "Exit the app",
slashName: "exit", slashName: "exit",
slashAliases: ["quit", "q"], slashAliases: ["quit", "q"],
run: () => exit(), run: () => destroyRenderer(renderer),
category: "System", category: "System",
}, },
{ {
@@ -955,10 +826,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
title: "Suspend terminal", title: "Suspend terminal",
category: "System", category: "System",
hidden: true, hidden: true,
enabled: environment.capabilities.terminalSuspend, enabled: process.platform !== "win32",
run: () => { run: () => {
renderer.suspend() renderer.suspend()
props.host.lifecycle.suspend?.(() => renderer.resume()) process.once("SIGCONT", () => renderer.resume())
process.kill(0, "SIGTSTP")
}, },
}, },
{ {
@@ -1094,7 +966,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
if (workspace !== project.workspace.current()) return if (workspace !== project.workspace.current()) return
const error = evt.properties.error const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = errorMessage(error, props.host) const message = errorMessage(error)
toast.show({ toast.show({
variant: "error", variant: "error",
@@ -1148,7 +1020,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`, `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
) )
void exit() destroyRenderer(renderer)
}) })
const plugin = createMemo(() => { const plugin = createMemo(() => {
@@ -1166,18 +1038,20 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
flexDirection="column" flexDirection="column"
backgroundColor={theme.background} backgroundColor={theme.background}
onMouseDown={(evt) => { onMouseDown={(evt) => {
if (environment.capabilities.copyOnSelect) return if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return if (evt.button !== MouseButton.RIGHT) return
if (!Selection.copy(renderer, toast, platform.clipboard)) return if (!Selection.copy(renderer, toast, clipboard)) return
evt.preventDefault() evt.preventDefault()
evt.stopPropagation() evt.stopPropagation()
}} }}
onMouseUp={ onMouseUp={
environment.capabilities.copyOnSelect ? () => Selection.copy(renderer, toast, platform.clipboard) : undefined !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
? () => Selection.copy(renderer, toast, clipboard)
: undefined
} }
> >
<Show when={environment.capabilities.showTimeToFirstDraw}> <Show when={Flag.OPENCODE_SHOW_TTFD}>
<TimeToFirstDraw /> <TimeToFirstDraw />
</Show> </Show>
<Show when={ready()}> <Show when={ready()}>
@@ -1199,7 +1073,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
</box> </box>
<pluginRuntime.Slot name="app" /> <pluginRuntime.Slot name="app" />
</Show> </Show>
<Show when={!environment.skipInitialLoading}> <Show when={!startup.skipInitialLoading}>
<StartupLoading ready={ready} /> <StartupLoading ready={ready} />
</Show> </Show>
</box> </box>
@@ -1,3 +1,4 @@
/// <reference path="./audio.d.ts" />
import type { import type {
TuiAttention, TuiAttention,
TuiAttentionNotifyInput, TuiAttentionNotifyInput,
@@ -9,7 +10,7 @@ import type {
TuiAttentionSoundPack, TuiAttentionSoundPack,
TuiAttentionSoundPackInfo, TuiAttentionSoundPackInfo,
} from "@opencode-ai/plugin/tui" } from "@opencode-ai/plugin/tui"
import { AttentionSoundName, type TuiConfig } from "@opencode-ai/tui/config" import { AttentionSoundName, type TuiConfig } from "./config"
import { Schema } from "effect" import { Schema } from "effect"
import stripAnsi from "strip-ansi" import stripAnsi from "strip-ansi"
import * as TuiAudio from "./audio" import * as TuiAudio from "./audio"
@@ -19,7 +20,6 @@ import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with {
import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" } import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" } import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" } import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" }
import * as Log from "@opencode-ai/core/util/log"
type FocusState = "unknown" | "focused" | "blurred" type FocusState = "unknown" | "focused" | "blurred"
@@ -38,8 +38,6 @@ type TuiAttentionHost = TuiAttention & {
dispose(): void dispose(): void
} }
const log = Log.create({ service: "tui.attention" })
const DEFAULT_TITLE = "opencode" const DEFAULT_TITLE = "opencode"
const DEFAULT_PACK_ID = "opencode.default" const DEFAULT_PACK_ID = "opencode.default"
const KV_SOUND_PACK = "attention_sound_pack" const KV_SOUND_PACK = "attention_sound_pack"
@@ -154,7 +152,7 @@ export function createTuiAttention(input: {
try { try {
for (const file of soundCandidates(name)) { for (const file of soundCandidates(name)) {
const current = await audio.loadSoundFile(file).catch((error) => { const current = await audio.loadSoundFile(file).catch((error) => {
log.debug("failed to load attention sound", { file, error }) console.debug("failed to load attention sound", { file, error })
return null return null
}) })
if (disposed) return false if (disposed) return false
@@ -163,7 +161,7 @@ export function createTuiAttention(input: {
} }
return false return false
} catch (error) { } catch (error) {
log.debug("failed to play attention sound", { error }) console.debug("failed to play attention sound", { error })
return false return false
} }
} }
@@ -189,7 +187,7 @@ export function createTuiAttention(input: {
normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT), normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT),
) )
} catch (error) { } catch (error) {
log.debug("failed to trigger attention notification", { error }) console.debug("failed to trigger attention notification", { error })
return false return false
} }
})() })()
@@ -212,7 +210,7 @@ export function createTuiAttention(input: {
sound, sound,
} }
} catch (error) { } catch (error) {
log.debug("failed to handle attention notification", { error }) console.debug("failed to handle attention notification", { error })
return { return {
ok: false, ok: false,
notification: false, notification: false,
+9
View File
@@ -0,0 +1,9 @@
declare module "*.mp3" {
const path: string
export default path
}
declare module "@opencode-ai/ui/audio/*.mp3" {
const path: string
export default path
}
@@ -1,7 +1,5 @@
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core" import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
import * as Log from "@opencode-ai/core/util/log" import { readFile } from "node:fs/promises"
const log = Log.create({ service: "tui.audio" })
let audio: Audio | null | undefined let audio: Audio | null | undefined
const sounds = new Map<string, Promise<AudioSound | null>>() const sounds = new Map<string, Promise<AudioSound | null>>()
@@ -11,12 +9,12 @@ function getAudio() {
try { try {
const next = Audio.create({ autoStart: false }) const next = Audio.create({ autoStart: false })
next.on("error", (error: Error, context: AudioErrorContext) => { next.on("error", (error: Error, context: AudioErrorContext) => {
log.debug("tui audio error", { error, context }) console.debug("tui audio error", { error, context })
}) })
audio = next audio = next
return next return next
} catch (error) { } catch (error) {
log.debug("failed to create tui audio", { error }) console.debug("failed to create tui audio", { error })
audio = null audio = null
return null return null
} }
@@ -27,11 +25,10 @@ export function loadSoundFile(file: string) {
if (!current) return Promise.resolve(null) if (!current) return Promise.resolve(null)
const cached = sounds.get(file) const cached = sounds.get(file)
if (cached) return cached if (cached) return cached
const task = Bun.file(file) const task = readFile(file)
.bytes()
.then((bytes) => current.loadSound(bytes)) .then((bytes) => current.loadSound(bytes))
.catch((error) => { .catch((error) => {
log.debug("failed to load tui sound", { file, error }) console.debug("failed to load tui sound", { file, error })
return null return null
}) })
sounds.set(file, task) sounds.set(file, task)
@@ -54,5 +51,3 @@ export function dispose() {
audio = undefined audio = undefined
sounds.clear() sounds.clear()
} }
export * as TuiAudio from "./audio"
+120
View File
@@ -0,0 +1,120 @@
import { execFile, spawn } from "node:child_process"
import { readFile, rm } from "node:fs/promises"
import { platform, release, tmpdir } from "node:os"
import path from "node:path"
import { promisify } from "node:util"
const exec = promisify(execFile)
function command(command: string, args: string[] = [], input?: string) {
return new Promise<Buffer>((resolve, reject) => {
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
const output: Buffer[] = []
child.on("error", reject)
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
child.on("close", (code) => {
if (code === 0) return resolve(Buffer.concat(output))
reject(new Error(`${command} exited with code ${code}`))
})
if (input !== undefined) child.stdin?.end(input)
})
}
function writeOsc52(text: string) {
if (!process.stdout.isTTY) return
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
}
export async function read() {
if (platform() === "darwin") {
const file = path.join(tmpdir(), "opencode-clipboard.png")
try {
await exec("osascript", [
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${file}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
])
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
} catch {
// Fall through to text clipboard.
} finally {
await rm(file, { force: true }).catch(() => {})
}
}
if (platform() === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
Buffer.alloc(0),
)
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
}
if (platform() === "linux") {
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
Buffer.alloc(0),
)
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
}
const { default: clipboardy } = await import("clipboardy")
const text = await clipboardy.read().catch(() => undefined)
if (text) return { data: text, mime: "text/plain" }
}
export function copyCommand(os: NodeJS.Platform, wayland: boolean, has: (name: string) => boolean): string[] | undefined {
if (os === "darwin" && has("osascript")) return ["osascript"]
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
if (os === "win32" && has("powershell.exe")) {
return [
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
]
}
}
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
function getCopyMethod() {
return (copyMethod ??= (async () => {
const { which } = await import("@opencode-ai/core/util/which")
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
if (native?.[0] === "osascript") {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
}
}
if (native) {
return async (text: string) => {
await command(native[0], native.slice(1), text).catch(() => undefined)
}
}
return async (text: string) => {
const { default: clipboardy } = await import("clipboardy")
await clipboardy.write(text).catch(() => undefined)
}
})())
}
export async function write(text: string) {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
}
@@ -7,7 +7,8 @@ import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSync } from "../context/sync" import { useSync } from "../context/sync"
import { abbreviateHome, useTuiEnvironment } from "../runtime" import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { errorMessage } from "../util/error" import { errorMessage } from "../util/error"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
@@ -32,7 +33,7 @@ export function DialogMoveSession(props: {
const sync = useSync() const sync = useSync()
const projectContext = useProject() const projectContext = useProject()
const toast = useToast() const toast = useToast()
const environment = useTuiEnvironment() const paths = useTuiPaths()
const [working, setWorking] = createSignal(Boolean(props.initialRemoving)) const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving) const [removing, setRemoving] = createSignal(props.initialRemoving)
@@ -101,7 +102,7 @@ export function DialogMoveSession(props: {
}) })
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12) const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
return list.map((item) => { return list.map((item) => {
const title = abbreviateHome(item.location, environment.paths.home) const title = abbreviateHome(item.location, paths.home)
const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location) const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
const visible = Locale.truncateLeft(title, titleWidth) const visible = Locale.truncateLeft(title, titleWidth)
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
@@ -14,7 +14,7 @@ import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "../util/provider-origin" import { isConsoleManagedProvider } from "../util/provider-origin"
import { useConnected } from "./use-connected" import { useConnected } from "./use-connected"
import { useBindings } from "../keymap" import { useBindings } from "../keymap"
import { useTuiPlatform } from "../platform" import { useClipboard } from "../context/clipboard"
const PROVIDER_PRIORITY: Record<string, number> = { const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0, opencode: 0,
@@ -242,7 +242,7 @@ function AutoMethod(props: AutoMethodProps) {
const dialog = useDialog() const dialog = useDialog()
const sync = useSync() const sync = useSync()
const toast = useToast() const toast = useToast()
const platform = useTuiPlatform() const clipboard = useClipboard()
useBindings(() => ({ useBindings(() => ({
bindings: [ bindings: [
@@ -253,8 +253,8 @@ function AutoMethod(props: AutoMethodProps) {
cmd: () => { cmd: () => {
const code = const code =
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
platform.clipboard clipboard
?.write?.(code) .write?.(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error) .catch(toast.error)
}, },
@@ -8,7 +8,7 @@ import { useProject } from "../context/project"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { useLocal } from "../context/local" import { useLocal } from "../context/local"
import { useTuiEnvironment } from "../runtime" import { Flag } from "@opencode-ai/core/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename" import { DialogSessionRename } from "./dialog-session-rename"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
@@ -27,7 +27,6 @@ export function DialogSessionList() {
const { theme } = useTheme() const { theme } = useTheme()
const sdk = useSDK() const sdk = useSDK()
const local = useLocal() const local = useLocal()
const environment = useTuiEnvironment()
const toast = useToast() const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
@@ -174,7 +173,7 @@ export function DialogSessionList() {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let footer: JSX.Element | string = "" let footer: JSX.Element | string = ""
if (environment.capabilities.workspaces) { if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) { if (x.workspaceID) {
footer = workspace ? ( footer = workspace ? (
<WorkspaceLabel <WorkspaceLabel
+10 -9
View File
@@ -1,22 +1,23 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { getScrollAcceleration } from "../util/scroll" import { getScrollAcceleration } from "../util/scroll"
import { useTuiPlatform } from "../platform" import { useClipboard } from "../context/clipboard"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { destroyRenderer } from "../util/renderer"
export function ErrorComponent(props: { export function ErrorComponent(props: {
error: Error error: Error
reset: () => void reset: () => void
exit: () => Promise<void>
version: string
mode?: "dark" | "light" mode?: "dark" | "light"
}) { }) {
const term = useTerminalDimensions() const term = useTerminalDimensions()
const platform = useTuiPlatform() const renderer = useRenderer()
const clipboard = useClipboard()
useKeyboard((evt) => { useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") { if (evt.ctrl && evt.name === "c") {
void props.exit() destroyRenderer(renderer)
} }
}) })
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
@@ -43,10 +44,10 @@ export function ErrorComponent(props: {
) )
} }
issueURL.searchParams.set("opencode-version", props.version) issueURL.searchParams.set("opencode-version", InstallationVersion)
const copyIssueURL = () => { const copyIssueURL = () => {
void platform.clipboard?.write?.(issueURL.toString()).then(() => { void clipboard.write?.(issueURL.toString()).then(() => {
setCopied(true) setCopied(true)
}) })
} }
@@ -69,7 +70,7 @@ export function ErrorComponent(props: {
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}> <box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Reset TUI</text> <text fg={colors.bg}>Reset TUI</text>
</box> </box>
<box onMouseUp={() => void props.exit()} backgroundColor={colors.primary} padding={1}> <box onMouseUp={() => destroyRenderer(renderer)} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Exit</text> <text fg={colors.bg}>Exit</text>
</box> </box>
</box> </box>
@@ -10,7 +10,7 @@ import { useProject } from "../../context/project"
import { useSDK } from "../../context/sdk" import { useSDK } from "../../context/sdk"
import { useSync } from "../../context/sync" import { useSync } from "../../context/sync"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { useTuiEnvironment } from "../../runtime" import { useTuiPaths } from "../../context/runtime"
import { useTuiConfig } from "../../config" import { useTuiConfig } from "../../config"
import { useTheme, selectedForeground } from "../../context/theme" import { useTheme, selectedForeground } from "../../context/theme"
import { SplitBorder } from "../../ui/border" import { SplitBorder } from "../../ui/border"
@@ -92,7 +92,7 @@ export function Autocomplete(props: {
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const frecency = useFrecency() const frecency = useFrecency()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const environment = useTuiEnvironment() const paths = useTuiPaths()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
index: 0, index: 0,
selected: 0, selected: 0,
@@ -236,7 +236,7 @@ export function Autocomplete(props: {
} }
function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) { function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
const baseDir = (sync.path.directory || environment.cwd).replace(/\/+$/, "") const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "")
const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item) const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
const urlObj = pathToFileURL(fullPath) const urlObj = pathToFileURL(fullPath)
const filename = const filename =
@@ -306,7 +306,7 @@ export function Autocomplete(props: {
}) })
function normalizeMentionPath(filePath: string) { function normalizeMentionPath(filePath: string) {
const baseDir = sync.path.directory || environment.cwd const baseDir = sync.path.directory || paths.cwd
const absolute = path.resolve(filePath) const absolute = path.resolve(filePath)
const relative = path.relative(baseDir, absolute) const relative = path.relative(baseDir, absolute)
+16 -13
View File
@@ -14,10 +14,11 @@ import "opentui-spinner/solid"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { useLocal } from "../../context/local" import { useLocal } from "../../context/local"
import { Flag } from "@opencode-ai/core/flag/flag"
import { tint, useTheme } from "../../context/theme" import { tint, useTheme } from "../../context/theme"
import { EmptyBorder, SplitBorder } from "../../ui/border" import { EmptyBorder, SplitBorder } from "../../ui/border"
import { useTuiEnvironment } from "../../runtime" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { useTuiPlatform } from "../../platform" import { useClipboard } from "../../context/clipboard"
import { Spinner } from "../spinner" import { Spinner } from "../spinner"
import { useSDK } from "../../context/sdk" import { useSDK } from "../../context/sdk"
import { useRoute } from "../../context/route" import { useRoute } from "../../context/route"
@@ -25,6 +26,8 @@ import { useProject } from "../../context/project"
import { useSync } from "../../context/sync" import { useSync } from "../../context/sync"
import { useEvent } from "../../context/event" import { useEvent } from "../../context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
import { openEditor } from "../../editor"
import { destroyRenderer } from "../../util/renderer"
import { promptOffsetWidth } from "../../prompt/display" import { promptOffsetWidth } from "../../prompt/display"
import { createStore, produce, unwrap } from "solid-js/store" import { createStore, produce, unwrap } from "solid-js/store"
import { usePromptHistory, type PromptInfo } from "../../prompt/history" import { usePromptHistory, type PromptInfo } from "../../prompt/history"
@@ -34,7 +37,6 @@ import { usePromptStash } from "../../prompt/stash"
import { DialogStash } from "../dialog-stash" import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useExit } from "../../context/exit"
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error" import { errorMessage } from "../../util/error"
@@ -143,8 +145,9 @@ export function Prompt(props: PromptProps) {
const leader = useLeaderActive() const leader = useLeaderActive()
const local = useLocal() const local = useLocal()
const args = useArgs() const args = useArgs()
const environment = useTuiEnvironment() const paths = useTuiPaths()
const platform = useTuiPlatform() const terminalEnvironment = useTuiTerminalEnvironment()
const clipboard = useClipboard()
const sdk = useSDK() const sdk = useSDK()
const editor = useEditorContext() const editor = useEditorContext()
const route = useRoute() const route = useRoute()
@@ -366,7 +369,7 @@ export function Prompt(props: PromptProps) {
run: async (ctx: CommandContext<Renderable, KeyEvent>) => { run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
ctx.event.preventDefault() ctx.event.preventDefault()
ctx.event.stopPropagation() ctx.event.stopPropagation()
const content = await platform.clipboard?.read?.() const content = await clipboard.read?.()
if (content?.mime.startsWith("image/")) { if (content?.mime.startsWith("image/")) {
await pasteAttachment({ await pasteAttachment({
filename: "clipboard", filename: "clipboard",
@@ -430,12 +433,13 @@ export function Prompt(props: PromptProps) {
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text") const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
const value = text const value = text
const content = await platform.editor?.open({ const content = await openEditor({
renderer,
value, value,
cwd: cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() || project.instance.directory() ||
environment.cwd, paths.cwd,
}) })
if (!content) return if (!content) return
@@ -526,7 +530,7 @@ export function Prompt(props: PromptProps) {
desc: "Change the workspace for the session", desc: "Change the workspace for the session",
name: "workspace.set", name: "workspace.set",
category: "Session", category: "Session",
enabled: environment.capabilities.workspaces, enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slashName: "warp", slashName: "warp",
run: () => { run: () => {
workspace.open() workspace.open()
@@ -950,7 +954,7 @@ export function Prompt(props: PromptProps) {
if (!agent) return false if (!agent) return false
const trimmed = store.prompt.input.trim() const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit() destroyRenderer(renderer)
return true return true
} }
const selectedModel = local.model.current() const selectedModel = local.model.current()
@@ -1124,7 +1128,6 @@ export function Prompt(props: PromptProps) {
if (finishMoveProgress) move.finishSubmit() if (finishMoveProgress) move.finishSubmit()
return true return true
} }
const exit = useExit()
function pasteText(text: string, virtualText: string) { function pasteText(text: string, virtualText: string) {
const currentOffset = input.cursorOffset const currentOffset = input.cursorOffset
@@ -1163,10 +1166,10 @@ export function Prompt(props: PromptProps) {
async function pasteInputText(text: string) { async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim() const pastedContent = normalizedText.trim()
const filepath = pastedFilepath(pastedContent, environment.platform) const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath) const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) { if (!isUrl) {
const attachment = await readLocalAttachment(platform.files, filepath) const attachment = await readLocalAttachment(filepath)
const filename = path.basename(filepath) const filename = path.basename(filepath)
if (attachment?.type === "text") { if (attachment?.type === "text") {
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`) pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
@@ -1,10 +1,39 @@
import type { PlatformFiles } from "../../platform" import { readFile } from "node:fs/promises"
import path from "node:path"
export type LocalFiles = Readonly<{
readText(path: string): Promise<string>
readBytes(path: string): Promise<Uint8Array>
mime(path: string): Promise<string>
}>
export type LocalAttachment = export type LocalAttachment =
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }> | Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }> | Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
export async function readLocalAttachment(files: PlatformFiles, path: string): Promise<LocalAttachment | undefined> { export function readLocalAttachment(file: string) {
return readLocalAttachmentWith(
{
readText: (value) => readFile(value, "utf8"),
readBytes: (value) => readFile(value),
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
},
file,
)
}
const mimeTypes: Record<string, string> = {
".avif": "image/avif",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
const mime = await files.mime(path).catch(() => undefined) const mime = await files.mime(path).catch(() => undefined)
if (!mime) return if (!mime) return
if (mime === "image/svg+xml") { if (mime === "image/svg+xml") {
+3 -3
View File
@@ -1,6 +1,6 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import path from "path" import path from "path"
import { useTuiEnvironment } from "../../runtime" import { useTuiPaths } from "../../context/runtime"
import { errorMessage } from "../../util/error" import { errorMessage } from "../../util/error"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { useSDK } from "../../context/sdk" import { useSDK } from "../../context/sdk"
@@ -20,7 +20,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const sync = useSync() const sync = useSync()
const toast = useToast() const toast = useToast()
const homeDestination = useHomeSessionDestination() const homeDestination = useHomeSessionDestination()
const environment = useTuiEnvironment() const paths = useTuiPaths()
const [creating, setCreating] = createSignal(false) const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3) const [creatingDots, setCreatingDots] = createSignal(3)
const [progress, setProgress] = createSignal<string>() const [progress, setProgress] = createSignal<string>()
@@ -35,7 +35,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
{ {
projectID, projectID,
strategy: "git_worktree", strategy: "git_worktree",
directory: path.join(environment.paths.worktree, projectID.slice(0, 6)), directory: path.join(paths.worktree, projectID.slice(0, 6)),
context, context,
}, },
{ throwOnError: true }, { throwOnError: true },
+18
View File
@@ -0,0 +1,18 @@
import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"
export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardService = Readonly<{
read?(): Promise<ClipboardContent | undefined>
write?(text: string): Promise<void>
}>
const clipboard = { read, write }
const ClipboardContext = createContext<ClipboardService>(clipboard)
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
}
export function useClipboard() {
return useContext(ClipboardContext)
}
+5 -4
View File
@@ -1,15 +1,16 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useProject } from "./project" import { useProject } from "./project"
import { useSync } from "./sync" import { useSync } from "./sync"
import { abbreviateHome, useTuiEnvironment } from "../runtime" import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "./runtime"
export function useDirectory() { export function useDirectory() {
const project = useProject() const project = useProject()
const sync = useSync() const sync = useSync()
const environment = useTuiEnvironment() const paths = useTuiPaths()
return createMemo(() => { return createMemo(() => {
const directory = project.instance.path().directory || environment.cwd const directory = project.instance.path().directory || paths.cwd
const result = abbreviateHome(directory, environment.paths.home) const result = abbreviateHome(directory, paths.home)
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result return result
}) })
+25 -17
View File
@@ -2,9 +2,9 @@ import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Option, Schema, SchemaGetter } from "effect" import { Option, Schema, SchemaGetter } from "effect"
import { isRecord } from "../util/record" import { isRecord } from "../util/record"
import { useTuiEnvironment } from "../runtime" import { useTuiPaths } from "./runtime"
import { useOptionalTuiPlatform } from "../platform"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { editorIntegration } from "../editor"
const MCP_PROTOCOL_VERSION = "2025-11-25" const MCP_PROTOCOL_VERSION = "2025-11-25"
@@ -104,11 +104,20 @@ type EditorConnection = {
source: string source: string
} }
export type EditorIntegration = Readonly<{
connection?(directory: string): EditorConnection | undefined
selection?(directory: string): Promise<unknown>
}>
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({ export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext", name: "EditorContext",
init: (props: { WebSocketImpl?: typeof WebSocket }) => { init: (props: { integration?: EditorIntegration; WebSocketImpl?: typeof WebSocket }) => {
const environment = useTuiEnvironment() const paths = useTuiPaths()
const platform = useOptionalTuiPlatform() const editor = props.integration ?? editorIntegration
const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
const parsedPort = value ? Number.parseInt(value, 10) : undefined
const port = parsedPort && Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : undefined
const zedTerminal = process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
const mentionListeners = new Set<(mention: EditorMention) => void>() const mentionListeners = new Set<(mention: EditorMention) => void>()
const WebSocketImpl = props.WebSocketImpl ?? WebSocket const WebSocketImpl = props.WebSocketImpl ?? WebSocket
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
@@ -130,7 +139,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
let requestID = 0 let requestID = 0
let zedSelection: Promise<void> | undefined let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined let lastZedSelectionKey: string | undefined
let directory = environment.cwd let directory = paths.cwd
let preserveSelectionOnReconnect = false let preserveSelectionOnReconnect = false
const pending = new Map<number, string>() const pending = new Map<number, string>()
@@ -163,20 +172,20 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const connect = () => { const connect = () => {
if (closed) return if (closed) return
const connection = resolveEditorConnection(directory, environment.editor.port, platform?.editor?.connection) const connection = resolveEditorConnection(directory, port, editor.connection)
if (!connection) { if (!connection) {
if (!environment.editor.zedTerminal) { if (!zedTerminal) {
setStore("status", "disabled")
scheduleReconnect()
return
}
if (!editor.selection) {
setStore("status", "disabled") setStore("status", "disabled")
scheduleReconnect() scheduleReconnect()
return return
} }
if (!platform?.editor?.selection) { zedSelection ??= editor
setStore("status", "disabled")
scheduleReconnect()
return
}
zedSelection ??= platform.editor
.selection(directory) .selection(directory)
.then((result) => { .then((result) => {
if (closed || socket) return if (closed || socket) return
@@ -278,7 +287,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
} }
const reconnectWithDirectory = (nextDirectory?: string) => { const reconnectWithDirectory = (nextDirectory?: string) => {
const resolved = nextDirectory || environment.cwd const resolved = nextDirectory || paths.cwd
const sameDirectory = directory === resolved const sameDirectory = directory === resolved
clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory }) clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory })
if (sameDirectory) return if (sameDirectory) return
@@ -311,8 +320,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return { return {
enabled() { enabled() {
return Boolean( return Boolean(
resolveEditorConnection(directory, environment.editor.port, platform?.editor?.connection) || resolveEditorConnection(directory, port, editor.connection) || (zedTerminal && editor.selection),
(environment.editor.zedTerminal && platform?.editor?.selection),
) )
}, },
connected() { connected() {
+6
View File
@@ -0,0 +1,6 @@
import { createSimpleContext } from "./helper"
export const { use: useEpilogue, provider: EpilogueProvider } = createSimpleContext({
name: "Epilogue",
init: (props: { set(value?: string): void }) => props.set,
})
-42
View File
@@ -1,42 +0,0 @@
import { createSimpleContext } from "./helper"
export type Exit = ((reason?: unknown) => Promise<void>) & {
message: {
set: (value?: string) => () => void
clear: () => void
get: () => string | undefined
}
}
export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise<void>) {
let message: string | undefined
let task: Promise<void> | undefined
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
},
clear: () => {
message = undefined
},
get: () => message,
}
return Object.assign(
(reason?: unknown) => {
task ??= run(reason, store.get)
return task
},
{
message: store,
},
) satisfies Exit
}
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { exit: Exit }) => input.exit,
})
+13 -4
View File
@@ -1,18 +1,25 @@
import { createSignal, type Setter } from "solid-js" import { createSignal, type Setter } from "solid-js"
import { createStore, unwrap } from "solid-js/store" import { createStore, unwrap } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useOptionalTuiPlatform } from "../platform" import { Flock } from "@opencode-ai/core/util/flock"
import { Global } from "@opencode-ai/core/global"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTuiPaths } from "./runtime"
import path from "path"
export const { use: useKV, provider: KVProvider } = createSimpleContext({ export const { use: useKV, provider: KVProvider } = createSimpleContext({
name: "KV", name: "KV",
init: () => { init: () => {
const platform = useOptionalTuiPlatform() const paths = useTuiPaths()
void Global.Path.state
const file = path.join(paths.state, "kv.json")
const lock = `tui-kv:${file}`
const [ready, setReady] = createSignal(false) const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore<Record<string, any>>() const [store, setStore] = createStore<Record<string, any>>()
// Queue same-process writes so rapid updates persist in order. // Queue same-process writes so rapid updates persist in order.
let write = Promise.resolve() let write = Promise.resolve()
;(platform?.state?.read() ?? Promise.resolve({})) ;Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
.then((x) => { .then((x) => {
setStore(x) setStore(x)
}) })
@@ -48,7 +55,9 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
setStore(key, value) setStore(key, value)
const snapshot = structuredClone(unwrap(store)) const snapshot = structuredClone(unwrap(store))
write = write write = write
.then(() => platform?.state?.write(snapshot)) .then(() =>
Flock.withLock(lock, () => writeJsonAtomic(file, snapshot)),
)
.catch((error) => { .catch((error) => {
console.error("Failed to write KV state", { error }) console.error("Failed to write KV state", { error })
}) })
+11 -19
View File
@@ -4,11 +4,14 @@ import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "./sync" import { useSync } from "./sync"
import { useEvent } from "./event" import { useEvent } from "./event"
import path from "path" import path from "path"
import { useTuiEnvironment } from "../runtime" import { useTuiPaths } from "./runtime"
import { useArgs } from "./args" import { useArgs } from "./args"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core" import { RGBA } from "@opentui/core"
import { readJson, writeJsonAtomic } from "../util/persistence" import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTheme } from "./theme"
import { useToast } from "../ui/toast"
import { useRoute } from "./route"
export type LocalTheme = { export type LocalTheme = {
secondary: RGBA secondary: RGBA
@@ -20,17 +23,6 @@ export type LocalTheme = {
info: RGBA info: RGBA
} }
export type LocalDependencies = {
theme: LocalTheme
toast: {
show(options: { variant: "info" | "warning" | "error"; message: string; duration?: number }): void
}
route: {
readonly data: { type: string; sessionID?: string }
navigate(route: { type: "session"; sessionID: string }): void
}
}
export function parseModel(model: string) { export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/") const [providerID, ...rest] = model.split("/")
return { return {
@@ -57,11 +49,13 @@ export function recentModels(
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
init: (props: LocalDependencies) => { init: () => {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const toast = props.toast const toast = useToast()
const environment = useTuiEnvironment() const theme = useTheme().theme
const route = useRoute()
const paths = useTuiPaths()
function isModelValid(model: { providerID: string; modelID: string }) { function isModelValid(model: { providerID: string; modelID: string }) {
const provider = sync.data.provider.find((x) => x.id === model.providerID) const provider = sync.data.provider.find((x) => x.id === model.providerID)
@@ -82,7 +76,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore({ const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined, current: undefined as string | undefined,
}) })
const theme = props.theme
const colors = createMemo(() => [ const colors = createMemo(() => [
theme.secondary, theme.secondary,
theme.accent, theme.accent,
@@ -164,7 +157,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
variant: {}, variant: {},
}) })
const filePath = path.join(environment.paths.state, "model.json") const filePath = path.join(paths.state, "model.json")
const state = { const state = {
pending: false, pending: false,
} }
@@ -421,7 +414,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
pinned: [], pinned: [],
}) })
const filePath = path.join(environment.paths.state, "session.json") const filePath = path.join(paths.state, "session.json")
const state = { const state = {
pending: false, pending: false,
} }
@@ -453,7 +446,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (state.pending) save() if (state.pending) save()
}) })
const route = props.route
const event = useEvent() const event = useEvent()
const slots = createMemo(() => { const slots = createMemo(() => {
+5 -4
View File
@@ -1,6 +1,7 @@
import path from "path" import path from "path"
import { createContext, useContext, type ParentProps } from "solid-js" import { createContext, useContext, type ParentProps } from "solid-js"
import { abbreviateHome, useTuiEnvironment } from "../runtime" import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "./runtime"
const context = createContext<{ const context = createContext<{
path: () => string path: () => string
@@ -8,12 +9,12 @@ const context = createContext<{
}>() }>()
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) { export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
const environment = useTuiEnvironment() const paths = useTuiPaths()
return ( return (
<context.Provider <context.Provider
value={{ value={{
path: () => props.path || environment.cwd, path: () => props.path || paths.cwd,
format: (input) => formatPath(input, props.path || environment.cwd, environment.paths.home), format: (input) => formatPath(input, props.path || paths.cwd, paths.home),
}} }}
> >
{props.children} {props.children}
+3 -3
View File
@@ -1,7 +1,7 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../prompt/history" import type { PromptInfo } from "../prompt/history"
import { useTuiEnvironment } from "../runtime" import { useTuiStartup } from "./runtime"
export type HomeRoute = { export type HomeRoute = {
type: "home" type: "home"
@@ -25,9 +25,9 @@ export type Route = HomeRoute | SessionRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route", name: "Route",
init: (props: { initialRoute?: Route }) => { init: (props: { initialRoute?: Route }) => {
const environment = useTuiEnvironment() const startup = useTuiStartup()
const [store, setStore] = createStore<Route>( const [store, setStore] = createStore<Route>(
props.initialRoute ?? initialRoute(environment.initialRoute) ?? { type: "home" }, props.initialRoute ?? initialRoute(startup.initialRoute) ?? { type: "home" },
) )
return { return {
+62
View File
@@ -0,0 +1,62 @@
import { createComponent, createContext, type JSX, useContext } from "solid-js"
export type TuiPaths = Readonly<{
cwd: string
home: string
state: string
worktree: string
}>
export type TuiTerminalEnvironment = Readonly<{
platform: string
multiplexer?: "tmux" | "screen"
displayServer?: "wayland" | "x11"
}>
export type TuiStartup = Readonly<{
initialRoute?: unknown
skipInitialLoading: boolean
}>
const PathsContext = createContext<TuiPaths>()
const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>()
const StartupContext = createContext<TuiStartup>()
function provider<T>(context: ReturnType<typeof createContext<T>>, value: T, children: () => JSX.Element) {
return createComponent(context.Provider, {
value: Object.freeze({ ...value }),
get children() {
return children()
},
})
}
export function TuiPathsProvider(props: { value: TuiPaths; children: JSX.Element }) {
return provider(PathsContext, props.value, () => props.children)
}
export function TuiTerminalEnvironmentProvider(props: { value: TuiTerminalEnvironment; children: JSX.Element }) {
return provider(TerminalEnvironmentContext, props.value, () => props.children)
}
export function TuiStartupProvider(props: { value: TuiStartup; children: JSX.Element }) {
return provider(StartupContext, props.value, () => props.children)
}
function required<T>(context: ReturnType<typeof createContext<T>>, name: string) {
const value = useContext(context)
if (!value) throw new Error(`${name} is missing`)
return value
}
export function useTuiPaths() {
return required(PathsContext, "TuiPathsProvider")
}
export function useTuiTerminalEnvironment() {
return required(TerminalEnvironmentContext, "TuiTerminalEnvironmentProvider")
}
export function useTuiStartup() {
return required(StartupContext, "TuiStartupProvider")
}
+3 -4
View File
@@ -1,6 +1,6 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { useTuiEnvironment } from "../runtime" import { Flag } from "@opencode-ai/core/flag/flag"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { batch, onCleanup, onMount } from "solid-js" import { batch, onCleanup, onMount } from "solid-js"
@@ -17,7 +17,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
headers?: RequestInit["headers"] headers?: RequestInit["headers"]
events?: EventSource events?: EventSource
}) => { }) => {
const environment = useTuiEnvironment()
const abort = new AbortController() const abort = new AbortController()
let sse: AbortController | undefined let sse: AbortController | undefined
@@ -94,7 +93,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
sseMaxRetryAttempts: 0, sseMaxRetryAttempts: 0,
}) })
if (environment.capabilities.workspaces) { if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after // Start syncing workspaces, it's important to do this after
// we've started listening to events // we've started listening to events
await sdk.sync.start().catch(() => {}) await sdk.sync.start().catch(() => {})
@@ -122,7 +121,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const unsub = await props.events.subscribe(handleEvent) const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub) onCleanup(unsub)
if (environment.capabilities.workspaces) { if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after // Start syncing workspaces, it's important to do this after
// we've started listening to events // we've started listening to events
await sdk.sync.start().catch(() => {}) await sdk.sync.start().catch(() => {})
+12 -19
View File
@@ -24,13 +24,15 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "./project" import { useProject } from "./project"
import { useEvent } from "./event" import { useEvent } from "./event"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useTuiEnvironment } from "../runtime" import { useTuiStartup } from "./runtime"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useExit } from "./exit" import { useRenderer } from "@opentui/solid"
import { useArgs } from "./args" import { useArgs } from "./args"
import { batch, onMount } from "solid-js" import { batch, onMount } from "solid-js"
import path from "path" import path from "path"
import { aggregateFailures } from "./aggregate-failures" import { aggregateFailures } from "./aggregate-failures"
import { useKV } from "./kv"
import { destroyRenderer } from "../util/renderer"
const emptyConsoleState: ConsoleState = { const emptyConsoleState: ConsoleState = {
consoleManagedProviders: [], consoleManagedProviders: [],
@@ -50,19 +52,11 @@ function search<T>(items: T[], target: string, key: (item: T) => string) {
return { found: false, index: left } return { found: false, index: left }
} }
export type SyncDependencies = { export const { context: SyncContext, use: useSync, provider: SyncProvider } = createSimpleContext({
kv: { get(key: string, defaultValue: boolean): boolean }
logger: { error(message: string, extra?: Record<string, unknown>): void }
}
export const {
context: SyncContext,
use: useSync,
provider: SyncProvider,
} = createSimpleContext({
name: "Sync", name: "Sync",
init: (dependencies: SyncDependencies) => { init: () => {
const environment = useTuiEnvironment() const startup = useTuiStartup()
const kv = useKV()
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete" status: "loading" | "partial" | "complete"
provider: Provider[] provider: Provider[]
@@ -136,7 +130,6 @@ export const {
const event = useEvent() const event = useEvent()
const project = useProject() const project = useProject()
const sdk = useSDK() const sdk = useSDK()
const kv = dependencies.kv
const fullSyncedSessions = new Set<string>() const fullSyncedSessions = new Set<string>()
const syncingSessions = new Map<string, Promise<void>>() const syncingSessions = new Map<string, Promise<void>>()
@@ -427,7 +420,7 @@ export const {
} }
}) })
const exit = useExit() const renderer = useRenderer()
const args = useArgs() const args = useArgs()
async function bootstrap(input: { fatal?: boolean } = {}) { async function bootstrap(input: { fatal?: boolean } = {}) {
@@ -520,13 +513,13 @@ export const {
}) })
}) })
.catch(async (e) => { .catch(async (e) => {
dependencies.logger.error("tui bootstrap failed", { console.error("tui bootstrap failed", {
error: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e),
name: e instanceof Error ? e.name : undefined, name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined, stack: e instanceof Error ? e.stack : undefined,
}) })
if (fatal) { if (fatal) {
await exit(e) destroyRenderer(renderer)
} else { } else {
throw e throw e
} }
@@ -544,7 +537,7 @@ export const {
return store.status return store.status
}, },
get ready() { get ready() {
if (environment.skipInitialLoading) return true if (startup.skipInitialLoading) return true
return store.status !== "loading" return store.status !== "loading"
}, },
get path() { get path() {
+41 -5
View File
@@ -24,7 +24,41 @@ import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useKV } from "./kv" import { useKV } from "./kv"
import { useTuiConfig } from "../config" import { useTuiConfig } from "../config"
import { useOptionalTuiPlatform } from "../platform" import { Global } from "@opencode-ai/core/global"
import { Glob } from "@opencode-ai/core/util/glob"
import { readFile } from "node:fs/promises"
import path from "node:path"
export type ThemeSource = Readonly<{
discover(): Promise<Record<string, unknown>>
subscribeRefresh?(refresh: () => void): () => void
}>
const themeSource: ThemeSource = {
async discover() {
const directories = [Global.Path.config]
for (let current = process.cwd(); ; current = path.dirname(current)) {
directories.push(path.join(current, ".opencode"))
if (path.dirname(current) === current) break
}
return discoverThemes(directories)
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)
return () => process.off("SIGUSR2", refresh)
},
}
export async function discoverThemes(directories: string[]) {
const result: Record<string, unknown> = {}
for (const directory of directories) {
const files = await Glob.scan("themes/*.json", { cwd: directory, absolute: true, dot: true, symlink: true })
for (const file of files) {
result[path.basename(file, ".json")] = JSON.parse(await readFile(file, "utf8")) as unknown
}
}
return result
}
export { export {
DEFAULT_THEMES, DEFAULT_THEMES,
@@ -67,11 +101,11 @@ subscribeThemes((themes) => setStore("themes", themes))
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme", name: "Theme",
init: (props: { mode: "dark" | "light" }) => { init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
const renderer = useRenderer() const renderer = useRenderer()
const config = useTuiConfig() const config = useTuiConfig()
const kv = useKV() const kv = useKV()
const platform = useOptionalTuiPlatform() const themes = props.source ?? themeSource
const pick = (value: unknown) => { const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value if (value === "dark" || value === "light") return value
return return
@@ -96,7 +130,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}) })
function syncCustomThemes() { function syncCustomThemes() {
return (platform?.themes?.discover() ?? Promise.resolve({})) return themes
.discover()
.then((themes) => { .then((themes) => {
setCustomThemes( setCustomThemes(
Object.entries(themes).reduce<Record<string, ThemeJson>>((result, [name, theme]) => { Object.entries(themes).reduce<Record<string, ThemeJson>>((result, [name, theme]) => {
@@ -207,7 +242,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}, delay), }, delay),
) )
} }
const unsubscribeRefresh = platform?.themes?.subscribeRefresh?.(refresh) let unsubscribeRefresh: (() => void) | undefined
unsubscribeRefresh = themes.subscribeRefresh?.(refresh)
onCleanup(() => { onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle) renderer.off(CliRenderEvents.THEME_MODE, handle)
@@ -1,9 +1,10 @@
import { Database } from "bun:sqlite" import { Database } from "bun:sqlite"
import { statSync } from "node:fs" import { statSync } from "node:fs"
import { readFile as readFileAsync } from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { Option, Schema } from "effect" import { Option, Schema } from "effect"
import type { EditorSelection } from "@opencode-ai/tui/context/editor" import type { EditorSelection } from "./context/editor"
const ZedEditorRowSchema = Schema.Struct({ const ZedEditorRowSchema = Schema.Struct({
item_kind: Schema.String, item_kind: Schema.String,
@@ -63,9 +64,7 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
const text = const text =
contents.type === "contents" && contents.contents != null contents.type === "contents" && contents.contents != null
? contents.contents ? contents.contents
: await Bun.file(row.buffer_path) : await readFileAsync(row.buffer_path, "utf8").catch(() => undefined)
.text()
.catch(() => undefined)
if (text == null) return { type: "unavailable" } if (text == null) return { type: "unavailable" }
const ranges = byteRanges.map((range) => { const ranges = byteRanges.map((range) => {
+84
View File
@@ -0,0 +1,84 @@
import type { CliRenderer } from "@opentui/core"
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
import { readFile, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { spawn } from "node:child_process"
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string }) {
const editor = process.env.VISUAL || process.env.EDITOR
if (!editor) return
const file = path.join(os.tmpdir(), `${Date.now()}.md`)
await writeFile(file, input.value)
input.renderer.suspend()
input.renderer.currentRenderBuffer.clear()
try {
await new Promise<void>((resolve, reject) => {
const parts = editor.split(" ")
const child = spawn(parts[0]!, [...parts.slice(1), file], {
cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(),
stdio: "inherit",
shell: process.platform === "win32",
})
child.on("error", reject)
child.on("exit", (code, signal) => {
if (code === 0) return resolve()
reject(new Error(`Editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`))
})
})
return (await readFile(file, "utf8")) || undefined
} finally {
await rm(file, { force: true }).catch(() => {})
input.renderer.currentRenderBuffer.clear()
input.renderer.resume()
input.renderer.requestRender()
}
}
export function discoverEditorConnection(directory: string) {
const root = path.join(os.homedir(), ".claude", "ide")
const contains = (parent: string) => {
const resolved = path.resolve(parent)
const relative = path.relative(resolved, path.resolve(directory))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
}
try {
return readdirSync(root)
.filter((entry) => entry.endsWith(".lock"))
.flatMap((entry) => {
const file = path.join(root, entry)
const port = Number.parseInt(path.basename(file, ".lock"), 10)
if (!Number.isInteger(port) || port <= 0 || port > 65535) return []
try {
const value = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>
if (value.transport !== undefined && value.transport !== "ws") return []
const folders = Array.isArray(value.workspaceFolders)
? value.workspaceFolders.filter((item): item is string => typeof item === "string")
: []
const score = Math.max(0, ...folders.map(contains))
if (!score) return []
return [
{
url: `ws://127.0.0.1:${port}`,
authToken: typeof value.authToken === "string" ? value.authToken : undefined,
source: `lock:${port}`,
score,
mtime: statSync(file).mtimeMs,
},
]
} catch {
return []
}
})
.sort((left, right) => right.score - left.score || right.mtime - left.mtime)
.map(({ url, authToken, source }) => ({ url, authToken, source }))[0]
} catch {
return undefined
}
}
export const editorIntegration = {
connection: discoverEditorConnection,
selection: (directory: string) => resolveZedSelection(resolveZedDbPath() ?? "", directory),
}
@@ -1,7 +1,8 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins" import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, Match, Show, Switch } from "solid-js" import { createMemo, Match, Show, Switch } from "solid-js"
import { abbreviateHome, useTuiEnvironment } from "../../runtime" import { abbreviateHome } from "../../runtime"
import { useTuiPaths } from "../../context/runtime"
import { useHomeSessionDestination } from "../../routes/home/session-destination" import { useHomeSessionDestination } from "../../routes/home/session-destination"
const id = "internal:home-footer" const id = "internal:home-footer"
@@ -9,13 +10,13 @@ const id = "internal:home-footer"
function Directory(props: { api: TuiPluginApi }) { function Directory(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current const theme = () => props.api.theme.current
const destination = useHomeSessionDestination() const destination = useHomeSessionDestination()
const environment = useTuiEnvironment() const paths = useTuiPaths()
const dir = createMemo(() => { const dir = createMemo(() => {
const selected = destination?.destination() const selected = destination?.destination()
if (!selected || selected.type === "new") return if (!selected || selected.type === "new") return
const out = abbreviateHome(selected.directory, environment.paths.home) const out = abbreviateHome(selected.directory, paths.home)
const branch = const branch =
selected.directory === (props.api.state.path.directory || environment.cwd) selected.directory === (props.api.state.path.directory || paths.cwd)
? props.api.state.vcs?.branch ? props.api.state.vcs?.branch
: undefined : undefined
if (branch) return out + ":" + branch if (branch) return out + ":" + branch
@@ -2,7 +2,6 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createMemo, For, type Accessor } from "solid-js" import { createMemo, For, type Accessor } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "../../context/theme" import { DEFAULT_THEMES, useTheme } from "../../context/theme"
import { useCommandShortcut } from "../../keymap" import { useCommandShortcut } from "../../keymap"
import { useTuiEnvironment } from "../../runtime"
const themeCount = Object.keys(DEFAULT_THEMES).length const themeCount = Object.keys(DEFAULT_THEMES).length
@@ -97,7 +96,6 @@ function configShortcut(api: TuiPluginApi, command: string): TipShortcut {
export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
const theme = useTheme().theme const theme = useTheme().theme
const environment = useTuiEnvironment()
const tipOffset = Math.random() const tipOffset = Math.random()
const shortcuts: Shortcuts = { const shortcuts: Shortcuts = {
agentCycle: useCommandShortcut("agent.cycle"), agentCycle: useCommandShortcut("agent.cycle"),
@@ -136,12 +134,10 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
} }
const tip = createMemo(() => { const tip = createMemo(() => {
if (props.connected === false) return NO_MODELS_TIP if (props.connected === false) return NO_MODELS_TIP
const tips = [...TIPS, environment.capabilities.terminalSuspend ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap( const tips = [...TIPS, process.platform !== "win32" ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap((item) => {
(item) => { const value = typeof item === "string" ? item : item(shortcuts)
const value = typeof item === "string" ? item : item(shortcuts) return value ? [value] : []
return value ? [value] : [] })
},
)
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
}, NO_MODELS_TIP) }, NO_MODELS_TIP)
// Solid can expose a memo's initial value while a pure computation is pending. // Solid can expose a memo's initial value while a pure computation is pending.
@@ -1,12 +1,13 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins" import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { abbreviateHome, useTuiEnvironment } from "../../runtime" import { abbreviateHome } from "../../runtime"
import { useTuiPaths } from "../../context/runtime"
const id = "internal:sidebar-footer" const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi; sessionID: string }) { function View(props: { api: TuiPluginApi; sessionID: string }) {
const environment = useTuiEnvironment() const paths = useTuiPaths()
const theme = () => props.api.theme.current const theme = () => props.api.theme.current
const has = createMemo(() => const has = createMemo(() =>
props.api.state.provider.some( props.api.state.provider.some(
@@ -17,8 +18,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
const show = createMemo(() => !has() && !done()) const show = createMemo(() => !has() && !done())
const path = createMemo(() => { const path = createMemo(() => {
const session = props.api.state.session.get(props.sessionID) const session = props.api.state.session.get(props.sessionID)
const dir = session?.directory || props.api.state.path.directory || environment.cwd const dir = session?.directory || props.api.state.path.directory || paths.cwd
const out = abbreviateHome(dir, environment.paths.home) const out = abbreviateHome(dir, paths.home)
const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
const text = branch ? out + ":" + branch : out const text = branch ? out + ":" + branch : out
const list = text.split("/") const list = text.split("/")
@@ -10,7 +10,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { useBindings } from "../../keymap" import { useBindings } from "../../keymap"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import { useTuiEnvironment } from "../../runtime" import { useTuiPaths } from "../../context/runtime"
import { LANGUAGE_EXTENSIONS } from "../../util/filetype" import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
import { toolDisplayMetadata, webSearchProviderLabel } from "../../util/tool-display" import { toolDisplayMetadata, webSearchProviderLabel } from "../../util/tool-display"
import path from "path" import path from "path"
@@ -1122,7 +1122,7 @@ function input(input: Record<string, unknown>, omit?: string[]) {
} }
function usePathNormalizer() { function usePathNormalizer() {
const cwd = useTuiEnvironment().cwd const cwd = useTuiPaths().cwd
return (input?: string) => normalizePath(input, cwd) return (input?: string) => normalizePath(input, cwd)
} }
+1 -22
View File
@@ -1,22 +1 @@
export { export { run, type TuiInput } from "./app"
createTuiBuildInfo,
createTuiEnvironment,
TuiBuildInfoProvider,
TuiEnvironmentProvider,
useTuiBuildInfo,
useTuiEnvironment,
type TuiBuildInfo,
type TuiEnvironment,
} from "./runtime"
export {
createTuiRenderer,
createTuiRenderer as createRenderer,
mount,
tui,
tui as run,
tuiRendererConfig,
type TuiHandle,
type TuiHost,
type TuiInput,
type TuiRuntimeInput,
} from "./app"
-52
View File
@@ -1,52 +0,0 @@
import { createContext, type JSX, useContext } from "solid-js"
export type PlatformFiles = Readonly<{
readText(path: string): Promise<string>
readBytes(path: string): Promise<Uint8Array>
mime(path: string): Promise<string>
}>
export type PlatformClipboardContent = Readonly<{
data: string
mime: string
}>
export type TuiPlatform = Readonly<{
files: PlatformFiles
state?: Readonly<{
read(): Promise<Record<string, unknown>>
write(value: Record<string, unknown>): Promise<void>
}>
themes?: Readonly<{
discover(): Promise<Record<string, unknown>>
subscribeRefresh?(refresh: () => void): () => void
}>
clipboard?: Readonly<{
read?(): Promise<PlatformClipboardContent | undefined>
write?(text: string): Promise<void>
}>
editor?: Readonly<{
open(input: Readonly<{ value: string; cwd?: string }>): Promise<string | undefined>
connection?(directory: string): Readonly<{ url: string; authToken?: string; source: string }> | undefined
selection?(directory: string): Promise<unknown>
}>
export?: Readonly<{
write(path: string, content: string): Promise<void>
}>
}>
const PlatformContext = createContext<TuiPlatform>()
export function TuiPlatformProvider(props: { value: TuiPlatform; children: JSX.Element }) {
return <PlatformContext.Provider value={props.value}>{props.children}</PlatformContext.Provider>
}
export function useTuiPlatform() {
const value = useContext(PlatformContext)
if (!value) throw new Error("TuiPlatformProvider is missing")
return value
}
export function useOptionalTuiPlatform() {
return useContext(PlatformContext)
}
+5 -5
View File
@@ -2,7 +2,7 @@ import path from "path"
import { onMount } from "solid-js" import { onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "../context/helper" import { createSimpleContext } from "../context/helper"
import { useTuiEnvironment } from "../runtime" import { useTuiPaths } from "../context/runtime"
import { appendText, readText, writeText } from "../util/persistence" import { appendText, readText, writeText } from "../util/persistence"
type FrecencyEntry = { path: string; frequency: number; lastOpen: number } type FrecencyEntry = { path: string; frequency: number; lastOpen: number }
@@ -38,8 +38,8 @@ function calculateFrecency(entry?: { frequency: number; lastOpen: number }) {
export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
name: "Frecency", name: "Frecency",
init: () => { init: () => {
const environment = useTuiEnvironment() const paths = useTuiPaths()
const frecencyPath = path.join(environment.paths.state, "frecency.jsonl") const frecencyPath = path.join(paths.state, "frecency.jsonl")
onMount(async () => { onMount(async () => {
const lines = parseFrecency(await readText(frecencyPath).catch(() => "")) const lines = parseFrecency(await readText(frecencyPath).catch(() => ""))
setStore( setStore(
@@ -55,7 +55,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
const [store, setStore] = createStore({ data: {} as Record<string, { frequency: number; lastOpen: number }> }) const [store, setStore] = createStore({ data: {} as Record<string, { frequency: number; lastOpen: number }> })
function updateFrecency(filePath: string) { function updateFrecency(filePath: string) {
const absolutePath = path.resolve(environment.cwd, filePath) const absolutePath = path.resolve(paths.cwd, filePath)
const newEntry = { frequency: (store.data[absolutePath]?.frequency || 0) + 1, lastOpen: Date.now() } const newEntry = { frequency: (store.data[absolutePath]?.frequency || 0) + 1, lastOpen: Date.now() }
setStore("data", absolutePath, newEntry) setStore("data", absolutePath, newEntry)
appendText(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {}) appendText(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
@@ -72,7 +72,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
} }
return { return {
getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(environment.cwd, filePath)]), getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(paths.cwd, filePath)]),
updateFrecency, updateFrecency,
data: () => store.data, data: () => store.data,
} }
+3 -3
View File
@@ -3,7 +3,7 @@ import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store" import { createStore, produce, unwrap } from "solid-js/store"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2" import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "../context/helper" import { createSimpleContext } from "../context/helper"
import { useTuiEnvironment } from "../runtime" import { useTuiPaths } from "../context/runtime"
import { appendText, readText, writeText } from "../util/persistence" import { appendText, readText, writeText } from "../util/persistence"
export type PromptInfo = { export type PromptInfo = {
@@ -49,8 +49,8 @@ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptI
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({ export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
name: "PromptHistory", name: "PromptHistory",
init: () => { init: () => {
const environment = useTuiEnvironment() const paths = useTuiPaths()
const historyPath = path.join(environment.paths.state, "prompt-history.jsonl") const historyPath = path.join(paths.state, "prompt-history.jsonl")
onMount(async () => { onMount(async () => {
const lines = parsePromptHistory(await readText(historyPath).catch(() => "")) const lines = parsePromptHistory(await readText(historyPath).catch(() => ""))
setStore("history", lines) setStore("history", lines)
+3 -3
View File
@@ -2,7 +2,7 @@ import path from "path"
import { onMount } from "solid-js" import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store" import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../context/helper" import { createSimpleContext } from "../context/helper"
import { useTuiEnvironment } from "../runtime" import { useTuiPaths } from "../context/runtime"
import { appendText, readText, writeText } from "../util/persistence" import { appendText, readText, writeText } from "../util/persistence"
import type { PromptInfo } from "./history" import type { PromptInfo } from "./history"
@@ -32,8 +32,8 @@ export function parsePromptStash(text: string) {
export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({ export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
name: "PromptStash", name: "PromptStash",
init: () => { init: () => {
const environment = useTuiEnvironment() const paths = useTuiPaths()
const stashPath = path.join(environment.paths.state, "prompt-stash.jsonl") const stashPath = path.join(paths.state, "prompt-stash.jsonl")
onMount(async () => { onMount(async () => {
const lines = parsePromptStash(await readText(stashPath).catch(() => "")) const lines = parsePromptStash(await readText(stashPath).catch(() => ""))
setStore("entries", lines) setStore("entries", lines)
@@ -8,7 +8,7 @@ import {
type Setter, type Setter,
} from "solid-js" } from "solid-js"
import { useSync } from "../../context/sync" import { useSync } from "../../context/sync"
import { useTuiEnvironment } from "../../runtime" import { useTuiPaths } from "../../context/runtime"
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
@@ -22,10 +22,10 @@ const HomeSessionDestinationContext = createContext<Context>()
export function HomeSessionDestinationProvider(props: ParentProps) { export function HomeSessionDestinationProvider(props: ParentProps) {
const sync = useSync() const sync = useSync()
const environment = useTuiEnvironment() const paths = useTuiPaths()
const [selected, setDestination] = createSignal<HomeSessionDestination>() const [selected, setDestination] = createSignal<HomeSessionDestination>()
const destination = createMemo<HomeSessionDestination>( const destination = createMemo<HomeSessionDestination>(
() => selected() ?? { type: "directory", directory: sync.path.directory || environment.cwd, subdirectory: false }, () => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false },
) )
return ( return (
<HomeSessionDestinationContext.Provider <HomeSessionDestinationContext.Provider
@@ -3,7 +3,7 @@ import { useSync } from "../../context/sync"
import { DialogSelect } from "../../ui/dialog-select" import { DialogSelect } from "../../ui/dialog-select"
import { useSDK } from "../../context/sdk" import { useSDK } from "../../context/sdk"
import { useRoute } from "../../context/route" import { useRoute } from "../../context/route"
import { useTuiPlatform } from "../../platform" import { useClipboard } from "../../context/clipboard"
import type { PromptInfo } from "../../component/prompt/history" import type { PromptInfo } from "../../component/prompt/history"
import { stripPromptPartIDs as strip } from "../../prompt/part" import { stripPromptPartIDs as strip } from "../../prompt/part"
@@ -16,7 +16,7 @@ export function DialogMessage(props: {
const sdk = useSDK() const sdk = useSDK()
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID)) const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
const route = useRoute() const route = useRoute()
const platform = useTuiPlatform() const clipboard = useClipboard()
return ( return (
<DialogSelect <DialogSelect
@@ -69,7 +69,7 @@ export function DialogMessage(props: {
return agg return agg
}, "") }, "")
await platform.clipboard?.write?.(text) await clipboard.write?.(text)
dialog.clear() dialog.clear()
}, },
}, },
+37 -27
View File
@@ -7,6 +7,7 @@ import {
For, For,
Match, Match,
on, on,
onCleanup,
onMount, onMount,
Show, Show,
Switch, Switch,
@@ -15,12 +16,13 @@ import {
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import path from "node:path" import path from "node:path"
import { mkdir, writeFile } from "node:fs/promises"
import { useRoute, useRouteData } from "../../context/route" import { useRoute, useRouteData } from "../../context/route"
import { useProject } from "../../context/project" import { useProject } from "../../context/project"
import { useSync } from "../../context/sync" import { useSync } from "../../context/sync"
import { useEvent } from "../../context/event" import { useEvent } from "../../context/event"
import { SplitBorder } from "../../ui/border" import { SplitBorder } from "../../ui/border"
import { useTuiEnvironment } from "../../runtime" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner } from "../../component/spinner" import { Spinner } from "../../component/spinner"
import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme" import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
@@ -41,6 +43,7 @@ import { webSearchProviderLabel } from "../../util/tool-display"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "../../context/sdk" import { useSDK } from "../../context/sdk"
import { useEditorContext } from "../../context/editor" import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { DialogAlert } from "../../ui/dialog-alert" import { DialogAlert } from "../../ui/dialog-alert"
import { TodoItem } from "../../component/todo-item" import { TodoItem } from "../../component/todo-item"
@@ -59,17 +62,17 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx" import { useKV } from "../../context/kv.tsx"
import stripAnsi from "strip-ansi" import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt" import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit" import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path" import { normalizePath } from "../../util/path"
import { PermissionPrompt } from "./permission" import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question" import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options" import { DialogExportOptions } from "../../ui/dialog-export-options"
import * as Model from "../../util/model" import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript" import { formatTranscript } from "../../util/transcript"
import { sessionEpilogue } from "../../util/presentation"
import { setPreLayoutSiblingMargin } from "../../util/layout" import { setPreLayoutSiblingMargin } from "../../util/layout"
import { sessionExitSummary } from "../../util/presentation"
import { useTuiConfig } from "../../config" import { useTuiConfig } from "../../config"
import { useTuiPlatform } from "../../platform" import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking" import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output" import { collapseToolOutput } from "../../util/collapse-tool-output"
@@ -171,19 +174,30 @@ function use() {
} }
export function Session() { export function Session() {
const platform = useTuiPlatform() const setEpilogue = useEpilogue()
const clipboard = useClipboard()
const writeExport = async (file: string, content: string) => {
await mkdir(path.dirname(file), { recursive: true })
await writeFile(file, content)
}
const pluginRuntime = usePluginRuntime() const pluginRuntime = usePluginRuntime()
const route = useRouteData("session") const route = useRouteData("session")
const { navigate } = useRoute() const { navigate } = useRoute()
const sync = useSync() const sync = useSync()
const event = useEvent() const event = useEvent()
const project = useProject() const project = useProject()
const environment = useTuiEnvironment() const paths = useTuiPaths()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const kv = useKV() const kv = useKV()
const { theme } = useTheme() const { theme } = useTheme()
const promptRef = usePromptRef() const promptRef = usePromptRef()
const session = createMemo(() => sync.session.get(route.sessionID)) const session = createMemo(() => sync.session.get(route.sessionID))
createEffect(() => {
const title = Locale.truncate(session()?.title ?? "", 50)
setEpilogue(sessionEpilogue({ title, sessionID: session()?.id }))
})
onCleanup(() => setEpilogue())
const children = createMemo(() => { const children = createMemo(() => {
const parentID = session()?.parentID ?? session()?.id const parentID = session()?.parentID ?? session()?.id
return sync.data.session return sync.data.session
@@ -353,13 +367,6 @@ export function Session() {
}) })
}) })
const exit = useExit()
createEffect(() => {
const title = Locale.truncate(session()?.title ?? "", 50)
return exit.message.set(sessionExitSummary({ title, sessionID: session()?.id }))
})
// Helper: Find next visible message boundary in direction // Helper: Find next visible message boundary in direction
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
const children = scroll.getChildren() const children = scroll.getChildren()
@@ -460,8 +467,7 @@ export function Session() {
}, },
run: async () => { run: async () => {
const copy = (url: string) => const copy = (url: string) =>
platform.clipboard clipboard.write?.(url)
?.write?.(url)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" })) .catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }))
const url = session()?.share?.url const url = session()?.share?.url
@@ -896,8 +902,7 @@ export function Session() {
return return
} }
platform.clipboard clipboard.write?.(text)
?.write?.(text)
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" })) .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
dialog.clear() dialog.clear()
@@ -925,7 +930,7 @@ export function Session() {
providers: sync.data.provider, providers: sync.data.provider,
}, },
) )
await platform.clipboard?.write?.(transcript) await clipboard.write?.(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch { } catch {
toast.show({ message: "Failed to copy session transcript", variant: "error" }) toast.show({ message: "Failed to copy session transcript", variant: "error" })
@@ -972,30 +977,32 @@ export function Session() {
if (options.openWithoutSaving) { if (options.openWithoutSaving) {
// Just open in editor without saving // Just open in editor without saving
await platform.editor?.open({ await openEditor({
renderer,
value: transcript, value: transcript,
cwd: cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() || project.instance.directory() ||
environment.cwd, paths.cwd,
}) })
} else { } else {
const exportDir = environment.cwd const exportDir = paths.cwd
const filename = options.filename.trim() const filename = options.filename.trim()
const filepath = path.join(exportDir, filename) const filepath = path.join(exportDir, filename)
await platform.export?.write(filepath, transcript) await writeExport(filepath, transcript)
// Open with EDITOR if available // Open with EDITOR if available
const result = await platform.editor?.open({ const result = await openEditor({
renderer,
value: transcript, value: transcript,
cwd: cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() || project.instance.directory() ||
environment.cwd, paths.cwd,
}) })
if (result !== undefined) { if (result !== undefined) {
await platform.export?.write(filepath, result) await writeExport(filepath, result)
} }
toast.show({ message: `Session exported to ${filename}`, variant: "success" }) toast.show({ message: `Session exported to ${filename}`, variant: "success" })
@@ -2510,9 +2517,12 @@ function Skill(props: ToolProps) {
function Diagnostics(props: { diagnostics: unknown; filePath: string }) { function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
const { theme } = useTheme() const { theme } = useTheme()
const environment = useTuiEnvironment() const terminalEnvironment = useTuiTerminalEnvironment()
const errors = createMemo(() => { const errors = createMemo(() => {
const normalized = normalizePath(typeof props.filePath === "string" ? props.filePath : "", environment.platform) const normalized = normalizePath(
typeof props.filePath === "string" ? props.filePath : "",
terminalEnvironment.platform,
)
return parseDiagnostics(props.diagnostics, normalized) return parseDiagnostics(props.diagnostics, normalized)
}) })
+3 -4
View File
@@ -3,7 +3,7 @@ import { useSync } from "../../context/sync"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../config" import { useTuiConfig } from "../../config"
import { useTuiBuildInfo } from "../../runtime" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { usePluginRuntime } from "../../plugin/runtime" import { usePluginRuntime } from "../../plugin/runtime"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
@@ -15,7 +15,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync() const sync = useSync()
const { theme } = useTheme() const { theme } = useTheme()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const build = useTuiBuildInfo()
const session = createMemo(() => sync.session.get(props.sessionID)) const session = createMemo(() => sync.session.get(props.sessionID))
const workspace = () => { const workspace = () => {
const workspaceID = session()?.workspaceID const workspaceID = session()?.workspaceID
@@ -58,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}> <text fg={theme.text}>
<b>{session()!.title}</b> <b>{session()!.title}</b>
</text> </text>
<Show when={build.channel !== "latest"}> <Show when={InstallationChannel !== "latest"}>
<text fg={theme.textMuted}>{props.sessionID}</text> <text fg={theme.textMuted}>{props.sessionID}</text>
</Show> </Show>
<Show when={session()!.workspaceID}> <Show when={session()!.workspaceID}>
@@ -94,7 +93,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<span style={{ fg: theme.text }}> <span style={{ fg: theme.text }}>
<b>Code</b> <b>Code</b>
</span>{" "} </span>{" "}
<span>{build.version}</span> <span>{InstallationVersion}</span>
</text> </text>
</pluginRuntime.Slot> </pluginRuntime.Slot>
</box> </box>
-83
View File
@@ -1,88 +1,5 @@
import { createComponent, createContext, type JSX, useContext } from "solid-js"
import path from "path" import path from "path"
export type TuiEnvironment = Readonly<{
cwd: string
platform: string
initialRoute?: unknown
paths: Readonly<{
home: string
state: string
worktree: string
}>
capabilities: Readonly<{
mouse: boolean
copyOnSelect: boolean
terminalTitle: boolean
terminalSuspend: boolean
workspaces: boolean
showTimeToFirstDraw: boolean
}>
terminal: Readonly<{
multiplexer?: "tmux" | "screen"
displayServer?: "wayland" | "x11"
}>
editor: Readonly<{
command?: string
port?: number
zedTerminal: boolean
zedDatabase?: string
}>
skipInitialLoading: boolean
}>
export type TuiBuildInfo = Readonly<{
version: string
channel: string
}>
const EnvironmentContext = createContext<TuiEnvironment>()
const BuildInfoContext = createContext<TuiBuildInfo>()
export function TuiEnvironmentProvider(props: { value: TuiEnvironment; children: JSX.Element }) {
return createComponent(EnvironmentContext.Provider, {
value: props.value,
get children() {
return props.children
},
})
}
export function TuiBuildInfoProvider(props: { value: TuiBuildInfo; children: JSX.Element }) {
return createComponent(BuildInfoContext.Provider, {
value: props.value,
get children() {
return props.children
},
})
}
export function useTuiEnvironment() {
const value = useContext(EnvironmentContext)
if (!value) throw new Error("TuiEnvironmentProvider is missing")
return value
}
export function useTuiBuildInfo() {
const value = useContext(BuildInfoContext)
if (!value) throw new Error("TuiBuildInfoProvider is missing")
return value
}
export function createTuiEnvironment(input: TuiEnvironment): TuiEnvironment {
return Object.freeze({
...input,
paths: Object.freeze({ ...input.paths }),
capabilities: Object.freeze({ ...input.capabilities }),
terminal: Object.freeze({ ...input.terminal }),
editor: Object.freeze({ ...input.editor }),
})
}
export function createTuiBuildInfo(input: TuiBuildInfo): TuiBuildInfo {
return Object.freeze({ ...input })
}
export function abbreviateHome(input: string, home: string) { export function abbreviateHome(input: string, home: string) {
if (!home) return input if (!home) return input
const relative = path.relative(home, input) const relative = path.relative(home, input)
+7 -8
View File
@@ -4,9 +4,9 @@ import { useTheme } from "../context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core" import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useToast } from "./toast" import { useToast } from "./toast"
import { useTuiEnvironment } from "../runtime" import { Flag } from "@opencode-ai/core/flag/flag"
import { useBindings, useOpencodeModeStack } from "../keymap" import { useBindings, useOpencodeModeStack } from "../keymap"
import { useOptionalTuiPlatform } from "../platform" import { useClipboard } from "../context/clipboard"
export function Dialog( export function Dialog(
props: ParentProps<{ props: ParentProps<{
@@ -180,13 +180,12 @@ export function DialogProvider(props: ParentProps) {
const value = init() const value = init()
const renderer = useRenderer() const renderer = useRenderer()
const toast = useToast() const toast = useToast()
const environment = useTuiEnvironment() const clipboard = useClipboard()
const platform = useOptionalTuiPlatform()
function copySelection() { function copySelection() {
const text = renderer.getSelection()?.getSelectedText() const text = renderer.getSelection()?.getSelectedText()
if (!text || !platform?.clipboard?.write) return false if (!text || !clipboard.write) return false
void platform.clipboard.write(text).then( void clipboard.write(text).then(
() => toast.show({ message: "Copied to clipboard", variant: "info" }), () => toast.show({ message: "Copied to clipboard", variant: "info" }),
(error) => toast.error(error), (error) => toast.error(error),
) )
@@ -201,14 +200,14 @@ export function DialogProvider(props: ParentProps) {
position="absolute" position="absolute"
zIndex={3000} zIndex={3000}
onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => { onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => {
if (environment.capabilities.copyOnSelect) return if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return if (evt.button !== MouseButton.RIGHT) return
if (!copySelection()) return if (!copySelection()) return
evt.preventDefault() evt.preventDefault()
evt.stopPropagation() evt.stopPropagation()
}} }}
onMouseUp={environment.capabilities.copyOnSelect ? copySelection : undefined} onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined}
> >
<Show when={value.stack.length}> <Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}> <Dialog onClose={() => value.clear()} size={value.size}>
+1 -1
View File
@@ -26,7 +26,7 @@ function wordmark(pad = "") {
}) })
} }
export function sessionExitSummary(input: { title: string; sessionID?: string }) { export function sessionEpilogue(input: { title: string; sessionID?: string }) {
const weak = (text: string) => `${dim}${text.padEnd(10, " ")}${reset}` const weak = (text: string) => `${dim}${text.padEnd(10, " ")}${reset}`
return [ return [
...wordmark(" "), ...wordmark(" "),
+6
View File
@@ -0,0 +1,6 @@
import type { CliRenderer } from "@opentui/core"
export function destroyRenderer(renderer: Pick<CliRenderer, "isDestroyed" | "setTerminalTitle" | "destroy">) {
renderer.setTerminalTitle("")
if (!renderer.isDestroyed) renderer.destroy()
}
+3 -3
View File
@@ -1,4 +1,4 @@
import type { TuiPlatform } from "../platform" import type { ClipboardService } from "../context/clipboard"
type Toast = { type Toast = {
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
@@ -23,7 +23,7 @@ type SelectionKeyEvent = {
stopPropagation: () => void stopPropagation: () => void
} }
export function copy(renderer: Renderer, toast: Toast, clipboard: TuiPlatform["clipboard"]): boolean { export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean {
const selection = renderer.getSelection() const selection = renderer.getSelection()
if (!selection) return false if (!selection) return false
@@ -47,7 +47,7 @@ export function handleSelectionKey(
renderer: Renderer, renderer: Renderer,
toast: Toast, toast: Toast,
event: SelectionKeyEvent, event: SelectionKeyEvent,
clipboard: TuiPlatform["clipboard"], clipboard: ClipboardService,
) { ) {
const selection = renderer.getSelection() const selection = renderer.getSelection()
if (!selection) return if (!selection) return
+59
View File
@@ -0,0 +1,59 @@
import { expect, mock, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
import { createEventSource, createFetch, directory } from "./fixture/tui-sdk"
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const titles: string[] = []
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
titles.push(title)
setTitle(title)
}
const listeners = new Set(process.listeners("SIGHUP"))
const events = createEventSource()
const calls = createFetch()
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
let disposes = 0
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
url: "http://test",
directory,
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: events.source,
args: {},
pluginHost: {
async start() {
started()
},
async dispose() {
disposes++
},
},
}).pipe(Effect.provide(Global.defaultLayer)),
)
await ready
process.emit("SIGHUP")
await task
expect(setup.renderer.isDestroyed).toBe(true)
expect(titles.at(-1)).toBe("")
expect(disposes).toBe(1)
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
mock.restore()
}
})
+5 -16
View File
@@ -2,13 +2,12 @@
import { testRender } from "@opentui/solid" import { testRender } from "@opentui/solid"
import { onMount } from "solid-js" import { onMount } from "solid-js"
import { ArgsProvider } from "../../../../src/context/args" import { ArgsProvider } from "../../../../src/context/args"
import { createExit, ExitProvider } from "../../../../src/context/exit"
import { KVProvider, useKV } from "../../../../src/context/kv" import { KVProvider, useKV } from "../../../../src/context/kv"
import { ProjectProvider, useProject } from "../../../../src/context/project" import { ProjectProvider, useProject } from "../../../../src/context/project"
import { SDKProvider } from "../../../../src/context/sdk" import { SDKProvider } from "../../../../src/context/sdk"
import { SyncProvider, useSync } from "../../../../src/context/sync" import { SyncProvider, useSync } from "../../../../src/context/sync"
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk" import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
import { TestTuiEnvironmentProvider } from "../../../fixture/tui-environment" import { TestTuiContexts } from "../../../fixture/tui-environment"
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk" export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
export async function wait(fn: () => boolean, timeout = 2000) { export async function wait(fn: () => boolean, timeout = 2000) {
@@ -44,32 +43,22 @@ export async function mount(override?: FetchHandler, state?: string) {
} }
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiEnvironmentProvider paths={state ? { state } : undefined}> <TestTuiContexts paths={state ? { state } : undefined}>
<ArgsProvider> <ArgsProvider>
<ExitProvider exit={createExit(async () => {})}>
<KVProvider> <KVProvider>
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}> <SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
<ProjectProvider> <ProjectProvider>
<SyncFixtureProvider> <SyncProvider>
<Probe /> <Probe />
</SyncFixtureProvider> </SyncProvider>
</ProjectProvider> </ProjectProvider>
</SDKProvider> </SDKProvider>
</KVProvider> </KVProvider>
</ExitProvider>
</ArgsProvider> </ArgsProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
)) ))
await ready await ready
await wait(() => sync.status === "complete") await wait(() => sync.status === "complete")
return { app, emit: events.emit, kv, project, sync, session: calls.session } return { app, emit: events.emit, kv, project, sync, session: calls.session }
} }
function SyncFixtureProvider(props: { children: import("solid-js").JSX.Element }) {
return (
<SyncProvider kv={useKV()} logger={{ error() {} }}>
{props.children}
</SyncProvider>
)
}
@@ -9,7 +9,7 @@ import { onCleanup } from "solid-js"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import type { TuiKeybind } from "../../../src/config/keybind" import type { TuiKeybind } from "../../../src/config/keybind"
import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
async function wait(fn: () => boolean, timeout = 2000) { async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now() const start = Date.now()
@@ -57,7 +57,7 @@ async function mountPrompt(input: {
onCleanup(off) onCleanup(off)
return ( return (
<TestTuiEnvironmentProvider <TestTuiContexts
directory={input.root} directory={input.root}
paths={{ paths={{
home: input.root, home: input.root,
@@ -78,7 +78,7 @@ async function mountPrompt(input: {
</KVProvider> </KVProvider>
</TuiConfigProvider> </TuiConfigProvider>
</OpencodeKeymapProvider> </OpencodeKeymapProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
) )
} }
@@ -8,7 +8,7 @@ import { KVProvider } from "../../../src/context/kv"
import { ThemeProvider } from "../../../src/context/theme" import { ThemeProvider } from "../../../src/context/theme"
import { TuiConfigProvider } from "../../../src/config" import { TuiConfigProvider } from "../../../src/config"
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree" import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
import { import {
allExpandedFileTreeDirectories, allExpandedFileTreeDirectories,
buildFileTree, buildFileTree,
@@ -180,13 +180,13 @@ async function captureSettledFrame(app: Awaited<ReturnType<typeof testRender>>)
function withTheme(component: () => JSX.Element) { function withTheme(component: () => JSX.Element) {
return ( return (
<TestTuiEnvironmentProvider> <TestTuiContexts>
<TuiConfigProvider config={createTuiResolvedConfig()}> <TuiConfigProvider config={createTuiResolvedConfig()}>
<KVProvider> <KVProvider>
<ThemeProvider mode="dark">{component()}</ThemeProvider> <ThemeProvider mode="dark">{component()}</ThemeProvider>
</KVProvider> </KVProvider>
</TuiConfigProvider> </TuiConfigProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
) )
} }
@@ -13,7 +13,7 @@ import { OpencodeKeymapProvider } from "../../../src/keymap"
import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer" import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
test("closing the diff viewer returns to the route it opened from", async () => { test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([]) const viewer = await renderDiffViewer([])
@@ -152,7 +152,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
commands.get("diff.open")?.run?.({} as never) commands.get("diff.open")?.run?.({} as never)
return ( return (
<TestTuiEnvironmentProvider> <TestTuiContexts>
<OpencodeKeymapProvider keymap={keymap}> <OpencodeKeymapProvider keymap={keymap}>
<TuiConfigProvider config={config}> <TuiConfigProvider config={config}>
<KVProvider> <KVProvider>
@@ -162,7 +162,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
</KVProvider> </KVProvider>
</TuiConfigProvider> </TuiConfigProvider>
</OpencodeKeymapProvider> </OpencodeKeymapProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
) )
} }
+15 -15
View File
@@ -7,7 +7,7 @@ import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk" import { SDKProvider } from "../../../src/context/sdk"
import { SyncProviderV2, useSyncV2 } from "../../../src/context/sync-v2" import { SyncProviderV2, useSyncV2 } from "../../../src/context/sync-v2"
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
async function wait(fn: () => boolean, timeout = 2000) { async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now() const start = Date.now()
@@ -43,7 +43,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
} }
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiEnvironmentProvider> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
@@ -51,7 +51,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
</SyncProviderV2> </SyncProviderV2>
</ProjectProvider> </ProjectProvider>
</SDKProvider> </SDKProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
)) ))
try { try {
@@ -172,7 +172,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
} }
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiEnvironmentProvider> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
@@ -180,7 +180,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
</SyncProviderV2> </SyncProviderV2>
</ProjectProvider> </ProjectProvider>
</SDKProvider> </SDKProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
)) ))
try { try {
@@ -236,7 +236,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
} }
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiEnvironmentProvider> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
@@ -244,7 +244,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
</SyncProviderV2> </SyncProviderV2>
</ProjectProvider> </ProjectProvider>
</SDKProvider> </SDKProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
)) ))
try { try {
@@ -284,7 +284,7 @@ test("sync v2 projects live context updates with their message ID", async () =>
} }
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiEnvironmentProvider> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
@@ -292,7 +292,7 @@ test("sync v2 projects live context updates with their message ID", async () =>
</SyncProviderV2> </SyncProviderV2>
</ProjectProvider> </ProjectProvider>
</SDKProvider> </SDKProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
)) ))
try { try {
@@ -339,7 +339,7 @@ test("sync v2 preserves live events while snapshot hydration is in flight", asyn
} }
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiEnvironmentProvider> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
@@ -347,7 +347,7 @@ test("sync v2 preserves live events while snapshot hydration is in flight", asyn
</SyncProviderV2> </SyncProviderV2>
</ProjectProvider> </ProjectProvider>
</SDKProvider> </SDKProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
)) ))
try { try {
@@ -389,7 +389,7 @@ test("sync v2 replaces stale cached rows while preserving in-flight live rows",
} }
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiEnvironmentProvider> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
@@ -397,7 +397,7 @@ test("sync v2 replaces stale cached rows while preserving in-flight live rows",
</SyncProviderV2> </SyncProviderV2>
</ProjectProvider> </ProjectProvider>
</SDKProvider> </SDKProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
)) ))
try { try {
@@ -458,7 +458,7 @@ test("sync v2 preserves snapshot order and metadata for in-flight updates", asyn
} }
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiEnvironmentProvider> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
@@ -466,7 +466,7 @@ test("sync v2 preserves snapshot order and metadata for in-flight updates", asyn
</SyncProviderV2> </SyncProviderV2>
</ProjectProvider> </ProjectProvider>
</SDKProvider> </SDKProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
)) ))
try { try {
+3 -3
View File
@@ -7,7 +7,7 @@ import { ProjectProvider, useProject } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk" import { SDKProvider } from "../../../src/context/sdk"
import { useEvent } from "../../../src/context/event" import { useEvent } from "../../../src/context/event"
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk" import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
const projectID = "proj_test" const projectID = "proj_test"
@@ -60,7 +60,7 @@ async function mount() {
}) })
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiEnvironmentProvider> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider> <ProjectProvider>
<Probe <Probe
@@ -74,7 +74,7 @@ async function mount() {
/> />
</ProjectProvider> </ProjectProvider>
</SDKProvider> </SDKProvider>
</TestTuiEnvironmentProvider> </TestTuiContexts>
)) ))
await ready await ready
+19
View File
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test"
import { copyCommand } from "../src/clipboard"
test("prefers Wayland clipboard when available", () => {
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
})
test("uses osascript on macOS", () => {
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
})
test("falls back through X11 clipboard commands", () => {
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
})
test("returns undefined when native clipboard is unavailable", () => {
expect(copyCommand("linux", false, () => false)).toBeUndefined()
})
+23
View File
@@ -0,0 +1,23 @@
import { afterEach, expect, test } from "bun:test"
import { openEditor } from "../src/editor"
const editor = process.env.EDITOR
const visual = process.env.VISUAL
afterEach(() => {
process.env.EDITOR = editor
process.env.VISUAL = visual
})
test("rejects when the external editor cannot start", async () => {
delete process.env.VISUAL
process.env.EDITOR = "opencode-editor-that-does-not-exist"
const renderer = {
suspend() {},
resume() {},
requestRender() {},
currentRenderBuffer: { clear() {} },
}
await expect(openEditor({ value: "original", renderer: renderer as never })).rejects.toThrow()
})
+19 -29
View File
@@ -1,42 +1,32 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { createTuiEnvironment, TuiEnvironmentProvider, type TuiEnvironment } from "../../src/runtime" import {
TuiPathsProvider,
TuiStartupProvider,
TuiTerminalEnvironmentProvider,
type TuiPaths,
} from "../../src/context/runtime"
import type { ParentProps } from "solid-js" import type { ParentProps } from "solid-js"
export function TestTuiEnvironmentProvider( export function TestTuiContexts(
props: ParentProps<{ props: ParentProps<{
cwd?: string cwd?: string
directory?: string directory?: string
paths?: Partial<TuiEnvironment["paths"]> paths?: Partial<TuiPaths>
capabilities?: Partial<TuiEnvironment["capabilities"]>
editor?: Partial<TuiEnvironment["editor"]>
}>, }>,
) { ) {
return ( return (
<TuiEnvironmentProvider <TuiPathsProvider
value={createTuiEnvironment({ value={{
cwd: props.cwd ?? props.directory ?? "/tmp/opencode/packages/tui", cwd: props.cwd ?? props.directory ?? "/tmp/opencode/packages/tui",
platform: "linux", home: "/tmp/opencode/home",
paths: { state: "/tmp/opencode/state",
home: "/tmp/opencode/home", worktree: "/tmp/opencode",
state: "/tmp/opencode/state", ...props.paths,
worktree: "/tmp/opencode", }}
...props.paths,
},
capabilities: {
mouse: true,
copyOnSelect: true,
terminalTitle: false,
terminalSuspend: false,
workspaces: false,
showTimeToFirstDraw: false,
...props.capabilities,
},
terminal: {},
editor: { zedTerminal: false, ...props.editor },
skipInitialLoading: false,
})}
> >
{props.children} <TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
</TuiEnvironmentProvider> <TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
) )
} }
+2 -4
View File
@@ -1,8 +1,6 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { createRenderer, createTuiRenderer, mount, run, tui } from "../src" import { run } from "../src"
test("exports the canonical application lifecycle", () => { test("exports the canonical application lifecycle", () => {
expect(run).toBe(tui) expect(typeof run).toBe("function")
expect(createRenderer).toBe(createTuiRenderer)
expect(typeof mount).toBe("function")
}) })
-40
View File
@@ -1,40 +0,0 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { TuiPlatformProvider, useTuiPlatform, type TuiPlatform } from "../src/platform"
test("provides host platform operations", async () => {
const platform: TuiPlatform = {
files: {
readText: async (path) => `text:${path}`,
readBytes: async () => new Uint8Array([1, 2, 3]),
mime: async () => "text/plain",
},
}
function Consumer() {
const value = useTuiPlatform()
return <text>{value.clipboard ? "clipboard" : "files-only"}</text>
}
const app = await testRender(
() => (
<TuiPlatformProvider value={platform}>
<Consumer />
</TuiPlatformProvider>
),
{ width: 20, height: 3 },
)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("files-only")
expect(await platform.files.readText("file.txt")).toBe("text:file.txt")
expect(await platform.files.readBytes("file.bin")).toEqual(new Uint8Array([1, 2, 3]))
} finally {
app.renderer.destroy()
}
})
test("requires a platform provider", () => {
expect(() => useTuiPlatform()).toThrow("TuiPlatformProvider is missing")
})
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { readLocalAttachment } from "../../src/component/prompt/local-attachment" import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
import type { PlatformFiles } from "../../src/platform" import type { LocalFiles } from "../../src/component/prompt/local-attachment"
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): PlatformFiles { function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
return { return {
mime: async () => input.mime, mime: async () => input.mime,
readText: async () => input.text ?? "", readText: async () => input.text ?? "",
@@ -12,7 +12,7 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Plat
describe("prompt local attachments", () => { describe("prompt local attachments", () => {
test("reads SVG attachments as text", async () => { test("reads SVG attachments as text", async () => {
expect(await readLocalAttachment(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({ expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
type: "text", type: "text",
mime: "image/svg+xml", mime: "image/svg+xml",
content: "<svg />", content: "<svg />",
@@ -21,7 +21,7 @@ describe("prompt local attachments", () => {
test("reads image and PDF attachments as bytes", async () => { test("reads image and PDF attachments as bytes", async () => {
const content = new Uint8Array([1, 2, 3]) const content = new Uint8Array([1, 2, 3])
expect(await readLocalAttachment(files({ mime: "application/pdf", bytes: content }), "/tmp/file.pdf")).toEqual({ expect(await readLocalAttachmentWith(files({ mime: "application/pdf", bytes: content }), "/tmp/file.pdf")).toEqual({
type: "binary", type: "binary",
mime: "application/pdf", mime: "application/pdf",
content, content,
@@ -29,9 +29,9 @@ describe("prompt local attachments", () => {
}) })
test("ignores unsupported and unreadable local files", async () => { test("ignores unsupported and unreadable local files", async () => {
expect(await readLocalAttachment(files({ mime: "text/plain" }), "/tmp/file.txt")).toBeUndefined() expect(await readLocalAttachmentWith(files({ mime: "text/plain" }), "/tmp/file.txt")).toBeUndefined()
expect( expect(
await readLocalAttachment( await readLocalAttachmentWith(
{ {
...files({ mime: "image/png" }), ...files({ mime: "image/png" }),
readBytes: async () => Promise.reject(new Error("missing")), readBytes: async () => Promise.reject(new Error("missing")),
+13 -38
View File
@@ -1,14 +1,10 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid" import { testRender } from "@opentui/solid"
import { abbreviateHome } from "../src/runtime"
import { import {
abbreviateHome, TuiPathsProvider,
createTuiBuildInfo, useTuiPaths,
createTuiEnvironment, } from "../src/context/runtime"
TuiBuildInfoProvider,
TuiEnvironmentProvider,
useTuiBuildInfo,
useTuiEnvironment,
} from "../src/runtime"
test("abbreviates paths within home boundaries", () => { test("abbreviates paths within home boundaries", () => {
expect(abbreviateHome("/home/test", "/home/test")).toBe("~") expect(abbreviateHome("/home/test", "/home/test")).toBe("~")
@@ -17,48 +13,27 @@ test("abbreviates paths within home boundaries", () => {
expect(abbreviateHome("/tmp/project", "/home/test")).toBe("/tmp/project") expect(abbreviateHome("/tmp/project", "/home/test")).toBe("/tmp/project")
}) })
test("provides immutable runtime inputs", async () => { test("provides focused immutable runtime inputs", async () => {
const environment = createTuiEnvironment({ let paths: ReturnType<typeof useTuiPaths>
cwd: "/work",
platform: "linux",
paths: { home: "/home/test", state: "/state", worktree: "/data/worktree" },
capabilities: {
mouse: true,
copyOnSelect: true,
terminalTitle: true,
terminalSuspend: true,
workspaces: false,
showTimeToFirstDraw: false,
},
terminal: { multiplexer: "tmux", displayServer: "wayland" },
editor: { command: "vim", port: 4242, zedTerminal: false },
skipInitialLoading: false,
})
const build = createTuiBuildInfo({ version: "1.2.3", channel: "beta" })
function Runtime() { function Runtime() {
const runtime = useTuiEnvironment() paths = useTuiPaths()
const info = useTuiBuildInfo() return <text>{paths.cwd}</text>
return <text>{`${runtime.cwd} ${runtime.editor.command} ${info.version}`}</text>
} }
const app = await testRender( const app = await testRender(
() => ( () => (
<TuiEnvironmentProvider value={environment}> <TuiPathsProvider value={{ cwd: "/work", home: "/home/test", state: "/state", worktree: "/worktree" }}>
<TuiBuildInfoProvider value={build}> <Runtime />
<Runtime /> </TuiPathsProvider>
</TuiBuildInfoProvider>
</TuiEnvironmentProvider>
), ),
{ width: 40, height: 3 }, { width: 40, height: 3 },
) )
try { try {
await app.renderOnce() await app.renderOnce()
expect(app.captureCharFrame()).toContain("/work vim 1.2.3") expect(app.captureCharFrame()).toContain("/work")
expect(Object.isFrozen(environment)).toBe(true) expect(Object.isFrozen(paths!)).toBe(true)
expect(Object.isFrozen(environment.paths)).toBe(true)
expect(Object.isFrozen(build)).toBe(true)
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
+16
View File
@@ -1,6 +1,10 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import type { TerminalColors } from "@opentui/core" import type { TerminalColors } from "@opentui/core"
import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme, terminalMode } from "../src/theme" import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme, terminalMode } from "../src/theme"
import { discoverThemes } from "../src/context/theme"
import { tmpdir } from "./fixture/fixture"
test("addTheme writes into module theme store", () => { test("addTheme writes into module theme store", () => {
const name = `plugin-theme-${Date.now()}` const name = `plugin-theme-${Date.now()}`
@@ -63,3 +67,15 @@ test("terminalMode derives mode from refreshed background", () => {
test("terminalMode does not derive mode from ANSI slot zero", () => { test("terminalMode does not derive mode from ANSI slot zero", () => {
expect(terminalMode(terminalColors(null, ["#000000"]))).toBeUndefined() expect(terminalMode(terminalColors(null, ["#000000"]))).toBeUndefined()
}) })
test("custom theme precedence follows directory order", async () => {
await using tmp = await tmpdir()
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
await mkdir(path.join(global, "themes"), { recursive: true })
await mkdir(path.join(project, "themes"), { recursive: true })
await writeFile(path.join(global, "themes", "custom.json"), JSON.stringify({ source: "global" }))
await writeFile(path.join(project, "themes", "custom.json"), JSON.stringify({ source: "project" }))
await expect(discoverThemes([global, project])).resolves.toEqual({ custom: { source: "project" } })
})
+6 -9
View File
@@ -1,11 +1,8 @@
import { describe, expect, test } from "bun:test" import { expect, test } from "bun:test"
import { sessionExitSummary } from "../../src/util/presentation" import { sessionEpilogue } from "../../src/util/presentation"
describe("util.presentation", () => { test("formats session continuation summary", () => {
test("formats the ANSI session exit summary", () => { const epilogue = sessionEpilogue({ title: "A session", sessionID: "ses_123" })
const summary = sessionExitSummary({ title: "A session", sessionID: "ses_123" }) expect(epilogue).toContain("A session")
expect(summary.split("\n")).toHaveLength(8) expect(epilogue).toContain("opencode -s ses_123")
expect(summary).toContain("\x1b[90mSession \x1b[0m\x1b[1mA session\x1b[0m")
expect(summary).toContain("\x1b[1mopencode -s ses_123\x1b[0m")
})
}) })
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test"
import { destroyRenderer } from "../../src/util/renderer"
test("clears the terminal title before destroying the renderer", () => {
const calls: string[] = []
destroyRenderer({
isDestroyed: false,
setTerminalTitle(title) {
calls.push(`title:${title}`)
},
destroy() {
calls.push("destroy")
},
})
expect(calls).toEqual(["title:", "destroy"])
})
test("still clears the title after renderer destruction", () => {
const calls: string[] = []
destroyRenderer({
isDestroyed: true,
setTerminalTitle(title) {
calls.push(`title:${title}`)
},
destroy() {
calls.push("destroy")
},
})
expect(calls).toEqual(["title:"])
})