From 9d7c5efb9b0b60c62aef3777b65b458a31ebbc88 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Wed, 28 May 2025 15:36:31 -0500 Subject: wip: refactoring tui --- README.md | 2 +- cmd/opencode/opencode.go | 32 ---- cmd/root.go | 26 +-- cmd/root_test.go | 143 ---------------- internal/app/app.go | 180 -------------------- internal/app/app_new.go | 203 ---------------------- internal/app/interfaces.go | 42 ----- internal/app/lsp.go | 134 --------------- internal/app/services_bridge.go | 245 -------------------------- internal/tui/app/app.go | 177 +++++++++++++++++++ internal/tui/app/bridge.go | 246 +++++++++++++++++++++++++++ internal/tui/app/interfaces.go | 42 +++++ internal/tui/components/chat/editor.go | 26 +-- internal/tui/components/chat/messages.go | 2 +- internal/tui/components/chat/sidebar.go | 178 +++++++++---------- internal/tui/components/core/status.go | 2 +- internal/tui/components/dialog/filepicker.go | 4 +- internal/tui/components/logs/details.go | 187 -------------------- internal/tui/components/logs/table.go | 207 ---------------------- internal/tui/page/chat.go | 2 +- internal/tui/page/logs.go | 224 ------------------------ internal/tui/styles/icons.go | 2 +- internal/tui/styles/styles.go | 6 - internal/tui/tui.go | 52 +----- 24 files changed, 577 insertions(+), 1787 deletions(-) delete mode 100644 cmd/opencode/opencode.go delete mode 100644 cmd/root_test.go delete mode 100644 internal/app/app.go delete mode 100644 internal/app/app_new.go delete mode 100644 internal/app/interfaces.go delete mode 100644 internal/app/lsp.go delete mode 100644 internal/app/services_bridge.go create mode 100644 internal/tui/app/app.go create mode 100644 internal/tui/app/bridge.go create mode 100644 internal/tui/app/interfaces.go delete mode 100644 internal/tui/components/logs/details.go delete mode 100644 internal/tui/components/logs/table.go delete mode 100644 internal/tui/page/logs.go diff --git a/README.md b/README.md index c9ebb45e7..e1a60670b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ⓒ OpenCode +# ◍OpenCode ![OpenCode Terminal UI](screenshot.png) diff --git a/cmd/opencode/opencode.go b/cmd/opencode/opencode.go deleted file mode 100644 index 9a64c1fb7..000000000 --- a/cmd/opencode/opencode.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "context" - "os" - "os/signal" - - "github.com/sst/opencode/pkg/app" - "github.com/sst/opencode/pkg/server" - "golang.org/x/sync/errgroup" -) - -func main() { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - cwd, _ := os.Getwd() - app, err := app.New(ctx, cwd) - if err != nil { - panic(err) - } - - server, err := server.New(app) - - var wg errgroup.Group - wg.Go(func() error { - defer stop() - return server.Start(ctx) - }) - - <-ctx.Done() - - wg.Wait() -} diff --git a/cmd/root.go b/cmd/root.go index 685e0ca16..ccee60d39 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,35 +13,15 @@ import ( tea "github.com/charmbracelet/bubbletea" zone "github.com/lrstanley/bubblezone" "github.com/spf13/cobra" - "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/logging" "github.com/sst/opencode/internal/lsp/discovery" "github.com/sst/opencode/internal/pubsub" "github.com/sst/opencode/internal/tui" + "github.com/sst/opencode/internal/tui/app" "github.com/sst/opencode/internal/version" ) -type SessionIDHandler struct { - slog.Handler - app *app.App -} - -func (h *SessionIDHandler) Handle(ctx context.Context, r slog.Record) error { - if h.app != nil { - sessionID := h.app.CurrentSession.ID - if sessionID != "" { - r.AddAttrs(slog.String("session_id", sessionID)) - } - } - return h.Handler.Handle(ctx, r) -} - -func (h *SessionIDHandler) WithApp(app *app.App) *SessionIDHandler { - h.app = app - return h -} - var rootCmd = &cobra.Command{ Use: "OpenCode", Short: "A terminal AI assistant for software development", @@ -244,10 +224,6 @@ 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", 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() { diff --git a/cmd/root_test.go b/cmd/root_test.go deleted file mode 100644 index 284ef5837..000000000 --- a/cmd/root_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package cmd - -import ( - "bytes" - "io" - "os" - "testing" -) - -func TestCheckStdinPipe(t *testing.T) { - // Save original stdin - origStdin := os.Stdin - - // Restore original stdin when test completes - defer func() { - os.Stdin = origStdin - }() - - // Test case 1: Data is piped in - t.Run("WithPipedData", func(t *testing.T) { - // Create a pipe - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("Failed to create pipe: %v", err) - } - - // Replace stdin with our pipe - os.Stdin = r - - // Write test data to the pipe - testData := "test piped input" - go func() { - defer w.Close() - w.Write([]byte(testData)) - }() - - // Call the function - data, hasPiped := checkStdinPipe() - - // Check results - if !hasPiped { - t.Error("Expected hasPiped to be true, got false") - } - if data != testData { - t.Errorf("Expected data to be %q, got %q", testData, data) - } - }) - - // Test case 2: No data is piped in (simulated terminal) - t.Run("WithoutPipedData", func(t *testing.T) { - // Create a temporary file to simulate a terminal - tmpFile, err := os.CreateTemp("", "terminal-sim") - if err != nil { - t.Fatalf("Failed to create temp file: %v", err) - } - defer os.Remove(tmpFile.Name()) - defer tmpFile.Close() - - // Open the file for reading - f, err := os.Open(tmpFile.Name()) - if err != nil { - t.Fatalf("Failed to open temp file: %v", err) - } - defer f.Close() - - // Replace stdin with our file - os.Stdin = f - - // Call the function - data, hasPiped := checkStdinPipe() - - // Check results - if hasPiped { - t.Error("Expected hasPiped to be false, got true") - } - if data != "" { - t.Errorf("Expected data to be empty, got %q", data) - } - }) -} - -// This is a mock implementation for testing since we can't easily mock os.Stdin.Stat() -// in a way that would return the correct Mode() for our test cases -func mockCheckStdinPipe(reader io.Reader, isPipe bool) (string, bool) { - if !isPipe { - return "", false - } - - data, err := io.ReadAll(reader) - if err != nil { - return "", false - } - - if len(data) > 0 { - return string(data), true - } - return "", false -} - -func TestMockCheckStdinPipe(t *testing.T) { - // Test with data - t.Run("WithData", func(t *testing.T) { - testData := "test data" - reader := bytes.NewBufferString(testData) - - data, hasPiped := mockCheckStdinPipe(reader, true) - - if !hasPiped { - t.Error("Expected hasPiped to be true, got false") - } - if data != testData { - t.Errorf("Expected data to be %q, got %q", testData, data) - } - }) - - // Test without data - t.Run("WithoutData", func(t *testing.T) { - reader := bytes.NewBufferString("") - - data, hasPiped := mockCheckStdinPipe(reader, true) - - if hasPiped { - t.Error("Expected hasPiped to be false, got true") - } - if data != "" { - t.Errorf("Expected data to be empty, got %q", data) - } - }) - - // Test not a pipe - t.Run("NotAPipe", func(t *testing.T) { - reader := bytes.NewBufferString("data that should be ignored") - - data, hasPiped := mockCheckStdinPipe(reader, false) - - if hasPiped { - t.Error("Expected hasPiped to be false, got true") - } - if data != "" { - t.Errorf("Expected data to be empty, got %q", data) - } - }) -} \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index 7aed6fb10..000000000 --- a/internal/app/app.go +++ /dev/null @@ -1,180 +0,0 @@ -package app - -import ( - "context" - "maps" - "sync" - "time" - - "log/slog" - - "github.com/sst/opencode/internal/config" - "github.com/sst/opencode/internal/fileutil" - "github.com/sst/opencode/internal/lsp" - "github.com/sst/opencode/internal/session" - "github.com/sst/opencode/internal/status" - "github.com/sst/opencode/internal/tui/theme" - "github.com/sst/opencode/pkg/client" -) - -type App struct { - State map[string]any - - CurrentSession *session.Session - Logs any // TODO: Define LogService interface when needed - Sessions SessionService - Messages MessageService - History any // TODO: Define HistoryService interface when needed - Permissions any // TODO: Define PermissionService interface when needed - Status status.Service - Client *client.ClientWithResponses - Events *client.Client - - PrimaryAgent AgentService - - LSPClients map[string]*lsp.Client - - clientsMutex sync.RWMutex - - watcherCancelFuncs []context.CancelFunc - cancelFuncsMutex sync.Mutex - watcherWG sync.WaitGroup - - // UI state - filepickerOpen bool - completionDialogOpen bool -} - -func New(ctx context.Context) (*App, error) { - // Initialize status service (still needed for UI notifications) - err := status.InitService() - if err != nil { - slog.Error("Failed to initialize status service", "error", err) - return nil, err - } - - // Initialize file utilities - fileutil.Init() - - // Create HTTP client - url := "http://localhost:16713" - httpClient, err := client.NewClientWithResponses(url) - if err != nil { - slog.Error("Failed to create client", "error", err) - return nil, err - } - eventClient, err := client.NewClient(url) - if err != nil { - slog.Error("Failed to create event client", "error", err) - return nil, err - } - - // Create service bridges - sessionBridge := NewSessionServiceBridge(httpClient) - messageBridge := NewMessageServiceBridge(httpClient) - agentBridge := NewAgentServiceBridge(httpClient) - - app := &App{ - State: make(map[string]any), - Client: httpClient, - Events: eventClient, - CurrentSession: &session.Session{}, - Sessions: sessionBridge, - Messages: messageBridge, - PrimaryAgent: agentBridge, - Status: status.GetService(), - LSPClients: make(map[string]*lsp.Client), - - // TODO: These services need API endpoints: - Logs: nil, // logging.GetService(), - History: nil, // history.GetService(), - Permissions: nil, // permission.GetService(), - } - - // Initialize theme based on configuration - app.initTheme() - - // Initialize LSP clients in the background - go app.initLSPClients(ctx) - - // TODO: Remove this once agent is fully replaced by API - // app.PrimaryAgent, err = agent.NewAgent( - // config.AgentPrimary, - // app.Sessions, - // app.Messages, - // agent.PrimaryAgentTools( - // app.Permissions, - // app.Sessions, - // app.Messages, - // app.History, - // app.LSPClients, - // ), - // ) - // if err != nil { - // slog.Error("Failed to create primary agent", "error", err) - // return nil, err - // } - - return app, nil -} - -// initTheme sets the application theme based on the configuration -func (app *App) initTheme() { - cfg := config.Get() - if cfg == nil || cfg.TUI.Theme == "" { - return // Use default theme - } - - // Try to set the theme from config - err := theme.SetTheme(cfg.TUI.Theme) - if err != nil { - slog.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err) - } else { - slog.Debug("Set theme from config", "theme", cfg.TUI.Theme) - } -} - -// IsFilepickerOpen returns whether the filepicker is currently open -func (app *App) IsFilepickerOpen() bool { - return app.filepickerOpen -} - -// SetFilepickerOpen sets the state of the filepicker -func (app *App) SetFilepickerOpen(open bool) { - app.filepickerOpen = open -} - -// IsCompletionDialogOpen returns whether the completion dialog is currently open -func (app *App) IsCompletionDialogOpen() bool { - return app.completionDialogOpen -} - -// SetCompletionDialogOpen sets the state of the completion dialog -func (app *App) SetCompletionDialogOpen(open bool) { - app.completionDialogOpen = open -} - -// Shutdown performs a clean shutdown of the application -func (app *App) Shutdown() { - // Cancel all watcher goroutines - app.cancelFuncsMutex.Lock() - for _, cancel := range app.watcherCancelFuncs { - cancel() - } - app.cancelFuncsMutex.Unlock() - app.watcherWG.Wait() - - // Perform additional cleanup for LSP clients - app.clientsMutex.RLock() - clients := make(map[string]*lsp.Client, len(app.LSPClients)) - maps.Copy(clients, app.LSPClients) - app.clientsMutex.RUnlock() - - for name, client := range clients { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - if err := client.Shutdown(shutdownCtx); err != nil { - slog.Error("Failed to shutdown LSP client", "name", name, "error", err) - } - cancel() - } -} diff --git a/internal/app/app_new.go b/internal/app/app_new.go deleted file mode 100644 index 1568b4d57..000000000 --- a/internal/app/app_new.go +++ /dev/null @@ -1,203 +0,0 @@ -package app - -import ( - "context" - "encoding/json" - "fmt" - "sync" - - "log/slog" - - "github.com/sst/opencode/pkg/client" -) - -// AppNew is the new app structure that uses the TypeScript backend -type AppNew struct { - Client *client.Client - CurrentSession *client.SessionInfo - - // Event handling - eventCtx context.Context - eventCancel context.CancelFunc - eventChan <-chan any - - // UI state - filepickerOpen bool - completionDialogOpen bool - - // Mutex for thread-safe operations - mu sync.RWMutex -} - -// NewApp creates a new app instance connected to the TypeScript backend -func NewApp(ctx context.Context) (*AppNew, error) { - httpClient, err := client.NewClient("http://localhost:16713") - if err != nil { - slog.Error("Failed to create client", "error", err) - return nil, err - } - - app := &AppNew{ - Client: httpClient, - } - - // Start event listener - if err := app.startEventListener(ctx); err != nil { - return nil, err - } - - return app, nil -} - -// startEventListener connects to the SSE endpoint and processes events -func (a *AppNew) startEventListener(ctx context.Context) error { - a.eventCtx, a.eventCancel = context.WithCancel(ctx) - - eventChan, err := a.Client.Event(a.eventCtx) - if err != nil { - return err - } - - a.eventChan = eventChan - - // Start processing events in background - go a.processEvents() - - return nil -} - -// processEvents handles incoming SSE events -func (a *AppNew) processEvents() { - for event := range a.eventChan { - switch e := event.(type) { - case *client.EventStorageWrite: - // Handle storage write events - slog.Debug("Storage write event", "key", e.Key) - // TODO: Update local state based on storage events - default: - slog.Debug("Unknown event type", "event", e) - } - } -} - -// CreateSession creates a new session via the API -func (a *AppNew) CreateSession(ctx context.Context) error { - resp, err := a.Client.PostSessionCreate(ctx) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("failed to create session: %d", resp.StatusCode) - } - - var session client.SessionInfo - if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { - return err - } - - a.mu.Lock() - a.CurrentSession = &session - a.mu.Unlock() - - return nil -} - -// SendMessage sends a message to the current session -func (a *AppNew) SendMessage(ctx context.Context, text string) error { - if a.CurrentSession == nil { - if err := a.CreateSession(ctx); err != nil { - return err - } - } - - a.mu.RLock() - sessionID := a.CurrentSession.Id - a.mu.RUnlock() - - parts := interface{}([]map[string]interface{}{ - { - "type": "text", - "text": text, - }, - }) - - resp, err := a.Client.PostSessionChat(ctx, client.PostSessionChatJSONRequestBody{ - SessionID: sessionID, - Parts: &parts, - }) - if err != nil { - return err - } - defer resp.Body.Close() - - // The response will be streamed via SSE - return nil -} - -// GetSessions retrieves all sessions -func (a *AppNew) GetSessions(ctx context.Context) ([]client.SessionInfo, error) { - resp, err := a.Client.PostSessionList(ctx) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var sessions []client.SessionInfo - if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { - return nil, err - } - - return sessions, nil -} - -// GetMessages retrieves messages for a session -func (a *AppNew) GetMessages(ctx context.Context, sessionID string) (interface{}, error) { - resp, err := a.Client.PostSessionMessages(ctx, client.PostSessionMessagesJSONRequestBody{ - SessionID: sessionID, - }) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var messages interface{} - if err := json.NewDecoder(resp.Body).Decode(&messages); err != nil { - return nil, err - } - - return messages, nil -} - -// Close shuts down the app and its connections -func (a *AppNew) Close() { - if a.eventCancel != nil { - a.eventCancel() - } -} - -// UI state methods -func (a *AppNew) SetFilepickerOpen(open bool) { - a.mu.Lock() - defer a.mu.Unlock() - a.filepickerOpen = open -} - -func (a *AppNew) IsFilepickerOpen() bool { - a.mu.RLock() - defer a.mu.RUnlock() - return a.filepickerOpen -} - -func (a *AppNew) SetCompletionDialogOpen(open bool) { - a.mu.Lock() - defer a.mu.Unlock() - a.completionDialogOpen = open -} - -func (a *AppNew) IsCompletionDialogOpen() bool { - a.mu.RLock() - defer a.mu.RUnlock() - return a.completionDialogOpen -} \ No newline at end of file diff --git a/internal/app/interfaces.go b/internal/app/interfaces.go deleted file mode 100644 index 2f4287078..000000000 --- a/internal/app/interfaces.go +++ /dev/null @@ -1,42 +0,0 @@ -package app - -import ( - "context" - "time" - - "github.com/sst/opencode/internal/message" - "github.com/sst/opencode/internal/pubsub" - "github.com/sst/opencode/internal/session" -) - -// SessionService defines the interface for session operations -type SessionService interface { - Create(ctx context.Context, title string) (session.Session, error) - Get(ctx context.Context, id string) (session.Session, error) - List(ctx context.Context) ([]session.Session, error) - Update(ctx context.Context, id, title string) error - Delete(ctx context.Context, id string) error -} - -// MessageService defines the interface for message operations -type MessageService interface { - pubsub.Subscriber[message.Message] - - GetBySession(ctx context.Context, sessionID string) ([]message.Message, error) - List(ctx context.Context, sessionID string) ([]message.Message, error) - Create(ctx context.Context, sessionID string, params message.CreateMessageParams) (message.Message, error) - Update(ctx context.Context, msg message.Message) (message.Message, error) - Delete(ctx context.Context, id string) error - DeleteSessionMessages(ctx context.Context, sessionID string) error - Get(ctx context.Context, id string) (message.Message, error) - ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]message.Message, error) -} - -// AgentService defines the interface for agent operations -type AgentService interface { - Run(ctx context.Context, sessionID string, text string, attachments ...message.Attachment) (string, error) - Cancel(sessionID string) error - IsBusy() bool - IsSessionBusy(sessionID string) bool - CompactSession(ctx context.Context, sessionID string, force bool) error -} \ No newline at end of file diff --git a/internal/app/lsp.go b/internal/app/lsp.go deleted file mode 100644 index 214f104b8..000000000 --- a/internal/app/lsp.go +++ /dev/null @@ -1,134 +0,0 @@ -package app - -import ( - "context" - "time" - - "log/slog" - - "github.com/sst/opencode/internal/config" - "github.com/sst/opencode/internal/logging" - "github.com/sst/opencode/internal/lsp" - "github.com/sst/opencode/internal/lsp/watcher" -) - -func (app *App) initLSPClients(ctx context.Context) { - cfg := config.Get() - - // Initialize LSP clients - for name, clientConfig := range cfg.LSP { - // Start each client initialization in its own goroutine - go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...) - } - 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 - 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 { - 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 { - slog.Error("Initialize failed", "name", name, "error", err) - // Clean up the client to prevent resource leaks - lspClient.Close() - return - } - - // Wait for the server to be ready - if err := lspClient.WaitForServerReady(initCtx); err != nil { - 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 { - slog.Info("LSP server is ready", "name", name) - lspClient.SetServerState(lsp.StateReady) - } - - 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) - - // Store the cancel function to be called during cleanup - app.cancelFuncsMutex.Lock() - app.watcherCancelFuncs = append(app.watcherCancelFuncs, cancelFunc) - app.cancelFuncsMutex.Unlock() - - // Add the watcher to a WaitGroup to track active goroutines - app.watcherWG.Add(1) - - // Add to map with mutex protection before starting goroutine - app.clientsMutex.Lock() - app.LSPClients[name] = lspClient - app.clientsMutex.Unlock() - - go app.runWorkspaceWatcher(watchCtx, name, workspaceWatcher) -} - -// runWorkspaceWatcher executes the workspace watcher for an LSP client -func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) { - defer app.watcherWG.Done() - defer logging.RecoverPanic("LSP-"+name, func() { - // Try to restart the client - app.restartLSPClient(ctx, name) - }) - - workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory()) - slog.Info("Workspace watcher stopped", "client", name) -} - -// restartLSPClient attempts to restart a crashed or failed LSP client -func (app *App) restartLSPClient(ctx context.Context, name string) { - // Get the original configuration - cfg := config.Get() - clientConfig, exists := cfg.LSP[name] - if !exists { - slog.Error("Cannot restart client, configuration not found", "client", name) - return - } - - // Clean up the old client if it exists - app.clientsMutex.Lock() - oldClient, exists := app.LSPClients[name] - if exists { - delete(app.LSPClients, name) // Remove from map before potentially slow shutdown - } - app.clientsMutex.Unlock() - - if exists && oldClient != nil { - // Try to shut it down gracefully, but don't block on errors - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - _ = oldClient.Shutdown(shutdownCtx) - cancel() - - // Ensure we close the client to free resources - _ = oldClient.Close() - } - - // Wait a moment before restarting to avoid rapid restart cycles - time.Sleep(1 * time.Second) - - // Create a new client using the shared function - app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...) - slog.Info("Successfully restarted LSP client", "client", name) -} diff --git a/internal/app/services_bridge.go b/internal/app/services_bridge.go deleted file mode 100644 index d3f2e9a23..000000000 --- a/internal/app/services_bridge.go +++ /dev/null @@ -1,245 +0,0 @@ -package app - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/sst/opencode/internal/message" - "github.com/sst/opencode/internal/pubsub" - "github.com/sst/opencode/internal/session" - "github.com/sst/opencode/pkg/client" -) - -// SessionServiceBridge adapts the HTTP API to the old session.Service interface -type SessionServiceBridge struct { - client *client.ClientWithResponses -} - -// NewSessionServiceBridge creates a new session service bridge -func NewSessionServiceBridge(client *client.ClientWithResponses) *SessionServiceBridge { - return &SessionServiceBridge{client: client} -} - -// Create creates a new session -func (s *SessionServiceBridge) Create(ctx context.Context, title string) (session.Session, error) { - resp, err := s.client.PostSessionCreateWithResponse(ctx) - if err != nil { - return session.Session{}, err - } - if resp.StatusCode() != 200 { - return session.Session{}, fmt.Errorf("failed to create session: %d", resp.StatusCode) - } - info := resp.JSON200 - - // Convert to old session type - return session.Session{ - ID: info.Id, - Title: info.Title, - CreatedAt: time.Now(), // API doesn't provide this yet - UpdatedAt: time.Now(), // API doesn't provide this yet - }, nil -} - -// Get retrieves a session by ID -func (s *SessionServiceBridge) Get(ctx context.Context, id string) (session.Session, error) { - // TODO: API doesn't have a get by ID endpoint yet - // For now, list all and find the one we want - sessions, err := s.List(ctx) - if err != nil { - return session.Session{}, err - } - - for _, sess := range sessions { - if sess.ID == id { - return sess, nil - } - } - - return session.Session{}, fmt.Errorf("session not found: %s", id) -} - -// List retrieves all sessions -func (s *SessionServiceBridge) List(ctx context.Context) ([]session.Session, error) { - resp, err := s.client.PostSessionListWithResponse(ctx) - if err != nil { - return nil, err - } - - if resp.StatusCode() != 200 { - return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode()) - } - - if resp.JSON200 == nil { - return []session.Session{}, nil - } - - infos := *resp.JSON200 - - // Convert to old session type - sessions := make([]session.Session, len(infos)) - for i, info := range infos { - sessions[i] = session.Session{ - ID: info.Id, - Title: info.Title, - CreatedAt: time.Now(), // API doesn't provide this yet - UpdatedAt: time.Now(), // API doesn't provide this yet - } - } - - return sessions, nil -} - -// Update updates a session - NOT IMPLEMENTED IN API YET -func (s *SessionServiceBridge) Update(ctx context.Context, id, title string) error { - // TODO: Not implemented in TypeScript API yet - return fmt.Errorf("session update not implemented in API") -} - -// Delete deletes a session - NOT IMPLEMENTED IN API YET -func (s *SessionServiceBridge) Delete(ctx context.Context, id string) error { - // TODO: Not implemented in TypeScript API yet - return fmt.Errorf("session delete not implemented in API") -} - -// AgentServiceBridge provides a minimal agent service that sends messages to the API -type AgentServiceBridge struct { - client *client.ClientWithResponses -} - -// NewAgentServiceBridge creates a new agent service bridge -func NewAgentServiceBridge(client *client.ClientWithResponses) *AgentServiceBridge { - return &AgentServiceBridge{client: client} -} - -// Run sends a message to the chat API -func (a *AgentServiceBridge) Run(ctx context.Context, sessionID string, text string, attachments ...message.Attachment) (string, error) { - // TODO: Handle attachments when API supports them - if len(attachments) > 0 { - // For now, ignore attachments - // return "", fmt.Errorf("attachments not supported yet") - } - - parts := any([]map[string]any{ - { - "type": "text", - "text": text, - }, - }) - - go a.client.PostSessionChatWithResponse(ctx, client.PostSessionChatJSONRequestBody{ - SessionID: sessionID, - Parts: &parts, - }) - - // The actual response will come through SSE - // For now, just return success - return "", nil -} - -// Cancel cancels the current generation - NOT IMPLEMENTED IN API YET -func (a *AgentServiceBridge) Cancel(sessionID string) error { - // TODO: Not implemented in TypeScript API yet - return nil -} - -// IsBusy checks if the agent is busy - NOT IMPLEMENTED IN API YET -func (a *AgentServiceBridge) IsBusy() bool { - // TODO: Not implemented in TypeScript API yet - return false -} - -// IsSessionBusy checks if the agent is busy for a specific session - NOT IMPLEMENTED IN API YET -func (a *AgentServiceBridge) IsSessionBusy(sessionID string) bool { - // TODO: Not implemented in TypeScript API yet - return false -} - -// CompactSession compacts a session - NOT IMPLEMENTED IN API YET -func (a *AgentServiceBridge) CompactSession(ctx context.Context, sessionID string, force bool) error { - // TODO: Not implemented in TypeScript API yet - return fmt.Errorf("session compaction not implemented in API") -} - -// MessageServiceBridge provides a minimal message service that fetches from the API -type MessageServiceBridge struct { - client *client.ClientWithResponses - broker *pubsub.Broker[message.Message] -} - -// NewMessageServiceBridge creates a new message service bridge -func NewMessageServiceBridge(client *client.ClientWithResponses) *MessageServiceBridge { - return &MessageServiceBridge{ - client: client, - broker: pubsub.NewBroker[message.Message](), - } -} - -// GetBySession retrieves messages for a session -func (m *MessageServiceBridge) GetBySession(ctx context.Context, sessionID string) ([]message.Message, error) { - return m.List(ctx, sessionID) -} - -// List retrieves messages for a session -func (m *MessageServiceBridge) List(ctx context.Context, sessionID string) ([]message.Message, error) { - resp, err := m.client.PostSessionMessages(ctx, client.PostSessionMessagesJSONRequestBody{ - SessionID: sessionID, - }) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - // The API returns a different format, we'll need to adapt it - var rawMessages any - if err := json.NewDecoder(resp.Body).Decode(&rawMessages); err != nil { - return nil, err - } - - // TODO: Convert the API message format to our internal format - // For now, return empty to avoid compilation errors - return []message.Message{}, nil -} - -// Create creates a new message - NOT NEEDED, handled by chat API -func (m *MessageServiceBridge) Create(ctx context.Context, sessionID string, params message.CreateMessageParams) (message.Message, error) { - // Messages are created through the chat API - return message.Message{}, fmt.Errorf("use chat API to send messages") -} - -// Update updates a message - NOT IMPLEMENTED IN API YET -func (m *MessageServiceBridge) Update(ctx context.Context, msg message.Message) (message.Message, error) { - // TODO: Not implemented in TypeScript API yet - return message.Message{}, fmt.Errorf("message update not implemented in API") -} - -// Delete deletes a message - NOT IMPLEMENTED IN API YET -func (m *MessageServiceBridge) Delete(ctx context.Context, id string) error { - // TODO: Not implemented in TypeScript API yet - return fmt.Errorf("message delete not implemented in API") -} - -// DeleteSessionMessages deletes all messages for a session - NOT IMPLEMENTED IN API YET -func (m *MessageServiceBridge) DeleteSessionMessages(ctx context.Context, sessionID string) error { - // TODO: Not implemented in TypeScript API yet - return fmt.Errorf("delete session messages not implemented in API") -} - -// Get retrieves a message by ID - NOT IMPLEMENTED IN API YET -func (m *MessageServiceBridge) Get(ctx context.Context, id string) (message.Message, error) { - // TODO: Not implemented in TypeScript API yet - return message.Message{}, fmt.Errorf("get message by ID not implemented in API") -} - -// ListAfter retrieves messages after a timestamp - NOT IMPLEMENTED IN API YET -func (m *MessageServiceBridge) ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]message.Message, error) { - // TODO: Not implemented in TypeScript API yet - return []message.Message{}, fmt.Errorf("list messages after timestamp not implemented in API") -} - -// Subscribe subscribes to message events -func (m *MessageServiceBridge) Subscribe(ctx context.Context) <-chan pubsub.Event[message.Message] { - return m.broker.Subscribe(ctx) -} - diff --git a/internal/tui/app/app.go b/internal/tui/app/app.go new file mode 100644 index 000000000..7acf3021d --- /dev/null +++ b/internal/tui/app/app.go @@ -0,0 +1,177 @@ +package app + +import ( + "context" + "maps" + "sync" + "time" + + "log/slog" + + "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/fileutil" + "github.com/sst/opencode/internal/lsp" + "github.com/sst/opencode/internal/session" + "github.com/sst/opencode/internal/status" + "github.com/sst/opencode/internal/tui/theme" + "github.com/sst/opencode/pkg/client" +) + +type App struct { + State map[string]any + + CurrentSession *session.Session + Logs any // TODO: Define LogService interface when needed + Sessions SessionService + Messages MessageService + History any // TODO: Define HistoryService interface when needed + Permissions any // TODO: Define PermissionService interface when needed + Status status.Service + Client *client.ClientWithResponses + Events *client.Client + + PrimaryAgent AgentService + + LSPClients map[string]*lsp.Client + + clientsMutex sync.RWMutex + + watcherCancelFuncs []context.CancelFunc + cancelFuncsMutex sync.Mutex + watcherWG sync.WaitGroup + + // UI state + filepickerOpen bool + completionDialogOpen bool +} + +func New(ctx context.Context) (*App, error) { + // Initialize status service (still needed for UI notifications) + err := status.InitService() + if err != nil { + slog.Error("Failed to initialize status service", "error", err) + return nil, err + } + + // Initialize file utilities + fileutil.Init() + + // Create HTTP client + url := "http://localhost:16713" + httpClient, err := client.NewClientWithResponses(url) + if err != nil { + slog.Error("Failed to create client", "error", err) + return nil, err + } + eventClient, err := client.NewClient(url) + if err != nil { + slog.Error("Failed to create event client", "error", err) + return nil, err + } + + // Create service bridges + sessionBridge := NewSessionServiceBridge(httpClient) + messageBridge := NewMessageServiceBridge(httpClient) + agentBridge := NewAgentServiceBridge(httpClient) + + app := &App{ + State: make(map[string]any), + Client: httpClient, + Events: eventClient, + CurrentSession: &session.Session{}, + Sessions: sessionBridge, + Messages: messageBridge, + PrimaryAgent: agentBridge, + Status: status.GetService(), + LSPClients: make(map[string]*lsp.Client), + + // TODO: These services need API endpoints: + Logs: nil, // logging.GetService(), + History: nil, // history.GetService(), + Permissions: nil, // permission.GetService(), + } + + // Initialize theme based on configuration + app.initTheme() + + // TODO: Remove this once agent is fully replaced by API + // app.PrimaryAgent, err = agent.NewAgent( + // config.AgentPrimary, + // app.Sessions, + // app.Messages, + // agent.PrimaryAgentTools( + // app.Permissions, + // app.Sessions, + // app.Messages, + // app.History, + // app.LSPClients, + // ), + // ) + // if err != nil { + // slog.Error("Failed to create primary agent", "error", err) + // return nil, err + // } + + return app, nil +} + +// initTheme sets the application theme based on the configuration +func (app *App) initTheme() { + cfg := config.Get() + if cfg == nil || cfg.TUI.Theme == "" { + return // Use default theme + } + + // Try to set the theme from config + err := theme.SetTheme(cfg.TUI.Theme) + if err != nil { + slog.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err) + } else { + slog.Debug("Set theme from config", "theme", cfg.TUI.Theme) + } +} + +// IsFilepickerOpen returns whether the filepicker is currently open +func (app *App) IsFilepickerOpen() bool { + return app.filepickerOpen +} + +// SetFilepickerOpen sets the state of the filepicker +func (app *App) SetFilepickerOpen(open bool) { + app.filepickerOpen = open +} + +// IsCompletionDialogOpen returns whether the completion dialog is currently open +func (app *App) IsCompletionDialogOpen() bool { + return app.completionDialogOpen +} + +// SetCompletionDialogOpen sets the state of the completion dialog +func (app *App) SetCompletionDialogOpen(open bool) { + app.completionDialogOpen = open +} + +// Shutdown performs a clean shutdown of the application +func (app *App) Shutdown() { + // Cancel all watcher goroutines + app.cancelFuncsMutex.Lock() + for _, cancel := range app.watcherCancelFuncs { + cancel() + } + app.cancelFuncsMutex.Unlock() + app.watcherWG.Wait() + + // Perform additional cleanup for LSP clients + app.clientsMutex.RLock() + clients := make(map[string]*lsp.Client, len(app.LSPClients)) + maps.Copy(clients, app.LSPClients) + app.clientsMutex.RUnlock() + + for name, client := range clients { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := client.Shutdown(shutdownCtx); err != nil { + slog.Error("Failed to shutdown LSP client", "name", name, "error", err) + } + cancel() + } +} diff --git a/internal/tui/app/bridge.go b/internal/tui/app/bridge.go new file mode 100644 index 000000000..f9c075cf5 --- /dev/null +++ b/internal/tui/app/bridge.go @@ -0,0 +1,246 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/sst/opencode/internal/message" + "github.com/sst/opencode/internal/pubsub" + "github.com/sst/opencode/internal/session" + "github.com/sst/opencode/pkg/client" +) + +// SessionServiceBridge adapts the HTTP API to the old session.Service interface +type SessionServiceBridge struct { + client *client.ClientWithResponses +} + +// NewSessionServiceBridge creates a new session service bridge +func NewSessionServiceBridge(client *client.ClientWithResponses) *SessionServiceBridge { + return &SessionServiceBridge{client: client} +} + +// Create creates a new session +func (s *SessionServiceBridge) Create(ctx context.Context, title string) (session.Session, error) { + resp, err := s.client.PostSessionCreateWithResponse(ctx) + if err != nil { + return session.Session{}, err + } + if resp.StatusCode() != 200 { + return session.Session{}, fmt.Errorf("failed to create session: %d", resp.StatusCode()) + } + info := resp.JSON200 + + // Convert to old session type + return session.Session{ + ID: info.Id, + Title: info.Title, + CreatedAt: time.Now(), // API doesn't provide this yet + UpdatedAt: time.Now(), // API doesn't provide this yet + }, nil +} + +// Get retrieves a session by ID +func (s *SessionServiceBridge) Get(ctx context.Context, id string) (session.Session, error) { + // TODO: API doesn't have a get by ID endpoint yet + // For now, list all and find the one we want + sessions, err := s.List(ctx) + if err != nil { + return session.Session{}, err + } + + for _, sess := range sessions { + if sess.ID == id { + return sess, nil + } + } + + return session.Session{}, fmt.Errorf("session not found: %s", id) +} + +// List retrieves all sessions +func (s *SessionServiceBridge) List(ctx context.Context) ([]session.Session, error) { + resp, err := s.client.PostSessionListWithResponse(ctx) + if err != nil { + return nil, err + } + + if resp.StatusCode() != 200 { + return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode()) + } + + if resp.JSON200 == nil { + return []session.Session{}, nil + } + + infos := *resp.JSON200 + + // Convert to old session type + sessions := make([]session.Session, len(infos)) + for i, info := range infos { + sessions[i] = session.Session{ + ID: info.Id, + Title: info.Title, + CreatedAt: time.Now(), // API doesn't provide this yet + UpdatedAt: time.Now(), // API doesn't provide this yet + } + } + + return sessions, nil +} + +// Update updates a session - NOT IMPLEMENTED IN API YET +func (s *SessionServiceBridge) Update(ctx context.Context, id, title string) error { + // TODO: Not implemented in TypeScript API yet + return fmt.Errorf("session update not implemented in API") +} + +// Delete deletes a session - NOT IMPLEMENTED IN API YET +func (s *SessionServiceBridge) Delete(ctx context.Context, id string) error { + // TODO: Not implemented in TypeScript API yet + return fmt.Errorf("session delete not implemented in API") +} + +// AgentServiceBridge provides a minimal agent service that sends messages to the API +type AgentServiceBridge struct { + client *client.ClientWithResponses +} + +// NewAgentServiceBridge creates a new agent service bridge +func NewAgentServiceBridge(client *client.ClientWithResponses) *AgentServiceBridge { + return &AgentServiceBridge{client: client} +} + +// Run sends a message to the chat API +func (a *AgentServiceBridge) Run(ctx context.Context, sessionID string, text string, attachments ...message.Attachment) (string, error) { + // TODO: Handle attachments when API supports them + if len(attachments) > 0 { + // For now, ignore attachments + // return "", fmt.Errorf("attachments not supported yet") + } + + part := client.SessionMessagePart{} + part.FromSessionMessagePartText(client.SessionMessagePartText{ + Type: "text", + Text: text, + }) + parts := []client.SessionMessagePart{part} + + go a.client.PostSessionChatWithResponse(ctx, client.PostSessionChatJSONRequestBody{ + SessionID: sessionID, + Parts: parts, + ProviderID: "anthropic", + ModelID: "claude-sonnet-4-20250514", + }) + + // The actual response will come through SSE + // For now, just return success + return "", nil +} + +// Cancel cancels the current generation - NOT IMPLEMENTED IN API YET +func (a *AgentServiceBridge) Cancel(sessionID string) error { + // TODO: Not implemented in TypeScript API yet + return nil +} + +// IsBusy checks if the agent is busy - NOT IMPLEMENTED IN API YET +func (a *AgentServiceBridge) IsBusy() bool { + // TODO: Not implemented in TypeScript API yet + return false +} + +// IsSessionBusy checks if the agent is busy for a specific session - NOT IMPLEMENTED IN API YET +func (a *AgentServiceBridge) IsSessionBusy(sessionID string) bool { + // TODO: Not implemented in TypeScript API yet + return false +} + +// CompactSession compacts a session - NOT IMPLEMENTED IN API YET +func (a *AgentServiceBridge) CompactSession(ctx context.Context, sessionID string, force bool) error { + // TODO: Not implemented in TypeScript API yet + return fmt.Errorf("session compaction not implemented in API") +} + +// MessageServiceBridge provides a minimal message service that fetches from the API +type MessageServiceBridge struct { + client *client.ClientWithResponses + broker *pubsub.Broker[message.Message] +} + +// NewMessageServiceBridge creates a new message service bridge +func NewMessageServiceBridge(client *client.ClientWithResponses) *MessageServiceBridge { + return &MessageServiceBridge{ + client: client, + broker: pubsub.NewBroker[message.Message](), + } +} + +// GetBySession retrieves messages for a session +func (m *MessageServiceBridge) GetBySession(ctx context.Context, sessionID string) ([]message.Message, error) { + return m.List(ctx, sessionID) +} + +// List retrieves messages for a session +func (m *MessageServiceBridge) List(ctx context.Context, sessionID string) ([]message.Message, error) { + resp, err := m.client.PostSessionMessages(ctx, client.PostSessionMessagesJSONRequestBody{ + SessionID: sessionID, + }) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // The API returns a different format, we'll need to adapt it + var rawMessages any + if err := json.NewDecoder(resp.Body).Decode(&rawMessages); err != nil { + return nil, err + } + + // TODO: Convert the API message format to our internal format + // For now, return empty to avoid compilation errors + return []message.Message{}, nil +} + +// Create creates a new message - NOT NEEDED, handled by chat API +func (m *MessageServiceBridge) Create(ctx context.Context, sessionID string, params message.CreateMessageParams) (message.Message, error) { + // Messages are created through the chat API + return message.Message{}, fmt.Errorf("use chat API to send messages") +} + +// Update updates a message - NOT IMPLEMENTED IN API YET +func (m *MessageServiceBridge) Update(ctx context.Context, msg message.Message) (message.Message, error) { + // TODO: Not implemented in TypeScript API yet + return message.Message{}, fmt.Errorf("message update not implemented in API") +} + +// Delete deletes a message - NOT IMPLEMENTED IN API YET +func (m *MessageServiceBridge) Delete(ctx context.Context, id string) error { + // TODO: Not implemented in TypeScript API yet + return fmt.Errorf("message delete not implemented in API") +} + +// DeleteSessionMessages deletes all messages for a session - NOT IMPLEMENTED IN API YET +func (m *MessageServiceBridge) DeleteSessionMessages(ctx context.Context, sessionID string) error { + // TODO: Not implemented in TypeScript API yet + return fmt.Errorf("delete session messages not implemented in API") +} + +// Get retrieves a message by ID - NOT IMPLEMENTED IN API YET +func (m *MessageServiceBridge) Get(ctx context.Context, id string) (message.Message, error) { + // TODO: Not implemented in TypeScript API yet + return message.Message{}, fmt.Errorf("get message by ID not implemented in API") +} + +// ListAfter retrieves messages after a timestamp - NOT IMPLEMENTED IN API YET +func (m *MessageServiceBridge) ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]message.Message, error) { + // TODO: Not implemented in TypeScript API yet + return []message.Message{}, fmt.Errorf("list messages after timestamp not implemented in API") +} + +// Subscribe subscribes to message events +func (m *MessageServiceBridge) Subscribe(ctx context.Context) <-chan pubsub.Event[message.Message] { + return m.broker.Subscribe(ctx) +} diff --git a/internal/tui/app/interfaces.go b/internal/tui/app/interfaces.go new file mode 100644 index 000000000..9729a3bcb --- /dev/null +++ b/internal/tui/app/interfaces.go @@ -0,0 +1,42 @@ +package app + +import ( + "context" + "time" + + "github.com/sst/opencode/internal/message" + "github.com/sst/opencode/internal/pubsub" + "github.com/sst/opencode/internal/session" +) + +// SessionService defines the interface for session operations +type SessionService interface { + Create(ctx context.Context, title string) (session.Session, error) + Get(ctx context.Context, id string) (session.Session, error) + List(ctx context.Context) ([]session.Session, error) + Update(ctx context.Context, id, title string) error + Delete(ctx context.Context, id string) error +} + +// MessageService defines the interface for message operations +type MessageService interface { + pubsub.Subscriber[message.Message] + + GetBySession(ctx context.Context, sessionID string) ([]message.Message, error) + List(ctx context.Context, sessionID string) ([]message.Message, error) + Create(ctx context.Context, sessionID string, params message.CreateMessageParams) (message.Message, error) + Update(ctx context.Context, msg message.Message) (message.Message, error) + Delete(ctx context.Context, id string) error + DeleteSessionMessages(ctx context.Context, sessionID string) error + Get(ctx context.Context, id string) (message.Message, error) + ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]message.Message, error) +} + +// AgentService defines the interface for agent operations +type AgentService interface { + Run(ctx context.Context, sessionID string, text string, attachments ...message.Attachment) (string, error) + Cancel(sessionID string) error + IsBusy() bool + IsSessionBusy(sessionID string) bool + CompactSession(ctx context.Context, sessionID string, force bool) error +} diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index 0858e22df..3b82c96d3 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -13,9 +13,9 @@ import ( "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/message" "github.com/sst/opencode/internal/status" + "github.com/sst/opencode/internal/tui/app" "github.com/sst/opencode/internal/tui/components/dialog" "github.com/sst/opencode/internal/tui/image" "github.com/sst/opencode/internal/tui/layout" @@ -37,10 +37,10 @@ type editorCmp struct { } type EditorKeyMaps struct { - Send key.Binding - OpenEditor key.Binding - Paste key.Binding - HistoryUp key.Binding + Send key.Binding + OpenEditor key.Binding + Paste key.Binding + HistoryUp key.Binding HistoryDown key.Binding } @@ -251,14 +251,14 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() { // Get the current line number currentLine := m.textarea.Line() - + // Only navigate history if we're at the first line if currentLine == 0 && len(m.history) > 0 { // Save current message if we're just starting to navigate if m.historyIndex == len(m.history) { m.currentMessage = m.textarea.Value() } - + // Go to previous message in history if m.historyIndex > 0 { m.historyIndex-- @@ -267,14 +267,14 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } - + if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() { // Get the current line number and total lines currentLine := m.textarea.Line() value := m.textarea.Value() lines := strings.Split(value, "\n") totalLines := len(lines) - + // Only navigate history if we're at the last line if currentLine == totalLines-1 { if m.historyIndex < len(m.history)-1 { @@ -403,10 +403,10 @@ func CreateTextArea(existing *textarea.Model) textarea.Model { func NewEditorCmp(app *app.App) tea.Model { ta := CreateTextArea(nil) return &editorCmp{ - app: app, - textarea: ta, - history: []string{}, - historyIndex: 0, + app: app, + textarea: ta, + history: []string{}, + historyIndex: 0, currentMessage: "", } } diff --git a/internal/tui/components/chat/messages.go b/internal/tui/components/chat/messages.go index 668f0f6cd..ce02e2e07 100644 --- a/internal/tui/components/chat/messages.go +++ b/internal/tui/components/chat/messages.go @@ -12,11 +12,11 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/message" "github.com/sst/opencode/internal/pubsub" "github.com/sst/opencode/internal/session" "github.com/sst/opencode/internal/status" + "github.com/sst/opencode/internal/tui/app" "github.com/sst/opencode/internal/tui/components/dialog" "github.com/sst/opencode/internal/tui/state" "github.com/sst/opencode/internal/tui/styles" diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go index c2b392c24..6676f9b09 100644 --- a/internal/tui/components/chat/sidebar.go +++ b/internal/tui/components/chat/sidebar.go @@ -8,8 +8,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/tui/app" // "github.com/sst/opencode/internal/diff" "github.com/sst/opencode/internal/history" "github.com/sst/opencode/internal/pubsub" @@ -216,17 +216,17 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) { // TODO: History service not implemented in API yet return /* - // Get all latest files for this session - latestFiles, err := m.app.History.ListLatestSessionFiles(ctx, m.app.CurrentSession.ID) - if err != nil { - return - } + // Get all latest files for this session + latestFiles, err := m.app.History.ListLatestSessionFiles(ctx, m.app.CurrentSession.ID) + if err != nil { + return + } - // Get all files for this session (to find initial versions) - allFiles, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID) - if err != nil { - return - } + // Get all files for this session (to find initial versions) + allFiles, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID) + if err != nil { + return + } */ // Clear the existing map to rebuild it @@ -236,28 +236,75 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) { }) /* - // Process each latest file - for _, file := range latestFiles { + // Process each latest file + for _, file := range latestFiles { + // Skip if this is the initial version (no changes to show) + if file.Version == history.InitialVersion { + continue + } + + // Find the initial version for this specific file + var initialVersion history.File + for _, v := range allFiles { + if v.Path == file.Path && v.Version == history.InitialVersion { + initialVersion = v + break + } + } + + // Skip if we can't find the initial version + if initialVersion.ID == "" { + continue + } + if initialVersion.Content == file.Content { + continue + } + + // Calculate diff between initial and latest version + _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path) + + // Only add to modified files if there are changes + if additions > 0 || removals > 0 { + // Remove working directory prefix from file path + displayPath := file.Path + workingDir := config.WorkingDirectory() + displayPath = strings.TrimPrefix(displayPath, workingDir) + displayPath = strings.TrimPrefix(displayPath, "/") + + m.modFiles[displayPath] = struct { + additions int + removals int + }{ + additions: additions, + removals: removals, + } + } + } + */ +} + +func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) { + // TODO: History service not implemented in API yet + return + /* // Skip if this is the initial version (no changes to show) if file.Version == history.InitialVersion { - continue + return } - // Find the initial version for this specific file - var initialVersion history.File - for _, v := range allFiles { - if v.Path == file.Path && v.Version == history.InitialVersion { - initialVersion = v - break - } + // Find the initial version for this file + initialVersion, err := m.findInitialVersion(ctx, file.Path) + if err != nil || initialVersion.ID == "" { + return } - // Skip if we can't find the initial version - if initialVersion.ID == "" { - continue - } + // Skip if content hasn't changed if initialVersion.Content == file.Content { - continue + // If this file was previously modified but now matches the initial version, + // remove it from the modified files list + displayPath := getDisplayPath(file.Path) + delete(m.modFiles, displayPath) + return } // Calculate diff between initial and latest version @@ -265,12 +312,7 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) { // Only add to modified files if there are changes if additions > 0 || removals > 0 { - // Remove working directory prefix from file path - displayPath := file.Path - workingDir := config.WorkingDirectory() - displayPath = strings.TrimPrefix(displayPath, workingDir) - displayPath = strings.TrimPrefix(displayPath, "/") - + displayPath := getDisplayPath(file.Path) m.modFiles[displayPath] = struct { additions int removals int @@ -278,53 +320,11 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) { additions: additions, removals: removals, } + } else { + // If no changes, remove from modified files + displayPath := getDisplayPath(file.Path) + delete(m.modFiles, displayPath) } - } - */ -} - -func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) { - // TODO: History service not implemented in API yet - return - /* - // Skip if this is the initial version (no changes to show) - if file.Version == history.InitialVersion { - return - } - - // Find the initial version for this file - initialVersion, err := m.findInitialVersion(ctx, file.Path) - if err != nil || initialVersion.ID == "" { - return - } - - // Skip if content hasn't changed - if initialVersion.Content == file.Content { - // If this file was previously modified but now matches the initial version, - // remove it from the modified files list - displayPath := getDisplayPath(file.Path) - delete(m.modFiles, displayPath) - return - } - - // Calculate diff between initial and latest version - _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path) - - // Only add to modified files if there are changes - if additions > 0 || removals > 0 { - displayPath := getDisplayPath(file.Path) - m.modFiles[displayPath] = struct { - additions int - removals int - }{ - additions: additions, - removals: removals, - } - } else { - // If no changes, remove from modified files - displayPath := getDisplayPath(file.Path) - delete(m.modFiles, displayPath) - } */ } @@ -333,22 +333,22 @@ func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (histo // TODO: History service not implemented in API yet return history.File{}, fmt.Errorf("history service not implemented") /* - // Get all versions of this file for the session - fileVersions, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID) - if err != nil { - return history.File{}, err - } + // Get all versions of this file for the session + fileVersions, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID) + if err != nil { + return history.File{}, err + } */ /* - // Find the initial version - for _, v := range fileVersions { - if v.Path == path && v.Version == history.InitialVersion { - return v, nil + // Find the initial version + for _, v := range fileVersions { + if v.Path == path && v.Version == history.InitialVersion { + return v, nil + } } - } - return history.File{}, fmt.Errorf("initial version not found") + return history.File{}, fmt.Errorf("initial version not found") */ } diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go index 2ac48cfdc..681703c36 100644 --- a/internal/tui/components/core/status.go +++ b/internal/tui/components/core/status.go @@ -7,13 +7,13 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/llm/models" "github.com/sst/opencode/internal/lsp" "github.com/sst/opencode/internal/lsp/protocol" "github.com/sst/opencode/internal/pubsub" "github.com/sst/opencode/internal/status" + "github.com/sst/opencode/internal/tui/app" "github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/theme" ) diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go index 77e64e16f..33f7599db 100644 --- a/internal/tui/components/dialog/filepicker.go +++ b/internal/tui/components/dialog/filepicker.go @@ -17,10 +17,10 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/message" "github.com/sst/opencode/internal/status" + "github.com/sst/opencode/internal/tui/app" "github.com/sst/opencode/internal/tui/image" "github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/theme" @@ -42,7 +42,7 @@ type FilePrickerKeyMap struct { OpenFilePicker key.Binding Esc key.Binding InsertCWD key.Binding - Paste key.Binding + Paste key.Binding } var filePickerKeyMap = FilePrickerKeyMap{ diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go deleted file mode 100644 index bc59fdc6f..000000000 --- a/internal/tui/components/logs/details.go +++ /dev/null @@ -1,187 +0,0 @@ -package logs - -import ( - "bytes" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/sst/opencode/internal/logging" - "github.com/sst/opencode/internal/tui/layout" - "github.com/sst/opencode/internal/tui/styles" - "github.com/sst/opencode/internal/tui/theme" -) - -type DetailComponent interface { - tea.Model - layout.Sizeable - layout.Bindings -} - -type detailCmp struct { - width, height int - currentLog logging.Log - viewport viewport.Model - focused bool -} - -func (i *detailCmp) Init() tea.Cmd { - return nil -} - -func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case selectedLogMsg: - if msg.ID != i.currentLog.ID { - 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 - if !i.focused { - return i, nil - } - // Handle keyboard input for scrolling - i.viewport, cmd = i.viewport.Update(msg) - return i, cmd - } - - return i, cmd -} - -func (i *detailCmp) updateContent() { - var content strings.Builder - t := theme.CurrentTheme() - - // Format the header with timestamp and level - timeStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) - levelStyle := getLevelStyle(i.currentLog.Level) - - // Format timestamp - timeStr := i.currentLog.Timestamp.Format(time.RFC3339) - - header := lipgloss.JoinHorizontal( - lipgloss.Center, - timeStyle.Render(timeStr), - " ", - levelStyle.Render(i.currentLog.Level), - ) - - content.WriteString(lipgloss.NewStyle().Bold(true).Render(header)) - content.WriteString("\n\n") - - // Message with styling - messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text()) - content.WriteString(messageStyle.Render("Message:")) - content.WriteString("\n") - content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).Render(i.currentLog.Message)) - content.WriteString("\n\n") - - // Attributes section - if len(i.currentLog.Attributes) > 0 { - attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text()) - content.WriteString(attrHeaderStyle.Render("Attributes:")) - content.WriteString("\n") - - // Create a table-like display for attributes - keyStyle := lipgloss.NewStyle().Foreground(t.Primary()).Bold(true) - valueStyle := lipgloss.NewStyle().Foreground(t.Text()) - - for key, value := range i.currentLog.Attributes { - // if value is JSON, render it with indentation - if strings.HasPrefix(value, "{") { - var indented bytes.Buffer - if err := json.Indent(&indented, []byte(value), "", " "); err != nil { - indented.WriteString(value) - } - value = indented.String() - } - - attrLine := fmt.Sprintf("%s: %s", - keyStyle.Render(key), - valueStyle.Render(value), - ) - - content.WriteString(lipgloss.NewStyle().Padding(0, 2).Width(i.width).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).Width(i.width).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()) - case "warn", "warning": - return style.Foreground(t.Warning()) - case "error", "err": - return style.Foreground(t.Error()) - case "debug": - return style.Foreground(t.Success()) - default: - return style.Foreground(t.Text()) - } -} - -func (i *detailCmp) View() string { - t := theme.CurrentTheme() - return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), t.Background()) -} - -func (i *detailCmp) GetSize() (int, int) { - return i.width, i.height -} - -func (i *detailCmp) SetSize(width int, height int) tea.Cmd { - i.width = width - i.height = height - i.viewport.Width = i.width - i.viewport.Height = i.height - i.updateContent() - return nil -} - -func (i *detailCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(i.viewport.KeyMap) -} - -func NewLogsDetails() DetailComponent { - return &detailCmp{ - viewport: viewport.New(0, 0), - } -} - -// Focus implements the focusable interface -func (i *detailCmp) Focus() { - i.focused = true - i.viewport.SetYOffset(i.viewport.YOffset) -} - -// Blur implements the blurable interface -func (i *detailCmp) Blur() { - i.focused = false -} diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go deleted file mode 100644 index 1fc1daa38..000000000 --- a/internal/tui/components/logs/table.go +++ /dev/null @@ -1,207 +0,0 @@ -package logs - -import ( - // "context" - "fmt" - "log/slog" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/logging" - "github.com/sst/opencode/internal/pubsub" - "github.com/sst/opencode/internal/tui/layout" - "github.com/sst/opencode/internal/tui/state" - "github.com/sst/opencode/internal/tui/theme" -) - -type TableComponent interface { - tea.Model - layout.Sizeable - layout.Bindings -} - -type tableCmp struct { - app *app.App - table table.Model - focused bool - logs []logging.Log - selectedLogID string -} - -type selectedLogMsg logging.Log - -type LogsLoadedMsg struct { - logs []logging.Log -} - -func (i *tableCmp) Init() tea.Cmd { - return i.fetchLogs() -} - -func (i *tableCmp) fetchLogs() tea.Cmd { - return func() tea.Msg { - // ctx := context.Background() - - var logs []logging.Log - var err error - - // Limit the number of logs to improve performance - const logLimit = 100 - // TODO: Logs service not implemented in API yet - logs = []logging.Log{} - err = fmt.Errorf("logs service not implemented") - - if err != nil { - slog.Error("Failed to fetch logs", "error", err) - return nil - } - - return LogsLoadedMsg{logs: logs} - } -} - -func (i *tableCmp) updateRows() tea.Cmd { - return func() tea.Msg { - rows := make([]table.Row, 0, len(i.logs)) - - for _, log := range i.logs { - timeStr := log.Timestamp.Local().Format("15:04:05") - - // Include ID as hidden first column for selection - row := table.Row{ - log.ID, - timeStr, - log.Level, - log.Message, - } - rows = append(rows, row) - } - - i.table.SetRows(rows) - return nil - } -} - -func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case LogsLoadedMsg: - i.logs = msg.logs - return i, i.updateRows() - - case state.SessionSelectedMsg: - return i, i.fetchLogs() - - case pubsub.Event[logging.Log]: - if msg.Type == logging.EventLogCreated { - // 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] - } - return i, 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 - - selectedRow := i.table.SelectedRow() - if selectedRow != nil { - // Only send message if it's a new selection - if i.selectedLogID != selectedRow[0] { - cmds = append(cmds, func() tea.Msg { - for _, log := range i.logs { - if log.ID == selectedRow[0] { - return selectedLogMsg(log) - } - } - return nil - }) - } - - i.selectedLogID = selectedRow[0] - } - - return i, tea.Batch(cmds...) -} - -func (i *tableCmp) View() string { - t := theme.CurrentTheme() - defaultStyles := table.DefaultStyles() - defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary()) - i.table.SetStyles(defaultStyles) - return i.table.View() -} - -func (i *tableCmp) GetSize() (int, int) { - return i.table.Width(), i.table.Height() -} - -func (i *tableCmp) SetSize(width int, height int) tea.Cmd { - i.table.SetWidth(width) - i.table.SetHeight(height) - columns := i.table.Columns() - - // Calculate widths for visible columns - timeWidth := 8 // Fixed width for Time column - levelWidth := 7 // Fixed width for Level column - - // Message column gets the remaining space - messageWidth := width - timeWidth - levelWidth - 5 // 5 for padding and borders - - // Set column widths - columns[0].Width = 0 // ID column (hidden) - columns[1].Width = timeWidth - columns[2].Width = levelWidth - columns[3].Width = messageWidth - - i.table.SetColumns(columns) - return nil -} - -func (i *tableCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(i.table.KeyMap) -} - -func NewLogsTable(app *app.App) TableComponent { - columns := []table.Column{ - {Title: "ID", Width: 0}, // ID column with zero width - {Title: "Time", Width: 8}, - {Title: "Level", Width: 7}, - {Title: "Message", Width: 30}, - } - - tableModel := table.New( - table.WithColumns(columns), - ) - tableModel.Focus() - return &tableCmp{ - app: app, - table: tableModel, - logs: []logging.Log{}, - } -} - -// Focus implements the focusable interface -func (i *tableCmp) Focus() { - i.focused = true - i.table.Focus() -} - -// Blur implements the blurable interface -func (i *tableCmp) Blur() { - i.focused = false - i.table.Blur() -} diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index a1561b6be..65ac8afe9 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -8,11 +8,11 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/completions" "github.com/sst/opencode/internal/message" "github.com/sst/opencode/internal/session" "github.com/sst/opencode/internal/status" + "github.com/sst/opencode/internal/tui/app" "github.com/sst/opencode/internal/tui/components/chat" "github.com/sst/opencode/internal/tui/components/dialog" "github.com/sst/opencode/internal/tui/layout" diff --git a/internal/tui/page/logs.go b/internal/tui/page/logs.go deleted file mode 100644 index df5fb2c1b..000000000 --- a/internal/tui/page/logs.go +++ /dev/null @@ -1,224 +0,0 @@ -package page - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/tui/components/logs" - "github.com/sst/opencode/internal/tui/layout" - "github.com/sst/opencode/internal/tui/styles" - "github.com/sst/opencode/internal/tui/theme" -) - -var LogsPage PageID = "logs" - -type LogPage interface { - tea.Model - layout.Sizeable - layout.Bindings -} - -// Custom keybindings for logs page -type logsKeyMap struct { - Left key.Binding - Right key.Binding - Tab key.Binding -} - -var logsKeys = logsKeyMap{ - Left: key.NewBinding( - key.WithKeys("left", "h"), - key.WithHelp("←/h", "left pane"), - ), - Right: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→/l", "right pane"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch panes"), - ), -} - -type logsPage struct { - width, height int - table layout.Container - details layout.Container - activePane int // 0 = table, 1 = details - keyMap logsKeyMap -} - -// Message to switch active pane -type switchPaneMsg struct { - pane int // 0 = table, 1 = details -} - -func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.width = msg.Width - p.height = msg.Height - return p, p.SetSize(msg.Width, msg.Height) - case switchPaneMsg: - p.activePane = msg.pane - if p.activePane == 0 { - p.table.Focus() - p.details.Blur() - } else { - p.table.Blur() - p.details.Focus() - } - return p, nil - case tea.KeyMsg: - // Handle navigation keys - switch { - case key.Matches(msg, p.keyMap.Left): - return p, func() tea.Msg { - return switchPaneMsg{pane: 0} - } - case key.Matches(msg, p.keyMap.Right): - return p, func() tea.Msg { - return switchPaneMsg{pane: 1} - } - case key.Matches(msg, p.keyMap.Tab): - return p, func() tea.Msg { - return switchPaneMsg{pane: (p.activePane + 1) % 2} - } - } - } - - // Update the active pane first to handle keyboard input - if p.activePane == 0 { - table, cmd := p.table.Update(msg) - cmds = append(cmds, cmd) - p.table = table.(layout.Container) - - // Update details pane without focus - details, cmd := p.details.Update(msg) - cmds = append(cmds, cmd) - p.details = details.(layout.Container) - } else { - details, cmd := p.details.Update(msg) - cmds = append(cmds, cmd) - p.details = details.(layout.Container) - - // Update table pane without focus - table, cmd := p.table.Update(msg) - cmds = append(cmds, cmd) - p.table = table.(layout.Container) - } - - return p, tea.Batch(cmds...) -} - -func (p *logsPage) View() string { - t := theme.CurrentTheme() - - // Add padding to the right of the table view - tableView := lipgloss.NewStyle().PaddingRight(3).Render(p.table.View()) - - // Add border to the active pane - tableStyle := lipgloss.NewStyle() - detailsStyle := lipgloss.NewStyle() - - if p.activePane == 0 { - tableStyle = tableStyle.BorderForeground(t.Primary()) - } else { - detailsStyle = detailsStyle.BorderForeground(t.Primary()) - } - - tableView = tableStyle.Render(tableView) - detailsView := detailsStyle.Render(p.details.View()) - - return styles.ForceReplaceBackgroundWithLipgloss( - lipgloss.JoinVertical( - lipgloss.Left, - styles.Bold().Render(" esc")+styles.Muted().Render(" to go back")+ - " "+styles.Bold().Render(" tab/←→/h/l")+styles.Muted().Render(" to switch panes"), - "", - lipgloss.JoinHorizontal(lipgloss.Top, - tableView, - detailsView, - ), - "", - ), - t.Background(), - ) -} - -func (p *logsPage) BindingKeys() []key.Binding { - // Add our custom keybindings - bindings := []key.Binding{ - p.keyMap.Left, - p.keyMap.Right, - p.keyMap.Tab, - } - - // Add the active pane's keybindings - if p.activePane == 0 { - bindings = append(bindings, p.table.BindingKeys()...) - } else { - bindings = append(bindings, p.details.BindingKeys()...) - } - - return bindings -} - -// GetSize implements LogPage. -func (p *logsPage) GetSize() (int, int) { - return p.width, p.height -} - -// SetSize implements LogPage. -func (p *logsPage) SetSize(width int, height int) tea.Cmd { - p.width = width - p.height = height - - // Account for padding between panes (3 characters) - const padding = 3 - leftPaneWidth := (width - padding) / 2 - rightPaneWidth := width - leftPaneWidth - padding - - return tea.Batch( - p.table.SetSize(leftPaneWidth, height-3), - p.details.SetSize(rightPaneWidth, height-3), - ) -} - -func (p *logsPage) Init() tea.Cmd { - // Start with table pane active - p.activePane = 0 - p.table.Focus() - p.details.Blur() - - // Force an initial selection to update the details pane - var cmds []tea.Cmd - cmds = append(cmds, p.table.Init()) - cmds = append(cmds, p.details.Init()) - - // Send a key down and then key up to select the first row - // This ensures the details pane is populated when returning to the logs page - cmds = append(cmds, func() tea.Msg { - return tea.KeyMsg{Type: tea.KeyDown} - }) - cmds = append(cmds, func() tea.Msg { - return tea.KeyMsg{Type: tea.KeyUp} - }) - - return tea.Batch(cmds...) -} - -func NewLogsPage(app *app.App) tea.Model { - // Create containers with borders to visually indicate active pane - tableContainer := layout.NewContainer(logs.NewLogsTable(app), layout.WithBorderHorizontal()) - detailsContainer := layout.NewContainer(logs.NewLogsDetails(), layout.WithBorderHorizontal()) - - return &logsPage{ - table: tableContainer, - details: detailsContainer, - activePane: 0, // Start with table pane active - keyMap: logsKeys, - } -} diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go index 6f9af6a5b..8ff5fe8bf 100644 --- a/internal/tui/styles/icons.go +++ b/internal/tui/styles/icons.go @@ -1,7 +1,7 @@ package styles const ( - OpenCodeIcon string = "ⓒ" + OpenCodeIcon string = "◍" ErrorIcon string = "ⓔ" WarningIcon string = "ⓦ" diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go index d1b5a92bf..91661a1dd 100644 --- a/internal/tui/styles/styles.go +++ b/internal/tui/styles/styles.go @@ -5,12 +5,6 @@ import ( "github.com/sst/opencode/internal/tui/theme" ) -var ( - ImageBakcground = "#212121" -) - -// Style generation functions that use the current theme - // BaseStyle returns the base style with background and foreground colors func BaseStyle() lipgloss.Style { t := theme.CurrentTheme() diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c70d1f2bf..5165e7cb3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -10,10 +10,9 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/tui/app" - "github.com/sst/opencode/internal/logging" "github.com/sst/opencode/internal/message" "github.com/sst/opencode/internal/permission" "github.com/sst/opencode/internal/pubsub" @@ -22,7 +21,6 @@ import ( "github.com/sst/opencode/internal/tui/components/chat" "github.com/sst/opencode/internal/tui/components/core" "github.com/sst/opencode/internal/tui/components/dialog" - "github.com/sst/opencode/internal/tui/components/logs" "github.com/sst/opencode/internal/tui/layout" "github.com/sst/opencode/internal/tui/page" "github.com/sst/opencode/internal/tui/state" @@ -31,7 +29,6 @@ import ( ) type keyMap struct { - Logs key.Binding Quit key.Binding Help key.Binding SwitchSession key.Binding @@ -47,11 +44,6 @@ const ( ) var keys = keyMap{ - Logs: key.NewBinding( - key.WithKeys("ctrl+l"), - key.WithHelp("ctrl+l", "logs"), - ), - Quit: key.NewBinding( key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit"), @@ -100,11 +92,6 @@ var returnKey = key.NewBinding( key.WithHelp("esc", "close"), ) -var logsKeyReturnKey = key.NewBinding( - key.WithKeys("esc", "backspace", quitKey), - key.WithHelp("esc/q", "go back"), -) - type appModel struct { width, height int currentPage page.PageID @@ -268,10 +255,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case page.PageChangeMsg: return a, a.moveToPage(msg.ID) - case logs.LogsLoadedMsg: - a.pages[page.LogsPage], cmd = a.pages[page.LogsPage].Update(msg) - cmds = append(cmds, cmd) - case state.SessionSelectedMsg: a.app.CurrentSession = msg return a.updateAllPages(msg) @@ -553,11 +536,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil case key.Matches(msg, returnKey) || key.Matches(msg): - if msg.String() == quitKey { - if a.currentPage == page.LogsPage { - return a, a.moveToPage(page.ChatPage) - } - } else if !a.filepicker.IsCWDFocused() { + if !a.filepicker.IsCWDFocused() { if a.showToolsDialog { a.showToolsDialog = false return a, nil @@ -585,13 +564,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.app.SetFilepickerOpen(a.showFilepicker) return a, nil } - if a.currentPage == page.LogsPage { - // Always allow returning from logs page, even when agent is busy - return a, a.moveToPageUnconditional(page.ChatPage) - } } - case key.Matches(msg, keys.Logs): - return a, a.moveToPage(page.LogsPage) case key.Matches(msg, keys.Help): if a.showQuit { return a, nil @@ -627,11 +600,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } - case pubsub.Event[logging.Log]: - a.pages[page.LogsPage], cmd = a.pages[page.LogsPage].Update(msg) - cmds = append(cmds, cmd) - return a, tea.Batch(cmds...) - case pubsub.Event[message.Message]: a.pages[page.ChatPage], cmd = a.pages[page.ChatPage].Update(msg) cmds = append(cmds, cmd) @@ -772,18 +740,6 @@ func getAvailableToolNames(app *app.App) []string { } func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { - // Allow navigating to logs page even when agent is busy - if a.app.PrimaryAgent.IsBusy() && pageID != page.LogsPage { - // Don't move to other pages if the agent is busy - status.Warn("Agent is busy, please wait...") - return nil - } - - return a.moveToPageUnconditional(pageID) -} - -// moveToPageUnconditional is like moveToPage but doesn't check if the agent is busy -func (a *appModel) moveToPageUnconditional(pageID page.PageID) tea.Cmd { var cmds []tea.Cmd if _, ok := a.loadedPages[pageID]; !ok { cmd := a.pages[pageID].Init() @@ -854,9 +810,6 @@ func (a appModel) View() string { if a.showPermissions { bindings = append(bindings, a.permissions.BindingKeys()...) } - if a.currentPage == page.LogsPage { - bindings = append(bindings, logsKeyReturnKey) - } if !a.app.PrimaryAgent.IsBusy() { bindings = append(bindings, helpEsc) } @@ -1035,7 +988,6 @@ func New(app *app.App) tea.Model { commands: []dialog.Command{}, pages: map[page.PageID]tea.Model{ page.ChatPage: page.NewChatPage(app), - page.LogsPage: page.NewLogsPage(app), }, filepicker: dialog.NewFilepickerCmp(app), } -- cgit v1.2.3