wip: refactoring tui

This commit is contained in:
adamdottv
2025-05-28 10:12:08 -05:00
parent 641e9ff664
commit 5e738ce7d3
12 changed files with 938 additions and 467 deletions

View File

@@ -2,7 +2,6 @@ package app
import (
"context"
"database/sql"
"maps"
"sync"
"time"
@@ -11,12 +10,7 @@ import (
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
"github.com/sst/opencode/internal/session"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/theme"
@@ -25,15 +19,15 @@ import (
type App struct {
CurrentSession *session.Session
Logs logging.Service
Sessions session.Service
Messages message.Service
History history.Service
Permissions permission.Service
Logs interface{} // TODO: Define LogService interface when needed
Sessions SessionService
Messages MessageService
History interface{} // TODO: Define HistoryService interface when needed
Permissions interface{} // TODO: Define PermissionService interface when needed
Status status.Service
Client *client.Client
PrimaryAgent agent.Service
PrimaryAgent AgentService
LSPClients map[string]*lsp.Client
@@ -48,55 +42,42 @@ type App struct {
completionDialogOpen bool
}
func New(ctx context.Context, conn *sql.DB) (*App, error) {
err := logging.InitService(conn)
if err != nil {
slog.Error("Failed to initialize logging service", "error", err)
return nil, err
}
err = session.InitService(conn)
if err != nil {
slog.Error("Failed to initialize session service", "error", err)
return nil, err
}
err = message.InitService(conn)
if err != nil {
slog.Error("Failed to initialize message service", "error", err)
return nil, err
}
err = history.InitService(conn)
if err != nil {
slog.Error("Failed to initialize history service", "error", err)
return nil, err
}
err = permission.InitService()
if err != nil {
slog.Error("Failed to initialize permission service", "error", err)
return nil, err
}
err = status.InitService()
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()
client, err := client.NewClient("http://localhost:16713")
// Create HTTP client
httpClient, err := client.NewClient("http://localhost:16713")
if err != nil {
slog.Error("Failed to create client", "error", err)
return nil, err
}
// Create service bridges
sessionBridge := NewSessionServiceBridge(httpClient)
messageBridge := NewMessageServiceBridge(httpClient)
agentBridge := NewAgentServiceBridge(httpClient)
app := &App{
Client: client,
Client: httpClient,
CurrentSession: &session.Session{},
Logs: logging.GetService(),
Sessions: session.GetService(),
Messages: message.GetService(),
History: history.GetService(),
Permissions: permission.GetService(),
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
@@ -105,22 +86,23 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
// Initialize LSP clients in the background
go app.initLSPClients(ctx)
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
}
// 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
}

203
internal/app/app_new.go Normal file
View File

@@ -0,0 +1,203 @@
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
}

View File

@@ -0,0 +1,158 @@
package app
import (
"encoding/json"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/pkg/client"
)
// StorageWriteMsg is sent when a storage.write event is received
type StorageWriteMsg struct {
Key string
Content interface{}
}
// ProcessSSEEvent converts SSE events into TUI messages
func ProcessSSEEvent(event interface{}) tea.Msg {
switch e := event.(type) {
case *client.EventStorageWrite:
return StorageWriteMsg{
Key: e.Key,
Content: e.Content,
}
}
// Return the raw event if we don't have a specific handler
return event
}
// MessageFromStorage converts storage content to internal message format
type MessageData struct {
ID string `json:"id"`
Role string `json:"role"`
Parts []interface{} `json:"parts"`
Metadata map[string]interface{} `json:"metadata"`
}
// SessionInfoFromStorage converts storage content to session info
type SessionInfoData struct {
ID string `json:"id"`
Title string `json:"title"`
ShareID *string `json:"shareID,omitempty"`
Tokens struct {
Input float32 `json:"input"`
Output float32 `json:"output"`
Reasoning float32 `json:"reasoning"`
} `json:"tokens"`
}
// ConvertStorageMessage converts a storage message to internal message format
func ConvertStorageMessage(data interface{}, sessionID string) (*message.Message, error) {
// Convert the interface{} to JSON then back to our struct
jsonData, err := json.Marshal(data)
if err != nil {
return nil, err
}
var msgData MessageData
if err := json.Unmarshal(jsonData, &msgData); err != nil {
return nil, err
}
// Convert parts
var parts []message.ContentPart
for _, part := range msgData.Parts {
partMap, ok := part.(map[string]interface{})
if !ok {
continue
}
partType, ok := partMap["type"].(string)
if !ok {
continue
}
switch partType {
case "text":
if text, ok := partMap["text"].(string); ok {
parts = append(parts, message.TextContent{Text: text})
}
case "tool-invocation":
if toolInv, ok := partMap["toolInvocation"].(map[string]interface{}); ok {
// Convert tool invocation to tool call
toolCall := message.ToolCall{
ID: toolInv["toolCallId"].(string),
Name: toolInv["toolName"].(string),
Type: "function",
}
if args, ok := toolInv["args"]; ok {
argsJSON, _ := json.Marshal(args)
toolCall.Input = string(argsJSON)
}
if state, ok := toolInv["state"].(string); ok {
toolCall.Finished = state == "result"
}
parts = append(parts, toolCall)
// If there's a result, add it as a tool result
if result, ok := toolInv["result"]; ok && toolCall.Finished {
resultStr := ""
switch r := result.(type) {
case string:
resultStr = r
default:
resultJSON, _ := json.Marshal(r)
resultStr = string(resultJSON)
}
parts = append(parts, message.ToolResult{
ToolCallID: toolCall.ID,
Name: toolCall.Name,
Content: resultStr,
})
}
}
}
}
// Convert role
var role message.MessageRole
switch msgData.Role {
case "user":
role = message.User
case "assistant":
role = message.Assistant
case "system":
role = message.System
default:
role = message.MessageRole(msgData.Role)
}
// Create message
msg := &message.Message{
ID: msgData.ID,
Role: role,
SessionID: sessionID,
Parts: parts,
CreatedAt: time.Now(), // TODO: Get from metadata
UpdatedAt: time.Now(), // TODO: Get from metadata
}
// Try to get timestamps from metadata
if metadata, ok := msgData.Metadata["time"].(map[string]interface{}); ok {
if created, ok := metadata["created"].(float64); ok {
msg.CreatedAt = time.Unix(int64(created/1000), 0)
}
if completed, ok := metadata["completed"].(float64); ok {
msg.UpdatedAt = time.Unix(int64(completed/1000), 0)
}
}
return msg, nil
}

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

@@ -0,0 +1,250 @@
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.Client
}
// NewSessionServiceBridge creates a new session service bridge
func NewSessionServiceBridge(client *client.Client) *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.PostSessionCreate(ctx)
if err != nil {
return session.Session{}, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return session.Session{}, fmt.Errorf("failed to create session: %d", resp.StatusCode)
}
var info client.SessionInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return session.Session{}, err
}
// 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.PostSessionList(ctx)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var infos []client.SessionInfo
if err := json.NewDecoder(resp.Body).Decode(&infos); err != nil {
return nil, err
}
// 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.Client
}
// NewAgentServiceBridge creates a new agent service bridge
func NewAgentServiceBridge(client *client.Client) *AgentServiceBridge {
return &AgentServiceBridge{client: client}
}
// Run sends a message to the chat API
func (a *AgentServiceBridge) Run(ctx context.Context, sessionID string, text string, attachments ...message.Attachment) (string, error) {
// TODO: Handle attachments when API supports them
if len(attachments) > 0 {
// For now, ignore attachments
// return "", fmt.Errorf("attachments not supported yet")
}
parts := 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 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.Client
broker *pubsub.Broker[message.Message]
}
// NewMessageServiceBridge creates a new message service bridge
func NewMessageServiceBridge(client *client.Client) *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 interface{}
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

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
@@ -155,6 +156,55 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
case app.StorageWriteMsg:
// Handle storage write events from the TypeScript backend
keyParts := strings.Split(msg.Key, "/")
if len(keyParts) >= 4 && keyParts[0] == "session" && keyParts[1] == "message" {
sessionID := keyParts[2]
if sessionID == m.app.CurrentSession.ID {
// Convert storage message to internal format
convertedMsg, err := app.ConvertStorageMessage(msg.Content, sessionID)
if err != nil {
status.Error("Failed to convert message: " + err.Error())
return m, nil
}
// Check if message exists
messageExists := false
messageIndex := -1
for i, v := range m.messages {
if v.ID == convertedMsg.ID {
messageExists = true
messageIndex = i
break
}
}
needsRerender := false
if messageExists {
// Update existing message
m.messages[messageIndex] = *convertedMsg
delete(m.cachedContent, convertedMsg.ID)
needsRerender = true
} else {
// Add new message
if len(m.messages) > 0 {
lastMsgID := m.messages[len(m.messages)-1].ID
delete(m.cachedContent, lastMsgID)
}
m.messages = append(m.messages, *convertedMsg)
delete(m.cachedContent, m.currentMsgID)
m.currentMsgID = convertedMsg.ID
needsRerender = true
}
if needsRerender {
m.renderView()
m.viewport.GotoBottom()
}
}
}
}
spinner, cmd := m.spinner.Update(msg)

