refactor(tui): decouple TUI from app package using client and config

Ayman Bagabas created

Change summary

internal/tui/components/chat/chat.go                       |  40 +-
internal/tui/components/chat/editor/editor.go              |  21 
internal/tui/components/chat/header/header.go              |  38 ++
internal/tui/components/chat/messages/messages.go          |  11 
internal/tui/components/chat/messages/tool.go              |   3 
internal/tui/components/chat/sidebar/sidebar.go            |  56 ++--
internal/tui/components/chat/splash/splash.go              |  57 ++--
internal/tui/components/dialogs/commands/commands.go       |  20 
internal/tui/components/dialogs/commands/loader.go         |   3 
internal/tui/components/dialogs/compact/compact.go         |   9 
internal/tui/components/dialogs/models/list.go             |  20 
internal/tui/components/dialogs/models/models.go           |  18 
internal/tui/components/dialogs/permissions/permissions.go |   9 
internal/tui/components/dialogs/reasoning/reasoning.go     |  12 
internal/tui/components/files/files.go                     |   8 
internal/tui/components/lsp/lsp.go                         |  37 +
internal/tui/components/mcp/mcp.go                         |  10 
internal/tui/page/chat/chat.go                             | 106 ++++---
internal/tui/tui.go                                        |  97 ++++--
19 files changed, 338 insertions(+), 237 deletions(-)

Detailed changes

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

@@ -8,7 +8,8 @@ import (
 	"github.com/atotto/clipboard"
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/app"
+	"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"
@@ -58,7 +59,8 @@ type MessageListCmp interface {
 // of chat messages with support for tool calls, real-time updates, and
 // session switching.
 type messageListCmp struct {
-	app              *app.App
+	client           *client.Client
+	cfg              *config.Config
 	width, height    int
 	session          session.Session
 	listCmp          list.List[list.Item]
@@ -77,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 *app.App) MessageListCmp {
+func New(app *client.Client, cfg *config.Config) MessageListCmp {
 	defaultListKeyMap := list.DefaultKeyMap()
 	listCmp := list.New(
 		[]list.Item{},
@@ -88,7 +90,8 @@ func New(app *app.App) MessageListCmp {
 		list.WithEnableMouse(),
 	)
 	return &messageListCmp{
-		app:               app,
+		client:            app,
+		cfg:               cfg,
 		listCmp:           listCmp,
 		previousSelected:  "",
 		defaultListKeyMap: defaultListKeyMap,
@@ -103,8 +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
-	if m.session.ID != "" && m.app.CoderAgent != nil {
-		queueSize := m.app.CoderAgent.QueuedPrompts(m.session.ID)
+	info, err := m.client.GetAgentInfo(context.TODO())
+	if m.session.ID != "" && err == nil && !info.IsZero() {
+		queueSize, _ := m.client.GetAgentSessionQueuedPrompts(context.TODO(), m.session.ID)
 		if queueSize != m.promptQueue {
 			m.promptQueue = queueSize
 			cmds = append(cmds, m.SetSize(m.width, m.height))
@@ -235,7 +239,8 @@ func (m *messageListCmp) View() string {
 				m.listCmp.View(),
 			),
 	}
-	if m.app.CoderAgent != nil && m.promptQueue > 0 {
+	info, err := m.client.GetAgentInfo(context.TODO())
+	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))
 	}
@@ -289,7 +294,6 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
 			nestedCall := messages.NewToolCallCmp(
 				event.Payload.ID,
 				tc,
-				m.app.Permissions,
 				messages.WithToolCallNested(true),
 			)
 			cmds = append(cmds, nestedCall.Init())
@@ -369,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(msg))
+	return m.listCmp.AppendItem(messages.NewMessageCmp(m.cfg, msg))
 }
 
 // handleToolMessage updates existing tool calls with their results.
@@ -462,6 +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,
 					msg,
 					time.Unix(m.lastUserMessageTime, 0),
 				),
@@ -508,7 +513,7 @@ func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.Too
 	}
 
 	// Add new tool call if not found
-	return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
+	return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
 }
 
 // handleNewAssistantMessage processes new assistant messages and their tool calls.
@@ -519,6 +524,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
 	if m.shouldShowAssistantMessage(msg) {
 		cmd := m.listCmp.AppendItem(
 			messages.NewMessageCmp(
+				m.cfg,
 				msg,
 			),
 		)
@@ -527,7 +533,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
 
 	// Add tool calls
 	for _, tc := range msg.ToolCalls() {
-		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
+		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
 		cmds = append(cmds, cmd)
 	}
 
@@ -541,7 +547,7 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
 	}
 
 	m.session = session
-	sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
+	sessionMessages, err := m.client.ListMessages(context.Background(), session.ID)
 	if err != nil {
 		return util.ReportError(err)
 	}
@@ -581,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(msg))
+			uiMessages = append(uiMessages, messages.NewMessageCmp(m.cfg, 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(msg, time.Unix(m.lastUserMessageTime, 0)))
+				uiMessages = append(uiMessages, messages.NewAssistantSection(m.cfg, msg, time.Unix(m.lastUserMessageTime, 0)))
 			}
 		}
 	}
@@ -602,7 +608,7 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
 		uiMessages = append(
 			uiMessages,
 			messages.NewMessageCmp(
-				msg,
+				m.cfg, msg,
 			),
 		)
 	}
