on main help sign in
refactor: use client and server for workspace access and management
commite70c8ba8f6dedd224ebaa712f374b9a54d10c669 authorAyman Bagabas <ayman.bagabas@gmail.com> dateThu 12 Mar 2026, 18:24 (7 months ago) parent64f6cdf
34 files changed+3172−262
internal/backend/agent.go+30−2 internal/backend/config.go+165 internal/backend/events.go+40 internal/backend/filetracker.go+37 internal/backend/session.go+42−1 internal/client/client.go+4 internal/client/config.go+252 internal/client/proto.go+339−35 internal/cmd/root.go+59−73 internal/proto/agent.go+4−3 internal/proto/mcp.go+100−7 internal/proto/proto.go+79−3 internal/server/config.go+292 internal/server/events.go+214 internal/server/proto.go+160−3 internal/server/server.go+27 internal/ui/common/common.go+7−12 internal/ui/dialog/api_key_input.go+2−4 internal/ui/dialog/filepicker.go+1−1 internal/ui/dialog/models.go+1−1 internal/ui/dialog/oauth.go+1−3 internal/ui/dialog/sessions.go+5−5 internal/ui/model/header.go+15−16 internal/ui/model/history.go+2−2 internal/ui/model/landing.go+5−5 internal/ui/model/lsp.go+4−4 internal/ui/model/onboarding.go+3−7 internal/ui/model/pills.go+2−2 internal/ui/model/session.go+4−4 internal/ui/model/sidebar.go+7−3 internal/ui/model/ui.go+59−66 internal/workspace/app_workspace.go+370 internal/workspace/client_workspace.go+690 internal/workspace/workspace.go+150
internal/backend/agent.go+30−2
@@ -3,6 +3,7 @@ package backend
33import (
44 "context"
55
6+ "github.com/charmbracelet/crush/internal/config"
67 "github.com/charmbracelet/crush/internal/proto"
78)
89
@@ -33,8 +34,10 @@ func (b *Backend) GetAgentInfo(workspaceID string) (proto.AgentInfo, error) {
3334 if ws.AgentCoordinator != nil {
3435 m := ws.AgentCoordinator.Model()
3536 agentInfo = proto.AgentInfo{
36− Model: m.CatwalkCfg,
37− IsBusy: ws.AgentCoordinator.IsBusy(),
37+ Model: m.CatwalkCfg,
38+ ModelCfg: m.ModelCfg,
39+ IsBusy: ws.AgentCoordinator.IsBusy(),
40+ IsReady: true,
3841 }
3942 }
4043 return agentInfo, nil
@@ -114,3 +117,28 @@ func (b *Backend) ClearQueue(workspaceID, sessionID string) error {
114117 }
115118 return nil
116119}
120+
121+// QueuedPromptsList returns the list of queued prompt strings for a
122+// session.
123+func (b *Backend) QueuedPromptsList(workspaceID, sessionID string) ([]string, error) {
124+ ws, err := b.GetWorkspace(workspaceID)
125+ if err != nil {
126+ return nil, err
127+ }
128+
129+ if ws.AgentCoordinator == nil {
130+ return nil, nil
131+ }
132+
133+ return ws.AgentCoordinator.QueuedPromptsList(sessionID), nil
134+}
135+
136+// GetDefaultSmallModel returns the default small model for a provider.
137+func (b *Backend) GetDefaultSmallModel(workspaceID, providerID string) (config.SelectedModel, error) {
138+ ws, err := b.GetWorkspace(workspaceID)
139+ if err != nil {
140+ return config.SelectedModel{}, err
141+ }
142+
143+ return ws.GetDefaultSmallModel(providerID), nil
144+}
internal/backend/config.gonew file+165
@@ -0,0 +1,165 @@
1+package backend
2+
3+import (
4+ "context"
5+
6+ "github.com/charmbracelet/crush/internal/agent"
7+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
8+ "github.com/charmbracelet/crush/internal/commands"
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/oauth"
11+)
12+
13+// MCPResourceContents holds the contents of an MCP resource returned
14+// by the backend.
15+type MCPResourceContents struct {
16+ URI string `json:"uri"`
17+ MIMEType string `json:"mime_type,omitempty"`
18+ Text string `json:"text,omitempty"`
19+ Blob []byte `json:"blob,omitempty"`
20+}
21+
22+// SetConfigField sets a key/value pair in the config file for the
23+// given scope.
24+func (b *Backend) SetConfigField(workspaceID string, scope config.Scope, key string, value any) error {
25+ ws, err := b.GetWorkspace(workspaceID)
26+ if err != nil {
27+ return err
28+ }
29+ return ws.Cfg.SetConfigField(scope, key, value)
30+}
31+
32+// RemoveConfigField removes a key from the config file for the given
33+// scope.
34+func (b *Backend) RemoveConfigField(workspaceID string, scope config.Scope, key string) error {
35+ ws, err := b.GetWorkspace(workspaceID)
36+ if err != nil {
37+ return err
38+ }
39+ return ws.Cfg.RemoveConfigField(scope, key)
40+}
41+
42+// UpdatePreferredModel updates the preferred model for the given type
43+// and persists it to the config file at the given scope.
44+func (b *Backend) UpdatePreferredModel(workspaceID string, scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
45+ ws, err := b.GetWorkspace(workspaceID)
46+ if err != nil {
47+ return err
48+ }
49+ return ws.Cfg.UpdatePreferredModel(scope, modelType, model)
50+}
51+
52+// SetCompactMode sets the compact mode setting and persists it.
53+func (b *Backend) SetCompactMode(workspaceID string, scope config.Scope, enabled bool) error {
54+ ws, err := b.GetWorkspace(workspaceID)
55+ if err != nil {
56+ return err
57+ }
58+ return ws.Cfg.SetCompactMode(scope, enabled)
59+}
60+
61+// SetProviderAPIKey sets the API key for a provider and persists it.
62+func (b *Backend) SetProviderAPIKey(workspaceID string, scope config.Scope, providerID string, apiKey any) error {
63+ ws, err := b.GetWorkspace(workspaceID)
64+ if err != nil {
65+ return err
66+ }
67+ return ws.Cfg.SetProviderAPIKey(scope, providerID, apiKey)
68+}
69+
70+// ImportCopilot attempts to import a GitHub Copilot token from disk.
71+func (b *Backend) ImportCopilot(workspaceID string) (*oauth.Token, bool, error) {
72+ ws, err := b.GetWorkspace(workspaceID)
73+ if err != nil {
74+ return nil, false, err
75+ }
76+ token, ok := ws.Cfg.ImportCopilot()
77+ return token, ok, nil
78+}
79+
80+// RefreshOAuthToken refreshes the OAuth token for a provider.
81+func (b *Backend) RefreshOAuthToken(ctx context.Context, workspaceID string, scope config.Scope, providerID string) error {
82+ ws, err := b.GetWorkspace(workspaceID)
83+ if err != nil {
84+ return err
85+ }
86+ return ws.Cfg.RefreshOAuthToken(ctx, scope, providerID)
87+}
88+
89+// ProjectNeedsInitialization checks whether the project in this
90+// workspace needs initialization.
91+func (b *Backend) ProjectNeedsInitialization(workspaceID string) (bool, error) {
92+ ws, err := b.GetWorkspace(workspaceID)
93+ if err != nil {
94+ return false, err
95+ }
96+ return config.ProjectNeedsInitialization(ws.Cfg)
97+}
98+
99+// MarkProjectInitialized marks the project as initialized.
100+func (b *Backend) MarkProjectInitialized(workspaceID string) error {
101+ ws, err := b.GetWorkspace(workspaceID)
102+ if err != nil {
103+ return err
104+ }
105+ return config.MarkProjectInitialized(ws.Cfg)
106+}
107+
108+// InitializePrompt builds the initialization prompt for the workspace.
109+func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
110+ ws, err := b.GetWorkspace(workspaceID)
111+ if err != nil {
112+ return "", err
113+ }
114+ return agent.InitializePrompt(ws.Cfg)
115+}
116+
117+// RefreshMCPTools refreshes the tools for a named MCP server.
118+func (b *Backend) RefreshMCPTools(ctx context.Context, workspaceID, name string) error {
119+ ws, err := b.GetWorkspace(workspaceID)
120+ if err != nil {
121+ return err
122+ }
123+ mcptools.RefreshTools(ctx, ws.Cfg, name)
124+ return nil
125+}
126+
127+// ReadMCPResource reads a resource from a named MCP server.
128+func (b *Backend) ReadMCPResource(ctx context.Context, workspaceID, name, uri string) ([]MCPResourceContents, error) {
129+ ws, err := b.GetWorkspace(workspaceID)
130+ if err != nil {
131+ return nil, err
132+ }
133+ contents, err := mcptools.ReadResource(ctx, ws.Cfg, name, uri)
134+ if err != nil {
135+ return nil, err
136+ }
137+ result := make([]MCPResourceContents, len(contents))
138+ for i, c := range contents {
139+ result[i] = MCPResourceContents{
140+ URI: c.URI,
141+ MIMEType: c.MIMEType,
142+ Text: c.Text,
143+ Blob: c.Blob,
144+ }
145+ }
146+ return result, nil
147+}
148+
149+// GetMCPPrompt retrieves a prompt from a named MCP server.
150+func (b *Backend) GetMCPPrompt(workspaceID, clientID, promptID string, args map[string]string) (string, error) {
151+ ws, err := b.GetWorkspace(workspaceID)
152+ if err != nil {
153+ return "", err
154+ }
155+ return commands.GetMCPPrompt(ws.Cfg, clientID, promptID, args)
156+}
157+
158+// GetWorkingDir returns the working directory for a workspace.
159+func (b *Backend) GetWorkingDir(workspaceID string) (string, error) {
160+ ws, err := b.GetWorkspace(workspaceID)
161+ if err != nil {
162+ return "", err
163+ }
164+ return ws.Cfg.WorkingDir(), nil
165+}
internal/backend/events.go+40
@@ -1,8 +1,11 @@
11package backend
22
33import (
4+ "context"
5+
46 tea "charm.land/bubbletea/v2"
57
8+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
69 "github.com/charmbracelet/crush/internal/app"
710 "github.com/charmbracelet/crush/internal/config"
811)
@@ -65,3 +68,40 @@ func (b *Backend) GetWorkspaceProviders(workspaceID string) (any, error) {
6568 providers, _ := config.Providers(ws.Cfg.Config())
6669 return providers, nil
6770}
71+
72+// LSPStart starts an LSP server for the given path.
73+func (b *Backend) LSPStart(ctx context.Context, workspaceID, path string) error {
74+ ws, err := b.GetWorkspace(workspaceID)
75+ if err != nil {
76+ return err
77+ }
78+
79+ ws.LSPManager.Start(ctx, path)
80+ return nil
81+}
82+
83+// LSPStopAll stops all LSP servers for a workspace.
84+func (b *Backend) LSPStopAll(ctx context.Context, workspaceID string) error {
85+ ws, err := b.GetWorkspace(workspaceID)
86+ if err != nil {
87+ return err
88+ }
89+
90+ ws.LSPManager.StopAll(ctx)
91+ return nil
92+}
93+
94+// MCPGetStates returns the current state of all MCP clients.
95+func (b *Backend) MCPGetStates(_ string) map[string]mcptools.ClientInfo {
96+ return mcptools.GetStates()
97+}
98+
99+// MCPRefreshPrompts refreshes prompts for a named MCP client.
100+func (b *Backend) MCPRefreshPrompts(ctx context.Context, _ string, name string) {
101+ mcptools.RefreshPrompts(ctx, name)
102+}
103+
104+// MCPRefreshResources refreshes resources for a named MCP client.
105+func (b *Backend) MCPRefreshResources(ctx context.Context, _ string, name string) {
106+ mcptools.RefreshResources(ctx, name)
107+}
internal/backend/filetracker.gonew file+37
@@ -0,0 +1,37 @@
1+package backend
2+
3+import (
4+ "context"
5+ "time"
6+)
7+
8+// FileTrackerRecordRead records a file read for a session.
9+func (b *Backend) FileTrackerRecordRead(ctx context.Context, workspaceID, sessionID, path string) error {
10+ ws, err := b.GetWorkspace(workspaceID)
11+ if err != nil {
12+ return err
13+ }
14+
15+ ws.FileTracker.RecordRead(ctx, sessionID, path)
16+ return nil
17+}
18+
19+// FileTrackerLastReadTime returns the last read time for a file in a session.
20+func (b *Backend) FileTrackerLastReadTime(ctx context.Context, workspaceID, sessionID, path string) (time.Time, error) {
21+ ws, err := b.GetWorkspace(workspaceID)
22+ if err != nil {
23+ return time.Time{}, err
24+ }
25+
26+ return ws.FileTracker.LastReadTime(ctx, sessionID, path), nil
27+}
28+
29+// FileTrackerListReadFiles returns the list of read files for a session.
30+func (b *Backend) FileTrackerListReadFiles(ctx context.Context, workspaceID, sessionID string) ([]string, error) {
31+ ws, err := b.GetWorkspace(workspaceID)
32+ if err != nil {
33+ return nil, err
34+ }
35+
36+ return ws.FileTracker.ListReadFiles(ctx, sessionID)
37+}
internal/backend/session.go+42−1
@@ -3,6 +3,7 @@ package backend
33import (
44 "context"
55
6+ "github.com/charmbracelet/crush/internal/message"
67 "github.com/charmbracelet/crush/internal/proto"
78 "github.com/charmbracelet/crush/internal/session"
89)
@@ -65,7 +66,7 @@ func (b *Backend) GetAgentSession(ctx context.Context, workspaceID, sessionID st
6566}
6667
6768// ListSessionMessages returns all messages for a session.
68−func (b *Backend) ListSessionMessages(ctx context.Context, workspaceID, sessionID string) (any, error) {
69+func (b *Backend) ListSessionMessages(ctx context.Context, workspaceID, sessionID string) ([]message.Message, error) {
6970 ws, err := b.GetWorkspace(workspaceID)
7071 if err != nil {
7172 return nil, err
@@ -83,3 +84,43 @@ func (b *Backend) ListSessionHistory(ctx context.Context, workspaceID, sessionID
8384
8485 return ws.History.ListBySession(ctx, sessionID)
8586}
87+
88+// SaveSession updates a session in the given workspace.
89+func (b *Backend) SaveSession(ctx context.Context, workspaceID string, sess session.Session) (session.Session, error) {
90+ ws, err := b.GetWorkspace(workspaceID)
91+ if err != nil {
92+ return session.Session{}, err
93+ }
94+
95+ return ws.Sessions.Save(ctx, sess)
96+}
97+
98+// DeleteSession deletes a session from the given workspace.
99+func (b *Backend) DeleteSession(ctx context.Context, workspaceID, sessionID string) error {
100+ ws, err := b.GetWorkspace(workspaceID)
101+ if err != nil {
102+ return err
103+ }
104+
105+ return ws.Sessions.Delete(ctx, sessionID)
106+}
107+
108+// ListUserMessages returns user-role messages for a session.
109+func (b *Backend) ListUserMessages(ctx context.Context, workspaceID, sessionID string) ([]message.Message, error) {
110+ ws, err := b.GetWorkspace(workspaceID)
111+ if err != nil {
112+ return nil, err
113+ }
114+
115+ return ws.Messages.ListUserMessages(ctx, sessionID)
116+}
117+
118+// ListAllUserMessages returns all user-role messages across sessions.
119+func (b *Backend) ListAllUserMessages(ctx context.Context, workspaceID string) ([]message.Message, error) {
120+ ws, err := b.GetWorkspace(workspaceID)
121+ if err != nil {
122+ return nil, err
123+ }
124+
125+ return ws.Messages.ListAllUserMessages(ctx)
126+}
internal/client/client.go+4
@@ -150,6 +150,10 @@ func (c *Client) delete(ctx context.Context, path string, query url.Values, head
150150 return c.sendReq(ctx, http.MethodDelete, path, query, nil, headers)
151151}
152152
153+func (c *Client) put(ctx context.Context, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
154+ return c.sendReq(ctx, http.MethodPut, path, query, body, headers)
155+}
156+
153157func (c *Client) sendReq(ctx context.Context, method, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
154158 url := (&url.URL{
155159 Path: stdpath.Join("/v1", path),
internal/client/config.gonew file+252
@@ -0,0 +1,252 @@
1+package client
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/oauth"
11+)
12+
13+// SetConfigField sets a config key/value pair on the server.
14+func (c *Client) SetConfigField(ctx context.Context, id string, scope config.Scope, key string, value any) error {
15+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/set", id), nil, jsonBody(struct {
16+ Scope config.Scope `json:"scope"`
17+ Key string `json:"key"`
18+ Value any `json:"value"`
19+ }{Scope: scope, Key: key, Value: value}), http.Header{"Content-Type": []string{"application/json"}})
20+ if err != nil {
21+ return fmt.Errorf("failed to set config field: %w", err)
22+ }
23+ defer rsp.Body.Close()
24+ if rsp.StatusCode != http.StatusOK {
25+ return fmt.Errorf("failed to set config field: status code %d", rsp.StatusCode)
26+ }
27+ return nil
28+}
29+
30+// RemoveConfigField removes a config key on the server.
31+func (c *Client) RemoveConfigField(ctx context.Context, id string, scope config.Scope, key string) error {
32+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/remove", id), nil, jsonBody(struct {
33+ Scope config.Scope `json:"scope"`
34+ Key string `json:"key"`
35+ }{Scope: scope, Key: key}), http.Header{"Content-Type": []string{"application/json"}})
36+ if err != nil {
37+ return fmt.Errorf("failed to remove config field: %w", err)
38+ }
39+ defer rsp.Body.Close()
40+ if rsp.StatusCode != http.StatusOK {
41+ return fmt.Errorf("failed to remove config field: status code %d", rsp.StatusCode)
42+ }
43+ return nil
44+}
45+
46+// UpdatePreferredModel updates the preferred model on the server.
47+func (c *Client) UpdatePreferredModel(ctx context.Context, id string, scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
48+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/model", id), nil, jsonBody(struct {
49+ Scope config.Scope `json:"scope"`
50+ ModelType config.SelectedModelType `json:"model_type"`
51+ Model config.SelectedModel `json:"model"`
52+ }{Scope: scope, ModelType: modelType, Model: model}), http.Header{"Content-Type": []string{"application/json"}})
53+ if err != nil {
54+ return fmt.Errorf("failed to update preferred model: %w", err)
55+ }
56+ defer rsp.Body.Close()
57+ if rsp.StatusCode != http.StatusOK {
58+ return fmt.Errorf("failed to update preferred model: status code %d", rsp.StatusCode)
59+ }
60+ return nil
61+}
62+
63+// SetCompactMode sets compact mode on the server.
64+func (c *Client) SetCompactMode(ctx context.Context, id string, scope config.Scope, enabled bool) error {
65+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/compact", id), nil, jsonBody(struct {
66+ Scope config.Scope `json:"scope"`
67+ Enabled bool `json:"enabled"`
68+ }{Scope: scope, Enabled: enabled}), http.Header{"Content-Type": []string{"application/json"}})
69+ if err != nil {
70+ return fmt.Errorf("failed to set compact mode: %w", err)
71+ }
72+ defer rsp.Body.Close()
73+ if rsp.StatusCode != http.StatusOK {
74+ return fmt.Errorf("failed to set compact mode: status code %d", rsp.StatusCode)
75+ }
76+ return nil
77+}
78+
79+// SetProviderAPIKey sets a provider API key on the server.
80+func (c *Client) SetProviderAPIKey(ctx context.Context, id string, scope config.Scope, providerID string, apiKey any) error {
81+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/provider-key", id), nil, jsonBody(struct {
82+ Scope config.Scope `json:"scope"`
83+ ProviderID string `json:"provider_id"`
84+ APIKey any `json:"api_key"`
85+ }{Scope: scope, ProviderID: providerID, APIKey: apiKey}), http.Header{"Content-Type": []string{"application/json"}})
86+ if err != nil {
87+ return fmt.Errorf("failed to set provider API key: %w", err)
88+ }
89+ defer rsp.Body.Close()
90+ if rsp.StatusCode != http.StatusOK {
91+ return fmt.Errorf("failed to set provider API key: status code %d", rsp.StatusCode)
92+ }
93+ return nil
94+}
95+
96+// ImportCopilot attempts to import a GitHub Copilot token on the
97+// server.
98+func (c *Client) ImportCopilot(ctx context.Context, id string) (*oauth.Token, bool, error) {
99+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/import-copilot", id), nil, nil, nil)
100+ if err != nil {
101+ return nil, false, fmt.Errorf("failed to import copilot: %w", err)
102+ }
103+ defer rsp.Body.Close()
104+ if rsp.StatusCode != http.StatusOK {
105+ return nil, false, fmt.Errorf("failed to import copilot: status code %d", rsp.StatusCode)
106+ }
107+ var result struct {
108+ Token *oauth.Token `json:"token"`
109+ Success bool `json:"success"`
110+ }
111+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
112+ return nil, false, fmt.Errorf("failed to decode import copilot response: %w", err)
113+ }
114+ return result.Token, result.Success, nil
115+}
116+
117+// RefreshOAuthToken refreshes an OAuth token for a provider on the
118+// server.
119+func (c *Client) RefreshOAuthToken(ctx context.Context, id string, scope config.Scope, providerID string) error {
120+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/refresh-oauth", id), nil, jsonBody(struct {
121+ Scope config.Scope `json:"scope"`
122+ ProviderID string `json:"provider_id"`
123+ }{Scope: scope, ProviderID: providerID}), http.Header{"Content-Type": []string{"application/json"}})
124+ if err != nil {
125+ return fmt.Errorf("failed to refresh OAuth token: %w", err)
126+ }
127+ defer rsp.Body.Close()
128+ if rsp.StatusCode != http.StatusOK {
129+ return fmt.Errorf("failed to refresh OAuth token: status code %d", rsp.StatusCode)
130+ }
131+ return nil
132+}
133+
134+// ProjectNeedsInitialization checks if the project needs
135+// initialization.
136+func (c *Client) ProjectNeedsInitialization(ctx context.Context, id string) (bool, error) {
137+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/project/needs-init", id), nil, nil)
138+ if err != nil {
139+ return false, fmt.Errorf("failed to check project init: %w", err)
140+ }
141+ defer rsp.Body.Close()
142+ if rsp.StatusCode != http.StatusOK {
143+ return false, fmt.Errorf("failed to check project init: status code %d", rsp.StatusCode)
144+ }
145+ var result struct {
146+ NeedsInit bool `json:"needs_init"`
147+ }
148+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
149+ return false, fmt.Errorf("failed to decode project init response: %w", err)
150+ }
151+ return result.NeedsInit, nil
152+}
153+
154+// MarkProjectInitialized marks the project as initialized on the
155+// server.
156+func (c *Client) MarkProjectInitialized(ctx context.Context, id string) error {
157+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/project/init", id), nil, nil, nil)
158+ if err != nil {
159+ return fmt.Errorf("failed to mark project initialized: %w", err)
160+ }
161+ defer rsp.Body.Close()
162+ if rsp.StatusCode != http.StatusOK {
163+ return fmt.Errorf("failed to mark project initialized: status code %d", rsp.StatusCode)
164+ }
165+ return nil
166+}
167+
168+// GetInitializePrompt retrieves the initialization prompt from the
169+// server.
170+func (c *Client) GetInitializePrompt(ctx context.Context, id string) (string, error) {
171+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/project/init-prompt", id), nil, nil)
172+ if err != nil {
173+ return "", fmt.Errorf("failed to get init prompt: %w", err)
174+ }
175+ defer rsp.Body.Close()
176+ if rsp.StatusCode != http.StatusOK {
177+ return "", fmt.Errorf("failed to get init prompt: status code %d", rsp.StatusCode)
178+ }
179+ var result struct {
180+ Prompt string `json:"prompt"`
181+ }
182+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
183+ return "", fmt.Errorf("failed to decode init prompt response: %w", err)
184+ }
185+ return result.Prompt, nil
186+}
187+
188+// MCPResourceContents holds the contents of an MCP resource.
189+type MCPResourceContents struct {
190+ URI string `json:"uri"`
191+ MIMEType string `json:"mime_type,omitempty"`
192+ Text string `json:"text,omitempty"`
193+ Blob []byte `json:"blob,omitempty"`
194+}
195+
196+// RefreshMCPTools refreshes tools for a named MCP server.
197+func (c *Client) RefreshMCPTools(ctx context.Context, id, name string) error {
198+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-tools", id), nil, jsonBody(struct {
199+ Name string `json:"name"`
200+ }{Name: name}), http.Header{"Content-Type": []string{"application/json"}})
201+ if err != nil {
202+ return fmt.Errorf("failed to refresh MCP tools: %w", err)
203+ }
204+ defer rsp.Body.Close()
205+ if rsp.StatusCode != http.StatusOK {
206+ return fmt.Errorf("failed to refresh MCP tools: status code %d", rsp.StatusCode)
207+ }
208+ return nil
209+}
210+
211+// ReadMCPResource reads a resource from a named MCP server.
212+func (c *Client) ReadMCPResource(ctx context.Context, id, name, uri string) ([]MCPResourceContents, error) {
213+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/read-resource", id), nil, jsonBody(struct {
214+ Name string `json:"name"`
215+ URI string `json:"uri"`
216+ }{Name: name, URI: uri}), http.Header{"Content-Type": []string{"application/json"}})
217+ if err != nil {
218+ return nil, fmt.Errorf("failed to read MCP resource: %w", err)
219+ }
220+ defer rsp.Body.Close()
221+ if rsp.StatusCode != http.StatusOK {
222+ return nil, fmt.Errorf("failed to read MCP resource: status code %d", rsp.StatusCode)
223+ }
224+ var contents []MCPResourceContents
225+ if err := json.NewDecoder(rsp.Body).Decode(&contents); err != nil {
226+ return nil, fmt.Errorf("failed to decode MCP resource: %w", err)
227+ }
228+ return contents, nil
229+}
230+
231+// GetMCPPrompt retrieves a prompt from a named MCP server.
232+func (c *Client) GetMCPPrompt(ctx context.Context, id, clientID, promptID string, args map[string]string) (string, error) {
233+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/get-prompt", id), nil, jsonBody(struct {
234+ ClientID string `json:"client_id"`
235+ PromptID string `json:"prompt_id"`
236+ Args map[string]string `json:"args"`
237+ }{ClientID: clientID, PromptID: promptID, Args: args}), http.Header{"Content-Type": []string{"application/json"}})
238+ if err != nil {
239+ return "", fmt.Errorf("failed to get MCP prompt: %w", err)
240+ }
241+ defer rsp.Body.Close()
242+ if rsp.StatusCode != http.StatusOK {
243+ return "", fmt.Errorf("failed to get MCP prompt: status code %d", rsp.StatusCode)
244+ }
245+ var result struct {
246+ Prompt string `json:"prompt"`
247+ }
248+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
249+ return "", fmt.Errorf("failed to decode MCP prompt response: %w", err)
250+ }
251+ return result.Prompt, nil
252+}
internal/client/proto.go+339−35
@@ -10,9 +10,9 @@ import (
1010 "io"
1111 "log/slog"
1212 "net/http"
13+ "net/url"
1314 "time"
1415
15− "github.com/charmbracelet/crush/internal/app"
1616 "github.com/charmbracelet/crush/internal/config"
1717 "github.com/charmbracelet/crush/internal/history"
1818 "github.com/charmbracelet/crush/internal/message"
@@ -39,6 +39,23 @@ func (c *Client) CreateWorkspace(ctx context.Context, ws proto.Workspace) (*prot
3939 return &created, nil
4040}
4141
42+// GetWorkspace retrieves a workspace from the server.
43+func (c *Client) GetWorkspace(ctx context.Context, id string) (*proto.Workspace, error) {
44+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s", id), nil, nil)
45+ if err != nil {
46+ return nil, fmt.Errorf("failed to get workspace: %w", err)
47+ }
48+ defer rsp.Body.Close()
49+ if rsp.StatusCode != http.StatusOK {
50+ return nil, fmt.Errorf("failed to get workspace: status code %d", rsp.StatusCode)
51+ }
52+ var ws proto.Workspace
53+ if err := json.NewDecoder(rsp.Body).Decode(&ws); err != nil {
54+ return nil, fmt.Errorf("failed to decode workspace: %w", err)
55+ }
56+ return &ws, nil
57+}
58+
4259// DeleteWorkspace deletes a workspace on the server.
4360func (c *Client) DeleteWorkspace(ctx context.Context, id string) error {
4461 rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s", id), nil, nil)
@@ -95,63 +112,44 @@ func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, er
95112
96113 data = bytes.TrimSpace(data)
97114
98− var event pubsub.Event[any]
99− if err := json.Unmarshal(data, &event); err != nil {
100− slog.Error("Unmarshaling event", "error", err)
101− continue
102− }
103−
104− type alias pubsub.Event[any]
105− aux := &struct {
106− Payload json.RawMessage `json:"payload"`
107− *alias
108− }{
109− alias: (*alias)(&event),
110− }
111−
112− if err := json.Unmarshal(data, &aux); err != nil {
113− slog.Error("Unmarshaling event payload", "error", err)
114− continue
115− }
116−
117115 var p pubsub.Payload
118− if err := json.Unmarshal(aux.Payload, &p); err != nil {
119− slog.Error("Unmarshaling event payload", "error", err)
116+ if err := json.Unmarshal(data, &p); err != nil {
117+ slog.Error("Unmarshaling event envelope", "error", err)
120118 continue
121119 }
122120
123121 switch p.Type {
124122 case pubsub.PayloadTypeLSPEvent:
125123 var e pubsub.Event[proto.LSPEvent]
126− _ = json.Unmarshal(data, &e)
124+ _ = json.Unmarshal(p.Payload, &e)
127125 sendEvent(ctx, events, e)
128126 case pubsub.PayloadTypeMCPEvent:
129127 var e pubsub.Event[proto.MCPEvent]
130− _ = json.Unmarshal(data, &e)
128+ _ = json.Unmarshal(p.Payload, &e)
131129 sendEvent(ctx, events, e)
132130 case pubsub.PayloadTypePermissionRequest:
133131 var e pubsub.Event[proto.PermissionRequest]
134− _ = json.Unmarshal(data, &e)
132+ _ = json.Unmarshal(p.Payload, &e)
135133 sendEvent(ctx, events, e)
136134 case pubsub.PayloadTypePermissionNotification:
137135 var e pubsub.Event[proto.PermissionNotification]
138− _ = json.Unmarshal(data, &e)
136+ _ = json.Unmarshal(p.Payload, &e)
139137 sendEvent(ctx, events, e)
140138 case pubsub.PayloadTypeMessage:
141139 var e pubsub.Event[proto.Message]
142− _ = json.Unmarshal(data, &e)
140+ _ = json.Unmarshal(p.Payload, &e)
143141 sendEvent(ctx, events, e)
144142 case pubsub.PayloadTypeSession:
145143 var e pubsub.Event[proto.Session]
146− _ = json.Unmarshal(data, &e)
144+ _ = json.Unmarshal(p.Payload, &e)
147145 sendEvent(ctx, events, e)
148146 case pubsub.PayloadTypeFile:
149147 var e pubsub.Event[proto.File]
150− _ = json.Unmarshal(data, &e)
148+ _ = json.Unmarshal(p.Payload, &e)
151149 sendEvent(ctx, events, e)
152150 case pubsub.PayloadTypeAgentEvent:
153151 var e pubsub.Event[proto.AgentEvent]
154− _ = json.Unmarshal(data, &e)
152+ _ = json.Unmarshal(p.Payload, &e)
155153 sendEvent(ctx, events, e)
156154 default:
157155 slog.Warn("Unknown event type", "type", p.Type)
@@ -191,7 +189,7 @@ func (c *Client) GetLSPDiagnostics(ctx context.Context, id string, lspName strin
191189}
192190
193191// GetLSPs retrieves the LSP client states for a workspace.
194−func (c *Client) GetLSPs(ctx context.Context, id string) (map[string]app.LSPClientInfo, error) {
192+func (c *Client) GetLSPs(ctx context.Context, id string) (map[string]proto.LSPClientInfo, error) {
195193 rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/lsps", id), nil, nil)
196194 if err != nil {
197195 return nil, fmt.Errorf("failed to get LSPs: %w", err)
@@ -200,13 +198,64 @@ func (c *Client) GetLSPs(ctx context.Context, id string) (map[string]app.LSPClie
200198 if rsp.StatusCode != http.StatusOK {
201199 return nil, fmt.Errorf("failed to get LSPs: status code %d", rsp.StatusCode)
202200 }
203− var lsps map[string]app.LSPClientInfo
201+ var lsps map[string]proto.LSPClientInfo
204202 if err := json.NewDecoder(rsp.Body).Decode(&lsps); err != nil {
205203 return nil, fmt.Errorf("failed to decode LSPs: %w", err)
206204 }
207205 return lsps, nil
208206}
209207
208+// MCPGetStates retrieves the MCP client states for a workspace.
209+func (c *Client) MCPGetStates(ctx context.Context, id string) (map[string]proto.MCPClientInfo, error) {
210+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/mcp/states", id), nil, nil)
211+ if err != nil {
212+ return nil, fmt.Errorf("failed to get MCP states: %w", err)
213+ }
214+ defer rsp.Body.Close()
215+ if rsp.StatusCode != http.StatusOK {
216+ return nil, fmt.Errorf("failed to get MCP states: status code %d", rsp.StatusCode)
217+ }
218+ var states map[string]proto.MCPClientInfo
219+ if err := json.NewDecoder(rsp.Body).Decode(&states); err != nil {
220+ return nil, fmt.Errorf("failed to decode MCP states: %w", err)
221+ }
222+ return states, nil
223+}
224+
225+// MCPRefreshPrompts refreshes prompts for a named MCP client.
226+func (c *Client) MCPRefreshPrompts(ctx context.Context, id, name string) error {
227+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-prompts", id), nil,
228+ jsonBody(struct {
229+ Name string `json:"name"`
230+ }{Name: name}),
231+ http.Header{"Content-Type": []string{"application/json"}})
232+ if err != nil {
233+ return fmt.Errorf("failed to refresh MCP prompts: %w", err)
234+ }
235+ defer rsp.Body.Close()
236+ if rsp.StatusCode != http.StatusOK {
237+ return fmt.Errorf("failed to refresh MCP prompts: status code %d", rsp.StatusCode)
238+ }
239+ return nil
240+}
241+
242+// MCPRefreshResources refreshes resources for a named MCP client.
243+func (c *Client) MCPRefreshResources(ctx context.Context, id, name string) error {
244+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-resources", id), nil,
245+ jsonBody(struct {
246+ Name string `json:"name"`
247+ }{Name: name}),
248+ http.Header{"Content-Type": []string{"application/json"}})
249+ if err != nil {
250+ return fmt.Errorf("failed to refresh MCP resources: %w", err)
251+ }
252+ defer rsp.Body.Close()
253+ if rsp.StatusCode != http.StatusOK {
254+ return fmt.Errorf("failed to refresh MCP resources: status code %d", rsp.StatusCode)
255+ }
256+ return nil
257+}
258+
210259// GetAgentSessionQueuedPrompts retrieves the number of queued prompts for a
211260// session.
212261func (c *Client) GetAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) (int, error) {
@@ -347,11 +396,11 @@ func (c *Client) ListMessages(ctx context.Context, id string, sessionID string)
347396 if rsp.StatusCode != http.StatusOK {
348397 return nil, fmt.Errorf("failed to get messages: status code %d", rsp.StatusCode)
349398 }
350− var messages []message.Message
351− if err := json.NewDecoder(rsp.Body).Decode(&messages); err != nil {
399+ var protoMsgs []proto.Message
400+ if err := json.NewDecoder(rsp.Body).Decode(&protoMsgs); err != nil && !errors.Is(err, io.EOF) {
352401 return nil, fmt.Errorf("failed to decode messages: %w", err)
353402 }
354− return messages, nil
403+ return protoToMessages(protoMsgs), nil
355404}
356405
357406// GetSession retrieves a specific session.
@@ -488,3 +537,258 @@ func jsonBody(v any) *bytes.Buffer {
488537 b.Write(m)
489538 return b
490539}
540+
541+// SaveSession updates a session in a workspace.
542+func (c *Client) SaveSession(ctx context.Context, id string, sess session.Session) (*session.Session, error) {
543+ rsp, err := c.put(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sess.ID), nil, jsonBody(sess), http.Header{"Content-Type": []string{"application/json"}})
544+ if err != nil {
545+ return nil, fmt.Errorf("failed to save session: %w", err)
546+ }
547+ defer rsp.Body.Close()
548+ if rsp.StatusCode != http.StatusOK {
549+ return nil, fmt.Errorf("failed to save session: status code %d", rsp.StatusCode)
550+ }
551+ var saved session.Session
552+ if err := json.NewDecoder(rsp.Body).Decode(&saved); err != nil {
553+ return nil, fmt.Errorf("failed to decode session: %w", err)
554+ }
555+ return &saved, nil
556+}
557+
558+// DeleteSession deletes a session from a workspace.
559+func (c *Client) DeleteSession(ctx context.Context, id string, sessionID string) error {
560+ rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
561+ if err != nil {
562+ return fmt.Errorf("failed to delete session: %w", err)
563+ }
564+ defer rsp.Body.Close()
565+ if rsp.StatusCode != http.StatusOK {
566+ return fmt.Errorf("failed to delete session: status code %d", rsp.StatusCode)
567+ }
568+ return nil
569+}
570+
571+// ListUserMessages retrieves user-role messages for a session.
572+func (c *Client) ListUserMessages(ctx context.Context, id string, sessionID string) ([]message.Message, error) {
573+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages/user", id, sessionID), nil, nil)
574+ if err != nil {
575+ return nil, fmt.Errorf("failed to get user messages: %w", err)
576+ }
577+ defer rsp.Body.Close()
578+ if rsp.StatusCode != http.StatusOK {
579+ return nil, fmt.Errorf("failed to get user messages: status code %d", rsp.StatusCode)
580+ }
581+ var protoMsgs []proto.Message
582+ if err := json.NewDecoder(rsp.Body).Decode(&protoMsgs); err != nil && !errors.Is(err, io.EOF) {
583+ return nil, fmt.Errorf("failed to decode user messages: %w", err)
584+ }
585+ return protoToMessages(protoMsgs), nil
586+}
587+
588+// ListAllUserMessages retrieves all user-role messages across sessions.
589+func (c *Client) ListAllUserMessages(ctx context.Context, id string) ([]message.Message, error) {
590+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/messages/user", id), nil, nil)
591+ if err != nil {
592+ return nil, fmt.Errorf("failed to get all user messages: %w", err)
593+ }
594+ defer rsp.Body.Close()
595+ if rsp.StatusCode != http.StatusOK {
596+ return nil, fmt.Errorf("failed to get all user messages: status code %d", rsp.StatusCode)
597+ }
598+ var protoMsgs []proto.Message
599+ if err := json.NewDecoder(rsp.Body).Decode(&protoMsgs); err != nil && !errors.Is(err, io.EOF) {
600+ return nil, fmt.Errorf("failed to decode all user messages: %w", err)
601+ }
602+ return protoToMessages(protoMsgs), nil
603+}
604+
605+// CancelAgentSession cancels an ongoing agent operation for a session.
606+func (c *Client) CancelAgentSession(ctx context.Context, id string, sessionID string) error {
607+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/cancel", id, sessionID), nil, nil, nil)
608+ if err != nil {
609+ return fmt.Errorf("failed to cancel agent session: %w", err)
610+ }
611+ defer rsp.Body.Close()
612+ if rsp.StatusCode != http.StatusOK {
613+ return fmt.Errorf("failed to cancel agent session: status code %d", rsp.StatusCode)
614+ }
615+ return nil
616+}
617+
618+// GetAgentSessionQueuedPromptsList retrieves the list of queued prompt
619+// strings for a session.
620+func (c *Client) GetAgentSessionQueuedPromptsList(ctx context.Context, id string, sessionID string) ([]string, error) {
621+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/list", id, sessionID), nil, nil)
622+ if err != nil {
623+ return nil, fmt.Errorf("failed to get queued prompts list: %w", err)
624+ }
625+ defer rsp.Body.Close()
626+ if rsp.StatusCode != http.StatusOK {
627+ return nil, fmt.Errorf("failed to get queued prompts list: status code %d", rsp.StatusCode)
628+ }
629+ var prompts []string
630+ if err := json.NewDecoder(rsp.Body).Decode(&prompts); err != nil {
631+ return nil, fmt.Errorf("failed to decode queued prompts list: %w", err)
632+ }
633+ return prompts, nil
634+}
635+
636+// GetDefaultSmallModel retrieves the default small model for a provider.
637+func (c *Client) GetDefaultSmallModel(ctx context.Context, id string, providerID string) (*config.SelectedModel, error) {
638+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/default-small-model", id), url.Values{"provider_id": []string{providerID}}, nil)
639+ if err != nil {
640+ return nil, fmt.Errorf("failed to get default small model: %w", err)
641+ }
642+ defer rsp.Body.Close()
643+ if rsp.StatusCode != http.StatusOK {
644+ return nil, fmt.Errorf("failed to get default small model: status code %d", rsp.StatusCode)
645+ }
646+ var model config.SelectedModel
647+ if err := json.NewDecoder(rsp.Body).Decode(&model); err != nil {
648+ return nil, fmt.Errorf("failed to decode default small model: %w", err)
649+ }
650+ return &model, nil
651+}
652+
653+// FileTrackerRecordRead records a file read for a session.
654+func (c *Client) FileTrackerRecordRead(ctx context.Context, id string, sessionID, path string) error {
655+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/filetracker/read", id), nil, jsonBody(struct {
656+ SessionID string `json:"session_id"`
657+ Path string `json:"path"`
658+ }{SessionID: sessionID, Path: path}), http.Header{"Content-Type": []string{"application/json"}})
659+ if err != nil {
660+ return fmt.Errorf("failed to record file read: %w", err)
661+ }
662+ defer rsp.Body.Close()
663+ if rsp.StatusCode != http.StatusOK {
664+ return fmt.Errorf("failed to record file read: status code %d", rsp.StatusCode)
665+ }
666+ return nil
667+}
668+
669+// FileTrackerLastReadTime returns the last read time for a file in a
670+// session.
671+func (c *Client) FileTrackerLastReadTime(ctx context.Context, id string, sessionID, path string) (time.Time, error) {
672+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/filetracker/lastread", id), url.Values{
673+ "session_id": []string{sessionID},
674+ "path": []string{path},
675+ }, nil)
676+ if err != nil {
677+ return time.Time{}, fmt.Errorf("failed to get last read time: %w", err)
678+ }
679+ defer rsp.Body.Close()
680+ if rsp.StatusCode != http.StatusOK {
681+ return time.Time{}, fmt.Errorf("failed to get last read time: status code %d", rsp.StatusCode)
682+ }
683+ var t time.Time
684+ if err := json.NewDecoder(rsp.Body).Decode(&t); err != nil {
685+ return time.Time{}, fmt.Errorf("failed to decode last read time: %w", err)
686+ }
687+ return t, nil
688+}
689+
690+// FileTrackerListReadFiles returns the list of read files for a session.
691+func (c *Client) FileTrackerListReadFiles(ctx context.Context, id string, sessionID string) ([]string, error) {
692+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/filetracker/files", id, sessionID), nil, nil)
693+ if err != nil {
694+ return nil, fmt.Errorf("failed to get read files: %w", err)
695+ }
696+ defer rsp.Body.Close()
697+ if rsp.StatusCode != http.StatusOK {
698+ return nil, fmt.Errorf("failed to get read files: status code %d", rsp.StatusCode)
699+ }
700+ var files []string
701+ if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
702+ return nil, fmt.Errorf("failed to decode read files: %w", err)
703+ }
704+ return files, nil
705+}
706+
707+// LSPStart starts an LSP server for a path.
708+func (c *Client) LSPStart(ctx context.Context, id string, path string) error {
709+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/start", id), nil, jsonBody(struct {
710+ Path string `json:"path"`
711+ }{Path: path}), http.Header{"Content-Type": []string{"application/json"}})
712+ if err != nil {
713+ return fmt.Errorf("failed to start LSP: %w", err)
714+ }
715+ defer rsp.Body.Close()
716+ if rsp.StatusCode != http.StatusOK {
717+ return fmt.Errorf("failed to start LSP: status code %d", rsp.StatusCode)
718+ }
719+ return nil
720+}
721+
722+// LSPStopAll stops all LSP servers for a workspace.
723+func (c *Client) LSPStopAll(ctx context.Context, id string) error {
724+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/stop", id), nil, nil, nil)
725+ if err != nil {
726+ return fmt.Errorf("failed to stop LSPs: %w", err)
727+ }
728+ defer rsp.Body.Close()
729+ if rsp.StatusCode != http.StatusOK {
730+ return fmt.Errorf("failed to stop LSPs: status code %d", rsp.StatusCode)
731+ }
732+ return nil
733+}
734+
735+func protoToMessages(msgs []proto.Message) []message.Message {
736+ out := make([]message.Message, len(msgs))
737+ for i, m := range msgs {
738+ out[i] = protoToMessage(m)
739+ }
740+ return out
741+}
742+
743+func protoToMessage(m proto.Message) message.Message {
744+ msg := message.Message{
745+ ID: m.ID,
746+ SessionID: m.SessionID,
747+ Role: message.MessageRole(m.Role),
748+ Model: m.Model,
749+ Provider: m.Provider,
750+ CreatedAt: m.CreatedAt,
751+ UpdatedAt: m.UpdatedAt,
752+ }
753+
754+ for _, p := range m.Parts {
755+ switch v := p.(type) {
756+ case proto.TextContent:
757+ msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
758+ case proto.ReasoningContent:
759+ msg.Parts = append(msg.Parts, message.ReasoningContent{
760+ Thinking: v.Thinking,
761+ Signature: v.Signature,
762+ StartedAt: v.StartedAt,
763+ FinishedAt: v.FinishedAt,
764+ })
765+ case proto.ToolCall:
766+ msg.Parts = append(msg.Parts, message.ToolCall{
767+ ID: v.ID,
768+ Name: v.Name,
769+ Input: v.Input,
770+ Finished: v.Finished,
771+ })
772+ case proto.ToolResult:
773+ msg.Parts = append(msg.Parts, message.ToolResult{
774+ ToolCallID: v.ToolCallID,
775+ Name: v.Name,
776+ Content: v.Content,
777+ IsError: v.IsError,
778+ })
779+ case proto.Finish:
780+ msg.Parts = append(msg.Parts, message.Finish{
781+ Reason: message.FinishReason(v.Reason),
782+ Time: v.Time,
783+ Message: v.Message,
784+ Details: v.Details,
785+ })
786+ case proto.ImageURLContent:
787+ msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
788+ case proto.BinaryContent:
789+ msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
790+ }
791+ }
792+
793+ return msg
794+}
internal/cmd/root.go+59−73
@@ -25,13 +25,13 @@ import (
2525 "github.com/charmbracelet/crush/internal/config"
2626 "github.com/charmbracelet/crush/internal/db"
2727 "github.com/charmbracelet/crush/internal/event"
28− "github.com/charmbracelet/crush/internal/log"
2928 "github.com/charmbracelet/crush/internal/projects"
3029 "github.com/charmbracelet/crush/internal/proto"
3130 "github.com/charmbracelet/crush/internal/server"
3231 "github.com/charmbracelet/crush/internal/ui/common"
3332 ui "github.com/charmbracelet/crush/internal/ui/model"
3433 "github.com/charmbracelet/crush/internal/version"
34+ "github.com/charmbracelet/crush/internal/workspace"
3535 "github.com/charmbracelet/fang"
3636 uv "github.com/charmbracelet/ultraviolet"
3737 "github.com/charmbracelet/x/ansi"
@@ -96,51 +96,33 @@ crush --data-dir /path/to/custom/.crush
9696 return err
9797 }
9898
99− appInstance, err := setupAppWithProgressBar(cmd)
99+ c, ws, err := setupClientApp(cmd, hostURL)
100100 if err != nil {
101101 return err
102102 }
103− defer appInstance.Shutdown()
103+ defer func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }()
104104
105− // Register the workspace with the server so it tracks active
106− // clients and auto-shuts down when the last one exits.
107− cwd, _ := ResolveCwd(cmd)
108− dataDir, _ := cmd.Flags().GetString("data-dir")
109− debug, _ := cmd.Flags().GetBool("debug")
110− yolo, _ := cmd.Flags().GetBool("yolo")
105+ event.AppInitialized()
111106
112− c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
113− if err != nil {
114− return fmt.Errorf("failed to create client: %v", err)
115− }
107+ clientWs := workspace.NewClientWorkspace(c, *ws)
116108
117− ws, err := c.CreateWorkspace(cmd.Context(), proto.Workspace{
118− Path: cwd,
119− DataDir: dataDir,
120− Debug: debug,
121− YOLO: yolo,
122− Version: version.Version,
123− })
124− if err != nil {
125− return fmt.Errorf("failed to register workspace: %v", err)
109+ if ws.Config.IsConfigured() {
110+ if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
111+ slog.Error("Failed to initialize coder agent", "error", err)
112+ }
126113 }
127− defer func() { _ = c.DeleteWorkspace(cmd.Context(), ws.ID) }()
128−
129− event.AppInitialized()
130−
131− // Set up the TUI.
132− var env uv.Environ = os.Environ()
133114
134− com := common.DefaultCommon(appInstance)
115+ com := common.DefaultCommon(clientWs)
135116 model := ui.New(com)
136117
118+ var env uv.Environ = os.Environ()
137119 program := tea.NewProgram(
138120 model,
139121 tea.WithEnvironment(env),
140122 tea.WithContext(cmd.Context()),
141− tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
123+ tea.WithFilter(ui.MouseEventFilter),
142124 )
143− go appInstance.Subscribe(program)
125+ go clientWs.Subscribe(program)
144126
145127 if _, err := program.Run(); err != nil {
146128 event.Error(err)
@@ -295,18 +277,14 @@ func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *prot
295277 DataDir: dataDir,
296278 Debug: debug,
297279 YOLO: yolo,
280+ Version: version.Version,
298281 Env: os.Environ(),
299282 })
300283 if err != nil {
301284 return nil, nil, fmt.Errorf("failed to create workspace: %v", err)
302285 }
303286
304− cfg, err := c.GetGlobalConfig(cmd.Context())
305− if err != nil {
306− return nil, nil, fmt.Errorf("failed to get global config: %v", err)
307− }
308−
309− if shouldEnableMetrics(cfg) {
287+ if shouldEnableMetrics(ws.Config) {
310288 event.Init()
311289 }
312290
@@ -314,18 +292,29 @@ func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *prot
314292}
315293
316294// ensureServer auto-starts a detached server if the socket file does not
317−// exist. When connecting to an existing server, it waits for the health
318−// endpoint to respond.
295+// exist. When the socket exists, it verifies that the running server
296+// version matches the client; on mismatch it shuts down the old server
297+// and starts a fresh one.
319298func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
320299 switch hostURL.Scheme {
321300 case "unix", "npipe":
322− _, err := os.Stat(hostURL.Host)
323− if err != nil && errors.Is(err, fs.ErrNotExist) {
301+ needsStart := false
302+ if _, err := os.Stat(hostURL.Host); err != nil && errors.Is(err, fs.ErrNotExist) {
303+ needsStart = true
304+ } else if err == nil {
305+ if err := restartIfStale(cmd, hostURL); err != nil {
306+ slog.Warn("Failed to check server version, restarting", "error", err)
307+ needsStart = true
308+ }
309+ }
310+
311+ if needsStart {
324312 if err := startDetachedServer(cmd); err != nil {
325313 return err
326314 }
327315 }
328316
317+ var err error
329318 for range 10 {
330319 _, err = os.Stat(hostURL.Host)
331320 if err == nil {
@@ -345,43 +334,40 @@ func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
345334 return nil
346335}
347336
348−// waitForHealth polls the server's health endpoint until it responds.
349−func waitForHealth(ctx context.Context, c *client.Client) error {
350− var err error
351− for range 10 {
352− err = c.Health(ctx)
353− if err == nil {
354− return nil
337+// restartIfStale checks whether the running server matches the current
338+// client version. When they differ, it sends a shutdown command and
339+// removes the stale socket so the caller can start a fresh server.
340+func restartIfStale(cmd *cobra.Command, hostURL *url.URL) error {
341+ c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
342+ if err != nil {
343+ return err
344+ }
345+ vi, err := c.VersionInfo(cmd.Context())
346+ if err != nil {
347+ return err
348+ }
349+ if vi.Version == version.Version {
350+ return nil
351+ }
352+ slog.Info("Server version mismatch, restarting",
353+ "server", vi.Version,
354+ "client", version.Version,
355+ )
356+ _ = c.ShutdownServer(cmd.Context())
357+ // Give the old process a moment to release the socket.
358+ for range 20 {
359+ if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
360+ break
355361 }
356362 select {
357− case <-ctx.Done():
358− return ctx.Err()
363+ case <-cmd.Context().Done():
364+ return cmd.Context().Err()
359365 case <-time.After(100 * time.Millisecond):
360366 }
361367 }
362− return fmt.Errorf("failed to connect to crush server: %v", err)
363−}
364−
365−// streamEvents forwards SSE events from the client to the TUI program.
366−func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
367− defer log.RecoverPanic("app.Subscribe", func() {
368− slog.Info("TUI subscription panic: attempting graceful shutdown")
369− p.Quit()
370− })
371−
372− for {
373− select {
374− case <-ctx.Done():
375− slog.Debug("TUI message handler shutting down")
376− return
377− case ev, ok := <-evc:
378− if !ok {
379− slog.Debug("TUI message channel closed")
380− return
381− }
382− p.Send(ev)
383− }
384− }
368+ // Force-remove if the socket is still lingering.
369+ _ = os.Remove(hostURL.Host)
370+ return nil
385371}
386372
387373var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
internal/proto/agent.go+4−3
@@ -32,9 +32,10 @@ type AgentEvent struct {
3232 Error error `json:"error,omitempty"`
3333
3434 // When summarizing.
35− SessionID string `json:"session_id,omitempty"`
36− Progress string `json:"progress,omitempty"`
37− Done bool `json:"done,omitempty"`
35+ SessionID string `json:"session_id,omitempty"`
36+ SessionTitle string `json:"session_title,omitempty"`
37+ Progress string `json:"progress,omitempty"`
38+ Done bool `json:"done,omitempty"`
3839}
3940
4041// MarshalJSON implements the [json.Marshaler] interface.
internal/proto/mcp.go+100−7
@@ -1,6 +1,10 @@
11package proto
22
3−import "fmt"
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+)
48
59// MCPState represents the current state of an MCP client.
610type MCPState int
@@ -54,7 +58,10 @@ func (s MCPState) String() string {
5458type MCPEventType string
5559
5660const (
57− MCPEventStateChanged MCPEventType = "state_changed"
61+ MCPEventStateChanged MCPEventType = "state_changed"
62+ MCPEventToolsListChanged MCPEventType = "tools_list_changed"
63+ MCPEventPromptsListChanged MCPEventType = "prompts_list_changed"
64+ MCPEventResourcesListChanged MCPEventType = "resources_list_changed"
5865)
5966
6067// MarshalText implements the [encoding.TextMarshaler] interface.
@@ -70,9 +77,95 @@ func (t *MCPEventType) UnmarshalText(data []byte) error {
7077
7178// MCPEvent represents an event in the MCP system.
7279type MCPEvent struct {
73− Type MCPEventType `json:"type"`
74− Name string `json:"name"`
75− State MCPState `json:"state"`
76− Error error `json:"error,omitempty"`
77− ToolCount int `json:"tool_count,omitempty"`
80+ Type MCPEventType `json:"type"`
81+ Name string `json:"name"`
82+ State MCPState `json:"state"`
83+ Error error `json:"error,omitempty"`
84+ ToolCount int `json:"tool_count,omitempty"`
85+ PromptCount int `json:"prompt_count,omitempty"`
86+ ResourceCount int `json:"resource_count,omitempty"`
87+}
88+
89+// MarshalJSON implements the [json.Marshaler] interface.
90+func (e MCPEvent) MarshalJSON() ([]byte, error) {
91+ type Alias MCPEvent
92+ return json.Marshal(&struct {
93+ Error string `json:"error,omitempty"`
94+ Alias
95+ }{
96+ Error: func() string {
97+ if e.Error != nil {
98+ return e.Error.Error()
99+ }
100+ return ""
101+ }(),
102+ Alias: (Alias)(e),
103+ })
104+}
105+
106+// UnmarshalJSON implements the [json.Unmarshaler] interface.
107+func (e *MCPEvent) UnmarshalJSON(data []byte) error {
108+ type Alias MCPEvent
109+ aux := &struct {
110+ Error string `json:"error,omitempty"`
111+ Alias
112+ }{
113+ Alias: (Alias)(*e),
114+ }
115+ if err := json.Unmarshal(data, &aux); err != nil {
116+ return err
117+ }
118+ *e = MCPEvent(aux.Alias)
119+ if aux.Error != "" {
120+ e.Error = errors.New(aux.Error)
121+ }
122+ return nil
123+}
124+
125+// MCPClientInfo is the wire-format representation of an MCP client's
126+// state, suitable for JSON transport between server and client.
127+type MCPClientInfo struct {
128+ Name string `json:"name"`
129+ State MCPState `json:"state"`
130+ Error error `json:"error,omitempty"`
131+ ToolCount int `json:"tool_count,omitempty"`
132+ PromptCount int `json:"prompt_count,omitempty"`
133+ ResourceCount int `json:"resource_count,omitempty"`
134+ ConnectedAt int64 `json:"connected_at,omitempty"`
135+}
136+
137+// MarshalJSON implements the [json.Marshaler] interface.
138+func (i MCPClientInfo) MarshalJSON() ([]byte, error) {
139+ type Alias MCPClientInfo
140+ return json.Marshal(&struct {
141+ Error string `json:"error,omitempty"`
142+ Alias
143+ }{
144+ Error: func() string {
145+ if i.Error != nil {
146+ return i.Error.Error()
147+ }
148+ return ""
149+ }(),
150+ Alias: (Alias)(i),
151+ })
152+}
153+
154+// UnmarshalJSON implements the [json.Unmarshaler] interface.
155+func (i *MCPClientInfo) UnmarshalJSON(data []byte) error {
156+ type Alias MCPClientInfo
157+ aux := &struct {
158+ Error string `json:"error,omitempty"`
159+ Alias
160+ }{
161+ Alias: (Alias)(*i),
162+ }
163+ if err := json.Unmarshal(data, &aux); err != nil {
164+ return err
165+ }
166+ *i = MCPClientInfo(aux.Alias)
167+ if aux.Error != "" {
168+ i.Error = errors.New(aux.Error)
169+ }
170+ return nil
78171}
internal/proto/proto.go+79−3
@@ -1,6 +1,8 @@
11package proto
22
33import (
4+ "encoding/json"
5+ "errors"
46 "time"
57
68 "charm.land/catwalk/pkg/catwalk"
@@ -28,13 +30,15 @@ type Error struct {
2830
2931// AgentInfo represents information about the agent.
3032type AgentInfo struct {
31− IsBusy bool `json:"is_busy"`
32− Model catwalk.Model `json:"model"`
33+ IsBusy bool `json:"is_busy"`
34+ IsReady bool `json:"is_ready"`
35+ Model catwalk.Model `json:"model"`
36+ ModelCfg config.SelectedModel `json:"model_cfg"`
3337}
3438
3539// IsZero checks if the AgentInfo is zero-valued.
3640func (a AgentInfo) IsZero() bool {
37− return !a.IsBusy && a.Model.ID == ""
41+ return !a.IsBusy && !a.IsReady && a.Model.ID == ""
3842}
3943
4044// AgentMessage represents a message sent to the agent.
@@ -114,6 +118,42 @@ type LSPEvent struct {
114118 DiagnosticCount int `json:"diagnostic_count,omitempty"`
115119}
116120
121+// MarshalJSON implements the [json.Marshaler] interface.
122+func (e LSPEvent) MarshalJSON() ([]byte, error) {
123+ type Alias LSPEvent
124+ return json.Marshal(&struct {
125+ Error string `json:"error,omitempty"`
126+ Alias
127+ }{
128+ Error: func() string {
129+ if e.Error != nil {
130+ return e.Error.Error()
131+ }
132+ return ""
133+ }(),
134+ Alias: (Alias)(e),
135+ })
136+}
137+
138+// UnmarshalJSON implements the [json.Unmarshaler] interface.
139+func (e *LSPEvent) UnmarshalJSON(data []byte) error {
140+ type Alias LSPEvent
141+ aux := &struct {
142+ Error string `json:"error,omitempty"`
143+ Alias
144+ }{
145+ Alias: (Alias)(*e),
146+ }
147+ if err := json.Unmarshal(data, &aux); err != nil {
148+ return err
149+ }
150+ *e = LSPEvent(aux.Alias)
151+ if aux.Error != "" {
152+ e.Error = errors.New(aux.Error)
153+ }
154+ return nil
155+}
156+
117157// LSPClientInfo holds information about an LSP client's state.
118158type LSPClientInfo struct {
119159 Name string `json:"name"`
@@ -122,3 +162,39 @@ type LSPClientInfo struct {
122162 DiagnosticCount int `json:"diagnostic_count,omitempty"`
123163 ConnectedAt time.Time `json:"connected_at"`
124164}
165+
166+// MarshalJSON implements the [json.Marshaler] interface.
167+func (i LSPClientInfo) MarshalJSON() ([]byte, error) {
168+ type Alias LSPClientInfo
169+ return json.Marshal(&struct {
170+ Error string `json:"error,omitempty"`
171+ Alias
172+ }{
173+ Error: func() string {
174+ if i.Error != nil {
175+ return i.Error.Error()
176+ }
177+ return ""
178+ }(),
179+ Alias: (Alias)(i),
180+ })
181+}
182+
183+// UnmarshalJSON implements the [json.Unmarshaler] interface.
184+func (i *LSPClientInfo) UnmarshalJSON(data []byte) error {
185+ type Alias LSPClientInfo
186+ aux := &struct {
187+ Error string `json:"error,omitempty"`
188+ Alias
189+ }{
190+ Alias: (Alias)(*i),
191+ }
192+ if err := json.Unmarshal(data, &aux); err != nil {
193+ return err
194+ }
195+ *i = LSPClientInfo(aux.Alias)
196+ if aux.Error != "" {
197+ i.Error = errors.New(aux.Error)
198+ }
199+ return nil
200+}
internal/server/config.gonew file+292
@@ -0,0 +1,292 @@
1+package server
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+
7+ "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/proto"
9+)
10+
11+func (c *controllerV1) handlePostWorkspaceConfigSet(w http.ResponseWriter, r *http.Request) {
12+ id := r.PathValue("id")
13+
14+ var req struct {
15+ Scope config.Scope `json:"scope"`
16+ Key string `json:"key"`
17+ Value any `json:"value"`
18+ }
19+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
20+ c.server.logError(r, "Failed to decode request", "error", err)
21+ jsonError(w, http.StatusBadRequest, "failed to decode request")
22+ return
23+ }
24+
25+ if err := c.backend.SetConfigField(id, req.Scope, req.Key, req.Value); err != nil {
26+ c.handleError(w, r, err)
27+ return
28+ }
29+ w.WriteHeader(http.StatusOK)
30+}
31+
32+func (c *controllerV1) handlePostWorkspaceConfigRemove(w http.ResponseWriter, r *http.Request) {
33+ id := r.PathValue("id")
34+
35+ var req struct {
36+ Scope config.Scope `json:"scope"`
37+ Key string `json:"key"`
38+ }
39+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
40+ c.server.logError(r, "Failed to decode request", "error", err)
41+ jsonError(w, http.StatusBadRequest, "failed to decode request")
42+ return
43+ }
44+
45+ if err := c.backend.RemoveConfigField(id, req.Scope, req.Key); err != nil {
46+ c.handleError(w, r, err)
47+ return
48+ }
49+ w.WriteHeader(http.StatusOK)
50+}
51+
52+func (c *controllerV1) handlePostWorkspaceConfigModel(w http.ResponseWriter, r *http.Request) {
53+ id := r.PathValue("id")
54+
55+ var req struct {
56+ Scope config.Scope `json:"scope"`
57+ ModelType config.SelectedModelType `json:"model_type"`
58+ Model config.SelectedModel `json:"model"`
59+ }
60+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
61+ c.server.logError(r, "Failed to decode request", "error", err)
62+ jsonError(w, http.StatusBadRequest, "failed to decode request")
63+ return
64+ }
65+
66+ if err := c.backend.UpdatePreferredModel(id, req.Scope, req.ModelType, req.Model); err != nil {
67+ c.handleError(w, r, err)
68+ return
69+ }
70+ w.WriteHeader(http.StatusOK)
71+}
72+
73+func (c *controllerV1) handlePostWorkspaceConfigCompact(w http.ResponseWriter, r *http.Request) {
74+ id := r.PathValue("id")
75+
76+ var req struct {
77+ Scope config.Scope `json:"scope"`
78+ Enabled bool `json:"enabled"`
79+ }
80+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
81+ c.server.logError(r, "Failed to decode request", "error", err)
82+ jsonError(w, http.StatusBadRequest, "failed to decode request")
83+ return
84+ }
85+
86+ if err := c.backend.SetCompactMode(id, req.Scope, req.Enabled); err != nil {
87+ c.handleError(w, r, err)
88+ return
89+ }
90+ w.WriteHeader(http.StatusOK)
91+}
92+
93+func (c *controllerV1) handlePostWorkspaceConfigProviderKey(w http.ResponseWriter, r *http.Request) {
94+ id := r.PathValue("id")
95+
96+ var req struct {
97+ Scope config.Scope `json:"scope"`
98+ ProviderID string `json:"provider_id"`
99+ APIKey any `json:"api_key"`
100+ }
101+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
102+ c.server.logError(r, "Failed to decode request", "error", err)
103+ jsonError(w, http.StatusBadRequest, "failed to decode request")
104+ return
105+ }
106+
107+ if err := c.backend.SetProviderAPIKey(id, req.Scope, req.ProviderID, req.APIKey); err != nil {
108+ c.handleError(w, r, err)
109+ return
110+ }
111+ w.WriteHeader(http.StatusOK)
112+}
113+
114+func (c *controllerV1) handlePostWorkspaceConfigImportCopilot(w http.ResponseWriter, r *http.Request) {
115+ id := r.PathValue("id")
116+ token, ok, err := c.backend.ImportCopilot(id)
117+ if err != nil {
118+ c.handleError(w, r, err)
119+ return
120+ }
121+ jsonEncode(w, struct {
122+ Token any `json:"token"`
123+ Success bool `json:"success"`
124+ }{Token: token, Success: ok})
125+}
126+
127+func (c *controllerV1) handlePostWorkspaceConfigRefreshOAuth(w http.ResponseWriter, r *http.Request) {
128+ id := r.PathValue("id")
129+
130+ var req struct {
131+ Scope config.Scope `json:"scope"`
132+ ProviderID string `json:"provider_id"`
133+ }
134+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
135+ c.server.logError(r, "Failed to decode request", "error", err)
136+ jsonError(w, http.StatusBadRequest, "failed to decode request")
137+ return
138+ }
139+
140+ if err := c.backend.RefreshOAuthToken(r.Context(), id, req.Scope, req.ProviderID); err != nil {
141+ c.handleError(w, r, err)
142+ return
143+ }
144+ w.WriteHeader(http.StatusOK)
145+}
146+
147+func (c *controllerV1) handleGetWorkspaceProjectNeedsInit(w http.ResponseWriter, r *http.Request) {
148+ id := r.PathValue("id")
149+ needs, err := c.backend.ProjectNeedsInitialization(id)
150+ if err != nil {
151+ c.handleError(w, r, err)
152+ return
153+ }
154+ jsonEncode(w, struct {
155+ NeedsInit bool `json:"needs_init"`
156+ }{NeedsInit: needs})
157+}
158+
159+func (c *controllerV1) handlePostWorkspaceProjectInit(w http.ResponseWriter, r *http.Request) {
160+ id := r.PathValue("id")
161+ if err := c.backend.MarkProjectInitialized(id); err != nil {
162+ c.handleError(w, r, err)
163+ return
164+ }
165+ w.WriteHeader(http.StatusOK)
166+}
167+
168+func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter, r *http.Request) {
169+ id := r.PathValue("id")
170+ prompt, err := c.backend.InitializePrompt(id)
171+ if err != nil {
172+ c.handleError(w, r, err)
173+ return
174+ }
175+ jsonEncode(w, struct {
176+ Prompt string `json:"prompt"`
177+ }{Prompt: prompt})
178+}
179+
180+func (c *controllerV1) handlePostWorkspaceMCPRefreshTools(w http.ResponseWriter, r *http.Request) {
181+ id := r.PathValue("id")
182+
183+ var req struct {
184+ Name string `json:"name"`
185+ }
186+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
187+ c.server.logError(r, "Failed to decode request", "error", err)
188+ jsonError(w, http.StatusBadRequest, "failed to decode request")
189+ return
190+ }
191+
192+ if err := c.backend.RefreshMCPTools(r.Context(), id, req.Name); err != nil {
193+ c.handleError(w, r, err)
194+ return
195+ }
196+ w.WriteHeader(http.StatusOK)
197+}
198+
199+func (c *controllerV1) handlePostWorkspaceMCPReadResource(w http.ResponseWriter, r *http.Request) {
200+ id := r.PathValue("id")
201+
202+ var req struct {
203+ Name string `json:"name"`
204+ URI string `json:"uri"`
205+ }
206+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
207+ c.server.logError(r, "Failed to decode request", "error", err)
208+ jsonError(w, http.StatusBadRequest, "failed to decode request")
209+ return
210+ }
211+
212+ contents, err := c.backend.ReadMCPResource(r.Context(), id, req.Name, req.URI)
213+ if err != nil {
214+ c.handleError(w, r, err)
215+ return
216+ }
217+ jsonEncode(w, contents)
218+}
219+
220+func (c *controllerV1) handlePostWorkspaceMCPGetPrompt(w http.ResponseWriter, r *http.Request) {
221+ id := r.PathValue("id")
222+
223+ var req struct {
224+ ClientID string `json:"client_id"`
225+ PromptID string `json:"prompt_id"`
226+ Args map[string]string `json:"args"`
227+ }
228+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
229+ c.server.logError(r, "Failed to decode request", "error", err)
230+ jsonError(w, http.StatusBadRequest, "failed to decode request")
231+ return
232+ }
233+
234+ prompt, err := c.backend.GetMCPPrompt(id, req.ClientID, req.PromptID, req.Args)
235+ if err != nil {
236+ c.handleError(w, r, err)
237+ return
238+ }
239+ jsonEncode(w, struct {
240+ Prompt string `json:"prompt"`
241+ }{Prompt: prompt})
242+}
243+
244+func (c *controllerV1) handleGetWorkspaceMCPStates(w http.ResponseWriter, r *http.Request) {
245+ id := r.PathValue("id")
246+ states := c.backend.MCPGetStates(id)
247+ result := make(map[string]proto.MCPClientInfo, len(states))
248+ for k, v := range states {
249+ result[k] = proto.MCPClientInfo{
250+ Name: v.Name,
251+ State: proto.MCPState(v.State),
252+ Error: v.Error,
253+ ToolCount: v.Counts.Tools,
254+ PromptCount: v.Counts.Prompts,
255+ ResourceCount: v.Counts.Resources,
256+ ConnectedAt: v.ConnectedAt.Unix(),
257+ }
258+ }
259+ jsonEncode(w, result)
260+}
261+
262+func (c *controllerV1) handlePostWorkspaceMCPRefreshPrompts(w http.ResponseWriter, r *http.Request) {
263+ id := r.PathValue("id")
264+
265+ var req struct {
266+ Name string `json:"name"`
267+ }
268+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
269+ c.server.logError(r, "Failed to decode request", "error", err)
270+ jsonError(w, http.StatusBadRequest, "failed to decode request")
271+ return
272+ }
273+
274+ c.backend.MCPRefreshPrompts(r.Context(), id, req.Name)
275+ w.WriteHeader(http.StatusOK)
276+}
277+
278+func (c *controllerV1) handlePostWorkspaceMCPRefreshResources(w http.ResponseWriter, r *http.Request) {
279+ id := r.PathValue("id")
280+
281+ var req struct {
282+ Name string `json:"name"`
283+ }
284+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
285+ c.server.logError(r, "Failed to decode request", "error", err)
286+ jsonError(w, http.StatusBadRequest, "failed to decode request")
287+ return
288+ }
289+
290+ c.backend.MCPRefreshResources(r.Context(), id, req.Name)
291+ w.WriteHeader(http.StatusOK)
292+}
internal/server/events.gonew file+214
@@ -0,0 +1,214 @@
1+package server
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "log/slog"
7+
8+ "github.com/charmbracelet/crush/internal/agent/notify"
9+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
10+ "github.com/charmbracelet/crush/internal/app"
11+ "github.com/charmbracelet/crush/internal/history"
12+ "github.com/charmbracelet/crush/internal/message"
13+ "github.com/charmbracelet/crush/internal/permission"
14+ "github.com/charmbracelet/crush/internal/proto"
15+ "github.com/charmbracelet/crush/internal/pubsub"
16+ "github.com/charmbracelet/crush/internal/session"
17+)
18+
19+// wrapEvent converts a raw tea.Msg (a pubsub.Event[T] from the app
20+// event fan-in) into a pubsub.Payload envelope with the correct
21+// PayloadType discriminator and a proto-typed inner payload that has
22+// proper JSON tags. Returns nil if the event type is unrecognized.
23+func wrapEvent(ev any) *pubsub.Payload {
24+ switch e := ev.(type) {
25+ case pubsub.Event[app.LSPEvent]:
26+ return envelope(pubsub.PayloadTypeLSPEvent, pubsub.Event[proto.LSPEvent]{
27+ Type: e.Type,
28+ Payload: proto.LSPEvent{
29+ Type: proto.LSPEventType(e.Payload.Type),
30+ Name: e.Payload.Name,
31+ State: e.Payload.State,
32+ Error: e.Payload.Error,
33+ DiagnosticCount: e.Payload.DiagnosticCount,
34+ },
35+ })
36+ case pubsub.Event[mcp.Event]:
37+ return envelope(pubsub.PayloadTypeMCPEvent, pubsub.Event[proto.MCPEvent]{
38+ Type: e.Type,
39+ Payload: proto.MCPEvent{
40+ Type: mcpEventTypeToProto(e.Payload.Type),
41+ Name: e.Payload.Name,
42+ State: proto.MCPState(e.Payload.State),
43+ Error: e.Payload.Error,
44+ ToolCount: e.Payload.Counts.Tools,
45+ },
46+ })
47+ case pubsub.Event[permission.PermissionRequest]:
48+ return envelope(pubsub.PayloadTypePermissionRequest, pubsub.Event[proto.PermissionRequest]{
49+ Type: e.Type,
50+ Payload: proto.PermissionRequest{
51+ ID: e.Payload.ID,
52+ SessionID: e.Payload.SessionID,
53+ ToolCallID: e.Payload.ToolCallID,
54+ ToolName: e.Payload.ToolName,
55+ Description: e.Payload.Description,
56+ Action: e.Payload.Action,
57+ Path: e.Payload.Path,
58+ Params: e.Payload.Params,
59+ },
60+ })
61+ case pubsub.Event[permission.PermissionNotification]:
62+ return envelope(pubsub.PayloadTypePermissionNotification, pubsub.Event[proto.PermissionNotification]{
63+ Type: e.Type,
64+ Payload: proto.PermissionNotification{
65+ ToolCallID: e.Payload.ToolCallID,
66+ Granted: e.Payload.Granted,
67+ Denied: e.Payload.Denied,
68+ },
69+ })
70+ case pubsub.Event[message.Message]:
71+ return envelope(pubsub.PayloadTypeMessage, pubsub.Event[proto.Message]{
72+ Type: e.Type,
73+ Payload: messageToProto(e.Payload),
74+ })
75+ case pubsub.Event[session.Session]:
76+ return envelope(pubsub.PayloadTypeSession, pubsub.Event[proto.Session]{
77+ Type: e.Type,
78+ Payload: sessionToProto(e.Payload),
79+ })
80+ case pubsub.Event[history.File]:
81+ return envelope(pubsub.PayloadTypeFile, pubsub.Event[proto.File]{
82+ Type: e.Type,
83+ Payload: fileToProto(e.Payload),
84+ })
85+ case pubsub.Event[notify.Notification]:
86+ return envelope(pubsub.PayloadTypeAgentEvent, pubsub.Event[proto.AgentEvent]{
87+ Type: e.Type,
88+ Payload: proto.AgentEvent{
89+ SessionID: e.Payload.SessionID,
90+ SessionTitle: e.Payload.SessionTitle,
91+ Type: proto.AgentEventType(e.Payload.Type),
92+ },
93+ })
94+ default:
95+ slog.Warn("Unrecognized event type for SSE wrapping", "type", fmt.Sprintf("%T", ev))
96+ return nil
97+ }
98+}
99+
100+// envelope marshals the inner event and wraps it in a pubsub.Payload.
101+func envelope(payloadType pubsub.PayloadType, inner any) *pubsub.Payload {
102+ raw, err := json.Marshal(inner)
103+ if err != nil {
104+ slog.Error("Failed to marshal event payload", "error", err)
105+ return nil
106+ }
107+ return &pubsub.Payload{
108+ Type: payloadType,
109+ Payload: raw,
110+ }
111+}
112+
113+func mcpEventTypeToProto(t mcp.EventType) proto.MCPEventType {
114+ switch t {
115+ case mcp.EventStateChanged:
116+ return proto.MCPEventStateChanged
117+ case mcp.EventToolsListChanged:
118+ return proto.MCPEventToolsListChanged
119+ case mcp.EventPromptsListChanged:
120+ return proto.MCPEventPromptsListChanged
121+ case mcp.EventResourcesListChanged:
122+ return proto.MCPEventResourcesListChanged
123+ default:
124+ return proto.MCPEventStateChanged
125+ }
126+}
127+
128+func sessionToProto(s session.Session) proto.Session {
129+ return proto.Session{
130+ ID: s.ID,
131+ ParentSessionID: s.ParentSessionID,
132+ Title: s.Title,
133+ SummaryMessageID: s.SummaryMessageID,
134+ MessageCount: s.MessageCount,
135+ PromptTokens: s.PromptTokens,
136+ CompletionTokens: s.CompletionTokens,
137+ Cost: s.Cost,
138+ CreatedAt: s.CreatedAt,
139+ UpdatedAt: s.UpdatedAt,
140+ }
141+}
142+
143+func fileToProto(f history.File) proto.File {
144+ return proto.File{
145+ ID: f.ID,
146+ SessionID: f.SessionID,
147+ Path: f.Path,
148+ Content: f.Content,
149+ Version: f.Version,
150+ CreatedAt: f.CreatedAt,
151+ UpdatedAt: f.UpdatedAt,
152+ }
153+}
154+
155+func messageToProto(m message.Message) proto.Message {
156+ msg := proto.Message{
157+ ID: m.ID,
158+ SessionID: m.SessionID,
159+ Role: proto.MessageRole(m.Role),
160+ Model: m.Model,
161+ Provider: m.Provider,
162+ CreatedAt: m.CreatedAt,
163+ UpdatedAt: m.UpdatedAt,
164+ }
165+
166+ for _, p := range m.Parts {
167+ switch v := p.(type) {
168+ case message.TextContent:
169+ msg.Parts = append(msg.Parts, proto.TextContent{Text: v.Text})
170+ case message.ReasoningContent:
171+ msg.Parts = append(msg.Parts, proto.ReasoningContent{
172+ Thinking: v.Thinking,
173+ Signature: v.Signature,
174+ StartedAt: v.StartedAt,
175+ FinishedAt: v.FinishedAt,
176+ })
177+ case message.ToolCall:
178+ msg.Parts = append(msg.Parts, proto.ToolCall{
179+ ID: v.ID,
180+ Name: v.Name,
181+ Input: v.Input,
182+ Finished: v.Finished,
183+ })
184+ case message.ToolResult:
185+ msg.Parts = append(msg.Parts, proto.ToolResult{
186+ ToolCallID: v.ToolCallID,
187+ Name: v.Name,
188+ Content: v.Content,
189+ IsError: v.IsError,
190+ })
191+ case message.Finish:
192+ msg.Parts = append(msg.Parts, proto.Finish{
193+ Reason: proto.FinishReason(v.Reason),
194+ Time: v.Time,
195+ Message: v.Message,
196+ Details: v.Details,
197+ })
198+ case message.ImageURLContent:
199+ msg.Parts = append(msg.Parts, proto.ImageURLContent{URL: v.URL, Detail: v.Detail})
200+ case message.BinaryContent:
201+ msg.Parts = append(msg.Parts, proto.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
202+ }
203+ }
204+
205+ return msg
206+}
207+
208+func messagesToProto(msgs []message.Message) []proto.Message {
209+ out := make([]proto.Message, len(msgs))
210+ for i, m := range msgs {
211+ out[i] = messageToProto(m)
212+ }
213+ return out
214+}
internal/server/proto.go+160−3
@@ -124,7 +124,11 @@ func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.R
124124 return
125125 }
126126 c.server.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev, ev))
127− data, err := json.Marshal(ev)
127+ wrapped := wrapEvent(ev)
128+ if wrapped == nil {
129+ continue
130+ }
131+ data, err := json.Marshal(wrapped)
128132 if err != nil {
129133 c.server.logError(r, "Failed to marshal event", "error", err)
130134 continue
@@ -143,7 +147,17 @@ func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Req
143147 c.handleError(w, r, err)
144148 return
145149 }
146− jsonEncode(w, states)
150+ result := make(map[string]proto.LSPClientInfo, len(states))
151+ for k, v := range states {
152+ result[k] = proto.LSPClientInfo{
153+ Name: v.Name,
154+ State: v.State,
155+ Error: v.Error,
156+ DiagnosticCount: v.DiagnosticCount,
157+ ConnectedAt: v.ConnectedAt,
158+ }
159+ }
160+ jsonEncode(w, result)
147161}
148162
149163func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
@@ -215,7 +229,128 @@ func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter,
215229 c.handleError(w, r, err)
216230 return
217231 }
218− jsonEncode(w, messages)
232+ jsonEncode(w, messagesToProto(messages))
233+}
234+
235+func (c *controllerV1) handlePutWorkspaceSession(w http.ResponseWriter, r *http.Request) {
236+ id := r.PathValue("id")
237+
238+ var sess session.Session
239+ if err := json.NewDecoder(r.Body).Decode(&sess); err != nil {
240+ c.server.logError(r, "Failed to decode request", "error", err)
241+ jsonError(w, http.StatusBadRequest, "failed to decode request")
242+ return
243+ }
244+
245+ saved, err := c.backend.SaveSession(r.Context(), id, sess)
246+ if err != nil {
247+ c.handleError(w, r, err)
248+ return
249+ }
250+ jsonEncode(w, saved)
251+}
252+
253+func (c *controllerV1) handleDeleteWorkspaceSession(w http.ResponseWriter, r *http.Request) {
254+ id := r.PathValue("id")
255+ sid := r.PathValue("sid")
256+ if err := c.backend.DeleteSession(r.Context(), id, sid); err != nil {
257+ c.handleError(w, r, err)
258+ return
259+ }
260+ w.WriteHeader(http.StatusOK)
261+}
262+
263+func (c *controllerV1) handleGetWorkspaceSessionUserMessages(w http.ResponseWriter, r *http.Request) {
264+ id := r.PathValue("id")
265+ sid := r.PathValue("sid")
266+ messages, err := c.backend.ListUserMessages(r.Context(), id, sid)
267+ if err != nil {
268+ c.handleError(w, r, err)
269+ return
270+ }
271+ jsonEncode(w, messagesToProto(messages))
272+}
273+
274+func (c *controllerV1) handleGetWorkspaceAllUserMessages(w http.ResponseWriter, r *http.Request) {
275+ id := r.PathValue("id")
276+ messages, err := c.backend.ListAllUserMessages(r.Context(), id)
277+ if err != nil {
278+ c.handleError(w, r, err)
279+ return
280+ }
281+ jsonEncode(w, messagesToProto(messages))
282+}
283+
284+func (c *controllerV1) handleGetWorkspaceSessionFileTrackerFiles(w http.ResponseWriter, r *http.Request) {
285+ id := r.PathValue("id")
286+ sid := r.PathValue("sid")
287+ files, err := c.backend.FileTrackerListReadFiles(r.Context(), id, sid)
288+ if err != nil {
289+ c.handleError(w, r, err)
290+ return
291+ }
292+ jsonEncode(w, files)
293+}
294+
295+func (c *controllerV1) handlePostWorkspaceFileTrackerRead(w http.ResponseWriter, r *http.Request) {
296+ id := r.PathValue("id")
297+
298+ var req struct {
299+ SessionID string `json:"session_id"`
300+ Path string `json:"path"`
301+ }
302+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
303+ c.server.logError(r, "Failed to decode request", "error", err)
304+ jsonError(w, http.StatusBadRequest, "failed to decode request")
305+ return
306+ }
307+
308+ if err := c.backend.FileTrackerRecordRead(r.Context(), id, req.SessionID, req.Path); err != nil {
309+ c.handleError(w, r, err)
310+ return
311+ }
312+ w.WriteHeader(http.StatusOK)
313+}
314+
315+func (c *controllerV1) handleGetWorkspaceFileTrackerLastRead(w http.ResponseWriter, r *http.Request) {
316+ id := r.PathValue("id")
317+ sid := r.URL.Query().Get("session_id")
318+ path := r.URL.Query().Get("path")
319+
320+ t, err := c.backend.FileTrackerLastReadTime(r.Context(), id, sid, path)
321+ if err != nil {
322+ c.handleError(w, r, err)
323+ return
324+ }
325+ jsonEncode(w, t)
326+}
327+
328+func (c *controllerV1) handlePostWorkspaceLSPStart(w http.ResponseWriter, r *http.Request) {
329+ id := r.PathValue("id")
330+
331+ var req struct {
332+ Path string `json:"path"`
333+ }
334+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
335+ c.server.logError(r, "Failed to decode request", "error", err)
336+ jsonError(w, http.StatusBadRequest, "failed to decode request")
337+ return
338+ }
339+
340+ if err := c.backend.LSPStart(r.Context(), id, req.Path); err != nil {
341+ c.handleError(w, r, err)
342+ return
343+ }
344+ w.WriteHeader(http.StatusOK)
345+}
346+
347+func (c *controllerV1) handlePostWorkspaceLSPStopAll(w http.ResponseWriter, r *http.Request) {
348+ id := r.PathValue("id")
349+ if err := c.backend.LSPStopAll(r.Context(), id); err != nil {
350+ c.handleError(w, r, err)
351+ return
352+ }
353+ w.WriteHeader(http.StatusOK)
219354}
220355
221356func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
@@ -313,6 +448,28 @@ func (c *controllerV1) handleGetWorkspaceAgentSessionSummarize(w http.ResponseWr
313448 }
314449}
315450
451+func (c *controllerV1) handleGetWorkspaceAgentSessionPromptList(w http.ResponseWriter, r *http.Request) {
452+ id := r.PathValue("id")
453+ sid := r.PathValue("sid")
454+ prompts, err := c.backend.QueuedPromptsList(id, sid)
455+ if err != nil {
456+ c.handleError(w, r, err)
457+ return
458+ }
459+ jsonEncode(w, prompts)
460+}
461+
462+func (c *controllerV1) handleGetWorkspaceAgentDefaultSmallModel(w http.ResponseWriter, r *http.Request) {
463+ id := r.PathValue("id")
464+ providerID := r.URL.Query().Get("provider_id")
465+ model, err := c.backend.GetDefaultSmallModel(id, providerID)
466+ if err != nil {
467+ c.handleError(w, r, err)
468+ return
469+ }
470+ jsonEncode(w, model)
471+}
472+
316473func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
317474 id := r.PathValue("id")
318475
internal/server/server.go+27
@@ -118,10 +118,19 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
118118 mux.HandleFunc("GET /v1/workspaces/{id}/sessions", c.handleGetWorkspaceSessions)
119119 mux.HandleFunc("POST /v1/workspaces/{id}/sessions", c.handlePostWorkspaceSessions)
120120 mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}", c.handleGetWorkspaceSession)
121+ mux.HandleFunc("PUT /v1/workspaces/{id}/sessions/{sid}", c.handlePutWorkspaceSession)
122+ mux.HandleFunc("DELETE /v1/workspaces/{id}/sessions/{sid}", c.handleDeleteWorkspaceSession)
121123 mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/history", c.handleGetWorkspaceSessionHistory)
122124 mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/messages", c.handleGetWorkspaceSessionMessages)
125+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/messages/user", c.handleGetWorkspaceSessionUserMessages)
126+ mux.HandleFunc("GET /v1/workspaces/{id}/messages/user", c.handleGetWorkspaceAllUserMessages)
127+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/filetracker/files", c.handleGetWorkspaceSessionFileTrackerFiles)
128+ mux.HandleFunc("POST /v1/workspaces/{id}/filetracker/read", c.handlePostWorkspaceFileTrackerRead)
129+ mux.HandleFunc("GET /v1/workspaces/{id}/filetracker/lastread", c.handleGetWorkspaceFileTrackerLastRead)
123130 mux.HandleFunc("GET /v1/workspaces/{id}/lsps", c.handleGetWorkspaceLSPs)
124131 mux.HandleFunc("GET /v1/workspaces/{id}/lsps/{lsp}/diagnostics", c.handleGetWorkspaceLSPDiagnostics)
132+ mux.HandleFunc("POST /v1/workspaces/{id}/lsps/start", c.handlePostWorkspaceLSPStart)
133+ mux.HandleFunc("POST /v1/workspaces/{id}/lsps/stop", c.handlePostWorkspaceLSPStopAll)
125134 mux.HandleFunc("GET /v1/workspaces/{id}/permissions/skip", c.handleGetWorkspacePermissionsSkip)
126135 mux.HandleFunc("POST /v1/workspaces/{id}/permissions/skip", c.handlePostWorkspacePermissionsSkip)
127136 mux.HandleFunc("POST /v1/workspaces/{id}/permissions/grant", c.handlePostWorkspacePermissionsGrant)
@@ -132,8 +141,26 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
132141 mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}", c.handleGetWorkspaceAgentSession)
133142 mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/cancel", c.handlePostWorkspaceAgentSessionCancel)
134143 mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/queued", c.handleGetWorkspaceAgentSessionPromptQueued)
144+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/list", c.handleGetWorkspaceAgentSessionPromptList)
135145 mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/prompts/clear", c.handlePostWorkspaceAgentSessionPromptClear)
136146 mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/summarize", c.handleGetWorkspaceAgentSessionSummarize)
147+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/default-small-model", c.handleGetWorkspaceAgentDefaultSmallModel)
148+ mux.HandleFunc("POST /v1/workspaces/{id}/config/set", c.handlePostWorkspaceConfigSet)
149+ mux.HandleFunc("POST /v1/workspaces/{id}/config/remove", c.handlePostWorkspaceConfigRemove)
150+ mux.HandleFunc("POST /v1/workspaces/{id}/config/model", c.handlePostWorkspaceConfigModel)
151+ mux.HandleFunc("POST /v1/workspaces/{id}/config/compact", c.handlePostWorkspaceConfigCompact)
152+ mux.HandleFunc("POST /v1/workspaces/{id}/config/provider-key", c.handlePostWorkspaceConfigProviderKey)
153+ mux.HandleFunc("POST /v1/workspaces/{id}/config/import-copilot", c.handlePostWorkspaceConfigImportCopilot)
154+ mux.HandleFunc("POST /v1/workspaces/{id}/config/refresh-oauth", c.handlePostWorkspaceConfigRefreshOAuth)
155+ mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
156+ mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
157+ mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
158+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
159+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
160+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
161+ mux.HandleFunc("GET /v1/workspaces/{id}/mcp/states", c.handleGetWorkspaceMCPStates)
162+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-prompts", c.handlePostWorkspaceMCPRefreshPrompts)
163+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-resources", c.handlePostWorkspaceMCPRefreshResources)
137164 s.h = &http.Server{
138165 Protocols: &p,
139166 Handler: s.loggingHandler(mux),
internal/ui/common/common.go+7−12
@@ -7,10 +7,10 @@ import (
77
88 tea "charm.land/bubbletea/v2"
99 "github.com/atotto/clipboard"
10− "github.com/charmbracelet/crush/internal/app"
1110 "github.com/charmbracelet/crush/internal/config"
1211 "github.com/charmbracelet/crush/internal/ui/styles"
1312 "github.com/charmbracelet/crush/internal/ui/util"
13+ "github.com/charmbracelet/crush/internal/workspace"
1414 uv "github.com/charmbracelet/ultraviolet"
1515)
1616
@@ -22,26 +22,21 @@ var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
2222
2323// Common defines common UI options and configurations.
2424type Common struct {
25− App *app.App
26− Styles *styles.Styles
25+ Workspace workspace.Workspace
26+ Styles *styles.Styles
2727}
2828
2929// Config returns the pure-data configuration associated with this [Common] instance.
3030func (c *Common) Config() *config.Config {
31− return c.App.Config()
32−}
33−
34−// Store returns the config store associated with this [Common] instance.
35−func (c *Common) Store() *config.ConfigStore {
36− return c.App.Store()
31+ return c.Workspace.Config()
3732}
3833
3934// DefaultCommon returns the default common UI configurations.
40−func DefaultCommon(app *app.App) *Common {
35+func DefaultCommon(ws workspace.Workspace) *Common {
4136 s := styles.DefaultStyles()
4237 return &Common{
43− App: app,
44− Styles: &s,
38+ Workspace: ws,
39+ Styles: &s,
4540 }
4641}
4742
internal/ui/dialog/api_key_input.go+2−4
@@ -296,7 +296,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
296296 Type: m.provider.Type,
297297 BaseURL: m.provider.APIEndpoint,
298298 }
299− err := providerConfig.TestConnection(m.com.Store().Resolver())
299+ err := providerConfig.TestConnection(m.com.Workspace.Resolver())
300300
301301 // intentionally wait for at least 750ms to make sure the user sees the spinner
302302 elapsed := time.Since(start)
@@ -312,9 +312,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
312312}
313313
314314func (m *APIKeyInput) saveKeyAndContinue() Action {
315− store := m.com.Store()
316−
317− err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
315+ err := m.com.Workspace.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
318316 if err != nil {
319317 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
320318 }
internal/ui/dialog/filepicker.go+1−1
@@ -123,7 +123,7 @@ func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
123123
124124// WorkingDir returns the current working directory of the [FilePicker].
125125func (f *FilePicker) WorkingDir() string {
126− wd := f.com.Store().WorkingDir()
126+ wd := f.com.Workspace.WorkingDir()
127127 if len(wd) > 0 {
128128 return wd
129129 }
internal/ui/dialog/models.go+1−1
@@ -490,7 +490,7 @@ func (m *Models) setProviderItems() error {
490490
491491 if len(validRecentItems) != len(recentItems) {
492492 // FIXME: Does this need to be here? Is it mutating the config during a read?
493− if err := m.com.Store().SetConfigField(config.ScopeGlobal, fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
493+ if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
494494 return fmt.Errorf("failed to update recent models: %w", err)
495495 }
496496 }
internal/ui/dialog/oauth.go+1−3
@@ -373,9 +373,7 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
373373}
374374
375375func (m *OAuth) saveKeyAndContinue() Action {
376− store := m.com.Store()
377−
378− err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
376+ err := m.com.Workspace.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
379377 if err != nil {
380378 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
381379 }
internal/ui/dialog/sessions.go+5−5
@@ -61,7 +61,7 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
6161 s := new(Session)
6262 s.sessionsMode = sessionsModeNormal
6363 s.com = com
64− sessions, err := com.App.Sessions.List(context.TODO())
64+ sessions, err := com.Workspace.ListSessions(context.TODO())
6565 if err != nil {
6666 return nil, err
6767 }
@@ -349,7 +349,7 @@ func (s *Session) removeSession(id string) {
349349
350350func (s *Session) deleteSessionCmd(id string) tea.Cmd {
351351 return func() tea.Msg {
352− err := s.com.App.Sessions.Delete(context.TODO(), id)
352+ err := s.com.Workspace.DeleteSession(context.TODO(), id)
353353 if err != nil {
354354 return util.NewErrorMsg(err)
355355 }
@@ -385,7 +385,7 @@ func (s *Session) updateSession(session session.Session) {
385385
386386func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
387387 return func() tea.Msg {
388− _, err := s.com.App.Sessions.Save(context.TODO(), session)
388+ _, err := s.com.Workspace.SaveSession(context.TODO(), session)
389389 if err != nil {
390390 return util.NewErrorMsg(err)
391391 }
@@ -399,11 +399,11 @@ func (s *Session) isCurrentSessionBusy() bool {
399399 return false
400400 }
401401
402− if s.com.App.AgentCoordinator == nil {
402+ if !s.com.Workspace.AgentIsReady() {
403403 return false
404404 }
405405
406− return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID())
406+ return s.com.Workspace.AgentIsSessionBusy(sessionItem.ID())
407407}
408408
409409// ShortHelp implements [help.KeyMap].
internal/ui/model/header.go+15−16
@@ -6,9 +6,7 @@ import (
66
77 "charm.land/lipgloss/v2"
88 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/csync"
109 "github.com/charmbracelet/crush/internal/fsext"
11− "github.com/charmbracelet/crush/internal/lsp"
1210 "github.com/charmbracelet/crush/internal/session"
1311 "github.com/charmbracelet/crush/internal/ui/common"
1412 "github.com/charmbracelet/crush/internal/ui/styles"
@@ -62,7 +60,7 @@ func (h *header) drawHeader(
6260 h.width = width
6361 h.compact = compact
6462
65− if !compact || session == nil || h.com.App == nil {
63+ if !compact || session == nil {
6664 uv.NewStyledString(h.logo).Draw(scr, area)
6765 return
6866 }
@@ -75,10 +73,14 @@ func (h *header) drawHeader(
7573 b.WriteString(h.compactLogo)
7674
7775 availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - diagToDetailsSpacing
76+ lspErrorCount := 0
77+ for _, info := range h.com.Workspace.LSPGetStates() {
78+ lspErrorCount += info.DiagnosticCount
79+ }
7880 details := renderHeaderDetails(
7981 h.com,
8082 session,
81− h.com.App.LSPManager.Clients(),
83+ lspErrorCount,
8284 detailsOpen,
8385 availDetailWidth,
8486 )
@@ -108,7 +110,7 @@ func (h *header) drawHeader(
108110func renderHeaderDetails(
109111 com *common.Common,
110112 session *session.Session,
111− lspClients *csync.Map[string, *lsp.Client],
113+ lspErrorCount int,
112114 detailsOpen bool,
113115 availWidth int,
114116) string {
@@ -116,20 +118,17 @@ func renderHeaderDetails(
116118
117119 var parts []string
118120
119− errorCount := 0
120− for l := range lspClients.Seq() {
121− errorCount += l.GetDiagnosticCounts().Error
122− }
123−
124− if errorCount > 0 {
125− parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, errorCount)))
121+ if lspErrorCount > 0 {
122+ parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, lspErrorCount)))
126123 }
127124
128125 agentCfg := com.Config().Agents[config.AgentCoder]
129126 model := com.Config().GetModelByType(agentCfg.Model)
130− percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
131− formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
132− parts = append(parts, formattedPercentage)
127+ if model != nil && model.ContextWindow > 0 {
128+ percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
129+ formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
130+ parts = append(parts, formattedPercentage)
131+ }
133132
134133 const keystroke = "ctrl+d"
135134 if detailsOpen {
@@ -143,7 +142,7 @@ func renderHeaderDetails(
143142 metadata = dot + metadata
144143
145144 const dirTrimLimit = 4
146− cwd := fsext.DirTrim(fsext.PrettyPath(com.Store().WorkingDir()), dirTrimLimit)
145+ cwd := fsext.DirTrim(fsext.PrettyPath(com.Workspace.WorkingDir()), dirTrimLimit)
147146 cwd = t.Header.WorkingDir.Render(cwd)
148147
149148 result := cwd + metadata
internal/ui/model/history.go+2−2
@@ -22,9 +22,9 @@ func (m *UI) loadPromptHistory() tea.Cmd {
2222 var err error
2323
2424 if m.session != nil {
25− messages, err = m.com.App.Messages.ListUserMessages(ctx, m.session.ID)
25+ messages, err = m.com.Workspace.ListUserMessages(ctx, m.session.ID)
2626 } else {
27− messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
27+ messages, err = m.com.Workspace.ListAllUserMessages(ctx)
2828 }
2929 if err != nil {
3030 slog.Error("Failed to load prompt history", "error", err)
internal/ui/model/landing.go+5−5
@@ -2,16 +2,16 @@ package model
22
33import (
44 "charm.land/lipgloss/v2"
5− "github.com/charmbracelet/crush/internal/agent"
65 "github.com/charmbracelet/crush/internal/ui/common"
6+ "github.com/charmbracelet/crush/internal/workspace"
77 "github.com/charmbracelet/ultraviolet/layout"
88)
99
1010// selectedLargeModel returns the currently selected large language model from
1111// the agent coordinator, if one exists.
12−func (m *UI) selectedLargeModel() *agent.Model {
13− if m.com.App.AgentCoordinator != nil {
14− model := m.com.App.AgentCoordinator.Model()
12+func (m *UI) selectedLargeModel() *workspace.AgentModel {
13+ if m.com.Workspace.AgentIsReady() {
14+ model := m.com.Workspace.AgentModel()
1515 return &model
1616 }
1717 return nil
@@ -22,7 +22,7 @@ func (m *UI) selectedLargeModel() *agent.Model {
2222func (m *UI) landingView() string {
2323 t := m.com.Styles
2424 width := m.layout.main.Dx()
25− cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
25+ cwd := common.PrettyPath(t, m.com.Workspace.WorkingDir(), width)
2626
2727 parts := []string{
2828 cwd,
internal/ui/model/lsp.go+4−4
@@ -7,16 +7,16 @@ import (
77 "strings"
88
99 "charm.land/lipgloss/v2"
10− "github.com/charmbracelet/crush/internal/app"
1110 "github.com/charmbracelet/crush/internal/lsp"
1211 "github.com/charmbracelet/crush/internal/ui/common"
1312 "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/crush/internal/workspace"
1414 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
1515)
1616
1717// LSPInfo wraps LSP client information with diagnostic counts by severity.
1818type LSPInfo struct {
19− app.LSPClientInfo
19+ workspace.LSPClientInfo
2020 Diagnostics map[protocol.DiagnosticSeverity]int
2121}
2222
@@ -25,14 +25,14 @@ type LSPInfo struct {
2525func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
2626 t := m.com.Styles
2727
28− states := slices.SortedFunc(maps.Values(m.lspStates), func(a, b app.LSPClientInfo) int {
28+ states := slices.SortedFunc(maps.Values(m.lspStates), func(a, b workspace.LSPClientInfo) int {
2929 return strings.Compare(a.Name, b.Name)
3030 })
3131
3232 var lsps []LSPInfo
3333 for _, state := range states {
3434 lspErrs := map[protocol.DiagnosticSeverity]int{}
35− if client, ok := m.com.App.LSPManager.Clients().Get(state.Name); ok {
35+ if client, ok := m.com.Workspace.LSPGetClient(state.Name); ok {
3636 counts := client.GetDiagnosticCounts()
3737 lspErrs[protocol.SeverityError] = counts.Error
3838 lspErrs[protocol.SeverityWarning] = counts.Warning
internal/ui/model/onboarding.go+3−7
@@ -9,8 +9,6 @@ import (
99 tea "charm.land/bubbletea/v2"
1010 "charm.land/lipgloss/v2"
1111
12− "github.com/charmbracelet/crush/internal/agent"
13− "github.com/charmbracelet/crush/internal/config"
1412 "github.com/charmbracelet/crush/internal/home"
1513 "github.com/charmbracelet/crush/internal/ui/common"
1614 "github.com/charmbracelet/crush/internal/ui/util"
@@ -19,7 +17,7 @@ import (
1917// markProjectInitialized marks the current project as initialized in the config.
2018func (m *UI) markProjectInitialized() tea.Msg {
2119 // TODO: handle error so we show it in the tui footer
22− err := config.MarkProjectInitialized(m.com.Store())
20+ err := m.com.Workspace.MarkProjectInitialized()
2321 if err != nil {
2422 slog.Error(err.Error())
2523 }
@@ -52,10 +50,8 @@ func (m *UI) initializeProject() tea.Cmd {
5250 if cmd := m.newSession(); cmd != nil {
5351 cmds = append(cmds, cmd)
5452 }
55− cfg := m.com.Store()
56−
5753 initialize := func() tea.Msg {
58− initPrompt, err := agent.InitializePrompt(cfg)
54+ initPrompt, err := m.com.Workspace.InitializePrompt()
5955 if err != nil {
6056 return util.InfoMsg{
6157 Type: util.InfoTypeError,
@@ -81,7 +77,7 @@ func (m *UI) skipInitializeProject() tea.Cmd {
8177// initializeView renders the project initialization prompt with Yes/No buttons.
8278func (m *UI) initializeView() string {
8379 s := m.com.Styles.Initialize
84− cwd := home.Short(m.com.Store().WorkingDir())
80+ cwd := home.Short(m.com.Workspace.WorkingDir())
8581 initFile := m.com.Config().Options.InitializeAs
8682
8783 header := s.Header.Render("Would you like to initialize this project?")
internal/ui/model/pills.go+2−2
@@ -249,8 +249,8 @@ func (m *UI) renderPills() {
249249 if todosFocused && hasIncomplete {
250250 expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
251251 } else if queueFocused && hasQueue {
252− if m.com.App != nil && m.com.App.AgentCoordinator != nil {
253− queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
252+ if m.com.Workspace.AgentIsReady() {
253+ queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
254254 expandedList = queueList(queueItems, t)
255255 }
256256 }
internal/ui/model/session.go+4−4
@@ -66,7 +66,7 @@ type SessionFile struct {
6666// returns a sessionFilesLoadedMsg containing the processed session files.
6767func (m *UI) loadSession(sessionID string) tea.Cmd {
6868 return func() tea.Msg {
69− session, err := m.com.App.Sessions.Get(context.Background(), sessionID)
69+ session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
7070 if err != nil {
7171 return util.ReportError(err)
7272 }
@@ -76,7 +76,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
7676 return util.ReportError(err)
7777 }
7878
79− readFiles, err := m.com.App.FileTracker.ListReadFiles(context.Background(), sessionID)
79+ readFiles, err := m.com.Workspace.FileTrackerListReadFiles(context.Background(), sessionID)
8080 if err != nil {
8181 slog.Error("Failed to load read files for session", "error", err)
8282 }
@@ -90,7 +90,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
9090}
9191
9292func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
93− files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
93+ files, err := m.com.Workspace.ListSessionHistory(context.Background(), sessionID)
9494 if err != nil {
9595 return nil, err
9696 }
@@ -241,7 +241,7 @@ func (m *UI) startLSPs(paths []string) tea.Cmd {
241241 return func() tea.Msg {
242242 ctx := context.Background()
243243 for _, path := range paths {
244− m.com.App.LSPManager.Start(ctx, path)
244+ m.com.Workspace.LSPStart(ctx, path)
245245 }
246246 return nil
247247 }
internal/ui/model/sidebar.go+7−3
@@ -48,7 +48,11 @@ func (m *UI) modelInfo(width int) string {
4848 ModelContext: model.CatwalkCfg.ContextWindow,
4949 }
5050 }
51− return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width)
51+ var modelName string
52+ if model != nil {
53+ modelName = model.CatwalkCfg.Name
54+ }
55+ return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width)
5256}
5357
5458// getDynamicHeightLimits will give us the num of items to show in each section based on the hight
@@ -112,7 +116,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
112116 height := area.Dy()
113117
114118 title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
115− cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
119+ cwd := common.PrettyPath(t, m.com.Workspace.WorkingDir(), width)
116120 sidebarLogo := m.sidebarLogo
117121 if height < logoHeightBreakpoint {
118122 sidebarLogo = logo.SmallRender(m.com.Styles, width)
@@ -138,7 +142,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
138142
139143 lspSection := m.lspInfo(width, maxLSPs, true)
140144 mcpSection := m.mcpInfo(width, maxMCPs, true)
141− filesSection := m.filesInfo(m.com.Store().WorkingDir(), width, maxFiles, true)
145+ filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
142146
143147 uv.NewStyledString(
144148 lipgloss.NewStyle().
internal/ui/model/ui.go+59−66
@@ -28,7 +28,6 @@ import (
2828 "github.com/charmbracelet/crush/internal/agent/notify"
2929 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
3030 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
31− "github.com/charmbracelet/crush/internal/app"
3231 "github.com/charmbracelet/crush/internal/commands"
3332 "github.com/charmbracelet/crush/internal/config"
3433 "github.com/charmbracelet/crush/internal/fsext"
@@ -50,6 +49,7 @@ import (
5049 "github.com/charmbracelet/crush/internal/ui/styles"
5150 "github.com/charmbracelet/crush/internal/ui/util"
5251 "github.com/charmbracelet/crush/internal/version"
52+ "github.com/charmbracelet/crush/internal/workspace"
5353 uv "github.com/charmbracelet/ultraviolet"
5454 "github.com/charmbracelet/ultraviolet/layout"
5555 "github.com/charmbracelet/ultraviolet/screen"
@@ -195,7 +195,7 @@ type UI struct {
195195 }
196196
197197 // lsp
198− lspStates map[string]app.LSPClientInfo
198+ lspStates map[string]workspace.LSPClientInfo
199199
200200 // mcp
201201 mcpStates map[string]mcp.ClientInfo
@@ -294,7 +294,7 @@ func New(com *common.Common) *UI {
294294 completions: comp,
295295 attachments: attachments,
296296 todoSpinner: todoSpinner,
297− lspStates: make(map[string]app.LSPClientInfo),
297+ lspStates: make(map[string]workspace.LSPClientInfo),
298298 mcpStates: make(map[string]mcp.ClientInfo),
299299 notifyBackend: notification.NoopBackend{},
300300 notifyWindowFocused: true,
@@ -317,7 +317,7 @@ func New(com *common.Common) *UI {
317317 desiredFocus := uiFocusEditor
318318 if !com.Config().IsConfigured() {
319319 desiredState = uiOnboarding
320− } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
320+ } else if n, _ := com.Workspace.ProjectNeedsInitialization(); n {
321321 desiredState = uiInitialize
322322 }
323323
@@ -415,7 +415,7 @@ func (m *UI) loadMCPrompts() tea.Msg {
415415func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
416416 var cmds []tea.Cmd
417417 if m.hasSession() && m.isAgentBusy() {
418− queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
418+ queueSize := m.com.Workspace.AgentQueuedPrompts(m.session.ID)
419419 if queueSize != m.promptQueue {
420420 m.promptQueue = queueSize
421421 m.updateLayoutAndSize()
@@ -450,7 +450,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
450450 m.session = msg.session
451451 m.sessionFiles = msg.files
452452 cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
453− msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
453+ msgs, err := m.com.Workspace.ListMessages(context.Background(), m.session.ID)
454454 if err != nil {
455455 cmds = append(cmds, util.ReportError(err))
456456 break
@@ -567,8 +567,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
567567 m.renderPills()
568568 case pubsub.Event[history.File]:
569569 cmds = append(cmds, m.handleFileEvent(msg.Payload))
570− case pubsub.Event[app.LSPEvent]:
571− m.lspStates = app.GetLSPStates()
570+ case pubsub.Event[workspace.LSPEvent]:
571+ m.lspStates = m.com.Workspace.LSPGetStates()
572572 case pubsub.Event[mcp.Event]:
573573 switch msg.Payload.Type {
574574 case mcp.EventStateChanged:
@@ -577,11 +577,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
577577 m.loadMCPrompts,
578578 )
579579 case mcp.EventPromptsListChanged:
580− return m, handleMCPPromptsEvent(msg.Payload.Name)
580+ return m, handleMCPPromptsEvent(m.com.Workspace, msg.Payload.Name)
581581 case mcp.EventToolsListChanged:
582− return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
582+ return m, handleMCPToolsEvent(m.com.Workspace, msg.Payload.Name)
583583 case mcp.EventResourcesListChanged:
584− return m, handleMCPResourcesEvent(msg.Payload.Name)
584+ return m, handleMCPResourcesEvent(m.com.Workspace, msg.Payload.Name)
585585 }
586586 case pubsub.Event[permission.PermissionRequest]:
587587 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -830,7 +830,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
830830 } else {
831831 m.textarea.Placeholder = m.readyPlaceholder
832832 }
833− if m.com.App.Permissions.SkipRequests() {
833+ if m.com.Workspace.PermissionSkipRequests() {
834834 m.textarea.Placeholder = "Yolo mode!"
835835 }
836836 }
@@ -909,10 +909,10 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
909909 messageID := toolItem.MessageID()
910910
911911 // Get the agent tool session ID.
912− agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
912+ agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
913913
914914 // Fetch nested messages.
915− nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
915+ nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
916916 if err != nil || len(nestedMsgs) == 0 {
917917 continue
918918 }
@@ -1114,7 +1114,7 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
11141114
11151115 // Check if this is an agent tool session and parse it.
11161116 childSessionID := event.Payload.SessionID
1117− _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
1117+ _, toolCallID, ok := m.com.Workspace.ParseAgentToolSessionID(childSessionID)
11181118 if !ok {
11191119 return nil
11201120 }
@@ -1246,8 +1246,8 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
12461246
12471247 // Command dialog messages
12481248 case dialog.ActionToggleYoloMode:
1249− yolo := !m.com.App.Permissions.SkipRequests()
1250− m.com.App.Permissions.SetSkipRequests(yolo)
1249+ yolo := !m.com.Workspace.PermissionSkipRequests()
1250+ m.com.Workspace.PermissionSetSkipRequests(yolo)
12511251 m.setEditorPrompt(yolo)
12521252 m.dialog.CloseDialog(dialog.CommandsID)
12531253 case dialog.ActionNewSession:
@@ -1265,7 +1265,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
12651265 break
12661266 }
12671267 cmds = append(cmds, func() tea.Msg {
1268− err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
1268+ err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
12691269 if err != nil {
12701270 return util.ReportError(err)()
12711271 }
@@ -1304,10 +1304,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13041304
13051305 currentModel := cfg.Models[agentCfg.Model]
13061306 currentModel.Think = !currentModel.Think
1307− if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1307+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
13081308 return util.ReportError(err)()
13091309 }
1310− m.com.App.UpdateAgentModel(context.TODO())
1310+ m.com.Workspace.UpdateAgentModel(context.TODO())
13111311 status := "disabled"
13121312 if currentModel.Think {
13131313 status = "enabled"
@@ -1345,7 +1345,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13451345
13461346 // Attempt to import GitHub Copilot tokens from VSCode if available.
13471347 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1348− m.com.Store().ImportCopilot()
1348+ m.com.Workspace.ImportCopilot()
13491349 }
13501350
13511351 if !isConfigured() || msg.ReAuthenticate {
@@ -1356,18 +1356,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13561356 break
13571357 }
13581358
1359− if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1359+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
13601360 cmds = append(cmds, util.ReportError(err))
13611361 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
13621362 // Ensure small model is set is unset.
1363− smallModel := m.com.App.GetDefaultSmallModel(providerID)
1364− if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1363+ smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1364+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
13651365 cmds = append(cmds, util.ReportError(err))
13661366 }
13671367 }
13681368
13691369 cmds = append(cmds, func() tea.Msg {
1370− if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
1370+ if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
13711371 return util.ReportError(err)
13721372 }
13731373
@@ -1383,7 +1383,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13831383 if isOnboarding {
13841384 m.setState(uiLanding, uiFocusEditor)
13851385 m.com.Config().SetupAgents()
1386− if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
1386+ if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
13871387 cmds = append(cmds, util.ReportError(err))
13881388 }
13891389 }
@@ -1407,13 +1407,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
14071407
14081408 currentModel := cfg.Models[agentCfg.Model]
14091409 currentModel.ReasoningEffort = msg.Effort
1410− if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1410+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
14111411 cmds = append(cmds, util.ReportError(err))
14121412 break
14131413 }
14141414
14151415 cmds = append(cmds, func() tea.Msg {
1416− m.com.App.UpdateAgentModel(context.TODO())
1416+ m.com.Workspace.UpdateAgentModel(context.TODO())
14171417 return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
14181418 })
14191419 m.dialog.CloseDialog(dialog.ReasoningID)
@@ -1421,11 +1421,11 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
14211421 m.dialog.CloseDialog(dialog.PermissionsID)
14221422 switch msg.Action {
14231423 case dialog.PermissionAllow:
1424− m.com.App.Permissions.Grant(msg.Permission)
1424+ m.com.Workspace.PermissionGrant(msg.Permission)
14251425 case dialog.PermissionAllowForSession:
1426− m.com.App.Permissions.GrantPersistent(msg.Permission)
1426+ m.com.Workspace.PermissionGrantPersistent(msg.Permission)
14271427 case dialog.PermissionDeny:
1428− m.com.App.Permissions.Deny(msg.Permission)
1428+ m.com.Workspace.PermissionDeny(msg.Permission)
14291429 }
14301430
14311431 case dialog.ActionFilePickerSelected:
@@ -2019,7 +2019,7 @@ func (m *UI) View() tea.View {
20192019 }
20202020 v.MouseMode = tea.MouseModeCellMotion
20212021 v.ReportFocus = m.caps.ReportFocusEvents
2022− v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
2022+ v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
20232023
20242024 canvas := uv.NewScreenBuffer(m.width, m.height)
20252025 v.Cursor = m.Draw(canvas, canvas.Bounds())
@@ -2062,7 +2062,7 @@ func (m *UI) ShortHelp() []key.Binding {
20622062 cancelBinding := k.Chat.Cancel
20632063 if m.isCanceling {
20642064 cancelBinding.SetHelp("esc", "press again to cancel")
2065− } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2065+ } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
20662066 cancelBinding.SetHelp("esc", "clear queue")
20672067 }
20682068 binds = append(binds, cancelBinding)
@@ -2141,7 +2141,7 @@ func (m *UI) FullHelp() [][]key.Binding {
21412141 cancelBinding := k.Chat.Cancel
21422142 if m.isCanceling {
21432143 cancelBinding.SetHelp("esc", "press again to cancel")
2144− } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2144+ } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
21452145 cancelBinding.SetHelp("esc", "clear queue")
21462146 }
21472147 binds = append(binds, []key.Binding{cancelBinding})
@@ -2258,7 +2258,7 @@ func (m *UI) FullHelp() [][]key.Binding {
22582258func (m *UI) toggleCompactMode() tea.Cmd {
22592259 m.forceCompactMode = !m.forceCompactMode
22602260
2261− err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2261+ err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
22622262 if err != nil {
22632263 return util.ReportError(err)
22642264 }
@@ -2600,7 +2600,7 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
26002600
26012601 if m.hasSession() {
26022602 // Skip attachment if file was already read and hasn't been modified.
2603− lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2603+ lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
26042604 if !lastRead.IsZero() {
26052605 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
26062606 return nil
@@ -2638,9 +2638,8 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
26382638 }
26392639
26402640 return func() tea.Msg {
2641− contents, err := mcp.ReadResource(
2641+ contents, err := m.com.Workspace.ReadMCPResource(
26422642 context.Background(),
2643− m.com.Store(),
26442643 item.MCPName,
26452644 item.URI,
26462645 )
@@ -2708,9 +2707,8 @@ func isWhitespace(b byte) bool {
27082707// isAgentBusy returns true if the agent coordinator exists and is currently
27092708// busy processing a request.
27102709func (m *UI) isAgentBusy() bool {
2711− return m.com.App != nil &&
2712− m.com.App.AgentCoordinator != nil &&
2713− m.com.App.AgentCoordinator.IsBusy()
2710+ return m.com.Workspace.AgentIsReady() &&
2711+ m.com.Workspace.AgentIsBusy()
27142712}
27152713
27162714// hasSession returns true if there is an active session with a valid ID.
@@ -2767,13 +2765,13 @@ func (m *UI) cacheSidebarLogo(width int) {
27672765
27682766// sendMessage sends a message with the given content and attachments.
27692767func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2770− if m.com.App.AgentCoordinator == nil {
2768+ if !m.com.Workspace.AgentIsReady() {
27712769 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
27722770 }
27732771
27742772 var cmds []tea.Cmd
27752773 if !m.hasSession() {
2776− newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2774+ newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
27772775 if err != nil {
27782776 return util.ReportError(err)
27792777 }
@@ -2790,8 +2788,8 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
27902788 ctx := context.Background()
27912789 cmds = append(cmds, func() tea.Msg {
27922790 for _, path := range m.sessionFileReads {
2793− m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2794− m.com.App.LSPManager.Start(ctx, path)
2791+ m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
2792+ m.com.Workspace.LSPStart(ctx, path)
27952793 }
27962794 return nil
27972795 })
@@ -2799,7 +2797,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
27992797 // Capture session ID to avoid race with main goroutine updating m.session.
28002798 sessionID := m.session.ID
28012799 cmds = append(cmds, func() tea.Msg {
2802− _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2800+ err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
28032801 if err != nil {
28042802 isCancelErr := errors.Is(err, context.Canceled)
28052803 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
@@ -2833,15 +2831,14 @@ func (m *UI) cancelAgent() tea.Cmd {
28332831 return nil
28342832 }
28352833
2836− coordinator := m.com.App.AgentCoordinator
2837− if coordinator == nil {
2834+ if !m.com.Workspace.AgentIsReady() {
28382835 return nil
28392836 }
28402837
28412838 if m.isCanceling {
28422839 // Second escape press - actually cancel the agent.
28432840 m.isCanceling = false
2844− coordinator.Cancel(m.session.ID)
2841+ m.com.Workspace.AgentCancel(m.session.ID)
28452842 // Stop the spinning todo indicator.
28462843 m.todoIsSpinning = false
28472844 m.renderPills()
@@ -2849,8 +2846,8 @@ func (m *UI) cancelAgent() tea.Cmd {
28492846 }
28502847
28512848 // Check if there are queued prompts - if so, clear the queue.
2852− if coordinator.QueuedPrompts(m.session.ID) > 0 {
2853− coordinator.ClearQueue(m.session.ID)
2849+ if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2850+ m.com.Workspace.AgentClearQueue(m.session.ID)
28542851 return nil
28552852 }
28562853
@@ -3071,7 +3068,7 @@ func (m *UI) newSession() tea.Cmd {
30713068 agenttools.ResetCache()
30723069 return tea.Batch(
30733070 func() tea.Msg {
3074− m.com.App.LSPManager.StopAll(context.Background())
3071+ m.com.Workspace.LSPStopAll(context.Background())
30753072 return nil
30763073 },
30773074 m.loadPromptHistory(),
@@ -3302,7 +3299,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
33023299
33033300 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
33043301 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3305− filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3302+ filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
33063303 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
33073304 uv.NewStyledString(
33083305 s.CompactDetails.View.
@@ -3320,7 +3317,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
33203317
33213318func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
33223319 load := func() tea.Msg {
3323− prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3320+ prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
33243321 if err != nil {
33253322 // TODO: make this better
33263323 return util.ReportError(err)()
@@ -3347,34 +3344,30 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
33473344
33483345func (m *UI) handleStateChanged() tea.Cmd {
33493346 return func() tea.Msg {
3350− m.com.App.UpdateAgentModel(context.Background())
3347+ m.com.Workspace.UpdateAgentModel(context.Background())
33513348 return mcpStateChangedMsg{
3352− states: mcp.GetStates(),
3349+ states: m.com.Workspace.MCPGetStates(),
33533350 }
33543351 }
33553352}
33563353
3357−func handleMCPPromptsEvent(name string) tea.Cmd {
3354+func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
33583355 return func() tea.Msg {
3359− mcp.RefreshPrompts(context.Background(), name)
3356+ ws.MCPRefreshPrompts(context.Background(), name)
33603357 return nil
33613358 }
33623359}
33633360
3364−func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3361+func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
33653362 return func() tea.Msg {
3366− mcp.RefreshTools(
3367− context.Background(),
3368− cfg,
3369− name,
3370− )
3363+ ws.RefreshMCPTools(context.Background(), name)
33713364 return nil
33723365 }
33733366}
33743367
3375−func handleMCPResourcesEvent(name string) tea.Cmd {
3368+func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
33763369 return func() tea.Msg {
3377− mcp.RefreshResources(context.Background(), name)
3370+ ws.MCPRefreshResources(context.Background(), name)
33783371 return nil
33793372 }
33803373}
internal/workspace/app_workspace.gonew file+370
@@ -0,0 +1,370 @@
1+package workspace
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "time"
7+
8+ tea "charm.land/bubbletea/v2"
9+ "github.com/charmbracelet/crush/internal/agent"
10+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
11+ "github.com/charmbracelet/crush/internal/app"
12+ "github.com/charmbracelet/crush/internal/commands"
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/history"
15+ "github.com/charmbracelet/crush/internal/log"
16+ "github.com/charmbracelet/crush/internal/lsp"
17+ "github.com/charmbracelet/crush/internal/message"
18+ "github.com/charmbracelet/crush/internal/oauth"
19+ "github.com/charmbracelet/crush/internal/permission"
20+ "github.com/charmbracelet/crush/internal/pubsub"
21+ "github.com/charmbracelet/crush/internal/session"
22+)
23+
24+// AppWorkspace wraps an in-process app.App to satisfy the Workspace
25+// interface. This is the default mode when no server is involved.
26+type AppWorkspace struct {
27+ app *app.App
28+}
29+
30+// NewAppWorkspace creates a Workspace backed by a local app.App.
31+func NewAppWorkspace(a *app.App) *AppWorkspace {
32+ return &AppWorkspace{app: a}
33+}
34+
35+// App returns the underlying app.App for callers that still need
36+// direct access during the migration period.
37+func (w *AppWorkspace) App() *app.App {
38+ return w.app
39+}
40+
41+// -- Sessions --
42+
43+func (w *AppWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
44+ return w.app.Sessions.Create(ctx, title)
45+}
46+
47+func (w *AppWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
48+ return w.app.Sessions.Get(ctx, sessionID)
49+}
50+
51+func (w *AppWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
52+ return w.app.Sessions.List(ctx)
53+}
54+
55+func (w *AppWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
56+ return w.app.Sessions.Save(ctx, sess)
57+}
58+
59+func (w *AppWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
60+ return w.app.Sessions.Delete(ctx, sessionID)
61+}
62+
63+func (w *AppWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
64+ return w.app.Sessions.CreateAgentToolSessionID(messageID, toolCallID)
65+}
66+
67+func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
68+ return w.app.Sessions.ParseAgentToolSessionID(sessionID)
69+}
70+
71+// -- Messages --
72+
73+func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
74+ return w.app.Messages.List(ctx, sessionID)
75+}
76+
77+func (w *AppWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
78+ return w.app.Messages.ListUserMessages(ctx, sessionID)
79+}
80+
81+func (w *AppWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
82+ return w.app.Messages.ListAllUserMessages(ctx)
83+}
84+
85+// -- Agent --
86+
87+func (w *AppWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
88+ if w.app.AgentCoordinator == nil {
89+ return nil
90+ }
91+ _, err := w.app.AgentCoordinator.Run(ctx, sessionID, prompt, attachments...)
92+ return err
93+}
94+
95+func (w *AppWorkspace) AgentCancel(sessionID string) {
96+ if w.app.AgentCoordinator != nil {
97+ w.app.AgentCoordinator.Cancel(sessionID)
98+ }
99+}
100+
101+func (w *AppWorkspace) AgentIsBusy() bool {
102+ if w.app.AgentCoordinator == nil {
103+ return false
104+ }
105+ return w.app.AgentCoordinator.IsBusy()
106+}
107+
108+func (w *AppWorkspace) AgentIsSessionBusy(sessionID string) bool {
109+ if w.app.AgentCoordinator == nil {
110+ return false
111+ }
112+ return w.app.AgentCoordinator.IsSessionBusy(sessionID)
113+}
114+
115+func (w *AppWorkspace) AgentModel() AgentModel {
116+ if w.app.AgentCoordinator == nil {
117+ return AgentModel{}
118+ }
119+ m := w.app.AgentCoordinator.Model()
120+ return AgentModel{
121+ CatwalkCfg: m.CatwalkCfg,
122+ ModelCfg: m.ModelCfg,
123+ }
124+}
125+
126+func (w *AppWorkspace) AgentIsReady() bool {
127+ return w.app.AgentCoordinator != nil
128+}
129+
130+func (w *AppWorkspace) AgentQueuedPrompts(sessionID string) int {
131+ if w.app.AgentCoordinator == nil {
132+ return 0
133+ }
134+ return w.app.AgentCoordinator.QueuedPrompts(sessionID)
135+}
136+
137+func (w *AppWorkspace) AgentQueuedPromptsList(sessionID string) []string {
138+ if w.app.AgentCoordinator == nil {
139+ return nil
140+ }
141+ return w.app.AgentCoordinator.QueuedPromptsList(sessionID)
142+}
143+
144+func (w *AppWorkspace) AgentClearQueue(sessionID string) {
145+ if w.app.AgentCoordinator != nil {
146+ w.app.AgentCoordinator.ClearQueue(sessionID)
147+ }
148+}
149+
150+func (w *AppWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
151+ if w.app.AgentCoordinator == nil {
152+ return nil
153+ }
154+ return w.app.AgentCoordinator.Summarize(ctx, sessionID)
155+}
156+
157+func (w *AppWorkspace) UpdateAgentModel(ctx context.Context) error {
158+ return w.app.UpdateAgentModel(ctx)
159+}
160+
161+func (w *AppWorkspace) InitCoderAgent(ctx context.Context) error {
162+ return w.app.InitCoderAgent(ctx)
163+}
164+
165+func (w *AppWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
166+ return w.app.GetDefaultSmallModel(providerID)
167+}
168+
169+// -- Permissions --
170+
171+func (w *AppWorkspace) PermissionGrant(perm permission.PermissionRequest) {
172+ w.app.Permissions.Grant(perm)
173+}
174+
175+func (w *AppWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
176+ w.app.Permissions.GrantPersistent(perm)
177+}
178+
179+func (w *AppWorkspace) PermissionDeny(perm permission.PermissionRequest) {
180+ w.app.Permissions.Deny(perm)
181+}
182+
183+func (w *AppWorkspace) PermissionSkipRequests() bool {
184+ return w.app.Permissions.SkipRequests()
185+}
186+
187+func (w *AppWorkspace) PermissionSetSkipRequests(skip bool) {
188+ w.app.Permissions.SetSkipRequests(skip)
189+}
190+
191+// -- FileTracker --
192+
193+func (w *AppWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
194+ w.app.FileTracker.RecordRead(ctx, sessionID, path)
195+}
196+
197+func (w *AppWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
198+ return w.app.FileTracker.LastReadTime(ctx, sessionID, path)
199+}
200+
201+func (w *AppWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
202+ return w.app.FileTracker.ListReadFiles(ctx, sessionID)
203+}
204+
205+// -- History --
206+
207+func (w *AppWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
208+ return w.app.History.ListBySession(ctx, sessionID)
209+}
210+
211+// -- LSP --
212+
213+func (w *AppWorkspace) LSPStart(ctx context.Context, path string) {
214+ w.app.LSPManager.Start(ctx, path)
215+}
216+
217+func (w *AppWorkspace) LSPStopAll(ctx context.Context) {
218+ w.app.LSPManager.StopAll(ctx)
219+}
220+
221+func (w *AppWorkspace) LSPGetStates() map[string]LSPClientInfo {
222+ states := app.GetLSPStates()
223+ result := make(map[string]LSPClientInfo, len(states))
224+ for k, v := range states {
225+ result[k] = LSPClientInfo{
226+ Name: v.Name,
227+ State: v.State,
228+ Error: v.Error,
229+ DiagnosticCount: v.DiagnosticCount,
230+ ConnectedAt: v.ConnectedAt,
231+ }
232+ }
233+ return result
234+}
235+
236+func (w *AppWorkspace) LSPGetClient(name string) (*lsp.Client, bool) {
237+ info, ok := app.GetLSPState(name)
238+ if !ok {
239+ return nil, false
240+ }
241+ return info.Client, true
242+}
243+
244+// -- Config (read-only) --
245+
246+func (w *AppWorkspace) Config() *config.Config {
247+ return w.app.Config()
248+}
249+
250+func (w *AppWorkspace) WorkingDir() string {
251+ return w.app.Store().WorkingDir()
252+}
253+
254+func (w *AppWorkspace) Resolver() config.VariableResolver {
255+ return w.app.Store().Resolver()
256+}
257+
258+// -- Config mutations --
259+
260+func (w *AppWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
261+ return w.app.Store().UpdatePreferredModel(scope, modelType, model)
262+}
263+
264+func (w *AppWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
265+ return w.app.Store().SetCompactMode(scope, enabled)
266+}
267+
268+func (w *AppWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
269+ return w.app.Store().SetProviderAPIKey(scope, providerID, apiKey)
270+}
271+
272+func (w *AppWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
273+ return w.app.Store().SetConfigField(scope, key, value)
274+}
275+
276+func (w *AppWorkspace) RemoveConfigField(scope config.Scope, key string) error {
277+ return w.app.Store().RemoveConfigField(scope, key)
278+}
279+
280+func (w *AppWorkspace) ImportCopilot() (*oauth.Token, bool) {
281+ return w.app.Store().ImportCopilot()
282+}
283+
284+func (w *AppWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
285+ return w.app.Store().RefreshOAuthToken(ctx, scope, providerID)
286+}
287+
288+// -- Project lifecycle --
289+
290+func (w *AppWorkspace) ProjectNeedsInitialization() (bool, error) {
291+ return config.ProjectNeedsInitialization(w.app.Store())
292+}
293+
294+func (w *AppWorkspace) MarkProjectInitialized() error {
295+ return config.MarkProjectInitialized(w.app.Store())
296+}
297+
298+func (w *AppWorkspace) InitializePrompt() (string, error) {
299+ return agent.InitializePrompt(w.app.Store())
300+}
301+
302+// -- MCP operations --
303+
304+func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
305+ return mcptools.GetStates()
306+}
307+
308+func (w *AppWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
309+ mcptools.RefreshPrompts(ctx, name)
310+}
311+
312+func (w *AppWorkspace) MCPRefreshResources(ctx context.Context, name string) {
313+ mcptools.RefreshResources(ctx, name)
314+}
315+
316+func (w *AppWorkspace) RefreshMCPTools(ctx context.Context, name string) {
317+ mcptools.RefreshTools(ctx, w.app.Store(), name)
318+}
319+
320+func (w *AppWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
321+ contents, err := mcptools.ReadResource(ctx, w.app.Store(), name, uri)
322+ if err != nil {
323+ return nil, err
324+ }
325+ result := make([]MCPResourceContents, len(contents))
326+ for i, c := range contents {
327+ result[i] = MCPResourceContents{
328+ URI: c.URI,
329+ MIMEType: c.MIMEType,
330+ Text: c.Text,
331+ Blob: c.Blob,
332+ }
333+ }
334+ return result, nil
335+}
336+
337+func (w *AppWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
338+ return commands.GetMCPPrompt(w.app.Store(), clientID, promptID, args)
339+}
340+
341+// -- Lifecycle --
342+
343+func (w *AppWorkspace) Subscribe(program *tea.Program) {
344+ defer log.RecoverPanic("AppWorkspace.Subscribe", func() {
345+ slog.Info("TUI subscription panic: attempting graceful shutdown")
346+ program.Quit()
347+ })
348+
349+ for msg := range w.app.Events() {
350+ switch ev := msg.(type) {
351+ case pubsub.Event[app.LSPEvent]:
352+ program.Send(pubsub.Event[LSPEvent]{
353+ Type: ev.Type,
354+ Payload: LSPEvent{
355+ Type: LSPEventType(ev.Payload.Type),
356+ Name: ev.Payload.Name,
357+ State: ev.Payload.State,
358+ Error: ev.Payload.Error,
359+ DiagnosticCount: ev.Payload.DiagnosticCount,
360+ },
361+ })
362+ default:
363+ program.Send(msg)
364+ }
365+ }
366+}
367+
368+func (w *AppWorkspace) Shutdown() {
369+ w.app.Shutdown()
370+}
internal/workspace/client_workspace.gonew file+690
@@ -0,0 +1,690 @@
1+package workspace
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+ "strings"
8+ "sync"
9+ "time"
10+
11+ tea "charm.land/bubbletea/v2"
12+ "github.com/charmbracelet/crush/internal/agent/notify"
13+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
14+ "github.com/charmbracelet/crush/internal/client"
15+ "github.com/charmbracelet/crush/internal/config"
16+ "github.com/charmbracelet/crush/internal/history"
17+ "github.com/charmbracelet/crush/internal/log"
18+ "github.com/charmbracelet/crush/internal/lsp"
19+ "github.com/charmbracelet/crush/internal/message"
20+ "github.com/charmbracelet/crush/internal/oauth"
21+ "github.com/charmbracelet/crush/internal/permission"
22+ "github.com/charmbracelet/crush/internal/proto"
23+ "github.com/charmbracelet/crush/internal/pubsub"
24+ "github.com/charmbracelet/crush/internal/session"
25+)
26+
27+// ClientWorkspace implements the Workspace interface by delegating all
28+// operations to a remote server via the client SDK. It caches the
29+// proto.Workspace returned at creation time and refreshes it after
30+// config-mutating operations.
31+type ClientWorkspace struct {
32+ client *client.Client
33+
34+ mu sync.RWMutex
35+ ws proto.Workspace
36+}
37+
38+// NewClientWorkspace creates a new ClientWorkspace that proxies all
39+// operations through the given client SDK. The ws parameter is the
40+// proto.Workspace snapshot returned by the server at creation time.
41+func NewClientWorkspace(c *client.Client, ws proto.Workspace) *ClientWorkspace {
42+ if ws.Config != nil {
43+ ws.Config.SetupAgents()
44+ }
45+ return &ClientWorkspace{
46+ client: c,
47+ ws: ws,
48+ }
49+}
50+
51+// refreshWorkspace re-fetches the workspace from the server, updating
52+// the cached snapshot. Called after config-mutating operations.
53+func (w *ClientWorkspace) refreshWorkspace() {
54+ updated, err := w.client.GetWorkspace(context.Background(), w.ws.ID)
55+ if err != nil {
56+ slog.Error("Failed to refresh workspace", "error", err)
57+ return
58+ }
59+ if updated.Config != nil {
60+ updated.Config.SetupAgents()
61+ }
62+ w.mu.Lock()
63+ w.ws = *updated
64+ w.mu.Unlock()
65+}
66+
67+// cached returns a snapshot of the cached workspace.
68+func (w *ClientWorkspace) cached() proto.Workspace {
69+ w.mu.RLock()
70+ defer w.mu.RUnlock()
71+ return w.ws
72+}
73+
74+// workspaceID returns the cached workspace ID.
75+func (w *ClientWorkspace) workspaceID() string {
76+ return w.cached().ID
77+}
78+
79+// -- Sessions --
80+
81+func (w *ClientWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
82+ sess, err := w.client.CreateSession(ctx, w.workspaceID(), title)
83+ if err != nil {
84+ return session.Session{}, err
85+ }
86+ return *sess, nil
87+}
88+
89+func (w *ClientWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
90+ sess, err := w.client.GetSession(ctx, w.workspaceID(), sessionID)
91+ if err != nil {
92+ return session.Session{}, err
93+ }
94+ return *sess, nil
95+}
96+
97+func (w *ClientWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
98+ return w.client.ListSessions(ctx, w.workspaceID())
99+}
100+
101+func (w *ClientWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
102+ saved, err := w.client.SaveSession(ctx, w.workspaceID(), sess)
103+ if err != nil {
104+ return session.Session{}, err
105+ }
106+ return *saved, nil
107+}
108+
109+func (w *ClientWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
110+ return w.client.DeleteSession(ctx, w.workspaceID(), sessionID)
111+}
112+
113+func (w *ClientWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
114+ return fmt.Sprintf("%s$$%s", messageID, toolCallID)
115+}
116+
117+func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
118+ parts := strings.Split(sessionID, "$$")
119+ if len(parts) != 2 {
120+ return "", "", false
121+ }
122+ return parts[0], parts[1], true
123+}
124+
125+// -- Messages --
126+
127+func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
128+ return w.client.ListMessages(ctx, w.workspaceID(), sessionID)
129+}
130+
131+func (w *ClientWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
132+ return w.client.ListUserMessages(ctx, w.workspaceID(), sessionID)
133+}
134+
135+func (w *ClientWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
136+ return w.client.ListAllUserMessages(ctx, w.workspaceID())
137+}
138+
139+// -- Agent --
140+
141+func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
142+ return w.client.SendMessage(ctx, w.workspaceID(), sessionID, prompt, attachments...)
143+}
144+
145+func (w *ClientWorkspace) AgentCancel(sessionID string) {
146+ _ = w.client.CancelAgentSession(context.Background(), w.workspaceID(), sessionID)
147+}
148+
149+func (w *ClientWorkspace) AgentIsBusy() bool {
150+ info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
151+ if err != nil {
152+ return false
153+ }
154+ return info.IsBusy
155+}
156+
157+func (w *ClientWorkspace) AgentIsSessionBusy(sessionID string) bool {
158+ info, err := w.client.GetAgentSessionInfo(context.Background(), w.workspaceID(), sessionID)
159+ if err != nil {
160+ return false
161+ }
162+ return info.IsBusy
163+}
164+
165+func (w *ClientWorkspace) AgentModel() AgentModel {
166+ info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
167+ if err != nil {
168+ return AgentModel{}
169+ }
170+ return AgentModel{
171+ CatwalkCfg: info.Model,
172+ ModelCfg: info.ModelCfg,
173+ }
174+}
175+
176+func (w *ClientWorkspace) AgentIsReady() bool {
177+ info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
178+ if err != nil {
179+ return false
180+ }
181+ return info.IsReady
182+}
183+
184+func (w *ClientWorkspace) AgentQueuedPrompts(sessionID string) int {
185+ count, err := w.client.GetAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
186+ if err != nil {
187+ return 0
188+ }
189+ return count
190+}
191+
192+func (w *ClientWorkspace) AgentQueuedPromptsList(sessionID string) []string {
193+ prompts, err := w.client.GetAgentSessionQueuedPromptsList(context.Background(), w.workspaceID(), sessionID)
194+ if err != nil {
195+ return nil
196+ }
197+ return prompts
198+}
199+
200+func (w *ClientWorkspace) AgentClearQueue(sessionID string) {
201+ _ = w.client.ClearAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
202+}
203+
204+func (w *ClientWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
205+ return w.client.AgentSummarizeSession(ctx, w.workspaceID(), sessionID)
206+}
207+
208+func (w *ClientWorkspace) UpdateAgentModel(ctx context.Context) error {
209+ return w.client.UpdateAgent(ctx, w.workspaceID())
210+}
211+
212+func (w *ClientWorkspace) InitCoderAgent(ctx context.Context) error {
213+ return w.client.InitiateAgentProcessing(ctx, w.workspaceID())
214+}
215+
216+func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
217+ model, err := w.client.GetDefaultSmallModel(context.Background(), w.workspaceID(), providerID)
218+ if err != nil {
219+ return config.SelectedModel{}
220+ }
221+ return *model
222+}
223+
224+// -- Permissions --
225+
226+func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) {
227+ _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
228+ Permission: proto.PermissionRequest{
229+ ID: perm.ID,
230+ SessionID: perm.SessionID,
231+ ToolCallID: perm.ToolCallID,
232+ ToolName: perm.ToolName,
233+ Description: perm.Description,
234+ Action: perm.Action,
235+ Path: perm.Path,
236+ Params: perm.Params,
237+ },
238+ Action: proto.PermissionAllowForSession,
239+ })
240+}
241+
242+func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
243+ _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
244+ Permission: proto.PermissionRequest{
245+ ID: perm.ID,
246+ SessionID: perm.SessionID,
247+ ToolCallID: perm.ToolCallID,
248+ ToolName: perm.ToolName,
249+ Description: perm.Description,
250+ Action: perm.Action,
251+ Path: perm.Path,
252+ Params: perm.Params,
253+ },
254+ Action: proto.PermissionAllow,
255+ })
256+}
257+
258+func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) {
259+ _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
260+ Permission: proto.PermissionRequest{
261+ ID: perm.ID,
262+ SessionID: perm.SessionID,
263+ ToolCallID: perm.ToolCallID,
264+ ToolName: perm.ToolName,
265+ Description: perm.Description,
266+ Action: perm.Action,
267+ Path: perm.Path,
268+ Params: perm.Params,
269+ },
270+ Action: proto.PermissionDeny,
271+ })
272+}
273+
274+func (w *ClientWorkspace) PermissionSkipRequests() bool {
275+ skip, err := w.client.GetPermissionsSkipRequests(context.Background(), w.workspaceID())
276+ if err != nil {
277+ return false
278+ }
279+ return skip
280+}
281+
282+func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) {
283+ _ = w.client.SetPermissionsSkipRequests(context.Background(), w.workspaceID(), skip)
284+}
285+
286+// -- FileTracker --
287+
288+func (w *ClientWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
289+ _ = w.client.FileTrackerRecordRead(ctx, w.workspaceID(), sessionID, path)
290+}
291+
292+func (w *ClientWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
293+ t, err := w.client.FileTrackerLastReadTime(ctx, w.workspaceID(), sessionID, path)
294+ if err != nil {
295+ return time.Time{}
296+ }
297+ return t
298+}
299+
300+func (w *ClientWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
301+ return w.client.FileTrackerListReadFiles(ctx, w.workspaceID(), sessionID)
302+}
303+
304+// -- History --
305+
306+func (w *ClientWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
307+ return w.client.ListSessionHistoryFiles(ctx, w.workspaceID(), sessionID)
308+}
309+
310+// -- LSP --
311+
312+func (w *ClientWorkspace) LSPStart(ctx context.Context, path string) {
313+ _ = w.client.LSPStart(ctx, w.workspaceID(), path)
314+}
315+
316+func (w *ClientWorkspace) LSPStopAll(ctx context.Context) {
317+ _ = w.client.LSPStopAll(ctx, w.workspaceID())
318+}
319+
320+func (w *ClientWorkspace) LSPGetStates() map[string]LSPClientInfo {
321+ states, err := w.client.GetLSPs(context.Background(), w.workspaceID())
322+ if err != nil {
323+ return nil
324+ }
325+ result := make(map[string]LSPClientInfo, len(states))
326+ for k, v := range states {
327+ result[k] = LSPClientInfo{
328+ Name: v.Name,
329+ State: v.State,
330+ Error: v.Error,
331+ DiagnosticCount: v.DiagnosticCount,
332+ ConnectedAt: v.ConnectedAt,
333+ }
334+ }
335+ return result
336+}
337+
338+func (w *ClientWorkspace) LSPGetClient(_ string) (*lsp.Client, bool) {
339+ return nil, false
340+}
341+
342+// -- Config (read-only) --
343+
344+func (w *ClientWorkspace) Config() *config.Config {
345+ return w.cached().Config
346+}
347+
348+func (w *ClientWorkspace) WorkingDir() string {
349+ return w.cached().Path
350+}
351+
352+func (w *ClientWorkspace) Resolver() config.VariableResolver {
353+ // In client mode, variable resolution is handled server-side.
354+ return nil
355+}
356+
357+// -- Config mutations --
358+
359+func (w *ClientWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
360+ err := w.client.UpdatePreferredModel(context.Background(), w.workspaceID(), scope, modelType, model)
361+ if err == nil {
362+ w.refreshWorkspace()
363+ }
364+ return err
365+}
366+
367+func (w *ClientWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
368+ err := w.client.SetCompactMode(context.Background(), w.workspaceID(), scope, enabled)
369+ if err == nil {
370+ w.refreshWorkspace()
371+ }
372+ return err
373+}
374+
375+func (w *ClientWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
376+ err := w.client.SetProviderAPIKey(context.Background(), w.workspaceID(), scope, providerID, apiKey)
377+ if err == nil {
378+ w.refreshWorkspace()
379+ }
380+ return err
381+}
382+
383+func (w *ClientWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
384+ err := w.client.SetConfigField(context.Background(), w.workspaceID(), scope, key, value)
385+ if err == nil {
386+ w.refreshWorkspace()
387+ }
388+ return err
389+}
390+
391+func (w *ClientWorkspace) RemoveConfigField(scope config.Scope, key string) error {
392+ err := w.client.RemoveConfigField(context.Background(), w.workspaceID(), scope, key)
393+ if err == nil {
394+ w.refreshWorkspace()
395+ }
396+ return err
397+}
398+
399+func (w *ClientWorkspace) ImportCopilot() (*oauth.Token, bool) {
400+ token, ok, err := w.client.ImportCopilot(context.Background(), w.workspaceID())
401+ if err != nil {
402+ return nil, false
403+ }
404+ if ok {
405+ w.refreshWorkspace()
406+ }
407+ return token, ok
408+}
409+
410+func (w *ClientWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
411+ err := w.client.RefreshOAuthToken(ctx, w.workspaceID(), scope, providerID)
412+ if err == nil {
413+ w.refreshWorkspace()
414+ }
415+ return err
416+}
417+
418+// -- Project lifecycle --
419+
420+func (w *ClientWorkspace) ProjectNeedsInitialization() (bool, error) {
421+ return w.client.ProjectNeedsInitialization(context.Background(), w.workspaceID())
422+}
423+
424+func (w *ClientWorkspace) MarkProjectInitialized() error {
425+ return w.client.MarkProjectInitialized(context.Background(), w.workspaceID())
426+}
427+
428+func (w *ClientWorkspace) InitializePrompt() (string, error) {
429+ return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
430+}
431+
432+// -- MCP operations --
433+
434+func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
435+ states, err := w.client.MCPGetStates(context.Background(), w.workspaceID())
436+ if err != nil {
437+ return nil
438+ }
439+ result := make(map[string]mcp.ClientInfo, len(states))
440+ for k, v := range states {
441+ result[k] = mcp.ClientInfo{
442+ Name: v.Name,
443+ State: mcp.State(v.State),
444+ Error: v.Error,
445+ Counts: mcp.Counts{
446+ Tools: v.ToolCount,
447+ Prompts: v.PromptCount,
448+ Resources: v.ResourceCount,
449+ },
450+ ConnectedAt: time.Unix(v.ConnectedAt, 0),
451+ }
452+ }
453+ return result
454+}
455+
456+func (w *ClientWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
457+ _ = w.client.MCPRefreshPrompts(ctx, w.workspaceID(), name)
458+}
459+
460+func (w *ClientWorkspace) MCPRefreshResources(ctx context.Context, name string) {
461+ _ = w.client.MCPRefreshResources(ctx, w.workspaceID(), name)
462+}
463+
464+func (w *ClientWorkspace) RefreshMCPTools(ctx context.Context, name string) {
465+ _ = w.client.RefreshMCPTools(ctx, w.workspaceID(), name)
466+}
467+
468+func (w *ClientWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
469+ contents, err := w.client.ReadMCPResource(ctx, w.workspaceID(), name, uri)
470+ if err != nil {
471+ return nil, err
472+ }
473+ result := make([]MCPResourceContents, len(contents))
474+ for i, c := range contents {
475+ result[i] = MCPResourceContents{
476+ URI: c.URI,
477+ MIMEType: c.MIMEType,
478+ Text: c.Text,
479+ Blob: c.Blob,
480+ }
481+ }
482+ return result, nil
483+}
484+
485+func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
486+ return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
487+}
488+
489+// -- Lifecycle --
490+
491+func (w *ClientWorkspace) Subscribe(program *tea.Program) {
492+ defer log.RecoverPanic("ClientWorkspace.Subscribe", func() {
493+ slog.Info("TUI subscription panic: attempting graceful shutdown")
494+ program.Quit()
495+ })
496+
497+ evc, err := w.client.SubscribeEvents(context.Background(), w.workspaceID())
498+ if err != nil {
499+ slog.Error("Failed to subscribe to events", "error", err)
500+ return
501+ }
502+
503+ for ev := range evc {
504+ translated := translateEvent(ev)
505+ if translated != nil {
506+ program.Send(translated)
507+ }
508+ }
509+}
510+
511+func (w *ClientWorkspace) Shutdown() {
512+ _ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
513+}
514+
515+// translateEvent converts proto-typed SSE events into the domain types
516+// that the TUI's Update() method expects.
517+func translateEvent(ev any) tea.Msg {
518+ switch e := ev.(type) {
519+ case pubsub.Event[proto.LSPEvent]:
520+ return pubsub.Event[LSPEvent]{
521+ Type: e.Type,
522+ Payload: LSPEvent{
523+ Type: LSPEventType(e.Payload.Type),
524+ Name: e.Payload.Name,
525+ State: e.Payload.State,
526+ Error: e.Payload.Error,
527+ DiagnosticCount: e.Payload.DiagnosticCount,
528+ },
529+ }
530+ case pubsub.Event[proto.MCPEvent]:
531+ return pubsub.Event[mcp.Event]{
532+ Type: e.Type,
533+ Payload: mcp.Event{
534+ Type: protoToMCPEventType(e.Payload.Type),
535+ Name: e.Payload.Name,
536+ State: mcp.State(e.Payload.State),
537+ Error: e.Payload.Error,
538+ Counts: mcp.Counts{
539+ Tools: e.Payload.ToolCount,
540+ Prompts: e.Payload.PromptCount,
541+ Resources: e.Payload.ResourceCount,
542+ },
543+ },
544+ }
545+ case pubsub.Event[proto.PermissionRequest]:
546+ return pubsub.Event[permission.PermissionRequest]{
547+ Type: e.Type,
548+ Payload: permission.PermissionRequest{
549+ ID: e.Payload.ID,
550+ SessionID: e.Payload.SessionID,
551+ ToolCallID: e.Payload.ToolCallID,
552+ ToolName: e.Payload.ToolName,
553+ Description: e.Payload.Description,
554+ Action: e.Payload.Action,
555+ Path: e.Payload.Path,
556+ Params: e.Payload.Params,
557+ },
558+ }
559+ case pubsub.Event[proto.PermissionNotification]:
560+ return pubsub.Event[permission.PermissionNotification]{
561+ Type: e.Type,
562+ Payload: permission.PermissionNotification{
563+ ToolCallID: e.Payload.ToolCallID,
564+ Granted: e.Payload.Granted,
565+ Denied: e.Payload.Denied,
566+ },
567+ }
568+ case pubsub.Event[proto.Message]:
569+ return pubsub.Event[message.Message]{
570+ Type: e.Type,
571+ Payload: protoToMessage(e.Payload),
572+ }
573+ case pubsub.Event[proto.Session]:
574+ return pubsub.Event[session.Session]{
575+ Type: e.Type,
576+ Payload: protoToSession(e.Payload),
577+ }
578+ case pubsub.Event[proto.File]:
579+ return pubsub.Event[history.File]{
580+ Type: e.Type,
581+ Payload: protoToFile(e.Payload),
582+ }
583+ case pubsub.Event[proto.AgentEvent]:
584+ return pubsub.Event[notify.Notification]{
585+ Type: e.Type,
586+ Payload: notify.Notification{
587+ SessionID: e.Payload.SessionID,
588+ SessionTitle: e.Payload.SessionTitle,
589+ Type: notify.Type(e.Payload.Type),
590+ },
591+ }
592+ default:
593+ return ev.(tea.Msg)
594+ }
595+}
596+
597+func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
598+ switch t {
599+ case proto.MCPEventStateChanged:
600+ return mcp.EventStateChanged
601+ case proto.MCPEventToolsListChanged:
602+ return mcp.EventToolsListChanged
603+ case proto.MCPEventPromptsListChanged:
604+ return mcp.EventPromptsListChanged
605+ case proto.MCPEventResourcesListChanged:
606+ return mcp.EventResourcesListChanged
607+ default:
608+ return mcp.EventStateChanged
609+ }
610+}
611+
612+func protoToSession(s proto.Session) session.Session {
613+ return session.Session{
614+ ID: s.ID,
615+ ParentSessionID: s.ParentSessionID,
616+ Title: s.Title,
617+ SummaryMessageID: s.SummaryMessageID,
618+ MessageCount: s.MessageCount,
619+ PromptTokens: s.PromptTokens,
620+ CompletionTokens: s.CompletionTokens,
621+ Cost: s.Cost,
622+ CreatedAt: s.CreatedAt,
623+ UpdatedAt: s.UpdatedAt,
624+ }
625+}
626+
627+func protoToFile(f proto.File) history.File {
628+ return history.File{
629+ ID: f.ID,
630+ SessionID: f.SessionID,
631+ Path: f.Path,
632+ Content: f.Content,
633+ Version: f.Version,
634+ CreatedAt: f.CreatedAt,
635+ UpdatedAt: f.UpdatedAt,
636+ }
637+}
638+
639+func protoToMessage(m proto.Message) message.Message {
640+ msg := message.Message{
641+ ID: m.ID,
642+ SessionID: m.SessionID,
643+ Role: message.MessageRole(m.Role),
644+ Model: m.Model,
645+ Provider: m.Provider,
646+ CreatedAt: m.CreatedAt,
647+ UpdatedAt: m.UpdatedAt,
648+ }
649+
650+ for _, p := range m.Parts {
651+ switch v := p.(type) {
652+ case proto.TextContent:
653+ msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
654+ case proto.ReasoningContent:
655+ msg.Parts = append(msg.Parts, message.ReasoningContent{
656+ Thinking: v.Thinking,
657+ Signature: v.Signature,
658+ StartedAt: v.StartedAt,
659+ FinishedAt: v.FinishedAt,
660+ })
661+ case proto.ToolCall:
662+ msg.Parts = append(msg.Parts, message.ToolCall{
663+ ID: v.ID,
664+ Name: v.Name,
665+ Input: v.Input,
666+ Finished: v.Finished,
667+ })
668+ case proto.ToolResult:
669+ msg.Parts = append(msg.Parts, message.ToolResult{
670+ ToolCallID: v.ToolCallID,
671+ Name: v.Name,
672+ Content: v.Content,
673+ IsError: v.IsError,
674+ })
675+ case proto.Finish:
676+ msg.Parts = append(msg.Parts, message.Finish{
677+ Reason: message.FinishReason(v.Reason),
678+ Time: v.Time,
679+ Message: v.Message,
680+ Details: v.Details,
681+ })
682+ case proto.ImageURLContent:
683+ msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
684+ case proto.BinaryContent:
685+ msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
686+ }
687+ }
688+
689+ return msg
690+}
internal/workspace/workspace.gonew file+150
@@ -0,0 +1,150 @@
1+// Package workspace defines the Workspace interface used by all
2+// frontends (TUI, CLI) to interact with a running workspace. Two
3+// implementations exist: one wrapping a local app.App instance and one
4+// wrapping the HTTP client SDK.
5+package workspace
6+
7+import (
8+ "context"
9+ "time"
10+
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/catwalk/pkg/catwalk"
13+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/history"
16+ "github.com/charmbracelet/crush/internal/lsp"
17+ "github.com/charmbracelet/crush/internal/message"
18+ "github.com/charmbracelet/crush/internal/oauth"
19+ "github.com/charmbracelet/crush/internal/permission"
20+ "github.com/charmbracelet/crush/internal/session"
21+)
22+
23+// LSPClientInfo holds information about an LSP client's state. This is
24+// the frontend-facing type; implementations translate from the
25+// underlying app or proto representation.
26+type LSPClientInfo struct {
27+ Name string
28+ State lsp.ServerState
29+ Error error
30+ DiagnosticCount int
31+ ConnectedAt time.Time
32+}
33+
34+// LSPEventType represents the type of LSP event.
35+type LSPEventType string
36+
37+const (
38+ LSPEventStateChanged LSPEventType = "state_changed"
39+ LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed"
40+)
41+
42+// LSPEvent represents an LSP event forwarded to the TUI.
43+type LSPEvent struct {
44+ Type LSPEventType
45+ Name string
46+ State lsp.ServerState
47+ Error error
48+ DiagnosticCount int
49+}
50+
51+// AgentModel holds the model information exposed to the UI.
52+type AgentModel struct {
53+ CatwalkCfg catwalk.Model
54+ ModelCfg config.SelectedModel
55+}
56+
57+// Workspace is the main abstraction consumed by the TUI and CLI. It
58+// groups every operation a frontend needs to perform against a running
59+// workspace, regardless of whether the workspace is in-process or
60+// remote.
61+type Workspace interface {
62+ // Sessions
63+ CreateSession(ctx context.Context, title string) (session.Session, error)
64+ GetSession(ctx context.Context, sessionID string) (session.Session, error)
65+ ListSessions(ctx context.Context) ([]session.Session, error)
66+ SaveSession(ctx context.Context, sess session.Session) (session.Session, error)
67+ DeleteSession(ctx context.Context, sessionID string) error
68+ CreateAgentToolSessionID(messageID, toolCallID string) string
69+ ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool)
70+
71+ // Messages
72+ ListMessages(ctx context.Context, sessionID string) ([]message.Message, error)
73+ ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error)
74+ ListAllUserMessages(ctx context.Context) ([]message.Message, error)
75+
76+ // Agent
77+ AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error
78+ AgentCancel(sessionID string)
79+ AgentIsBusy() bool
80+ AgentIsSessionBusy(sessionID string) bool
81+ AgentModel() AgentModel
82+ AgentIsReady() bool
83+ AgentQueuedPrompts(sessionID string) int
84+ AgentQueuedPromptsList(sessionID string) []string
85+ AgentClearQueue(sessionID string)
86+ AgentSummarize(ctx context.Context, sessionID string) error
87+ UpdateAgentModel(ctx context.Context) error
88+ InitCoderAgent(ctx context.Context) error
89+ GetDefaultSmallModel(providerID string) config.SelectedModel
90+
91+ // Permissions
92+ PermissionGrant(perm permission.PermissionRequest)
93+ PermissionGrantPersistent(perm permission.PermissionRequest)
94+ PermissionDeny(perm permission.PermissionRequest)
95+ PermissionSkipRequests() bool
96+ PermissionSetSkipRequests(skip bool)
97+
98+ // FileTracker
99+ FileTrackerRecordRead(ctx context.Context, sessionID, path string)
100+ FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time
101+ FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error)
102+
103+ // History
104+ ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error)
105+
106+ // LSP
107+ LSPStart(ctx context.Context, path string)
108+ LSPStopAll(ctx context.Context)
109+ LSPGetStates() map[string]LSPClientInfo
110+ LSPGetClient(name string) (*lsp.Client, bool)
111+
112+ // Config (read-only data)
113+ Config() *config.Config
114+ WorkingDir() string
115+ Resolver() config.VariableResolver
116+
117+ // Config mutations (proxied to server in client mode)
118+ UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error
119+ SetCompactMode(scope config.Scope, enabled bool) error
120+ SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error
121+ SetConfigField(scope config.Scope, key string, value any) error
122+ RemoveConfigField(scope config.Scope, key string) error
123+ ImportCopilot() (*oauth.Token, bool)
124+ RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error
125+
126+ // Project lifecycle
127+ ProjectNeedsInitialization() (bool, error)
128+ MarkProjectInitialized() error
129+ InitializePrompt() (string, error)
130+
131+ // MCP operations (server-side in client mode)
132+ MCPGetStates() map[string]mcptools.ClientInfo
133+ MCPRefreshPrompts(ctx context.Context, name string)
134+ MCPRefreshResources(ctx context.Context, name string)
135+ RefreshMCPTools(ctx context.Context, name string)
136+ ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error)
137+ GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error)
138+
139+ // Events
140+ Subscribe(program *tea.Program)
141+ Shutdown()
142+}
143+
144+// MCPResourceContents holds the contents of an MCP resource.
145+type MCPResourceContents struct {
146+ URI string `json:"uri"`
147+ MIMEType string `json:"mime_type,omitempty"`
148+ Text string `json:"text,omitempty"`
149+ Blob []byte `json:"blob,omitempty"`
150+}