Explorar o código

feat(tui): add structured logging

Dax Raad hai 1 mes
pai
achega
cedf365674

+ 12 - 0
packages/cli/src/tui.ts

@@ -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 - 0
packages/tui/package.json

@@ -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
packages/tui/src/app.tsx

@@ -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 - 0
packages/tui/src/context/log.tsx

@@ -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
packages/tui/src/context/sdk.tsx

@@ -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 - 0
packages/tui/src/index.tsx

@@ -1 +1,2 @@
 export { run, type TuiInput } from "./app"
+export { LogProvider, useLog, type LogLevel, type LogSink, type LogTags } from "./context/log"

+ 2 - 0
packages/tui/test/app-lifecycle.test.tsx

@@ -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
packages/tui/test/cli/tui/use-event.test.tsx

@@ -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
packages/tui/test/fixture/tui-environment.tsx

@@ -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>
   )
 }