fix: remove client ID storage and pass it explicitly

Ayman Bagabas created

Change summary

internal/client/client.go                            | 11 -
internal/client/proto.go                             | 80 ++++++------
internal/cmd/root.go                                 | 22 +--
internal/cmd/run.go                                  |  6 
internal/proto/proto.go                              | 12 +
internal/server/proto.go                             | 26 ++++
internal/server/server.go                            | 11 -
internal/tui/components/chat/chat.go                 | 34 ++--
internal/tui/components/chat/editor/editor.go        | 19 +-
internal/tui/components/chat/header/header.go        | 18 +-
internal/tui/components/chat/sidebar/sidebar.go      | 47 ++++---
internal/tui/components/chat/splash/splash.go        | 51 ++++----
internal/tui/components/dialogs/commands/commands.go |  2 
internal/tui/components/dialogs/compact/compact.go   | 11 +
internal/tui/components/lsp/lsp.go                   | 16 +-
internal/tui/page/chat/chat.go                       | 83 +++++++------
internal/tui/tui.go                                  | 63 ++++-----
17 files changed, 259 insertions(+), 253 deletions(-)

Detailed changes

internal/client/client.go 🔗

@@ -23,7 +23,6 @@ const DummyHost = "api.crush.localhost"
 // Client represents an RPC client connected to a Crush server.
 type Client struct {
 	h       *http.Client
-	id      string
 	path    string
 	network string
 	addr    string
@@ -62,16 +61,6 @@ func NewClient(path, network, address string) (*Client, error) {
 	return c, nil
 }
 
-// ID returns the client's instance unique identifier.
-func (c *Client) ID() string {
-	return c.id
-}
-
-// SetID sets the client's instance unique identifier.
-func (c *Client) SetID(id string) {
-	c.id = id
-}
-
 // Path returns the client's instance filesystem path.
 func (c *Client) Path() string {
 	return c.path

internal/client/proto.go 🔗

@@ -22,9 +22,9 @@ import (
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
 
-func (c *Client) SubscribeEvents(ctx context.Context) (<-chan any, error) {
+func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, error) {
 	events := make(chan any, 100)
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/events", c.id), nil, http.Header{
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/events", id), nil, http.Header{
 		"Accept":        []string{"text/event-stream"},
 		"Cache-Control": []string{"no-cache"},
 		"Connection":    []string{"keep-alive"},
@@ -143,8 +143,8 @@ func sendEvent(ctx context.Context, evc chan any, ev any) {
 	}
 }
 
-func (c *Client) GetLSPDiagnostics(ctx context.Context, lsp string) (map[protocol.DocumentURI][]protocol.Diagnostic, error) {
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/lsps/%s/diagnostics", c.id, lsp), nil, nil)
+func (c *Client) GetLSPDiagnostics(ctx context.Context, id string, lsp string) (map[protocol.DocumentURI][]protocol.Diagnostic, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/lsps/%s/diagnostics", id, lsp), nil, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get LSP diagnostics: %w", err)
 	}
@@ -159,8 +159,8 @@ func (c *Client) GetLSPDiagnostics(ctx context.Context, lsp string) (map[protoco
 	return diagnostics, nil
 }
 
-func (c *Client) GetLSPs(ctx context.Context) (map[string]app.LSPClientInfo, error) {
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/lsps", c.id), nil, nil)
+func (c *Client) GetLSPs(ctx context.Context, id string) (map[string]app.LSPClientInfo, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/lsps", id), nil, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get LSPs: %w", err)
 	}
@@ -175,8 +175,8 @@ func (c *Client) GetLSPs(ctx context.Context) (map[string]app.LSPClientInfo, err
 	return lsps, nil
 }
 
-func (c *Client) GetAgentSessionQueuedPrompts(ctx context.Context, sessionID string) (int, error) {
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/agent/sessions/%s/prompts/queued", c.id, sessionID), nil, nil)
+func (c *Client) GetAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) (int, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/agent/sessions/%s/prompts/queued", id, sessionID), nil, nil)
 	if err != nil {
 		return 0, fmt.Errorf("failed to get session agent queued prompts: %w", err)
 	}
@@ -191,8 +191,8 @@ func (c *Client) GetAgentSessionQueuedPrompts(ctx context.Context, sessionID str
 	return count, nil
 }
 
-func (c *Client) ClearAgentSessionQueuedPrompts(ctx context.Context, sessionID string) error {
-	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/agent/sessions/%s/prompts/clear", c.id, sessionID), nil, nil, nil)
+func (c *Client) ClearAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/agent/sessions/%s/prompts/clear", id, sessionID), nil, nil, nil)
 	if err != nil {
 		return fmt.Errorf("failed to clear session agent queued prompts: %w", err)
 	}
@@ -203,8 +203,8 @@ func (c *Client) ClearAgentSessionQueuedPrompts(ctx context.Context, sessionID s
 	return nil
 }
 
-func (c *Client) GetAgentInfo(ctx context.Context) (*proto.AgentInfo, error) {
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/agent", c.id), nil, nil)
+func (c *Client) GetAgentInfo(ctx context.Context, id string) (*proto.AgentInfo, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/agent", id), nil, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get agent status: %w", err)
 	}
@@ -219,8 +219,8 @@ func (c *Client) GetAgentInfo(ctx context.Context) (*proto.AgentInfo, error) {
 	return &info, nil
 }
 
-func (c *Client) UpdateAgent(ctx context.Context) error {
-	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/agent/update", c.id), nil, nil, nil)
+func (c *Client) UpdateAgent(ctx context.Context, id string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/agent/update", id), nil, nil, nil)
 	if err != nil {
 		return fmt.Errorf("failed to update agent: %w", err)
 	}
@@ -231,8 +231,8 @@ func (c *Client) UpdateAgent(ctx context.Context) error {
 	return nil
 }
 
-func (c *Client) SendMessage(ctx context.Context, sessionID, message string, attchments ...message.Attachment) error {
-	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/agent", c.id), nil, jsonBody(proto.AgentMessage{
+func (c *Client) SendMessage(ctx context.Context, id string, sessionID, message string, attchments ...message.Attachment) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/agent", id), nil, jsonBody(proto.AgentMessage{
 		SessionID:   sessionID,
 		Prompt:      message,
 		Attachments: attchments,
@@ -247,8 +247,8 @@ func (c *Client) SendMessage(ctx context.Context, sessionID, message string, att
 	return nil
 }
 
-func (c *Client) GetAgentSessionInfo(ctx context.Context, sessionID string) (*proto.AgentSession, error) {
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/agent/sessions/%s", c.id, sessionID), nil, nil)
+func (c *Client) GetAgentSessionInfo(ctx context.Context, id string, sessionID string) (*proto.AgentSession, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/agent/sessions/%s", id, sessionID), nil, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get session agent info: %w", err)
 	}
@@ -263,8 +263,8 @@ func (c *Client) GetAgentSessionInfo(ctx context.Context, sessionID string) (*pr
 	return &info, nil
 }
 
-func (c *Client) AgentSummarizeSession(ctx context.Context, sessionID string) error {
-	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/agent/sessions/%s/summarize", c.id, sessionID), nil, nil, nil)
+func (c *Client) AgentSummarizeSession(ctx context.Context, id string, sessionID string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/agent/sessions/%s/summarize", id, sessionID), nil, nil, nil)
 	if err != nil {
 		return fmt.Errorf("failed to summarize session: %w", err)
 	}
@@ -275,8 +275,8 @@ func (c *Client) AgentSummarizeSession(ctx context.Context, sessionID string) er
 	return nil
 }
 
-func (c *Client) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/sessions/%s/messages", c.id, sessionID), nil, nil)
+func (c *Client) ListMessages(ctx context.Context, id string, sessionID string) ([]message.Message, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/sessions/%s/messages", id, sessionID), nil, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get messages: %w", err)
 	}
@@ -291,8 +291,8 @@ func (c *Client) ListMessages(ctx context.Context, sessionID string) ([]message.
 	return messages, nil
 }
 
-func (c *Client) GetSession(ctx context.Context, sessionID string) (*session.Session, error) {
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/sessions/%s", c.id, sessionID), nil, nil)
+func (c *Client) GetSession(ctx context.Context, id string, sessionID string) (*session.Session, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/sessions/%s", id, sessionID), nil, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get session: %w", err)
 	}
@@ -307,8 +307,8 @@ func (c *Client) GetSession(ctx context.Context, sessionID string) (*session.Ses
 	return &sess, nil
 }
 
-func (c *Client) InitiateAgentProcessing(ctx context.Context) error {
-	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/agent/init", c.id), nil, nil, nil)
+func (c *Client) InitiateAgentProcessing(ctx context.Context, id string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/agent/init", id), nil, nil, nil)
 	if err != nil {
 		return fmt.Errorf("failed to initiate session agent processing: %w", err)
 	}
@@ -319,8 +319,8 @@ func (c *Client) InitiateAgentProcessing(ctx context.Context) error {
 	return nil
 }
 
-func (c *Client) ListSessionHistoryFiles(ctx context.Context, sessionID string) ([]history.File, error) {
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/sessions/%s/history", c.id, sessionID), nil, nil)
+func (c *Client) ListSessionHistoryFiles(ctx context.Context, id string, sessionID string) ([]history.File, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/sessions/%s/history", id, sessionID), nil, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get session history files: %w", err)
 	}
@@ -335,8 +335,8 @@ func (c *Client) ListSessionHistoryFiles(ctx context.Context, sessionID string)
 	return files, nil
 }
 
-func (c *Client) CreateSession(ctx context.Context, title string) (*session.Session, error) {
-	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/sessions", c.id), nil, jsonBody(session.Session{Title: title}), http.Header{"Content-Type": []string{"application/json"}})
+func (c *Client) CreateSession(ctx context.Context, id string, title string) (*session.Session, error) {
+	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/sessions", id), nil, jsonBody(session.Session{Title: title}), http.Header{"Content-Type": []string{"application/json"}})
 	if err != nil {
 		return nil, fmt.Errorf("failed to create session: %w", err)
 	}
@@ -351,8 +351,8 @@ func (c *Client) CreateSession(ctx context.Context, title string) (*session.Sess
 	return &sess, nil
 }
 
-func (c *Client) ListSessions(ctx context.Context) ([]session.Session, error) {
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/sessions", c.id), nil, nil)
+func (c *Client) ListSessions(ctx context.Context, id string) ([]session.Session, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/sessions", id), nil, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get sessions: %w", err)
 	}
@@ -367,8 +367,8 @@ func (c *Client) ListSessions(ctx context.Context) ([]session.Session, error) {
 	return sessions, nil
 }
 
-func (c *Client) GrantPermission(ctx context.Context, req proto.PermissionGrant) error {
-	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/permissions/grant", c.id), nil, jsonBody(req), http.Header{"Content-Type": []string{"application/json"}})
+func (c *Client) GrantPermission(ctx context.Context, id string, req proto.PermissionGrant) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/permissions/grant", id), nil, jsonBody(req), http.Header{"Content-Type": []string{"application/json"}})
 	if err != nil {
 		return fmt.Errorf("failed to grant permission: %w", err)
 	}
@@ -379,8 +379,8 @@ func (c *Client) GrantPermission(ctx context.Context, req proto.PermissionGrant)
 	return nil
 }
 
-func (c *Client) SetPermissionsSkipRequests(ctx context.Context, skip bool) error {
-	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/permissions/skip", c.id), nil, jsonBody(proto.PermissionSkipRequest{Skip: skip}), http.Header{"Content-Type": []string{"application/json"}})
+func (c *Client) SetPermissionsSkipRequests(ctx context.Context, id string, skip bool) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/instances/%s/permissions/skip", id), nil, jsonBody(proto.PermissionSkipRequest{Skip: skip}), http.Header{"Content-Type": []string{"application/json"}})
 	if err != nil {
 		return fmt.Errorf("failed to set permissions skip requests: %w", err)
 	}
@@ -391,8 +391,8 @@ func (c *Client) SetPermissionsSkipRequests(ctx context.Context, skip bool) erro
 	return nil
 }
 
-func (c *Client) GetPermissionsSkipRequests(ctx context.Context) (bool, error) {
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/permissions/skip", c.id), nil, nil)
+func (c *Client) GetPermissionsSkipRequests(ctx context.Context, id string) (bool, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/permissions/skip", id), nil, nil)
 	if err != nil {
 		return false, fmt.Errorf("failed to get permissions skip requests: %w", err)
 	}
@@ -407,8 +407,8 @@ func (c *Client) GetPermissionsSkipRequests(ctx context.Context) (bool, error) {
 	return skip.Skip, nil
 }
 
-func (c *Client) GetConfig(ctx context.Context) (*config.Config, error) {
-	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/config", c.id), nil, nil)
+func (c *Client) GetConfig(ctx context.Context, id string) (*config.Config, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/instances/%s/config", id), nil, nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get config: %w", err)
 	}

internal/cmd/root.go 🔗

@@ -108,7 +108,7 @@ crush -y
 			// TODO: implement TCP support
 		}
 
-		c, err := setupApp(cmd, hostURL)
+		c, ins, err := setupApp(cmd, hostURL)
 		if err != nil {
 			return err
 		}
@@ -128,12 +128,12 @@ crush -y
 			return fmt.Errorf("failed to connect to crush server: %v", err)
 		}
 
-		m, err := tui.New(c)
+		m, err := tui.New(c, ins)
 		if err != nil {
 			return fmt.Errorf("failed to create TUI model: %v", err)
 		}
 
-		defer func() { c.DeleteInstance(cmd.Context(), c.ID()) }()
+		defer func() { c.DeleteInstance(cmd.Context(), ins.ID) }()
 
 		event.AppInitialized()
 
@@ -146,7 +146,7 @@ crush -y
 			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
 		)
 
-		evc, err := c.SubscribeEvents(cmd.Context())
+		evc, err := c.SubscribeEvents(cmd.Context(), ins.ID)
 		if err != nil {
 			return fmt.Errorf("failed to subscribe to events: %v", err)
 		}
@@ -199,7 +199,7 @@ func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
 
 // setupApp handles the common setup logic for both interactive and non-interactive modes.
 // It returns the app instance, config, cleanup function, and any error.
-func setupApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, error) {
+func setupApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *proto.Instance, error) {
 	debug, _ := cmd.Flags().GetBool("debug")
 	yolo, _ := cmd.Flags().GetBool("yolo")
 	dataDir, _ := cmd.Flags().GetString("data-dir")
@@ -207,12 +207,12 @@ func setupApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, error) {
 
 	cwd, err := ResolveCwd(cmd)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	ins, err := c.CreateInstance(ctx, proto.Instance{
@@ -222,21 +222,19 @@ func setupApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, error) {
 		YOLO:    yolo,
 	})
 	if err != nil {
-		return nil, fmt.Errorf("failed to create or connect to instance: %v", err)
+		return nil, nil, fmt.Errorf("failed to create or connect to instance: %v", err)
 	}
 
-	c.SetID(ins.ID)
-
 	cfg, err := c.GetGlobalConfig(cmd.Context())
 	if err != nil {
-		return nil, fmt.Errorf("failed to get global config: %v", err)
+		return nil, nil, fmt.Errorf("failed to get global config: %v", err)
 	}
 
 	if shouldEnableMetrics(cfg) {
 		event.Init()
 	}
 
-	return c, nil
+	return c, ins, nil
 }
 
 var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)

internal/cmd/run.go 🔗

@@ -31,13 +31,13 @@ crush run -q "Generate a README for this project"
 			return fmt.Errorf("invalid host URL: %v", err)
 		}
 
-		c, err := setupApp(cmd, hostURL)
+		c, ins, err := setupApp(cmd, hostURL)
 		if err != nil {
 			return err
 		}
-		defer func() { c.DeleteInstance(cmd.Context(), c.ID()) }()
+		defer func() { c.DeleteInstance(cmd.Context(), ins.ID) }()
 
-		cfg, err := c.GetConfig(cmd.Context())
+		cfg, err := c.GetConfig(cmd.Context(), ins.ID)
 		if err != nil {
 			return fmt.Errorf("failed to get config: %v", err)
 		}

internal/proto/proto.go 🔗

@@ -4,17 +4,19 @@ import (
 	"time"
 
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/lsp"
 )
 
 // Instance represents a running app.App instance with its associated resources
 // and state.
 type Instance struct {
-	ID      string `json:"id"`
-	Path    string `json:"path"`
-	YOLO    bool   `json:"yolo,omitempty"`
-	Debug   bool   `json:"debug,omitempty"`
-	DataDir string `json:"data_dir,omitempty"`
+	ID      string         `json:"id"`
+	Path    string         `json:"path"`
+	YOLO    bool           `json:"yolo,omitempty"`
+	Debug   bool           `json:"debug,omitempty"`
+	DataDir string         `json:"data_dir,omitempty"`
+	Config  *config.Config `json:"config,omitempty"`
 }
 
 // Error represents an error response.

internal/server/proto.go 🔗

@@ -67,12 +67,14 @@ func (c *controllerV1) handleGetConfig(w http.ResponseWriter, r *http.Request) {
 func (c *controllerV1) handleGetInstances(w http.ResponseWriter, r *http.Request) {
 	instances := []proto.Instance{}
 	for _, ins := range c.instances.Seq2() {
+		// TODO: implement pagination?
 		instances = append(instances, proto.Instance{
-			ID:      ins.ID(),
-			Path:    ins.Path(),
+			ID:      ins.id,
+			Path:    ins.path,
 			YOLO:    ins.cfg.Permissions != nil && ins.cfg.Permissions.SkipRequests,
 			DataDir: ins.cfg.Options.DataDirectory,
 			Debug:   ins.cfg.Options.Debug,
+			Config:  ins.cfg,
 		})
 	}
 	jsonEncode(w, instances)
@@ -512,6 +514,25 @@ func (c *controllerV1) handleDeleteInstances(w http.ResponseWriter, r *http.Requ
 	c.instances.Del(id)
 }
 
+func (c *controllerV1) handleGetInstance(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	ins, ok := c.instances.Get(id)
+	if !ok {
+		c.logError(r, "instance not found", "id", id)
+		jsonError(w, http.StatusNotFound, "instance not found")
+		return
+	}
+
+	jsonEncode(w, proto.Instance{
+		ID:      ins.id,
+		Path:    ins.path,
+		YOLO:    ins.cfg.Permissions != nil && ins.cfg.Permissions.SkipRequests,
+		DataDir: ins.cfg.Options.DataDirectory,
+		Debug:   ins.cfg.Options.Debug,
+		Config:  ins.cfg,
+	})
+}
+
 func (c *controllerV1) handlePostInstances(w http.ResponseWriter, r *http.Request) {
 	var args proto.Instance
 	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
@@ -575,6 +596,7 @@ func (c *controllerV1) handlePostInstances(w http.ResponseWriter, r *http.Reques
 		DataDir: cfg.Options.DataDirectory,
 		Debug:   cfg.Options.Debug,
 		YOLO:    cfg.Permissions.SkipRequests,
+		Config:  cfg,
 	})
 }
 

internal/server/server.go 🔗

@@ -42,16 +42,6 @@ type Instance struct {
 	path  string
 }
 
-// ID returns the unique identifier of the instance.
-func (i *Instance) ID() string {
-	return i.id
-}
-
-// Path returns the filesystem path associated with the instance.
-func (i *Instance) Path() string {
-	return i.path
-}
-
 // ParseHostURL parses a host URL into a [url.URL].
 func ParseHostURL(host string) (*url.URL, error) {
 	proto, addr, ok := strings.Cut(host, "://")
@@ -141,6 +131,7 @@ func NewServer(cfg *config.Config, network, address string) *Server {
 	mux.HandleFunc("GET /v1/instances", c.handleGetInstances)
 	mux.HandleFunc("POST /v1/instances", c.handlePostInstances)
 	mux.HandleFunc("DELETE /v1/instances/{id}", c.handleDeleteInstances)
+	mux.HandleFunc("GET /v1/instances/{id}", c.handleGetInstance)
 	mux.HandleFunc("GET /v1/instances/{id}/config", c.handleGetInstanceConfig)
 	mux.HandleFunc("GET /v1/instances/{id}/events", c.handleGetInstanceEvents)
 	mux.HandleFunc("GET /v1/instances/{id}/sessions", c.handleGetInstanceSessions)

internal/tui/components/chat/chat.go 🔗

@@ -9,10 +9,10 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/client"
-	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
@@ -59,8 +59,8 @@ type MessageListCmp interface {
 // of chat messages with support for tool calls, real-time updates, and
 // session switching.
 type messageListCmp struct {
-	client           *client.Client
-	cfg              *config.Config
+	c                *client.Client
+	ins              *proto.Instance
 	width, height    int
 	session          session.Session
 	listCmp          list.List[list.Item]
@@ -79,7 +79,7 @@ type messageListCmp struct {
 
 // New creates a new message list component with custom keybindings
 // and reverse ordering (newest messages at bottom).
-func New(app *client.Client, cfg *config.Config) MessageListCmp {
+func New(app *client.Client, ins *proto.Instance) MessageListCmp {
 	defaultListKeyMap := list.DefaultKeyMap()
 	listCmp := list.New(
 		[]list.Item{},
@@ -90,8 +90,8 @@ func New(app *client.Client, cfg *config.Config) MessageListCmp {
 		list.WithEnableMouse(),
 	)
 	return &messageListCmp{
-		client:            app,
-		cfg:               cfg,
+		c:                 app,
+		ins:               ins,
 		listCmp:           listCmp,
 		previousSelected:  "",
 		defaultListKeyMap: defaultListKeyMap,
@@ -106,9 +106,9 @@ func (m *messageListCmp) Init() tea.Cmd {
 // Update handles incoming messages and updates the component state.
 func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
-	info, err := m.client.GetAgentInfo(context.TODO())
+	info, err := m.c.GetAgentInfo(context.TODO(), m.ins.ID)
 	if m.session.ID != "" && err == nil && !info.IsZero() {
-		queueSize, _ := m.client.GetAgentSessionQueuedPrompts(context.TODO(), m.session.ID)
+		queueSize, _ := m.c.GetAgentSessionQueuedPrompts(context.TODO(), m.ins.ID, m.session.ID)
 		if queueSize != m.promptQueue {
 			m.promptQueue = queueSize
 			cmds = append(cmds, m.SetSize(m.width, m.height))
@@ -239,7 +239,7 @@ func (m *messageListCmp) View() string {
 				m.listCmp.View(),
 			),
 	}
-	info, err := m.client.GetAgentInfo(context.TODO())
+	info, err := m.c.GetAgentInfo(context.TODO(), m.ins.ID)
 	if err == nil && !info.IsZero() && m.promptQueue > 0 {
 		queuePill := queuePill(m.promptQueue, t)
 		view = append(view, t.S().Base.PaddingLeft(4).PaddingTop(1).Render(queuePill))
@@ -373,7 +373,7 @@ func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
 // handleNewUserMessage adds a new user message to the list and updates the timestamp.
 func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
 	m.lastUserMessageTime = msg.CreatedAt
-	return m.listCmp.AppendItem(messages.NewMessageCmp(m.cfg, msg))
+	return m.listCmp.AppendItem(messages.NewMessageCmp(m.ins.Config, msg))
 }
 
 // handleToolMessage updates existing tool calls with their results.
@@ -466,7 +466,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
 		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 			m.listCmp.AppendItem(
 				messages.NewAssistantSection(
-					m.cfg,
+					m.ins.Config,
 					msg,
 					time.Unix(m.lastUserMessageTime, 0),
 				),
@@ -524,7 +524,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
 	if m.shouldShowAssistantMessage(msg) {
 		cmd := m.listCmp.AppendItem(
 			messages.NewMessageCmp(
-				m.cfg,
+				m.ins.Config,
 				msg,
 			),
 		)
@@ -547,7 +547,7 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
 	}
 
 	m.session = session
-	sessionMessages, err := m.client.ListMessages(context.Background(), session.ID)
+	sessionMessages, err := m.c.ListMessages(context.Background(), m.ins.ID, session.ID)
 	if err != nil {
 		return util.ReportError(err)
 	}
@@ -587,11 +587,11 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
 		switch msg.Role {
 		case message.User:
 			m.lastUserMessageTime = msg.CreatedAt
-			uiMessages = append(uiMessages, messages.NewMessageCmp(m.cfg, msg))
+			uiMessages = append(uiMessages, messages.NewMessageCmp(m.ins.Config, msg))
 		case message.Assistant:
 			uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
 			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
-				uiMessages = append(uiMessages, messages.NewAssistantSection(m.cfg, msg, time.Unix(m.lastUserMessageTime, 0)))
+				uiMessages = append(uiMessages, messages.NewAssistantSection(m.ins.Config, msg, time.Unix(m.lastUserMessageTime, 0)))
 			}
 		}
 	}
@@ -608,7 +608,7 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
 		uiMessages = append(
 			uiMessages,
 			messages.NewMessageCmp(
-				m.cfg, msg,
+				m.ins.Config, msg,
 			),
 		)
 	}
@@ -619,7 +619,7 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
 		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
 		// If this tool call is the agent tool, fetch nested tool calls
 		if tc.Name == agent.AgentToolName {
-			nestedMessages, _ := m.client.ListMessages(context.Background(), tc.ID)
+			nestedMessages, _ := m.c.ListMessages(context.Background(), m.ins.ID, tc.ID)
 			nestedToolResultMap := m.buildToolResultMap(nestedMessages)
 			nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
 			nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))

internal/tui/components/chat/editor/editor.go 🔗

@@ -19,6 +19,7 @@ import (
 	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -53,7 +54,8 @@ type editorCmp struct {
 	width              int
 	height             int
 	x, y               int
-	app                *client.Client
+	c                  *client.Client
+	ins                *proto.Instance
 	session            session.Session
 	textarea           *textarea.Model
 	attachments        []message.Attachment
@@ -211,7 +213,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 	case commands.OpenExternalEditorMsg:
-		info, err := m.app.GetAgentSessionInfo(context.TODO(), m.session.ID)
+		info, err := m.c.GetAgentSessionInfo(context.TODO(), m.ins.ID, m.session.ID)
 		if err == nil && info.IsBusy {
 			return m, util.ReportWarn("Agent is working, please wait...")
 		}
@@ -298,7 +300,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 		if key.Matches(msg, m.keyMap.OpenEditor) {
-			info, err := m.app.GetAgentSessionInfo(context.TODO(), m.session.ID)
+			info, err := m.c.GetAgentSessionInfo(context.TODO(), m.ins.ID, m.session.ID)
 			if err == nil && info.IsBusy {
 				return m, util.ReportWarn("Agent is working, please wait...")
 			}
@@ -367,7 +369,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (m *editorCmp) setEditorPrompt() {
-	skip, err := m.app.GetPermissionsSkipRequests(context.TODO())
+	skip, err := m.c.GetPermissionsSkipRequests(context.TODO(), m.ins.ID)
 	if err == nil && skip {
 		m.textarea.SetPromptFunc(4, yoloPromptFunc)
 		return
@@ -418,13 +420,13 @@ func (m *editorCmp) randomizePlaceholders() {
 func (m *editorCmp) View() string {
 	t := styles.CurrentTheme()
 	// Update placeholder
-	info, err := m.app.GetAgentInfo(context.TODO())
+	info, err := m.c.GetAgentInfo(context.TODO(), m.ins.ID)
 	if err == nil && info.IsBusy {
 		m.textarea.Placeholder = m.workingPlaceholder
 	} else {
 		m.textarea.Placeholder = m.readyPlaceholder
 	}
-	skip, err := m.app.GetPermissionsSkipRequests(context.TODO())
+	skip, err := m.c.GetPermissionsSkipRequests(context.TODO(), m.ins.ID)
 	if err == nil && skip {
 		m.textarea.Placeholder = "Yolo mode!"
 	}
@@ -568,7 +570,7 @@ func yoloPromptFunc(info textarea.PromptInfo) string {
 	return fmt.Sprintf("%s ", t.YoloDotsBlurred)
 }
 
-func New(app *client.Client) Editor {
+func New(c *client.Client, ins *proto.Instance) Editor {
 	t := styles.CurrentTheme()
 	ta := textarea.New()
 	ta.SetStyles(t.S().TextArea)
@@ -578,7 +580,8 @@ func New(app *client.Client) Editor {
 	ta.Focus()
 	e := &editorCmp{
 		// TODO: remove the app instance from here
-		app:      app,
+		c:        c,
+		ins:      ins,
 		textarea: ta,
 		keyMap:   DefaultEditorKeyMap(),
 	}

internal/tui/components/chat/header/header.go 🔗

@@ -7,8 +7,8 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/client"
-	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/styles"
@@ -30,14 +30,14 @@ type header struct {
 	width       int
 	session     session.Session
 	client      *client.Client
-	cfg         *config.Config
+	ins         *proto.Instance
 	detailsOpen bool
 }
 
-func New(lspClients *client.Client, cfg *config.Config) Header {
+func New(lspClients *client.Client, ins *proto.Instance) Header {
 	return &header{
 		client: lspClients,
-		cfg:    cfg,
+		ins:    ins,
 		width:  0,
 	}
 }
@@ -108,14 +108,14 @@ func (h *header) details(availWidth int) string {
 
 	errorCount := 0
 	// TODO: Move this to update?
-	lsps, err := h.client.GetLSPs(context.TODO())
+	lsps, err := h.client.GetLSPs(context.TODO(), h.ins.ID)
 	if err != nil {
 		return ""
 	}
 
 	for l := range lsps {
 		// TODO: Same here, move to update?
-		diags, err := h.client.GetLSPDiagnostics(context.TODO(), l)
+		diags, err := h.client.GetLSPDiagnostics(context.TODO(), h.ins.ID, l)
 		if err != nil {
 			return ""
 		}
@@ -132,8 +132,8 @@ func (h *header) details(availWidth int) string {
 		parts = append(parts, s.Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
 	}
 
-	agentCfg := h.cfg.Agents["coder"]
-	model := h.cfg.GetModelByType(agentCfg.Model)
+	agentCfg := h.ins.Config.Agents["coder"]
+	model := h.ins.Config.GetModelByType(agentCfg.Model)
 	if model == nil {
 		return "No model"
 	}
@@ -154,7 +154,7 @@ func (h *header) details(availWidth int) string {
 
 	// Truncate cwd if necessary, and insert it at the beginning.
 	const dirTrimLimit = 4
-	cwd := fsext.DirTrim(fsext.PrettyPath(h.cfg.WorkingDir()), dirTrimLimit)
+	cwd := fsext.DirTrim(fsext.PrettyPath(h.ins.Config.WorkingDir()), dirTrimLimit)
 	cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
 	cwd = s.Muted.Render(cwd)
 

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -15,6 +15,7 @@ import (
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
@@ -69,16 +70,16 @@ type sidebarCmp struct {
 	session       session.Session
 	logo          string
 	cwd           string
-	client        *client.Client
+	c             *client.Client
 	compactMode   bool
 	files         *csync.Map[string, SessionFile]
-	cfg           *config.Config
+	ins           *proto.Instance
 }
 
-func New(c *client.Client, cfg *config.Config, compact bool) Sidebar {
+func New(c *client.Client, ins *proto.Instance, compact bool) Sidebar {
 	return &sidebarCmp{
-		client:      c,
-		cfg:         cfg,
+		c:           c,
+		ins:         ins,
 		compactMode: compact,
 		files:       csync.NewMap[string, SessionFile](),
 	}
@@ -194,7 +195,7 @@ func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) te
 			before, _ := fsext.ToUnixLineEndings(existing.History.initialVersion.Content)
 			after, _ := fsext.ToUnixLineEndings(existing.History.latestVersion.Content)
 			path := existing.History.initialVersion.Path
-			cwd := m.cfg.WorkingDir()
+			cwd := m.ins.Config.WorkingDir()
 			path = strings.TrimPrefix(path, cwd)
 			_, additions, deletions := diff.GenerateDiff(before, after, path)
 			existing.Additions = additions
@@ -221,7 +222,7 @@ func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) te
 }
 
 func (m *sidebarCmp) loadSessionFiles() tea.Msg {
-	files, err := m.client.ListSessionHistoryFiles(context.Background(), m.session.ID)
+	files, err := m.c.ListSessionHistoryFiles(context.Background(), m.ins.ID, m.session.ID)
 	if err != nil {
 		return util.InfoMsg{
 			Type: util.InfoTypeError,
@@ -247,7 +248,7 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
 
 	sessionFiles := make([]SessionFile, 0, len(fileMap))
 	for path, fh := range fileMap {
-		cwd := m.cfg.WorkingDir()
+		cwd := m.ins.Config.WorkingDir()
 		path = strings.TrimPrefix(path, cwd)
 		before, _ := fsext.ToUnixLineEndings(fh.initialVersion.Content)
 		after, _ := fsext.ToUnixLineEndings(fh.latestVersion.Content)
@@ -267,7 +268,7 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
 
 func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
 	m.logo = m.logoBlock()
-	m.cwd = cwd(m.cfg)
+	m.cwd = cwd(m.ins.Config)
 	m.width = width
 	m.height = height
 	return nil
@@ -406,7 +407,7 @@ func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
 		maxItems = min(maxItems, availableHeight)
 	}
 
-	return files.RenderFileBlock(m.cfg, fileSlice, files.RenderOptions{
+	return files.RenderFileBlock(m.ins.Config, fileSlice, files.RenderOptions{
 		MaxWidth:    maxWidth,
 		MaxItems:    maxItems,
 		ShowSection: true,
@@ -417,14 +418,14 @@ func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
 // lspBlockCompact renders the LSP block with limited width and height for horizontal layout
 func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
 	// Limit items for horizontal layout
-	lspConfigs := m.cfg.LSP.Sorted()
+	lspConfigs := m.ins.Config.LSP.Sorted()
 	maxItems := min(5, len(lspConfigs))
 	availableHeight := m.height - 8
 	if availableHeight > 0 {
 		maxItems = min(maxItems, availableHeight)
 	}
 
-	return lspcomponent.RenderLSPBlock(m.client, m.cfg, lspcomponent.RenderOptions{
+	return lspcomponent.RenderLSPBlock(m.c, m.ins, lspcomponent.RenderOptions{
 		MaxWidth:    maxWidth,
 		MaxItems:    maxItems,
 		ShowSection: true,
@@ -435,13 +436,13 @@ func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
 // mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
 func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
 	// Limit items for horizontal layout
-	maxItems := min(5, len(m.cfg.MCP.Sorted()))
+	maxItems := min(5, len(m.ins.Config.MCP.Sorted()))
 	availableHeight := m.height - 8
 	if availableHeight > 0 {
 		maxItems = min(maxItems, availableHeight)
 	}
 
-	return mcp.RenderMCPBlock(m.cfg, mcp.RenderOptions{
+	return mcp.RenderMCPBlock(m.ins.Config, mcp.RenderOptions{
 		MaxWidth:    maxWidth,
 		MaxItems:    maxItems,
 		ShowSection: true,
@@ -469,7 +470,7 @@ func (m *sidebarCmp) filesBlock() string {
 	maxFiles, _, _ := m.getDynamicLimits()
 	maxFiles = min(len(fileSlice), maxFiles)
 
-	return files.RenderFileBlock(m.cfg, fileSlice, files.RenderOptions{
+	return files.RenderFileBlock(m.ins.Config, fileSlice, files.RenderOptions{
 		MaxWidth:    m.getMaxWidth(),
 		MaxItems:    maxFiles,
 		ShowSection: true,
@@ -480,10 +481,10 @@ func (m *sidebarCmp) filesBlock() string {
 func (m *sidebarCmp) lspBlock() string {
 	// Limit the number of LSPs shown
 	_, maxLSPs, _ := m.getDynamicLimits()
-	lspConfigs := m.cfg.LSP.Sorted()
+	lspConfigs := m.ins.Config.LSP.Sorted()
 	maxLSPs = min(len(lspConfigs), maxLSPs)
 
-	return lspcomponent.RenderLSPBlock(m.client, m.cfg, lspcomponent.RenderOptions{
+	return lspcomponent.RenderLSPBlock(m.c, m.ins, lspcomponent.RenderOptions{
 		MaxWidth:    m.getMaxWidth(),
 		MaxItems:    maxLSPs,
 		ShowSection: true,
@@ -494,10 +495,10 @@ func (m *sidebarCmp) lspBlock() string {
 func (m *sidebarCmp) mcpBlock() string {
 	// Limit the number of MCPs shown
 	_, _, maxMCPs := m.getDynamicLimits()
-	mcps := m.cfg.MCP.Sorted()
+	mcps := m.ins.Config.MCP.Sorted()
 	maxMCPs = min(len(mcps), maxMCPs)
 
-	return mcp.RenderMCPBlock(m.cfg, mcp.RenderOptions{
+	return mcp.RenderMCPBlock(m.ins.Config, mcp.RenderOptions{
 		MaxWidth:    m.getMaxWidth(),
 		MaxItems:    maxMCPs,
 		ShowSection: true,
@@ -544,15 +545,15 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
 }
 
 func (s *sidebarCmp) currentModelBlock() string {
-	agentCfg := s.cfg.Agents["coder"]
+	agentCfg := s.ins.Config.Agents["coder"]
 
-	selectedModel := s.cfg.Models[agentCfg.Model]
+	selectedModel := s.ins.Config.Models[agentCfg.Model]
 
-	model := s.cfg.GetModelByType(agentCfg.Model)
+	model := s.ins.Config.GetModelByType(agentCfg.Model)
 	if model == nil {
 		return "No model found"
 	}
-	modelProvider := s.cfg.GetProviderForModel(agentCfg.Model)
+	modelProvider := s.ins.Config.GetProviderForModel(agentCfg.Model)
 
 	t := styles.CurrentTheme()
 

internal/tui/components/chat/splash/splash.go 🔗

@@ -13,6 +13,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/llm/prompt"
+	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
@@ -74,11 +75,11 @@ type splashCmp struct {
 	isAPIKeyValid bool
 	apiKeyValue   string
 
-	client *client.Client
-	cfg    *config.Config
+	c   *client.Client
+	ins *proto.Instance
 }
 
-func New(c *client.Client, cfg *config.Config) Splash {
+func New(c *client.Client, ins *proto.Instance) Splash {
 	keyMap := DefaultKeyMap()
 	listKeyMap := list.DefaultKeyMap()
 	listKeyMap.Down.SetEnabled(false)
@@ -90,12 +91,12 @@ func New(c *client.Client, cfg *config.Config) Splash {
 	listKeyMap.DownOneItem = keyMap.Next
 	listKeyMap.UpOneItem = keyMap.Previous
 
-	modelList := models.NewModelListComponent(cfg, listKeyMap, "Find your fave", false)
+	modelList := models.NewModelListComponent(ins.Config, listKeyMap, "Find your fave", false)
 	apiKeyInput := models.NewAPIKeyInput()
 
 	return &splashCmp{
-		client:       c,
-		cfg:          cfg,
+		c:            c,
+		ins:          ins,
 		width:        0,
 		height:       0,
 		keyMap:       keyMap,
@@ -217,7 +218,7 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					}),
 					func() tea.Msg {
 						start := time.Now()
-						err := providerConfig.TestConnection(s.cfg.Resolver())
+						err := providerConfig.TestConnection(s.ins.Config.Resolver())
 						// intentionally wait for at least 750ms to make sure the user sees the spinner
 						elapsed := time.Since(start)
 						if elapsed < 750*time.Millisecond {
@@ -311,7 +312,7 @@ func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
 		return nil
 	}
 
-	err := s.cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
+	err := s.ins.Config.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
 	if err != nil {
 		return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
 	}
@@ -329,7 +330,7 @@ func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
 func (s *splashCmp) initializeProject() tea.Cmd {
 	s.needsProjectInit = false
 
-	if err := config.MarkProjectInitialized(s.cfg); err != nil {
+	if err := config.MarkProjectInitialized(s.ins.Config); err != nil {
 		return util.ReportError(err)
 	}
 	var cmds []tea.Cmd
@@ -347,7 +348,7 @@ func (s *splashCmp) initializeProject() tea.Cmd {
 }
 
 func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
-	model := s.cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
+	model := s.ins.Config.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
 	if model == nil {
 		return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
 	}
@@ -359,7 +360,7 @@ func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
 		MaxTokens:       model.DefaultMaxTokens,
 	}
 
-	err := s.cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
+	err := s.ins.Config.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
 	if err != nil {
 		return util.ReportError(err)
 	}
@@ -371,16 +372,16 @@ func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
 	}
 	if knownProvider == nil {
 		// for local provider we just use the same model
-		err = s.cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
+		err = s.ins.Config.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
 		if err != nil {
 			return util.ReportError(err)
 		}
 	} else {
 		smallModel := knownProvider.DefaultSmallModelID
-		model := s.cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
+		model := s.ins.Config.GetModel(string(selectedItem.Provider.ID), smallModel)
 		// should never happen
 		if model == nil {
-			err = s.cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
+			err = s.ins.Config.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
 			if err != nil {
 				return util.ReportError(err)
 			}
@@ -392,17 +393,17 @@ func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
 			ReasoningEffort: model.DefaultReasoningEffort,
 			MaxTokens:       model.DefaultMaxTokens,
 		}
-		err = s.cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
+		err = s.ins.Config.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
 		if err != nil {
 			return util.ReportError(err)
 		}
 	}
-	s.cfg.SetupAgents()
+	s.ins.Config.SetupAgents()
 	return nil
 }
 
 func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
-	providers, err := config.Providers(s.cfg)
+	providers, err := config.Providers(s.ins.Config)
 	if err != nil {
 		return nil, err
 	}
@@ -415,7 +416,7 @@ func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.
 }
 
 func (s *splashCmp) isProviderConfigured(providerID string) bool {
-	if _, ok := s.cfg.Providers.Get(providerID); ok {
+	if _, ok := s.ins.Config.Providers.Get(providerID); ok {
 		return true
 	}
 	return false
@@ -652,11 +653,11 @@ func (s *splashCmp) cwdPart() string {
 }
 
 func (s *splashCmp) cwd() string {
-	return home.Short(s.cfg.WorkingDir())
+	return home.Short(s.ins.Config.WorkingDir())
 }
 
-func LSPList(c *client.Client, cfg *config.Config, maxWidth int) []string {
-	return lspcomponent.RenderLSPList(c, cfg, lspcomponent.RenderOptions{
+func LSPList(c *client.Client, ins *proto.Instance, maxWidth int) []string {
+	return lspcomponent.RenderLSPList(c, ins, lspcomponent.RenderOptions{
 		MaxWidth:    maxWidth,
 		ShowSection: false,
 	})
@@ -666,7 +667,7 @@ func (s *splashCmp) lspBlock() string {
 	t := styles.CurrentTheme()
 	maxWidth := s.getMaxInfoWidth() / 2
 	section := t.S().Subtle.Render("LSPs")
-	lspList := append([]string{section, ""}, LSPList(s.client, s.cfg, maxWidth-1)...)
+	lspList := append([]string{section, ""}, LSPList(s.c, s.ins, maxWidth-1)...)
 	return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
 		lipgloss.JoinVertical(
 			lipgloss.Left,
@@ -686,7 +687,7 @@ func (s *splashCmp) mcpBlock() string {
 	t := styles.CurrentTheme()
 	maxWidth := s.getMaxInfoWidth() / 2
 	section := t.S().Subtle.Render("MCPs")
-	mcpList := append([]string{section, ""}, MCPList(s.cfg, maxWidth-1)...)
+	mcpList := append([]string{section, ""}, MCPList(s.ins.Config, maxWidth-1)...)
 	return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
 		lipgloss.JoinVertical(
 			lipgloss.Left,
@@ -696,8 +697,8 @@ func (s *splashCmp) mcpBlock() string {
 }
 
 func (s *splashCmp) currentModelBlock() string {
-	agentCfg := s.cfg.Agents["coder"]
-	model := s.cfg.GetModelByType(agentCfg.Model)
+	agentCfg := s.ins.Config.Agents["coder"]
+	model := s.ins.Config.GetModelByType(agentCfg.Model)
 	if model == nil {
 		return ""
 	}

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -354,7 +354,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 	if c.sessionID != "" {
 		agentCfg := c.cfg.Agents["coder"]
 		model := c.cfg.GetModelByType(agentCfg.Model)
-		if model.SupportsImages {
+		if model != nil && model.SupportsImages {
 			commands = append(commands, Command{
 				ID:          "file_picker",
 				Title:       "Open File Picker",

internal/tui/components/dialogs/compact/compact.go 🔗

@@ -9,6 +9,7 @@ import (
 
 	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/llm/agent"
+	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/styles"
@@ -30,7 +31,8 @@ type compactDialogCmp struct {
 	sessionID       string
 	state           compactState
 	progress        string
-	client          *client.Client
+	c               *client.Client
+	ins             *proto.Instance
 	noAsk           bool // If true, skip confirmation dialog
 }
 
@@ -43,13 +45,14 @@ const (
 )
 
 // NewCompactDialogCmp creates a new session compact dialog
-func NewCompactDialogCmp(c *client.Client, sessionID string, noAsk bool) CompactDialog {
+func NewCompactDialogCmp(c *client.Client, ins *proto.Instance, sessionID string, noAsk bool) CompactDialog {
 	return &compactDialogCmp{
 		sessionID: sessionID,
 		keyMap:    DefaultKeyMap(),
 		state:     stateConfirm,
 		selected:  0,
-		client:    c,
+		c:         c,
+		ins:       ins,
 		noAsk:     noAsk,
 	}
 }
@@ -134,7 +137,7 @@ func (c *compactDialogCmp) startCompaction() tea.Cmd {
 	c.state = stateCompacting
 	c.progress = "Starting summarization..."
 	return func() tea.Msg {
-		err := c.client.AgentSummarizeSession(context.Background(), c.sessionID)
+		err := c.c.AgentSummarizeSession(context.Background(), c.ins.ID, c.sessionID)
 		if err != nil {
 			c.state = stateError
 			c.progress = "Error: " + err.Error()

internal/tui/components/lsp/lsp.go 🔗

@@ -7,8 +7,8 @@ import (
 	"strings"
 
 	"github.com/charmbracelet/crush/internal/client"
-	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/lipgloss/v2"
@@ -24,7 +24,7 @@ type RenderOptions struct {
 }
 
 // RenderLSPList renders a list of LSP status items with the given options.
-func RenderLSPList(c *client.Client, cfg *config.Config, opts RenderOptions) []string {
+func RenderLSPList(c *client.Client, ins *proto.Instance, opts RenderOptions) []string {
 	t := styles.CurrentTheme()
 	lspList := []string{}
 
@@ -37,14 +37,14 @@ func RenderLSPList(c *client.Client, cfg *config.Config, opts RenderOptions) []s
 		lspList = append(lspList, section, "")
 	}
 
-	lspConfigs := cfg.LSP.Sorted()
+	lspConfigs := ins.Config.LSP.Sorted()
 	if len(lspConfigs) == 0 {
 		lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
 		return lspList
 	}
 
 	// Get LSP states
-	lspStates, err := c.GetLSPs(context.TODO())
+	lspStates, err := c.GetLSPs(context.TODO(), ins.ID)
 	if err != nil {
 		slog.Error("failed to get lsp clients")
 		return nil
@@ -98,7 +98,7 @@ func RenderLSPList(c *client.Client, cfg *config.Config, opts RenderOptions) []s
 				protocol.SeverityInformation: 0,
 			}
 			if _, ok := lspStates[l.Name]; ok {
-				diags, err := c.GetLSPDiagnostics(context.TODO(), l.Name)
+				diags, err := c.GetLSPDiagnostics(context.TODO(), ins.ID, l.Name)
 				if err != nil {
 					slog.Error("couldn't get lsp diagnostics", "lsp", l.Name)
 					return nil
@@ -145,10 +145,10 @@ func RenderLSPList(c *client.Client, cfg *config.Config, opts RenderOptions) []s
 }
 
 // RenderLSPBlock renders a complete LSP block with optional truncation indicator.
-func RenderLSPBlock(c *client.Client, cfg *config.Config, opts RenderOptions, showTruncationIndicator bool) string {
+func RenderLSPBlock(c *client.Client, ins *proto.Instance, opts RenderOptions, showTruncationIndicator bool) string {
 	t := styles.CurrentTheme()
-	lspList := RenderLSPList(c, cfg, opts)
-	cfg, err := c.GetConfig(context.TODO())
+	lspList := RenderLSPList(c, ins, opts)
+	cfg, err := c.GetConfig(context.TODO(), ins.ID)
 	if err != nil {
 		slog.Error("failed to get config for lsp block rendering", "error", err)
 		return ""

internal/tui/page/chat/chat.go 🔗

@@ -15,6 +15,7 @@ import (
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
@@ -90,8 +91,8 @@ func cancelTimerCmd() tea.Cmd {
 type chatPage struct {
 	width, height               int
 	detailsWidth, detailsHeight int
-	app                         *client.Client
-	cfg                         *config.Config
+	c                           *client.Client
+	ins                         *proto.Instance
 	keyboardEnhancements        tea.KeyboardEnhancementsMsg
 
 	// Layout state
@@ -118,33 +119,33 @@ type chatPage struct {
 	isProjectInit    bool
 }
 
-func New(app *client.Client, cfg *config.Config) ChatPage {
+func New(c *client.Client, ins *proto.Instance) ChatPage {
 	return &chatPage{
-		app:         app,
-		cfg:         cfg,
+		c:           c,
+		ins:         ins,
 		keyMap:      DefaultKeyMap(),
-		header:      header.New(app, cfg),
-		sidebar:     sidebar.New(app, cfg, false),
-		chat:        chat.New(app, cfg),
-		editor:      editor.New(app),
-		splash:      splash.New(app, cfg),
+		header:      header.New(c, ins),
+		sidebar:     sidebar.New(c, ins, false),
+		chat:        chat.New(c, ins),
+		editor:      editor.New(c, ins),
+		splash:      splash.New(c, ins),
 		focusedPane: PanelTypeSplash,
 	}
 }
 
 func (p *chatPage) Init() tea.Cmd {
-	compact := p.cfg.Options.TUI.CompactMode
+	compact := p.ins.Config.Options.TUI.CompactMode
 	p.compact = compact
 	p.forceCompact = compact
 	p.sidebar.SetCompactMode(p.compact)
 
 	// Set splash state based on config
-	if !config.HasInitialDataConfig(p.cfg) {
+	if !config.HasInitialDataConfig(p.ins.Config) {
 		// First-time setup: show model selection
 		p.splash.SetOnboarding(true)
 		p.isOnboarding = true
 		p.splashFullScreen = true
-	} else if b, _ := config.ProjectNeedsInitialization(p.cfg); b {
+	} else if b, _ := config.ProjectNeedsInitialization(p.ins.Config); b {
 		// Project needs CRUSH.md initialization
 		p.splash.SetProjectInit(true)
 		p.isProjectInit = true
@@ -332,7 +333,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return p, tea.Batch(cmds...)
 
 	case commands.CommandRunCustomMsg:
-		info, err := p.app.GetAgentInfo(context.TODO())
+		info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 		if err != nil {
 			return p, util.ReportError(fmt.Errorf("failed to get agent info: %w", err))
 		}
@@ -346,12 +347,12 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case splash.OnboardingCompleteMsg:
 		p.splashFullScreen = false
-		if b, _ := config.ProjectNeedsInitialization(p.cfg); b {
+		if b, _ := config.ProjectNeedsInitialization(p.ins.Config); b {
 			p.splash.SetProjectInit(true)
 			p.splashFullScreen = true
 			return p, p.SetSize(p.width, p.height)
 		}
-		err := p.app.InitiateAgentProcessing(context.TODO())
+		err := p.c.InitiateAgentProcessing(context.TODO(), p.ins.ID)
 		if err != nil {
 			return p, util.ReportError(err)
 		}
@@ -360,7 +361,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		p.focusedPane = PanelTypeEditor
 		return p, p.SetSize(p.width, p.height)
 	case commands.NewSessionsMsg:
-		info, err := p.app.GetAgentInfo(context.TODO())
+		info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 		if err != nil {
 			return p, util.ReportError(fmt.Errorf("failed to get agent info: %w", err))
 		}
@@ -372,7 +373,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch {
 		case key.Matches(msg, p.keyMap.NewSession):
 			// if we have no agent do nothing
-			info, err := p.app.GetAgentInfo(context.TODO())
+			info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 			if err != nil || info.IsZero() {
 				return p, nil
 			}
@@ -381,8 +382,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 			return p, p.newSession()
 		case key.Matches(msg, p.keyMap.AddAttachment):
-			agentCfg := p.cfg.Agents["coder"]
-			model := p.cfg.GetModelByType(agentCfg.Model)
+			agentCfg := p.ins.Config.Agents["coder"]
+			model := p.ins.Config.GetModelByType(agentCfg.Model)
 			if model.SupportsImages {
 				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
 			} else {
@@ -397,7 +398,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			p.changeFocus()
 			return p, nil
 		case key.Matches(msg, p.keyMap.Cancel):
-			info, err := p.app.GetAgentInfo(context.TODO())
+			info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 			if err != nil {
 				return p, util.ReportError(fmt.Errorf("failed to get agent info: %w", err))
 			}
@@ -530,7 +531,7 @@ func (p *chatPage) View() string {
 
 func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 	return func() tea.Msg {
-		err := p.cfg.SetCompactMode(compact)
+		err := p.ins.Config.SetCompactMode(compact)
 		if err != nil {
 			return util.InfoMsg{
 				Type: util.InfoTypeError,
@@ -543,15 +544,15 @@ func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 
 func (p *chatPage) toggleThinking() tea.Cmd {
 	return func() tea.Msg {
-		agentCfg := p.cfg.Agents["coder"]
-		currentModel := p.cfg.Models[agentCfg.Model]
+		agentCfg := p.ins.Config.Agents["coder"]
+		currentModel := p.ins.Config.Models[agentCfg.Model]
 
 		// Toggle the thinking mode
 		currentModel.Think = !currentModel.Think
-		p.cfg.Models[agentCfg.Model] = currentModel
+		p.ins.Config.Models[agentCfg.Model] = currentModel
 
 		// Update the agent with the new configuration
-		if err := p.app.UpdateAgent(context.TODO()); err != nil {
+		if err := p.c.UpdateAgent(context.TODO(), p.ins.ID); err != nil {
 			return util.InfoMsg{
 				Type: util.InfoTypeError,
 				Msg:  "Failed to update thinking mode: " + err.Error(),
@@ -571,15 +572,15 @@ func (p *chatPage) toggleThinking() tea.Cmd {
 
 func (p *chatPage) openReasoningDialog() tea.Cmd {
 	return func() tea.Msg {
-		agentCfg := p.cfg.Agents["coder"]
-		model := p.cfg.GetModelByType(agentCfg.Model)
-		providerCfg := p.cfg.GetProviderForModel(agentCfg.Model)
+		agentCfg := p.ins.Config.Agents["coder"]
+		model := p.ins.Config.GetModelByType(agentCfg.Model)
+		providerCfg := p.ins.Config.GetProviderForModel(agentCfg.Model)
 
 		if providerCfg != nil && model != nil &&
 			providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
 			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
 			return dialogs.OpenDialogMsg{
-				Model: reasoning.NewReasoningDialog(p.cfg),
+				Model: reasoning.NewReasoningDialog(p.ins.Config),
 			}
 		}
 		return nil
@@ -588,7 +589,7 @@ func (p *chatPage) openReasoningDialog() tea.Cmd {
 
 func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 	return func() tea.Msg {
-		cfg := p.cfg
+		cfg := p.ins.Config
 		agentCfg := cfg.Agents["coder"]
 		currentModel := cfg.Models[agentCfg.Model]
 
@@ -597,7 +598,7 @@ func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 		cfg.Models[agentCfg.Model] = currentModel
 
 		// Update the agent with the new configuration
-		if err := p.app.UpdateAgent(context.TODO()); err != nil {
+		if err := p.c.UpdateAgent(context.TODO(), p.ins.ID); err != nil {
 			return util.InfoMsg{
 				Type: util.InfoTypeError,
 				Msg:  "Failed to update reasoning effort: " + err.Error(),
@@ -718,13 +719,13 @@ func (p *chatPage) changeFocus() {
 func (p *chatPage) cancel() tea.Cmd {
 	if p.isCanceling {
 		p.isCanceling = false
-		_ = p.app.ClearAgentSessionQueuedPrompts(context.TODO(), p.session.ID)
+		_ = p.c.ClearAgentSessionQueuedPrompts(context.TODO(), p.ins.ID, p.session.ID)
 		return nil
 	}
 
-	queued, _ := p.app.GetAgentSessionQueuedPrompts(context.TODO(), p.session.ID)
+	queued, _ := p.c.GetAgentSessionQueuedPrompts(context.TODO(), p.ins.ID, p.session.ID)
 	if queued > 0 {
-		_ = p.app.ClearAgentSessionQueuedPrompts(context.TODO(), p.session.ID)
+		_ = p.c.ClearAgentSessionQueuedPrompts(context.TODO(), p.ins.ID, p.session.ID)
 		return nil
 	}
 	p.isCanceling = true
@@ -750,18 +751,18 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te
 	session := p.session
 	var cmds []tea.Cmd
 	if p.session.ID == "" {
-		newSession, err := p.app.CreateSession(context.Background(), "New Session")
+		newSession, err := p.c.CreateSession(context.Background(), p.ins.ID, "New Session")
 		if err != nil {
 			return util.ReportError(err)
 		}
 		session = *newSession
 		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 	}
-	info, err := p.app.GetAgentInfo(context.TODO())
+	info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 	if err != nil || info.IsZero() {
 		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 	}
-	if err := p.app.SendMessage(context.Background(), session.ID, text, attachments...); err != nil {
+	if err := p.c.SendMessage(context.Background(), p.ins.ID, session.ID, text, attachments...); err != nil {
 		return util.ReportError(err)
 	}
 	cmds = append(cmds, p.chat.GoToBottom())
@@ -773,7 +774,7 @@ func (p *chatPage) Bindings() []key.Binding {
 		p.keyMap.NewSession,
 		p.keyMap.AddAttachment,
 	}
-	info, err := p.app.GetAgentInfo(context.TODO())
+	info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 	if err == nil && info.IsBusy {
 		cancelBinding := p.keyMap.Cancel
 		if p.isCanceling {
@@ -895,7 +896,7 @@ func (p *chatPage) Help() help.KeyMap {
 			}
 			return core.NewSimpleHelp(shortList, fullList)
 		}
-		info, err := p.app.GetAgentInfo(context.TODO())
+		info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 		if err == nil && info.IsBusy {
 			cancelBinding := key.NewBinding(
 				key.WithKeys("esc", "alt+esc"),
@@ -907,7 +908,7 @@ func (p *chatPage) Help() help.KeyMap {
 					key.WithHelp("esc", "press again to cancel"),
 				)
 			}
-			queued, _ := p.app.GetAgentSessionQueuedPrompts(context.TODO(), p.session.ID)
+			queued, _ := p.c.GetAgentSessionQueuedPrompts(context.TODO(), p.ins.ID, p.session.ID)
 			if queued > 0 {
 				cancelBinding = key.NewBinding(
 					key.WithKeys("esc", "alt+esc"),

internal/tui/tui.go 🔗

@@ -68,8 +68,8 @@ type appModel struct {
 	status          status.StatusCmp
 	showingFullHelp bool
 
-	app *client.Client
-	cfg *config.Config
+	c   *client.Client
+	ins *proto.Instance
 
 	dialog       dialogs.DialogCmp
 	completions  completions.Completions
@@ -103,7 +103,7 @@ func (a appModel) Init() tea.Cmd {
 func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
-	a.isConfigured = config.HasInitialDataConfig(a.cfg)
+	a.isConfigured = config.HasInitialDataConfig(a.ins.Config)
 
 	switch msg := msg.(type) {
 	case tea.KeyboardEnhancementsMsg:
@@ -169,7 +169,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	// Commands
 	case commands.SwitchSessionsMsg:
 		return a, func() tea.Msg {
-			allSessions, _ := a.app.ListSessions(context.Background())
+			allSessions, _ := a.c.ListSessions(context.Background(), a.ins.ID)
 			return dialogs.OpenDialogMsg{
 				Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
 			}
@@ -178,24 +178,24 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case commands.SwitchModelMsg:
 		return a, util.CmdHandler(
 			dialogs.OpenDialogMsg{
-				Model: models.NewModelDialogCmp(a.cfg),
+				Model: models.NewModelDialogCmp(a.ins.Config),
 			},
 		)
 	// Compact
 	case commands.CompactMsg:
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: compact.NewCompactDialogCmp(a.app, msg.SessionID, true),
+			Model: compact.NewCompactDialogCmp(a.c, a.ins, msg.SessionID, true),
 		})
 	case commands.QuitMsg:
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: quit.NewQuitDialog(),
 		})
 	case commands.ToggleYoloModeMsg:
-		skip, err := a.app.GetPermissionsSkipRequests(context.TODO())
+		skip, err := a.c.GetPermissionsSkipRequests(context.TODO(), a.ins.ID)
 		if err != nil {
 			return a, util.ReportError(fmt.Errorf("failed to get permissions skip requests: %v", err))
 		}
-		if err := a.app.SetPermissionsSkipRequests(context.TODO(), !skip); err != nil {
+		if err := a.c.SetPermissionsSkipRequests(context.TODO(), a.ins.ID, !skip); err != nil {
 			return a, util.ReportError(fmt.Errorf("failed to toggle YOLO mode: %v", err))
 		}
 	case commands.ToggleHelpMsg:
@@ -204,17 +204,17 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return a, a.handleWindowResize(a.wWidth, a.wHeight)
 	// Model Switch
 	case models.ModelSelectedMsg:
-		info, err := a.app.GetAgentInfo(context.TODO())
+		info, err := a.c.GetAgentInfo(context.TODO(), a.ins.ID)
 		if err != nil {
 			return a, util.ReportError(fmt.Errorf("failed to check if agent is busy: %v", err))
 		}
 		if info.IsBusy {
 			return a, util.ReportWarn("Agent is busy, please wait...")
 		}
-		a.cfg.UpdatePreferredModel(msg.ModelType, msg.Model)
+		a.ins.Config.UpdatePreferredModel(msg.ModelType, msg.Model)
 
 		// Update the agent with the new model/provider configuration
-		if err := a.app.UpdateAgent(context.TODO()); err != nil {
+		if err := a.c.UpdateAgent(context.TODO(), a.ins.ID); err != nil {
 			return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
 		}
 
@@ -233,7 +233,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
 		}
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: filepicker.NewFilePickerCmp(a.cfg.WorkingDir()),
+			Model: filepicker.NewFilePickerCmp(a.ins.Config.WorkingDir()),
 		})
 	// Permissions
 	case pubsub.Event[permission.PermissionNotification]:
@@ -252,11 +252,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case pubsub.Event[permission.PermissionRequest]:
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
-				DiffMode: a.cfg.Options.TUI.DiffMode,
+				DiffMode: a.ins.Config.Options.TUI.DiffMode,
 			}),
 		})
 	case permissions.PermissionResponseMsg:
-		if err := a.app.GrantPermission(context.TODO(), proto.PermissionGrant(msg)); err != nil {
+		if err := a.c.GrantPermission(context.TODO(), a.ins.ID, proto.PermissionGrant(msg)); err != nil {
 			return a, util.ReportError(fmt.Errorf("failed to grant permission: %v", err))
 		}
 		return a, nil
@@ -281,18 +281,18 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// Handle auto-compact logic
 		if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
 			// Get current session to check token usage
-			session, err := a.app.GetSession(context.Background(), a.selectedSessionID)
+			session, err := a.c.GetSession(context.Background(), a.ins.ID, a.selectedSessionID)
 			if err == nil {
-				info, err := a.app.GetAgentInfo(context.Background())
+				info, err := a.c.GetAgentInfo(context.Background(), a.ins.ID)
 				if err != nil {
 					return a, util.ReportError(fmt.Errorf("failed to check if agent is busy: %v", err))
 				}
 				model := info.Model
 				contextWindow := model.ContextWindow
 				tokens := session.CompletionTokens + session.PromptTokens
-				if (tokens >= int64(float64(contextWindow)*0.95)) && !a.cfg.Options.DisableAutoSummarize { // Show compact confirmation dialog
+				if (tokens >= int64(float64(contextWindow)*0.95)) && !a.ins.Config.Options.DisableAutoSummarize { // Show compact confirmation dialog
 					cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
-						Model: compact.NewCompactDialogCmp(a.app, a.selectedSessionID, false),
+						Model: compact.NewCompactDialogCmp(a.c, a.ins, a.selectedSessionID, false),
 					}))
 				}
 			}
@@ -305,7 +305,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return a, nil
 		}
 
-		a.isConfigured = config.HasInitialDataConfig(a.cfg)
+		a.isConfigured = config.HasInitialDataConfig(a.ins.Config)
 		updated, pageCmd := item.Update(msg)
 		if model, ok := updated.(util.Model); ok {
 			a.pages[a.currentPage] = model
@@ -472,7 +472,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 			return nil
 		}
 		return util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: commands.NewCommandDialog(a.cfg, a.selectedSessionID),
+			Model: commands.NewCommandDialog(a.ins.Config, a.selectedSessionID),
 		})
 	case key.Matches(msg, a.keyMap.Sessions):
 		// if the app is not configured show no sessions
@@ -492,7 +492,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		}
 		cmds = append(cmds,
 			func() tea.Msg {
-				allSessions, _ := a.app.ListSessions(context.Background())
+				allSessions, _ := a.c.ListSessions(context.Background(), a.ins.ID)
 				return dialogs.OpenDialogMsg{
 					Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
 				}
@@ -500,7 +500,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		)
 		return tea.Sequence(cmds...)
 	case key.Matches(msg, a.keyMap.Suspend):
-		info, err := a.app.GetAgentInfo(context.TODO())
+		info, err := a.c.GetAgentInfo(context.TODO(), a.ins.ID)
 		if err != nil || info.IsBusy {
 			return util.ReportWarn("Agent is busy, please wait...")
 		}
@@ -521,7 +521,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 
 // moveToPage handles navigation between different pages in the application.
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
-	info, err := a.app.GetAgentInfo(context.TODO())
+	info, err := a.c.GetAgentInfo(context.TODO(), a.ins.ID)
 	if err != nil {
 		return util.ReportError(fmt.Errorf("failed to check if agent is busy: %v", err))
 	}
@@ -626,25 +626,20 @@ func (a *appModel) View() tea.View {
 }
 
 // New creates and initializes a new TUI application model.
-func New(app *client.Client) (tea.Model, error) {
-	cfg, err := app.GetConfig(context.TODO())
-	if err != nil {
-		return nil, fmt.Errorf("failed to get config: %v", err)
-	}
-
+func New(c *client.Client, ins *proto.Instance) (tea.Model, error) {
 	// Setup logs
 	log.Setup(
-		filepath.Join(cfg.Options.DataDirectory, "logs", "tui.log"),
-		cfg.Options.Debug,
+		filepath.Join(ins.Config.Options.DataDirectory, "logs", "tui.log"),
+		ins.Config.Options.Debug,
 	)
 
-	chatPage := chat.New(app, cfg)
+	chatPage := chat.New(c, ins)
 	keyMap := DefaultKeyMap()
 	keyMap.pageBindings = chatPage.Bindings()
 	model := &appModel{
+		ins:         ins,
 		currentPage: chat.ChatPageID,
-		app:         app,
-		cfg:         cfg,
+		c:           c,
 		status:      status.NewStatusCmp(),
 		loadedPages: make(map[page.PageID]bool),
 		keyMap:      keyMap,