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