client_workspace.go

  1package workspace
  2
  3import (
  4	"context"
  5	"fmt"
  6	"log/slog"
  7	"strings"
  8	"sync"
  9	"time"
 10
 11	tea "charm.land/bubbletea/v2"
 12	"github.com/charmbracelet/crush/internal/agent/notify"
 13	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 14	"github.com/charmbracelet/crush/internal/client"
 15	"github.com/charmbracelet/crush/internal/config"
 16	"github.com/charmbracelet/crush/internal/history"
 17	"github.com/charmbracelet/crush/internal/log"
 18	"github.com/charmbracelet/crush/internal/lsp"
 19	"github.com/charmbracelet/crush/internal/message"
 20	"github.com/charmbracelet/crush/internal/oauth"
 21	"github.com/charmbracelet/crush/internal/permission"
 22	"github.com/charmbracelet/crush/internal/proto"
 23	"github.com/charmbracelet/crush/internal/pubsub"
 24	"github.com/charmbracelet/crush/internal/session"
 25	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 26)
 27
 28// ClientWorkspace implements the Workspace interface by delegating all
 29// operations to a remote server via the client SDK. It caches the
 30// proto.Workspace returned at creation time and refreshes it after
 31// config-mutating operations.
 32type ClientWorkspace struct {
 33	client *client.Client
 34
 35	mu sync.RWMutex
 36	ws proto.Workspace
 37}
 38
 39// NewClientWorkspace creates a new ClientWorkspace that proxies all
 40// operations through the given client SDK. The ws parameter is the
 41// proto.Workspace snapshot returned by the server at creation time.
 42func NewClientWorkspace(c *client.Client, ws proto.Workspace) *ClientWorkspace {
 43	if ws.Config != nil {
 44		ws.Config.SetupAgents()
 45	}
 46	return &ClientWorkspace{
 47		client: c,
 48		ws:     ws,
 49	}
 50}
 51
 52// refreshWorkspace re-fetches the workspace from the server, updating
 53// the cached snapshot. Called after config-mutating operations.
 54func (w *ClientWorkspace) refreshWorkspace() {
 55	updated, err := w.client.GetWorkspace(context.Background(), w.workspaceID())
 56	if err != nil {
 57		slog.Error("Failed to refresh workspace", "error", err)
 58		return
 59	}
 60	if updated.Config != nil {
 61		updated.Config.SetupAgents()
 62	}
 63	w.mu.Lock()
 64	w.ws = *updated
 65	w.mu.Unlock()
 66}
 67
 68// cached returns a snapshot of the cached workspace.
 69func (w *ClientWorkspace) cached() proto.Workspace {
 70	w.mu.RLock()
 71	defer w.mu.RUnlock()
 72	return w.ws
 73}
 74
 75// workspaceID returns the cached workspace ID.
 76func (w *ClientWorkspace) workspaceID() string {
 77	return w.cached().ID
 78}
 79
 80// -- Sessions --
 81
 82func (w *ClientWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
 83	sess, err := w.client.CreateSession(ctx, w.workspaceID(), title)
 84	if err != nil {
 85		return session.Session{}, err
 86	}
 87	return protoToSession(*sess), nil
 88}
 89
 90func (w *ClientWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
 91	sess, err := w.client.GetSession(ctx, w.workspaceID(), sessionID)
 92	if err != nil {
 93		return session.Session{}, err
 94	}
 95	return protoToSession(*sess), nil
 96}
 97
 98func (w *ClientWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
 99	protoSessions, err := w.client.ListSessions(ctx, w.workspaceID())
100	if err != nil {
101		return nil, err
102	}
103	sessions := make([]session.Session, len(protoSessions))
104	for i, s := range protoSessions {
105		sessions[i] = protoToSession(s)
106	}
107	return sessions, nil
108}
109
110func (w *ClientWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
111	saved, err := w.client.SaveSession(ctx, w.workspaceID(), sessionToProto(sess))
112	if err != nil {
113		return session.Session{}, err
114	}
115	return protoToSession(*saved), nil
116}
117
118func (w *ClientWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
119	return w.client.DeleteSession(ctx, w.workspaceID(), sessionID)
120}
121
122func (w *ClientWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
123	return fmt.Sprintf("%s$$%s", messageID, toolCallID)
124}
125
126func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
127	parts := strings.Split(sessionID, "$$")
128	if len(parts) != 2 {
129		return "", "", false
130	}
131	return parts[0], parts[1], true
132}
133
134// SetCurrentSession reports the session this client is currently
135// viewing to the server. Empty sessionID clears the entry. Errors
136// are propagated to the caller; the TUI logs and ignores them since
137// the presence record is a hint, not correctness-critical state.
138func (w *ClientWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error {
139	return w.client.SetCurrentSession(ctx, w.workspaceID(), sessionID)
140}
141
142// -- Messages --
143
144func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
145	msgs, err := w.client.ListMessages(ctx, w.workspaceID(), sessionID)
146	if err != nil {
147		return nil, err
148	}
149	return protoToMessages(msgs), nil
150}
151
152func (w *ClientWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
153	msgs, err := w.client.ListUserMessages(ctx, w.workspaceID(), sessionID)
154	if err != nil {
155		return nil, err
156	}
157	return protoToMessages(msgs), nil
158}
159
160func (w *ClientWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
161	msgs, err := w.client.ListAllUserMessages(ctx, w.workspaceID())
162	if err != nil {
163		return nil, err
164	}
165	return protoToMessages(msgs), nil
166}
167
168// -- Agent --
169
170func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
171	return w.client.SendMessage(ctx, w.workspaceID(), sessionID, prompt, attachments...)
172}
173
174func (w *ClientWorkspace) AgentCancel(sessionID string) {
175	_ = w.client.CancelAgentSession(context.Background(), w.workspaceID(), sessionID)
176}
177
178func (w *ClientWorkspace) AgentIsBusy() bool {
179	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
180	if err != nil {
181		return false
182	}
183	return info.IsBusy
184}
185
186func (w *ClientWorkspace) AgentIsSessionBusy(sessionID string) bool {
187	info, err := w.client.GetAgentSessionInfo(context.Background(), w.workspaceID(), sessionID)
188	if err != nil {
189		return false
190	}
191	return info.IsBusy
192}
193
194func (w *ClientWorkspace) AgentModel() AgentModel {
195	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
196	if err != nil {
197		return AgentModel{}
198	}
199	return AgentModel{
200		CatwalkCfg: info.Model,
201		ModelCfg:   info.ModelCfg,
202	}
203}
204
205func (w *ClientWorkspace) AgentIsReady() bool {
206	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
207	if err != nil {
208		return false
209	}
210	return info.IsReady
211}
212
213func (w *ClientWorkspace) AgentQueuedPrompts(sessionID string) int {
214	count, err := w.client.GetAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
215	if err != nil {
216		return 0
217	}
218	return count
219}
220
221func (w *ClientWorkspace) AgentQueuedPromptsList(sessionID string) []string {
222	prompts, err := w.client.GetAgentSessionQueuedPromptsList(context.Background(), w.workspaceID(), sessionID)
223	if err != nil {
224		return nil
225	}
226	return prompts
227}
228
229func (w *ClientWorkspace) AgentClearQueue(sessionID string) {
230	_ = w.client.ClearAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
231}
232
233func (w *ClientWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
234	return w.client.AgentSummarizeSession(ctx, w.workspaceID(), sessionID)
235}
236
237func (w *ClientWorkspace) UpdateAgentModel(ctx context.Context) error {
238	return w.client.UpdateAgent(ctx, w.workspaceID())
239}
240
241func (w *ClientWorkspace) InitCoderAgent(ctx context.Context) error {
242	return w.client.InitiateAgentProcessing(ctx, w.workspaceID())
243}
244
245func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
246	model, err := w.client.GetDefaultSmallModel(context.Background(), w.workspaceID(), providerID)
247	if err != nil {
248		return config.SelectedModel{}
249	}
250	return *model
251}
252
253// -- Permissions --
254
255func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) bool {
256	resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
257		Permission: proto.PermissionRequest{
258			ID:          perm.ID,
259			SessionID:   perm.SessionID,
260			ToolCallID:  perm.ToolCallID,
261			ToolName:    perm.ToolName,
262			Description: perm.Description,
263			Action:      perm.Action,
264			Path:        perm.Path,
265			Params:      perm.Params,
266		},
267		Action: proto.PermissionAllow,
268	})
269	return resolved
270}
271
272func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) bool {
273	resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
274		Permission: proto.PermissionRequest{
275			ID:          perm.ID,
276			SessionID:   perm.SessionID,
277			ToolCallID:  perm.ToolCallID,
278			ToolName:    perm.ToolName,
279			Description: perm.Description,
280			Action:      perm.Action,
281			Path:        perm.Path,
282			Params:      perm.Params,
283		},
284		Action: proto.PermissionAllowForSession,
285	})
286	return resolved
287}
288
289func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) bool {
290	resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
291		Permission: proto.PermissionRequest{
292			ID:          perm.ID,
293			SessionID:   perm.SessionID,
294			ToolCallID:  perm.ToolCallID,
295			ToolName:    perm.ToolName,
296			Description: perm.Description,
297			Action:      perm.Action,
298			Path:        perm.Path,
299			Params:      perm.Params,
300		},
301		Action: proto.PermissionDeny,
302	})
303	return resolved
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
486// -- MCP operations --
487
488func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
489	states, err := w.client.MCPGetStates(context.Background(), w.workspaceID())
490	if err != nil {
491		return nil
492	}
493	result := make(map[string]mcp.ClientInfo, len(states))
494	for k, v := range states {
495		result[k] = mcp.ClientInfo{
496			Name:  v.Name,
497			State: mcp.State(v.State),
498			Error: v.Error,
499			Counts: mcp.Counts{
500				Tools:     v.ToolCount,
501				Prompts:   v.PromptCount,
502				Resources: v.ResourceCount,
503			},
504			ConnectedAt: v.ConnectedAt,
505		}
506	}
507	return result
508}
509
510func (w *ClientWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
511	_ = w.client.MCPRefreshPrompts(ctx, w.workspaceID(), name)
512}
513
514func (w *ClientWorkspace) MCPRefreshResources(ctx context.Context, name string) {
515	_ = w.client.MCPRefreshResources(ctx, w.workspaceID(), name)
516}
517
518func (w *ClientWorkspace) RefreshMCPTools(ctx context.Context, name string) {
519	_ = w.client.RefreshMCPTools(ctx, w.workspaceID(), name)
520}
521
522func (w *ClientWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
523	contents, err := w.client.ReadMCPResource(ctx, w.workspaceID(), name, uri)
524	if err != nil {
525		return nil, err
526	}
527	result := make([]MCPResourceContents, len(contents))
528	for i, c := range contents {
529		result[i] = MCPResourceContents{
530			URI:      c.URI,
531			MIMEType: c.MIMEType,
532			Text:     c.Text,
533			Blob:     c.Blob,
534		}
535	}
536	return result, nil
537}
538
539func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
540	return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
541}
542
543func (w *ClientWorkspace) EnableDockerMCP(ctx context.Context) error {
544	return w.client.EnableDockerMCP(ctx, w.workspaceID())
545}
546
547func (w *ClientWorkspace) DisableDockerMCP() error {
548	return w.client.DisableDockerMCP(context.Background(), w.workspaceID())
549}
550
551// -- Lifecycle --
552
553func (w *ClientWorkspace) Subscribe(program *tea.Program) {
554	defer log.RecoverPanic("ClientWorkspace.Subscribe", func() {
555		slog.Info("TUI subscription panic: attempting graceful shutdown")
556		program.Quit()
557	})
558
559	evc, err := w.client.SubscribeEvents(context.Background(), w.workspaceID())
560	if err != nil {
561		slog.Error("Failed to subscribe to events", "error", err)
562		return
563	}
564
565	w.consumeEvents(evc, program.Send)
566}
567
568// consumeEvents drives the workspace event loop. It is split out from
569// Subscribe so tests can drive it without a real *tea.Program.
570// ConfigChanged events trigger a workspace refresh; all other events
571// are translated into domain types and forwarded to send.
572func (w *ClientWorkspace) consumeEvents(evc <-chan any, send func(tea.Msg)) {
573	for ev := range evc {
574		if _, ok := ev.(pubsub.Event[proto.ConfigChanged]); ok {
575			w.refreshWorkspace()
576			continue
577		}
578		translated := translateEvent(ev)
579		if translated != nil && send != nil {
580			send(translated)
581		}
582	}
583}
584
585func (w *ClientWorkspace) Shutdown() {
586	_ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
587}
588
589// translateEvent converts proto-typed SSE events into the domain types
590// that the TUI's Update() method expects.
591func translateEvent(ev any) tea.Msg {
592	switch e := ev.(type) {
593	case pubsub.Event[proto.LSPEvent]:
594		return pubsub.Event[LSPEvent]{
595			Type: e.Type,
596			Payload: LSPEvent{
597				Type:            LSPEventType(e.Payload.Type),
598				Name:            e.Payload.Name,
599				State:           e.Payload.State,
600				Error:           e.Payload.Error,
601				DiagnosticCount: e.Payload.DiagnosticCount,
602			},
603		}
604	case pubsub.Event[proto.MCPEvent]:
605		return pubsub.Event[mcp.Event]{
606			Type: e.Type,
607			Payload: mcp.Event{
608				Type:  protoToMCPEventType(e.Payload.Type),
609				Name:  e.Payload.Name,
610				State: mcp.State(e.Payload.State),
611				Error: e.Payload.Error,
612				Counts: mcp.Counts{
613					Tools:     e.Payload.ToolCount,
614					Prompts:   e.Payload.PromptCount,
615					Resources: e.Payload.ResourceCount,
616				},
617			},
618		}
619	case pubsub.Event[proto.PermissionRequest]:
620		return pubsub.Event[permission.PermissionRequest]{
621			Type: e.Type,
622			Payload: permission.PermissionRequest{
623				ID:          e.Payload.ID,
624				SessionID:   e.Payload.SessionID,
625				ToolCallID:  e.Payload.ToolCallID,
626				ToolName:    e.Payload.ToolName,
627				Description: e.Payload.Description,
628				Action:      e.Payload.Action,
629				Path:        e.Payload.Path,
630				Params:      e.Payload.Params,
631			},
632		}
633	case pubsub.Event[proto.PermissionNotification]:
634		return pubsub.Event[permission.PermissionNotification]{
635			Type: e.Type,
636			Payload: permission.PermissionNotification{
637				ToolCallID: e.Payload.ToolCallID,
638				Granted:    e.Payload.Granted,
639				Denied:     e.Payload.Denied,
640			},
641		}
642	case pubsub.Event[proto.Message]:
643		return pubsub.Event[message.Message]{
644			Type:    e.Type,
645			Payload: protoToMessage(e.Payload),
646		}
647	case pubsub.Event[proto.Session]:
648		return pubsub.Event[session.Session]{
649			Type:    e.Type,
650			Payload: protoToSession(e.Payload),
651		}
652	case pubsub.Event[proto.File]:
653		return pubsub.Event[history.File]{
654			Type:    e.Type,
655			Payload: protoToFile(e.Payload),
656		}
657	case pubsub.Event[proto.AgentEvent]:
658		return pubsub.Event[notify.Notification]{
659			Type: e.Type,
660			Payload: notify.Notification{
661				SessionID:    e.Payload.SessionID,
662				SessionTitle: e.Payload.SessionTitle,
663				Type:         notify.Type(e.Payload.Type),
664			},
665		}
666	default:
667		slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
668		return nil
669	}
670}
671
672func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
673	switch t {
674	case proto.MCPEventStateChanged:
675		return mcp.EventStateChanged
676	case proto.MCPEventToolsListChanged:
677		return mcp.EventToolsListChanged
678	case proto.MCPEventPromptsListChanged:
679		return mcp.EventPromptsListChanged
680	case proto.MCPEventResourcesListChanged:
681		return mcp.EventResourcesListChanged
682	default:
683		return mcp.EventStateChanged
684	}
685}
686
687// protoToSession converts a wire-level proto.Session into the domain
688// session.Session. Fields that exist only on the wire (computed-on-read
689// signals like IsBusy, and any future presence counters) are
690// intentionally dropped here: session.Session models persisted state,
691// not transient runtime signals. UI features that need those signals
692// should either extend session.Session or read them from the proto
693// payload directly before this conversion runs.
694func protoToSession(s proto.Session) session.Session {
695	return session.Session{
696		ID:               s.ID,
697		ParentSessionID:  s.ParentSessionID,
698		Title:            s.Title,
699		SummaryMessageID: s.SummaryMessageID,
700		MessageCount:     s.MessageCount,
701		PromptTokens:     s.PromptTokens,
702		CompletionTokens: s.CompletionTokens,
703		Cost:             s.Cost,
704		Todos:            protoToTodos(s.Todos),
705		CreatedAt:        s.CreatedAt,
706		UpdatedAt:        s.UpdatedAt,
707	}
708}
709
710func protoToTodos(todos []proto.Todo) []session.Todo {
711	if len(todos) == 0 {
712		return nil
713	}
714	out := make([]session.Todo, len(todos))
715	for i, t := range todos {
716		out[i] = session.Todo{
717			Content:    t.Content,
718			Status:     session.TodoStatus(t.Status),
719			ActiveForm: t.ActiveForm,
720		}
721	}
722	return out
723}
724
725func protoToFile(f proto.File) history.File {
726	return history.File{
727		ID:        f.ID,
728		SessionID: f.SessionID,
729		Path:      f.Path,
730		Content:   f.Content,
731		Version:   f.Version,
732		CreatedAt: f.CreatedAt,
733		UpdatedAt: f.UpdatedAt,
734	}
735}
736
737func protoToMessage(m proto.Message) message.Message {
738	msg := message.Message{
739		ID:        m.ID,
740		SessionID: m.SessionID,
741		Role:      message.MessageRole(m.Role),
742		Model:     m.Model,
743		Provider:  m.Provider,
744		CreatedAt: m.CreatedAt,
745		UpdatedAt: m.UpdatedAt,
746	}
747
748	for _, p := range m.Parts {
749		switch v := p.(type) {
750		case proto.TextContent:
751			msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
752		case proto.ReasoningContent:
753			msg.Parts = append(msg.Parts, message.ReasoningContent{
754				Thinking:   v.Thinking,
755				Signature:  v.Signature,
756				StartedAt:  v.StartedAt,
757				FinishedAt: v.FinishedAt,
758			})
759		case proto.ToolCall:
760			msg.Parts = append(msg.Parts, message.ToolCall{
761				ID:       v.ID,
762				Name:     v.Name,
763				Input:    v.Input,
764				Finished: v.Finished,
765			})
766		case proto.ToolResult:
767			msg.Parts = append(msg.Parts, message.ToolResult{
768				ToolCallID: v.ToolCallID,
769				Name:       v.Name,
770				Content:    v.Content,
771				Data:       v.Data,
772				MIMEType:   v.MIMEType,
773				Metadata:   v.Metadata,
774				IsError:    v.IsError,
775			})
776		case proto.Finish:
777			msg.Parts = append(msg.Parts, message.Finish{
778				Reason:  message.FinishReason(v.Reason),
779				Time:    v.Time,
780				Message: v.Message,
781				Details: v.Details,
782			})
783		case proto.ImageURLContent:
784			msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
785		case proto.BinaryContent:
786			msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
787		}
788	}
789
790	return msg
791}
792
793func protoToMessages(msgs []proto.Message) []message.Message {
794	out := make([]message.Message, len(msgs))
795	for i, m := range msgs {
796		out[i] = protoToMessage(m)
797	}
798	return out
799}
800
801func protoToFiles(files []proto.File) []history.File {
802	out := make([]history.File, len(files))
803	for i, f := range files {
804		out[i] = protoToFile(f)
805	}
806	return out
807}
808
809func sessionToProto(s session.Session) proto.Session {
810	return proto.Session{
811		ID:               s.ID,
812		ParentSessionID:  s.ParentSessionID,
813		Title:            s.Title,
814		SummaryMessageID: s.SummaryMessageID,
815		MessageCount:     s.MessageCount,
816		PromptTokens:     s.PromptTokens,
817		CompletionTokens: s.CompletionTokens,
818		Cost:             s.Cost,
819		Todos:            todosToProto(s.Todos),
820		CreatedAt:        s.CreatedAt,
821		UpdatedAt:        s.UpdatedAt,
822	}
823}
824
825func todosToProto(todos []session.Todo) []proto.Todo {
826	if len(todos) == 0 {
827		return nil
828	}
829	out := make([]proto.Todo, len(todos))
830	for i, t := range todos {
831		out[i] = proto.Todo{
832			Content:    t.Content,
833			Status:     string(t.Status),
834			ActiveForm: t.ActiveForm,
835		}
836	}
837	return out
838}