summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--cmd/root.go15
-rw-r--r--internal/app/app.go35
-rw-r--r--internal/app/event_adapter.go158
-rw-r--r--internal/app/services_bridge.go19
-rw-r--r--internal/config/config.go3
-rw-r--r--internal/tui/components/chat/messages.go72
-rw-r--r--internal/tui/page/chat.go1
-rw-r--r--internal/tui/state/state.go3
-rw-r--r--internal/tui/tui.go101
-rw-r--r--js/example/ink.tsx1
10 files changed, 136 insertions, 272 deletions
diff --git a/cmd/root.go b/cmd/root.go
index 7c7795552..685e0ca16 100644
--- a/cmd/root.go
+++ b/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
diff --git a/internal/app/app.go b/internal/app/app.go
index 7d0bdd4cb..7aed6fb10 100644
--- a/internal/app/app.go
+++ b/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
diff --git a/internal/app/event_adapter.go b/internal/app/event_adapter.go
deleted file mode 100644
index 4772bde49..000000000
--- a/internal/app/event_adapter.go
+++ /dev/null
@@ -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
-} \ No newline at end of file
diff --git a/internal/app/services_bridge.go b/internal/app/services_bridge.go
index e2d2ab5ba..d7e032bfd 100644
--- a/internal/app/services_bridge.go
+++ b/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)
-} \ No newline at end of file
+}
+
diff --git a/internal/config/config.go b/internal/config/config.go
index 432ba6e08..c8aea435c 100644
--- a/internal/config/config.go
+++ b/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
diff --git a/internal/tui/components/chat/messages.go b/internal/tui/components/chat/messages.go
index 613c4169a..681606b9f 100644
--- a/internal/tui/components/chat/messages.go
+++ b/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.
diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go
index e4fb62caf..a1561b6be 100644
--- a/internal/tui/page/chat.go
+++ b/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...)
}
diff --git a/internal/tui/state/state.go b/internal/tui/state/state.go
index 83745d6f7..d2125b930 100644
--- a/internal/tui/state/state.go
+++ b/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
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index ed3a94f76..939607678 100644
--- a/internal/tui/tui.go
+++ b/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{
diff --git a/js/example/ink.tsx b/js/example/ink.tsx
index 2cd4aaa0c..5eaab4d3b 100644
--- a/js/example/ink.tsx
+++ b/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 => {