View File

@@ -10,7 +10,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
// "github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/tui/state"
@@ -28,39 +28,28 @@ type sidebarCmp struct {
}
func (m *sidebarCmp) Init() tea.Cmd {
if m.app.History != nil {
ctx := context.Background()
// Subscribe to file events
filesCh := m.app.History.Subscribe(ctx)
// Initialize the modified files map
m.modFiles = make(map[string]struct {
additions int
removals int
})
// Load initial files and calculate diffs
m.loadModifiedFiles(ctx)
// Return a command that will send file events to the Update method
return func() tea.Msg {
return <-filesCh
}
}
// TODO: History service not implemented in API yet
// Initialize the modified files map
m.modFiles = make(map[string]struct {
additions int
removals int
})
return nil
}
func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
switch msg.(type) {
case state.SessionSelectedMsg:
ctx := context.Background()
m.loadModifiedFiles(ctx)
// TODO: History service not implemented in API yet
// ctx := context.Background()
// m.loadModifiedFiles(ctx)
case pubsub.Event[history.File]:
if msg.Payload.SessionID == m.app.CurrentSession.ID {
// Process the individual file change instead of reloading all files
ctx := context.Background()
m.processFileChanges(ctx, msg.Payload)
}
// TODO: History service not implemented in API yet
// if msg.Payload.SessionID == m.app.CurrentSession.ID {
// // Process the individual file change instead of reloading all files
// ctx := context.Background()
// m.processFileChanges(ctx, msg.Payload)
// }
}
return m, nil
}
@@ -224,6 +213,9 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
return
}
// 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 {
@@ -235,6 +227,7 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
if err != nil {
return
}
*/
// Clear the existing map to rebuild it
m.modFiles = make(map[string]struct {
@@ -242,6 +235,7 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
removals int
})
/*
// Process each latest file
for _, file := range latestFiles {
// Skip if this is the initial version (no changes to show)
@@ -286,9 +280,13 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
}
}
}
*/
}
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
@@ -327,16 +325,22 @@ func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File)
displayPath := getDisplayPath(file.Path)
delete(m.modFiles, displayPath)
}
*/
}
// Helper function to find the initial version of a file
func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
// 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
}
*/
/*
// Find the initial version
for _, v := range fileVersions {
if v.Path == path && v.Version == history.InitialVersion {
@@ -345,6 +349,7 @@ func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (histo
}
return history.File{}, fmt.Errorf("initial version not found")
*/
}
// Helper function to get the display path for a file

