wip: refactoring tui

This commit is contained in:
adamdottv
2025-05-28 15:36:31 -05:00
parent 8863a499a9
commit 9d7c5efb9b
21 changed files with 136 additions and 1346 deletions

177
internal/tui/app/app.go Normal file
View File

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

246
internal/tui/app/bridge.go Normal file
View File

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

View File

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

View File

@@ -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: "",
}
}

View File

@@ -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"

View File

@@ -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,50 +236,50 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
})
/*
// 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
}
// 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
// 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,
}
}
}
// 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,
}
}
}
*/
}
@@ -287,44 +287,44 @@ 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,
// 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)
}
} 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")
*/
}

View File

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

View File

@@ -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{

View File

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

View File

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

View File

@@ -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"

View File

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

View File

@@ -1,7 +1,7 @@
package styles
const (
OpenCodeIcon string = ""
OpenCodeIcon string = ""
ErrorIcon string = "ⓔ"
WarningIcon string = "ⓦ"

View File

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

View File

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