summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/app
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-05-28 15:36:31 -0500
committeradamdottv <[email protected]>2025-05-28 15:36:36 -0500
commit9d7c5efb9b0b60c62aef3777b65b458a31ebbc88 (patch)
tree0f5acb5b8093d872b30178ded53df719be40cf44 /internal/tui/app
parent8863a499a9e311a48d6ab8bc05d267fb2a01f060 (diff)
downloadopencode-9d7c5efb9b0b60c62aef3777b65b458a31ebbc88.tar.gz
opencode-9d7c5efb9b0b60c62aef3777b65b458a31ebbc88.zip
wip: refactoring tui
Diffstat (limited to 'internal/tui/app')
-rw-r--r--internal/tui/app/app.go177
-rw-r--r--internal/tui/app/bridge.go246
-rw-r--r--internal/tui/app/interfaces.go42
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
+}