mini: move frontend into tui package (#37754)

This commit is contained in:
Simon Klee 2026-07-19 14:12:22 +02:00 committed by GitHub
commit c50554d907
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 2365 additions and 1488 deletions

View file

@ -0,0 +1,209 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import fs from "node:fs"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
export type InteractiveStdin = {
stdin: NodeJS.ReadStream
cleanup(): void
}
type MiniHost = MiniFrontendInput["host"]
type ModelState = Record<string, unknown> & {
variant?: Record<string, string | undefined>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function state(value: unknown): ModelState {
if (!isRecord(value)) return {}
const variant = isRecord(value.variant)
? Object.fromEntries(
Object.entries(value.variant).flatMap(([key, item]) =>
typeof item === "string" ? ([[key, item]] as const) : [],
),
)
: undefined
return { ...value, variant }
}
function variantKey(model: NonNullable<MiniFrontendInput["model"]>) {
return `${model.providerID}/${model.modelID}`
}
function preferences(statePath: string): MiniHost["preferences"] {
const file = path.join(statePath, "model.json")
const read = () =>
readFile(file, "utf8")
.then((value) => state(JSON.parse(value)))
.catch(() => state(undefined))
return {
async resolveVariant(model) {
if (!model) return
return (await read()).variant?.[variantKey(model)]
},
async saveVariant(model, variant) {
if (!model) return
const current = await read()
const next = { ...current.variant }
if (variant) next[variantKey(model)] = variant
if (!variant) delete next[variantKey(model)]
await mkdir(path.dirname(file), { recursive: true })
.then(() => writeFile(file, JSON.stringify({ ...current, variant: next }, null, 2)))
.catch(() => {})
},
}
}
function signal(name: "SIGINT" | "SIGUSR2"): MiniHost["signals"]["sigint"] {
return {
subscribe(listener) {
let subscribed = true
process.on(name, listener)
return () => {
if (!subscribed) return
subscribed = false
process.off(name, listener)
}
},
}
}
function createTrace(
logPath: string,
diagnostics: Pick<MiniHost["diagnostics"], "pid" | "cwd" | "argv">,
): MiniHost["diagnostics"]["trace"] {
if (!process.env.OPENCODE_DIRECT_TRACE) return
const stamp = new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d+Z$/, "Z")
const target = path.join(logPath, "direct", `${stamp}-${diagnostics.pid}.jsonl`)
const text = (data: unknown) =>
JSON.stringify(data, (_key, value) => (typeof value === "bigint" ? String(value) : value), 0)
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(
path.join(logPath, "direct", "latest.json"),
text({
time: new Date().toISOString(),
...diagnostics,
path: target,
}) + "\n",
)
const trace = {
write(type: string, data?: unknown) {
fs.appendFileSync(
target,
text({
time: new Date().toISOString(),
pid: diagnostics.pid,
type,
data,
}) + "\n",
)
},
}
trace.write("trace.start", {
argv: diagnostics.argv,
cwd: diagnostics.cwd,
path: target,
})
return trace
}
function openTerminalStdin(target: string): NodeJS.ReadStream {
return new ReadStream(fs.openSync(target, "r"))
}
export function resolveInteractiveStdin(
stdin: NodeJS.ReadStream = process.stdin,
open: (target: string) => NodeJS.ReadStream = openTerminalStdin,
platform: NodeJS.Platform = process.platform,
): InteractiveStdin {
if (stdin.isTTY) return { stdin, cleanup() {} }
const target = platform === "win32" ? "CONIN$" : "/dev/tty"
try {
const source = open(target)
let cleaned = false
return {
stdin: source,
cleanup() {
if (cleaned) return
cleaned = true
source.destroy()
},
}
} catch (error) {
throw new Error(INTERACTIVE_INPUT_ERROR, { cause: error })
}
}
/** @internal Exported for owner-local resource cleanup tests. */
export async function usingInteractiveStdin<T>(
run: (terminal: InteractiveStdin) => Promise<T>,
resolve: () => InteractiveStdin = resolveInteractiveStdin,
) {
const terminal = resolve()
try {
return await run(terminal)
} finally {
terminal.cleanup()
}
}
/** @internal Exported for owner-local host capability tests. */
export function createMiniHost(input: {
terminal: InteractiveStdin
directory: string
paths?: MiniHost["paths"]
}): MiniHost {
const paths = input.paths ?? {
home: Global.Path.home,
state: Global.Path.state,
log: Global.Path.log,
}
const diagnostics = {
pid: process.pid,
cwd: input.directory,
argv: process.argv.slice(2),
}
return {
terminal: input.terminal,
platform: process.platform,
stdout: {
write(value) {
process.stdout.write(value)
},
},
files: {
readText: (url) => readFile(new URL(url), "utf8"),
},
editor: {
async open(options) {
const { openEditor } = await import("@opencode-ai/tui/editor")
return openEditor(options)
},
},
paths,
signals: {
sigint: signal("SIGINT"),
sigusr2: signal("SIGUSR2"),
},
startup: {
showTiming: Flag.OPENCODE_SHOW_TTFD,
now: () => performance.now(),
},
diagnostics: {
...diagnostics,
trace: createTrace(paths.log, diagnostics),
},
preferences: preferences(paths.state),
}
}