feat(tui): add structured logging

This commit is contained in:
Dax Raad
2026-07-08 15:59:49 -04:00
parent 5b39972947
commit cedf365674
9 changed files with 208 additions and 119 deletions
+12
View File
@@ -18,6 +18,7 @@ export function runTui(
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
return Effect.gen(function* () {
const runFork = Effect.runForkWith(yield* Effect.context())
const options = { baseUrl: transport.url, headers: transport.headers }
const api = OpenCode.make(options)
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
@@ -41,6 +42,17 @@ export function runTui(
reload,
args,
config,
log: (level, message, tags) => {
const effect =
level === "debug"
? Effect.logDebug(message, tags)
: level === "warn"
? Effect.logWarning(message, tags)
: level === "error"
? Effect.logError(message, tags)
: Effect.logInfo(message, tags)
runFork(effect)
},
pluginHost: {
async start(input) {
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
+1
View File
@@ -17,6 +17,7 @@
"./context/epilogue": "./src/context/epilogue.tsx",
"./context/exit": "./src/context/exit.tsx",
"./context/kv": "./src/context/kv.tsx",
"./context/log": "./src/context/log.tsx",
"./context/project": "./src/context/project.tsx",
"./context/runtime": "./src/context/runtime.tsx",
"./context/sdk": "./src/context/sdk.tsx",
+108 -103
View File
@@ -6,6 +6,7 @@ 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 { LogProvider, useLog, type LogSink } from "./context/log"
import { ExitProvider, useExit } from "./context/exit"
import { EpilogueProvider } from "./context/epilogue"
import * as Selection from "./util/selection"
@@ -149,6 +150,7 @@ export type TuiInput = {
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
pluginHost: TuiPluginHost
log: LogSink
}
function errorMessage(error: unknown) {
@@ -230,7 +232,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)
input.log("error", "Failed to dispose TUI plugins", { error })
}
}),
)
@@ -252,112 +254,116 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
await render(() => {
return (
<ExitProvider
exit={(reason) => {
if (renderer.isDestroyed) return
exit.reason = reason
destroyRenderer(renderer)
}}
>
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
<TuiPathsProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
<LogProvider log={input.log}>
<ExitProvider
exit={(reason) => {
if (renderer.isDestroyed) return
exit.reason = reason
destroyRenderer(renderer)
}}
>
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
>
<TuiTerminalEnvironmentProvider
<TuiPathsProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiStartupProvider
<TuiTerminalEnvironmentProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
client={input.client}
api={input.api}
discover={input.discover}
reload={input.reload}
>
<PermissionProvider>
<ProjectProvider>
<SyncProvider>
<DataProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<LocationProvider>
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
/>
</LocationProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</DataProvider>
</SyncProvider>
</ProjectProvider>
</PermissionProvider>
</SDKProvider>
</PluginRuntimeProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</ErrorBoundary>
</EpilogueProvider>
</ExitProvider>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
client={input.client}
api={input.api}
discover={input.discover}
reload={input.reload}
>
<PermissionProvider>
<ProjectProvider>
<SyncProvider>
<DataProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<LocationProvider>
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
/>
</LocationProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</DataProvider>
</SyncProvider>
</ProjectProvider>
</PermissionProvider>
</SDKProvider>
</PluginRuntimeProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</ErrorBoundary>
</EpilogueProvider>
</ExitProvider>
</LogProvider>
)
}, renderer)
})
@@ -374,6 +380,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost }) {
const log = useLog({ component: "app" })
const startup = useTuiStartup()
const tuiConfig = useTuiConfig()
const route = useRoute()
@@ -454,7 +461,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
dispose: () => attention.dispose(),
})
.catch((error) => {
console.error("Failed to load TUI plugins", error)
log.error("Failed to load TUI plugins", { error })
})
.finally(() => {
setReady(true)
@@ -816,8 +823,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
toast.show({ variant: "info", message: "Reloading server...", duration: 30000 })
// reload resolves once the replacement service is healthy; the
// event stream reattaches through the reconnect loop.
await sdk
.reload!()
await sdk.reload!()
.then(() => toast.show({ variant: "success", message: "Server reloaded" }))
.catch(toast.error)
},
@@ -1091,7 +1097,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
})
event.on("installation.update-available", async (evt) => {
console.log("installation.update-available", evt)
const version = evt.data.version
const skipped = kv.get("skipped_version")
+24
View File
@@ -0,0 +1,24 @@
import { createContext, useContext, type ParentProps } from "solid-js"
export type LogLevel = "debug" | "info" | "warn" | "error"
export type LogTags = Readonly<Record<string, unknown>>
export type LogSink = (level: LogLevel, message: string, tags: LogTags) => void
const LogContext = createContext<LogSink>()
export function LogProvider(props: ParentProps<{ log: LogSink }>) {
return <LogContext.Provider value={props.log}>{props.children}</LogContext.Provider>
}
export function useLog(tags: LogTags = {}) {
const sink = useContext(LogContext)
if (!sink) throw new Error("Log context must be used within a LogProvider")
const write = (level: LogLevel, message: string, extra: LogTags = {}) => sink(level, message, { ...tags, ...extra })
return {
debug: (message: string, extra?: LogTags) => write("debug", message, extra),
info: (message: string, extra?: LogTags) => write("info", message, extra),
warn: (message: string, extra?: LogTags) => write("warn", message, extra),
error: (message: string, extra?: LogTags) => write("error", message, extra),
}
}
+10 -1
View File
@@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting"
@@ -19,6 +20,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
// Stops and starts the managed service; present only in service mode.
reload?: () => Promise<void>
}) => {
const log = useLog()
const abort = new AbortController()
let client = props.client
let api = props.api
@@ -64,7 +66,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
return connection.signal.reason instanceof Error
? connection.signal.reason
: new Error("Event stream disconnected")
if (first.value.type !== "server.connected") return new Error("Event stream did not start with server.connected")
if (first.value.type !== "server.connected")
return new Error("Event stream did not start with server.connected")
clearTimeout(timeout)
attempt = 0
events.emit(first.value.type, first.value)
@@ -74,6 +77,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const event = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return
if (event.done) return new Error("Event stream disconnected")
if ("durable" in event.value)
log.info("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
events.emit(event.value.type, event.value)
}
})()
+1
View File
@@ -1 +1,2 @@
export { run, type TuiInput } from "./app"
export { LogProvider, useLog, type LogLevel, type LogSink, type LogTags } from "./context/log"
+2
View File
@@ -34,6 +34,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
api: createApi(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }),
args: {},
log: () => {},
pluginHost: {
async start() {
started()
@@ -115,6 +116,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
api: createApi(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }),
args: { sessionID: "dummy" },
log: () => {},
pluginHost: {
async start(input) {
api = input.api
+33 -2
View File
@@ -9,6 +9,7 @@ import { SDKProvider, useSDK } from "../../../src/context/sdk"
import { useEvent } from "../../../src/context/event"
import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
import type { LogSink } from "../../../src/context/log"
const projectID = "proj_test"
@@ -49,7 +50,7 @@ function update(version: string): V2Event {
}
}
async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) {
async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>, log?: LogSink) {
const events = createEventStream()
const calls = createFetch(undefined, events)
const seen: V2Event[] = []
@@ -62,7 +63,7 @@ async function mount(discover?: () => Promise<{ client: OpencodeClient; api: Ope
})
const app = await testRender(() => (
<TestTuiContexts>
<TestTuiContexts log={log}>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} discover={discover}>
<ProjectProvider>
<Probe
@@ -105,6 +106,36 @@ function Probe(props: {
}
describe("useEvent", () => {
test("logs only durable events", async () => {
const logs: Array<{ message: string; tags: Readonly<Record<string, unknown>> }> = []
const { app, emit, seen } = await mount(undefined, (_level, message, tags) => logs.push({ message, tags }))
const durable = event(
{
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: { sessionID: "ses_test", title: "Renamed" },
},
{ directory: "/tmp/project" },
)
try {
emit(vcs("main"))
emit(durable)
await wait(() => seen.length === 2 && logs.length === 1)
expect(logs).toEqual([
{
message: "event",
tags: { type: "session.renamed", aggregateID: "ses_test", seq: 1 },
},
])
} finally {
app.renderer.destroy()
}
})
test("delivers events for the current project", async () => {
const { app, emit, seen, workspaces } = await mount()
+17 -13
View File
@@ -6,27 +6,31 @@ import {
type TuiPaths,
} from "../../src/context/runtime"
import type { ParentProps } from "solid-js"
import { LogProvider, type LogSink } from "../../src/context/log"
export function TestTuiContexts(
props: ParentProps<{
cwd?: string
directory?: string
paths?: Partial<TuiPaths>
log?: LogSink
}>,
) {
return (
<TuiPathsProvider
value={{
cwd: props.cwd ?? props.directory ?? "/tmp/opencode/packages/tui",
home: "/tmp/opencode/home",
state: "/tmp/opencode/state",
worktree: "/tmp/opencode",
...props.paths,
}}
>
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
<LogProvider log={props.log ?? (() => {})}>
<TuiPathsProvider
value={{
cwd: props.cwd ?? props.directory ?? "/tmp/opencode/packages/tui",
home: "/tmp/opencode/home",
state: "/tmp/opencode/state",
worktree: "/tmp/opencode",
...props.paths,
}}
>
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</LogProvider>
)
}