From b831c7c1d5bb75b781d2e02f6a477b16e89a0b60 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 23 Sep 2025 16:36:06 -0400 Subject: [PATCH] refactor(tui): decouple TUI from app package using client and config --- internal/tui/components/chat/chat.go | 40 ++++--- internal/tui/components/chat/editor/editor.go | 21 ++-- internal/tui/components/chat/header/header.go | 38 +++++-- .../tui/components/chat/messages/messages.go | 11 +- internal/tui/components/chat/messages/tool.go | 3 +- .../tui/components/chat/sidebar/sidebar.go | 56 ++++----- internal/tui/components/chat/splash/splash.go | 57 +++++----- .../components/dialogs/commands/commands.go | 20 ++-- .../tui/components/dialogs/commands/loader.go | 3 +- .../tui/components/dialogs/compact/compact.go | 9 +- .../tui/components/dialogs/models/list.go | 20 ++-- .../tui/components/dialogs/models/models.go | 18 +-- .../dialogs/permissions/permissions.go | 9 +- .../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(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 8688f7e24c94290c74ae4344499acff61b43ac39..15086f866afe249451d6219e68241b89be4691b4 100644 --- a/internal/tui/components/chat/chat.go +++ b/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)) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 04fb5ed1976c7cf7ba4af372dd16ecef48ceb82f..38c8309ad69ba6bc9a61d4af663527cd7f0afb1e 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/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) diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go index 21861a4a2eda1340f6e01c0748f24cb713f15398..f92b1bfffcf3e8bda2b81e56a95f06c40cde7299 100644 --- a/internal/tui/components/chat/header/header.go +++ b/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) diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 5cc15d0303fb152f299aef9a2cdc596b9ffb57d4..ba7297ba5d023fa522d148ca0f46dfc504b2b6bc 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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{ diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 7e03674f97243e7d9e569b341fe1c6f1d2450b93..c4eaf6a9d2cb57e8ccbc2113c26cb628a2c65961 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/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, diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index b50a78c7f8697e4f4db19649a01794cfe7a23bac..65ca23107ca93f3755798966cd88c63a01cd386f 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/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)) } diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 187fc35e6ec47a858b99f35e135a8cef3500fbf1..1c4f43d175c2aa278c4406fdc621f3aaca4502e2 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/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 "" } diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 664158fc392a87d8a7725bfa964748f7ef4f8e67..16470c3a0a8b09598d44efd9ae7824528ad4fc5c 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/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", diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/tui/components/dialogs/commands/loader.go index 74d9c7e4baee2e2d19f8baca914942f0c0d34cd3..d495c797f78c0879bea24f23e7a5d07e10360003 100644 --- a/internal/tui/components/dialogs/commands/loader.go +++ b/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") } diff --git a/internal/tui/components/dialogs/compact/compact.go b/internal/tui/components/dialogs/compact/compact.go index ecde402fd8dfe1f31791834cd4e4bae13ec45e00..bd388055ce0df66073576149f55b41f869105081 100644 --- a/internal/tui/components/dialogs/compact/compact.go +++ b/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() diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index 77398c4d17d85126ab155a9e9c5b2085c0691672..be6446c9c8d8f0c05eef3bf1e53d00a0ccb3c137 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/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]]{ diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index 7c2863706c29180cffcfb88c385a012e39df464c..1301b7ffb0b18661ac0d710c54d92469c45e2765 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/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)) } diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 2633c0a2f1a50f78adf010214680c157f302073b..35ddc4fc2fc3118abc5736ba5571ca6a3d8181da 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/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" ) diff --git a/internal/tui/components/dialogs/reasoning/reasoning.go b/internal/tui/components/dialogs/reasoning/reasoning.go index ba49abd8c58a0e7eb84235e7b68f5f5193a96b1b..d4f1d3f4a057ae903e2576ac634e02c49fb269d2 100644 --- a/internal/tui/components/dialogs/reasoning/reasoning.go +++ b/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 diff --git a/internal/tui/components/files/files.go b/internal/tui/components/files/files.go index 3e99f222f96e26ef2bec6943d0bfeb3156b25777..9c754ecd2a6ecbe15f2a69d34e00ed515ff5e4c9 100644 --- a/internal/tui/components/files/files.go +++ b/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 { diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go index f5f4061045901c91ecb8bce1f47eab3ac1f7abcf..2a0fc30a65ba2ce01d4dcd7db583f5b47d6009d5 100644 --- a/internal/tui/components/lsp/lsp.go +++ b/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 { diff --git a/internal/tui/components/mcp/mcp.go b/internal/tui/components/mcp/mcp.go index d11826b77749ba65276b5336a5d88cdbc8552881..219b7288c17c25818c34758089ac0a78b1c64872 100644 --- a/internal/tui/components/mcp/mcp.go +++ b/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 { diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 88523388e31824a65d7e9922b89a1886a5fbcc0d..c9c19e3051e316b0bafdb94a9d1a09d965c5df0d 100644 --- a/internal/tui/page/chat/chat.go +++ b/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"), diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0986aca31dcd779ca6fe611e1d71eff8ad6908e9..52b47ea3a4d4755eb544dbaf2dc1caac205d573b 100644 --- a/internal/tui/tui.go +++ b/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 }