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