@@ -610,10 +616,10 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
 	// Add tool calls with their results and status
 	for _, tc := range msg.ToolCalls() {
 		options := m.buildToolCallOptions(tc, msg, toolResultMap)
-		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
+		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.app.Messages.List(context.Background(), tc.ID)
+			nestedMessages, _ := m.client.ListMessages(context.Background(), 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 🔗

@@ -16,7 +16,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	"github.com/charmbracelet/bubbles/v2/textarea"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/session"
@@ -53,7 +53,7 @@ type editorCmp struct {
 	width              int
 	height             int
 	x, y               int
-	app                *app.App
+	app                *client.Client
 	session            session.Session
 	textarea           *textarea.Model
 	attachments        []message.Attachment
@@ -211,7 +211,8 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 	case commands.OpenExternalEditorMsg:
-		if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+		info, err := m.app.GetAgentSessionInfo(context.TODO(), m.session.ID)
+		if err == nil && info.IsBusy {
 			return m, util.ReportWarn("Agent is working, please wait...")
 		}
 		return m, m.openEditor(m.textarea.Value())
@@ -297,7 +298,8 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 		if key.Matches(msg, m.keyMap.OpenEditor) {
-			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+			info, err := m.app.GetAgentSessionInfo(context.TODO(), m.session.ID)
+			if err == nil && info.IsBusy {
 				return m, util.ReportWarn("Agent is working, please wait...")
 			}
 			return m, m.openEditor(m.textarea.Value())
@@ -365,7 +367,8 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (m *editorCmp) setEditorPrompt() {
-	if m.app.Permissions.SkipRequests() {
+	skip, err := m.app.GetPermissionsSkipRequests(context.TODO())
+	if err == nil && skip {
 		m.textarea.SetPromptFunc(4, yoloPromptFunc)
 		return
 	}
@@ -415,12 +418,14 @@ func (m *editorCmp) randomizePlaceholders() {
 func (m *editorCmp) View() string {
 	t := styles.CurrentTheme()
 	// Update placeholder
-	if m.app.CoderAgent != nil && m.app.CoderAgent.IsBusy() {
+	info, err := m.app.GetAgentInfo(context.TODO())
+	if err == nil && info.IsBusy {
 		m.textarea.Placeholder = m.workingPlaceholder
 	} else {
 		m.textarea.Placeholder = m.readyPlaceholder
 	}
-	if m.app.Permissions.SkipRequests() {
+	skip, err := m.app.GetPermissionsSkipRequests(context.TODO())
+	if err == nil && skip {
 		m.textarea.Placeholder = "Yolo mode!"
 	}
 	if len(m.attachments) == 0 {
@@ -563,7 +568,7 @@ func yoloPromptFunc(info textarea.PromptInfo) string {
 	return fmt.Sprintf("%s ", t.YoloDotsBlurred)
 }
 
-func New(app *app.App) Editor {
+func New(app *client.Client) Editor {
 	t := styles.CurrentTheme()
 	ta := textarea.New()
 	ta.SetStyles(t.S().TextArea)

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

@@ -1,14 +1,14 @@
 package header
 
 import (
+	"context"
 	"fmt"
 	"strings"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/styles"
@@ -29,14 +29,16 @@ type Header interface {
 type header struct {
 	width       int
 	session     session.Session
-	lspClients  *csync.Map[string, *lsp.Client]
+	client      *client.Client
+	cfg         *config.Config
 	detailsOpen bool
 }
 
-func New(lspClients *csync.Map[string, *lsp.Client]) Header {
+func New(lspClients *client.Client, cfg *config.Config) Header {
 	return &header{
-		lspClients: lspClients,
-		width:      0,
+		client: lspClients,
+		cfg:    cfg,
+		width:  0,
 	}
 }
 
@@ -105,8 +107,19 @@ func (h *header) details(availWidth int) string {
 	var parts []string
 
 	errorCount := 0
-	for l := range h.lspClients.Seq() {
-		for _, diagnostics := range l.GetDiagnostics() {
+	// TODO: Move this to update?
+	lsps, err := h.client.GetLSPs(context.TODO())
+	if err != nil {
+		return ""
+	}
+
+	for l := range lsps {
+		// TODO: Same here, move to update?
+		diags, err := h.client.GetLSPDiagnostics(context.TODO(), l)
+		if err != nil {
+			return ""
+		}
+		for _, diagnostics := range diags {
 			for _, diagnostic := range diagnostics {
 				if diagnostic.Severity == protocol.SeverityError {
 					errorCount++
@@ -119,8 +132,11 @@ func (h *header) details(availWidth int) string {
 		parts = append(parts, s.Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
 	}
 
-	agentCfg := config.Get().Agents["coder"]
-	model := config.Get().GetModelByType(agentCfg.Model)
+	agentCfg := h.cfg.Agents["coder"]
+	model := h.cfg.GetModelByType(agentCfg.Model)
+	if model == nil {
+		return "No model"
+	}
 	percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
 	formattedPercentage := s.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
 	parts = append(parts, formattedPercentage)
@@ -138,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(config.Get().WorkingDir()), dirTrimLimit)
+	cwd := fsext.DirTrim(fsext.PrettyPath(h.cfg.WorkingDir()), dirTrimLimit)
 	cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
 	cwd = s.Muted.Render(cwd)
 

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

@@ -57,6 +57,8 @@ type messageCmp struct {
 
 	// Thinking viewport for displaying reasoning content
 	thinkingViewport viewport.Model
+
+	cfg *config.Config
 }
 
 var focusedMessageBorder = lipgloss.Border{
@@ -64,7 +66,7 @@ var focusedMessageBorder = lipgloss.Border{
 }
 
 // NewMessageCmp creates a new message component with the given message and options
-func NewMessageCmp(msg message.Message) MessageCmp {
+func NewMessageCmp(cfg *config.Config, msg message.Message) MessageCmp {
 	t := styles.CurrentTheme()
 
 	thinkingViewport := viewport.New()
@@ -72,6 +74,7 @@ func NewMessageCmp(msg message.Message) MessageCmp {
 	thinkingViewport.KeyMap = viewport.KeyMap{}
 
 	m := &messageCmp{
+		cfg:     cfg,
 		message: msg,
 		anim: anim.New(anim.Settings{
 			Size:        15,
@@ -363,6 +366,7 @@ type assistantSectionModel struct {
 	id                  string
 	message             message.Message
 	lastUserMessageTime time.Time
+	cfg                 *config.Config
 }
 
 // ID implements AssistantSection.
@@ -370,12 +374,13 @@ func (m *assistantSectionModel) ID() string {
 	return m.id
 }
 
-func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
+func NewAssistantSection(cfg *config.Config, message message.Message, lastUserMessageTime time.Time) AssistantSection {
 	return &assistantSectionModel{
 		width:               0,
 		id:                  uuid.NewString(),
 		message:             message,
 		lastUserMessageTime: lastUserMessageTime,
+		cfg:                 cfg,
 	}
 }
 
@@ -394,7 +399,7 @@ func (m *assistantSectionModel) View() string {
 	duration := finishTime.Sub(m.lastUserMessageTime)
 	infoMsg := t.S().Subtle.Render(duration.String())
 	icon := t.S().Subtle.Render(styles.ModelIcon)
-	model := config.Get().GetModel(m.message.Provider, m.message.Model)
+	model := m.cfg.GetModel(m.message.Provider, m.message.Model)
 	if model == nil {
 		// This means the model is not configured anymore
 		model = &catwalk.Model{

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

@@ -15,7 +15,6 @@ import (
 	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
@@ -110,7 +109,7 @@ func WithToolPermissionGranted() ToolCallOption {
 
 // NewToolCallCmp creates a new tool call component with the given parent message ID,
 // tool call, and optional configuration
-func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions permission.Service, opts ...ToolCallOption) ToolCallCmp {
+func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
 	m := &toolCallCmp{
 		call:            tc,
 		parentMessageID: parentMessageID,

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

@@ -8,13 +8,13 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/home"
-	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
@@ -69,16 +69,16 @@ type sidebarCmp struct {
 	session       session.Session
 	logo          string
 	cwd           string
-	lspClients    *csync.Map[string, *lsp.Client]
+	client        *client.Client
 	compactMode   bool
-	history       history.Service
 	files         *csync.Map[string, SessionFile]
+	cfg           *config.Config
 }
 
-func New(history history.Service, lspClients *csync.Map[string, *lsp.Client], compact bool) Sidebar {
+func New(c *client.Client, cfg *config.Config, compact bool) Sidebar {
 	return &sidebarCmp{
-		lspClients:  lspClients,
-		history:     history,
+		client:      c,
+		cfg:         cfg,
 		compactMode: compact,
 		files:       csync.NewMap[string, SessionFile](),
 	}
@@ -194,7 +194,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 := config.Get().WorkingDir()
+			cwd := m.cfg.WorkingDir()
 			path = strings.TrimPrefix(path, cwd)
 			_, additions, deletions := diff.GenerateDiff(before, after, path)
 			existing.Additions = additions
@@ -221,7 +221,7 @@ func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) te
 }
 
 func (m *sidebarCmp) loadSessionFiles() tea.Msg {
-	files, err := m.history.ListBySession(context.Background(), m.session.ID)
+	files, err := m.client.ListSessionHistoryFiles(context.Background(), m.session.ID)
 	if err != nil {
 		return util.InfoMsg{
 			Type: util.InfoTypeError,
@@ -247,7 +247,7 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
 
 	sessionFiles := make([]SessionFile, 0, len(fileMap))
 	for path, fh := range fileMap {
-		cwd := config.Get().WorkingDir()
+		cwd := m.cfg.WorkingDir()
 		path = strings.TrimPrefix(path, cwd)
 		before, _ := fsext.ToUnixLineEndings(fh.initialVersion.Content)
 		after, _ := fsext.ToUnixLineEndings(fh.latestVersion.Content)
@@ -267,7 +267,7 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
 
 func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
 	m.logo = m.logoBlock()
-	m.cwd = cwd()
+	m.cwd = cwd(m.cfg)
 	m.width = width
 	m.height = height
 	return nil
@@ -406,7 +406,7 @@ func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
 		maxItems = min(maxItems, availableHeight)
 	}
 
-	return files.RenderFileBlock(fileSlice, files.RenderOptions{
+	return files.RenderFileBlock(m.cfg, fileSlice, files.RenderOptions{
 		MaxWidth:    maxWidth,
 		MaxItems:    maxItems,
 		ShowSection: true,
@@ -417,14 +417,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 := config.Get().LSP.Sorted()
+	lspConfigs := m.cfg.LSP.Sorted()
 	maxItems := min(5, len(lspConfigs))
 	availableHeight := m.height - 8
 	if availableHeight > 0 {
 		maxItems = min(maxItems, availableHeight)
 	}
 
-	return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
+	return lspcomponent.RenderLSPBlock(m.client, m.cfg, lspcomponent.RenderOptions{
 		MaxWidth:    maxWidth,
 		MaxItems:    maxItems,
 		ShowSection: true,
@@ -435,13 +435,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(config.Get().MCP.Sorted()))
+	maxItems := min(5, len(m.cfg.MCP.Sorted()))
 	availableHeight := m.height - 8
 	if availableHeight > 0 {
 		maxItems = min(maxItems, availableHeight)
 	}
 
-	return mcp.RenderMCPBlock(mcp.RenderOptions{
+	return mcp.RenderMCPBlock(m.cfg, mcp.RenderOptions{
 		MaxWidth:    maxWidth,
 		MaxItems:    maxItems,
 		ShowSection: true,
@@ -469,7 +469,7 @@ func (m *sidebarCmp) filesBlock() string {
 	maxFiles, _, _ := m.getDynamicLimits()
 	maxFiles = min(len(fileSlice), maxFiles)
 
-	return files.RenderFileBlock(fileSlice, files.RenderOptions{
+	return files.RenderFileBlock(m.cfg, fileSlice, files.RenderOptions{
 		MaxWidth:    m.getMaxWidth(),
 		MaxItems:    maxFiles,
 		ShowSection: true,
@@ -480,10 +480,10 @@ func (m *sidebarCmp) filesBlock() string {
 func (m *sidebarCmp) lspBlock() string {
 	// Limit the number of LSPs shown
 	_, maxLSPs, _ := m.getDynamicLimits()
-	lspConfigs := config.Get().LSP.Sorted()
+	lspConfigs := m.cfg.LSP.Sorted()
 	maxLSPs = min(len(lspConfigs), maxLSPs)
 
-	return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
+	return lspcomponent.RenderLSPBlock(m.client, m.cfg, lspcomponent.RenderOptions{
 		MaxWidth:    m.getMaxWidth(),
 		MaxItems:    maxLSPs,
 		ShowSection: true,
@@ -494,10 +494,10 @@ func (m *sidebarCmp) lspBlock() string {
 func (m *sidebarCmp) mcpBlock() string {
 	// Limit the number of MCPs shown
 	_, _, maxMCPs := m.getDynamicLimits()
-	mcps := config.Get().MCP.Sorted()
+	mcps := m.cfg.MCP.Sorted()
 	maxMCPs = min(len(mcps), maxMCPs)
 
-	return mcp.RenderMCPBlock(mcp.RenderOptions{
+	return mcp.RenderMCPBlock(m.cfg, mcp.RenderOptions{
 		MaxWidth:    m.getMaxWidth(),
 		MaxItems:    maxMCPs,
 		ShowSection: true,
@@ -544,13 +544,15 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
 }
 
 func (s *sidebarCmp) currentModelBlock() string {
-	cfg := config.Get()
-	agentCfg := cfg.Agents["coder"]
+	agentCfg := s.cfg.Agents["coder"]
 
-	selectedModel := cfg.Models[agentCfg.Model]
+	selectedModel := s.cfg.Models[agentCfg.Model]
 
-	model := config.Get().GetModelByType(agentCfg.Model)
-	modelProvider := config.Get().GetProviderForModel(agentCfg.Model)
+	model := s.cfg.GetModelByType(agentCfg.Model)
+	if model == nil {
+		return "No model found"
+	}
+	modelProvider := s.cfg.GetProviderForModel(agentCfg.Model)
 
 	t := styles.CurrentTheme()
 
@@ -606,8 +608,8 @@ func (m *sidebarCmp) SetCompactMode(compact bool) {
 	m.compactMode = compact
 }
 
-func cwd() string {
-	cwd := config.Get().WorkingDir()
+func cwd(cfg *config.Config) string {
+	cwd := cfg.WorkingDir()
 	t := styles.CurrentTheme()
 	return t.S().Muted.Render(home.Short(cwd))
 }

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

@@ -9,6 +9,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/llm/prompt"
@@ -72,9 +73,12 @@ type splashCmp struct {
 	selectedModel *models.ModelOption
 	isAPIKeyValid bool
 	apiKeyValue   string
+
+	client *client.Client
+	cfg    *config.Config
 }
 
-func New() Splash {
+func New(c *client.Client, cfg *config.Config) Splash {
 	keyMap := DefaultKeyMap()
 	listKeyMap := list.DefaultKeyMap()
 	listKeyMap.Down.SetEnabled(false)
@@ -86,10 +90,12 @@ func New() Splash {
 	listKeyMap.DownOneItem = keyMap.Next
 	listKeyMap.UpOneItem = keyMap.Previous
 
-	modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
+	modelList := models.NewModelListComponent(cfg, listKeyMap, "Find your fave", false)
 	apiKeyInput := models.NewAPIKeyInput()
 
 	return &splashCmp{
+		client:       c,
+		cfg:          cfg,
 		width:        0,
 		height:       0,
 		keyMap:       keyMap,
@@ -211,7 +217,7 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					}),
 					func() tea.Msg {
 						start := time.Now()
-						err := providerConfig.TestConnection(config.Get().Resolver())
+						err := providerConfig.TestConnection(s.cfg.Resolver())
 						// intentionally wait for at least 750ms to make sure the user sees the spinner
 						elapsed := time.Since(start)
 						if elapsed < 750*time.Millisecond {
@@ -305,8 +311,7 @@ func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
 		return nil
 	}
 
-	cfg := config.Get()
-	err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
+	err := s.cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
 	if err != nil {
 		return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
 	}
@@ -324,7 +329,7 @@ func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
 func (s *splashCmp) initializeProject() tea.Cmd {
 	s.needsProjectInit = false
 
-	if err := config.MarkProjectInitialized(); err != nil {
+	if err := config.MarkProjectInitialized(s.cfg); err != nil {
 		return util.ReportError(err)
 	}
 	var cmds []tea.Cmd
@@ -342,8 +347,7 @@ func (s *splashCmp) initializeProject() tea.Cmd {
 }
 
 func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
-	cfg := config.Get()
-	model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
+	model := s.cfg.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))
 	}
@@ -355,7 +359,7 @@ func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
 		MaxTokens:       model.DefaultMaxTokens,
 	}
 
-	err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
+	err := s.cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
 	if err != nil {
 		return util.ReportError(err)
 	}
@@ -367,16 +371,16 @@ func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
 	}
 	if knownProvider == nil {
 		// for local provider we just use the same model
-		err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
+		err = s.cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
 		if err != nil {
 			return util.ReportError(err)
 		}
 	} else {
 		smallModel := knownProvider.DefaultSmallModelID
-		model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
+		model := s.cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
 		// should never happen
 		if model == nil {
-			err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
+			err = s.cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
 			if err != nil {
 				return util.ReportError(err)
 			}
@@ -388,18 +392,17 @@ func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
 			ReasoningEffort: model.DefaultReasoningEffort,
 			MaxTokens:       model.DefaultMaxTokens,
 		}
-		err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
+		err = s.cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
 		if err != nil {
 			return util.ReportError(err)
 		}
 	}
-	cfg.SetupAgents()
+	s.cfg.SetupAgents()
 	return nil
 }
 
 func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
-	cfg := config.Get()
-	providers, err := config.Providers(cfg)
+	providers, err := config.Providers(s.cfg)
 	if err != nil {
 		return nil, err
 	}
@@ -412,8 +415,7 @@ func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.
 }
 
 func (s *splashCmp) isProviderConfigured(providerID string) bool {
-	cfg := config.Get()
-	if _, ok := cfg.Providers.Get(providerID); ok {
+	if _, ok := s.cfg.Providers.Get(providerID); ok {
 		return true
 	}
 	return false
@@ -650,11 +652,11 @@ func (s *splashCmp) cwdPart() string {
 }
 
 func (s *splashCmp) cwd() string {
-	return home.Short(config.Get().WorkingDir())
+	return home.Short(s.cfg.WorkingDir())
 }
 
-func LSPList(maxWidth int) []string {
-	return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{
+func LSPList(c *client.Client, cfg *config.Config, maxWidth int) []string {
+	return lspcomponent.RenderLSPList(c, cfg, lspcomponent.RenderOptions{
 		MaxWidth:    maxWidth,
 		ShowSection: false,
 	})
@@ -664,7 +666,7 @@ func (s *splashCmp) lspBlock() string {
 	t := styles.CurrentTheme()
 	maxWidth := s.getMaxInfoWidth() / 2
 	section := t.S().Subtle.Render("LSPs")
-	lspList := append([]string{section, ""}, LSPList(maxWidth-1)...)
+	lspList := append([]string{section, ""}, LSPList(s.client, s.cfg, maxWidth-1)...)
 	return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
 		lipgloss.JoinVertical(
 			lipgloss.Left,
@@ -673,8 +675,8 @@ func (s *splashCmp) lspBlock() string {
 	)
 }
 
-func MCPList(maxWidth int) []string {
-	return mcp.RenderMCPList(mcp.RenderOptions{
+func MCPList(cfg *config.Config, maxWidth int) []string {
+	return mcp.RenderMCPList(cfg, mcp.RenderOptions{
 		MaxWidth:    maxWidth,
 		ShowSection: false,
 	})
@@ -684,7 +686,7 @@ func (s *splashCmp) mcpBlock() string {
 	t := styles.CurrentTheme()
 	maxWidth := s.getMaxInfoWidth() / 2
 	section := t.S().Subtle.Render("MCPs")
-	mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...)
+	mcpList := append([]string{section, ""}, MCPList(s.cfg, maxWidth-1)...)
 	return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
 		lipgloss.JoinVertical(
 			lipgloss.Left,
@@ -694,9 +696,8 @@ func (s *splashCmp) mcpBlock() string {
 }
 
 func (s *splashCmp) currentModelBlock() string {
-	cfg := config.Get()
-	agentCfg := cfg.Agents["coder"]
-	model := config.Get().GetModelByType(agentCfg.Model)
+	agentCfg := s.cfg.Agents["coder"]
+	model := s.cfg.GetModelByType(agentCfg.Model)
 	if model == nil {
 		return ""
 	}

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

@@ -57,6 +57,8 @@ type commandDialogCmp struct {
 	commandType  int       // SystemCommands or UserCommands
 	userCommands []Command // User-defined commands
 	sessionID    string    // Current session ID
+
+	cfg *config.Config
 }
 
 type (
@@ -76,7 +78,7 @@ type (
 	}
 )
 
-func NewCommandDialog(sessionID string) CommandsDialog {
+func NewCommandDialog(cfg *config.Config, sessionID string) CommandsDialog {
 	keyMap := DefaultCommandsDialogKeyMap()
 	listKeyMap := list.DefaultKeyMap()
 	listKeyMap.Down.SetEnabled(false)
@@ -104,11 +106,12 @@ func NewCommandDialog(sessionID string) CommandsDialog {
 		help:        help,
 		commandType: SystemCommands,
 		sessionID:   sessionID,
+		cfg:         cfg,
 	}
 }
 
 func (c *commandDialogCmp) Init() tea.Cmd {
-	commands, err := LoadCustomCommands()
+	commands, err := LoadCustomCommands(c.cfg)
 	if err != nil {
 		return util.ReportError(err)
 	}
@@ -302,12 +305,11 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 	}
 
 	// Add reasoning toggle for models that support it
-	cfg := config.Get()
-	if agentCfg, ok := cfg.Agents["coder"]; ok {
-		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
-		model := cfg.GetModelByType(agentCfg.Model)
+	if agentCfg, ok := c.cfg.Agents["coder"]; ok {
+		providerCfg := c.cfg.GetProviderForModel(agentCfg.Model)
+		model := c.cfg.GetModelByType(agentCfg.Model)
 		if providerCfg != nil && model != nil && model.CanReason {
-			selectedModel := cfg.Models[agentCfg.Model]
+			selectedModel := c.cfg.Models[agentCfg.Model]
 
 			// Anthropic models: thinking toggle
 			if providerCfg.Type == catwalk.TypeAnthropic {
@@ -350,8 +352,8 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 		})
 	}
 	if c.sessionID != "" {
-		agentCfg := config.Get().Agents["coder"]
-		model := config.Get().GetModelByType(agentCfg.Model)
+		agentCfg := c.cfg.Agents["coder"]
+		model := c.cfg.GetModelByType(agentCfg.Model)
 		if model.SupportsImages {
 			commands = append(commands, Command{
 				ID:          "file_picker",

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

@@ -30,8 +30,7 @@ type commandSource struct {
 	prefix string
 }
 
-func LoadCustomCommands() ([]Command, error) {
-	cfg := config.Get()
+func LoadCustomCommands(cfg *config.Config) ([]Command, error) {
 	if cfg == nil {
 		return nil, fmt.Errorf("config not loaded")
 	}

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

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

internal/tui/components/dialogs/models/list.go 🔗

@@ -19,9 +19,10 @@ type ModelListComponent struct {
 	list      listModel
 	modelType int
 	providers []catwalk.Provider
+	cfg       *config.Config
 }
 
-func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
+func NewModelListComponent(cfg *config.Config, keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
 	t := styles.CurrentTheme()
 	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
 	options := []list.ListOption{
@@ -43,14 +44,14 @@ func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldRe
 	return &ModelListComponent{
 		list:      modelList,
 		modelType: LargeModelType,
+		cfg:       cfg,
 	}
 }
 
 func (m *ModelListComponent) Init() tea.Cmd {
 	var cmds []tea.Cmd
 	if len(m.providers) == 0 {
-		cfg := config.Get()
-		providers, err := config.Providers(cfg)
+		providers, err := config.Providers(m.cfg)
 		filteredProviders := []catwalk.Provider{}
 		for _, p := range providers {
 			hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
@@ -104,12 +105,11 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 	// first none section
 	selectedItemID := ""
 
-	cfg := config.Get()
 	var currentModel config.SelectedModel
 	if m.modelType == LargeModelType {
-		currentModel = cfg.Models[config.SelectedModelTypeLarge]
+		currentModel = m.cfg.Models[config.SelectedModelTypeLarge]
 	} else {
-		currentModel = cfg.Models[config.SelectedModelTypeSmall]
+		currentModel = m.cfg.Models[config.SelectedModelTypeSmall]
 	}
 
 	configuredIcon := t.S().Base.Foreground(t.Success).Render(styles.CheckIcon)
@@ -120,11 +120,11 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 
 	// First, add any configured providers that are not in the known providers list
 	// These should appear at the top of the list
-	knownProviders, err := config.Providers(cfg)
+	knownProviders, err := config.Providers(m.cfg)
 	if err != nil {
 		return util.ReportError(err)
 	}
-	for providerID, providerConfig := range cfg.Providers.Seq2() {
+	for providerID, providerConfig := range m.cfg.Providers.Seq2() {
 		if providerConfig.Disable {
 			continue
 		}
@@ -196,7 +196,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 		}
 
 		// Check if this provider is configured and not disabled
-		if providerConfig, exists := cfg.Providers.Get(string(provider.ID)); exists && providerConfig.Disable {
+		if providerConfig, exists := m.cfg.Providers.Get(string(provider.ID)); exists && providerConfig.Disable {
 			continue
 		}
 
@@ -206,7 +206,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 		}
 
 		section := list.NewItemSection(name)
-		if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
+		if _, ok := m.cfg.Providers.Get(string(provider.ID)); ok {
 			section.SetInfo(configured)
 		}
 		group := list.Group[list.CompletionItem[ModelOption]]{

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

@@ -67,9 +67,11 @@ type modelDialogCmp struct {
 	selectedModelType config.SelectedModelType
 	isAPIKeyValid     bool
 	apiKeyValue       string
+
+	cfg *config.Config
 }
 
-func NewModelDialogCmp() ModelDialog {
+func NewModelDialogCmp(cfg *config.Config) ModelDialog {
 	keyMap := DefaultKeyMap()
 
 	listKeyMap := list.DefaultKeyMap()
@@ -79,7 +81,7 @@ func NewModelDialogCmp() ModelDialog {
 	listKeyMap.UpOneItem = keyMap.Previous
 
 	t := styles.CurrentTheme()
-	modelList := NewModelListComponent(listKeyMap, largeModelInputPlaceholder, true)
+	modelList := NewModelListComponent(cfg, listKeyMap, largeModelInputPlaceholder, true)
 	apiKeyInput := NewAPIKeyInput()
 	apiKeyInput.SetShowTitle(false)
 	help := help.New()
@@ -91,6 +93,7 @@ func NewModelDialogCmp() ModelDialog {
 		width:       defaultWidth,
 		keyMap:      DefaultKeyMap(),
 		help:        help,
+		cfg:         cfg,
 	}
 }
 
@@ -136,7 +139,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					}),
 					func() tea.Msg {
 						start := time.Now()
-						err := providerConfig.TestConnection(config.Get().Resolver())
+						err := providerConfig.TestConnection(m.cfg.Resolver())
 						// intentionally wait for at least 750ms to make sure the user sees the spinner
 						elapsed := time.Since(start)
 						if elapsed < 750*time.Millisecond {
@@ -344,16 +347,14 @@ func (m *modelDialogCmp) modelTypeRadio() string {
 }
 
 func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
-	cfg := config.Get()
-	if _, ok := cfg.Providers.Get(providerID); ok {
+	if _, ok := m.cfg.Providers.Get(providerID); ok {
 		return true
 	}
 	return false
 }
 
 func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
-	cfg := config.Get()
-	providers, err := config.Providers(cfg)
+	providers, err := config.Providers(m.cfg)
 	if err != nil {
 		return nil, err
 	}
@@ -370,8 +371,7 @@ func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
 		return util.ReportError(fmt.Errorf("no model selected"))
 	}
 
-	cfg := config.Get()
-	err := cfg.SetProviderAPIKey(string(m.selectedModel.Provider.ID), apiKey)
+	err := m.cfg.SetProviderAPIKey(string(m.selectedModel.Provider.ID), apiKey)
 	if err != nil {
 		return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
 	}

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

@@ -11,6 +11,7 @@ import (
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/permission"
+	"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"
@@ -19,13 +20,13 @@ import (
 	"github.com/charmbracelet/x/ansi"
 )
 
-type PermissionAction string
+type PermissionAction = proto.PermissionAction
 
 // Permission responses
 const (
-	PermissionAllow           PermissionAction = "allow"
-	PermissionAllowForSession PermissionAction = "allow_session"
-	PermissionDeny            PermissionAction = "deny"
+	PermissionAllow           = proto.PermissionAllow
+	PermissionAllowForSession = proto.PermissionAllowForSession
+	PermissionDeny            = proto.PermissionDeny
 
 	PermissionsDialogID dialogs.DialogID = "permissions"
 )

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

@@ -39,6 +39,8 @@ type reasoningDialogCmp struct {
 	effortList listModel
 	keyMap     ReasoningDialogKeyMap
 	help       help.Model
+
+	cfg *config.Config
 }
 
 type ReasoningEffortSelectedMsg struct {
@@ -84,7 +86,7 @@ func (k ReasoningDialogKeyMap) FullHelp() [][]key.Binding {
 	}
 }
 
-func NewReasoningDialog() ReasoningDialog {
+func NewReasoningDialog(cfg *config.Config) ReasoningDialog {
 	keyMap := DefaultReasoningDialogKeyMap()
 	listKeyMap := list.DefaultKeyMap()
 	listKeyMap.Down.SetEnabled(false)
@@ -111,6 +113,7 @@ func NewReasoningDialog() ReasoningDialog {
 		width:      defaultWidth,
 		keyMap:     keyMap,
 		help:       help,
+		cfg:        cfg,
 	}
 }
 
@@ -119,10 +122,9 @@ func (r *reasoningDialogCmp) Init() tea.Cmd {
 }
 
 func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
-	cfg := config.Get()
-	if agentCfg, ok := cfg.Agents["coder"]; ok {
-		selectedModel := cfg.Models[agentCfg.Model]
-		model := cfg.GetModelByType(agentCfg.Model)
+	if agentCfg, ok := r.cfg.Agents["coder"]; ok {
+		selectedModel := r.cfg.Models[agentCfg.Model]
+		model := r.cfg.GetModelByType(agentCfg.Model)
 
 		// Get current reasoning effort
 		currentEffort := selectedModel.ReasoningEffort

internal/tui/components/files/files.go 🔗

@@ -40,7 +40,7 @@ type RenderOptions struct {
 }
 
 // RenderFileList renders a list of file status items with the given options.
-func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
+func RenderFileList(cfg *config.Config, fileSlice []SessionFile, opts RenderOptions) []string {
 	t := styles.CurrentTheme()
 	fileList := []string{}
 
@@ -90,7 +90,7 @@ func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
 		}
 
 		extraContent := strings.Join(statusParts, " ")
-		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
+		cwd := cfg.WorkingDir() + string(os.PathSeparator)
 		filePath := file.FilePath
 		if rel, err := filepath.Rel(cwd, filePath); err == nil {
 			filePath = rel
@@ -114,9 +114,9 @@ func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
 }
 
 // RenderFileBlock renders a complete file block with optional truncation indicator.
-func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string {
+func RenderFileBlock(cfg *config.Config, fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string {
 	t := styles.CurrentTheme()
-	fileList := RenderFileList(fileSlice, opts)
+	fileList := RenderFileList(cfg, fileSlice, opts)
 
 	// Add truncation indicator if needed
 	if showTruncationIndicator && opts.MaxItems > 0 {

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

@@ -1,12 +1,13 @@
 package lsp
 
 import (
+	"context"
 	"fmt"
+	"log/slog"
 	"strings"
 
-	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/styles"
@@ -23,7 +24,7 @@ type RenderOptions struct {
 }
 
 // RenderLSPList renders a list of LSP status items with the given options.
-func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions) []string {
+func RenderLSPList(c *client.Client, cfg *config.Config, opts RenderOptions) []string {
 	t := styles.CurrentTheme()
 	lspList := []string{}
 
@@ -36,14 +37,18 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
 		lspList = append(lspList, section, "")
 	}
 
-	lspConfigs := config.Get().LSP.Sorted()
+	lspConfigs := cfg.LSP.Sorted()
 	if len(lspConfigs) == 0 {
 		lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
 		return lspList
 	}
 
 	// Get LSP states
-	lspStates := app.GetLSPStates()
+	lspStates, err := c.GetLSPs(context.TODO())
+	if err != nil {
+		slog.Error("failed to get lsp clients")
+		return nil
+	}
 
 	// Determine how many items to show
 	maxItems := len(lspConfigs)
@@ -85,15 +90,20 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
 
 		// Calculate diagnostic counts if we have LSP clients
 		var extraContent string
-		if lspClients != nil {
+		if c != nil {
 			lspErrs := map[protocol.DiagnosticSeverity]int{
 				protocol.SeverityError:       0,
 				protocol.SeverityWarning:     0,
 				protocol.SeverityHint:        0,
 				protocol.SeverityInformation: 0,
 			}
-			if client, ok := lspClients.Get(l.Name); ok {
-				for _, diagnostics := range client.GetDiagnostics() {
+			if _, ok := lspStates[l.Name]; ok {
+				diags, err := c.GetLSPDiagnostics(context.TODO(), l.Name)
+				if err != nil {
+					slog.Error("couldn't get lsp diagnostics", "lsp", l.Name)
+					return nil
+				}
+				for _, diagnostics := range diags {
 					for _, diagnostic := range diagnostics {
 						if severity, ok := lspErrs[diagnostic.Severity]; ok {
 							lspErrs[diagnostic.Severity] = severity + 1
@@ -135,13 +145,18 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
 }
 
 // RenderLSPBlock renders a complete LSP block with optional truncation indicator.
-func RenderLSPBlock(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions, showTruncationIndicator bool) string {
+func RenderLSPBlock(c *client.Client, cfg *config.Config, opts RenderOptions, showTruncationIndicator bool) string {
 	t := styles.CurrentTheme()
-	lspList := RenderLSPList(lspClients, opts)
+	lspList := RenderLSPList(c, cfg, opts)
+	cfg, err := c.GetConfig(context.TODO())
+	if err != nil {
+		slog.Error("failed to get config for lsp block rendering", "error", err)
+		return ""
+	}
 
 	// Add truncation indicator if needed
 	if showTruncationIndicator && opts.MaxItems > 0 {
-		lspConfigs := config.Get().LSP.Sorted()
+		lspConfigs := cfg.LSP.Sorted()
 		if len(lspConfigs) > opts.MaxItems {
 			remaining := len(lspConfigs) - opts.MaxItems
 			if remaining == 1 {

internal/tui/components/mcp/mcp.go 🔗

@@ -20,7 +20,7 @@ type RenderOptions struct {
 }
 
 // RenderMCPList renders a list of MCP status items with the given options.
-func RenderMCPList(opts RenderOptions) []string {
+func RenderMCPList(cfg *config.Config, opts RenderOptions) []string {
 	t := styles.CurrentTheme()
 	mcpList := []string{}
 
@@ -33,7 +33,7 @@ func RenderMCPList(opts RenderOptions) []string {
 		mcpList = append(mcpList, section, "")
 	}
 
-	mcps := config.Get().MCP.Sorted()
+	mcps := cfg.MCP.Sorted()
 	if len(mcps) == 0 {
 		mcpList = append(mcpList, t.S().Base.Foreground(t.Border).Render("None"))
 		return mcpList
@@ -99,13 +99,13 @@ func RenderMCPList(opts RenderOptions) []string {
 }
 
 // RenderMCPBlock renders a complete MCP block with optional truncation indicator.
-func RenderMCPBlock(opts RenderOptions, showTruncationIndicator bool) string {
+func RenderMCPBlock(cfg *config.Config, opts RenderOptions, showTruncationIndicator bool) string {
 	t := styles.CurrentTheme()
-	mcpList := RenderMCPList(opts)
+	mcpList := RenderMCPList(cfg, opts)
 
 	// Add truncation indicator if needed
 	if showTruncationIndicator && opts.MaxItems > 0 {
-		mcps := config.Get().MCP.Sorted()
+		mcps := cfg.MCP.Sorted()
 		if len(mcps) > opts.MaxItems {
 			remaining := len(mcps) - opts.MaxItems
 			if remaining == 1 {

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

@@ -10,7 +10,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/message"
@@ -90,7 +90,8 @@ func cancelTimerCmd() tea.Cmd {
 type chatPage struct {
 	width, height               int
 	detailsWidth, detailsHeight int
-	app                         *app.App
+	app                         *client.Client
+	cfg                         *config.Config
 	keyboardEnhancements        tea.KeyboardEnhancementsMsg
 
 	// Layout state
@@ -117,33 +118,33 @@ type chatPage struct {
 	isProjectInit    bool
 }
 
-func New(app *app.App) ChatPage {
+func New(app *client.Client, cfg *config.Config) ChatPage {
 	return &chatPage{
 		app:         app,
+		cfg:         cfg,
 		keyMap:      DefaultKeyMap(),
-		header:      header.New(app.LSPClients),
-		sidebar:     sidebar.New(app.History, app.LSPClients, false),
-		chat:        chat.New(app),
+		header:      header.New(app, cfg),
+		sidebar:     sidebar.New(app, cfg, false),
+		chat:        chat.New(app, cfg),
 		editor:      editor.New(app),
-		splash:      splash.New(),
+		splash:      splash.New(app, cfg),
 		focusedPane: PanelTypeSplash,
 	}
 }
 
 func (p *chatPage) Init() tea.Cmd {
-	cfg := config.Get()
-	compact := cfg.Options.TUI.CompactMode
+	compact := p.cfg.Options.TUI.CompactMode
 	p.compact = compact
 	p.forceCompact = compact
 	p.sidebar.SetCompactMode(p.compact)
 
 	// Set splash state based on config
-	if !config.HasInitialDataConfig() {
+	if !config.HasInitialDataConfig(p.cfg) {
 		// First-time setup: show model selection
 		p.splash.SetOnboarding(true)
 		p.isOnboarding = true
 		p.splashFullScreen = true
-	} else if b, _ := config.ProjectNeedsInitialization(); b {
+	} else if b, _ := config.ProjectNeedsInitialization(p.cfg); b {
 		// Project needs CRUSH.md initialization
 		p.splash.SetProjectInit(true)
 		p.isProjectInit = true
@@ -331,7 +332,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return p, tea.Batch(cmds...)
 
 	case commands.CommandRunCustomMsg:
-		if p.app.CoderAgent.IsBusy() {
+		info, err := p.app.GetAgentInfo(context.TODO())
+		if err != nil {
+			return p, util.ReportError(fmt.Errorf("failed to get agent info: %w", err))
+		}
+		if info.IsBusy {
 			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
 		}
 
@@ -341,12 +346,12 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case splash.OnboardingCompleteMsg:
 		p.splashFullScreen = false
-		if b, _ := config.ProjectNeedsInitialization(); b {
+		if b, _ := config.ProjectNeedsInitialization(p.cfg); b {
 			p.splash.SetProjectInit(true)
 			p.splashFullScreen = true
 			return p, p.SetSize(p.width, p.height)
 		}
-		err := p.app.InitCoderAgent()
+		err := p.app.InitiateAgentProcessing(context.TODO())
 		if err != nil {
 			return p, util.ReportError(err)
 		}
@@ -355,7 +360,11 @@ 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:
-		if p.app.CoderAgent.IsBusy() {
+		info, err := p.app.GetAgentInfo(context.TODO())
+		if err != nil {
+			return p, util.ReportError(fmt.Errorf("failed to get agent info: %w", err))
+		}
+		if info.IsBusy {
 			return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 		}
 		return p, p.newSession()
@@ -363,16 +372,17 @@ 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
-			if p.app.CoderAgent == nil {
+			info, err := p.app.GetAgentInfo(context.TODO())
+			if err != nil || info.IsZero() {
 				return p, nil
 			}
-			if p.app.CoderAgent.IsBusy() {
+			if info.IsBusy {
 				return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 			}
 			return p, p.newSession()
 		case key.Matches(msg, p.keyMap.AddAttachment):
-			agentCfg := config.Get().Agents["coder"]
-			model := config.Get().GetModelByType(agentCfg.Model)
+			agentCfg := p.cfg.Agents["coder"]
+			model := p.cfg.GetModelByType(agentCfg.Model)
 			if model.SupportsImages {
 				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
 			} else {
@@ -387,7 +397,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			p.changeFocus()
 			return p, nil
 		case key.Matches(msg, p.keyMap.Cancel):
-			if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
+			info, err := p.app.GetAgentInfo(context.TODO())
+			if err != nil {
+				return p, util.ReportError(fmt.Errorf("failed to get agent info: %w", err))
+			}
+			if p.session.ID != "" && info.IsBusy {
 				return p, p.cancel()
 			}
 		case key.Matches(msg, p.keyMap.Details):
@@ -516,7 +530,7 @@ func (p *chatPage) View() string {
 
 func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 	return func() tea.Msg {
-		err := config.Get().SetCompactMode(compact)
+		err := p.cfg.SetCompactMode(compact)
 		if err != nil {
 			return util.InfoMsg{
 				Type: util.InfoTypeError,
@@ -529,16 +543,15 @@ func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 
 func (p *chatPage) toggleThinking() tea.Cmd {
 	return func() tea.Msg {
-		cfg := config.Get()
-		agentCfg := cfg.Agents["coder"]
-		currentModel := cfg.Models[agentCfg.Model]
+		agentCfg := p.cfg.Agents["coder"]
+		currentModel := p.cfg.Models[agentCfg.Model]
 
 		// Toggle the thinking mode
 		currentModel.Think = !currentModel.Think
-		cfg.Models[agentCfg.Model] = currentModel
+		p.cfg.Models[agentCfg.Model] = currentModel
 
 		// Update the agent with the new configuration
-		if err := p.app.UpdateAgentModel(); err != nil {
+		if err := p.app.UpdateAgent(context.TODO()); err != nil {
 			return util.InfoMsg{
 				Type: util.InfoTypeError,
 				Msg:  "Failed to update thinking mode: " + err.Error(),
@@ -558,16 +571,15 @@ func (p *chatPage) toggleThinking() tea.Cmd {
 
 func (p *chatPage) openReasoningDialog() tea.Cmd {
 	return func() tea.Msg {
-		cfg := config.Get()
-		agentCfg := cfg.Agents["coder"]
-		model := cfg.GetModelByType(agentCfg.Model)
-		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
+		agentCfg := p.cfg.Agents["coder"]
+		model := p.cfg.GetModelByType(agentCfg.Model)
+		providerCfg := p.cfg.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(),
+				Model: reasoning.NewReasoningDialog(p.cfg),
 			}
 		}
 		return nil
@@ -576,7 +588,7 @@ func (p *chatPage) openReasoningDialog() tea.Cmd {
 
 func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 	return func() tea.Msg {
-		cfg := config.Get()
+		cfg := p.cfg
 		agentCfg := cfg.Agents["coder"]
 		currentModel := cfg.Models[agentCfg.Model]
 
@@ -585,7 +597,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.UpdateAgentModel(); err != nil {
+		if err := p.app.UpdateAgent(context.TODO()); err != nil {
 			return util.InfoMsg{
 				Type: util.InfoTypeError,
 				Msg:  "Failed to update reasoning effort: " + err.Error(),
@@ -706,14 +718,13 @@ func (p *chatPage) changeFocus() {
 func (p *chatPage) cancel() tea.Cmd {
 	if p.isCanceling {
 		p.isCanceling = false
-		if p.app.CoderAgent != nil {
-			p.app.CoderAgent.Cancel(p.session.ID)
-		}
+		_ = p.app.ClearAgentSessionQueuedPrompts(context.TODO(), p.session.ID)
 		return nil
 	}
 
-	if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
-		p.app.CoderAgent.ClearQueue(p.session.ID)
+	queued, _ := p.app.GetAgentSessionQueuedPrompts(context.TODO(), p.session.ID)
+	if queued > 0 {
+		_ = p.app.ClearAgentSessionQueuedPrompts(context.TODO(), p.session.ID)
 		return nil
 	}
 	p.isCanceling = true
@@ -739,18 +750,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.Sessions.Create(context.Background(), "New Session")
+		newSession, err := p.app.CreateSession(context.Background(), "New Session")
 		if err != nil {
 			return util.ReportError(err)
 		}
-		session = newSession
+		session = *newSession
 		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 	}
-	if p.app.CoderAgent == nil {
+	info, err := p.app.GetAgentInfo(context.TODO())
+	if err != nil || info.IsZero() {
 		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 	}
-	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
-	if err != nil {
+	if err := p.app.SendMessage(context.Background(), session.ID, text, attachments...); err != nil {
 		return util.ReportError(err)
 	}
 	cmds = append(cmds, p.chat.GoToBottom())
@@ -762,7 +773,8 @@ func (p *chatPage) Bindings() []key.Binding {
 		p.keyMap.NewSession,
 		p.keyMap.AddAttachment,
 	}
-	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
+	info, err := p.app.GetAgentInfo(context.TODO())
+	if err == nil && info.IsBusy {
 		cancelBinding := p.keyMap.Cancel
 		if p.isCanceling {
 			cancelBinding = key.NewBinding(
@@ -883,7 +895,8 @@ func (p *chatPage) Help() help.KeyMap {
 			}
 			return core.NewSimpleHelp(shortList, fullList)
 		}
-		if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
+		info, err := p.app.GetAgentInfo(context.TODO())
+		if err == nil && info.IsBusy {
 			cancelBinding := key.NewBinding(
 				key.WithKeys("esc"),
 				key.WithHelp("esc", "cancel"),
@@ -894,7 +907,8 @@ func (p *chatPage) Help() help.KeyMap {
 					key.WithHelp("esc", "press again to cancel"),
 				)
 			}
-			if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
+			queued, _ := p.app.GetAgentSessionQueuedPrompts(context.TODO(), p.session.ID)
+			if queued > 0 {
 				cancelBinding = key.NewBinding(
 					key.WithKeys("esc"),
 					key.WithHelp("esc", "clear queue"),

internal/tui/tui.go 🔗

@@ -3,15 +3,18 @@ package tui
 import (
 	"context"
 	"fmt"
+	"path/filepath"
 	"strings"
 	"time"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/llm/agent"
+	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/chat/splash"
@@ -64,7 +67,8 @@ type appModel struct {
 	status          status.StatusCmp
 	showingFullHelp bool
 
-	app *app.App
+	app *client.Client
+	cfg *config.Config
 
 	dialog       dialogs.DialogCmp
 	completions  completions.Completions
@@ -98,7 +102,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.isConfigured = config.HasInitialDataConfig(a.cfg)
 
 	switch msg := msg.(type) {
 	case tea.KeyboardEnhancementsMsg:
@@ -164,7 +168,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	// Commands
 	case commands.SwitchSessionsMsg:
 		return a, func() tea.Msg {
-			allSessions, _ := a.app.Sessions.List(context.Background())
+			allSessions, _ := a.app.ListSessions(context.Background())
 			return dialogs.OpenDialogMsg{
 				Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
 			}
@@ -173,33 +177,43 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case commands.SwitchModelMsg:
 		return a, util.CmdHandler(
 			dialogs.OpenDialogMsg{
-				Model: models.NewModelDialogCmp(),
+				Model: models.NewModelDialogCmp(a.cfg),
 			},
 		)
 	// Compact
 	case commands.CompactMsg:
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
+			Model: compact.NewCompactDialogCmp(a.app, msg.SessionID, true),
 		})
 	case commands.QuitMsg:
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: quit.NewQuitDialog(),
 		})
 	case commands.ToggleYoloModeMsg:
-		a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
+		skip, err := a.app.GetPermissionsSkipRequests(context.TODO())
+		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 {
+			return a, util.ReportError(fmt.Errorf("failed to toggle YOLO mode: %v", err))
+		}
 	case commands.ToggleHelpMsg:
 		a.status.ToggleFullHelp()
 		a.showingFullHelp = !a.showingFullHelp
 		return a, a.handleWindowResize(a.wWidth, a.wHeight)
 	// Model Switch
 	case models.ModelSelectedMsg:
-		if a.app.CoderAgent.IsBusy() {
+		info, err := a.app.GetAgentInfo(context.TODO())
+		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...")
 		}
-		config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
+		a.cfg.UpdatePreferredModel(msg.ModelType, msg.Model)
 
 		// Update the agent with the new model/provider configuration
-		if err := a.app.UpdateAgentModel(); err != nil {
+		if err := a.app.UpdateAgent(context.TODO()); err != nil {
 			return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
 		}
 
@@ -216,7 +230,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.app.Config().WorkingDir()),
+			Model: filepicker.NewFilePickerCmp(a.cfg.WorkingDir()),
 		})
 	// Permissions
 	case pubsub.Event[permission.PermissionNotification]:
@@ -235,23 +249,22 @@ 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: config.Get().Options.TUI.DiffMode,
+				DiffMode: a.cfg.Options.TUI.DiffMode,
 			}),
 		})
 	case permissions.PermissionResponseMsg:
-		switch msg.Action {
-		case permissions.PermissionAllow:
-			a.app.Permissions.Grant(msg.Permission)
-		case permissions.PermissionAllowForSession:
-			a.app.Permissions.GrantPersistent(msg.Permission)
-		case permissions.PermissionDeny:
-			a.app.Permissions.Deny(msg.Permission)
+		if err := a.app.GrantPermission(context.TODO(), proto.PermissionGrant(msg)); err != nil {
+			return a, util.ReportError(fmt.Errorf("failed to grant permission: %v", err))
 		}
 		return a, nil
 	// Agent Events
 	case pubsub.Event[agent.AgentEvent]:
 		payload := msg.Payload
 
+		if payload.Type == agent.AgentEventTypeError {
+			return a, util.ReportError(fmt.Errorf("agent error: %v", payload.Error))
+		}
+
 		// Forward agent events to dialogs
 		if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
 			u, dialogCmd := a.dialog.Update(payload)
@@ -265,14 +278,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.Sessions.Get(context.Background(), a.selectedSessionID)
+			session, err := a.app.GetSession(context.Background(), a.selectedSessionID)
 			if err == nil {
-				model := a.app.CoderAgent.Model()
+				info, err := a.app.GetAgentInfo(context.Background())
+				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)) && !config.Get().Options.DisableAutoSummarize { // Show compact confirmation dialog
+				if (tokens >= int64(float64(contextWindow)*0.95)) && !a.cfg.Options.DisableAutoSummarize { // Show compact confirmation dialog
 					cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
-						Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
+						Model: compact.NewCompactDialogCmp(a.app, a.selectedSessionID, false),
 					}))
 				}
 			}
@@ -285,7 +302,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return a, nil
 		}
 
-		a.isConfigured = config.HasInitialDataConfig()
+		a.isConfigured = config.HasInitialDataConfig(a.cfg)
 		updated, pageCmd := item.Update(msg)
 		if model, ok := updated.(util.Model); ok {
 			a.pages[a.currentPage] = model
@@ -452,7 +469,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 			return nil
 		}
 		return util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: commands.NewCommandDialog(a.selectedSessionID),
+			Model: commands.NewCommandDialog(a.cfg, a.selectedSessionID),
 		})
 	case key.Matches(msg, a.keyMap.Sessions):
 		// if the app is not configured show no sessions
@@ -472,7 +489,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		}
 		cmds = append(cmds,
 			func() tea.Msg {
-				allSessions, _ := a.app.Sessions.List(context.Background())
+				allSessions, _ := a.app.ListSessions(context.Background())
 				return dialogs.OpenDialogMsg{
 					Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
 				}
@@ -480,7 +497,8 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		)
 		return tea.Sequence(cmds...)
 	case key.Matches(msg, a.keyMap.Suspend):
-		if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
+		info, err := a.app.GetAgentInfo(context.TODO())
+		if err != nil || info.IsBusy {
 			return util.ReportWarn("Agent is busy, please wait...")
 		}
 		return tea.Suspend
@@ -500,7 +518,11 @@ 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 {
-	if a.app.CoderAgent.IsBusy() {
+	info, err := a.app.GetAgentInfo(context.TODO())
+	if err != nil {
+		return util.ReportError(fmt.Errorf("failed to check if agent is busy: %v", err))
+	}
+	if info.IsBusy {
 		// TODO: maybe remove this :  For now we don't move to any page if the agent is busy
 		return util.ReportWarn("Agent is busy, please wait...")
 	}
@@ -601,14 +623,25 @@ func (a *appModel) View() tea.View {
 }
 
 // New creates and initializes a new TUI application model.
-func New(app *app.App) tea.Model {
-	chatPage := chat.New(app)
+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)
+	}
+
+	// Setup logs
+	log.Setup(
+		filepath.Join(cfg.Options.DataDirectory, "logs", "tui.log"),
+		cfg.Options.Debug,
+	)
+
+	chatPage := chat.New(app, cfg)
 	keyMap := DefaultKeyMap()
 	keyMap.pageBindings = chatPage.Bindings()
-
 	model := &appModel{
 		currentPage: chat.ChatPageID,
 		app:         app,
+		cfg:         cfg,
 		status:      status.NewStatusCmp(),
 		loadedPages: make(map[page.PageID]bool),
 		keyMap:      keyMap,
@@ -621,5 +654,5 @@ func New(app *app.App) tea.Model {
 		completions: completions.New(),
 	}
 
-	return model
+	return model, nil
 }