Browse Source

wip: refactoring tui

adamdottv 9 months ago
parent
commit
15d21bf04a

+ 8 - 7
cmd/root.go

@@ -60,10 +60,12 @@ to assist developers in writing, debugging, and understanding code directly from
 		}
 
 		// Setup logging
-		lvl := new(slog.LevelVar)
-		textHandler := slog.NewTextHandler(logging.NewSlogWriter(), &slog.HandlerOptions{Level: lvl})
-		sessionAwareHandler := &SessionIDHandler{Handler: textHandler}
-		logger := slog.New(sessionAwareHandler)
+		file, err := os.OpenFile("app.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
+		if err != nil {
+			panic(err)
+		}
+		defer file.Close()
+		logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
 		slog.SetDefault(logger)
 
 		// Load the config
@@ -82,7 +84,7 @@ to assist developers in writing, debugging, and understanding code directly from
 			}
 			cwd = c
 		}
-		_, err := config.Load(cwd, debug, lvl)
+		_, err = config.Load(cwd, debug)
 		if err != nil {
 			return err
 		}
@@ -102,7 +104,6 @@ to assist developers in writing, debugging, and understanding code directly from
 			slog.Error("Failed to create app", "error", err)
 			return err
 		}
-		sessionAwareHandler.WithApp(app)
 
 		// Set up the TUI
 		zone.NewGlobal()
@@ -141,7 +142,7 @@ to assist developers in writing, debugging, and understanding code directly from
 			}
 		}()
 
