1package workspace
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "strings"
9 "sync"
10 "time"
11
12 tea "charm.land/bubbletea/v2"
13 "github.com/charmbracelet/crush/internal/agent/notify"
14 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
15 "github.com/charmbracelet/crush/internal/client"
16 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/history"
18 "github.com/charmbracelet/crush/internal/log"
19 "github.com/charmbracelet/crush/internal/lsp"
20 "github.com/charmbracelet/crush/internal/message"
21 "github.com/charmbracelet/crush/internal/oauth"
22 "github.com/charmbracelet/crush/internal/permission"
23 "github.com/charmbracelet/crush/internal/proto"
24 "github.com/charmbracelet/crush/internal/pubsub"
25 "github.com/charmbracelet/crush/internal/session"
26 "github.com/charmbracelet/crush/internal/skills"
27 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
28)
29
30// ClientWorkspace implements the Workspace interface by delegating all
31// operations to a remote server via the client SDK. It caches the
32// proto.Workspace returned at creation time and refreshes it after
33// config-mutating operations.
34type ClientWorkspace struct {
35 client *client.Client
36
37 mu sync.RWMutex
38 ws proto.Workspace
39 skills *skills.Manager
40}
41
42// NewClientWorkspace creates a new ClientWorkspace that proxies all
43// operations through the given client SDK. The ws parameter is the
44// proto.Workspace snapshot returned by the server at creation time. The
45// snapshot's Skills field seeds a process-local skills.Manager so the
46// TUI sees discovery state before the first SSE event arrives. The
47// manager is constructed with WithGlobalMirror because the client
48// process represents exactly one workspace and the TUI reads
49// skills.GetLatestStates directly at construction time.
50func NewClientWorkspace(c *client.Client, ws proto.Workspace) *ClientWorkspace {
51 if ws.Config != nil {
52 ws.Config.SetupAgents()
53 }
54 states := protoToSkillStates(ws.Skills)
55 mgr := skills.NewManager(nil, nil, states, skills.WithGlobalMirror())
56 return &ClientWorkspace{
57 client: c,
58 ws: ws,
59 skills: mgr,
60 }
61}
62
63// refreshWorkspace re-fetches the workspace from the server, updating
64// the cached snapshot. Called after config-mutating operations.
65func (w *ClientWorkspace) refreshWorkspace() {
66 updated, err := w.client.GetWorkspace(context.Background(), w.workspaceID())
67 if err != nil {
68 slog.Error("Failed to refresh workspace", "error", err)
69 return
70 }
71 if updated.Config != nil {
72 updated.Config.SetupAgents()
73 }
74 w.mu.Lock()
75 w.ws = *updated
76 w.mu.Unlock()
77}
78
79// cached returns a snapshot of the cached workspace.
80func (w *ClientWorkspace) cached() proto.Workspace {
81 w.mu.RLock()
82 defer w.mu.RUnlock()
83 return w.ws
84}
85
86// workspaceID returns the cached workspace ID.
87func (w *ClientWorkspace) workspaceID() string {
88 return w.cached().ID
89}
90
91// -- Sessions --
92
93func (w *ClientWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
94 sess, err := w.client.CreateSession(ctx, w.workspaceID(), title)
95 if err != nil {
96 return session.Session{}, err
97 }
98 return protoToSession(*sess), nil
99}
100
101func (w *ClientWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
102 sess, err := w.client.GetSession(ctx, w.workspaceID(), sessionID)
103 if err != nil {
104 return session.Session{}, err
105 }
106 return protoToSession(*sess), nil
107}
108
109func (w *ClientWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
110 protoSessions, err := w.client.ListSessions(ctx, w.workspaceID())
111 if err != nil {
112 return nil, err
113 }
114 sessions := make([]session.Session, len(protoSessions))
115 for i, s := range protoSessions {
116 sessions[i] = protoToSession(s)
117 }
118 return sessions, nil
119}
120
121func (w *ClientWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
122 saved, err := w.client.SaveSession(ctx, w.workspaceID(), sessionToProto(sess))
123 if err != nil {
124 return session.Session{}, err
125 }
126 return protoToSession(*saved), nil
127}
128
129func (w *ClientWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
130 return w.client.DeleteSession(ctx, w.workspaceID(), sessionID)
131}
132
133func (w *ClientWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
134 return fmt.Sprintf("%s$$%s", messageID, toolCallID)
135}
136
137func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
138 parts := strings.Split(sessionID, "$$")
139 if len(parts) != 2 {
140 return "", "", false
141 }
142 return parts[0], parts[1], true
143}
144
145// SetCurrentSession reports the session this client is currently
146// viewing to the server. Empty sessionID clears the entry. Errors
147// are propagated to the caller; the TUI logs and ignores them since
148// the presence record is a hint, not correctness-critical state.
149func (w *ClientWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error {
150 return w.client.SetCurrentSession(ctx, w.workspaceID(), sessionID)
151}
152
153// -- Messages --
154
155func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
156 msgs, err := w.client.ListMessages(ctx, w.workspaceID(), sessionID)
157 if err != nil {
158 return nil, err
159 }
160 return protoToMessages(msgs), nil
161}
162
163func (w *ClientWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
164 msgs, err := w.client.ListUserMessages(ctx, w.workspaceID(), sessionID)
165 if err != nil {
166 return nil, err
167 }
168 return protoToMessages(msgs), nil
169}
170
171func (w *ClientWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
172 msgs, err := w.client.ListAllUserMessages(ctx, w.workspaceID())
173 if err != nil {
174 return nil, err
175 }
176 return protoToMessages(msgs), nil
177}
178
179// -- Agent --
180
181func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
182 return w.client.SendMessage(ctx, w.workspaceID(), sessionID, prompt, attachments...)
183}
184
185func (w *ClientWorkspace) AgentCancel(sessionID string) {
186 _ = w.client.CancelAgentSession(context.Background(), w.workspaceID(), sessionID)
187}
188
189func (w *ClientWorkspace) AgentIsBusy() bool {
190 info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
191 if err != nil {
192 return false
193 }
194 return info.IsBusy
195}
196
197func (w *ClientWorkspace) AgentIsSessionBusy(sessionID string) bool {
198 info, err := w.client.GetAgentSessionInfo(context.Background(), w.workspaceID(), sessionID)
199 if err != nil {
200 return false
201 }
202 return info.IsBusy
203}
204
205func (w *ClientWorkspace) AgentModel() AgentModel {
206 info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
207 if err != nil {
208 return AgentModel{}
209 }
210 return AgentModel{
211 CatwalkCfg: info.Model,
212 ModelCfg: info.ModelCfg,
213 }
214}
215
216func (w *ClientWorkspace) AgentIsReady() bool {
217 info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
218 if err != nil {
219 return false
220 }
221 return info.IsReady
222}
223
224func (w *ClientWorkspace) AgentQueuedPrompts(sessionID string) int {
225 count, err := w.client.GetAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
226 if err != nil {
227 return 0
228 }
229 return count
230}
231
232func (w *ClientWorkspace) AgentQueuedPromptsList(sessionID string) []string {
233 prompts, err := w.client.GetAgentSessionQueuedPromptsList(context.Background(), w.workspaceID(), sessionID)
234 if err != nil {
235 return nil
236 }
237 return prompts
238}
239
240func (w *ClientWorkspace) AgentClearQueue(sessionID string) {
241 _ = w.client.ClearAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
242}
243
244func (w *ClientWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
245 return w.client.AgentSummarizeSession(ctx, w.workspaceID(), sessionID)
246}
247
248func (w *ClientWorkspace) UpdateAgentModel(ctx context.Context) error {
249 return w.client.UpdateAgent(ctx, w.workspaceID())
250}
251
252func (w *ClientWorkspace) InitCoderAgent(ctx context.Context) error {
253 return w.client.InitiateAgentProcessing(ctx, w.workspaceID())
254}
255
256func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
257 model, err := w.client.GetDefaultSmallModel(context.Background(), w.workspaceID(), providerID)
258 if err != nil {
259 return config.SelectedModel{}
260 }
261 return *model
262}
263
264// -- Permissions --
265
266func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) bool {
267 resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
268 Permission: proto.PermissionRequest{
269 ID: perm.ID,
270 SessionID: perm.SessionID,
271 ToolCallID: perm.ToolCallID,
272 ToolName: perm.ToolName,
273 Description: perm.Description,
274 Action: perm.Action,
275 Path: perm.Path,
276 Params: perm.Params,
277 },
278 Action: proto.PermissionAllow,
279 })
280 return resolved
281}
282
283func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) bool {
284 resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
285 Permission: proto.PermissionRequest{
286 ID: perm.ID,
287 SessionID: perm.SessionID,
288 ToolCallID: perm.ToolCallID,
289 ToolName: perm.ToolName,
290 Description: perm.Description,
291 Action: perm.Action,
292 Path: perm.Path,
293 Params: perm.Params,
294 },
295 Action: proto.PermissionAllowForSession,
296 })
297 return resolved
298}
299
300func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) bool {
301 resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
302 Permission: proto.PermissionRequest{
303 ID: perm.ID,
304 SessionID: perm.SessionID,
305 ToolCallID: perm.ToolCallID,
306 ToolName: perm.ToolName,
307 Description: perm.Description,
308 Action: perm.Action,
309 Path: perm.Path,
310 Params: perm.Params,
311 },
312 Action: proto.PermissionDeny,
313 })
314 return resolved
315}
316
317func (w *ClientWorkspace) PermissionSkipRequests() bool {
318 skip, err := w.client.GetPermissionsSkipRequests(context.Background(), w.workspaceID())
319 if err != nil {
320 return false
321 }
322 return skip
323}
324
325func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) {
326 _ = w.client.SetPermissionsSkipRequests(context.Background(), w.workspaceID(), skip)
327}
328
329// -- FileTracker --
330
331func (w *ClientWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
332 _ = w.client.FileTrackerRecordRead(ctx, w.workspaceID(), sessionID, path)
333}
334
335func (w *ClientWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
336 t, err := w.client.FileTrackerLastReadTime(ctx, w.workspaceID(), sessionID, path)
337 if err != nil {
338 return time.Time{}
339 }
340 return t
341}
342
343func (w *ClientWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
344 return w.client.FileTrackerListReadFiles(ctx, w.workspaceID(), sessionID)
345}
346
347// -- History --
348
349func (w *ClientWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
350 files, err := w.client.ListSessionHistoryFiles(ctx, w.workspaceID(), sessionID)
351 if err != nil {
352 return nil, err
353 }
354 return protoToFiles(files), nil
355}
356
357// -- LSP --
358
359func (w *ClientWorkspace) LSPStart(ctx context.Context, path string) {
360 _ = w.client.LSPStart(ctx, w.workspaceID(), path)
361}
362
363func (w *ClientWorkspace) LSPStopAll(ctx context.Context) {
364 _ = w.client.LSPStopAll(ctx, w.workspaceID())
365}
366
367func (w *ClientWorkspace) LSPGetStates() map[string]LSPClientInfo {
368 states, err := w.client.GetLSPs(context.Background(), w.workspaceID())
369 if err != nil {
370 return nil
371 }
372 result := make(map[string]LSPClientInfo, len(states))
373 for k, v := range states {
374 result[k] = LSPClientInfo{
375 Name: v.Name,
376 State: v.State,
377 Error: v.Error,
378 DiagnosticCount: v.DiagnosticCount,
379 ConnectedAt: v.ConnectedAt,
380 }
381 }
382 return result
383}
384
385func (w *ClientWorkspace) LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts {
386 diags, err := w.client.GetLSPDiagnostics(context.Background(), w.workspaceID(), name)
387 if err != nil {
388 return lsp.DiagnosticCounts{}
389 }
390 var counts lsp.DiagnosticCounts
391 for _, fileDiags := range diags {
392 for _, d := range fileDiags {
393 switch d.Severity {
394 case protocol.SeverityError:
395 counts.Error++
396 case protocol.SeverityWarning:
397 counts.Warning++
398 case protocol.SeverityInformation:
399 counts.Information++
400 case protocol.SeverityHint:
401 counts.Hint++
402 }
403 }
404 }
405 return counts
406}
407
408// -- Config (read-only) --
409
410func (w *ClientWorkspace) Config() *config.Config {
411 return w.cached().Config
412}
413
414func (w *ClientWorkspace) WorkingDir() string {
415 return w.cached().Path
416}
417
418func (w *ClientWorkspace) Resolver() config.VariableResolver {
419 return config.IdentityResolver()
420}
421
422// -- Config mutations --
423
424func (w *ClientWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
425 err := w.client.UpdatePreferredModel(context.Background(), w.workspaceID(), scope, modelType, model)
426 if err == nil {
427 w.refreshWorkspace()
428 }
429 return err
430}
431
432func (w *ClientWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
433 err := w.client.SetCompactMode(context.Background(), w.workspaceID(), scope, enabled)
434 if err == nil {
435 w.refreshWorkspace()
436 }
437 return err
438}
439
440func (w *ClientWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
441 err := w.client.SetProviderAPIKey(context.Background(), w.workspaceID(), scope, providerID, apiKey)
442 if err == nil {
443 w.refreshWorkspace()
444 }
445 return err
446}
447
448func (w *ClientWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
449 err := w.client.SetConfigField(context.Background(), w.workspaceID(), scope, key, value)
450 if err == nil {
451 w.refreshWorkspace()
452 }
453 return err
454}
455
456func (w *ClientWorkspace) RemoveConfigField(scope config.Scope, key string) error {
457 err := w.client.RemoveConfigField(context.Background(), w.workspaceID(), scope, key)
458 if err == nil {
459 w.refreshWorkspace()
460 }
461 return err
462}
463
464func (w *ClientWorkspace) ImportCopilot() (*oauth.Token, bool) {
465 token, ok, err := w.client.ImportCopilot(context.Background(), w.workspaceID())
466 if err != nil {
467 return nil, false
468 }
469 if ok {
470 w.refreshWorkspace()
471 }
472 return token, ok
473}
474
475func (w *ClientWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
476 err := w.client.RefreshOAuthToken(ctx, w.workspaceID(), scope, providerID)
477 if err == nil {
478 w.refreshWorkspace()
479 }
480 return err
481}
482
483// -- Project lifecycle --
484
485func (w *ClientWorkspace) ProjectNeedsInitialization() (bool, error) {
486 return w.client.ProjectNeedsInitialization(context.Background(), w.workspaceID())
487}
488
489func (w *ClientWorkspace) MarkProjectInitialized() error {
490 return w.client.MarkProjectInitialized(context.Background(), w.workspaceID())
491}
492
493func (w *ClientWorkspace) InitializePrompt() (string, error) {
494 return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
495}
496
497func (w *ClientWorkspace) ListSkills(ctx context.Context) ([]skills.CatalogEntry, error) {
498 entries, err := w.client.ListSkills(ctx, w.workspaceID())
499 if err != nil {
500 return nil, err
501 }
502 result := make([]skills.CatalogEntry, len(entries))
503 for i, entry := range entries {
504 result[i] = skills.CatalogEntry{
505 ID: entry.ID,
506 Name: entry.Name,
507 Description: entry.Description,
508 Label: entry.Label,
509 Source: skills.SourceType(entry.Source),
510 }
511 }
512 return result, nil
513}
514
515func (w *ClientWorkspace) ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
516 resp, err := w.client.ReadSkill(ctx, w.workspaceID(), skillID)
517 if err != nil {
518 return nil, skills.SkillReadResult{}, err
519 }
520 return resp.Content, skills.SkillReadResult{
521 Name: resp.Result.Name,
522 Description: resp.Result.Description,
523 Source: skills.SourceType(resp.Result.Source),
524 Builtin: resp.Result.Builtin,
525 }, nil
526}
527
528// -- MCP operations --
529
530func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
531 states, err := w.client.MCPGetStates(context.Background(), w.workspaceID())
532 if err != nil {
533 return nil
534 }
535 result := make(map[string]mcp.ClientInfo, len(states))
536 for k, v := range states {
537 result[k] = mcp.ClientInfo{
538 Name: v.Name,
539 State: mcp.State(v.State),
540 Error: v.Error,
541 Counts: mcp.Counts{
542 Tools: v.ToolCount,
543 Prompts: v.PromptCount,
544 Resources: v.ResourceCount,
545 },
546 ConnectedAt: v.ConnectedAt,
547 }
548 }
549 return result
550}
551
552func (w *ClientWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
553 _ = w.client.MCPRefreshPrompts(ctx, w.workspaceID(), name)
554}
555
556func (w *ClientWorkspace) MCPRefreshResources(ctx context.Context, name string) {
557 _ = w.client.MCPRefreshResources(ctx, w.workspaceID(), name)
558}
559
560func (w *ClientWorkspace) RefreshMCPTools(ctx context.Context, name string) {
561 _ = w.client.RefreshMCPTools(ctx, w.workspaceID(), name)
562}
563
564func (w *ClientWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
565 contents, err := w.client.ReadMCPResource(ctx, w.workspaceID(), name, uri)
566 if err != nil {
567 return nil, err
568 }
569 result := make([]MCPResourceContents, len(contents))
570 for i, c := range contents {
571 result[i] = MCPResourceContents{
572 URI: c.URI,
573 MIMEType: c.MIMEType,
574 Text: c.Text,
575 Blob: c.Blob,
576 }
577 }
578 return result, nil
579}
580
581func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
582 return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
583}
584
585func (w *ClientWorkspace) EnableDockerMCP(ctx context.Context) error {
586 return w.client.EnableDockerMCP(ctx, w.workspaceID())
587}
588
589func (w *ClientWorkspace) DisableDockerMCP() error {
590 return w.client.DisableDockerMCP(context.Background(), w.workspaceID())
591}
592
593// -- Lifecycle --
594
595func (w *ClientWorkspace) Subscribe(program *tea.Program) {
596 defer log.RecoverPanic("ClientWorkspace.Subscribe", func() {
597 slog.Info("TUI subscription panic: attempting graceful shutdown")
598 program.Quit()
599 })
600
601 evc, err := w.client.SubscribeEvents(context.Background(), w.workspaceID())
602 if err != nil {
603 slog.Error("Failed to subscribe to events", "error", err)
604 return
605 }
606
607 w.consumeEvents(evc, program.Send)
608}
609
610// consumeEvents drives the workspace event loop. It is split out from
611// Subscribe so tests can drive it without a real *tea.Program.
612// ConfigChanged events trigger a workspace refresh; all other events
613// are translated into domain types and forwarded to send.
614func (w *ClientWorkspace) consumeEvents(evc <-chan any, send func(tea.Msg)) {
615 for ev := range evc {
616 if _, ok := ev.(pubsub.Event[proto.ConfigChanged]); ok {
617 w.refreshWorkspace()
618 continue
619 }
620 translated := w.translateEvent(ev)
621 if translated != nil && send != nil {
622 send(translated)
623 }
624 }
625}
626
627func (w *ClientWorkspace) Shutdown() {
628 _ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
629}
630
631// translateEvent converts proto-typed SSE events into the domain types
632// that the TUI's Update() method expects. Skills events also update the
633// process-local skills.Manager so callers reading
634// skills.GetLatestStates see fresh data.
635func (w *ClientWorkspace) translateEvent(ev any) tea.Msg {
636 switch e := ev.(type) {
637 case pubsub.Event[proto.LSPEvent]:
638 return pubsub.Event[LSPEvent]{
639 Type: e.Type,
640 Payload: LSPEvent{
641 Type: LSPEventType(e.Payload.Type),
642 Name: e.Payload.Name,
643 State: e.Payload.State,
644 Error: e.Payload.Error,
645 DiagnosticCount: e.Payload.DiagnosticCount,
646 },
647 }
648 case pubsub.Event[proto.MCPEvent]:
649 return pubsub.Event[mcp.Event]{
650 Type: e.Type,
651 Payload: mcp.Event{
652 Type: protoToMCPEventType(e.Payload.Type),
653 Name: e.Payload.Name,
654 State: mcp.State(e.Payload.State),
655 Error: e.Payload.Error,
656 Counts: mcp.Counts{
657 Tools: e.Payload.ToolCount,
658 Prompts: e.Payload.PromptCount,
659 Resources: e.Payload.ResourceCount,
660 },
661 },
662 }
663 case pubsub.Event[proto.PermissionRequest]:
664 return pubsub.Event[permission.PermissionRequest]{
665 Type: e.Type,
666 Payload: permission.PermissionRequest{
667 ID: e.Payload.ID,
668 SessionID: e.Payload.SessionID,
669 ToolCallID: e.Payload.ToolCallID,
670 ToolName: e.Payload.ToolName,
671 Description: e.Payload.Description,
672 Action: e.Payload.Action,
673 Path: e.Payload.Path,
674 Params: e.Payload.Params,
675 },
676 }
677 case pubsub.Event[proto.PermissionNotification]:
678 return pubsub.Event[permission.PermissionNotification]{
679 Type: e.Type,
680 Payload: permission.PermissionNotification{
681 ToolCallID: e.Payload.ToolCallID,
682 Granted: e.Payload.Granted,
683 Denied: e.Payload.Denied,
684 },
685 }
686 case pubsub.Event[proto.Message]:
687 return pubsub.Event[message.Message]{
688 Type: e.Type,
689 Payload: protoToMessage(e.Payload),
690 }
691 case pubsub.Event[proto.Session]:
692 return pubsub.Event[session.Session]{
693 Type: e.Type,
694 Payload: protoToSession(e.Payload),
695 }
696 case pubsub.Event[proto.File]:
697 return pubsub.Event[history.File]{
698 Type: e.Type,
699 Payload: protoToFile(e.Payload),
700 }
701 case pubsub.Event[proto.AgentEvent]:
702 return pubsub.Event[notify.Notification]{
703 Type: e.Type,
704 Payload: notify.Notification{
705 SessionID: e.Payload.SessionID,
706 SessionTitle: e.Payload.SessionTitle,
707 Type: notify.Type(e.Payload.Type),
708 },
709 }
710 case pubsub.Event[proto.SkillsEvent]:
711 states := protoToSkillStates(e.Payload.States)
712 if w.skills != nil {
713 w.skills.SetLatestStates(states)
714 }
715 return pubsub.Event[skills.Event]{
716 Type: e.Type,
717 Payload: skills.Event{States: states},
718 }
719 default:
720 slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
721 return nil
722 }
723}
724
725func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
726 switch t {
727 case proto.MCPEventStateChanged:
728 return mcp.EventStateChanged
729 case proto.MCPEventToolsListChanged:
730 return mcp.EventToolsListChanged
731 case proto.MCPEventPromptsListChanged:
732 return mcp.EventPromptsListChanged
733 case proto.MCPEventResourcesListChanged:
734 return mcp.EventResourcesListChanged
735 default:
736 return mcp.EventStateChanged
737 }
738}
739
740// protoToSession converts a wire-level proto.Session into the domain
741// session.Session. Fields that exist only on the wire (computed-on-read
742// signals like IsBusy, and any future presence counters) are
743// intentionally dropped here: session.Session models persisted state,
744// not transient runtime signals. UI features that need those signals
745// should either extend session.Session or read them from the proto
746// payload directly before this conversion runs.
747func protoToSession(s proto.Session) session.Session {
748 return session.Session{
749 ID: s.ID,
750 ParentSessionID: s.ParentSessionID,
751 Title: s.Title,
752 SummaryMessageID: s.SummaryMessageID,
753 MessageCount: s.MessageCount,
754 PromptTokens: s.PromptTokens,
755 CompletionTokens: s.CompletionTokens,
756 Cost: s.Cost,
757 Todos: protoToTodos(s.Todos),
758 CreatedAt: s.CreatedAt,
759 UpdatedAt: s.UpdatedAt,
760 }
761}
762
763func protoToTodos(todos []proto.Todo) []session.Todo {
764 if len(todos) == 0 {
765 return nil
766 }
767 out := make([]session.Todo, len(todos))
768 for i, t := range todos {
769 out[i] = session.Todo{
770 Content: t.Content,
771 Status: session.TodoStatus(t.Status),
772 ActiveForm: t.ActiveForm,
773 }
774 }
775 return out
776}
777
778func protoToFile(f proto.File) history.File {
779 return history.File{
780 ID: f.ID,
781 SessionID: f.SessionID,
782 Path: f.Path,
783 Content: f.Content,
784 Version: f.Version,
785 CreatedAt: f.CreatedAt,
786 UpdatedAt: f.UpdatedAt,
787 }
788}
789
790func protoToMessage(m proto.Message) message.Message {
791 msg := message.Message{
792 ID: m.ID,
793 SessionID: m.SessionID,
794 Role: message.MessageRole(m.Role),
795 Model: m.Model,
796 Provider: m.Provider,
797 CreatedAt: m.CreatedAt,
798 UpdatedAt: m.UpdatedAt,
799 }
800
801 for _, p := range m.Parts {
802 switch v := p.(type) {
803 case proto.TextContent:
804 msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
805 case proto.ReasoningContent:
806 msg.Parts = append(msg.Parts, message.ReasoningContent{
807 Thinking: v.Thinking,
808 Signature: v.Signature,
809 StartedAt: v.StartedAt,
810 FinishedAt: v.FinishedAt,
811 })
812 case proto.ToolCall:
813 msg.Parts = append(msg.Parts, message.ToolCall{
814 ID: v.ID,
815 Name: v.Name,
816 Input: v.Input,
817 Finished: v.Finished,
818 })
819 case proto.ToolResult:
820 msg.Parts = append(msg.Parts, message.ToolResult{
821 ToolCallID: v.ToolCallID,
822 Name: v.Name,
823 Content: v.Content,
824 Data: v.Data,
825 MIMEType: v.MIMEType,
826 Metadata: v.Metadata,
827 IsError: v.IsError,
828 })
829 case proto.Finish:
830 msg.Parts = append(msg.Parts, message.Finish{
831 Reason: message.FinishReason(v.Reason),
832 Time: v.Time,
833 Message: v.Message,
834 Details: v.Details,
835 })
836 case proto.ImageURLContent:
837 msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
838 case proto.BinaryContent:
839 msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
840 }
841 }
842
843 return msg
844}
845
846func protoToMessages(msgs []proto.Message) []message.Message {
847 out := make([]message.Message, len(msgs))
848 for i, m := range msgs {
849 out[i] = protoToMessage(m)
850 }
851 return out
852}
853
854func protoToFiles(files []proto.File) []history.File {
855 out := make([]history.File, len(files))
856 for i, f := range files {
857 out[i] = protoToFile(f)
858 }
859 return out
860}
861
862func sessionToProto(s session.Session) proto.Session {
863 return proto.Session{
864 ID: s.ID,
865 ParentSessionID: s.ParentSessionID,
866 Title: s.Title,
867 SummaryMessageID: s.SummaryMessageID,
868 MessageCount: s.MessageCount,
869 PromptTokens: s.PromptTokens,
870 CompletionTokens: s.CompletionTokens,
871 Cost: s.Cost,
872 Todos: todosToProto(s.Todos),
873 CreatedAt: s.CreatedAt,
874 UpdatedAt: s.UpdatedAt,
875 }
876}
877
878// protoToSkillStates reconstructs internal skill state slices from
879// their wire representation. Non-empty Error strings are turned into
880// synthetic error values; the TUI never type-asserts on Err.
881func protoToSkillStates(in []proto.SkillState) []*skills.SkillState {
882 if len(in) == 0 {
883 return nil
884 }
885 out := make([]*skills.SkillState, len(in))
886 for i, s := range in {
887 state := &skills.SkillState{
888 Name: s.Name,
889 Path: s.Path,
890 State: skills.DiscoveryState(s.State),
891 }
892 if s.Error != "" {
893 state.Err = errors.New(s.Error)
894 }
895 out[i] = state
896 }
897 return out
898}
899
900func todosToProto(todos []session.Todo) []proto.Todo {
901 if len(todos) == 0 {
902 return nil
903 }
904 out := make([]proto.Todo, len(todos))
905 for i, t := range todos {
906 out[i] = proto.Todo{
907 Content: t.Content,
908 Status: string(t.Status),
909 ActiveForm: t.ActiveForm,
910 }
911 }
912 return out
913}