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	"git.secluded.site/crush/internal/agent/notify"
 14	"git.secluded.site/crush/internal/agent/tools/mcp"
 15	"git.secluded.site/crush/internal/client"
 16	"git.secluded.site/crush/internal/config"
 17	"git.secluded.site/crush/internal/history"
 18	"git.secluded.site/crush/internal/log"
 19	"git.secluded.site/crush/internal/lsp"
 20	"git.secluded.site/crush/internal/message"
 21	"git.secluded.site/crush/internal/oauth"
 22	"git.secluded.site/crush/internal/permission"
 23	"git.secluded.site/crush/internal/proto"
 24	"git.secluded.site/crush/internal/pubsub"
 25	"git.secluded.site/crush/internal/session"
 26	"git.secluded.site/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}