-		evts, err := app.Client.Event(ctx)
+		evts, err := app.Events.Event(ctx)
 		if err != nil {
 			slog.Error("Failed to subscribe to events", "error", err)
 			return err

+ 23 - 12
internal/app/app.go

@@ -18,14 +18,17 @@ import (
 )
 
 type App struct {
+	State map[string]any
+
 	CurrentSession *session.Session
-	Logs           interface{} // TODO: Define LogService interface when needed
+	Logs           any // 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
+	History        any // TODO: Define HistoryService interface when needed
+	Permissions    any // TODO: Define PermissionService interface when needed
 	Status         status.Service
-	Client         *client.Client
+	Client         *client.ClientWithResponses
+	Events         *client.Client
 
 	PrimaryAgent AgentService
 
@@ -36,9 +39,9 @@ type App struct {
 	watcherCancelFuncs []context.CancelFunc
 	cancelFuncsMutex   sync.Mutex
 	watcherWG          sync.WaitGroup
-	
+
 	// UI state
-	filepickerOpen bool
+	filepickerOpen       bool
 	completionDialogOpen bool
 }
 
@@ -49,16 +52,22 @@ func New(ctx context.Context) (*App, error) {
 		slog.Error("Failed to initialize status service", "error", err)
 		return nil, err
 	}
-	
+
 	// Initialize file utilities
 	fileutil.Init()
 
 	// Create HTTP client
-	httpClient, err := client.NewClient("http://localhost:16713")
+	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)
@@ -66,18 +75,20 @@ func New(ctx context.Context) (*App, error) {
 	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(),
+		Logs:        nil, // logging.GetService(),
+		History:     nil, // history.GetService(),
+		Permissions: nil, // permission.GetService(),
 	}
 
 	// Initialize theme based on configuration

+ 0 - 158
internal/app/event_adapter.go

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

+ 10 - 9
internal/app/services_bridge.go

@@ -14,11 +14,11 @@ import (
 
 // SessionServiceBridge adapts the HTTP API to the old session.Service interface
 type SessionServiceBridge struct {
-	client *client.Client
+	client *client.ClientWithResponses
 }
 
 // NewSessionServiceBridge creates a new session service bridge
-func NewSessionServiceBridge(client *client.Client) *SessionServiceBridge {
+func NewSessionServiceBridge(client *client.ClientWithResponses) *SessionServiceBridge {
 	return &SessionServiceBridge{client: client}
 }
 
@@ -107,11 +107,11 @@ func (s *SessionServiceBridge) Delete(ctx context.Context, id string) error {
 
 // AgentServiceBridge provides a minimal agent service that sends messages to the API
 type AgentServiceBridge struct {
-	client *client.Client
+	client *client.ClientWithResponses
 }
 
 // NewAgentServiceBridge creates a new agent service bridge
-func NewAgentServiceBridge(client *client.Client) *AgentServiceBridge {
+func NewAgentServiceBridge(client *client.ClientWithResponses) *AgentServiceBridge {
 	return &AgentServiceBridge{client: client}
 }
 
@@ -123,7 +123,7 @@ func (a *AgentServiceBridge) Run(ctx context.Context, sessionID string, text str
 		// return "", fmt.Errorf("attachments not supported yet")
 	}
 
-	parts := interface{}([]map[string]interface{}{
+	parts := any([]map[string]any{
 		{
 			"type": "text",
 			"text": text,
@@ -170,12 +170,12 @@ func (a *AgentServiceBridge) CompactSession(ctx context.Context, sessionID strin
 
 // MessageServiceBridge provides a minimal message service that fetches from the API
 type MessageServiceBridge struct {
-	client *client.Client
+	client *client.ClientWithResponses
 	broker *pubsub.Broker[message.Message]
 }
 
 // NewMessageServiceBridge creates a new message service bridge
-func NewMessageServiceBridge(client *client.Client) *MessageServiceBridge {
+func NewMessageServiceBridge(client *client.ClientWithResponses) *MessageServiceBridge {
 	return &MessageServiceBridge{
 		client: client,
 		broker: pubsub.NewBroker[message.Message](),
@@ -198,7 +198,7 @@ func (m *MessageServiceBridge) List(ctx context.Context, sessionID string) ([]me
 	defer resp.Body.Close()
 
 	// The API returns a different format, we'll need to adapt it
-	var rawMessages interface{}
+	var rawMessages any
 	if err := json.NewDecoder(resp.Body).Decode(&rawMessages); err != nil {
 		return nil, err
 	}
@@ -247,4 +247,5 @@ func (m *MessageServiceBridge) ListAfter(ctx context.Context, sessionID string,
 // Subscribe subscribes to message events
 func (m *MessageServiceBridge) Subscribe(ctx context.Context) <-chan pubsub.Event[message.Message] {
 	return m.broker.Subscribe(ctx)
-}
+}
+

+ 1 - 2
internal/config/config.go

@@ -125,7 +125,7 @@ var cfg *Config
 // Load initializes the configuration from environment variables and config files.
 // If debug is true, debug mode is enabled and log level is set to debug.
 // It returns an error if configuration loading fails.
-func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) {
+func Load(workingDir string, debug bool) (*Config, error) {
 	if cfg != nil {
 		return cfg, nil
 	}
@@ -161,7 +161,6 @@ func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) {
 	if cfg.Debug {
 		defaultLevel = slog.LevelDebug
 	}
-	lvl.Set(defaultLevel)
 	slog.SetLogLoggerLevel(defaultLevel)
 
 	// Validate configuration

+ 18 - 54
internal/tui/components/chat/messages.go

@@ -2,9 +2,9 @@ package chat
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"math"
-	"strings"
 	"time"
 
 	"github.com/charmbracelet/bubbles/key"
@@ -156,55 +156,6 @@ 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)
@@ -293,20 +244,33 @@ func (m *messagesCmp) renderView() {
 		)
 	}
 
+	temp, _ := json.MarshalIndent(m.app.State, "", "    ")
+
 	m.viewport.SetContent(
 		baseStyle.
 			Width(m.width).
 			Render(
-				lipgloss.JoinVertical(
-					lipgloss.Top,
-					messages...,
-				),
+				string(temp),
+				// lipgloss.JoinVertical(
+				// 	lipgloss.Top,
+				// 	messages...,
+				// ),
 			),
 	)
 }
 
 func (m *messagesCmp) View() string {
 	baseStyle := styles.BaseStyle()
+	return baseStyle.
+		Width(m.width).
+		Render(
+			lipgloss.JoinVertical(
+				lipgloss.Top,
+				m.viewport.View(),
+				m.working(),
+				m.help(),
+			),
+		)
 
 	if m.rendering {
 		return baseStyle.

+ 1 - 0
internal/tui/page/chat.go

@@ -201,6 +201,7 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te
 		status.Error(err.Error())
 		return nil
 	}
+
 	return tea.Batch(cmds...)
 }
 

+ 3 - 0
internal/tui/state/state.go

@@ -5,3 +5,6 @@ import "github.com/sst/opencode/internal/session"
 type SessionSelectedMsg = *session.Session
 type SessionClearedMsg struct{}
 type CompactSessionMsg struct{}
+type StateUpdatedMsg struct {
+	State map[string]any
+}

+ 72 - 29
internal/tui/tui.go

@@ -2,6 +2,7 @@ package tui
 
 import (
 	"context"
+	"encoding/json"
 	// "fmt"
 	"log/slog"
 	"strings"
@@ -13,6 +14,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/logging"
 	"github.com/sst/opencode/internal/message"
@@ -251,6 +253,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case pubsub.Event[permission.PermissionRequest]:
 		a.showPermissions = true
 		return a, a.permissions.SetPermissions(msg.Payload)
+
 	case dialog.PermissionResponseMsg:
 		// TODO: Permissions service not implemented in API yet
 		// var cmd tea.Cmd
@@ -282,25 +285,44 @@ 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)
+		slog.Debug("Received SSE event", "key", msg.Key, "content", msg.Content)
+
+		// Create a deep copy of the state to avoid mutation issues
+		newState := deepCopyState(a.app.State)
+
+		// Split the key and traverse/create the nested structure
+		splits := strings.Split(msg.Key, "/")
+		current := newState
+
+		for i, part := range splits {
+			if i == len(splits)-1 {
+				// Last part - set the value
+				current[part] = msg.Content
+			} else {
+				// Intermediate parts - ensure map exists
+				if _, exists := current[part]; !exists {
+					current[part] = make(map[string]any)
+				}
+
+				// Navigate to the next level
+				nextLevel, ok := current[part].(map[string]any)
+				if !ok {
+					// If it's not a map, replace it with a new map
+					current[part] = make(map[string]any)
+					nextLevel = current[part].(map[string]any)
 				}
+				current = nextLevel
 			}
 		}
-		return a, nil
+
+		// Update the app state
+		a.app.State = newState
+
+		// Trigger UI update by updating all pages with the new state
+		return a.updateAllPages(state.StateUpdatedMsg{State: newState})
 
 	case dialog.CloseQuitMsg:
 		a.showQuit = false
@@ -733,22 +755,22 @@ 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,
-		app.Sessions,
-		app.Messages,
-		app.History,
-		app.LSPClients,
-	)
-
-	// Extract tool names
-	var toolNames []string
-	for _, tool := range allTools {
-		toolNames = append(toolNames, tool.Info().Name)
-	}
+		// Get primary agent tools (which already include MCP tools)
+		allTools := agent.PrimaryAgentTools(
+			app.Permissions,
+			app.Sessions,
+			app.Messages,
+			app.History,
+			app.LSPClients,
+		)
 
-	return toolNames
+		// Extract tool names
+		var toolNames []string
+		for _, tool := range allTools {
+			toolNames = append(toolNames, tool.Info().Name)
+		}
+
+		return toolNames
 	*/
 }
 
@@ -976,6 +998,27 @@ func (a appModel) View() string {
 	return appView
 }
 
+// deepCopyState creates a deep copy of a map[string]any
+func deepCopyState(src map[string]any) map[string]any {
+	if src == nil {
+		return nil
+	}
+
+	dst := make(map[string]any, len(src))
+	for k, v := range src {
+		switch val := v.(type) {
+		case map[string]any:
+			// Recursively copy nested maps
+			dst[k] = deepCopyState(val)
+		default:
+			// For other types, just copy the value
+			// Note: This is still a shallow copy for slices/arrays
+			dst[k] = v
+		}
+	}
+	return dst
+}
+
 func New(app *app.App) tea.Model {
 	startPage := page.ChatPage
 	model := &appModel{

+ 0 - 1
js/example/ink.tsx

@@ -38,7 +38,6 @@ function App() {
   const [state, setState] = useState(initial)
   const [input, setInput] = useState("")
 
-
   useEffect(() => {
     fetch("http://localhost:16713/event")
       .then(stream => {