From f1007771997bd0401516eda87a7e0ac92f269680 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 9 May 2025 13:37:13 -0500 Subject: [PATCH] wip: logging improvements --- cmd/root.go | 47 +++-- internal/app/app.go | 16 +- internal/app/lsp.go | 34 ++-- internal/config/config.go | 67 +++---- internal/db/connect.go | 10 +- internal/db/db.go | 30 ++++ internal/db/logs.sql.go | 128 ++++++++++++++ .../20250508122310_create_logs_table.sql | 16 ++ internal/db/models.go | 10 ++ internal/db/querier.go | 4 + internal/db/sql/logs.sql | 28 +++ internal/llm/agent/agent.go | 8 +- internal/llm/agent/mcp-tools.go | 10 +- internal/llm/agent/tools.go | 2 +- internal/llm/prompt/prompt.go | 4 +- internal/llm/prompt/prompt_test.go | 6 +- internal/llm/provider/anthropic.go | 12 +- internal/llm/provider/gemini.go | 8 +- internal/llm/provider/openai.go | 8 +- internal/llm/provider/provider.go | 20 +-- internal/llm/tools/edit.go | 12 +- internal/llm/tools/patch.go | 8 +- internal/llm/tools/write.go | 6 +- internal/logging/{logger.go => logging.go} | 23 +-- internal/logging/manager.go | 48 +++++ internal/logging/message.go | 19 -- internal/logging/service.go | 167 ++++++++++++++++++ internal/logging/writer.go | 68 ++----- internal/lsp/client.go | 34 ++-- internal/lsp/discovery/integration.go | 13 +- internal/lsp/discovery/language.go | 9 +- internal/lsp/discovery/server.go | 53 +++--- internal/lsp/handlers.go | 14 +- internal/lsp/transport.go | 32 ++-- internal/lsp/watcher/watcher.go | 112 ++++++------ internal/session/manager.go | 15 +- internal/tui/components/dialog/filepicker.go | 10 +- internal/tui/components/logs/details.go | 42 +++-- internal/tui/components/logs/table.go | 117 ++++++++---- internal/tui/theme/manager.go | 14 +- internal/tui/tui.go | 2 +- 41 files changed, 848 insertions(+), 438 deletions(-) create mode 100644 internal/db/logs.sql.go create mode 100644 internal/db/migrations/20250508122310_create_logs_table.sql create mode 100644 internal/db/sql/logs.sql rename internal/logging/{logger.go => logging.go} (79%) create mode 100644 internal/logging/manager.go delete mode 100644 internal/logging/message.go create mode 100644 internal/logging/service.go diff --git a/cmd/root.go b/cmd/root.go index 6f7bf1a1..7bf8485f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,6 +7,8 @@ import ( "sync" "time" + "log/slog" + tea "github.com/charmbracelet/bubbletea" zone "github.com/lrstanley/bubblezone" "github.com/opencode-ai/opencode/internal/app" @@ -38,6 +40,13 @@ to assist developers in writing, debugging, and understanding code directly from return nil } + // Setup logging + lvl := new(slog.LevelVar) + logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{ + Level: lvl, + })) + slog.SetDefault(logger) + // Load the config debug, _ := cmd.Flags().GetBool("debug") cwd, _ := cmd.Flags().GetString("cwd") @@ -54,14 +63,14 @@ to assist developers in writing, debugging, and understanding code directly from } cwd = c } - _, err := config.Load(cwd, debug) + _, err := config.Load(cwd, debug, lvl) if err != nil { return err } // Run LSP auto-discovery if err := discovery.IntegrateLSPServers(cwd); err != nil { - logging.Warn("Failed to auto-discover LSP servers", "error", err) + slog.Warn("Failed to auto-discover LSP servers", "error", err) // Continue anyway, this is not a fatal error } @@ -77,7 +86,7 @@ to assist developers in writing, debugging, and understanding code directly from app, err := app.New(ctx, conn) if err != nil { - logging.Error("Failed to create app: %v", err) + slog.Error("Failed to create app: %v", err) return err } @@ -109,11 +118,11 @@ to assist developers in writing, debugging, and understanding code directly from for { select { case <-tuiCtx.Done(): - logging.Info("TUI message handler shutting down") + slog.Info("TUI message handler shutting down") return case msg, ok := <-ch: if !ok { - logging.Info("TUI message channel closed") + slog.Info("TUI message channel closed") return } program.Send(msg) @@ -135,7 +144,7 @@ to assist developers in writing, debugging, and understanding code directly from // Wait for TUI message handler to finish tuiWg.Wait() - logging.Info("All goroutines cleaned up") + slog.Info("All goroutines cleaned up") } // Run the TUI @@ -143,18 +152,18 @@ to assist developers in writing, debugging, and understanding code directly from cleanup() if err != nil { - logging.Error("TUI error: %v", err) + slog.Error("TUI error: %v", err) return fmt.Errorf("TUI error: %v", err) } - logging.Info("TUI exited with result: %v", result) + slog.Info("TUI exited with result: %v", result) return nil }, } // attemptTUIRecovery tries to recover the TUI after a panic func attemptTUIRecovery(program *tea.Program) { - logging.Info("Attempting to recover TUI after panic") + slog.Info("Attempting to recover TUI after panic") // We could try to restart the TUI or gracefully exit // For now, we'll just quit the program to avoid further issues @@ -171,7 +180,7 @@ func initMCPTools(ctx context.Context, app *app.App) { // Set this up once with proper error handling agent.GetMcpTools(ctxWithTimeout, app.Permissions) - logging.Info("MCP message handling goroutine exiting") + slog.Info("MCP message handling goroutine exiting") }() } @@ -189,7 +198,7 @@ func setupSubscriber[T any]( subCh := subscriber(ctx) if subCh == nil { - logging.Warn("subscription channel is nil", "name", name) + slog.Warn("subscription channel is nil", "name", name) return } @@ -197,7 +206,7 @@ func setupSubscriber[T any]( select { case event, ok := <-subCh: if !ok { - logging.Info("subscription channel closed", "name", name) + slog.Info("subscription channel closed", "name", name) return } @@ -206,13 +215,13 @@ func setupSubscriber[T any]( select { case outputCh <- msg: case <-time.After(2 * time.Second): - logging.Warn("message dropped due to slow consumer", "name", name) + slog.Warn("message dropped due to slow consumer", "name", name) case <-ctx.Done(): - logging.Info("subscription cancelled", "name", name) + slog.Info("subscription cancelled", "name", name) return } case <-ctx.Done(): - logging.Info("subscription cancelled", "name", name) + slog.Info("subscription cancelled", "name", name) return } } @@ -225,14 +234,14 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, wg := sync.WaitGroup{} ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context - setupSubscriber(ctx, &wg, "logging", logging.Subscribe, ch) + setupSubscriber(ctx, &wg, "logging", app.Logs.Subscribe, ch) setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch) setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch) setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch) setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch) cleanupFunc := func() { - logging.Info("Cancelling all subscriptions") + slog.Info("Cancelling all subscriptions") cancel() // Signal all goroutines to stop waitCh := make(chan struct{}) @@ -244,10 +253,10 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, select { case <-waitCh: - logging.Info("All subscription goroutines completed successfully") + slog.Info("All subscription goroutines completed successfully") close(ch) // Only close after all writers are confirmed done case <-time.After(5 * time.Second): - logging.Warn("Timed out waiting for some subscription goroutines to complete") + slog.Warn("Timed out waiting for some subscription goroutines to complete") close(ch) } } diff --git a/internal/app/app.go b/internal/app/app.go index b4812fb4..42de2454 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,6 +7,8 @@ import ( "sync" "time" + "log/slog" + "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/db" "github.com/opencode-ai/opencode/internal/history" @@ -21,6 +23,7 @@ import ( ) type App struct { + Logs logging.Service Sessions session.Service Messages message.Service History history.Service @@ -40,12 +43,16 @@ type App struct { func New(ctx context.Context, conn *sql.DB) (*App, error) { q := db.New(conn) + loggingService := logging.NewService(q) sessionService := session.NewService(q) messageService := message.NewService(q) historyService := history.NewService(q, conn) permissionService := permission.NewPermissionService() statusService := status.NewService() + // Initialize logging service + logging.InitManager(loggingService) + // Initialize session manager session.InitManager(sessionService) @@ -53,6 +60,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { status.InitManager(statusService) app := &App{ + Logs: loggingService, Sessions: sessionService, Messages: messageService, History: historyService, @@ -81,7 +89,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { ), ) if err != nil { - logging.Error("Failed to create coder agent", err) + slog.Error("Failed to create coder agent", err) return nil, err } @@ -98,9 +106,9 @@ func (app *App) initTheme() { // Try to set the theme from config err := theme.SetTheme(cfg.TUI.Theme) if err != nil { - logging.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err) + slog.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err) } else { - logging.Debug("Set theme from config", "theme", cfg.TUI.Theme) + slog.Debug("Set theme from config", "theme", cfg.TUI.Theme) } } @@ -123,7 +131,7 @@ func (app *App) Shutdown() { for name, client := range clients { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) if err := client.Shutdown(shutdownCtx); err != nil { - logging.Error("Failed to shutdown LSP client", "name", name, "error", err) + slog.Error("Failed to shutdown LSP client", "name", name, "error", err) } cancel() } diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 934bd1a8..b3763fba 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -4,6 +4,8 @@ import ( "context" "time" + "log/slog" + "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/lsp" @@ -18,29 +20,29 @@ func (app *App) initLSPClients(ctx context.Context) { // Start each client initialization in its own goroutine go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...) } - logging.Info("LSP clients initialization started in background") + slog.Info("LSP clients initialization started in background") } // createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) { // Create a specific context for initialization with a timeout - logging.Info("Creating LSP client", "name", name, "command", command, "args", args) - + slog.Info("Creating LSP client", "name", name, "command", command, "args", args) + // Create the LSP client lspClient, err := lsp.NewClient(ctx, command, args...) if err != nil { - logging.Error("Failed to create LSP client for", name, err) + slog.Error("Failed to create LSP client for", name, err) return } // Create a longer timeout for initialization (some servers take time to start) initCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - + // Initialize with the initialization context _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory()) if err != nil { - logging.Error("Initialize failed", "name", name, "error", err) + slog.Error("Initialize failed", "name", name, "error", err) // Clean up the client to prevent resource leaks lspClient.Close() return @@ -48,22 +50,22 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman // Wait for the server to be ready if err := lspClient.WaitForServerReady(initCtx); err != nil { - logging.Error("Server failed to become ready", "name", name, "error", err) + slog.Error("Server failed to become ready", "name", name, "error", err) // We'll continue anyway, as some functionality might still work lspClient.SetServerState(lsp.StateError) } else { - logging.Info("LSP server is ready", "name", name) + slog.Info("LSP server is ready", "name", name) lspClient.SetServerState(lsp.StateReady) } - logging.Info("LSP client initialized", "name", name) - + slog.Info("LSP client initialized", "name", name) + // Create a child context that can be canceled when the app is shutting down watchCtx, cancelFunc := context.WithCancel(ctx) - + // Create a context with the server name for better identification watchCtx = context.WithValue(watchCtx, "serverName", name) - + // Create the workspace watcher workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient) @@ -92,7 +94,7 @@ func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceW }) workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory()) - logging.Info("Workspace watcher stopped", "client", name) + slog.Info("Workspace watcher stopped", "client", name) } // restartLSPClient attempts to restart a crashed or failed LSP client @@ -101,7 +103,7 @@ func (app *App) restartLSPClient(ctx context.Context, name string) { cfg := config.Get() clientConfig, exists := cfg.LSP[name] if !exists { - logging.Error("Cannot restart client, configuration not found", "client", name) + slog.Error("Cannot restart client, configuration not found", "client", name) return } @@ -118,7 +120,7 @@ func (app *App) restartLSPClient(ctx context.Context, name string) { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) _ = oldClient.Shutdown(shutdownCtx) cancel() - + // Ensure we close the client to free resources _ = oldClient.Close() } @@ -128,5 +130,5 @@ func (app *App) restartLSPClient(ctx context.Context, name string) { // Create a new client using the shared function app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...) - logging.Info("Successfully restarted LSP client", "client", name) + slog.Info("Successfully restarted LSP client", "client", name) } diff --git a/internal/config/config.go b/internal/config/config.go index 745d88d8..bb9ec447 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,7 +11,6 @@ import ( "strings" "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/logging" "github.com/spf13/viper" ) @@ -70,7 +69,7 @@ type LSPConfig struct { // TUIConfig defines the configuration for the Terminal User Interface. type TUIConfig struct { - Theme string `json:"theme,omitempty"` + Theme string `json:"theme,omitempty"` CustomTheme map[string]any `json:"customTheme,omitempty"` } @@ -119,7 +118,7 @@ var cfg *Config // Load initializes the configuration from environment variables and config files. // If debug is true, debug mode is enabled and log level is set to debug. // It returns an error if configuration loading fails. -func Load(workingDir string, debug bool) (*Config, error) { +func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) { if cfg != nil { return cfg, nil } @@ -150,39 +149,13 @@ func Load(workingDir string, debug bool) (*Config, error) { } applyDefaultValues() + defaultLevel := slog.LevelInfo if cfg.Debug { defaultLevel = slog.LevelDebug } - if os.Getenv("OPENCODE_DEV_DEBUG") == "true" { - loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log") - - // if file does not exist create it - if _, err := os.Stat(loggingFile); os.IsNotExist(err) { - if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil { - return cfg, fmt.Errorf("failed to create directory: %w", err) - } - if _, err := os.Create(loggingFile); err != nil { - return cfg, fmt.Errorf("failed to create log file: %w", err) - } - } - - sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) - if err != nil { - return cfg, fmt.Errorf("failed to open log file: %w", err) - } - // Configure logger - logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{ - Level: defaultLevel, - })) - slog.SetDefault(logger) - } else { - // Configure logger - logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{ - Level: defaultLevel, - })) - slog.SetDefault(logger) - } + lvl.Set(defaultLevel) + slog.SetLogLoggerLevel(defaultLevel) // Validate configuration if err := Validate(); err != nil { @@ -397,13 +370,13 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { // Check if model exists model, modelExists := models.SupportedModels[agent.Model] if !modelExists { - logging.Warn("unsupported model configured, reverting to default", + slog.Warn("unsupported model configured, reverting to default", "agent", name, "configured_model", agent.Model) // Set default model based on available providers if setDefaultModelForAgent(name) { - logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model) + slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model) } else { return fmt.Errorf("no valid provider available for agent %s", name) } @@ -418,14 +391,14 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { // Provider not configured, check if we have environment variables apiKey := getProviderAPIKey(provider) if apiKey == "" { - logging.Warn("provider not configured for model, reverting to default", + slog.Warn("provider not configured for model, reverting to default", "agent", name, "model", agent.Model, "provider", provider) // Set default model based on available providers if setDefaultModelForAgent(name) { - logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model) + slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model) } else { return fmt.Errorf("no valid provider available for agent %s", name) } @@ -434,18 +407,18 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { cfg.Providers[provider] = Provider{ APIKey: apiKey, } - logging.Info("added provider from environment", "provider", provider) + slog.Info("added provider from environment", "provider", provider) } } else if providerCfg.Disabled || providerCfg.APIKey == "" { // Provider is disabled or has no API key - logging.Warn("provider is disabled or has no API key, reverting to default", + slog.Warn("provider is disabled or has no API key, reverting to default", "agent", name, "model", agent.Model, "provider", provider) // Set default model based on available providers if setDefaultModelForAgent(name) { - logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model) + slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model) } else { return fmt.Errorf("no valid provider available for agent %s", name) } @@ -453,7 +426,7 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { // Validate max tokens if agent.MaxTokens <= 0 { - logging.Warn("invalid max tokens, setting to default", + slog.Warn("invalid max tokens, setting to default", "agent", name, "model", agent.Model, "max_tokens", agent.MaxTokens) @@ -468,7 +441,7 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { cfg.Agents[name] = updatedAgent } else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 { // Ensure max tokens doesn't exceed half the context window (reasonable limit) - logging.Warn("max tokens exceeds half the context window, adjusting", + slog.Warn("max tokens exceeds half the context window, adjusting", "agent", name, "model", agent.Model, "max_tokens", agent.MaxTokens, @@ -484,7 +457,7 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { if model.CanReason && provider == models.ProviderOpenAI { if agent.ReasoningEffort == "" { // Set default reasoning effort for models that support it - logging.Info("setting default reasoning effort for model that supports reasoning", + slog.Info("setting default reasoning effort for model that supports reasoning", "agent", name, "model", agent.Model) @@ -496,7 +469,7 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { // Check if reasoning effort is valid (low, medium, high) effort := strings.ToLower(agent.ReasoningEffort) if effort != "low" && effort != "medium" && effort != "high" { - logging.Warn("invalid reasoning effort, setting to medium", + slog.Warn("invalid reasoning effort, setting to medium", "agent", name, "model", agent.Model, "reasoning_effort", agent.ReasoningEffort) @@ -509,7 +482,7 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { } } else if !model.CanReason && agent.ReasoningEffort != "" { // Model doesn't support reasoning but reasoning effort is set - logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring", + slog.Warn("model doesn't support reasoning but reasoning effort is set, ignoring", "agent", name, "model", agent.Model, "reasoning_effort", agent.ReasoningEffort) @@ -539,7 +512,7 @@ func Validate() error { // Validate providers for provider, providerCfg := range cfg.Providers { if providerCfg.APIKey == "" && !providerCfg.Disabled { - logging.Warn("provider has no API key, marking as disabled", "provider", provider) + slog.Warn("provider has no API key, marking as disabled", "provider", provider) providerCfg.Disabled = true cfg.Providers[provider] = providerCfg } @@ -548,7 +521,7 @@ func Validate() error { // Validate LSP configurations for language, lspConfig := range cfg.LSP { if lspConfig.Command == "" && !lspConfig.Disabled { - logging.Warn("LSP configuration has no command, marking as disabled", "language", language) + slog.Warn("LSP configuration has no command, marking as disabled", "language", language) lspConfig.Disabled = true cfg.LSP[language] = lspConfig } @@ -782,7 +755,7 @@ func UpdateTheme(themeName string) error { return fmt.Errorf("failed to get home directory: %w", err) } configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName)) - logging.Info("config file not found, creating new one", "path", configFile) + slog.Info("config file not found, creating new one", "path", configFile) configData = []byte(`{}`) } else { // Read the existing config file diff --git a/internal/db/connect.go b/internal/db/connect.go index b8fcb736..f77ebdd2 100644 --- a/internal/db/connect.go +++ b/internal/db/connect.go @@ -10,7 +10,7 @@ import ( _ "github.com/ncruces/go-sqlite3/embed" "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" + "log/slog" "github.com/pressly/goose/v3" ) @@ -47,21 +47,21 @@ func Connect() (*sql.DB, error) { for _, pragma := range pragmas { if _, err = db.Exec(pragma); err != nil { - logging.Error("Failed to set pragma", pragma, err) + slog.Error("Failed to set pragma", pragma, err) } else { - logging.Debug("Set pragma", "pragma", pragma) + slog.Debug("Set pragma", "pragma", pragma) } } goose.SetBaseFS(FS) if err := goose.SetDialect("sqlite3"); err != nil { - logging.Error("Failed to set dialect", "error", err) + slog.Error("Failed to set dialect", "error", err) return nil, fmt.Errorf("failed to set dialect: %w", err) } if err := goose.Up(db, "migrations"); err != nil { - logging.Error("Failed to apply migrations", "error", err) + slog.Error("Failed to apply migrations", "error", err) return nil, fmt.Errorf("failed to apply migrations: %w", err) } return db, nil diff --git a/internal/db/db.go b/internal/db/db.go index e71b8622..dfd606cb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -27,6 +27,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createFileStmt, err = db.PrepareContext(ctx, createFile); err != nil { return nil, fmt.Errorf("error preparing query CreateFile: %w", err) } + if q.createLogStmt, err = db.PrepareContext(ctx, createLog); err != nil { + return nil, fmt.Errorf("error preparing query CreateLog: %w", err) + } if q.createMessageStmt, err = db.PrepareContext(ctx, createMessage); err != nil { return nil, fmt.Errorf("error preparing query CreateMessage: %w", err) } @@ -60,6 +63,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil { return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err) } + if q.listAllLogsStmt, err = db.PrepareContext(ctx, listAllLogs); err != nil { + return nil, fmt.Errorf("error preparing query ListAllLogs: %w", err) + } if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil { return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err) } @@ -69,6 +75,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listLatestSessionFilesStmt, err = db.PrepareContext(ctx, listLatestSessionFiles); err != nil { return nil, fmt.Errorf("error preparing query ListLatestSessionFiles: %w", err) } + if q.listLogsBySessionStmt, err = db.PrepareContext(ctx, listLogsBySession); err != nil { + return nil, fmt.Errorf("error preparing query ListLogsBySession: %w", err) + } if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil { return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err) } @@ -100,6 +109,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createFileStmt: %w", cerr) } } + if q.createLogStmt != nil { + if cerr := q.createLogStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createLogStmt: %w", cerr) + } + } if q.createMessageStmt != nil { if cerr := q.createMessageStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createMessageStmt: %w", cerr) @@ -155,6 +169,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr) } } + if q.listAllLogsStmt != nil { + if cerr := q.listAllLogsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAllLogsStmt: %w", cerr) + } + } if q.listFilesByPathStmt != nil { if cerr := q.listFilesByPathStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr) @@ -170,6 +189,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listLatestSessionFilesStmt: %w", cerr) } } + if q.listLogsBySessionStmt != nil { + if cerr := q.listLogsBySessionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listLogsBySessionStmt: %w", cerr) + } + } if q.listMessagesBySessionStmt != nil { if cerr := q.listMessagesBySessionStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr) @@ -245,6 +269,7 @@ type Queries struct { db DBTX tx *sql.Tx createFileStmt *sql.Stmt + createLogStmt *sql.Stmt createMessageStmt *sql.Stmt createSessionStmt *sql.Stmt deleteFileStmt *sql.Stmt @@ -256,9 +281,11 @@ type Queries struct { getFileByPathAndSessionStmt *sql.Stmt getMessageStmt *sql.Stmt getSessionByIDStmt *sql.Stmt + listAllLogsStmt *sql.Stmt listFilesByPathStmt *sql.Stmt listFilesBySessionStmt *sql.Stmt listLatestSessionFilesStmt *sql.Stmt + listLogsBySessionStmt *sql.Stmt listMessagesBySessionStmt *sql.Stmt listMessagesBySessionAfterStmt *sql.Stmt listNewFilesStmt *sql.Stmt @@ -273,6 +300,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { db: tx, tx: tx, createFileStmt: q.createFileStmt, + createLogStmt: q.createLogStmt, createMessageStmt: q.createMessageStmt, createSessionStmt: q.createSessionStmt, deleteFileStmt: q.deleteFileStmt, @@ -284,9 +312,11 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt, getMessageStmt: q.getMessageStmt, getSessionByIDStmt: q.getSessionByIDStmt, + listAllLogsStmt: q.listAllLogsStmt, listFilesByPathStmt: q.listFilesByPathStmt, listFilesBySessionStmt: q.listFilesBySessionStmt, listLatestSessionFilesStmt: q.listLatestSessionFilesStmt, + listLogsBySessionStmt: q.listLogsBySessionStmt, listMessagesBySessionStmt: q.listMessagesBySessionStmt, listMessagesBySessionAfterStmt: q.listMessagesBySessionAfterStmt, listNewFilesStmt: q.listNewFilesStmt, diff --git a/internal/db/logs.sql.go b/internal/db/logs.sql.go new file mode 100644 index 00000000..d227b472 --- /dev/null +++ b/internal/db/logs.sql.go @@ -0,0 +1,128 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: logs.sql + +package db + +import ( + "context" + "database/sql" +) + +const createLog = `-- name: CreateLog :exec +INSERT INTO logs ( + id, + session_id, + timestamp, + level, + message, + attributes, + created_at +) VALUES ( + ?, + ?, + ?, + ?, + ?, + ?, + ? +) +` + +type CreateLogParams struct { + ID string `json:"id"` + SessionID sql.NullString `json:"session_id"` + Timestamp int64 `json:"timestamp"` + Level string `json:"level"` + Message string `json:"message"` + Attributes sql.NullString `json:"attributes"` + CreatedAt int64 `json:"created_at"` +} + +func (q *Queries) CreateLog(ctx context.Context, arg CreateLogParams) error { + _, err := q.exec(ctx, q.createLogStmt, createLog, + arg.ID, + arg.SessionID, + arg.Timestamp, + arg.Level, + arg.Message, + arg.Attributes, + arg.CreatedAt, + ) + return err +} + +const listAllLogs = `-- name: ListAllLogs :many +SELECT id, session_id, timestamp, level, message, attributes, created_at FROM logs +ORDER BY timestamp DESC +LIMIT ? +` + +func (q *Queries) ListAllLogs(ctx context.Context, limit int64) ([]Log, error) { + rows, err := q.query(ctx, q.listAllLogsStmt, listAllLogs, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Log{} + for rows.Next() { + var i Log + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Timestamp, + &i.Level, + &i.Message, + &i.Attributes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLogsBySession = `-- name: ListLogsBySession :many +SELECT id, session_id, timestamp, level, message, attributes, created_at FROM logs +WHERE session_id = ? +ORDER BY timestamp ASC +` + +func (q *Queries) ListLogsBySession(ctx context.Context, sessionID sql.NullString) ([]Log, error) { + rows, err := q.query(ctx, q.listLogsBySessionStmt, listLogsBySession, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Log{} + for rows.Next() { + var i Log + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Timestamp, + &i.Level, + &i.Message, + &i.Attributes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/migrations/20250508122310_create_logs_table.sql b/internal/db/migrations/20250508122310_create_logs_table.sql new file mode 100644 index 00000000..f4876fba --- /dev/null +++ b/internal/db/migrations/20250508122310_create_logs_table.sql @@ -0,0 +1,16 @@ +-- +goose Up +CREATE TABLE logs ( + id TEXT PRIMARY KEY, + session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE, + timestamp INTEGER NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + attributes TEXT, + created_at INTEGER NOT NULL +); + +CREATE INDEX logs_session_id_idx ON logs(session_id); +CREATE INDEX logs_timestamp_idx ON logs(timestamp); + +-- +goose Down +DROP TABLE logs; \ No newline at end of file diff --git a/internal/db/models.go b/internal/db/models.go index 47028c19..473e18db 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -18,6 +18,16 @@ type File struct { UpdatedAt int64 `json:"updated_at"` } +type Log struct { + ID string `json:"id"` + SessionID sql.NullString `json:"session_id"` + Timestamp int64 `json:"timestamp"` + Level string `json:"level"` + Message string `json:"message"` + Attributes sql.NullString `json:"attributes"` + CreatedAt int64 `json:"created_at"` +} + type Message struct { ID string `json:"id"` SessionID string `json:"session_id"` diff --git a/internal/db/querier.go b/internal/db/querier.go index ee0a2f7b..08ad41ec 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -6,10 +6,12 @@ package db import ( "context" + "database/sql" ) type Querier interface { CreateFile(ctx context.Context, arg CreateFileParams) (File, error) + CreateLog(ctx context.Context, arg CreateLogParams) error CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) DeleteFile(ctx context.Context, id string) error @@ -21,9 +23,11 @@ type Querier interface { GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) GetMessage(ctx context.Context, id string) (Message, error) GetSessionByID(ctx context.Context, id string) (Session, error) + ListAllLogs(ctx context.Context, limit int64) ([]Log, error) ListFilesByPath(ctx context.Context, path string) ([]File, error) ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) + ListLogsBySession(ctx context.Context, sessionID sql.NullString) ([]Log, error) ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) ListMessagesBySessionAfter(ctx context.Context, arg ListMessagesBySessionAfterParams) ([]Message, error) ListNewFiles(ctx context.Context) ([]File, error) diff --git a/internal/db/sql/logs.sql b/internal/db/sql/logs.sql new file mode 100644 index 00000000..1a0655f7 --- /dev/null +++ b/internal/db/sql/logs.sql @@ -0,0 +1,28 @@ +-- name: CreateLog :exec +INSERT INTO logs ( + id, + session_id, + timestamp, + level, + message, + attributes, + created_at +) VALUES ( + ?, + ?, + ?, + ?, + ?, + ?, + ? +); + +-- name: ListLogsBySession :many +SELECT * FROM logs +WHERE session_id = ? +ORDER BY timestamp ASC; + +-- name: ListAllLogs :many +SELECT * FROM logs +ORDER BY timestamp DESC +LIMIT ?; \ No newline at end of file diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 695826cd..295ac465 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -8,6 +8,8 @@ import ( "sync" "time" + "log/slog" + "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/llm/models" "github.com/opencode-ai/opencode/internal/llm/prompt" @@ -177,7 +179,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac a.activeRequests.Store(sessionID, cancel) go func() { - logging.Debug("Request started", "sessionID", sessionID) + slog.Debug("Request started", "sessionID", sessionID) defer logging.RecoverPanic("agent.Run", func() { events <- a.err(fmt.Errorf("panic while running the agent")) }) @@ -189,7 +191,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) { status.Error(result.Err().Error()) } - logging.Debug("Request completed", "sessionID", sessionID) + slog.Debug("Request completed", "sessionID", sessionID) a.activeRequests.Delete(sessionID) cancel() events <- result @@ -276,7 +278,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string } return a.err(fmt.Errorf("failed to process events: %w", err)) } - logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults) + slog.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults) if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil { // We are not done, we need to respond with the tool response messages = append(messages, agentMessage, *toolResults) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 23756064..9966b99d 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -7,9 +7,9 @@ import ( "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/permission" "github.com/opencode-ai/opencode/internal/version" + "log/slog" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" @@ -146,13 +146,13 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions _, err := c.Initialize(ctx, initRequest) if err != nil { - logging.Error("error initializing mcp client", "error", err) + slog.Error("error initializing mcp client", "error", err) return stdioTools } toolsRequest := mcp.ListToolsRequest{} tools, err := c.ListTools(ctx, toolsRequest) if err != nil { - logging.Error("error listing tools", "error", err) + slog.Error("error listing tools", "error", err) return stdioTools } for _, t := range tools.Tools { @@ -175,7 +175,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba m.Args..., ) if err != nil { - logging.Error("error creating mcp client", "error", err) + slog.Error("error creating mcp client", "error", err) continue } @@ -186,7 +186,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba client.WithHeaders(m.Headers), ) if err != nil { - logging.Error("error creating mcp client", "error", err) + slog.Error("error creating mcp client", "error", err) continue } mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...) diff --git a/internal/llm/agent/tools.go b/internal/llm/agent/tools.go index 43e5978e..b337efb5 100644 --- a/internal/llm/agent/tools.go +++ b/internal/llm/agent/tools.go @@ -33,7 +33,7 @@ func CoderAgentTools( tools.NewGlobTool(), tools.NewGrepTool(), tools.NewLsTool(), - tools.NewSourcegraphTool(), + // tools.NewSourcegraphTool(), tools.NewViewTool(lspClients), tools.NewPatchTool(lspClients, permissions, history), tools.NewWriteTool(lspClients, permissions, history), diff --git a/internal/llm/prompt/prompt.go b/internal/llm/prompt/prompt.go index 83ec7442..769fd51a 100644 --- a/internal/llm/prompt/prompt.go +++ b/internal/llm/prompt/prompt.go @@ -9,7 +9,7 @@ import ( "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/logging" + "log/slog" ) func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string { @@ -28,7 +28,7 @@ func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) s if agentName == config.AgentCoder || agentName == config.AgentTask { // Add context from project-specific instruction files if they exist contextContent := getContextFromPaths() - logging.Debug("Context content", "Context", contextContent) + slog.Debug("Context content", "Context", contextContent) if contextContent != "" { return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent) } diff --git a/internal/llm/prompt/prompt_test.go b/internal/llm/prompt/prompt_test.go index 405ad519..fe492b13 100644 --- a/internal/llm/prompt/prompt_test.go +++ b/internal/llm/prompt/prompt_test.go @@ -2,6 +2,7 @@ package prompt import ( "fmt" + "log/slog" "os" "path/filepath" "testing" @@ -14,8 +15,11 @@ import ( func TestGetContextFromPaths(t *testing.T) { t.Parallel() + lvl := new(slog.LevelVar) + lvl.Set(slog.LevelDebug) + tmpDir := t.TempDir() - _, err := config.Load(tmpDir, false) + _, err := config.Load(tmpDir, false, lvl) if err != nil { t.Fatalf("Failed to load config: %v", err) } diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index edd1c1d7..9c599df5 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -15,9 +15,9 @@ import ( "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/llm/models" "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/status" + "log/slog" ) type anthropicOptions struct { @@ -107,7 +107,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic } if len(blocks) == 0 { - logging.Warn("There is a message without content, investigate, this should not happen") + slog.Warn("There is a message without content, investigate, this should not happen") continue } anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) @@ -210,7 +210,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message, cfg := config.Get() if cfg.Debug { jsonData, _ := json.Marshal(preparedMessages) - logging.Debug("Prepared messages", "messages", string(jsonData)) + slog.Debug("Prepared messages", "messages", string(jsonData)) } attempts := 0 @@ -222,7 +222,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message, ) // If there is an error we are going to see if we can retry the call if err != nil { - logging.Error("Error in Anthropic API call", "error", err) + slog.Error("Error in Anthropic API call", "error", err) retry, after, retryErr := a.shouldRetry(attempts, err) if retryErr != nil { return nil, retryErr @@ -259,7 +259,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message cfg := config.Get() if cfg.Debug { jsonData, _ := json.Marshal(preparedMessages) - logging.Debug("Prepared messages", "messages", string(jsonData)) + slog.Debug("Prepared messages", "messages", string(jsonData)) } attempts := 0 eventChan := make(chan ProviderEvent) @@ -277,7 +277,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message event := anthropicStream.Current() err := accumulatedMessage.Accumulate(event) if err != nil { - logging.Warn("Error accumulating message", "error", err) + slog.Warn("Error accumulating message", "error", err) continue } diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go index 2986c715..c37aee4b 100644 --- a/internal/llm/provider/gemini.go +++ b/internal/llm/provider/gemini.go @@ -13,11 +13,11 @@ import ( "github.com/google/uuid" "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/status" "google.golang.org/api/iterator" "google.golang.org/api/option" + "log/slog" ) type geminiOptions struct { @@ -42,7 +42,7 @@ func newGeminiClient(opts providerClientOptions) GeminiClient { client, err := genai.NewClient(context.Background(), option.WithAPIKey(opts.apiKey)) if err != nil { - logging.Error("Failed to create Gemini client", "error", err) + slog.Error("Failed to create Gemini client", "error", err) return nil } @@ -176,7 +176,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too cfg := config.Get() if cfg.Debug { jsonData, _ := json.Marshal(geminiMessages) - logging.Debug("Prepared messages", "messages", string(jsonData)) + slog.Debug("Prepared messages", "messages", string(jsonData)) } attempts := 0 @@ -263,7 +263,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t cfg := config.Get() if cfg.Debug { jsonData, _ := json.Marshal(geminiMessages) - logging.Debug("Prepared messages", "messages", string(jsonData)) + slog.Debug("Prepared messages", "messages", string(jsonData)) } attempts := 0 diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index 3bf8a6d4..777d9d8c 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -14,9 +14,9 @@ import ( "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/llm/models" "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/status" + "log/slog" ) type openaiOptions struct { @@ -199,7 +199,7 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too cfg := config.Get() if cfg.Debug { jsonData, _ := json.Marshal(params) - logging.Debug("Prepared messages", "messages", string(jsonData)) + slog.Debug("Prepared messages", "messages", string(jsonData)) } attempts := 0 for { @@ -256,7 +256,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t cfg := config.Get() if cfg.Debug { jsonData, _ := json.Marshal(params) - logging.Debug("Prepared messages", "messages", string(jsonData)) + slog.Debug("Prepared messages", "messages", string(jsonData)) } attempts := 0 @@ -427,7 +427,7 @@ func WithReasoningEffort(effort string) OpenAIOption { case "low", "medium", "high": defaultReasoningEffort = effort default: - logging.Warn("Invalid reasoning effort, using default: medium") + slog.Warn("Invalid reasoning effort, using default: medium") } options.reasoningEffort = defaultReasoningEffort } diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go index 6aaf9ff0..45c63aca 100644 --- a/internal/llm/provider/provider.go +++ b/internal/llm/provider/provider.go @@ -6,8 +6,8 @@ import ( "github.com/opencode-ai/opencode/internal/llm/models" "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/message" + "log/slog" ) type EventType string @@ -166,13 +166,13 @@ func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.M messages = p.cleanMessages(messages) response, err := p.client.send(ctx, messages, tools) if err == nil && response != nil { - logging.Debug("API request token usage", + slog.Debug("API request token usage", "model", p.options.model.Name, "input_tokens", response.Usage.InputTokens, "output_tokens", response.Usage.OutputTokens, "cache_creation_tokens", response.Usage.CacheCreationTokens, "cache_read_tokens", response.Usage.CacheReadTokens, - "total_tokens", response.Usage.InputTokens + response.Usage.OutputTokens) + "total_tokens", response.Usage.InputTokens+response.Usage.OutputTokens) } return response, err } @@ -188,30 +188,30 @@ func (p *baseProvider[C]) MaxTokens() int64 { func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { messages = p.cleanMessages(messages) eventChan := p.client.stream(ctx, messages, tools) - + // Create a new channel to intercept events wrappedChan := make(chan ProviderEvent) - + go func() { defer close(wrappedChan) - + for event := range eventChan { // Pass the event through wrappedChan <- event - + // Log token usage when we get the complete event if event.Type == EventComplete && event.Response != nil { - logging.Debug("API streaming request token usage", + slog.Debug("API streaming request token usage", "model", p.options.model.Name, "input_tokens", event.Response.Usage.InputTokens, "output_tokens", event.Response.Usage.OutputTokens, "cache_creation_tokens", event.Response.Usage.CacheCreationTokens, "cache_read_tokens", event.Response.Usage.CacheReadTokens, - "total_tokens", event.Response.Usage.InputTokens + event.Response.Usage.OutputTokens) + "total_tokens", event.Response.Usage.InputTokens+event.Response.Usage.OutputTokens) } } }() - + return wrappedChan } diff --git a/internal/llm/tools/edit.go b/internal/llm/tools/edit.go index a5f0687c..44787f52 100644 --- a/internal/llm/tools/edit.go +++ b/internal/llm/tools/edit.go @@ -12,9 +12,9 @@ import ( "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/diff" "github.com/opencode-ai/opencode/internal/history" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/lsp" "github.com/opencode-ai/opencode/internal/permission" + "log/slog" ) type EditParams struct { @@ -234,7 +234,7 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string) _, err = e.files.CreateVersion(ctx, sessionID, filePath, content) if err != nil { // Log error but don't fail the operation - logging.Debug("Error creating file history version", "error", err) + slog.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) @@ -347,13 +347,13 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string // User Manually changed the content store an intermediate version _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent) if err != nil { - logging.Debug("Error creating file history version", "error", err) + slog.Debug("Error creating file history version", "error", err) } } // Store the new version _, err = e.files.CreateVersion(ctx, sessionID, filePath, "") if err != nil { - logging.Debug("Error creating file history version", "error", err) + slog.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) @@ -467,13 +467,13 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS // User Manually changed the content store an intermediate version _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent) if err != nil { - logging.Debug("Error creating file history version", "error", err) + slog.Debug("Error creating file history version", "error", err) } } // Store the new version _, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent) if err != nil { - logging.Debug("Error creating file history version", "error", err) + slog.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) diff --git a/internal/llm/tools/patch.go b/internal/llm/tools/patch.go index dcd3027b..e0c0bf5b 100644 --- a/internal/llm/tools/patch.go +++ b/internal/llm/tools/patch.go @@ -11,9 +11,9 @@ import ( "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/diff" "github.com/opencode-ai/opencode/internal/history" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/lsp" "github.com/opencode-ai/opencode/internal/permission" + "log/slog" ) type PatchParams struct { @@ -318,7 +318,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error // If not adding a file, create history entry for existing file _, err = p.files.Create(ctx, sessionID, absPath, oldContent) if err != nil { - logging.Debug("Error creating file history", "error", err) + slog.Debug("Error creating file history", "error", err) } } @@ -326,7 +326,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error // User manually changed content, store intermediate version _, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent) if err != nil { - logging.Debug("Error creating file history version", "error", err) + slog.Debug("Error creating file history version", "error", err) } } @@ -337,7 +337,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error _, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent) } if err != nil { - logging.Debug("Error creating file history version", "error", err) + slog.Debug("Error creating file history version", "error", err) } // Record file operations diff --git a/internal/llm/tools/write.go b/internal/llm/tools/write.go index decc51e4..617d69c2 100644 --- a/internal/llm/tools/write.go +++ b/internal/llm/tools/write.go @@ -12,9 +12,9 @@ import ( "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/diff" "github.com/opencode-ai/opencode/internal/history" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/lsp" "github.com/opencode-ai/opencode/internal/permission" + "log/slog" ) type WriteParams struct { @@ -201,13 +201,13 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error // User Manually changed the content store an intermediate version _, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent) if err != nil { - logging.Debug("Error creating file history version", "error", err) + slog.Debug("Error creating file history version", "error", err) } } // Store the new version _, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content) if err != nil { - logging.Debug("Error creating file history version", "error", err) + slog.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) diff --git a/internal/logging/logger.go b/internal/logging/logging.go similarity index 79% rename from internal/logging/logger.go rename to internal/logging/logging.go index 31462a54..af14a0de 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logging.go @@ -10,22 +10,6 @@ import ( "github.com/opencode-ai/opencode/internal/status" ) -func Info(msg string, args ...any) { - slog.Info(msg, args...) -} - -func Debug(msg string, args ...any) { - slog.Debug(msg, args...) -} - -func Warn(msg string, args ...any) { - slog.Warn(msg, args...) -} - -func Error(msg string, args ...any) { - slog.Error(msg, args...) -} - // RecoverPanic is a common function to handle panics gracefully. // It logs the error, creates a panic log file with stack trace, // and executes an optional cleanup function before returning. @@ -33,7 +17,7 @@ func RecoverPanic(name string, cleanup func()) { if r := recover(); r != nil { // Log the panic errorMsg := fmt.Sprintf("Panic in %s: %v", name, r) - Error(errorMsg) + slog.Error(errorMsg) status.Error(errorMsg) // Create a timestamped panic log file @@ -43,7 +27,7 @@ func RecoverPanic(name string, cleanup func()) { file, err := os.Create(filename) if err != nil { errMsg := fmt.Sprintf("Failed to create panic log: %v", err) - Error(errMsg) + slog.Error(errMsg) status.Error(errMsg) } else { defer file.Close() @@ -54,7 +38,7 @@ func RecoverPanic(name string, cleanup func()) { fmt.Fprintf(file, "Stack Trace:\n%s\n", debug.Stack()) infoMsg := fmt.Sprintf("Panic details written to %s", filename) - Info(infoMsg) + slog.Info(infoMsg) status.Info(infoMsg) } @@ -64,3 +48,4 @@ func RecoverPanic(name string, cleanup func()) { } } } + diff --git a/internal/logging/manager.go b/internal/logging/manager.go new file mode 100644 index 00000000..e8e96520 --- /dev/null +++ b/internal/logging/manager.go @@ -0,0 +1,48 @@ +package logging + +import ( + "context" + "sync" +) + +// Manager handles logging management +type Manager struct { + service Service + mu sync.RWMutex +} + +// Global instance of the logging manager +var globalManager *Manager + +// InitManager initializes the global logging manager with the provided service +func InitManager(service Service) { + globalManager = &Manager{ + service: service, + } + + // Subscribe to log events if needed + go func() { + ctx := context.Background() + _ = service.Subscribe(ctx) // Just subscribing to keep the channel open + }() +} + +// GetService returns the logging service +func GetService() Service { + if globalManager == nil { + return nil + } + + globalManager.mu.RLock() + defer globalManager.mu.RUnlock() + + return globalManager.service +} + +func Create(ctx context.Context, log Log) error { + if globalManager == nil { + return nil + } + return globalManager.service.Create(ctx, log) +} + diff --git a/internal/logging/message.go b/internal/logging/message.go deleted file mode 100644 index b8a42d96..00000000 --- a/internal/logging/message.go +++ /dev/null @@ -1,19 +0,0 @@ -package logging - -import ( - "time" -) - -// LogMessage is the event payload for a log message -type LogMessage struct { - ID string - Time time.Time - Level string - Message string `json:"msg"` - Attributes []Attr -} - -type Attr struct { - Key string - Value string -} diff --git a/internal/logging/service.go b/internal/logging/service.go new file mode 100644 index 00000000..8cf8039d --- /dev/null +++ b/internal/logging/service.go @@ -0,0 +1,167 @@ +package logging + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "github.com/google/uuid" + "github.com/opencode-ai/opencode/internal/db" + "github.com/opencode-ai/opencode/internal/pubsub" +) + +// Log represents a log entry in the system +type Log struct { + ID string + SessionID string + Timestamp int64 + Level string + Message string + Attributes map[string]string + CreatedAt int64 +} + +// Service defines the interface for log operations +type Service interface { + pubsub.Suscriber[Log] + Create(ctx context.Context, log Log) error + ListBySession(ctx context.Context, sessionID string) ([]Log, error) + ListAll(ctx context.Context, limit int) ([]Log, error) +} + +// service implements the Service interface +type service struct { + *pubsub.Broker[Log] + q db.Querier +} + +// NewService creates a new logging service +func NewService(q db.Querier) Service { + broker := pubsub.NewBroker[Log]() + return &service{ + Broker: broker, + q: q, + } +} + +// Create adds a new log entry to the database +func (s *service) Create(ctx context.Context, log Log) error { + // Generate ID if not provided + if log.ID == "" { + log.ID = uuid.New().String() + } + + // Set timestamp if not provided + if log.Timestamp == 0 { + log.Timestamp = time.Now().Unix() + } + + // Set created_at if not provided + if log.CreatedAt == 0 { + log.CreatedAt = time.Now().Unix() + } + + // Convert attributes to JSON string + var attributesJSON sql.NullString + if len(log.Attributes) > 0 { + attributesBytes, err := json.Marshal(log.Attributes) + if err != nil { + return err + } + attributesJSON = sql.NullString{ + String: string(attributesBytes), + Valid: true, + } + } + + // Convert session ID to SQL nullable string + var sessionID sql.NullString + if log.SessionID != "" { + sessionID = sql.NullString{ + String: log.SessionID, + Valid: true, + } + } + + // Insert log into database + err := s.q.CreateLog(ctx, db.CreateLogParams{ + ID: log.ID, + SessionID: sessionID, + Timestamp: log.Timestamp, + Level: log.Level, + Message: log.Message, + Attributes: attributesJSON, + CreatedAt: log.CreatedAt, + }) + + if err != nil { + return err + } + + // Publish event + s.Publish(pubsub.CreatedEvent, log) + return nil +} + +// ListBySession retrieves logs for a specific session +func (s *service) ListBySession(ctx context.Context, sessionID string) ([]Log, error) { + dbLogs, err := s.q.ListLogsBySession(ctx, sql.NullString{ + String: sessionID, + Valid: true, + }) + if err != nil { + return nil, err + } + + logs := make([]Log, len(dbLogs)) + for i, dbLog := range dbLogs { + logs[i] = s.fromDBItem(dbLog) + } + return logs, nil +} + +// ListAll retrieves all logs with a limit +func (s *service) ListAll(ctx context.Context, limit int) ([]Log, error) { + dbLogs, err := s.q.ListAllLogs(ctx, int64(limit)) + if err != nil { + return nil, err + } + + logs := make([]Log, len(dbLogs)) + for i, dbLog := range dbLogs { + logs[i] = s.fromDBItem(dbLog) + } + return logs, nil +} + +// fromDBItem converts a database log item to a Log struct +func (s *service) fromDBItem(item db.Log) Log { + log := Log{ + ID: item.ID, + Timestamp: item.Timestamp, + Level: item.Level, + Message: item.Message, + CreatedAt: item.CreatedAt, + } + + // Convert session ID if valid + if item.SessionID.Valid { + log.SessionID = item.SessionID.String + } + + // Parse attributes JSON if present + if item.Attributes.Valid { + attributes := make(map[string]string) + if err := json.Unmarshal([]byte(item.Attributes.String), &attributes); err == nil { + log.Attributes = attributes + } else { + // Initialize empty map if parsing fails + log.Attributes = make(map[string]string) + } + } else { + log.Attributes = make(map[string]string) + } + + return log +} diff --git a/internal/logging/writer.go b/internal/logging/writer.go index 7191f772..4b5bcc4f 100644 --- a/internal/logging/writer.go +++ b/internal/logging/writer.go @@ -5,59 +5,19 @@ import ( "context" "fmt" "strings" - "sync" "time" "github.com/go-logfmt/logfmt" - "github.com/opencode-ai/opencode/internal/pubsub" + "github.com/opencode-ai/opencode/internal/session" ) -const ( - // Maximum number of log messages to keep in memory - maxLogMessages = 1000 -) - -type LogData struct { - messages []LogMessage - *pubsub.Broker[LogMessage] - lock sync.Mutex -} - -func (l *LogData) Add(msg LogMessage) { - l.lock.Lock() - defer l.lock.Unlock() - - // Add new message - l.messages = append(l.messages, msg) - - // Trim if exceeding max capacity - if len(l.messages) > maxLogMessages { - l.messages = l.messages[len(l.messages)-maxLogMessages:] - } - - l.Publish(pubsub.CreatedEvent, msg) -} - -func (l *LogData) List() []LogMessage { - l.lock.Lock() - defer l.lock.Unlock() - return l.messages -} - -var defaultLogData = &LogData{ - messages: make([]LogMessage, 0, maxLogMessages), - Broker: pubsub.NewBroker[LogMessage](), -} - type writer struct{} func (w *writer) Write(p []byte) (int, error) { d := logfmt.NewDecoder(bytes.NewReader(p)) for d.ScanRecord() { - msg := LogMessage{ - ID: fmt.Sprintf("%d", time.Now().UnixNano()), - Time: time.Now(), - } + msg := Log{} + for d.ScanKeyval() { switch string(d.Key()) { case "time": @@ -65,19 +25,21 @@ func (w *writer) Write(p []byte) (int, error) { if err != nil { return 0, fmt.Errorf("parsing time: %w", err) } - msg.Time = parsed + msg.Timestamp = parsed.UnixMilli() case "level": msg.Level = strings.ToLower(string(d.Value())) case "msg": msg.Message = string(d.Value()) default: - msg.Attributes = append(msg.Attributes, Attr{ - Key: string(d.Key()), - Value: string(d.Value()), - }) + if msg.Attributes == nil { + msg.Attributes = make(map[string]string) + } + msg.Attributes[string(d.Key())] = string(d.Value()) } } - defaultLogData.Add(msg) + + msg.SessionID = session.CurrentSessionID() + Create(context.Background(), msg) } if d.Err() != nil { return 0, d.Err() @@ -89,11 +51,3 @@ func NewWriter() *writer { w := &writer{} return w } - -func Subscribe(ctx context.Context) <-chan pubsub.Event[LogMessage] { - return defaultLogData.Subscribe(ctx) -} - -func List() []LogMessage { - return defaultLogData.List() -} diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 290a01cb..ca201669 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -14,6 +14,8 @@ import ( "sync/atomic" "time" + "log/slog" + "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/lsp/protocol" @@ -97,10 +99,10 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er go func() { scanner := bufio.NewScanner(stderr) for scanner.Scan() { - logging.Info("LSP Server", "message", scanner.Text()) + slog.Info("LSP Server", "message", scanner.Text()) } if err := scanner.Err(); err != nil { - logging.Error("Error reading LSP stderr", "error", err) + slog.Error("Error reading LSP stderr", "error", err) } }() @@ -301,7 +303,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error { defer ticker.Stop() if cnf.DebugLSP { - logging.Debug("Waiting for LSP server to be ready...") + slog.Debug("Waiting for LSP server to be ready...") } // Determine server type for specialized initialization @@ -310,7 +312,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error { // For TypeScript-like servers, we need to open some key files first if serverType == ServerTypeTypeScript { if cnf.DebugLSP { - logging.Debug("TypeScript-like server detected, opening key configuration files") + slog.Debug("TypeScript-like server detected, opening key configuration files") } c.openKeyConfigFiles(ctx) } @@ -327,15 +329,15 @@ func (c *Client) WaitForServerReady(ctx context.Context) error { // Server responded successfully c.SetServerState(StateReady) if cnf.DebugLSP { - logging.Debug("LSP server is ready") + slog.Debug("LSP server is ready") } return nil } else { - logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType) + slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType) } if cnf.DebugLSP { - logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType) + slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType) } } } @@ -410,9 +412,9 @@ func (c *Client) openKeyConfigFiles(ctx context.Context) { if _, err := os.Stat(file); err == nil { // File exists, try to open it if err := c.OpenFile(ctx, file); err != nil { - logging.Debug("Failed to open key config file", "file", file, "error", err) + slog.Debug("Failed to open key config file", "file", file, "error", err) } else { - logging.Debug("Opened key config file for initialization", "file", file) + slog.Debug("Opened key config file for initialization", "file", file) } } } @@ -488,7 +490,7 @@ func (c *Client) pingTypeScriptServer(ctx context.Context) error { return nil }) if err != nil { - logging.Debug("Error walking directory for TypeScript files", "error", err) + slog.Debug("Error walking directory for TypeScript files", "error", err) } // Final fallback - just try a generic capability @@ -528,7 +530,7 @@ func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) { if err := c.OpenFile(ctx, path); err == nil { filesOpened++ if cnf.DebugLSP { - logging.Debug("Opened TypeScript file for initialization", "file", path) + slog.Debug("Opened TypeScript file for initialization", "file", path) } } } @@ -537,11 +539,11 @@ func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) { }) if err != nil && cnf.DebugLSP { - logging.Debug("Error walking directory for TypeScript files", "error", err) + slog.Debug("Error walking directory for TypeScript files", "error", err) } if cnf.DebugLSP { - logging.Debug("Opened TypeScript files for initialization", "count", filesOpened) + slog.Debug("Opened TypeScript files for initialization", "count", filesOpened) } } @@ -691,7 +693,7 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error { } if cnf.DebugLSP { - logging.Debug("Closing file", "file", filepath) + slog.Debug("Closing file", "file", filepath) } if err := c.Notify(ctx, "textDocument/didClose", params); err != nil { return err @@ -730,12 +732,12 @@ func (c *Client) CloseAllFiles(ctx context.Context) { for _, filePath := range filesToClose { err := c.CloseFile(ctx, filePath) if err != nil && cnf.DebugLSP { - logging.Warn("Error closing file", "file", filePath, "error", err) + slog.Warn("Error closing file", "file", filePath, "error", err) } } if cnf.DebugLSP { - logging.Debug("Closed all files", "files", filesToClose) + slog.Debug("Closed all files", "files", filesToClose) } } diff --git a/internal/lsp/discovery/integration.go b/internal/lsp/discovery/integration.go index 7820b644..d4438938 100644 --- a/internal/lsp/discovery/integration.go +++ b/internal/lsp/discovery/integration.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" + "log/slog" ) // IntegrateLSPServers discovers languages and LSP servers and integrates them into the application configuration @@ -23,9 +23,9 @@ func IntegrateLSPServers(workingDir string) error { // Always run language detection, but log differently for first run vs. subsequent runs if shouldInit || len(cfg.LSP) == 0 { - logging.Info("Running initial LSP auto-discovery...") + slog.Info("Running initial LSP auto-discovery...") } else { - logging.Debug("Running LSP auto-discovery to detect new languages...") + slog.Debug("Running LSP auto-discovery to detect new languages...") } // Configure LSP servers @@ -38,7 +38,7 @@ func IntegrateLSPServers(workingDir string) error { for langID, serverInfo := range servers { // Skip languages that already have a configured server if _, exists := cfg.LSP[langID]; exists { - logging.Debug("LSP server already configured for language", "language", langID) + slog.Debug("LSP server already configured for language", "language", langID) continue } @@ -49,12 +49,12 @@ func IntegrateLSPServers(workingDir string) error { Command: serverInfo.Path, Args: serverInfo.Args, } - logging.Info("Added LSP server to configuration", + slog.Info("Added LSP server to configuration", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path) } else { - logging.Warn("LSP server not available", + slog.Warn("LSP server not available", "language", langID, "command", serverInfo.Command, "installCmd", serverInfo.InstallCmd) @@ -63,4 +63,3 @@ func IntegrateLSPServers(workingDir string) error { return nil } - diff --git a/internal/lsp/discovery/language.go b/internal/lsp/discovery/language.go index 5e0a8d1a..69fef01d 100644 --- a/internal/lsp/discovery/language.go +++ b/internal/lsp/discovery/language.go @@ -6,8 +6,8 @@ import ( "strings" "sync" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/lsp" + "log/slog" ) // LanguageInfo stores information about a detected language @@ -206,9 +206,9 @@ func DetectLanguages(rootDir string) (map[string]LanguageInfo, error) { // Log detected languages for id, info := range languages { if info.IsPrimary { - logging.Debug("Detected primary language", "language", id, "files", info.FileCount, "projectFiles", len(info.ProjectFiles)) + slog.Debug("Detected primary language", "language", id, "files", info.FileCount, "projectFiles", len(info.ProjectFiles)) } else { - logging.Debug("Detected secondary language", "language", id, "files", info.FileCount) + slog.Debug("Detected secondary language", "language", id, "files", info.FileCount) } } @@ -295,4 +295,5 @@ func GetLanguageIDFromPath(path string) string { uri := "file://" + path langKind := lsp.DetectLanguageID(uri) return GetLanguageIDFromProtocol(string(langKind)) -} \ No newline at end of file +} + diff --git a/internal/lsp/discovery/server.go b/internal/lsp/discovery/server.go index 2b7d4eeb..98b56bc1 100644 --- a/internal/lsp/discovery/server.go +++ b/internal/lsp/discovery/server.go @@ -8,7 +8,7 @@ import ( "runtime" "strings" - "github.com/opencode-ai/opencode/internal/logging" + "log/slog" ) // ServerInfo contains information about an LSP server @@ -114,7 +114,7 @@ func FindLSPServer(languageID string) (ServerInfo, error) { if err == nil { serverInfo.Available = true serverInfo.Path = path - logging.Debug("Found LSP server in PATH", "language", languageID, "command", serverInfo.Command, "path", path) + slog.Debug("Found LSP server in PATH", "language", languageID, "command", serverInfo.Command, "path", path) return serverInfo, nil } @@ -125,13 +125,13 @@ func FindLSPServer(languageID string) (ServerInfo, error) { // Found the server serverInfo.Available = true serverInfo.Path = searchPath - logging.Debug("Found LSP server in common location", "language", languageID, "command", serverInfo.Command, "path", searchPath) + slog.Debug("Found LSP server in common location", "language", languageID, "command", serverInfo.Command, "path", searchPath) return serverInfo, nil } } // Server not found - logging.Debug("LSP server not found", "language", languageID, "command", serverInfo.Command) + slog.Debug("LSP server not found", "language", languageID, "command", serverInfo.Command) return serverInfo, fmt.Errorf("LSP server for %s not found. Install with: %s", languageID, serverInfo.InstallCmd) } @@ -140,7 +140,7 @@ func getCommonLSPPaths(languageID, command string) []string { var paths []string homeDir, err := os.UserHomeDir() if err != nil { - logging.Error("Failed to get user home directory", "error", err) + slog.Error("Failed to get user home directory", "error", err) return paths } @@ -148,21 +148,21 @@ func getCommonLSPPaths(languageID, command string) []string { switch runtime.GOOS { case "darwin": // macOS paths - paths = append(paths, + paths = append(paths, fmt.Sprintf("/usr/local/bin/%s", command), fmt.Sprintf("/opt/homebrew/bin/%s", command), fmt.Sprintf("%s/.local/bin/%s", homeDir, command), ) case "linux": // Linux paths - paths = append(paths, + paths = append(paths, fmt.Sprintf("/usr/bin/%s", command), fmt.Sprintf("/usr/local/bin/%s", command), fmt.Sprintf("%s/.local/bin/%s", homeDir, command), ) case "windows": // Windows paths - paths = append(paths, + paths = append(paths, fmt.Sprintf("%s\\AppData\\Local\\Programs\\%s.exe", homeDir, command), fmt.Sprintf("C:\\Program Files\\%s\\bin\\%s.exe", command, command), ) @@ -182,12 +182,12 @@ func getCommonLSPPaths(languageID, command string) []string { case "typescript", "javascript", "html", "css", "json", "yaml", "php": // Node.js global packages if runtime.GOOS == "windows" { - paths = append(paths, + paths = append(paths, fmt.Sprintf("%s\\AppData\\Roaming\\npm\\%s.cmd", homeDir, command), fmt.Sprintf("%s\\AppData\\Roaming\\npm\\node_modules\\.bin\\%s.cmd", homeDir, command), ) } else { - paths = append(paths, + paths = append(paths, fmt.Sprintf("%s/.npm-global/bin/%s", homeDir, command), fmt.Sprintf("%s/.nvm/versions/node/*/bin/%s", homeDir, command), fmt.Sprintf("/usr/local/lib/node_modules/.bin/%s", command), @@ -196,12 +196,12 @@ func getCommonLSPPaths(languageID, command string) []string { case "python": // Python paths if runtime.GOOS == "windows" { - paths = append(paths, + paths = append(paths, fmt.Sprintf("%s\\AppData\\Local\\Programs\\Python\\Python*\\Scripts\\%s.exe", homeDir, command), fmt.Sprintf("C:\\Python*\\Scripts\\%s.exe", command), ) } else { - paths = append(paths, + paths = append(paths, fmt.Sprintf("%s/.local/bin/%s", homeDir, command), fmt.Sprintf("%s/.pyenv/shims/%s", homeDir, command), fmt.Sprintf("/usr/local/bin/%s", command), @@ -210,12 +210,12 @@ func getCommonLSPPaths(languageID, command string) []string { case "rust": // Rust paths if runtime.GOOS == "windows" { - paths = append(paths, + paths = append(paths, fmt.Sprintf("%s\\.rustup\\toolchains\\*\\bin\\%s.exe", homeDir, command), fmt.Sprintf("%s\\.cargo\\bin\\%s.exe", homeDir, command), ) } else { - paths = append(paths, + paths = append(paths, fmt.Sprintf("%s/.rustup/toolchains/*/bin/%s", homeDir, command), fmt.Sprintf("%s/.cargo/bin/%s", homeDir, command), ) @@ -248,7 +248,7 @@ func getCommonLSPPaths(languageID, command string) []string { // getVSCodeExtensionsPath returns the path to VSCode extensions directory func getVSCodeExtensionsPath(homeDir string) string { var basePath string - + switch runtime.GOOS { case "darwin": basePath = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage") @@ -259,12 +259,12 @@ func getVSCodeExtensionsPath(homeDir string) string { default: return "" } - + // Check if the directory exists if _, err := os.Stat(basePath); err != nil { return "" } - + return basePath } @@ -275,32 +275,33 @@ func ConfigureLSPServers(rootDir string) (map[string]ServerInfo, error) { if err != nil { return nil, fmt.Errorf("failed to detect languages: %w", err) } - + // Find LSP servers for detected languages servers := make(map[string]ServerInfo) for langID, langInfo := range languages { // Prioritize primary languages but include all languages that have server definitions if !langInfo.IsPrimary && langInfo.FileCount < 3 { // Skip non-primary languages with very few files - logging.Debug("Skipping non-primary language with few files", "language", langID, "files", langInfo.FileCount) + slog.Debug("Skipping non-primary language with few files", "language", langID, "files", langInfo.FileCount) continue } - + // Check if we have a server for this language serverInfo, err := FindLSPServer(langID) if err != nil { - logging.Warn("LSP server not found", "language", langID, "error", err) + slog.Warn("LSP server not found", "language", langID, "error", err) continue } - + // Add to the map of configured servers servers[langID] = serverInfo if langInfo.IsPrimary { - logging.Info("Configured LSP server for primary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path) + slog.Info("Configured LSP server for primary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path) } else { - logging.Info("Configured LSP server for secondary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path) + slog.Info("Configured LSP server for secondary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path) } } - + return servers, nil -} \ No newline at end of file +} + diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index e24945b4..656ec122 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -4,9 +4,9 @@ import ( "encoding/json" "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/lsp/protocol" "github.com/opencode-ai/opencode/internal/lsp/util" + "log/slog" ) // Requests @@ -18,7 +18,7 @@ func HandleWorkspaceConfiguration(params json.RawMessage) (any, error) { func HandleRegisterCapability(params json.RawMessage) (any, error) { var registerParams protocol.RegistrationParams if err := json.Unmarshal(params, ®isterParams); err != nil { - logging.Error("Error unmarshaling registration params", "error", err) + slog.Error("Error unmarshaling registration params", "error", err) return nil, err } @@ -28,13 +28,13 @@ func HandleRegisterCapability(params json.RawMessage) (any, error) { // Parse the registration options optionsJSON, err := json.Marshal(reg.RegisterOptions) if err != nil { - logging.Error("Error marshaling registration options", "error", err) + slog.Error("Error marshaling registration options", "error", err) continue } var options protocol.DidChangeWatchedFilesRegistrationOptions if err := json.Unmarshal(optionsJSON, &options); err != nil { - logging.Error("Error unmarshaling registration options", "error", err) + slog.Error("Error unmarshaling registration options", "error", err) continue } @@ -54,7 +54,7 @@ func HandleApplyEdit(params json.RawMessage) (any, error) { err := util.ApplyWorkspaceEdit(edit.Edit) if err != nil { - logging.Error("Error applying workspace edit", "error", err) + slog.Error("Error applying workspace edit", "error", err) return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil } @@ -89,7 +89,7 @@ func HandleServerMessage(params json.RawMessage) { } if err := json.Unmarshal(params, &msg); err == nil { if cnf.DebugLSP { - logging.Debug("Server message", "type", msg.Type, "message", msg.Message) + slog.Debug("Server message", "type", msg.Type, "message", msg.Message) } } } @@ -97,7 +97,7 @@ func HandleServerMessage(params json.RawMessage) { func HandleDiagnostics(client *Client, params json.RawMessage) { var diagParams protocol.PublishDiagnosticsParams if err := json.Unmarshal(params, &diagParams); err != nil { - logging.Error("Error unmarshaling diagnostics params", "error", err) + slog.Error("Error unmarshaling diagnostics params", "error", err) return } diff --git a/internal/lsp/transport.go b/internal/lsp/transport.go index 9b07d53c..577ba2ed 100644 --- a/internal/lsp/transport.go +++ b/internal/lsp/transport.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" + "log/slog" ) // Write writes an LSP message to the given writer @@ -21,7 +21,7 @@ func WriteMessage(w io.Writer, msg *Message) error { cnf := config.Get() if cnf.DebugLSP { - logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID) + slog.Debug("Sending message to server", "method", msg.Method, "id", msg.ID) } _, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(data)) @@ -50,7 +50,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) { line = strings.TrimSpace(line) if cnf.DebugLSP { - logging.Debug("Received header", "line", line) + slog.Debug("Received header", "line", line) } if line == "" { @@ -66,7 +66,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) { } if cnf.DebugLSP { - logging.Debug("Content-Length", "length", contentLength) + slog.Debug("Content-Length", "length", contentLength) } // Read content @@ -77,7 +77,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) { } if cnf.DebugLSP { - logging.Debug("Received content", "content", string(content)) + slog.Debug("Received content", "content", string(content)) } // Parse message @@ -96,7 +96,7 @@ func (c *Client) handleMessages() { msg, err := ReadMessage(c.stdout) if err != nil { if cnf.DebugLSP { - logging.Error("Error reading message", "error", err) + slog.Error("Error reading message", "error", err) } return } @@ -104,7 +104,7 @@ func (c *Client) handleMessages() { // Handle server->client request (has both Method and ID) if msg.Method != "" && msg.ID != 0 { if cnf.DebugLSP { - logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID) + slog.Debug("Received request from server", "method", msg.Method, "id", msg.ID) } response := &Message{ @@ -144,7 +144,7 @@ func (c *Client) handleMessages() { // Send response back to server if err := WriteMessage(c.stdin, response); err != nil { - logging.Error("Error sending response to server", "error", err) + slog.Error("Error sending response to server", "error", err) } continue @@ -158,11 +158,11 @@ func (c *Client) handleMessages() { if ok { if cnf.DebugLSP { - logging.Debug("Handling notification", "method", msg.Method) + slog.Debug("Handling notification", "method", msg.Method) } go handler(msg.Params) } else if cnf.DebugLSP { - logging.Debug("No handler for notification", "method", msg.Method) + slog.Debug("No handler for notification", "method", msg.Method) } continue } @@ -175,12 +175,12 @@ func (c *Client) handleMessages() { if ok { if cnf.DebugLSP { - logging.Debug("Received response for request", "id", msg.ID) + slog.Debug("Received response for request", "id", msg.ID) } ch <- msg close(ch) } else if cnf.DebugLSP { - logging.Debug("No handler for response", "id", msg.ID) + slog.Debug("No handler for response", "id", msg.ID) } } } @@ -192,7 +192,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any id := c.nextID.Add(1) if cnf.DebugLSP { - logging.Debug("Making call", "method", method, "id", id) + slog.Debug("Making call", "method", method, "id", id) } msg, err := NewRequest(id, method, params) @@ -218,14 +218,14 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any } if cnf.DebugLSP { - logging.Debug("Request sent", "method", method, "id", id) + slog.Debug("Request sent", "method", method, "id", id) } // Wait for response resp := <-ch if cnf.DebugLSP { - logging.Debug("Received response", "id", id) + slog.Debug("Received response", "id", id) } if resp.Error != nil { @@ -251,7 +251,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any func (c *Client) Notify(ctx context.Context, method string, params any) error { cnf := config.Get() if cnf.DebugLSP { - logging.Debug("Sending notification", "method", method) + slog.Debug("Sending notification", "method", method) } msg, err := NewNotification(method, params) diff --git a/internal/lsp/watcher/watcher.go b/internal/lsp/watcher/watcher.go index 58ad2569..ed8fe6b7 100644 --- a/internal/lsp/watcher/watcher.go +++ b/internal/lsp/watcher/watcher.go @@ -13,9 +13,9 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/fsnotify/fsnotify" "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/lsp" "github.com/opencode-ai/opencode/internal/lsp/protocol" + "log/slog" ) // WorkspaceWatcher manages LSP file watching @@ -46,7 +46,7 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher { func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) { cnf := config.Get() - logging.Debug("Adding file watcher registrations") + slog.Debug("Adding file watcher registrations") w.registrationMu.Lock() defer w.registrationMu.Unlock() @@ -55,33 +55,33 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc // Print detailed registration information for debugging if cnf.DebugLSP { - logging.Debug("Adding file watcher registrations", + slog.Debug("Adding file watcher registrations", "id", id, "watchers", len(watchers), "total", len(w.registrations), ) for i, watcher := range watchers { - logging.Debug("Registration", "index", i+1) + slog.Debug("Registration", "index", i+1) // Log the GlobPattern switch v := watcher.GlobPattern.Value.(type) { case string: - logging.Debug("GlobPattern", "pattern", v) + slog.Debug("GlobPattern", "pattern", v) case protocol.RelativePattern: - logging.Debug("GlobPattern", "pattern", v.Pattern) + slog.Debug("GlobPattern", "pattern", v.Pattern) // Log BaseURI details switch u := v.BaseURI.Value.(type) { case string: - logging.Debug("BaseURI", "baseURI", u) + slog.Debug("BaseURI", "baseURI", u) case protocol.DocumentUri: - logging.Debug("BaseURI", "baseURI", u) + slog.Debug("BaseURI", "baseURI", u) default: - logging.Debug("BaseURI", "baseURI", u) + slog.Debug("BaseURI", "baseURI", u) } default: - logging.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v)) + slog.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v)) } // Log WatchKind @@ -90,13 +90,13 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc watchKind = *watcher.Kind } - logging.Debug("WatchKind", "kind", watchKind) + slog.Debug("WatchKind", "kind", watchKind) } } // Determine server type for specialized handling serverName := getServerNameFromContext(ctx) - logging.Debug("Server type detected", "serverName", serverName) + slog.Debug("Server type detected", "serverName", serverName) // Check if this server has sent file watchers hasFileWatchers := len(watchers) > 0 @@ -124,7 +124,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc filesOpened += highPriorityFilesOpened if cnf.DebugLSP { - logging.Debug("Opened high-priority files", + slog.Debug("Opened high-priority files", "count", highPriorityFilesOpened, "serverName", serverName) } @@ -132,7 +132,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc // If we've already opened enough high-priority files, we might not need more if filesOpened >= maxFilesToOpen { if cnf.DebugLSP { - logging.Debug("Reached file limit with high-priority files", + slog.Debug("Reached file limit with high-priority files", "filesOpened", filesOpened, "maxFiles", maxFilesToOpen) } @@ -150,7 +150,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc if d.IsDir() { if path != w.workspacePath && shouldExcludeDir(path) { if cnf.DebugLSP { - logging.Debug("Skipping excluded directory", "path", path) + slog.Debug("Skipping excluded directory", "path", path) } return filepath.SkipDir } @@ -178,7 +178,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc elapsedTime := time.Since(startTime) if cnf.DebugLSP { - logging.Debug("Limited workspace scan complete", + slog.Debug("Limited workspace scan complete", "filesOpened", filesOpened, "maxFiles", maxFilesToOpen, "elapsedTime", elapsedTime.Seconds(), @@ -187,11 +187,11 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc } if err != nil && cnf.DebugLSP { - logging.Debug("Error scanning workspace for files to open", "error", err) + slog.Debug("Error scanning workspace for files to open", "error", err) } }() } else if cnf.DebugLSP { - logging.Debug("Using on-demand file loading for server", "server", serverName) + slog.Debug("Using on-demand file loading for server", "server", serverName) } } @@ -264,7 +264,7 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern) if err != nil { if cnf.DebugLSP { - logging.Debug("Error finding high-priority files", "pattern", pattern, "error", err) + slog.Debug("Error finding high-priority files", "pattern", pattern, "error", err) } continue } @@ -282,12 +282,12 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName // Open the file if err := w.client.OpenFile(ctx, fullPath); err != nil { if cnf.DebugLSP { - logging.Debug("Error opening high-priority file", "path", fullPath, "error", err) + slog.Debug("Error opening high-priority file", "path", fullPath, "error", err) } } else { filesOpened++ if cnf.DebugLSP { - logging.Debug("Opened high-priority file", "path", fullPath) + slog.Debug("Opened high-priority file", "path", fullPath) } } @@ -319,7 +319,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str } serverName := getServerNameFromContext(ctx) - logging.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName) + slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName) // Register handler for file watcher registrations from the server lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) { @@ -328,7 +328,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str watcher, err := fsnotify.NewWatcher() if err != nil { - logging.Error("Error creating watcher", "error", err) + slog.Error("Error creating watcher", "error", err) } defer watcher.Close() @@ -342,7 +342,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str if d.IsDir() && path != workspacePath { if shouldExcludeDir(path) { if cnf.DebugLSP { - logging.Debug("Skipping excluded directory", "path", path) + slog.Debug("Skipping excluded directory", "path", path) } return filepath.SkipDir } @@ -352,14 +352,14 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str if d.IsDir() { err = watcher.Add(path) if err != nil { - logging.Error("Error watching path", "path", path, "error", err) + slog.Error("Error watching path", "path", path, "error", err) } } return nil }) if err != nil { - logging.Error("Error walking workspace", "error", err) + slog.Error("Error walking workspace", "error", err) } // Event loop @@ -381,18 +381,18 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str if err != nil { if os.IsNotExist(err) { // File was deleted between event and processing - ignore - logging.Debug("File deleted between create event and stat", "path", event.Name) + slog.Debug("File deleted between create event and stat", "path", event.Name) continue } - logging.Error("Error getting file info", "path", event.Name, "error", err) + slog.Error("Error getting file info", "path", event.Name, "error", err) continue } - + if info.IsDir() { // Skip excluded directories if !shouldExcludeDir(event.Name) { if err := watcher.Add(event.Name); err != nil { - logging.Error("Error adding directory to watcher", "path", event.Name, "error", err) + slog.Error("Error adding directory to watcher", "path", event.Name, "error", err) } } } else { @@ -406,7 +406,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str // Debug logging if cnf.DebugLSP { matched, kind := w.isPathWatched(event.Name) - logging.Debug("File event", + slog.Debug("File event", "path", event.Name, "operation", event.Op.String(), "watched", matched, @@ -427,7 +427,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str // Just send the notification if needed info, err := os.Stat(event.Name) if err != nil { - logging.Error("Error getting file info", "path", event.Name, "error", err) + slog.Error("Error getting file info", "path", event.Name, "error", err) return } if !info.IsDir() && watchKind&protocol.WatchCreate != 0 { @@ -455,7 +455,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str if !ok { return } - logging.Error("Error watching file", "error", err) + slog.Error("Error watching file", "error", err) } } } @@ -580,7 +580,7 @@ func matchesSimpleGlob(pattern, path string) bool { // Fall back to simple matching for simpler patterns matched, err := filepath.Match(pattern, path) if err != nil { - logging.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err) + slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err) return false } @@ -591,7 +591,7 @@ func matchesSimpleGlob(pattern, path string) bool { func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool { patternInfo, err := pattern.AsPattern() if err != nil { - logging.Error("Error parsing pattern", "pattern", pattern, "error", err) + slog.Error("Error parsing pattern", "pattern", pattern, "error", err) return false } @@ -616,7 +616,7 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt // Make path relative to basePath for matching relPath, err := filepath.Rel(basePath, path) if err != nil { - logging.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err) + slog.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err) return false } relPath = filepath.ToSlash(relPath) @@ -654,15 +654,15 @@ func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri stri func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) { // If the file is open and it's a change event, use didChange notification filePath := uri[7:] // Remove "file://" prefix - + if changeType == protocol.FileChangeType(protocol.Deleted) { // Always clear diagnostics for deleted files w.client.ClearDiagnosticsForURI(protocol.DocumentUri(uri)) - + // If the file was open, close it in the LSP client if w.client.IsFileOpen(filePath) { if err := w.client.CloseFile(ctx, filePath); err != nil { - logging.Debug("Error closing deleted file in LSP client", "file", filePath, "error", err) + slog.Debug("Error closing deleted file in LSP client", "file", filePath, "error", err) // Continue anyway - the file is gone } } @@ -671,19 +671,19 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan if _, err := os.Stat(filePath); err != nil { if os.IsNotExist(err) { // File was deleted between the event and now - treat as delete - logging.Debug("File deleted between change event and processing", "file", filePath) + slog.Debug("File deleted between change event and processing", "file", filePath) w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted)) return } - logging.Error("Error getting file info", "path", filePath, "error", err) + slog.Error("Error getting file info", "path", filePath, "error", err) return } - + // File exists and is open, notify change if w.client.IsFileOpen(filePath) { err := w.client.NotifyChange(ctx, filePath) if err != nil { - logging.Error("Error notifying change", "error", err) + slog.Error("Error notifying change", "error", err) } return } @@ -692,17 +692,17 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan if _, err := os.Stat(filePath); err != nil { if os.IsNotExist(err) { // File was deleted between the event and now - ignore - logging.Debug("File deleted between create event and processing", "file", filePath) + slog.Debug("File deleted between create event and processing", "file", filePath) return } - logging.Error("Error getting file info", "path", filePath, "error", err) + slog.Error("Error getting file info", "path", filePath, "error", err) return } } // Notify LSP server about the file event using didChangeWatchedFiles if err := w.notifyFileEvent(ctx, uri, changeType); err != nil { - logging.Error("Error notifying LSP server about file event", "error", err) + slog.Error("Error notifying LSP server about file event", "error", err) } } @@ -710,7 +710,7 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error { cnf := config.Get() if cnf.DebugLSP { - logging.Debug("Notifying file event", + slog.Debug("Notifying file event", "uri", uri, "changeType", changeType, ) @@ -874,7 +874,7 @@ func shouldExcludeFile(filePath string) bool { if strings.HasSuffix(filePath, "~") { return true } - + // Skip numeric temporary files (often created by editors) if _, err := strconv.Atoi(fileName); err == nil { return true @@ -890,7 +890,7 @@ func shouldExcludeFile(filePath string) bool { // Skip large files if info.Size() > maxFileSize { if cnf.DebugLSP { - logging.Debug("Skipping large file", + slog.Debug("Skipping large file", "path", filePath, "size", info.Size(), "maxSize", maxFileSize, @@ -913,13 +913,13 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) { if err != nil { if os.IsNotExist(err) { // File was deleted between event and processing - ignore - logging.Debug("File deleted between event and openMatchingFile", "path", path) + slog.Debug("File deleted between event and openMatchingFile", "path", path) return } - logging.Error("Error getting file info", "path", path, "error", err) + slog.Error("Error getting file info", "path", path, "error", err) return } - + if info.IsDir() { return } @@ -938,10 +938,10 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) { // This helps with project initialization for certain language servers if isHighPriorityFile(path, serverName) { if cnf.DebugLSP { - logging.Debug("Opening high-priority file", "path", path, "serverName", serverName) + slog.Debug("Opening high-priority file", "path", path, "serverName", serverName) } if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP { - logging.Error("Error opening high-priority file", "path", path, "error", err) + slog.Error("Error opening high-priority file", "path", path, "error", err) } return } @@ -953,7 +953,7 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) { // Check file size - for preloading we're more conservative if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files if cnf.DebugLSP { - logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size()) + slog.Debug("Skipping large file for preloading", "path", path, "size", info.Size()) } return } @@ -985,7 +985,7 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) { if shouldOpen { // Don't need to check if it's already open - the client.OpenFile handles that if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP { - logging.Error("Error opening file", "path", path, "error", err) + slog.Error("Error opening file", "path", path, "error", err) } } } diff --git a/internal/session/manager.go b/internal/session/manager.go index 8df421bd..cd80fa58 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -4,8 +4,8 @@ import ( "context" "sync" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/pubsub" + "log/slog" ) // Manager handles session management, tracking the currently active session. @@ -41,7 +41,7 @@ func InitManager(service Service) { // SetCurrentSession changes the active session to the one with the specified ID. func SetCurrentSession(sessionID string) { if globalManager == nil { - logging.Warn("Session manager not initialized") + slog.Warn("Session manager not initialized") return } @@ -49,18 +49,17 @@ func SetCurrentSession(sessionID string) { defer globalManager.mu.Unlock() globalManager.currentSessionID = sessionID - logging.Debug("Current session changed", "sessionID", sessionID) + slog.Debug("Current session changed", "sessionID", sessionID) } // CurrentSessionID returns the ID of the currently active session. func CurrentSessionID() string { if globalManager == nil { - logging.Warn("Session manager not initialized") return "" } - globalManager.mu.RLock() - defer globalManager.mu.RUnlock() + // globalManager.mu.RLock() + // defer globalManager.mu.RUnlock() return globalManager.currentSessionID } @@ -69,7 +68,6 @@ func CurrentSessionID() string { // If no session is set or the session cannot be found, it returns nil. func CurrentSession() *Session { if globalManager == nil { - logging.Warn("Session manager not initialized") return nil } @@ -80,9 +78,8 @@ func CurrentSession() *Session { session, err := globalManager.service.Get(context.Background(), sessionID) if err != nil { - logging.Warn("Failed to get current session", "err", err) return nil } return &session -} \ No newline at end of file +} diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go index f747dc9a..fe4223f5 100644 --- a/internal/tui/components/dialog/filepicker.go +++ b/internal/tui/components/dialog/filepicker.go @@ -16,13 +16,13 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/opencode-ai/opencode/internal/app" "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/status" "github.com/opencode-ai/opencode/internal/tui/image" "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" + "log/slog" ) const ( @@ -376,7 +376,7 @@ func (f *filepickerCmp) IsCWDFocused() bool { func NewFilepickerCmp(app *app.App) FilepickerCmp { homepath, err := os.UserHomeDir() if err != nil { - logging.Error("error loading user files") + slog.Error("error loading user files") return nil } baseDir := DirNode{parent: nil, directory: homepath} @@ -392,7 +392,7 @@ func NewFilepickerCmp(app *app.App) FilepickerCmp { func (f *filepickerCmp) getCurrentFileBelowCursor() { if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) { - logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor)) + slog.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor)) f.viewport.SetContent("Preview unavailable") return } @@ -405,7 +405,7 @@ func (f *filepickerCmp) getCurrentFileBelowCursor() { go func() { imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath) if err != nil { - logging.Error(err.Error()) + slog.Error(err.Error()) f.viewport.SetContent("Preview unavailable") return } @@ -418,7 +418,7 @@ func (f *filepickerCmp) getCurrentFileBelowCursor() { } func readDir(path string, showHidden bool) []os.DirEntry { - logging.Info(fmt.Sprintf("Reading directory: %s", path)) + slog.Info(fmt.Sprintf("Reading directory: %s", path)) entriesChan := make(chan []os.DirEntry, 1) errChan := make(chan error, 1) diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go index 7bbfd17d..da6edff1 100644 --- a/internal/tui/components/logs/details.go +++ b/internal/tui/components/logs/details.go @@ -23,17 +23,12 @@ type DetailComponent interface { type detailCmp struct { width, height int - currentLog logging.LogMessage + currentLog logging.Log viewport viewport.Model focused bool } func (i *detailCmp) Init() tea.Cmd { - messages := logging.List() - if len(messages) == 0 { - return nil - } - i.currentLog = messages[0] return nil } @@ -42,8 +37,12 @@ func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case selectedLogMsg: if msg.ID != i.currentLog.ID { - i.currentLog = logging.LogMessage(msg) - i.updateContent() + i.currentLog = logging.Log(msg) + // Defer content update to avoid blocking the UI + cmd = tea.Tick(time.Millisecond*1, func(time.Time) tea.Msg { + i.updateContent() + return nil + }) } case tea.KeyMsg: // Only process keyboard input when focused @@ -55,7 +54,7 @@ func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return i, cmd } - return i, nil + return i, cmd } func (i *detailCmp) updateContent() { @@ -66,9 +65,12 @@ func (i *detailCmp) updateContent() { timeStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) levelStyle := getLevelStyle(i.currentLog.Level) + // Format timestamp + timeStr := time.Unix(i.currentLog.Timestamp, 0).Format(time.RFC3339) + header := lipgloss.JoinHorizontal( lipgloss.Center, - timeStyle.Render(i.currentLog.Time.Format(time.RFC3339)), + timeStyle.Render(timeStr), " ", levelStyle.Render(i.currentLog.Level), ) @@ -93,23 +95,33 @@ func (i *detailCmp) updateContent() { keyStyle := lipgloss.NewStyle().Foreground(t.Primary()).Bold(true) valueStyle := lipgloss.NewStyle().Foreground(t.Text()) - for _, attr := range i.currentLog.Attributes { - attrLine := fmt.Sprintf("%s: %s", - keyStyle.Render(attr.Key), - valueStyle.Render(attr.Value), + for key, value := range i.currentLog.Attributes { + attrLine := fmt.Sprintf("%s: %s", + keyStyle.Render(key), + valueStyle.Render(value), ) + content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine)) content.WriteString("\n") } } + // Session ID if available + if i.currentLog.SessionID != "" { + sessionStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text()) + content.WriteString("\n") + content.WriteString(sessionStyle.Render("Session:")) + content.WriteString("\n") + content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.SessionID)) + } + i.viewport.SetContent(content.String()) } func getLevelStyle(level string) lipgloss.Style { style := lipgloss.NewStyle().Bold(true) t := theme.CurrentTheme() - + switch strings.ToLower(level) { case "info": return style.Foreground(t.Info()) diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go index fe30c6aa..e7fc4ea7 100644 --- a/internal/tui/components/logs/table.go +++ b/internal/tui/components/logs/table.go @@ -1,15 +1,17 @@ package logs import ( - "slices" + "context" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/pubsub" + "github.com/opencode-ai/opencode/internal/session" + "github.com/opencode-ai/opencode/internal/tui/components/chat" "github.com/opencode-ai/opencode/internal/tui/layout" - // "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" ) @@ -23,46 +25,97 @@ type TableComponent interface { type tableCmp struct { table table.Model focused bool + logs []logging.Log } -type selectedLogMsg logging.LogMessage +type selectedLogMsg logging.Log + +// Message for when logs are loaded from the database +type logsLoadedMsg struct { + logs []logging.Log +} func (i *tableCmp) Init() tea.Cmd { - i.setRows() - return nil + return i.fetchLogs() +} + +func (i *tableCmp) fetchLogs() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + loggingService := logging.GetService() + if loggingService == nil { + return nil + } + + var logs []logging.Log + var err error + sessionId := session.CurrentSessionID() + + // Limit the number of logs to improve performance + const logLimit = 100 + if sessionId == "" { + logs, err = loggingService.ListAll(ctx, logLimit) + } else { + logs, err = loggingService.ListBySession(ctx, sessionId) + // Trim logs if there are too many + if err == nil && len(logs) > logLimit { + logs = logs[len(logs)-logLimit:] + } + } + + if err != nil { + return nil + } + + return logsLoadedMsg{logs: logs} + } } func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - switch msg.(type) { - case pubsub.Event[logging.LogMessage]: - i.setRows() + + switch msg := msg.(type) { + case logsLoadedMsg: + i.logs = msg.logs + i.updateRows() + return i, nil + + case chat.SessionSelectedMsg: + return i, i.fetchLogs() + + case pubsub.Event[logging.Log]: + // Only handle created events + if msg.Type == pubsub.CreatedEvent { + // Add the new log to our list + i.logs = append([]logging.Log{msg.Payload}, i.logs...) + // Keep the list at a reasonable size + if len(i.logs) > 100 { + i.logs = i.logs[:100] + } + i.updateRows() + } return i, nil } - + // Only process keyboard input when focused if _, ok := msg.(tea.KeyMsg); ok && !i.focused { return i, nil } - + t, cmd := i.table.Update(msg) cmds = append(cmds, cmd) i.table = t + + // Only send selected log message when selection changes selectedRow := i.table.SelectedRow() if selectedRow != nil { - // Always send the selected log message when a row is selected - // This fixes the issue where navigation doesn't update the detail pane - // when returning to the logs page - var log logging.LogMessage - for _, row := range logging.List() { - if row.ID == selectedRow[0] { - log = row + // Use a map for faster lookups by ID + for _, log := range i.logs { + if log.ID == selectedRow[0] { + cmds = append(cmds, util.CmdHandler(selectedLogMsg(log))) break } } - if log.ID != "" { - cmds = append(cmds, util.CmdHandler(selectedLogMsg(log))) - } } return i, tea.Batch(cmds...) } @@ -105,25 +158,20 @@ func (i *tableCmp) BindingKeys() []key.Binding { return layout.KeyMapToSlice(i.table.KeyMap) } -func (i *tableCmp) setRows() { - rows := []table.Row{} +func (i *tableCmp) updateRows() { + rows := make([]table.Row, 0, len(i.logs)) - logs := logging.List() - slices.SortFunc(logs, func(a, b logging.LogMessage) int { - if a.Time.Before(b.Time) { - return 1 - } - if a.Time.After(b.Time) { - return -1 - } - return 0 - }) + // Logs are already sorted by timestamp (newest first) from the database query + // Skip the expensive sort operation + + for _, log := range i.logs { + // Format timestamp as time + timeStr := time.Unix(log.Timestamp, 0).Format("15:04:05") - for _, log := range logs { // Include ID as hidden first column for selection row := table.Row{ log.ID, - log.Time.Format("15:04:05"), + timeStr, log.Level, log.Message, } @@ -146,6 +194,7 @@ func NewLogsTable() TableComponent { tableModel.Focus() return &tableCmp{ table: tableModel, + logs: []logging.Log{}, } } diff --git a/internal/tui/theme/manager.go b/internal/tui/theme/manager.go index 7bb887ff..c9e9f420 100644 --- a/internal/tui/theme/manager.go +++ b/internal/tui/theme/manager.go @@ -2,13 +2,13 @@ package theme import ( "fmt" + "log/slog" "slices" "strings" "sync" "github.com/alecthomas/chroma/v2/styles" "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" ) // Manager handles theme registration, selection, and retrieval. @@ -49,19 +49,19 @@ func SetTheme(name string) error { defer globalManager.mu.Unlock() delete(styles.Registry, "charm") - + // Handle custom theme if name == "custom" { cfg := config.Get() if cfg == nil || cfg.TUI.CustomTheme == nil || len(cfg.TUI.CustomTheme) == 0 { return fmt.Errorf("custom theme selected but no custom theme colors defined in config") } - + customTheme, err := LoadCustomTheme(cfg.TUI.CustomTheme) if err != nil { return fmt.Errorf("failed to load custom theme: %w", err) } - + // Register the custom theme globalManager.themes["custom"] = customTheme } else if _, exists := globalManager.themes[name]; !exists { @@ -73,7 +73,7 @@ func SetTheme(name string) error { // Update the config file using viper if err := updateConfigTheme(name); err != nil { // Log the error but don't fail the theme change - logging.Warn("Warning: Failed to update config file with new theme", "err", err) + slog.Warn("Warning: Failed to update config file with new theme", "err", err) } return nil @@ -140,7 +140,7 @@ func LoadCustomTheme(customTheme map[string]any) (Theme, error) { for key, value := range customTheme { adaptiveColor, err := ParseAdaptiveColor(value) if err != nil { - logging.Warn("Invalid color definition in custom theme", "key", key, "error", err) + slog.Warn("Invalid color definition in custom theme", "key", key, "error", err) continue // Skip this color but continue processing others } @@ -203,7 +203,7 @@ func LoadCustomTheme(customTheme map[string]any) (Theme, error) { case "diffremovedlinenumberbg": theme.DiffRemovedLineNumberBgColor = adaptiveColor default: - logging.Warn("Unknown color key in custom theme", "key", key) + slog.Warn("Unknown color key in custom theme", "key", key) } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 55269af6..ddc57432 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -201,7 +201,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) - case pubsub.Event[logging.LogMessage]: + case pubsub.Event[logging.Log]: a.pages[page.LogsPage], cmd = a.pages[page.LogsPage].Update(msg) cmds = append(cmds, cmd)