client_workspace.go

  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.ws.ID)
 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// -- Messages --
135
136func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
137	msgs, err := w.client.ListMessages(ctx, w.workspaceID(), sessionID)
138	if err != nil {
139		return nil, err
140	}
141	return protoToMessages(msgs), nil
142}
143
144func (w *ClientWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
145	msgs, err := w.client.ListUserMessages(ctx, w.workspaceID(), sessionID)
146	if err != nil {
147		return nil, err
148	}
149	return protoToMessages(msgs), nil
150}
151
152func (w *ClientWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
153	msgs, err := w.client.ListAllUserMessages(ctx, w.workspaceID())
154	if err != nil {
155		return nil, err
156	}
157	return protoToMessages(msgs), nil
158}
159
160// -- Agent --
161
162func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
163	return w.client.SendMessage(ctx, w.workspaceID(), sessionID, prompt, attachments...)
164}
165
166func (w *ClientWorkspace) AgentCancel(sessionID string) {
167	_ = w.client.CancelAgentSession(context.Background(), w.workspaceID(), sessionID)
168}
169
170func (w *ClientWorkspace) AgentIsBusy() bool {
171	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
172	if err != nil {
173		return false
174	}
175	return info.IsBusy
176}
177
178func (w *ClientWorkspace) AgentIsSessionBusy(sessionID string) bool {
179	info, err := w.client.GetAgentSessionInfo(context.Background(), w.workspaceID(), sessionID)
180	if err != nil {
181		return false
182	}
183	return info.IsBusy
184}
185
186func (w *ClientWorkspace) AgentModel() AgentModel {
187	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
188	if err != nil {
189		return AgentModel{}
190	}
191	return AgentModel{
192		CatwalkCfg: info.Model,
193		ModelCfg:   info.ModelCfg,
194	}
195}
196
197func (w *ClientWorkspace) AgentIsReady() bool {
198	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
199	if err != nil {
200		return false
201	}
202	return info.IsReady
203}
204
205func (w *ClientWorkspace) AgentQueuedPrompts(sessionID string) int {
206	count, err := w.client.GetAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
207	if err != nil {
208		return 0
209	}
210	return count
211}
212
213func (w *ClientWorkspace) AgentQueuedPromptsList(sessionID string) []string {
214	prompts, err := w.client.GetAgentSessionQueuedPromptsList(context.Background(), w.workspaceID(), sessionID)
215	if err != nil {
216		return nil
217	}
218	return prompts
219}
220
221func (w *ClientWorkspace) AgentClearQueue(sessionID string) {
222	_ = w.client.ClearAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
223}
224
225func (w *ClientWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
226	return w.client.AgentSummarizeSession(ctx, w.workspaceID(), sessionID)
227}
228
229func (w *ClientWorkspace) UpdateAgentModel(ctx context.Context) error {
230	return w.client.UpdateAgent(ctx, w.workspaceID())
231}
232
233func (w *ClientWorkspace) InitCoderAgent(ctx context.Context) error {
234	return w.client.InitiateAgentProcessing(ctx, w.workspaceID())
235}
236
237func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
238	model, err := w.client.GetDefaultSmallModel(context.Background(), w.workspaceID(), providerID)
239	if err != nil {
240		return config.SelectedModel{}
241	}
242	return *model
243}
244
245// -- Permissions --
246
247func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) {
248	_ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
249		Permission: proto.PermissionRequest{
250			ID:          perm.ID,
251			SessionID:   perm.SessionID,
252			ToolCallID:  perm.ToolCallID,
253			ToolName:    perm.ToolName,
254			Description: perm.Description,
255			Action:      perm.Action,
256			Path:        perm.Path,
257			Params:      perm.Params,
258		},
259		Action: proto.PermissionAllowForSession,
260	})
261}
262
263func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
264	_ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
265		Permission: proto.PermissionRequest{
266			ID:          perm.ID,
267			SessionID:   perm.SessionID,
268			ToolCallID:  perm.ToolCallID,
269			ToolName:    perm.ToolName,
270			Description: perm.Description,
271			Action:      perm.Action,
272			Path:        perm.Path,
273			Params:      perm.Params,
274		},
275		Action: proto.PermissionAllow,
276	})
277}
278
279func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) {
280	_ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
281		Permission: proto.PermissionRequest{
282			ID:          perm.ID,
283			SessionID:   perm.SessionID,
284			ToolCallID:  perm.ToolCallID,
285			ToolName:    perm.ToolName,
286			Description: perm.Description,
287			Action:      perm.Action,
288			Path:        perm.Path,
289			Params:      perm.Params,
290		},
291		Action: proto.PermissionDeny,
292	})
293}
294
295func (w *ClientWorkspace) PermissionSkipRequests() bool {
296	skip, err := w.client.GetPermissionsSkipRequests(context.Background(), w.workspaceID())
297	if err != nil {
298		return false
299	}
300	return skip
301}
302
303func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) {
304	_ = w.client.SetPermissionsSkipRequests(context.Background(), w.workspaceID(), skip)
305}
306
307// -- FileTracker --
308
309func (w *ClientWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
310	_ = w.client.FileTrackerRecordRead(ctx, w.workspaceID(), sessionID, path)
311}
312
313func (w *ClientWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
314	t, err := w.client.FileTrackerLastReadTime(ctx, w.workspaceID(), sessionID, path)
315	if err != nil {
316		return time.Time{}
317	}
318	return t
319}
320
321func (w *ClientWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
322	return w.client.FileTrackerListReadFiles(ctx, w.workspaceID(), sessionID)
323}
324
325// -- History --
326
327func (w *ClientWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
328	files, err := w.client.ListSessionHistoryFiles(ctx, w.workspaceID(), sessionID)
329	if err != nil {
330		return nil, err
331	}
332	return protoToFiles(files), nil
333}
334
335// -- LSP --
336
337func (w *ClientWorkspace) LSPStart(ctx context.Context, path string) {
338	_ = w.client.LSPStart(ctx, w.workspaceID(), path)
339}
340
341func (w *ClientWorkspace) LSPStopAll(ctx context.Context) {
342	_ = w.client.LSPStopAll(ctx, w.workspaceID())
343}
344
345func (w *ClientWorkspace) LSPGetStates() map[string]LSPClientInfo {
346	states, err := w.client.GetLSPs(context.Background(), w.workspaceID())
347	if err != nil {
348		return nil
349	}
350	result := make(map[string]LSPClientInfo, len(states))
351	for k, v := range states {
352		result[k] = LSPClientInfo{
353			Name:            v.Name,
354			State:           v.State,
355			Error:           v.Error,
356			DiagnosticCount: v.DiagnosticCount,
357			ConnectedAt:     v.ConnectedAt,
358		}
359	}
360	return result
361}
362
363func (w *ClientWorkspace) LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts {
364	diags, err := w.client.GetLSPDiagnostics(context.Background(), w.workspaceID(), name)
365	if err != nil {
366		return lsp.DiagnosticCounts{}
367	}
368	var counts lsp.DiagnosticCounts
369	for _, fileDiags := range diags {
370		for _, d := range fileDiags {
371			switch d.Severity {
372			case protocol.SeverityError:
373				counts.Error++
374			case protocol.SeverityWarning:
375				counts.Warning++
376			case protocol.SeverityInformation:
377				counts.Information++
378			case protocol.SeverityHint:
379				counts.Hint++
380			}
381		}
382	}
383	return counts
384}
385
386// -- Config (read-only) --
387
388func (w *ClientWorkspace) Config() *config.Config {
389	return w.cached().Config
390}
391
392func (w *ClientWorkspace) WorkingDir() string {
393	return w.cached().Path
394}
395
396func (w *ClientWorkspace) Resolver() config.VariableResolver {
397	return config.IdentityResolver()
398}
399
400// -- Config mutations --
401
402func (w *ClientWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
403	err := w.client.UpdatePreferredModel(context.Background(), w.workspaceID(), scope, modelType, model)
404	if err == nil {
405		w.refreshWorkspace()
406	}
407	return err
408}
409
410func (w *ClientWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
411	err := w.client.SetCompactMode(context.Background(), w.workspaceID(), scope, enabled)
412	if err == nil {
413		w.refreshWorkspace()
414	}
415	return err
416}
417
418func (w *ClientWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
419	err := w.client.SetProviderAPIKey(context.Background(), w.workspaceID(), scope, providerID, apiKey)
420	if err == nil {
421		w.refreshWorkspace()
422	}
423	return err
424}
425
426func (w *ClientWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
427	err := w.client.SetConfigField(context.Background(), w.workspaceID(), scope, key, value)
428	if err == nil {
429		w.refreshWorkspace()
430	}
431	return err
432}
433
434func (w *ClientWorkspace) RemoveConfigField(scope config.Scope, key string) error {
435	err := w.client.RemoveConfigField(context.Background(), w.workspaceID(), scope, key)
436	if err == nil {
437		w.refreshWorkspace()
438	}
439	return err
440}
441
442func (w *ClientWorkspace) ImportCopilot() (*oauth.Token, bool) {
443	token, ok, err := w.client.ImportCopilot(context.Background(), w.workspaceID())
444	if err != nil {
445		return nil, false
446	}
447	if ok {
448		w.refreshWorkspace()
449	}
450	return token, ok
451}
452
453func (w *ClientWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
454	err := w.client.RefreshOAuthToken(ctx, w.workspaceID(), scope, providerID)
455	if err == nil {
456		w.refreshWorkspace()
457	}
458	return err
459}
460
461// -- Project lifecycle --
462
463func (w *ClientWorkspace) ProjectNeedsInitialization() (bool, error) {
464	return w.client.ProjectNeedsInitialization(context.Background(), w.workspaceID())
465}
466
467func (w *ClientWorkspace) MarkProjectInitialized() error {
468	return w.client.MarkProjectInitialized(context.Background(), w.workspaceID())
469}
470
471func (w *ClientWorkspace) InitializePrompt() (string, error) {
472	return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
473}
474
475// -- MCP operations --
476
477func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
478	states, err := w.client.MCPGetStates(context.Background(), w.workspaceID())
479	if err != nil {
480		return nil
481	}
482	result := make(map[string]mcp.ClientInfo, len(states))
483	for k, v := range states {
484		result[k] = mcp.ClientInfo{
485			Name:  v.Name,
486			State: mcp.State(v.State),
487			Error: v.Error,
488			Counts: mcp.Counts{
489				Tools:     v.ToolCount,
490				Prompts:   v.PromptCount,
491				Resources: v.ResourceCount,
492			},
493			ConnectedAt: v.ConnectedAt,
494		}
495	}
496	return result
497}
498
499func (w *ClientWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
500	_ = w.client.MCPRefreshPrompts(ctx, w.workspaceID(), name)
501}
502
503func (w *ClientWorkspace) MCPRefreshResources(ctx context.Context, name string) {
504	_ = w.client.MCPRefreshResources(ctx, w.workspaceID(), name)
505}
506
507func (w *ClientWorkspace) RefreshMCPTools(ctx context.Context, name string) {
508	_ = w.client.RefreshMCPTools(ctx, w.workspaceID(), name)
509}
510
511func (w *ClientWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
512	contents, err := w.client.ReadMCPResource(ctx, w.workspaceID(), name, uri)
513	if err != nil {
514		return nil, err
515	}
516	result := make([]MCPResourceContents, len(contents))
517	for i, c := range contents {
518		result[i] = MCPResourceContents{
519			URI:      c.URI,
520			MIMEType: c.MIMEType,
521			Text:     c.Text,
522			Blob:     c.Blob,
523		}
524	}
525	return result, nil
526}
527
528func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
529	return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
530}
531
532func (w *ClientWorkspace) EnableDockerMCP(ctx context.Context) error {
533	return w.client.EnableDockerMCP(ctx, w.workspaceID())
534}
535
536func (w *ClientWorkspace) DisableDockerMCP() error {
537	return w.client.DisableDockerMCP(context.Background(), w.workspaceID())
538}
539
540// -- Lifecycle --
541
542func (w *ClientWorkspace) Subscribe(program *tea.Program) {
543	defer log.RecoverPanic("ClientWorkspace.Subscribe", func() {
544		slog.Info("TUI subscription panic: attempting graceful shutdown")
545		program.Quit()
546	})
547
548	evc, err := w.client.SubscribeEvents(context.Background(), w.workspaceID())
549	if err != nil {
550		slog.Error("Failed to subscribe to events", "error", err)
551		return
552	}
553
554	for ev := range evc {
555		translated := translateEvent(ev)
556		if translated != nil {
557			program.Send(translated)
558		}
559	}
560}
561
562func (w *ClientWorkspace) Shutdown() {
563	_ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
564}
565
566// translateEvent converts proto-typed SSE events into the domain types
567// that the TUI's Update() method expects.
568func translateEvent(ev any) tea.Msg {
569	switch e := ev.(type) {
570	case pubsub.Event[proto.LSPEvent]:
571		return pubsub.Event[LSPEvent]{
572			Type: e.Type,
573			Payload: LSPEvent{
574				Type:            LSPEventType(e.Payload.Type),
575				Name:            e.Payload.Name,
576				State:           e.Payload.State,
577				Error:           e.Payload.Error,
578				DiagnosticCount: e.Payload.DiagnosticCount,
579			},
580		}
581	case pubsub.Event[proto.MCPEvent]:
582		return pubsub.Event[mcp.Event]{
583			Type: e.Type,
584			Payload: mcp.Event{
585				Type:  protoToMCPEventType(e.Payload.Type),
586				Name:  e.Payload.Name,
587				State: mcp.State(e.Payload.State),
588				Error: e.Payload.Error,
589				Counts: mcp.Counts{
590					Tools:     e.Payload.ToolCount,
591					Prompts:   e.Payload.PromptCount,
592					Resources: e.Payload.ResourceCount,
593				},
594			},
595		}
596	case pubsub.Event[proto.PermissionRequest]:
597		return pubsub.Event[permission.PermissionRequest]{
598			Type: e.Type,
599			Payload: permission.PermissionRequest{
600				ID:          e.Payload.ID,
601				SessionID:   e.Payload.SessionID,
602				ToolCallID:  e.Payload.ToolCallID,
603				ToolName:    e.Payload.ToolName,
604				Description: e.Payload.Description,
605				Action:      e.Payload.Action,
606				Path:        e.Payload.Path,
607				Params:      e.Payload.Params,
608			},
609		}
610	case pubsub.Event[proto.PermissionNotification]:
611		return pubsub.Event[permission.PermissionNotification]{
612			Type: e.Type,
613			Payload: permission.PermissionNotification{
614				ToolCallID: e.Payload.ToolCallID,
615				Granted:    e.Payload.Granted,
616				Denied:     e.Payload.Denied,
617			},
618		}
619	case pubsub.Event[proto.Message]:
620		return pubsub.Event[message.Message]{
621			Type:    e.Type,
622			Payload: protoToMessage(e.Payload),
623		}
624	case pubsub.Event[proto.Session]:
625		return pubsub.Event[session.Session]{
626			Type:    e.Type,
627			Payload: protoToSession(e.Payload),
628		}
629	case pubsub.Event[proto.File]:
630		return pubsub.Event[history.File]{
631			Type:    e.Type,
632			Payload: protoToFile(e.Payload),
633		}
634	case pubsub.Event[proto.AgentEvent]:
635		return pubsub.Event[notify.Notification]{
636			Type: e.Type,
637			Payload: notify.Notification{
638				SessionID:    e.Payload.SessionID,
639				SessionTitle: e.Payload.SessionTitle,
640				Type:         notify.Type(e.Payload.Type),
641			},
642		}
643	default:
644		slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
645		return nil
646	}
647}
648
649func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
650	switch t {
651	case proto.MCPEventStateChanged:
652		return mcp.EventStateChanged
653	case proto.MCPEventToolsListChanged:
654		return mcp.EventToolsListChanged
655	case proto.MCPEventPromptsListChanged:
656		return mcp.EventPromptsListChanged
657	case proto.MCPEventResourcesListChanged:
658		return mcp.EventResourcesListChanged
659	default:
660		return mcp.EventStateChanged
661	}
662}
663
664func protoToSession(s proto.Session) session.Session {
665	return session.Session{
666		ID:               s.ID,
667		ParentSessionID:  s.ParentSessionID,
668		Title:            s.Title,
669		SummaryMessageID: s.SummaryMessageID,
670		MessageCount:     s.MessageCount,
671		PromptTokens:     s.PromptTokens,
672		CompletionTokens: s.CompletionTokens,
673		Cost:             s.Cost,
674		CreatedAt:        s.CreatedAt,
675		UpdatedAt:        s.UpdatedAt,
676	}
677}
678
679func protoToFile(f proto.File) history.File {
680	return history.File{
681		ID:        f.ID,
682		SessionID: f.SessionID,
683		Path:      f.Path,
684		Content:   f.Content,
685		Version:   f.Version,
686		CreatedAt: f.CreatedAt,
687		UpdatedAt: f.UpdatedAt,
688	}
689}
690
691func protoToMessage(m proto.Message) message.Message {
692	msg := message.Message{
693		ID:        m.ID,
694		SessionID: m.SessionID,
695		Role:      message.MessageRole(m.Role),
696		Model:     m.Model,
697		Provider:  m.Provider,
698		CreatedAt: m.CreatedAt,
699		UpdatedAt: m.UpdatedAt,
700	}
701
702	for _, p := range m.Parts {
703		switch v := p.(type) {
704		case proto.TextContent:
705			msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
706		case proto.ReasoningContent:
707			msg.Parts = append(msg.Parts, message.ReasoningContent{
708				Thinking:   v.Thinking,
709				Signature:  v.Signature,
710				StartedAt:  v.StartedAt,
711				FinishedAt: v.FinishedAt,
712			})
713		case proto.ToolCall:
714			msg.Parts = append(msg.Parts, message.ToolCall{
715				ID:       v.ID,
716				Name:     v.Name,
717				Input:    v.Input,
718				Finished: v.Finished,
719			})
720		case proto.ToolResult:
721			msg.Parts = append(msg.Parts, message.ToolResult{
722				ToolCallID: v.ToolCallID,
723				Name:       v.Name,
724				Content:    v.Content,
725				IsError:    v.IsError,
726			})
727		case proto.Finish:
728			msg.Parts = append(msg.Parts, message.Finish{
729				Reason:  message.FinishReason(v.Reason),
730				Time:    v.Time,
731				Message: v.Message,
732				Details: v.Details,
733			})
734		case proto.ImageURLContent:
735			msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
736		case proto.BinaryContent:
737			msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
738		}
739	}
740
741	return msg
742}
743
744func protoToMessages(msgs []proto.Message) []message.Message {
745	out := make([]message.Message, len(msgs))
746	for i, m := range msgs {
747		out[i] = protoToMessage(m)
748	}
749	return out
750}
751
752func protoToFiles(files []proto.File) []history.File {
753	out := make([]history.File, len(files))
754	for i, f := range files {
755		out[i] = protoToFile(f)
756	}
757	return out
758}
759
760func sessionToProto(s session.Session) proto.Session {
761	return proto.Session{
762		ID:               s.ID,
763		ParentSessionID:  s.ParentSessionID,
764		Title:            s.Title,
765		SummaryMessageID: s.SummaryMessageID,
766		MessageCount:     s.MessageCount,
767		PromptTokens:     s.PromptTokens,
768		CompletionTokens: s.CompletionTokens,
769		Cost:             s.Cost,
770		CreatedAt:        s.CreatedAt,
771		UpdatedAt:        s.UpdatedAt,
772	}
773}