summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--cmd/opencode/opencode.go32
-rw-r--r--cmd/root.go26
-rw-r--r--cmd/root_test.go143
-rw-r--r--internal/app/app_new.go203
-rw-r--r--internal/app/lsp.go134
-rw-r--r--internal/tui/app/app.go (renamed from internal/app/app.go)3
-rw-r--r--internal/tui/app/bridge.go (renamed from internal/app/services_bridge.go)19
-rw-r--r--internal/tui/app/interfaces.go (renamed from internal/app/interfaces.go)6
-rw-r--r--internal/tui/components/chat/editor.go26
-rw-r--r--internal/tui/components/chat/messages.go2
-rw-r--r--internal/tui/components/chat/sidebar.go178
-rw-r--r--internal/tui/components/core/status.go2
-rw-r--r--internal/tui/components/dialog/filepicker.go4
-rw-r--r--internal/tui/components/logs/details.go187
-rw-r--r--internal/tui/components/logs/table.go207
-rw-r--r--internal/tui/page/chat.go2
-rw-r--r--internal/tui/page/logs.go224
-rw-r--r--internal/tui/styles/icons.go2
-rw-r--r--internal/tui/styles/styles.go6
-rw-r--r--internal/tui/tui.go52
21 files changed, 125 insertions, 1335 deletions
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_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/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/app.go b/internal/tui/app/app.go
index 7aed6fb10..7acf3021d 100644
--- a/internal/app/app.go
+++ b/internal/tui/app/app.go
@@ -94,9 +94,6 @@ func New(ctx context.Context) (*App, error) {
// 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,
diff --git a/internal/app/services_bridge.go b/internal/tui/app/bridge.go
index d3f2e9a23..f9c075cf5 100644
--- a/internal/app/services_bridge.go
+++ b/internal/tui/app/bridge.go
@@ -29,7 +29,7 @@ func (s *SessionServiceBridge) Create(ctx context.Context, title string) (sessio
return session.Session{}, err
}
if resp.StatusCode() != 200 {
- return session.Session{}, fmt.Errorf("failed to create session: %d", resp.StatusCode)
+ return session.Session{}, fmt.Errorf("failed to create session: %d", resp.StatusCode())
}
info := resp.JSON200
@@ -121,16 +121,18 @@ func (a *AgentServiceBridge) Run(ctx context.Context, sessionID string, text str
// return "", fmt.Errorf("attachments not supported yet")
}
- parts := any([]map[string]any{
- {
- "type": "text",
- "text": text,
- },
+ 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,
+ SessionID: sessionID,
+ Parts: parts,
+ ProviderID: "anthropic",
+ ModelID: "claude-sonnet-4-20250514",
})
// The actual response will come through SSE
@@ -242,4 +244,3 @@ func (m *MessageServiceBridge) ListAfter(ctx context.Context, sessionID string,
func (m *MessageServiceBridge) Subscribe(ctx context.Context) <-chan pubsub.Event[message.Message] {
return m.broker.Subscribe(ctx)
}
-
diff --git a/internal/app/interfaces.go b/internal/tui/app/interfaces.go
index 2f4287078..9729a3bcb 100644
--- a/internal/app/interfaces.go
+++ b/internal/tui/app/interfaces.go
@@ -3,7 +3,7 @@ package app
import (
"context"
"time"
-
+
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/session"
@@ -21,7 +21,7 @@ type SessionService interface {
// 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)
@@ -39,4 +39,4 @@ type AgentService interface {
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/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),
}