diff options
| author | adamdottv <[email protected]> | 2025-05-28 15:36:31 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-05-28 15:36:36 -0500 |
| commit | 9d7c5efb9b0b60c62aef3777b65b458a31ebbc88 (patch) | |
| tree | 0f5acb5b8093d872b30178ded53df719be40cf44 /internal/tui/app | |
| parent | 8863a499a9e311a48d6ab8bc05d267fb2a01f060 (diff) | |
| download | opencode-9d7c5efb9b0b60c62aef3777b65b458a31ebbc88.tar.gz opencode-9d7c5efb9b0b60c62aef3777b65b458a31ebbc88.zip | |
wip: refactoring tui
Diffstat (limited to 'internal/tui/app')
| -rw-r--r-- | internal/tui/app/app.go | 177 | ||||
| -rw-r--r-- | internal/tui/app/bridge.go | 246 | ||||
| -rw-r--r-- | internal/tui/app/interfaces.go | 42 |
3 files changed, 465 insertions, 0 deletions
diff --git a/internal/tui/app/app.go b/internal/tui/app/app.go new file mode 100644 index 000000000..7acf3021d --- /dev/null +++ b/internal/tui/app/app.go @@ -0,0 +1,177 @@ +package app + +import ( + "context" + "maps" + "sync" + "time" + + "log/slog" + + "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/fileutil" + "github.com/sst/opencode/internal/lsp" + "github.com/sst/opencode/internal/session" + "github.com/sst/opencode/internal/status" + "github.com/sst/opencode/internal/tui/theme" + "github.com/sst/opencode/pkg/client" +) + +type App struct { + State map[string]any + + CurrentSession *session.Session + Logs any // TODO: Define LogService interface when needed + Sessions SessionService + Messages MessageService + History any // TODO: Define HistoryService interface when needed + Permissions any // TODO: Define PermissionService interface when needed + Status status.Service + Client *client.ClientWithResponses + Events *client.Client + + PrimaryAgent AgentService + + LSPClients map[string]*lsp.Client + + clientsMutex sync.RWMutex + + watcherCancelFuncs []context.CancelFunc + cancelFuncsMutex sync.Mutex + watcherWG sync.WaitGroup + + // UI state + filepickerOpen bool + completionDialogOpen bool +} + +func New(ctx context.Context) (*App, error) { + // Initialize status service (still needed for UI notifications) + err := status.InitService() + if err != nil { + slog.Error("Failed to initialize status service", "error", err) + return nil, err + } + + // Initialize file utilities + fileutil.Init() + + // Create HTTP client + url := "http://localhost:16713" + httpClient, err := client.NewClientWithResponses(url) + if err != nil { + slog.Error("Failed to create client", "error", err) + return nil, err + } + eventClient, err := client.NewClient(url) + if err != nil { + slog.Error("Failed to create event client", "error", err) + return nil, err + } + + // Create service bridges + sessionBridge := NewSessionServiceBridge(httpClient) + messageBridge := NewMessageServiceBridge(httpClient) + agentBridge := NewAgentServiceBridge(httpClient) + + app := &App{ + State: make(map[string]any), + Client: httpClient, + Events: eventClient, + CurrentSession: &session.Session{}, + Sessions: sessionBridge, + Messages: messageBridge, + PrimaryAgent: agentBridge, + Status: status.GetService(), + LSPClients: make(map[string]*lsp.Client), + + // TODO: These services need API endpoints: + Logs: nil, // logging.GetService(), + History: nil, // history.GetService(), + Permissions: nil, // permission.GetService(), + } + + // Initialize theme based on configuration + app.initTheme() + + // TODO: Remove this once agent is fully replaced by API + // app.PrimaryAgent, err = agent.NewAgent( + // config.AgentPrimary, + // app.Sessions, + // app.Messages, + // agent.PrimaryAgentTools( + // app.Permissions, + // app.Sessions, + // app.Messages, + // app.History, + // app.LSPClients, + // ), + // ) + // if err != nil { + // slog.Error("Failed to create primary agent", "error", err) + // return nil, err + // } + + return app, nil +} + +// initTheme sets the application theme based on the configuration +func (app *App) initTheme() { + cfg := config.Get() + if cfg == nil || cfg.TUI.Theme == "" { + return // Use default theme + } + + // Try to set the theme from config + err := theme.SetTheme(cfg.TUI.Theme) + if err != nil { + slog.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err) + } else { + slog.Debug("Set theme from config", "theme", cfg.TUI.Theme) + } +} + +// IsFilepickerOpen returns whether the filepicker is currently open +func (app *App) IsFilepickerOpen() bool { + return app.filepickerOpen +} + +// SetFilepickerOpen sets the state of the filepicker +func (app *App) SetFilepickerOpen(open bool) { + app.filepickerOpen = open +} + +// IsCompletionDialogOpen returns whether the completion dialog is currently open +func (app *App) IsCompletionDialogOpen() bool { + return app.completionDialogOpen +} + +// SetCompletionDialogOpen sets the state of the completion dialog +func (app *App) SetCompletionDialogOpen(open bool) { + app.completionDialogOpen = open +} + +// Shutdown performs a clean shutdown of the application +func (app *App) Shutdown() { + // Cancel all watcher goroutines + app.cancelFuncsMutex.Lock() + for _, cancel := range app.watcherCancelFuncs { + cancel() + } + app.cancelFuncsMutex.Unlock() + app.watcherWG.Wait() + + // Perform additional cleanup for LSP clients + app.clientsMutex.RLock() + clients := make(map[string]*lsp.Client, len(app.LSPClients)) + maps.Copy(clients, app.LSPClients) + app.clientsMutex.RUnlock() + + for name, client := range clients { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := client.Shutdown(shutdownCtx); err != nil { + slog.Error("Failed to shutdown LSP client", "name", name, "error", err) + } + cancel() + } +} diff --git a/internal/tui/app/bridge.go b/internal/tui/app/bridge.go new file mode 100644 index 000000000..f9c075cf5 --- /dev/null +++ b/internal/tui/app/bridge.go @@ -0,0 +1,246 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/sst/opencode/internal/message" + "github.com/sst/opencode/internal/pubsub" + "github.com/sst/opencode/internal/session" + "github.com/sst/opencode/pkg/client" +) + +// SessionServiceBridge adapts the HTTP API to the old session.Service interface +type SessionServiceBridge struct { + client *client.ClientWithResponses +} + +// NewSessionServiceBridge creates a new session service bridge +func NewSessionServiceBridge(client *client.ClientWithResponses) *SessionServiceBridge { + return &SessionServiceBridge{client: client} +} + +// Create creates a new session +func (s *SessionServiceBridge) Create(ctx context.Context, title string) (session.Session, error) { + resp, err := s.client.PostSessionCreateWithResponse(ctx) + if err != nil { + return session.Session{}, err + } + if resp.StatusCode() != 200 { + return session.Session{}, fmt.Errorf("failed to create session: %d", resp.StatusCode()) + } + info := resp.JSON200 + + // Convert to old session type + return session.Session{ + ID: info.Id, + Title: info.Title, + CreatedAt: time.Now(), // API doesn't provide this yet + UpdatedAt: time.Now(), // API doesn't provide this yet + }, nil +} + +// Get retrieves a session by ID +func (s *SessionServiceBridge) Get(ctx context.Context, id string) (session.Session, error) { + // TODO: API doesn't have a get by ID endpoint yet + // For now, list all and find the one we want + sessions, err := s.List(ctx) + if err != nil { + return session.Session{}, err + } + + for _, sess := range sessions { + if sess.ID == id { + return sess, nil + } + } + + return session.Session{}, fmt.Errorf("session not found: %s", id) +} + +// List retrieves all sessions +func (s *SessionServiceBridge) List(ctx context.Context) ([]session.Session, error) { + resp, err := s.client.PostSessionListWithResponse(ctx) + if err != nil { + return nil, err + } + + if resp.StatusCode() != 200 { + return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode()) + } + + if resp.JSON200 == nil { + return []session.Session{}, nil + } + + infos := *resp.JSON200 + + // Convert to old session type + sessions := make([]session.Session, len(infos)) + for i, info := range infos { + sessions[i] = session.Session{ + ID: info.Id, + Title: info.Title, + CreatedAt: time.Now(), // API doesn't provide this yet + UpdatedAt: time.Now(), // API doesn't provide this yet + } + } + + return sessions, nil +} + +// Update updates a session - NOT IMPLEMENTED IN API YET +func (s *SessionServiceBridge) Update(ctx context.Context, id, title string) error { + // TODO: Not implemented in TypeScript API yet + return fmt.Errorf("session update not implemented in API") +} + +// Delete deletes a session - NOT IMPLEMENTED IN API YET +func (s *SessionServiceBridge) Delete(ctx context.Context, id string) error { + // TODO: Not implemented in TypeScript API yet + return fmt.Errorf("session delete not implemented in API") +} + +// AgentServiceBridge provides a minimal agent service that sends messages to the API +type AgentServiceBridge struct { + client *client.ClientWithResponses +} + +// NewAgentServiceBridge creates a new agent service bridge +func NewAgentServiceBridge(client *client.ClientWithResponses) *AgentServiceBridge { + return &AgentServiceBridge{client: client} +} + +// Run sends a message to the chat API +func (a *AgentServiceBridge) Run(ctx context.Context, sessionID string, text string, attachments ...message.Attachment) (string, error) { + // TODO: Handle attachments when API supports them + if len(attachments) > 0 { + // For now, ignore attachments + // return "", fmt.Errorf("attachments not supported yet") + } + + part := client.SessionMessagePart{} + part.FromSessionMessagePartText(client.SessionMessagePartText{ + Type: "text", + Text: text, + }) + parts := []client.SessionMessagePart{part} + + go a.client.PostSessionChatWithResponse(ctx, client.PostSessionChatJSONRequestBody{ + SessionID: sessionID, + Parts: parts, + ProviderID: "anthropic", + ModelID: "claude-sonnet-4-20250514", + }) + + // The actual response will come through SSE + // For now, just return success + return "", nil +} + +// Cancel cancels the current generation - NOT IMPLEMENTED IN API YET +func (a *AgentServiceBridge) Cancel(sessionID string) error { + // TODO: Not implemented in TypeScript API yet + return nil +} + +// IsBusy checks if the agent is busy - NOT IMPLEMENTED IN API YET +func (a *AgentServiceBridge) IsBusy() bool { + // TODO: Not implemented in TypeScript API yet + return false +} + +// IsSessionBusy checks if the agent is busy for a specific session - NOT IMPLEMENTED IN API YET +func (a *AgentServiceBridge) IsSessionBusy(sessionID string) bool { + // TODO: Not implemented in TypeScript API yet + return false +} + +// CompactSession compacts a session - NOT IMPLEMENTED IN API YET +func (a *AgentServiceBridge) CompactSession(ctx context.Context, sessionID string, force bool) error { + // TODO: Not implemented in TypeScript API yet + return fmt.Errorf("session compaction not implemented in API") +} + +// MessageServiceBridge provides a minimal message service that fetches from the API +type MessageServiceBridge struct { + client *client.ClientWithResponses + broker *pubsub.Broker[message.Message] +} + +// NewMessageServiceBridge creates a new message service bridge +func NewMessageServiceBridge(client *client.ClientWithResponses) *MessageServiceBridge { + return &MessageServiceBridge{ + client: client, + broker: pubsub.NewBroker[message.Message](), + } +} + +// GetBySession retrieves messages for a session +func (m *MessageServiceBridge) GetBySession(ctx context.Context, sessionID string) ([]message.Message, error) { + return m.List(ctx, sessionID) +} + +// List retrieves messages for a session +func (m *MessageServiceBridge) List(ctx context.Context, sessionID string) ([]message.Message, error) { + resp, err := m.client.PostSessionMessages(ctx, client.PostSessionMessagesJSONRequestBody{ + SessionID: sessionID, + }) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // The API returns a different format, we'll need to adapt it + var rawMessages any + if err := json.NewDecoder(resp.Body).Decode(&rawMessages); err != nil { + return nil, err + } + + // TODO: Convert the API message format to our internal format + // For now, return empty to avoid compilation errors + return []message.Message{}, nil +} + +// Create creates a new message - NOT NEEDED, handled by chat API +func (m *MessageServiceBridge) Create(ctx context.Context, sessionID string, params message.CreateMessageParams) (message.Message, error) { + // Messages are created through the chat API + return message.Message{}, fmt.Errorf("use chat API to send messages") +} + +// Update updates a message - NOT IMPLEMENTED IN API YET +func (m *MessageServiceBridge) Update(ctx context.Context, msg message.Message) (message.Message, error) { + // TODO: Not implemented in TypeScript API yet + return message.Message{}, fmt.Errorf("message update not implemented in API") +} + +// Delete deletes a message - NOT IMPLEMENTED IN API YET +func (m *MessageServiceBridge) Delete(ctx context.Context, id string) error { + // TODO: Not implemented in TypeScript API yet + return fmt.Errorf("message delete not implemented in API") +} + +// DeleteSessionMessages deletes all messages for a session - NOT IMPLEMENTED IN API YET +func (m *MessageServiceBridge) DeleteSessionMessages(ctx context.Context, sessionID string) error { + // TODO: Not implemented in TypeScript API yet + return fmt.Errorf("delete session messages not implemented in API") +} + +// Get retrieves a message by ID - NOT IMPLEMENTED IN API YET +func (m *MessageServiceBridge) Get(ctx context.Context, id string) (message.Message, error) { + // TODO: Not implemented in TypeScript API yet + return message.Message{}, fmt.Errorf("get message by ID not implemented in API") +} + +// ListAfter retrieves messages after a timestamp - NOT IMPLEMENTED IN API YET +func (m *MessageServiceBridge) ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]message.Message, error) { + // TODO: Not implemented in TypeScript API yet + return []message.Message{}, fmt.Errorf("list messages after timestamp not implemented in API") +} + +// Subscribe subscribes to message events +func (m *MessageServiceBridge) Subscribe(ctx context.Context) <-chan pubsub.Event[message.Message] { + return m.broker.Subscribe(ctx) +} diff --git a/internal/tui/app/interfaces.go b/internal/tui/app/interfaces.go new file mode 100644 index 000000000..9729a3bcb --- /dev/null +++ b/internal/tui/app/interfaces.go @@ -0,0 +1,42 @@ +package app + +import ( + "context" + "time" + + "github.com/sst/opencode/internal/message" + "github.com/sst/opencode/internal/pubsub" + "github.com/sst/opencode/internal/session" +) + +// SessionService defines the interface for session operations +type SessionService interface { + Create(ctx context.Context, title string) (session.Session, error) + Get(ctx context.Context, id string) (session.Session, error) + List(ctx context.Context) ([]session.Session, error) + Update(ctx context.Context, id, title string) error + Delete(ctx context.Context, id string) error +} + +// MessageService defines the interface for message operations +type MessageService interface { + pubsub.Subscriber[message.Message] + + GetBySession(ctx context.Context, sessionID string) ([]message.Message, error) + List(ctx context.Context, sessionID string) ([]message.Message, error) + Create(ctx context.Context, sessionID string, params message.CreateMessageParams) (message.Message, error) + Update(ctx context.Context, msg message.Message) (message.Message, error) + Delete(ctx context.Context, id string) error + DeleteSessionMessages(ctx context.Context, sessionID string) error + Get(ctx context.Context, id string) (message.Message, error) + ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]message.Message, error) +} + +// AgentService defines the interface for agent operations +type AgentService interface { + Run(ctx context.Context, sessionID string, text string, attachments ...message.Attachment) (string, error) + Cancel(sessionID string) error + IsBusy() bool + IsSessionBusy(sessionID string) bool + CompactSession(ctx context.Context, sessionID string, force bool) error +} |
