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.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
486// -- MCP operations --
487
488func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
489 states, err := w.client.MCPGetStates(context.Background(), w.workspaceID())
490 if err != nil {
491 return nil
492 }
493 result := make(map[string]mcp.ClientInfo, len(states))
494 for k, v := range states {
495 result[k] = mcp.ClientInfo{
496 Name: v.Name,
497 State: mcp.State(v.State),
498 Error: v.Error,
499 Counts: mcp.Counts{
500 Tools: v.ToolCount,
501 Prompts: v.PromptCount,
502 Resources: v.ResourceCount,
503 },
504 ConnectedAt: v.ConnectedAt,
505 }
506 }
507 return result
508}
509
510func (w *ClientWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
511 _ = w.client.MCPRefreshPrompts(ctx, w.workspaceID(), name)
512}
513
514func (w *ClientWorkspace) MCPRefreshResources(ctx context.Context, name string) {
515 _ = w.client.MCPRefreshResources(ctx, w.workspaceID(), name)
516}
517
518func (w *ClientWorkspace) RefreshMCPTools(ctx context.Context, name string) {
519 _ = w.client.RefreshMCPTools(ctx, w.workspaceID(), name)
520}
521
522func (w *ClientWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
523 contents, err := w.client.ReadMCPResource(ctx, w.workspaceID(), name, uri)
524 if err != nil {
525 return nil, err
526 }
527 result := make([]MCPResourceContents, len(contents))
528 for i, c := range contents {
529 result[i] = MCPResourceContents{
530 URI: c.URI,
531 MIMEType: c.MIMEType,
532 Text: c.Text,
533 Blob: c.Blob,
534 }
535 }
536 return result, nil
537}
538
539func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
540 return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
541}
542
543func (w *ClientWorkspace) EnableDockerMCP(ctx context.Context) error {
544 return w.client.EnableDockerMCP(ctx, w.workspaceID())
545}
546
547func (w *ClientWorkspace) DisableDockerMCP() error {
548 return w.client.DisableDockerMCP(context.Background(), w.workspaceID())
549}
550
551// -- Lifecycle --
552
553func (w *ClientWorkspace) Subscribe(program *tea.Program) {
554 defer log.RecoverPanic("ClientWorkspace.Subscribe", func() {
555 slog.Info("TUI subscription panic: attempting graceful shutdown")
556 program.Quit()
557 })
558
559 evc, err := w.client.SubscribeEvents(context.Background(), w.workspaceID())
560 if err != nil {
561 slog.Error("Failed to subscribe to events", "error", err)
562 return
563 }
564
565 for ev := range evc {
566 translated := w.translateEvent(ev)
567 if translated != nil {
568 program.Send(translated)
569 }
570 }
571}
572
573func (w *ClientWorkspace) Shutdown() {
574 _ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
575}
576
577// translateEvent converts proto-typed SSE events into the domain types
578// that the TUI's Update() method expects. Skills events also update the
579// process-local skills.Manager so callers reading
580// skills.GetLatestStates see fresh data.
581func (w *ClientWorkspace) translateEvent(ev any) tea.Msg {
582 switch e := ev.(type) {
583 case pubsub.Event[proto.LSPEvent]:
584 return pubsub.Event[LSPEvent]{
585 Type: e.Type,
586 Payload: LSPEvent{
587 Type: LSPEventType(e.Payload.Type),
588 Name: e.Payload.Name,
589 State: e.Payload.State,
590 Error: e.Payload.Error,
591 DiagnosticCount: e.Payload.DiagnosticCount,
592 },
593 }
594 case pubsub.Event[proto.MCPEvent]:
595 return pubsub.Event[mcp.Event]{
596 Type: e.Type,
597 Payload: mcp.Event{
598 Type: protoToMCPEventType(e.Payload.Type),
599 Name: e.Payload.Name,
600 State: mcp.State(e.Payload.State),
601 Error: e.Payload.Error,
602 Counts: mcp.Counts{
603 Tools: e.Payload.ToolCount,
604 Prompts: e.Payload.PromptCount,
605 Resources: e.Payload.ResourceCount,
606 },
607 },
608 }
609 case pubsub.Event[proto.PermissionRequest]:
610 return pubsub.Event[permission.PermissionRequest]{
611 Type: e.Type,
612 Payload: permission.PermissionRequest{
613 ID: e.Payload.ID,
614 SessionID: e.Payload.SessionID,
615 ToolCallID: e.Payload.ToolCallID,
616 ToolName: e.Payload.ToolName,
617 Description: e.Payload.Description,
618 Action: e.Payload.Action,
619 Path: e.Payload.Path,
620 Params: e.Payload.Params,
621 },
622 }
623 case pubsub.Event[proto.PermissionNotification]:
624 return pubsub.Event[permission.PermissionNotification]{
625 Type: e.Type,
626 Payload: permission.PermissionNotification{
627 ToolCallID: e.Payload.ToolCallID,
628 Granted: e.Payload.Granted,
629 Denied: e.Payload.Denied,
630 },
631 }
632 case pubsub.Event[proto.Message]:
633 return pubsub.Event[message.Message]{
634 Type: e.Type,
635 Payload: protoToMessage(e.Payload),
636 }
637 case pubsub.Event[proto.Session]:
638 return pubsub.Event[session.Session]{
639 Type: e.Type,
640 Payload: protoToSession(e.Payload),
641 }
642 case pubsub.Event[proto.File]:
643 return pubsub.Event[history.File]{
644 Type: e.Type,
645 Payload: protoToFile(e.Payload),
646 }
647 case pubsub.Event[proto.AgentEvent]:
648 return pubsub.Event[notify.Notification]{
649 Type: e.Type,
650 Payload: notify.Notification{
651 SessionID: e.Payload.SessionID,
652 SessionTitle: e.Payload.SessionTitle,
653 Type: notify.Type(e.Payload.Type),
654 },
655 }
656 case pubsub.Event[proto.SkillsEvent]:
657 states := protoToSkillStates(e.Payload.States)
658 if w.skills != nil {
659 w.skills.SetLatestStates(states)
660 }
661 return pubsub.Event[skills.Event]{
662 Type: e.Type,
663 Payload: skills.Event{States: states},
664 }
665 default:
666 slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
667 return nil
668 }
669}
670
671func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
672 switch t {
673 case proto.MCPEventStateChanged:
674 return mcp.EventStateChanged
675 case proto.MCPEventToolsListChanged:
676 return mcp.EventToolsListChanged
677 case proto.MCPEventPromptsListChanged:
678 return mcp.EventPromptsListChanged
679 case proto.MCPEventResourcesListChanged:
680 return mcp.EventResourcesListChanged
681 default:
682 return mcp.EventStateChanged
683 }
684}
685
686func protoToSession(s proto.Session) session.Session {
687 return session.Session{
688 ID: s.ID,
689 ParentSessionID: s.ParentSessionID,
690 Title: s.Title,
691 SummaryMessageID: s.SummaryMessageID,
692 MessageCount: s.MessageCount,
693 PromptTokens: s.PromptTokens,
694 CompletionTokens: s.CompletionTokens,
695 Cost: s.Cost,
696 Todos: protoToTodos(s.Todos),
697 CreatedAt: s.CreatedAt,
698 UpdatedAt: s.UpdatedAt,
699 }
700}
701
702func protoToTodos(todos []proto.Todo) []session.Todo {
703 if len(todos) == 0 {
704 return nil
705 }
706 out := make([]session.Todo, len(todos))
707 for i, t := range todos {
708 out[i] = session.Todo{
709 Content: t.Content,
710 Status: session.TodoStatus(t.Status),
711 ActiveForm: t.ActiveForm,
712 }
713 }
714 return out
715}
716
717func protoToFile(f proto.File) history.File {
718 return history.File{
719 ID: f.ID,
720 SessionID: f.SessionID,
721 Path: f.Path,
722 Content: f.Content,
723 Version: f.Version,
724 CreatedAt: f.CreatedAt,
725 UpdatedAt: f.UpdatedAt,
726 }
727}
728
729func protoToMessage(m proto.Message) message.Message {
730 msg := message.Message{
731 ID: m.ID,
732 SessionID: m.SessionID,
733 Role: message.MessageRole(m.Role),
734 Model: m.Model,
735 Provider: m.Provider,
736 CreatedAt: m.CreatedAt,
737 UpdatedAt: m.UpdatedAt,
738 }
739
740 for _, p := range m.Parts {
741 switch v := p.(type) {
742 case proto.TextContent:
743 msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
744 case proto.ReasoningContent:
745 msg.Parts = append(msg.Parts, message.ReasoningContent{
746 Thinking: v.Thinking,
747 Signature: v.Signature,
748 StartedAt: v.StartedAt,
749 FinishedAt: v.FinishedAt,
750 })
751 case proto.ToolCall:
752 msg.Parts = append(msg.Parts, message.ToolCall{
753 ID: v.ID,
754 Name: v.Name,
755 Input: v.Input,
756 Finished: v.Finished,
757 })
758 case proto.ToolResult:
759 msg.Parts = append(msg.Parts, message.ToolResult{
760 ToolCallID: v.ToolCallID,
761 Name: v.Name,
762 Content: v.Content,
763 Data: v.Data,
764 MIMEType: v.MIMEType,
765 Metadata: v.Metadata,
766 IsError: v.IsError,
767 })
768 case proto.Finish:
769 msg.Parts = append(msg.Parts, message.Finish{
770 Reason: message.FinishReason(v.Reason),
771 Time: v.Time,
772 Message: v.Message,
773 Details: v.Details,
774 })
775 case proto.ImageURLContent:
776 msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
777 case proto.BinaryContent:
778 msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
779 }
780 }
781
782 return msg
783}
784
785func protoToMessages(msgs []proto.Message) []message.Message {
786 out := make([]message.Message, len(msgs))
787 for i, m := range msgs {
788 out[i] = protoToMessage(m)
789 }
790 return out
791}
792
793func protoToFiles(files []proto.File) []history.File {
794 out := make([]history.File, len(files))
795 for i, f := range files {
796 out[i] = protoToFile(f)
797 }
798 return out
799}
800
801func sessionToProto(s session.Session) proto.Session {
802 return proto.Session{
803 ID: s.ID,
804 ParentSessionID: s.ParentSessionID,
805 Title: s.Title,
806 SummaryMessageID: s.SummaryMessageID,
807 MessageCount: s.MessageCount,
808 PromptTokens: s.PromptTokens,
809 CompletionTokens: s.CompletionTokens,
810 Cost: s.Cost,
811 Todos: todosToProto(s.Todos),
812 CreatedAt: s.CreatedAt,
813 UpdatedAt: s.UpdatedAt,
814 }
815}
816
817// protoToSkillStates reconstructs internal skill state slices from
818// their wire representation. Non-empty Error strings are turned into
819// synthetic error values; the TUI never type-asserts on Err.
820func protoToSkillStates(in []proto.SkillState) []*skills.SkillState {
821 if len(in) == 0 {
822 return nil
823 }
824 out := make([]*skills.SkillState, len(in))
825 for i, s := range in {
826 state := &skills.SkillState{
827 Name: s.Name,
828 Path: s.Path,
829 State: skills.DiscoveryState(s.State),
830 }
831 if s.Error != "" {
832 state.Err = errors.New(s.Error)
833 }
834 out[i] = state
835 }
836 return out
837}
838
839func todosToProto(todos []session.Todo) []proto.Todo {
840 if len(todos) == 0 {
841 return nil
842 }
843 out := make([]proto.Todo, len(todos))
844 for i, t := range todos {
845 out[i] = proto.Todo{
846 Content: t.Content,
847 Status: string(t.Status),
848 ActiveForm: t.ActiveForm,
849 }
850 }
851 return out
852}