View File

@@ -1,7 +1,8 @@
package logs
import (
"context"
// "context"
"fmt"
"log/slog"
"github.com/charmbracelet/bubbles/key"
@@ -41,18 +42,16 @@ func (i *tableCmp) Init() tea.Cmd {
func (i *tableCmp) fetchLogs() tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
// ctx := context.Background()
var logs []logging.Log
var err error
// Limit the number of logs to improve performance
const logLimit = 100
if i.app.CurrentSession.ID == "" {
logs, err = i.app.Logs.ListAll(ctx, logLimit)
} else {
logs, err = i.app.Logs.ListBySession(ctx, i.app.CurrentSession.ID)
}
// 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)

View File

@@ -2,7 +2,7 @@ package tui
import (
"context"
"fmt"
// "fmt"
"log/slog"
"strings"
@@ -13,7 +13,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/agent"
// "github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
@@ -28,6 +28,7 @@ import (
"github.com/sst/opencode/internal/tui/page"
"github.com/sst/opencode/internal/tui/state"
"github.com/sst/opencode/internal/tui/util"
"github.com/sst/opencode/pkg/client"
)
type keyMap struct {
@@ -251,17 +252,18 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.showPermissions = true
return a, a.permissions.SetPermissions(msg.Payload)
case dialog.PermissionResponseMsg:
var cmd tea.Cmd
switch msg.Action {
case dialog.PermissionAllow:
a.app.Permissions.Grant(context.Background(), msg.Permission)
case dialog.PermissionAllowForSession:
a.app.Permissions.GrantPersistant(context.Background(), msg.Permission)
case dialog.PermissionDeny:
a.app.Permissions.Deny(context.Background(), msg.Permission)
}
// TODO: Permissions service not implemented in API yet
// var cmd tea.Cmd
// switch msg.Action {
// case dialog.PermissionAllow:
// a.app.Permissions.Grant(context.Background(), msg.Permission)
// case dialog.PermissionAllowForSession:
// a.app.Permissions.GrantPersistant(context.Background(), msg.Permission)
// case dialog.PermissionDeny:
// a.app.Permissions.Deny(context.Background(), msg.Permission)
// }
a.showPermissions = false
return a, cmd
return a, nil
case page.PageChangeMsg:
return a, a.moveToPage(msg.ID)
@@ -280,6 +282,25 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.CurrentSession = &msg.Payload
}
}
// Handle SSE events from the TypeScript backend
case *client.EventStorageWrite:
// Process storage write events
processedMsg := app.ProcessSSEEvent(msg)
if storageMsg, ok := processedMsg.(app.StorageWriteMsg); ok {
// Forward to the appropriate page/component based on key
keyParts := strings.Split(storageMsg.Key, "/")
if len(keyParts) >= 3 && keyParts[0] == "session" {
if keyParts[1] == "message" {
// This is a message update, forward to the chat page
return a.updateAllPages(storageMsg)
} else if keyParts[1] == "info" {
// This is a session info update
return a.updateAllPages(storageMsg)
}
}
}
return a, nil
case dialog.CloseQuitMsg:
a.showQuit = false
@@ -321,13 +342,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.ModelSelectedMsg:
a.showModelDialog = false
model, err := a.app.PrimaryAgent.Update(config.AgentPrimary, msg.Model.ID)
if err != nil {
status.Error(err.Error())
return a, nil
}
// TODO: Agent model update not implemented in API yet
// model, err := a.app.PrimaryAgent.Update(config.AgentPrimary, msg.Model.ID)
// if err != nil {
// status.Error(err.Error())
// return a, nil
// }
status.Info(fmt.Sprintf("Model changed to %s", model.Name))
// status.Info(fmt.Sprintf("Model changed to %s", model.Name))
status.Info("Model selection not implemented in API yet")
return a, nil
case dialog.ShowInitDialogMsg:
@@ -707,6 +730,9 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
// getAvailableToolNames returns a list of all available tool names
func getAvailableToolNames(app *app.App) []string {
// TODO: Tools not implemented in API yet
return []string{"Tools not available in API mode"}
/*
// Get primary agent tools (which already include MCP tools)
allTools := agent.PrimaryAgentTools(
app.Permissions,
@@ -723,6 +749,7 @@ func getAvailableToolNames(app *app.App) []string {
}
return toolNames
*/
}
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {