1package workspace
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "time"
8
9 tea "charm.land/bubbletea/v2"
10 "git.secluded.site/crush/internal/agent"
11 mcptools "git.secluded.site/crush/internal/agent/tools/mcp"
12 "git.secluded.site/crush/internal/app"
13 "git.secluded.site/crush/internal/commands"
14 "git.secluded.site/crush/internal/config"
15 "git.secluded.site/crush/internal/history"
16 "git.secluded.site/crush/internal/lsp"
17 "git.secluded.site/crush/internal/message"
18 "git.secluded.site/crush/internal/oauth"
19 "git.secluded.site/crush/internal/permission"
20 "git.secluded.site/crush/internal/session"
21 "git.secluded.site/crush/internal/skills"
22)
23
24// AppWorkspace implements the Workspace interface by delegating
25// directly to an in-process [app.App] instance. This is the default
26// mode when the client/server architecture is not enabled.
27type AppWorkspace struct {
28 app *app.App
29 store *config.ConfigStore
30}
31
32// NewAppWorkspace creates a new AppWorkspace wrapping the given app
33// and config store.
34func NewAppWorkspace(a *app.App, store *config.ConfigStore) *AppWorkspace {
35 return &AppWorkspace{
36 app: a,
37 store: store,
38 }
39}
40
41// -- Sessions --
42
43func (w *AppWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
44 return w.app.Sessions.Create(ctx, title)
45}
46
47func (w *AppWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
48 return w.app.Sessions.Get(ctx, sessionID)
49}
50
51func (w *AppWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
52 return w.app.Sessions.List(ctx)
53}
54
55func (w *AppWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
56 return w.app.Sessions.Save(ctx, sess)
57}
58
59func (w *AppWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
60 return w.app.Sessions.Delete(ctx, sessionID)
61}
62
63func (w *AppWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
64 return w.app.Sessions.CreateAgentToolSessionID(messageID, toolCallID)
65}
66
67func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
68 return w.app.Sessions.ParseAgentToolSessionID(sessionID)
69}
70
71// -- Messages --
72
73func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
74 // Drain any debounced updates so the caller observes the latest
75 // in-memory state. message.Service buffers streaming deltas and a
76 // cold List would otherwise miss them at session-switch time.
77 if err := w.app.Messages.FlushAll(ctx); err != nil {
78 return nil, err
79 }
80 return w.app.Messages.List(ctx, sessionID)
81}
82
83func (w *AppWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
84 return w.app.Messages.ListUserMessages(ctx, sessionID)
85}
86
87func (w *AppWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
88 return w.app.Messages.ListAllUserMessages(ctx)
89}
90
91// -- Agent --
92
93func (w *AppWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
94 if w.app.AgentCoordinator == nil {
95 return errors.New("agent coordinator not initialized")
96 }
97 _, err := w.app.AgentCoordinator.Run(ctx, sessionID, prompt, attachments...)
98 return err
99}
100
101func (w *AppWorkspace) AgentCancel(sessionID string) {
102 if w.app.AgentCoordinator != nil {
103 w.app.AgentCoordinator.Cancel(sessionID)
104 }
105}
106
107func (w *AppWorkspace) AgentIsBusy() bool {
108 if w.app.AgentCoordinator == nil {
109 return false
110 }
111 return w.app.AgentCoordinator.IsBusy()
112}
113
114func (w *AppWorkspace) AgentIsSessionBusy(sessionID string) bool {
115 if w.app.AgentCoordinator == nil {
116 return false
117 }
118 return w.app.AgentCoordinator.IsSessionBusy(sessionID)
119}
120
121func (w *AppWorkspace) AgentModel() AgentModel {
122 if w.app.AgentCoordinator == nil {
123 return AgentModel{}
124 }
125 m := w.app.AgentCoordinator.Model()
126 return AgentModel{
127 CatwalkCfg: m.CatwalkCfg,
128 ModelCfg: m.ModelCfg,
129 }
130}
131
132func (w *AppWorkspace) AgentIsReady() bool {
133 return w.app.AgentCoordinator != nil
134}
135
136func (w *AppWorkspace) AgentQueuedPrompts(sessionID string) int {
137 if w.app.AgentCoordinator == nil {
138 return 0
139 }
140 return w.app.AgentCoordinator.QueuedPrompts(sessionID)
141}
142
143func (w *AppWorkspace) AgentQueuedPromptsList(sessionID string) []string {
144 if w.app.AgentCoordinator == nil {
145 return nil
146 }
147 return w.app.AgentCoordinator.QueuedPromptsList(sessionID)
148}
149
150func (w *AppWorkspace) AgentClearQueue(sessionID string) {
151 if w.app.AgentCoordinator != nil {
152 w.app.AgentCoordinator.ClearQueue(sessionID)
153 }
154}
155
156func (w *AppWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
157 if w.app.AgentCoordinator == nil {
158 return errors.New("agent coordinator not initialized")
159 }
160 return w.app.AgentCoordinator.Summarize(ctx, sessionID)
161}
162
163func (w *AppWorkspace) UpdateAgentModel(ctx context.Context) error {
164 return w.app.UpdateAgentModel(ctx)
165}
166
167func (w *AppWorkspace) InitCoderAgent(ctx context.Context) error {
168 return w.app.InitCoderAgent(ctx)
169}
170
171func (w *AppWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
172 return w.app.GetDefaultSmallModel(providerID)
173}
174
175// -- Permissions --
176
177func (w *AppWorkspace) PermissionGrant(perm permission.PermissionRequest) {
178 w.app.Permissions.Grant(perm)
179}
180
181func (w *AppWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
182 w.app.Permissions.GrantPersistent(perm)
183}
184
185func (w *AppWorkspace) PermissionDeny(perm permission.PermissionRequest) {
186 w.app.Permissions.Deny(perm)
187}
188
189func (w *AppWorkspace) PermissionSkipRequests() bool {
190 return w.app.Permissions.SkipRequests()
191}
192
193func (w *AppWorkspace) PermissionSetSkipRequests(skip bool) {
194 w.app.Permissions.SetSkipRequests(skip)
195}
196
197// -- FileTracker --
198
199func (w *AppWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
200 w.app.FileTracker.RecordRead(ctx, sessionID, path)
201}
202
203func (w *AppWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
204 return w.app.FileTracker.LastReadTime(ctx, sessionID, path)
205}
206
207func (w *AppWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
208 return w.app.FileTracker.ListReadFiles(ctx, sessionID)
209}
210
211// -- History --
212
213func (w *AppWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
214 return w.app.History.ListBySession(ctx, sessionID)
215}
216
217// -- LSP --
218
219func (w *AppWorkspace) LSPStart(ctx context.Context, path string) {
220 w.app.LSPManager.Start(ctx, path)
221}
222
223func (w *AppWorkspace) LSPStopAll(ctx context.Context) {
224 w.app.LSPManager.StopAll(ctx)
225}
226
227func (w *AppWorkspace) LSPGetStates() map[string]LSPClientInfo {
228 states := app.GetLSPStates()
229 result := make(map[string]LSPClientInfo, len(states))
230 for k, v := range states {
231 result[k] = LSPClientInfo{
232 Name: v.Name,
233 State: v.State,
234 Error: v.Error,
235 DiagnosticCount: v.DiagnosticCount,
236 ConnectedAt: v.ConnectedAt,
237 }
238 }
239 return result
240}
241
242func (w *AppWorkspace) LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts {
243 state, ok := app.GetLSPState(name)
244 if !ok || state.Client == nil {
245 return lsp.DiagnosticCounts{}
246 }
247 return state.Client.GetDiagnosticCounts()
248}
249
250// -- Config (read-only) --
251
252func (w *AppWorkspace) Config() *config.Config {
253 return w.store.Config()
254}
255
256func (w *AppWorkspace) WorkingDir() string {
257 return w.store.WorkingDir()
258}
259
260func (w *AppWorkspace) Resolver() config.VariableResolver {
261 return w.store.Resolver()
262}
263
264// -- Config mutations --
265
266func (w *AppWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
267 return w.store.UpdatePreferredModel(scope, modelType, model)
268}
269
270func (w *AppWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
271 return w.store.SetCompactMode(scope, enabled)
272}
273
274func (w *AppWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
275 return w.store.SetProviderAPIKey(scope, providerID, apiKey)
276}
277
278func (w *AppWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
279 return w.store.SetConfigField(scope, key, value)
280}
281
282func (w *AppWorkspace) RemoveConfigField(scope config.Scope, key string) error {
283 return w.store.RemoveConfigField(scope, key)
284}
285
286func (w *AppWorkspace) ImportCopilot() (*oauth.Token, bool) {
287 return w.store.ImportCopilot()
288}
289
290func (w *AppWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
291 return w.store.RefreshOAuthToken(ctx, scope, providerID)
292}
293
294// -- Project lifecycle --
295
296func (w *AppWorkspace) ProjectNeedsInitialization() (bool, error) {
297 return config.ProjectNeedsInitialization(w.store)
298}
299
300func (w *AppWorkspace) MarkProjectInitialized() error {
301 return config.MarkProjectInitialized(w.store)
302}
303
304func (w *AppWorkspace) InitializePrompt() (string, error) {
305 return agent.InitializePrompt(w.store)
306}
307
308func (w *AppWorkspace) ListSkills(_ context.Context) ([]skills.CatalogEntry, error) {
309 mgr := w.app.Skills
310 return skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir()), nil
311}
312
313func (w *AppWorkspace) ReadSkill(_ context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
314 mgr := w.app.Skills
315 return skills.ReadContent(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID)
316}
317
318// -- MCP operations --
319
320func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
321 return mcptools.GetStates()
322}
323
324func (w *AppWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
325 mcptools.RefreshPrompts(ctx, name)
326}
327
328func (w *AppWorkspace) MCPRefreshResources(ctx context.Context, name string) {
329 mcptools.RefreshResources(ctx, name)
330}
331
332func (w *AppWorkspace) RefreshMCPTools(ctx context.Context, name string) {
333 mcptools.RefreshTools(ctx, w.store, name)
334}
335
336func (w *AppWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
337 contents, err := mcptools.ReadResource(ctx, w.store, name, uri)
338 if err != nil {
339 return nil, err
340 }
341 result := make([]MCPResourceContents, len(contents))
342 for i, c := range contents {
343 result[i] = MCPResourceContents{
344 URI: c.URI,
345 MIMEType: c.MIMEType,
346 Text: c.Text,
347 Blob: c.Blob,
348 }
349 }
350 return result, nil
351}
352
353func (w *AppWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
354 return commands.GetMCPPrompt(w.store, clientID, promptID, args)
355}
356
357func (w *AppWorkspace) EnableDockerMCP(ctx context.Context) error {
358 mcpConfig, err := w.store.PrepareDockerMCPConfig()
359 if err != nil {
360 return err
361 }
362
363 if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, w.store); err != nil {
364 disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
365 delete(w.store.Config().MCP, config.DockerMCPName)
366 return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
367 }
368
369 if err := w.store.PersistDockerMCPConfig(mcpConfig); err != nil {
370 disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
371 delete(w.store.Config().MCP, config.DockerMCPName)
372 return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
373 }
374
375 return nil
376}
377
378func (w *AppWorkspace) DisableDockerMCP() error {
379 if err := mcptools.DisableSingle(w.store, config.DockerMCPName); err != nil {
380 return fmt.Errorf("failed to disable docker MCP: %w", err)
381 }
382 return w.store.DisableDockerMCP()
383}
384
385// -- Lifecycle --
386
387func (w *AppWorkspace) Subscribe(program *tea.Program) {
388 w.app.Subscribe(program)
389}
390
391func (w *AppWorkspace) Shutdown() {
392 w.app.Shutdown()
393}
394
395// App returns the underlying app.App instance.
396func (w *AppWorkspace) App() *app.App {
397 return w.app
398}
399
400// Store returns the underlying config store.
401func (w *AppWorkspace) Store() *config.ConfigStore {
402 return w.store
403}
404
405// Compile-time check that AppWorkspace implements Workspace.
406var _ Workspace = (*AppWorkspace)(nil)