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.ws.ID)
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// -- Messages --
146
147func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
148 msgs, err := w.client.ListMessages(ctx, w.workspaceID(), sessionID)
149 if err != nil {
150 return nil, err
151 }
152 return protoToMessages(msgs), nil
153}
154
155func (w *ClientWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
156 msgs, err := w.client.ListUserMessages(ctx, w.workspaceID(), sessionID)
157 if err != nil {
158 return nil, err
159 }
160 return protoToMessages(msgs), nil
161}
162
163func (w *ClientWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
164 msgs, err := w.client.ListAllUserMessages(ctx, w.workspaceID())
165 if err != nil {
166 return nil, err
167 }
168 return protoToMessages(msgs), nil
169}
170
171// -- Agent --
172
173func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
174 return w.client.SendMessage(ctx, w.workspaceID(), sessionID, prompt, attachments...)
175}
176
177func (w *ClientWorkspace) AgentCancel(sessionID string) {
178 _ = w.client.CancelAgentSession(context.Background(), w.workspaceID(), sessionID)
179}
180
181func (w *ClientWorkspace) AgentIsBusy() bool {
182 info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
183 if err != nil {
184 return false
185 }
186 return info.IsBusy
187}
188
189func (w *ClientWorkspace) AgentIsSessionBusy(sessionID string) bool {
190 info, err := w.client.GetAgentSessionInfo(context.Background(), w.workspaceID(), sessionID)
191 if err != nil {
192 return false
193 }
194 return info.IsBusy
195}
196
197func (w *ClientWorkspace) AgentModel() AgentModel {
198 info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
199 if err != nil {
200 return AgentModel{}
201 }
202 return AgentModel{
203 CatwalkCfg: info.Model,
204 ModelCfg: info.ModelCfg,
205 }
206}
207
208func (w *ClientWorkspace) AgentIsReady() bool {
209 info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
210 if err != nil {
211 return false
212 }
213 return info.IsReady
214}
215
216func (w *ClientWorkspace) AgentQueuedPrompts(sessionID string) int {
217 count, err := w.client.GetAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
218 if err != nil {
219 return 0
220 }
221 return count
222}
223
224func (w *ClientWorkspace) AgentQueuedPromptsList(sessionID string) []string {
225 prompts, err := w.client.GetAgentSessionQueuedPromptsList(context.Background(), w.workspaceID(), sessionID)
226 if err != nil {
227 return nil
228 }
229 return prompts
230}
231
232func (w *ClientWorkspace) AgentClearQueue(sessionID string) {
233 _ = w.client.ClearAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
234}
235
236func (w *ClientWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
237 return w.client.AgentSummarizeSession(ctx, w.workspaceID(), sessionID)
238}
239
240func (w *ClientWorkspace) UpdateAgentModel(ctx context.Context) error {
241 return w.client.UpdateAgent(ctx, w.workspaceID())
242}
243
244func (w *ClientWorkspace) InitCoderAgent(ctx context.Context) error {
245 return w.client.InitiateAgentProcessing(ctx, w.workspaceID())
246}
247
248func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
249 model, err := w.client.GetDefaultSmallModel(context.Background(), w.workspaceID(), providerID)
250 if err != nil {
251 return config.SelectedModel{}
252 }
253 return *model
254}
255
256// -- Permissions --
257
258func (w *ClientWorkspace) PermissionGrant(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.PermissionAllowForSession,
271 })
272}
273
274func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
275 _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
276 Permission: proto.PermissionRequest{
277 ID: perm.ID,
278 SessionID: perm.SessionID,
279 ToolCallID: perm.ToolCallID,
280 ToolName: perm.ToolName,
281 Description: perm.Description,
282 Action: perm.Action,
283 Path: perm.Path,
284 Params: perm.Params,
285 },
286 Action: proto.PermissionAllow,
287 })
288}
289
290func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) {
291 _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
292 Permission: proto.PermissionRequest{
293 ID: perm.ID,
294 SessionID: perm.SessionID,
295 ToolCallID: perm.ToolCallID,
296 ToolName: perm.ToolName,
297 Description: perm.Description,
298 Action: perm.Action,
299 Path: perm.Path,
300 Params: perm.Params,
301 },
302 Action: proto.PermissionDeny,
303 })
304}
305
306func (w *ClientWorkspace) PermissionSkipRequests() bool {
307 skip, err := w.client.GetPermissionsSkipRequests(context.Background(), w.workspaceID())
308 if err != nil {
309 return false
310 }
311 return skip
312}
313
314func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) {
315 _ = w.client.SetPermissionsSkipRequests(context.Background(), w.workspaceID(), skip)
316}
317
318// -- FileTracker --
319
320func (w *ClientWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
321 _ = w.client.FileTrackerRecordRead(ctx, w.workspaceID(), sessionID, path)
322}
323
324func (w *ClientWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
325 t, err := w.client.FileTrackerLastReadTime(ctx, w.workspaceID(), sessionID, path)
326 if err != nil {
327 return time.Time{}
328 }
329 return t
330}
331
332func (w *ClientWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
333 return w.client.FileTrackerListReadFiles(ctx, w.workspaceID(), sessionID)
334}
335
336// -- History --
337
338func (w *ClientWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
339 files, err := w.client.ListSessionHistoryFiles(ctx, w.workspaceID(), sessionID)
340 if err != nil {
341 return nil, err
342 }
343 return protoToFiles(files), nil
344}
345
346// -- LSP --
347
348func (w *ClientWorkspace) LSPStart(ctx context.Context, path string) {
349 _ = w.client.LSPStart(ctx, w.workspaceID(), path)
350}
351
352func (w *ClientWorkspace) LSPStopAll(ctx context.Context) {
353 _ = w.client.LSPStopAll(ctx, w.workspaceID())
354}
355
356func (w *ClientWorkspace) LSPGetStates() map[string]LSPClientInfo {
357 states, err := w.client.GetLSPs(context.Background(), w.workspaceID())
358 if err != nil {
359 return nil
360 }
361 result := make(map[string]LSPClientInfo, len(states))
362 for k, v := range states {
363 result[k] = LSPClientInfo{
364 Name: v.Name,
365 State: v.State,
366 Error: v.Error,
367 DiagnosticCount: v.DiagnosticCount,
368 ConnectedAt: v.ConnectedAt,
369 }
370 }
371 return result
372}
373
374func (w *ClientWorkspace) LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts {
375 diags, err := w.client.GetLSPDiagnostics(context.Background(), w.workspaceID(), name)
376 if err != nil {
377 return lsp.DiagnosticCounts{}
378 }
379 var counts lsp.DiagnosticCounts
380 for _, fileDiags := range diags {
381 for _, d := range fileDiags {
382 switch d.Severity {
383 case protocol.SeverityError:
384 counts.Error++
385 case protocol.SeverityWarning:
386 counts.Warning++
387 case protocol.SeverityInformation:
388 counts.Information++
389 case protocol.SeverityHint:
390 counts.Hint++
391 }
392 }
393 }
394 return counts
395}
396
397// -- Config (read-only) --
398
399func (w *ClientWorkspace) Config() *config.Config {
400 return w.cached().Config
401}
402
403func (w *ClientWorkspace) WorkingDir() string {
404 return w.cached().Path
405}
406
407func (w *ClientWorkspace) Resolver() config.VariableResolver {
408 return config.IdentityResolver()
409}
410
411// -- Config mutations --
412
413func (w *ClientWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
414 err := w.client.UpdatePreferredModel(context.Background(), w.workspaceID(), scope, modelType, model)
415 if err == nil {
416 w.refreshWorkspace()
417 }
418 return err
419}
420
421func (w *ClientWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
422 err := w.client.SetCompactMode(context.Background(), w.workspaceID(), scope, enabled)
423 if err == nil {
424 w.refreshWorkspace()
425 }
426 return err
427}
428
429func (w *ClientWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
430 err := w.client.SetProviderAPIKey(context.Background(), w.workspaceID(), scope, providerID, apiKey)
431 if err == nil {
432 w.refreshWorkspace()
433 }
434 return err
435}
436
437func (w *ClientWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
438 err := w.client.SetConfigField(context.Background(), w.workspaceID(), scope, key, value)
439 if err == nil {
440 w.refreshWorkspace()
441 }
442 return err
443}
444
445func (w *ClientWorkspace) RemoveConfigField(scope config.Scope, key string) error {
446 err := w.client.RemoveConfigField(context.Background(), w.workspaceID(), scope, key)
447 if err == nil {
448 w.refreshWorkspace()
449 }
450 return err
451}
452
453func (w *ClientWorkspace) ImportCopilot() (*oauth.Token, bool) {
454 token, ok, err := w.client.ImportCopilot(context.Background(), w.workspaceID())
455 if err != nil {
456 return nil, false
457 }
458 if ok {
459 w.refreshWorkspace()
460 }
461 return token, ok
462}
463
464func (w *ClientWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
465 err := w.client.RefreshOAuthToken(ctx, w.workspaceID(), scope, providerID)
466 if err == nil {
467 w.refreshWorkspace()
468 }
469 return err
470}
471
472// -- Project lifecycle --
473
474func (w *ClientWorkspace) ProjectNeedsInitialization() (bool, error) {
475 return w.client.ProjectNeedsInitialization(context.Background(), w.workspaceID())
476}
477
478func (w *ClientWorkspace) MarkProjectInitialized() error {
479 return w.client.MarkProjectInitialized(context.Background(), w.workspaceID())
480}
481
482func (w *ClientWorkspace) InitializePrompt() (string, error) {
483 return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
484}
485
486func (w *ClientWorkspace) ListSkills(ctx context.Context) ([]skills.CatalogEntry, error) {
487 entries, err := w.client.ListSkills(ctx, w.workspaceID())
488 if err != nil {
489 return nil, err
490 }
491 result := make([]skills.CatalogEntry, len(entries))
492 for i, entry := range entries {
493 result[i] = skills.CatalogEntry{
494 ID: entry.ID,
495 Name: entry.Name,
496 Description: entry.Description,
497 Label: entry.Label,
498 Source: skills.SourceType(entry.Source),
499 }
500 }
501 return result, nil
502}
503
504func (w *ClientWorkspace) ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
505 resp, err := w.client.ReadSkill(ctx, w.workspaceID(), skillID)
506 if err != nil {
507 return nil, skills.SkillReadResult{}, err
508 }
509 return resp.Content, skills.SkillReadResult{
510 Name: resp.Result.Name,
511 Description: resp.Result.Description,
512 Source: skills.SourceType(resp.Result.Source),
513 Builtin: resp.Result.Builtin,
514 }, nil
515}
516
517// -- MCP operations --
518
519func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
520 states, err := w.client.MCPGetStates(context.Background(), w.workspaceID())
521 if err != nil {
522 return nil
523 }
524 result := make(map[string]mcp.ClientInfo, len(states))
525 for k, v := range states {
526 result[k] = mcp.ClientInfo{
527 Name: v.Name,
528 State: mcp.State(v.State),
529 Error: v.Error,
530 Counts: mcp.Counts{
531 Tools: v.ToolCount,
532 Prompts: v.PromptCount,
533 Resources: v.ResourceCount,
534 },
535 ConnectedAt: v.ConnectedAt,
536 }
537 }
538 return result
539}
540
541func (w *ClientWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
542 _ = w.client.MCPRefreshPrompts(ctx, w.workspaceID(), name)
543}
544
545func (w *ClientWorkspace) MCPRefreshResources(ctx context.Context, name string) {
546 _ = w.client.MCPRefreshResources(ctx, w.workspaceID(), name)
547}
548
549func (w *ClientWorkspace) RefreshMCPTools(ctx context.Context, name string) {
550 _ = w.client.RefreshMCPTools(ctx, w.workspaceID(), name)
551}
552
553func (w *ClientWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
554 contents, err := w.client.ReadMCPResource(ctx, w.workspaceID(), name, uri)
555 if err != nil {
556 return nil, err
557 }
558 result := make([]MCPResourceContents, len(contents))
559 for i, c := range contents {
560 result[i] = MCPResourceContents{
561 URI: c.URI,
562 MIMEType: c.MIMEType,
563 Text: c.Text,
564 Blob: c.Blob,
565 }
566 }
567 return result, nil
568}
569
570func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
571 return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
572}
573
574func (w *ClientWorkspace) EnableDockerMCP(ctx context.Context) error {
575 return w.client.EnableDockerMCP(ctx, w.workspaceID())
576}
577
578func (w *ClientWorkspace) DisableDockerMCP() error {
579 return w.client.DisableDockerMCP(context.Background(), w.workspaceID())
580}
581
582// -- Lifecycle --
583
584func (w *ClientWorkspace) Subscribe(program *tea.Program) {
585 defer log.RecoverPanic("ClientWorkspace.Subscribe", func() {
586 slog.Info("TUI subscription panic: attempting graceful shutdown")
587 program.Quit()
588 })
589
590 evc, err := w.client.SubscribeEvents(context.Background(), w.workspaceID())
591 if err != nil {
592 slog.Error("Failed to subscribe to events", "error", err)
593 return
594 }
595
596 for ev := range evc {
597 translated := w.translateEvent(ev)
598 if translated != nil {
599 program.Send(translated)
600 }
601 }
602}
603
604func (w *ClientWorkspace) Shutdown() {
605 _ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
606}
607
608// translateEvent converts proto-typed SSE events into the domain types
609// that the TUI's Update() method expects. Skills events also update the
610// process-local skills.Manager so callers reading
611// skills.GetLatestStates see fresh data.
612func (w *ClientWorkspace) translateEvent(ev any) tea.Msg {
613 switch e := ev.(type) {
614 case pubsub.Event[proto.LSPEvent]:
615 return pubsub.Event[LSPEvent]{
616 Type: e.Type,
617 Payload: LSPEvent{
618 Type: LSPEventType(e.Payload.Type),
619 Name: e.Payload.Name,
620 State: e.Payload.State,
621 Error: e.Payload.Error,
622 DiagnosticCount: e.Payload.DiagnosticCount,
623 },
624 }
625 case pubsub.Event[proto.MCPEvent]:
626 return pubsub.Event[mcp.Event]{
627 Type: e.Type,
628 Payload: mcp.Event{
629 Type: protoToMCPEventType(e.Payload.Type),
630 Name: e.Payload.Name,
631 State: mcp.State(e.Payload.State),
632 Error: e.Payload.Error,
633 Counts: mcp.Counts{
634 Tools: e.Payload.ToolCount,
635 Prompts: e.Payload.PromptCount,
636 Resources: e.Payload.ResourceCount,
637 },
638 },
639 }
640 case pubsub.Event[proto.PermissionRequest]:
641 return pubsub.Event[permission.PermissionRequest]{
642 Type: e.Type,
643 Payload: permission.PermissionRequest{
644 ID: e.Payload.ID,
645 SessionID: e.Payload.SessionID,
646 ToolCallID: e.Payload.ToolCallID,
647 ToolName: e.Payload.ToolName,
648 Description: e.Payload.Description,
649 Action: e.Payload.Action,
650 Path: e.Payload.Path,
651 Params: e.Payload.Params,
652 },
653 }
654 case pubsub.Event[proto.PermissionNotification]:
655 return pubsub.Event[permission.PermissionNotification]{
656 Type: e.Type,
657 Payload: permission.PermissionNotification{
658 ToolCallID: e.Payload.ToolCallID,
659 Granted: e.Payload.Granted,
660 Denied: e.Payload.Denied,
661 },
662 }
663 case pubsub.Event[proto.Message]:
664 return pubsub.Event[message.Message]{
665 Type: e.Type,
666 Payload: protoToMessage(e.Payload),
667 }
668 case pubsub.Event[proto.Session]:
669 return pubsub.Event[session.Session]{
670 Type: e.Type,
671 Payload: protoToSession(e.Payload),
672 }
673 case pubsub.Event[proto.File]:
674 return pubsub.Event[history.File]{
675 Type: e.Type,
676 Payload: protoToFile(e.Payload),
677 }
678 case pubsub.Event[proto.AgentEvent]:
679 return pubsub.Event[notify.Notification]{
680 Type: e.Type,
681 Payload: notify.Notification{
682 SessionID: e.Payload.SessionID,
683 SessionTitle: e.Payload.SessionTitle,
684 Type: notify.Type(e.Payload.Type),
685 },
686 }
687 case pubsub.Event[proto.SkillsEvent]:
688 states := protoToSkillStates(e.Payload.States)
689 if w.skills != nil {
690 w.skills.SetLatestStates(states)
691 }
692 return pubsub.Event[skills.Event]{
693 Type: e.Type,
694 Payload: skills.Event{States: states},
695 }
696 default:
697 slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
698 return nil
699 }
700}
701
702func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
703 switch t {
704 case proto.MCPEventStateChanged:
705 return mcp.EventStateChanged
706 case proto.MCPEventToolsListChanged:
707 return mcp.EventToolsListChanged
708 case proto.MCPEventPromptsListChanged:
709 return mcp.EventPromptsListChanged
710 case proto.MCPEventResourcesListChanged:
711 return mcp.EventResourcesListChanged
712 default:
713 return mcp.EventStateChanged
714 }
715}
716
717func protoToSession(s proto.Session) session.Session {
718 return session.Session{
719 ID: s.ID,
720 ParentSessionID: s.ParentSessionID,
721 Title: s.Title,
722 SummaryMessageID: s.SummaryMessageID,
723 MessageCount: s.MessageCount,
724 PromptTokens: s.PromptTokens,
725 CompletionTokens: s.CompletionTokens,
726 Cost: s.Cost,
727 Todos: protoToTodos(s.Todos),
728 CreatedAt: s.CreatedAt,
729 UpdatedAt: s.UpdatedAt,
730 }
731}
732
733func protoToTodos(todos []proto.Todo) []session.Todo {
734 if len(todos) == 0 {
735 return nil
736 }
737 out := make([]session.Todo, len(todos))
738 for i, t := range todos {
739 out[i] = session.Todo{
740 Content: t.Content,
741 Status: session.TodoStatus(t.Status),
742 ActiveForm: t.ActiveForm,
743 }
744 }
745 return out
746}
747
748func protoToFile(f proto.File) history.File {
749 return history.File{
750 ID: f.ID,
751 SessionID: f.SessionID,
752 Path: f.Path,
753 Content: f.Content,
754 Version: f.Version,
755 CreatedAt: f.CreatedAt,
756 UpdatedAt: f.UpdatedAt,
757 }
758}
759
760func protoToMessage(m proto.Message) message.Message {
761 msg := message.Message{
762 ID: m.ID,
763 SessionID: m.SessionID,
764 Role: message.MessageRole(m.Role),
765 Model: m.Model,
766 Provider: m.Provider,
767 CreatedAt: m.CreatedAt,
768 UpdatedAt: m.UpdatedAt,
769 }
770
771 for _, p := range m.Parts {
772 switch v := p.(type) {
773 case proto.TextContent:
774 msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
775 case proto.ReasoningContent:
776 msg.Parts = append(msg.Parts, message.ReasoningContent{
777 Thinking: v.Thinking,
778 Signature: v.Signature,
779 StartedAt: v.StartedAt,
780 FinishedAt: v.FinishedAt,
781 })
782 case proto.ToolCall:
783 msg.Parts = append(msg.Parts, message.ToolCall{
784 ID: v.ID,
785 Name: v.Name,
786 Input: v.Input,
787 Finished: v.Finished,
788 })
789 case proto.ToolResult:
790 msg.Parts = append(msg.Parts, message.ToolResult{
791 ToolCallID: v.ToolCallID,
792 Name: v.Name,
793 Content: v.Content,
794 Data: v.Data,
795 MIMEType: v.MIMEType,
796 Metadata: v.Metadata,
797 IsError: v.IsError,
798 })
799 case proto.Finish:
800 msg.Parts = append(msg.Parts, message.Finish{
801 Reason: message.FinishReason(v.Reason),
802 Time: v.Time,
803 Message: v.Message,
804 Details: v.Details,
805 })
806 case proto.ImageURLContent:
807 msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
808 case proto.BinaryContent:
809 msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
810 }
811 }
812
813 return msg
814}
815
816func protoToMessages(msgs []proto.Message) []message.Message {
817 out := make([]message.Message, len(msgs))
818 for i, m := range msgs {
819 out[i] = protoToMessage(m)
820 }
821 return out
822}
823
824func protoToFiles(files []proto.File) []history.File {
825 out := make([]history.File, len(files))
826 for i, f := range files {
827 out[i] = protoToFile(f)
828 }
829 return out
830}
831
832func sessionToProto(s session.Session) proto.Session {
833 return proto.Session{
834 ID: s.ID,
835 ParentSessionID: s.ParentSessionID,
836 Title: s.Title,
837 SummaryMessageID: s.SummaryMessageID,
838 MessageCount: s.MessageCount,
839 PromptTokens: s.PromptTokens,
840 CompletionTokens: s.CompletionTokens,
841 Cost: s.Cost,
842 Todos: todosToProto(s.Todos),
843 CreatedAt: s.CreatedAt,
844 UpdatedAt: s.UpdatedAt,
845 }
846}
847
848// protoToSkillStates reconstructs internal skill state slices from
849// their wire representation. Non-empty Error strings are turned into
850// synthetic error values; the TUI never type-asserts on Err.
851func protoToSkillStates(in []proto.SkillState) []*skills.SkillState {
852 if len(in) == 0 {
853 return nil
854 }
855 out := make([]*skills.SkillState, len(in))
856 for i, s := range in {
857 state := &skills.SkillState{
858 Name: s.Name,
859 Path: s.Path,
860 State: skills.DiscoveryState(s.State),
861 }
862 if s.Error != "" {
863 state.Err = errors.New(s.Error)
864 }
865 out[i] = state
866 }
867 return out
868}
869
870func todosToProto(todos []session.Todo) []proto.Todo {
871 if len(todos) == 0 {
872 return nil
873 }
874 out := make([]proto.Todo, len(todos))
875 for i, t := range todos {
876 out[i] = proto.Todo{
877 Content: t.Content,
878 Status: string(t.Status),
879 ActiveForm: t.ActiveForm,
880 }
881 }
882 return out
883}