1package workspace
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "strings"
8 "sync"
9 "time"
10
11 tea "charm.land/bubbletea/v2"
12 "github.com/charmbracelet/crush/internal/agent/notify"
13 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
14 "github.com/charmbracelet/crush/internal/client"
15 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/history"
17 "github.com/charmbracelet/crush/internal/log"
18 "github.com/charmbracelet/crush/internal/lsp"
19 "github.com/charmbracelet/crush/internal/message"
20 "github.com/charmbracelet/crush/internal/oauth"
21 "github.com/charmbracelet/crush/internal/permission"
22 "github.com/charmbracelet/crush/internal/proto"
23 "github.com/charmbracelet/crush/internal/pubsub"
24 "github.com/charmbracelet/crush/internal/session"
25 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
26)
27
28// ClientWorkspace implements the Workspace interface by delegating all
29// operations to a remote server via the client SDK. It caches the
30// proto.Workspace returned at creation time and refreshes it after
31// config-mutating operations.
32type ClientWorkspace struct {
33 client *client.Client
34
35 mu sync.RWMutex
36 ws proto.Workspace
37}
38
39// NewClientWorkspace creates a new ClientWorkspace that proxies all
40// operations through the given client SDK. The ws parameter is the
41// proto.Workspace snapshot returned by the server at creation time.
42func NewClientWorkspace(c *client.Client, ws proto.Workspace) *ClientWorkspace {
43 if ws.Config != nil {
44 ws.Config.SetupAgents()
45 }
46 return &ClientWorkspace{
47 client: c,
48 ws: ws,
49 }
50}
51
52// refreshWorkspace re-fetches the workspace from the server, updating
53// the cached snapshot. Called after config-mutating operations.
54func (w *ClientWorkspace) refreshWorkspace() {
55 updated, err := w.client.GetWorkspace(context.Background(), w.workspaceID())
56 if err != nil {
57 slog.Error("Failed to refresh workspace", "error", err)
58 return
59 }
60 if updated.Config != nil {
61 updated.Config.SetupAgents()
62 }
63 w.mu.Lock()
64 w.ws = *updated
65 w.mu.Unlock()
66}
67
68// cached returns a snapshot of the cached workspace.
69func (w *ClientWorkspace) cached() proto.Workspace {
70 w.mu.RLock()
71 defer w.mu.RUnlock()
72 return w.ws
73}
74
75// workspaceID returns the cached workspace ID.
76func (w *ClientWorkspace) workspaceID() string {
77 return w.cached().ID
78}
79
80// -- Sessions --
81
82func (w *ClientWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
83 sess, err := w.client.CreateSession(ctx, w.workspaceID(), title)
84 if err != nil {
85 return session.Session{}, err
86 }
87 return protoToSession(*sess), nil
88}
89
90func (w *ClientWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
91 sess, err := w.client.GetSession(ctx, w.workspaceID(), sessionID)
92 if err != nil {
93 return session.Session{}, err
94 }
95 return protoToSession(*sess), nil
96}
97
98func (w *ClientWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
99 protoSessions, err := w.client.ListSessions(ctx, w.workspaceID())
100 if err != nil {
101 return nil, err
102 }
103 sessions := make([]session.Session, len(protoSessions))
104 for i, s := range protoSessions {
105 sessions[i] = protoToSession(s)
106 }
107 return sessions, nil
108}
109
110func (w *ClientWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
111 saved, err := w.client.SaveSession(ctx, w.workspaceID(), sessionToProto(sess))
112 if err != nil {
113 return session.Session{}, err
114 }
115 return protoToSession(*saved), nil
116}
117
118func (w *ClientWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
119 return w.client.DeleteSession(ctx, w.workspaceID(), sessionID)
120}
121
122func (w *ClientWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
123 return fmt.Sprintf("%s$$%s", messageID, toolCallID)
124}
125
126func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
127 parts := strings.Split(sessionID, "$$")
128 if len(parts) != 2 {
129 return "", "", false
130 }
131 return parts[0], parts[1], true
132}
133
134// SetCurrentSession reports the session this client is currently
135// viewing to the server. Empty sessionID clears the entry. Errors
136// are propagated to the caller; the TUI logs and ignores them since
137// the presence record is a hint, not correctness-critical state.
138func (w *ClientWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error {
139 return w.client.SetCurrentSession(ctx, w.workspaceID(), sessionID)
140}
141
142// -- Messages --
143
144func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
145 msgs, err := w.client.ListMessages(ctx, w.workspaceID(), sessionID)
146 if err != nil {
147 return nil, err
148 }
149 return protoToMessages(msgs), nil
150}
151
152func (w *ClientWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
153 msgs, err := w.client.ListUserMessages(ctx, w.workspaceID(), sessionID)
154 if err != nil {
155 return nil, err
156 }
157 return protoToMessages(msgs), nil
158}
159
160func (w *ClientWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
161 msgs, err := w.client.ListAllUserMessages(ctx, w.workspaceID())
162 if err != nil {
163 return nil, err
164 }
165 return protoToMessages(msgs), nil
166}
167
168// -- Agent --
169
170func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
171 return w.client.SendMessage(ctx, w.workspaceID(), sessionID, prompt, attachments...)
172}
173
174func (w *ClientWorkspace) AgentCancel(sessionID string) {
175 _ = w.client.CancelAgentSession(context.Background(), w.workspaceID(), sessionID)
176}
177
178func (w *ClientWorkspace) AgentIsBusy() bool {
179 info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
180 if err != nil {
181 return false
182 }
183 return info.IsBusy
184}
185
186func (w *ClientWorkspace) AgentIsSessionBusy(sessionID string) bool {
187 info, err := w.client.GetAgentSessionInfo(context.Background(), w.workspaceID(), sessionID)
188 if err != nil {
189 return false
190 }
191 return info.IsBusy
192}
193
194func (w *ClientWorkspace) AgentModel() AgentModel {
195 info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
196 if err != nil {
197 return AgentModel{}
198 }
199 return AgentModel{
200 CatwalkCfg: info.Model,
201 ModelCfg: info.ModelCfg,
202 }
203}
204
205func (w *ClientWorkspace) AgentIsReady() bool {
206 info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
207 if err != nil {
208 return false
209 }
210 return info.IsReady
211}
212
213func (w *ClientWorkspace) AgentQueuedPrompts(sessionID string) int {
214 count, err := w.client.GetAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
215 if err != nil {
216 return 0
217 }
218 return count
219}
220
221func (w *ClientWorkspace) AgentQueuedPromptsList(sessionID string) []string {
222 prompts, err := w.client.GetAgentSessionQueuedPromptsList(context.Background(), w.workspaceID(), sessionID)
223 if err != nil {
224 return nil
225 }
226 return prompts
227}
228
229func (w *ClientWorkspace) AgentClearQueue(sessionID string) {
230 _ = w.client.ClearAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
231}
232
233func (w *ClientWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
234 return w.client.AgentSummarizeSession(ctx, w.workspaceID(), sessionID)
235}
236
237func (w *ClientWorkspace) UpdateAgentModel(ctx context.Context) error {
238 return w.client.UpdateAgent(ctx, w.workspaceID())
239}
240
241func (w *ClientWorkspace) InitCoderAgent(ctx context.Context) error {
242 return w.client.InitiateAgentProcessing(ctx, w.workspaceID())
243}
244
245func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
246 model, err := w.client.GetDefaultSmallModel(context.Background(), w.workspaceID(), providerID)
247 if err != nil {
248 return config.SelectedModel{}
249 }
250 return *model
251}
252
253// -- Permissions --
254
255func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) bool {
256 resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
257 Permission: proto.PermissionRequest{
258 ID: perm.ID,
259 SessionID: perm.SessionID,
260 ToolCallID: perm.ToolCallID,
261 ToolName: perm.ToolName,
262 Description: perm.Description,
263 Action: perm.Action,
264 Path: perm.Path,
265 Params: perm.Params,
266 },
267 Action: proto.PermissionAllow,
268 })
269 return resolved
270}
271
272func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) bool {
273 resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
274 Permission: proto.PermissionRequest{
275 ID: perm.ID,
276 SessionID: perm.SessionID,
277 ToolCallID: perm.ToolCallID,
278 ToolName: perm.ToolName,
279 Description: perm.Description,
280 Action: perm.Action,
281 Path: perm.Path,
282 Params: perm.Params,
283 },
284 Action: proto.PermissionAllowForSession,
285 })
286 return resolved
287}
288
289func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) bool {
290 resolved, _ := w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
291 Permission: proto.PermissionRequest{
292 ID: perm.ID,
293 SessionID: perm.SessionID,
294 ToolCallID: perm.ToolCallID,
295 ToolName: perm.ToolName,
296 Description: perm.Description,
297 Action: perm.Action,
298 Path: perm.Path,
299 Params: perm.Params,
300 },
301 Action: proto.PermissionDeny,
302 })
303 return resolved
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 w.consumeEvents(evc, program.Send)
566}
567
568// consumeEvents drives the workspace event loop. It is split out from
569// Subscribe so tests can drive it without a real *tea.Program.
570// ConfigChanged events trigger a workspace refresh; all other events
571// are translated into domain types and forwarded to send.
572func (w *ClientWorkspace) consumeEvents(evc <-chan any, send func(tea.Msg)) {
573 for ev := range evc {
574 if _, ok := ev.(pubsub.Event[proto.ConfigChanged]); ok {
575 w.refreshWorkspace()
576 continue
577 }
578 translated := translateEvent(ev)
579 if translated != nil && send != nil {
580 send(translated)
581 }
582 }
583}
584
585func (w *ClientWorkspace) Shutdown() {
586 _ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
587}
588
589// translateEvent converts proto-typed SSE events into the domain types
590// that the TUI's Update() method expects.
591func translateEvent(ev any) tea.Msg {
592 switch e := ev.(type) {
593 case pubsub.Event[proto.LSPEvent]:
594 return pubsub.Event[LSPEvent]{
595 Type: e.Type,
596 Payload: LSPEvent{
597 Type: LSPEventType(e.Payload.Type),
598 Name: e.Payload.Name,
599 State: e.Payload.State,
600 Error: e.Payload.Error,
601 DiagnosticCount: e.Payload.DiagnosticCount,
602 },
603 }
604 case pubsub.Event[proto.MCPEvent]:
605 return pubsub.Event[mcp.Event]{
606 Type: e.Type,
607 Payload: mcp.Event{
608 Type: protoToMCPEventType(e.Payload.Type),
609 Name: e.Payload.Name,
610 State: mcp.State(e.Payload.State),
611 Error: e.Payload.Error,
612 Counts: mcp.Counts{
613 Tools: e.Payload.ToolCount,
614 Prompts: e.Payload.PromptCount,
615 Resources: e.Payload.ResourceCount,
616 },
617 },
618 }
619 case pubsub.Event[proto.PermissionRequest]:
620 return pubsub.Event[permission.PermissionRequest]{
621 Type: e.Type,
622 Payload: permission.PermissionRequest{
623 ID: e.Payload.ID,
624 SessionID: e.Payload.SessionID,
625 ToolCallID: e.Payload.ToolCallID,
626 ToolName: e.Payload.ToolName,
627 Description: e.Payload.Description,
628 Action: e.Payload.Action,
629 Path: e.Payload.Path,
630 Params: e.Payload.Params,
631 },
632 }
633 case pubsub.Event[proto.PermissionNotification]:
634 return pubsub.Event[permission.PermissionNotification]{
635 Type: e.Type,
636 Payload: permission.PermissionNotification{
637 ToolCallID: e.Payload.ToolCallID,
638 Granted: e.Payload.Granted,
639 Denied: e.Payload.Denied,
640 },
641 }
642 case pubsub.Event[proto.Message]:
643 return pubsub.Event[message.Message]{
644 Type: e.Type,
645 Payload: protoToMessage(e.Payload),
646 }
647 case pubsub.Event[proto.Session]:
648 return pubsub.Event[session.Session]{
649 Type: e.Type,
650 Payload: protoToSession(e.Payload),
651 }
652 case pubsub.Event[proto.File]:
653 return pubsub.Event[history.File]{
654 Type: e.Type,
655 Payload: protoToFile(e.Payload),
656 }
657 case pubsub.Event[proto.AgentEvent]:
658 return pubsub.Event[notify.Notification]{
659 Type: e.Type,
660 Payload: notify.Notification{
661 SessionID: e.Payload.SessionID,
662 SessionTitle: e.Payload.SessionTitle,
663 Type: notify.Type(e.Payload.Type),
664 },
665 }
666 default:
667 slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
668 return nil
669 }
670}
671
672func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
673 switch t {
674 case proto.MCPEventStateChanged:
675 return mcp.EventStateChanged
676 case proto.MCPEventToolsListChanged:
677 return mcp.EventToolsListChanged
678 case proto.MCPEventPromptsListChanged:
679 return mcp.EventPromptsListChanged
680 case proto.MCPEventResourcesListChanged:
681 return mcp.EventResourcesListChanged
682 default:
683 return mcp.EventStateChanged
684 }
685}
686
687// protoToSession converts a wire-level proto.Session into the domain
688// session.Session. Fields that exist only on the wire (computed-on-read
689// signals like IsBusy, and any future presence counters) are
690// intentionally dropped here: session.Session models persisted state,
691// not transient runtime signals. UI features that need those signals
692// should either extend session.Session or read them from the proto
693// payload directly before this conversion runs.
694func protoToSession(s proto.Session) session.Session {
695 return session.Session{
696 ID: s.ID,
697 ParentSessionID: s.ParentSessionID,
698 Title: s.Title,
699 SummaryMessageID: s.SummaryMessageID,
700 MessageCount: s.MessageCount,
701 PromptTokens: s.PromptTokens,
702 CompletionTokens: s.CompletionTokens,
703 Cost: s.Cost,
704 Todos: protoToTodos(s.Todos),
705 CreatedAt: s.CreatedAt,
706 UpdatedAt: s.UpdatedAt,
707 }
708}
709
710func protoToTodos(todos []proto.Todo) []session.Todo {
711 if len(todos) == 0 {
712 return nil
713 }
714 out := make([]session.Todo, len(todos))
715 for i, t := range todos {
716 out[i] = session.Todo{
717 Content: t.Content,
718 Status: session.TodoStatus(t.Status),
719 ActiveForm: t.ActiveForm,
720 }
721 }
722 return out
723}
724
725func protoToFile(f proto.File) history.File {
726 return history.File{
727 ID: f.ID,
728 SessionID: f.SessionID,
729 Path: f.Path,
730 Content: f.Content,
731 Version: f.Version,
732 CreatedAt: f.CreatedAt,
733 UpdatedAt: f.UpdatedAt,
734 }
735}
736
737func protoToMessage(m proto.Message) message.Message {
738 msg := message.Message{
739 ID: m.ID,
740 SessionID: m.SessionID,
741 Role: message.MessageRole(m.Role),
742 Model: m.Model,
743 Provider: m.Provider,
744 CreatedAt: m.CreatedAt,
745 UpdatedAt: m.UpdatedAt,
746 }
747
748 for _, p := range m.Parts {
749 switch v := p.(type) {
750 case proto.TextContent:
751 msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
752 case proto.ReasoningContent:
753 msg.Parts = append(msg.Parts, message.ReasoningContent{
754 Thinking: v.Thinking,
755 Signature: v.Signature,
756 StartedAt: v.StartedAt,
757 FinishedAt: v.FinishedAt,
758 })
759 case proto.ToolCall:
760 msg.Parts = append(msg.Parts, message.ToolCall{
761 ID: v.ID,
762 Name: v.Name,
763 Input: v.Input,
764 Finished: v.Finished,
765 })
766 case proto.ToolResult:
767 msg.Parts = append(msg.Parts, message.ToolResult{
768 ToolCallID: v.ToolCallID,
769 Name: v.Name,
770 Content: v.Content,
771 Data: v.Data,
772 MIMEType: v.MIMEType,
773 Metadata: v.Metadata,
774 IsError: v.IsError,
775 })
776 case proto.Finish:
777 msg.Parts = append(msg.Parts, message.Finish{
778 Reason: message.FinishReason(v.Reason),
779 Time: v.Time,
780 Message: v.Message,
781 Details: v.Details,
782 })
783 case proto.ImageURLContent:
784 msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
785 case proto.BinaryContent:
786 msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
787 }
788 }
789
790 return msg
791}
792
793func protoToMessages(msgs []proto.Message) []message.Message {
794 out := make([]message.Message, len(msgs))
795 for i, m := range msgs {
796 out[i] = protoToMessage(m)
797 }
798 return out
799}
800
801func protoToFiles(files []proto.File) []history.File {
802 out := make([]history.File, len(files))
803 for i, f := range files {
804 out[i] = protoToFile(f)
805 }
806 return out
807}
808
809func sessionToProto(s session.Session) proto.Session {
810 return proto.Session{
811 ID: s.ID,
812 ParentSessionID: s.ParentSessionID,
813 Title: s.Title,
814 SummaryMessageID: s.SummaryMessageID,
815 MessageCount: s.MessageCount,
816 PromptTokens: s.PromptTokens,
817 CompletionTokens: s.CompletionTokens,
818 Cost: s.Cost,
819 Todos: todosToProto(s.Todos),
820 CreatedAt: s.CreatedAt,
821 UpdatedAt: s.UpdatedAt,
822 }
823}
824
825func todosToProto(todos []session.Todo) []proto.Todo {
826 if len(todos) == 0 {
827 return nil
828 }
829 out := make([]proto.Todo, len(todos))
830 for i, t := range todos {
831 out[i] = proto.Todo{
832 Content: t.Content,
833 Status: string(t.Status),
834 ActiveForm: t.ActiveForm,
835 }
836 }
837 return out
838}