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
497// -- MCP operations --
498
499func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
500 states, err := w.client.MCPGetStates(context.Background(), w.workspaceID())
501 if err != nil {
502 return nil
503 }
504 result := make(map[string]mcp.ClientInfo, len(states))
505 for k, v := range states {
506 result[k] = mcp.ClientInfo{
507 Name: v.Name,
508 State: mcp.State(v.State),
509 Error: v.Error,
510 Counts: mcp.Counts{
511 Tools: v.ToolCount,
512 Prompts: v.PromptCount,
513 Resources: v.ResourceCount,
514 },
515 ConnectedAt: v.ConnectedAt,
516 }
517 }
518 return result
519}
520
521func (w *ClientWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
522 _ = w.client.MCPRefreshPrompts(ctx, w.workspaceID(), name)
523}
524
525func (w *ClientWorkspace) MCPRefreshResources(ctx context.Context, name string) {
526 _ = w.client.MCPRefreshResources(ctx, w.workspaceID(), name)
527}
528
529func (w *ClientWorkspace) RefreshMCPTools(ctx context.Context, name string) {
530 _ = w.client.RefreshMCPTools(ctx, w.workspaceID(), name)
531}
532
533func (w *ClientWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
534 contents, err := w.client.ReadMCPResource(ctx, w.workspaceID(), name, uri)
535 if err != nil {
536 return nil, err
537 }
538 result := make([]MCPResourceContents, len(contents))
539 for i, c := range contents {
540 result[i] = MCPResourceContents{
541 URI: c.URI,
542 MIMEType: c.MIMEType,
543 Text: c.Text,
544 Blob: c.Blob,
545 }
546 }
547 return result, nil
548}
549
550func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
551 return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
552}
553
554func (w *ClientWorkspace) EnableDockerMCP(ctx context.Context) error {
555 return w.client.EnableDockerMCP(ctx, w.workspaceID())
556}
557
558func (w *ClientWorkspace) DisableDockerMCP() error {
559 return w.client.DisableDockerMCP(context.Background(), w.workspaceID())
560}
561
562// -- Lifecycle --
563
564func (w *ClientWorkspace) Subscribe(program *tea.Program) {
565 defer log.RecoverPanic("ClientWorkspace.Subscribe", func() {
566 slog.Info("TUI subscription panic: attempting graceful shutdown")
567 program.Quit()
568 })
569
570 evc, err := w.client.SubscribeEvents(context.Background(), w.workspaceID())
571 if err != nil {
572 slog.Error("Failed to subscribe to events", "error", err)
573 return
574 }
575
576 w.consumeEvents(evc, program.Send)
577}
578
579// consumeEvents drives the workspace event loop. It is split out from
580// Subscribe so tests can drive it without a real *tea.Program.
581// ConfigChanged events trigger a workspace refresh; all other events
582// are translated into domain types and forwarded to send.
583func (w *ClientWorkspace) consumeEvents(evc <-chan any, send func(tea.Msg)) {
584 for ev := range evc {
585 if _, ok := ev.(pubsub.Event[proto.ConfigChanged]); ok {
586 w.refreshWorkspace()
587 continue
588 }
589 translated := w.translateEvent(ev)
590 if translated != nil && send != nil {
591 send(translated)
592 }
593 }
594}
595
596func (w *ClientWorkspace) Shutdown() {
597 _ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
598}
599
600// translateEvent converts proto-typed SSE events into the domain types
601// that the TUI's Update() method expects. Skills events also update the
602// process-local skills.Manager so callers reading
603// skills.GetLatestStates see fresh data.
604func (w *ClientWorkspace) translateEvent(ev any) tea.Msg {
605 switch e := ev.(type) {
606 case pubsub.Event[proto.LSPEvent]:
607 return pubsub.Event[LSPEvent]{
608 Type: e.Type,
609 Payload: LSPEvent{
610 Type: LSPEventType(e.Payload.Type),
611 Name: e.Payload.Name,
612 State: e.Payload.State,
613 Error: e.Payload.Error,
614 DiagnosticCount: e.Payload.DiagnosticCount,
615 },
616 }
617 case pubsub.Event[proto.MCPEvent]:
618 return pubsub.Event[mcp.Event]{
619 Type: e.Type,
620 Payload: mcp.Event{
621 Type: protoToMCPEventType(e.Payload.Type),
622 Name: e.Payload.Name,
623 State: mcp.State(e.Payload.State),
624 Error: e.Payload.Error,
625 Counts: mcp.Counts{
626 Tools: e.Payload.ToolCount,
627 Prompts: e.Payload.PromptCount,
628 Resources: e.Payload.ResourceCount,
629 },
630 },
631 }
632 case pubsub.Event[proto.PermissionRequest]:
633 return pubsub.Event[permission.PermissionRequest]{
634 Type: e.Type,
635 Payload: permission.PermissionRequest{
636 ID: e.Payload.ID,
637 SessionID: e.Payload.SessionID,
638 ToolCallID: e.Payload.ToolCallID,
639 ToolName: e.Payload.ToolName,
640 Description: e.Payload.Description,
641 Action: e.Payload.Action,
642 Path: e.Payload.Path,
643 Params: e.Payload.Params,
644 },
645 }
646 case pubsub.Event[proto.PermissionNotification]:
647 return pubsub.Event[permission.PermissionNotification]{
648 Type: e.Type,
649 Payload: permission.PermissionNotification{
650 ToolCallID: e.Payload.ToolCallID,
651 Granted: e.Payload.Granted,
652 Denied: e.Payload.Denied,
653 },
654 }
655 case pubsub.Event[proto.Message]:
656 return pubsub.Event[message.Message]{
657 Type: e.Type,
658 Payload: protoToMessage(e.Payload),
659 }
660 case pubsub.Event[proto.Session]:
661 return pubsub.Event[session.Session]{
662 Type: e.Type,
663 Payload: protoToSession(e.Payload),
664 }
665 case pubsub.Event[proto.File]:
666 return pubsub.Event[history.File]{
667 Type: e.Type,
668 Payload: protoToFile(e.Payload),
669 }
670 case pubsub.Event[proto.AgentEvent]:
671 return pubsub.Event[notify.Notification]{
672 Type: e.Type,
673 Payload: notify.Notification{
674 SessionID: e.Payload.SessionID,
675 SessionTitle: e.Payload.SessionTitle,
676 Type: notify.Type(e.Payload.Type),
677 },
678 }
679 case pubsub.Event[proto.SkillsEvent]:
680 states := protoToSkillStates(e.Payload.States)
681 if w.skills != nil {
682 w.skills.SetLatestStates(states)
683 }
684 return pubsub.Event[skills.Event]{
685 Type: e.Type,
686 Payload: skills.Event{States: states},
687 }
688 default:
689 slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
690 return nil
691 }
692}
693
694func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
695 switch t {
696 case proto.MCPEventStateChanged:
697 return mcp.EventStateChanged
698 case proto.MCPEventToolsListChanged:
699 return mcp.EventToolsListChanged
700 case proto.MCPEventPromptsListChanged:
701 return mcp.EventPromptsListChanged
702 case proto.MCPEventResourcesListChanged:
703 return mcp.EventResourcesListChanged
704 default:
705 return mcp.EventStateChanged
706 }
707}
708
709// protoToSession converts a wire-level proto.Session into the domain
710// session.Session. Fields that exist only on the wire (computed-on-read
711// signals like IsBusy, and any future presence counters) are
712// intentionally dropped here: session.Session models persisted state,
713// not transient runtime signals. UI features that need those signals
714// should either extend session.Session or read them from the proto
715// payload directly before this conversion runs.
716func protoToSession(s proto.Session) session.Session {
717 return session.Session{
718 ID: s.ID,
719 ParentSessionID: s.ParentSessionID,
720 Title: s.Title,
721 SummaryMessageID: s.SummaryMessageID,
722 MessageCount: s.MessageCount,
723 PromptTokens: s.PromptTokens,
724 CompletionTokens: s.CompletionTokens,
725 Cost: s.Cost,
726 Todos: protoToTodos(s.Todos),
727 CreatedAt: s.CreatedAt,
728 UpdatedAt: s.UpdatedAt,
729 }
730}
731
732func protoToTodos(todos []proto.Todo) []session.Todo {
733 if len(todos) == 0 {
734 return nil
735 }
736 out := make([]session.Todo, len(todos))
737 for i, t := range todos {
738 out[i] = session.Todo{
739 Content: t.Content,
740 Status: session.TodoStatus(t.Status),
741 ActiveForm: t.ActiveForm,
742 }
743 }
744 return out
745}
746
747func protoToFile(f proto.File) history.File {
748 return history.File{
749 ID: f.ID,
750 SessionID: f.SessionID,
751 Path: f.Path,
752 Content: f.Content,
753 Version: f.Version,
754 CreatedAt: f.CreatedAt,
755 UpdatedAt: f.UpdatedAt,
756 }
757}
758
759func protoToMessage(m proto.Message) message.Message {
760 msg := message.Message{
761 ID: m.ID,
762 SessionID: m.SessionID,
763 Role: message.MessageRole(m.Role),
764 Model: m.Model,
765 Provider: m.Provider,
766 CreatedAt: m.CreatedAt,
767 UpdatedAt: m.UpdatedAt,
768 }
769
770 for _, p := range m.Parts {
771 switch v := p.(type) {
772 case proto.TextContent:
773 msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
774 case proto.ReasoningContent:
775 msg.Parts = append(msg.Parts, message.ReasoningContent{
776 Thinking: v.Thinking,
777 Signature: v.Signature,
778 StartedAt: v.StartedAt,
779 FinishedAt: v.FinishedAt,
780 })
781 case proto.ToolCall:
782 msg.Parts = append(msg.Parts, message.ToolCall{
783 ID: v.ID,
784 Name: v.Name,
785 Input: v.Input,
786 Finished: v.Finished,
787 })
788 case proto.ToolResult:
789 msg.Parts = append(msg.Parts, message.ToolResult{
790 ToolCallID: v.ToolCallID,
791 Name: v.Name,
792 Content: v.Content,
793 Data: v.Data,
794 MIMEType: v.MIMEType,
795 Metadata: v.Metadata,
796 IsError: v.IsError,
797 })
798 case proto.Finish:
799 msg.Parts = append(msg.Parts, message.Finish{
800 Reason: message.FinishReason(v.Reason),
801 Time: v.Time,
802 Message: v.Message,
803 Details: v.Details,
804 })
805 case proto.ImageURLContent:
806 msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
807 case proto.BinaryContent:
808 msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
809 }
810 }
811
812 return msg
813}
814
815func protoToMessages(msgs []proto.Message) []message.Message {
816 out := make([]message.Message, len(msgs))
817 for i, m := range msgs {
818 out[i] = protoToMessage(m)
819 }
820 return out
821}
822
823func protoToFiles(files []proto.File) []history.File {
824 out := make([]history.File, len(files))
825 for i, f := range files {
826 out[i] = protoToFile(f)
827 }
828 return out
829}
830
831func sessionToProto(s session.Session) proto.Session {
832 return proto.Session{
833 ID: s.ID,
834 ParentSessionID: s.ParentSessionID,
835 Title: s.Title,
836 SummaryMessageID: s.SummaryMessageID,
837 MessageCount: s.MessageCount,
838 PromptTokens: s.PromptTokens,
839 CompletionTokens: s.CompletionTokens,
840 Cost: s.Cost,
841 Todos: todosToProto(s.Todos),
842 CreatedAt: s.CreatedAt,
843 UpdatedAt: s.UpdatedAt,
844 }
845}
846
847// protoToSkillStates reconstructs internal skill state slices from
848// their wire representation. Non-empty Error strings are turned into
849// synthetic error values; the TUI never type-asserts on Err.
850func protoToSkillStates(in []proto.SkillState) []*skills.SkillState {
851 if len(in) == 0 {
852 return nil
853 }
854 out := make([]*skills.SkillState, len(in))
855 for i, s := range in {
856 state := &skills.SkillState{
857 Name: s.Name,
858 Path: s.Path,
859 State: skills.DiscoveryState(s.State),
860 }
861 if s.Error != "" {
862 state.Err = errors.New(s.Error)
863 }
864 out[i] = state
865 }
866 return out
867}
868
869func todosToProto(todos []session.Todo) []proto.Todo {
870 if len(todos) == 0 {
871 return nil
872 }
873 out := make([]proto.Todo, len(todos))
874 for i, t := range todos {
875 out[i] = proto.Todo{
876 Content: t.Content,
877 Status: string(t.Status),
878 ActiveForm: t.ActiveForm,
879 }
880 }
881 return out
882}