client_workspace.go

  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}