diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index 50ad2b689e..aae4cdcbae 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -95,6 +95,27 @@ jobs: # macOS has no other job in the repo, so it's safe to save here. save-cache: ${{ matrix.name == 'macos' }} + # Embed a models.dev catalog snapshot into the binary so air-gapped + # users can start opencode without reaching https://models.dev at + # runtime (see packages/opencode/script/generate.ts fallback order). + - name: Fetch models.dev Snapshot + if: inputs.platforms == '' || contains(inputs.platforms, matrix.name) + run: | + snapshot_path="$RUNNER_TEMP/models-dev-api.json" + if command -v cygpath &>/dev/null; then + snapshot_path="$(cygpath -m "$snapshot_path")" + fi + for attempt in 1 2 3; do + if curl -fsSL --max-time 30 https://models.dev/api.json -o "$snapshot_path"; then + echo "models.dev snapshot downloaded (attempt $attempt)" + echo "MODELS_DEV_API_JSON=$snapshot_path" >> "$GITHUB_ENV" + exit 0 + fi + echo "models.dev download failed (attempt $attempt), retrying..." + sleep 5 + done + echo "::warning::Failed to download models.dev api.json; build will fall back to @opencode-ai/models snapshot or an empty catalog" + - name: Build CLI if: inputs.platforms == '' || contains(inputs.platforms, matrix.name) run: ./packages/opencode/script/build.ts --single --skip-install diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 313c78d815..3078d9898f 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -1,5 +1,5 @@ import path from "path" -import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" +import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/schema/models-dev" import { Global } from "./global" @@ -142,6 +142,14 @@ export const layer = Layer.effect( ) const ttl = Duration.minutes(5) const lockKey = `models-dev:${filepath}` + let fetchFailed = false + let emptyFallback = false + + const publishFetchFailed = Effect.fn("ModelsDev.publishFetchFailed")(function* () { + if (fetchFailed) return + fetchFailed = true + yield* events.publish(Event.FetchFailed, { source }) + }) const fresh = Effect.fnUntraced(function* () { const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) @@ -173,9 +181,11 @@ export const layer = Layer.effect( Effect.map((v) => v as Record | undefined), ) - const loadSnapshot = Effect.sync(() => - typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, - ) + const loadSnapshot = Effect.sync(() => { + if (typeof OPENCODE_MODELS_DEV === "undefined") return undefined + if (Object.keys(OPENCODE_MODELS_DEV).length === 0) return undefined + return OPENCODE_MODELS_DEV + }) const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { const text = yield* fetchApi() @@ -204,7 +214,20 @@ export const layer = Layer.effect( yield* Flock.effect(lockKey) return yield* fetchAndWrite() }), + ).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logError("Failed to fetch models.dev", { cause: cause }).pipe( + Effect.andThen(publishFetchFailed()), + Effect.as(undefined), + ), + ), ) + if (!text) { + emptyFallback = true + return {} + } return JSON.parse(text) as Record }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) @@ -221,11 +244,19 @@ export const layer = Layer.effect( // our outer check and lock acquisition. if (!force && (yield* fresh())) return yield* fetchAndWrite() + fetchFailed = false + emptyFallback = false yield* invalidate yield* events.publish(Event.Refreshed, {}) }), ).pipe( - Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logError("Failed to fetch models.dev", { cause: cause }).pipe( + Effect.andThen(emptyFallback ? publishFetchFailed() : Effect.void), + ), + ), Effect.ignore, ) }) diff --git a/packages/core/test/models-dev.test.ts b/packages/core/test/models-dev.test.ts new file mode 100644 index 0000000000..d07b097a00 --- /dev/null +++ b/packages/core/test/models-dev.test.ts @@ -0,0 +1,133 @@ +import path from "path" +import { afterEach, describe, expect, test } from "bun:test" +import { Effect, Fiber, Layer, Stream } from "effect" +import type { Scope } from "effect/Scope" +import { EventV2 } from "@opencode-ai/core/event" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ModelsDev } from "@opencode-ai/core/models-dev" + +const eventLayer = EventV2.defaultLayer +const layer = ModelsDev.defaultLayer.pipe(Layer.provideMerge(eventLayer)) +const original = { + path: Flag.OPENCODE_MODELS_PATH, + url: Flag.OPENCODE_MODELS_URL, + disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH, +} + +const fixture = { + acme: { + api: "https://acme.test", + name: "Acme", + env: ["ACME_API_KEY"], + id: "acme", + models: { + "acme-large": { + id: "acme-large", + name: "Acme Large", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128_000, output: 4096 }, + }, + }, + }, +} satisfies Record + +afterEach(() => { + Flag.OPENCODE_MODELS_PATH = original.path + Flag.OPENCODE_MODELS_URL = original.url + Flag.OPENCODE_DISABLE_MODELS_FETCH = original.disabled +}) + +describe("ModelsDev", () => { + test("returns empty catalog and publishes fetch_failed when offline without cache or snapshot", () => + runWithFlags( + { + path: path.join(import.meta.dir, "fixtures", "missing-models-dev.json"), + url: "http://127.0.0.1:9", + disabled: false, + }, + Effect.gen(function* () { + const events = yield* EventV2.Service + const failed = yield* events.subscribe(ModelsDev.Event.FetchFailed).pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ) + const models = yield* ModelsDev.Service.use((service) => service.get()) + + expect(models).toEqual({}) + expect((yield* Fiber.join(failed)).map((event) => event.data)).toEqual([{ source: "http://127.0.0.1:9" }]) + }), + ), + ) + + test("loads disk cache before network", () => + runWithFlags( + { + path: path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json"), + url: "http://127.0.0.1:9", + disabled: false, + }, + Effect.gen(function* () { + const models = yield* ModelsDev.Service.use((service) => service.get()) + + expect(Object.keys(models)).toEqual(["acme", "local"]) + expect(models.acme.name).toEqual("Acme") + }), + ), + ) + + test("successful refresh after fallback repopulates the cached catalog", async () => { + let online = false + const server = Bun.serve({ + port: 0, + fetch() { + if (!online) return new Response("offline", { status: 503 }) + return Response.json(fixture) + }, + }) + try { + await runWithFlags( + { + path: path.join(import.meta.dir, "fixtures", "missing-models-dev-refresh.json"), + url: server.url.origin, + disabled: false, + }, + Effect.gen(function* () { + const events = yield* EventV2.Service + const refreshed = yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ) + const service = yield* ModelsDev.Service + + expect(yield* service.get()).toEqual({}) + online = true + yield* service.refresh(true) + expect(yield* Fiber.join(refreshed)).toHaveLength(1) + expect(yield* service.get()).toEqual(fixture) + }), + ) + } finally { + server.stop(true) + } + }) +}) + +function run(effect: Effect.Effect) { + return Effect.runPromise(effect.pipe(Effect.scoped, Effect.provide(layer))) +} + +function runWithFlags( + next: { path: string | undefined; url: string | undefined; disabled: boolean }, + effect: Effect.Effect, +) { + Flag.OPENCODE_MODELS_PATH = next.path + Flag.OPENCODE_MODELS_URL = next.url + Flag.OPENCODE_DISABLE_MODELS_FETCH = next.disabled + return run(effect) +} diff --git a/packages/opencode/script/generate.ts b/packages/opencode/script/generate.ts index 91a2d30eb7..dfbdc16e75 100644 --- a/packages/opencode/script/generate.ts +++ b/packages/opencode/script/generate.ts @@ -8,7 +8,28 @@ const dir = path.resolve(__dirname, "..") process.chdir(dir) const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" -export const modelsData = process.env.MODELS_DEV_API_JSON - ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() - : await fetch(`${modelsUrl}/api.json`).then((x) => x.text()) -console.log("Loaded models.dev snapshot") + +async function loadModelsData() { + if (process.env.MODELS_DEV_API_JSON) { + console.log("Loaded models.dev snapshot from MODELS_DEV_API_JSON") + return Bun.file(process.env.MODELS_DEV_API_JSON).text() + } + + const response = await fetch(`${modelsUrl}/api.json`).catch(() => undefined) + if (response?.ok) { + console.log("Loaded models.dev snapshot from api.json") + return response.text() + } + + const snapshotSpecifier = "@opencode-ai/models/snapshot" + const snapshot = await import(snapshotSpecifier).catch(() => undefined) + if (snapshot) { + console.log("Loaded models.dev snapshot from @opencode-ai/models") + return JSON.stringify(snapshot.providers) + } + + console.log("Loaded no models.dev snapshot") + return "undefined" +} + +export const modelsData = await loadModelsData() diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 088aa2fd76..71230c8f8d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1103,7 +1103,7 @@ export class InitError extends Schema.TaggedErrorClass()("ProviderIni export class NoProvidersError extends Schema.TaggedErrorClass()("ProviderNoProvidersError", {}) { override get message() { - return "No providers are available" + return "No providers are available. Configure a provider or restore network access to load the model catalog." } static isInstance(input: unknown): input is NoProvidersError { @@ -1115,7 +1115,7 @@ export class NoModelsError extends Schema.TaggedErrorClass()("Pro providerID: ProviderV2.ID, }) { override get message() { - return `No models are available for provider: ${this.providerID}` + return `No models are available for provider: ${this.providerID}. Configure a model for this provider or restore network access to load the model catalog.` } static isInstance(input: unknown): input is NoModelsError { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e1e949d222..3a44084c22 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -786,7 +786,16 @@ export const layer = Layer.effect( .findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model) .pipe(Effect.orDie) if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model - return yield* provider.defaultModel().pipe(Effect.orDie) + const exit = yield* provider.defaultModel().pipe(Effect.exit) + if (Exit.isSuccess(exit)) return exit.value + const err = Cause.squash(exit.cause) + if (Provider.NoProvidersError.isInstance(err) || Provider.NoModelsError.isInstance(err)) { + yield* events.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ message: err.message }).toObject(), + }) + } + return yield* Effect.die(err) }) const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { diff --git a/packages/schema/src/models-dev.ts b/packages/schema/src/models-dev.ts index 4432bc4591..8b7f32cbd4 100644 --- a/packages/schema/src/models-dev.ts +++ b/packages/schema/src/models-dev.ts @@ -1,9 +1,18 @@ export * as ModelsDev from "./models-dev" +import { Schema } from "effect" import { define, inventory } from "./event" const Refreshed = define({ type: "models-dev.refreshed", schema: {}, }) -export const Event = { Refreshed, Definitions: inventory(Refreshed) } + +const FetchFailed = define({ + type: "models-dev.fetch_failed", + schema: { + source: Schema.String, + }, +}) + +export const Event = { Refreshed, FetchFailed, Definitions: inventory(Refreshed, FetchFailed) } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b7949923c2..c677ea42ca 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -6,6 +6,7 @@ export type ClientOptions = { export type Event = | EventModelsDevRefreshed + | EventModelsDevFetchFailed | EventIntegrationUpdated | EventIntegrationConnectionUpdated | EventCatalogUpdated @@ -756,6 +757,13 @@ export type GlobalEvent = { [key: string]: unknown } } + | { + id: string + type: "models-dev.fetch_failed" + properties: { + source: string + } + } | { id: string type: "integration.updated" @@ -2829,6 +2837,7 @@ export type ProviderNotFoundError = { export type V2Event = | V2EventModelsDevRefreshed + | V2EventModelsDevFetchFailed | V2EventIntegrationUpdated | V2EventIntegrationConnectionUpdated | V2EventCatalogUpdated @@ -4395,6 +4404,23 @@ export type V2EventModelsDevRefreshed = { } } +export type V2EventModelsDevFetchFailed = { + id: string + metadata?: { + [key: string]: unknown + } + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + type: "models-dev.fetch_failed" + data: { + source: string + } +} + export type V2EventIntegrationUpdated = { id: string metadata?: { @@ -6260,6 +6286,14 @@ export type EventModelsDevRefreshed = { } } +export type EventModelsDevFetchFailed = { + id: string + type: "models-dev.fetch_failed" + properties: { + source: string + } +} + export type EventIntegrationUpdated = { id: string type: "integration.updated" diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57b99c45ce..90824438ab 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -83,6 +83,7 @@ import * as TuiAudio from "./audio" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" +import { TuiLog } from "./util/log" const appGlobalBindingCommands = [ "session.list", @@ -182,6 +183,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const result = yield* Effect.scoped( Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => TuiLog.installConsoleRedirect()), + (restoreConsole) => Effect.sync(restoreConsole), + ) const renderer = yield* Effect.acquireRelease( Effect.tryPromise(() => createCliRenderer({ @@ -214,7 +219,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { try { await input.pluginHost.dispose() } catch (error) { - console.error("Failed to dispose TUI plugins", error) + TuiLog.write("error", "Failed to dispose TUI plugins", error) } }), ) @@ -403,7 +408,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dispose: () => attention.dispose(), }) .catch((error) => { - console.error("Failed to load TUI plugins", error) + TuiLog.write("error", "Failed to load TUI plugins", error) }) .finally(() => { setReady(true) @@ -999,8 +1004,17 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) }) + event.on("models-dev.fetch_failed", (evt) => { + toast.show({ + variant: "warning", + title: "Model catalog unavailable", + message: `${evt.properties.source} is unreachable. Using cached or configured providers only. See ${TuiLog.logFilePath()} for details.`, + duration: 8000, + }) + }) + event.on("installation.update-available", async (evt) => { - console.log("installation.update-available", evt) + TuiLog.write("log", "installation.update-available", evt) const version = evt.properties.version const skipped = kv.get("skipped_version") diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index c48c0f8ee1..d662a0b276 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -6,6 +6,7 @@ import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "../ import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" import { useSDK } from "../context/sdk" +import { TuiLog } from "../util/log" function Status(props: { enabled: boolean; loading: boolean }) { const { theme } = useTheme() @@ -60,10 +61,10 @@ export function DialogMcp() { if (status.data) { sync.set("mcp", status.data) } else { - console.error("Failed to refresh MCP status: no data returned") + TuiLog.write("error", "Failed to refresh MCP status: no data returned") } } catch (error) { - console.error("Failed to toggle MCP:", error) + TuiLog.write("error", "Failed to toggle MCP", error) } finally { setLoading(null) } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index aa002080b1..e6c6d86ce1 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -56,6 +56,7 @@ import { useTuiConfig } from "../../config" import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" +import { TuiLog } from "../../util/log" export type PromptProps = { sessionID?: string @@ -1004,10 +1005,11 @@ export function Prompt(props: PromptProps) { if (res.error) { if (finishMoveProgress) move.finishSubmit() - console.log("Creating a session failed:", res.error) + TuiLog.write("error", "Creating a session failed", res.error) toast.show({ - message: "Creating a session failed. Open console for more details.", + title: "Creating a session failed", + message: errorMessage(res.error), variant: "error", }) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 9b2e58907a..fa7dee3688 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -20,6 +20,7 @@ import type { } from "@opencode-ai/sdk/v2" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" +import { TuiLog } from "../util/log" import { useSDK } from "./sdk" import { useEvent } from "./event" import { createSignal, onCleanup, onMount } from "solid-js" @@ -560,7 +561,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.skill.refresh(), ]).then((settled) => { for (const failure of settled.filter((item) => item.status === "rejected")) - console.error("Failed to refresh default location data", failure.reason) + TuiLog.write("error", "Failed to refresh default location data", failure.reason) }) }) diff --git a/packages/tui/src/context/kv.tsx b/packages/tui/src/context/kv.tsx index 7b90c95f59..7fd5b0dfbb 100644 --- a/packages/tui/src/context/kv.tsx +++ b/packages/tui/src/context/kv.tsx @@ -5,6 +5,7 @@ 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 { TuiLog } from "../util/log" import path from "path" export const { use: useKV, provider: KVProvider } = createSimpleContext({ @@ -24,7 +25,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ setStore(x) }) .catch((error) => { - console.error("Failed to read KV state", { error }) + TuiLog.write("error", "Failed to read KV state", { error }) }) .finally(() => { setReady(true) @@ -57,7 +58,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ write = write .then(() => Flock.withLock(lock, () => writeJsonAtomic(file, snapshot))) .catch((error) => { - console.error("Failed to write KV state", { error }) + TuiLog.write("error", "Failed to write KV state", { error }) }) }, } diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 8ca874a2f0..abcbd206b6 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -32,6 +32,7 @@ import { useArgs } from "./args" import { batch, onMount } from "solid-js" import path from "path" import { useKV } from "./kv" +import { TuiLog } from "../util/log" const emptyConsoleState: ConsoleState = { consoleManagedProviders: [], @@ -534,7 +535,7 @@ export const { }) }) .catch(async (e) => { - console.error("tui bootstrap failed", { + TuiLog.write("error", "tui bootstrap failed", { error: e instanceof Error ? e.message : String(e), name: e instanceof Error ? e.name : undefined, stack: e instanceof Error ? e.stack : undefined, diff --git a/packages/tui/src/plugin/command-shim.ts b/packages/tui/src/plugin/command-shim.ts index 61eb833fe7..ed0b533d69 100644 --- a/packages/tui/src/plugin/command-shim.ts +++ b/packages/tui/src/plugin/command-shim.ts @@ -2,6 +2,7 @@ import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui" import { TuiKeybind } from "../config/keybind" import type { DialogContext } from "../ui/dialog" +import { TuiLog } from "../util/log" const COMMAND_PALETTE_SHOW = "command.palette.show" const warned = new Set() @@ -13,7 +14,7 @@ type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"] function warnCommandShim(api: string, replacement: string) { // Warn v1 plugins about deprecated `api.command`; remove this shim path in v2. - console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement }) + TuiLog.write("warn", "[tui.plugin] deprecated TUI plugin API", { api, replacement }) } function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog { diff --git a/packages/tui/src/plugin/slots.tsx b/packages/tui/src/plugin/slots.tsx index f5ae73f030..2967883d77 100644 --- a/packages/tui/src/plugin/slots.tsx +++ b/packages/tui/src/plugin/slots.tsx @@ -2,6 +2,7 @@ import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@op import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid" import { createSignal } from "solid-js" import { isRecord } from "../util/record" +import { TuiLog } from "../util/log" type RuntimeSlotMap = TuiSlotMap> type SlotView = (props: TuiSlotProps) => JSX.Element | null @@ -35,7 +36,7 @@ export function createSlots() { { theme: api.theme }, { onPluginError(event) { - console.error("[tui.slot] plugin error", { + TuiLog.write("error", "[tui.slot] plugin error", { plugin: event.pluginId, slot: event.slot, phase: event.phase, diff --git a/packages/tui/src/util/log.ts b/packages/tui/src/util/log.ts new file mode 100644 index 0000000000..1018978def --- /dev/null +++ b/packages/tui/src/util/log.ts @@ -0,0 +1,49 @@ +import { Global } from "@opencode-ai/core/global" +import fs from "fs/promises" +import path from "path" +import { formatWithOptions } from "util" + +type ConsoleMethod = "log" | "warn" | "error" + +const levelName = { + log: "INFO", + warn: "WARN", + error: "ERROR", +} satisfies Record + +const original = { + log: console.log, + warn: console.warn, + error: console.error, +} + +let installed = false + +export function logFilePath() { + return path.join(Global.Path.log, "opencode.log") +} + +export function write(level: ConsoleMethod, ...args: unknown[]) { + void fs + .appendFile( + logFilePath(), + `${new Date().toISOString()} level=${levelName[level]} component=tui ${args.length ? formatWithOptions({ colors: false, depth: 8 }, ...args) : ""}\n`, + ) + .catch(() => {}) +} + +export function installConsoleRedirect() { + if (installed) return () => {} + installed = true + console.log = (...args) => write("log", ...args) + console.warn = (...args) => write("warn", ...args) + console.error = (...args) => write("error", ...args) + return () => { + console.log = original.log + console.warn = original.warn + console.error = original.error + installed = false + } +} + +export * as TuiLog from "./log"