import { cmd } from "@/cli/cmd/cmd"
import * as prompts from "@clack/prompts"
import { tui } from "./app"
import { requireLogin } from "@/plugin/deveco"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import * as Log from "@opencode-ai/core/util/log"
import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui"
import {
DEVECO_PROCESS_ROLE,
DEVECO_RUN_ID,
ensureRunID,
sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "./validate-session"
import { findDevEcoHomes, isDevEcoHome, loadSavedDevEcoHome, resolveDevEcoHome, saveDevEcoHome } from "@/tool/lib/env"
declare global {
const DEVECO_WORKER_PATH: string
}
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
const customDevEcoHomeOption = "__custom_deveco_home__"
const skipDevEcoHomeOption = "__skip_deveco_home__"
function createWorkerFetch(client: RpcClient): typeof fetch {
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const request = new Request(input, init)
const body = request.body ? await request.text() : undefined
const result = await client.call("fetch", {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body,
})
return new Response(result.body, {
status: result.status,
headers: result.headers,
})
}
return fn as typeof fetch
}
function createEventSource(client: RpcClient): EventSource {
return {
subscribe: async (handler) => {
return client.on<GlobalEvent>("global.event", (e) => {
handler(e)
})
},
}
}
async function target() {
if (typeof DEVECO_WORKER_PATH !== "undefined") return DEVECO_WORKER_PATH
const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url)
if (await Filesystem.exists(fileURLToPath(dist))) return dist
return new URL("./worker.ts", import.meta.url)
}
async function input(value?: string) {
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
if (!value) return piped
if (!piped) return value
return piped + "\n" + value
}
export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) {
const root = Filesystem.resolve(envPWD ?? cwd)
if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project))
return Filesystem.resolve(cwd)
}
async function inputDevEcoHome(): Promise<string> {
const home = await prompts.text({
message: "Enter DEVECO_HOME path",
})
if (prompts.isCancel(home)) process.exit(1)
const resolved = await resolveDevEcoHome(home)
if (resolved) return resolved
UI.println(
UI.Style.TEXT_DANGER_BOLD +
"Invalid DevEco Studio path. Please enter a directory that contains DevEco Studio tools." +
UI.Style.TEXT_NORMAL,
)
return inputDevEcoHome()
}
async function selectDevEcoHome(candidates: string[]): Promise<string | undefined> {
const selected = await prompts.select({
message: candidates.length ? "Please select DevEco Studio path" : "Please configure your DevEco Studio path:",
options: [
...candidates.map((candidate) => ({
label: candidate,
value: candidate,
})),
{
label: "Enter a custom path",
value: customDevEcoHomeOption,
},
{
label: "Skip (DevEco Studio Tools will be unavailable.)",
value: skipDevEcoHomeOption,
},
],
initialValue: candidates[0] ?? customDevEcoHomeOption,
})
if (prompts.isCancel(selected)) process.exit(1)
if (selected === customDevEcoHomeOption) return inputDevEcoHome()
if (selected === skipDevEcoHomeOption) return undefined
return selected
}
async function applyDevEcoHome(home: string) {
const resolved = await saveDevEcoHome(home)
if (resolved) process.env.DEVECO_HOME = resolved
}
async function ensureDevEcoHomeForTuiStartup() {
const configured = process.env.DEVECO_HOME?.trim()
if (configured) {
if (await isDevEcoHome(configured)) return
}
const saved = await loadSavedDevEcoHome()
if (saved) {
process.env.DEVECO_HOME = saved
return
}
const candidates = await findDevEcoHomes()
if (!process.stdin.isTTY || !process.stdout.isTTY) {
if (candidates[0]) await applyDevEcoHome(candidates[0])
return
}
UI.println(
UI.Style.TEXT_DANGER_BOLD +
"DEVECO_HOME environment variable is not configured, features may be unusable." +
UI.Style.TEXT_NORMAL,
)
const selected = await selectDevEcoHome(candidates)
if (selected) await applyDevEcoHome(selected)
}
export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start deveco tui",
builder: (yargs) =>
withNetworkOptions(yargs)
.positional("project", {
type: "string",
describe: "path to start deveco in",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("continue", {
alias: ["c"],
describe: "continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
type: "string",
describe: "session id to continue",
})
.option("fork", {
type: "boolean",
describe: "fork the session when continuing (use with --continue or --session)",
})
.option("prompt", {
type: "string",
describe: "prompt to use",
})
.option("agent", {
type: "string",
describe: "agent to use",
}),
handler: async (args) => {
const loggedIn = await requireLogin()
if (!loggedIn) {
process.exit(1)
}
await ensureDevEcoHomeForTuiStartup()
const unguard = win32InstallCtrlCGuard()
try {
win32DisableProcessedInput()
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exitCode = 1
return
}
const next = resolveThreadDirectory(args.project)
const file = await target()
try {
process.chdir(next)
} catch {
UI.error("Failed to change directory to " + next)
return
}
const cwd = Filesystem.resolve(process.cwd())
const env = sanitizedProcessEnv({
[DEVECO_PROCESS_ROLE]: "worker",
[DEVECO_RUN_ID]: ensureRunID(),
})
const worker = new Worker(file, {
env,
})
worker.onerror = (e) => {
Log.Default.error("thread error", {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
error: e.error,
})
}
const client = Rpc.client<typeof rpc>(worker)
const error = (e: unknown) => {
Log.Default.error("process error", { error: errorMessage(e) })
}
const reload = () => {
client.call("reload", undefined).catch((err) => {
Log.Default.warn("worker reload failed", {
error: errorMessage(err),
})
})
}
process.on("uncaughtException", error)
process.on("unhandledRejection", error)
process.on("SIGUSR2", reload)
let stopped = false
const stop = async () => {
if (stopped) return
stopped = true
process.off("uncaughtException", error)
process.off("unhandledRejection", error)
process.off("SIGUSR2", reload)
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
Log.Default.warn("worker shutdown failed", {
error: errorMessage(error),
})
})
worker.terminate()
}
const prompt = await input(args.prompt)
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const transport = external
? {
url: (await client.call("server", network)).url,
fetch: undefined,
events: undefined,
}
: {
url: "http://deveco.internal",
fetch: createWorkerFetch(client),
events: createEventSource(client),
}
try {
await validateSession({
url: transport.url,
sessionID: args.session,
directory: cwd,
fetch: transport.fetch,
})
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
return
}
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.()
try {
const { tui } = await import("./app")
await tui({
url: transport.url,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
},
})
} finally {
await stop()
}
} finally {
unguard?.()
}
process.exit(0)
},
})