From 1526d1afd904930d2d08074130809ea83097d2c7 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 2 Jul 2025 17:57:03 +0200 Subject: [PATCH 01/38] chore: rewrite the chat page chore: small fixes chore: small fixes --- internal/tui/components/chat/chat.go | 30 +- internal/tui/components/chat/editor/editor.go | 26 +- internal/tui/components/chat/header/header.go | 17 +- .../tui/components/chat/sidebar/sidebar.go | 63 +- internal/tui/components/chat/splash/splash.go | 21 +- internal/tui/components/core/layout/split.go | 5 - internal/tui/components/logo/logo.go | 2 +- internal/tui/page/chat/chat.go | 644 +++++++++++------- internal/tui/tui.go | 2 +- 9 files changed, 486 insertions(+), 324 deletions(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 21e7b74cdcf569c28a12a647069774a0c255715c..f10362f30bdfad64bb8d73d16819c1a9f1129f34 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -8,14 +8,15 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/logging" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/chat/messages" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" ) type SendMsg struct { @@ -37,6 +38,9 @@ type MessageListCmp interface { util.Model layout.Sizeable layout.Focusable + layout.Help + + SetSession(session.Session) tea.Cmd } // messageListCmp implements MessageListCmp, providing a virtualized list @@ -53,9 +57,9 @@ type messageListCmp struct { defaultListKeyMap list.KeyMap } -// NewMessagesListCmp creates a new message list component with custom keybindings +// New creates a new message list component with custom keybindings // and reverse ordering (newest messages at bottom). -func NewMessagesListCmp(app *app.App) MessageListCmp { +func New(app *app.App) MessageListCmp { defaultListKeyMap := list.DefaultKeyMap() listCmp := list.New( list.WithGapSize(1), @@ -70,13 +74,14 @@ func NewMessagesListCmp(app *app.App) MessageListCmp { } } -// Init initializes the component (no initialization needed). +// Init initializes the component. func (m *messageListCmp) Init() tea.Cmd { return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur()) } // Update handles incoming messages and updates the component state. func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + logging.Info("messageListCmp.Update", "msg", msg) switch msg := msg.(type) { case SessionSelectedMsg: if msg.ID != m.session.ID { @@ -102,11 +107,15 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the message list or an initial screen if empty. func (m *messageListCmp) View() tea.View { + t := styles.CurrentTheme() return tea.NewView( - lipgloss.JoinVertical( - lipgloss.Left, - m.listCmp.View().String(), - ), + t.S().Base. + Padding(1). + Width(m.width). + Height(m.height). + Render( + m.listCmp.View().String(), + ), ) } @@ -371,6 +380,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd // SetSession loads and displays messages for a new session. func (m *messageListCmp) SetSession(session session.Session) tea.Cmd { + logging.Info("messageListCmp.SetSession", "sessionID", session.ID) if m.session.ID == session.ID { return nil } @@ -489,8 +499,8 @@ func (m *messageListCmp) GetSize() (int, int) { // SetSize updates the component dimensions and propagates to the list component. func (m *messageListCmp) SetSize(width int, height int) tea.Cmd { m.width = width - m.height = height - 1 - return m.listCmp.SetSize(width, height-1) + m.height = height + return m.listCmp.SetSize(width-2, height-2) // for padding } // Blur implements MessageListCmp. diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index ca8624e9342857d436f0ec0995c5e5c3023680e7..71bc01e633ea0da5f0104ad79d184355cd0b7edd 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -18,6 +18,7 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/dialogs" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" @@ -26,6 +27,16 @@ import ( "github.com/charmbracelet/lipgloss/v2" ) +type Editor interface { + util.Model + layout.Sizeable + layout.Focusable + layout.Help + layout.Positional + + SetSession(session session.Session) tea.Cmd +} + type FileCompletionItem struct { Path string // The file path } @@ -145,11 +156,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { - case chat.SessionSelectedMsg: - if msg.ID != m.session.ID { - m.session = msg - } - return m, nil case filepicker.FilePickedMsg: if len(m.attachments) >= maxAttachments { return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments)) @@ -372,7 +378,14 @@ func (c *editorCmp) Bindings() []key.Binding { return c.keyMap.KeyBindings() } -func NewEditorCmp(app *app.App) util.Model { +// TODO: most likely we do not need to have the session here +// we need to move some functionality to the page level +func (c *editorCmp) SetSession(session session.Session) tea.Cmd { + c.session = session + return nil +} + +func New(app *app.App) Editor { t := styles.CurrentTheme() ta := textarea.New() ta.SetStyles(t.S().TextArea) @@ -393,6 +406,7 @@ func NewEditorCmp(app *app.App) util.Model { ta.Focus() return &editorCmp{ + // TODO: remove the app instance from here app: app, textarea: ta, keyMap: DefaultEditorKeyMap(), diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go index 45874a188f59c272cfcef4a7e41f0f631afea954..b3fe260a7909507cca3b4a09222dd119bb9b34d5 100644 --- a/internal/tui/components/chat/header/header.go +++ b/internal/tui/components/chat/header/header.go @@ -11,7 +11,6 @@ import ( "github.com/charmbracelet/crush/internal/lsp/protocol" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -19,7 +18,8 @@ import ( type Header interface { util.Model - SetSession(session session.Session) + SetSession(session session.Session) tea.Cmd + SetWidth(width int) tea.Cmd SetDetailsOpen(open bool) } @@ -43,10 +43,6 @@ func (h *header) Init() tea.Cmd { func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.width = msg.Width - 2 - case chat.SessionSelectedMsg: - p.session = msg case pubsub.Event[session.Session]: if msg.Type == pubsub.UpdatedEvent { if p.session.ID == msg.Payload.ID { @@ -131,6 +127,13 @@ func (h *header) SetDetailsOpen(open bool) { } // SetSession implements Header. -func (h *header) SetSession(session session.Session) { +func (h *header) SetSession(session session.Session) tea.Cmd { h.session = session + return nil +} + +// SetWidth implements Header. +func (h *header) SetWidth(width int) tea.Cmd { + h.width = width + return nil } diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 609b196cee106a19f6bf065bb3a050482a9d82a6..558d7b555abc10ab20ddf6cda6663926b081cd03 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -13,7 +13,6 @@ import ( "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" - "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/lsp/protocol" "github.com/charmbracelet/crush/internal/pubsub" @@ -29,10 +28,6 @@ import ( "github.com/charmbracelet/x/ansi" ) -const ( - logoBreakpoint = 65 -) - type FileHistory struct { initialVersion history.File latestVersion history.File @@ -52,6 +47,7 @@ type Sidebar interface { util.Model layout.Sizeable SetSession(session session.Session) tea.Cmd + SetCompactMode(bool) } type sidebarCmp struct { @@ -66,7 +62,7 @@ type sidebarCmp struct { files sync.Map } -func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar { +func New(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar { return &sidebarCmp{ lspClients: lspClients, history: history, @@ -75,15 +71,11 @@ func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, c } func (m *sidebarCmp) Init() tea.Cmd { - m.logo = m.logoBlock(false) - m.cwd = cwd() return nil } func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case chat.SessionSelectedMsg: - return m, m.SetSession(msg) case SessionFilesMsg: m.files = sync.Map{} for _, file := range msg.Files { @@ -137,8 +129,19 @@ func (m *sidebarCmp) View() tea.View { m.mcpBlock(), ) + // TODO: CHECK out why we need to set the background here weird issue + style := t.S().Base. + Background(t.BgBase). + Width(m.width). + Height(m.height). + Padding(1) + if m.compactMode { + style = style.PaddingTop(0) + } return tea.NewView( - lipgloss.JoinVertical(lipgloss.Left, parts...), + style.Render( + lipgloss.JoinVertical(lipgloss.Left, parts...), + ), ) } @@ -232,12 +235,8 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg { } func (m *sidebarCmp) SetSize(width, height int) tea.Cmd { - if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) { - m.logo = m.logoBlock(true) - } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) { - m.logo = m.logoBlock(false) - } - + m.logo = m.logoBlock() + m.cwd = cwd() m.width = width m.height = height return nil @@ -247,9 +246,9 @@ func (m *sidebarCmp) GetSize() (int, int) { return m.width, m.height } -func (m *sidebarCmp) logoBlock(compact bool) string { +func (m *sidebarCmp) logoBlock() string { t := styles.CurrentTheme() - return logo.Render(version.Version, compact, logo.Opts{ + return logo.Render(version.Version, true, logo.Opts{ FieldColor: t.Primary, TitleColorA: t.Secondary, TitleColorB: t.Primary, @@ -258,12 +257,15 @@ func (m *sidebarCmp) logoBlock(compact bool) string { }) } +func (m *sidebarCmp) getMaxWidth() int { + return min(m.width-2, 58) // -2 for padding +} + func (m *sidebarCmp) filesBlock() string { - maxWidth := min(m.width, 58) t := styles.CurrentTheme() section := t.S().Subtle.Render( - core.Section("Modified Files", maxWidth), + core.Section("Modified Files", m.getMaxWidth()), ) files := make([]SessionFile, 0) @@ -304,7 +306,7 @@ func (m *sidebarCmp) filesBlock() string { filePath := file.FilePath filePath = strings.TrimPrefix(filePath, cwd) filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2) - filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…") + filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…") fileList = append(fileList, core.Status( core.StatusOpts{ @@ -313,7 +315,7 @@ func (m *sidebarCmp) filesBlock() string { Title: filePath, ExtraContent: extraContent, }, - m.width, + m.getMaxWidth(), ), ) } @@ -325,11 +327,10 @@ func (m *sidebarCmp) filesBlock() string { } func (m *sidebarCmp) lspBlock() string { - maxWidth := min(m.width, 58) t := styles.CurrentTheme() section := t.S().Subtle.Render( - core.Section("LSPs", maxWidth), + core.Section("LSPs", m.getMaxWidth()), ) lspList := []string{section, ""} @@ -387,7 +388,7 @@ func (m *sidebarCmp) lspBlock() string { Description: l.Command, ExtraContent: strings.Join(errs, " "), }, - m.width, + m.getMaxWidth(), ), ) } @@ -399,11 +400,10 @@ func (m *sidebarCmp) lspBlock() string { } func (m *sidebarCmp) mcpBlock() string { - maxWidth := min(m.width, 58) t := styles.CurrentTheme() section := t.S().Subtle.Render( - core.Section("MCPs", maxWidth), + core.Section("MCPs", m.getMaxWidth()), ) mcpList := []string{section, ""} @@ -427,7 +427,7 @@ func (m *sidebarCmp) mcpBlock() string { Title: n, Description: l.Command, }, - m.width, + m.getMaxWidth(), ), ) } @@ -510,6 +510,11 @@ func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd { return m.loadSessionFiles } +// SetCompactMode sets the compact mode for the sidebar. +func (m *sidebarCmp) SetCompactMode(compact bool) { + m.compactMode = compact +} + func cwd() string { cwd := config.Get().WorkingDir() t := styles.CurrentTheme() diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index efb7b6a26fd7027fa1e3d94de5457d56bcb73312..34828baf4c0b91cb71f4495f9e436a13d75ffe46 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -17,6 +17,11 @@ type Splash interface { layout.Help } +const ( + SplashScreenPaddingX = 2 // Padding X for the splash screen + SplashScreenPaddingY = 1 // Padding Y for the splash screen +) + type splashCmp struct { width, height int keyMap KeyMap @@ -61,8 +66,20 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View implements SplashPage. func (s *splashCmp) View() tea.View { + t := styles.CurrentTheme() content := lipgloss.JoinVertical(lipgloss.Left, s.logoRendered) - return tea.NewView(content) + return tea.NewView( + t.S().Base. + Width(s.width). + Height(s.height). + PaddingTop(SplashScreenPaddingY). + PaddingLeft(SplashScreenPaddingX). + PaddingRight(SplashScreenPaddingX). + PaddingBottom(SplashScreenPaddingY). + Render( + content, + ), + ) } func (s *splashCmp) logoBlock() string { @@ -74,7 +91,7 @@ func (s *splashCmp) logoBlock() string { TitleColorB: t.Primary, CharmColor: t.Secondary, VersionColor: t.Primary, - Width: s.width - padding, + Width: s.width - (SplashScreenPaddingX * 2), }) } diff --git a/internal/tui/components/core/layout/split.go b/internal/tui/components/core/layout/split.go index 241a947d34e32f27a3bce4ad839e085e2b376b4c..57bea8422f817a01615d285cec90ff2bc47428a5 100644 --- a/internal/tui/components/core/layout/split.go +++ b/internal/tui/components/core/layout/split.go @@ -1,11 +1,8 @@ package layout import ( - "log/slog" - "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -156,8 +153,6 @@ func (s *splitPaneLayout) View() tea.View { func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { s.width = width s.height = height - slog.Info("Setting split pane size", "width", width, "height", height) - var topHeight, bottomHeight int var cmds []tea.Cmd if s.bottomPanel != nil { diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go index 7a063f79b7c8a9fd34507c1762b1c98842be5ac4..e6db4347d62421e1260f550ec2c974018b695f58 100644 --- a/internal/tui/components/logo/logo.go +++ b/internal/tui/components/logo/logo.go @@ -85,7 +85,7 @@ func Render(version string, compact bool, o Opts) string { } // Right field. - rightWidth := max(15, o.Width-crushWidth-leftWidth) // 2 for the gap. + rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap. const stepDownAt = 0 rightField := new(strings.Builder) for i := range fieldHeight { diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index cc72dbc520bb8fe3e5aacc0412cf2e09118b8bd7..39fee8e09b731a315338656b30bac692e58c9af9 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -2,21 +2,27 @@ package chat import ( "context" - "strings" "time" "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/spinner" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/chat/editor" "github.com/charmbracelet/crush/internal/tui/components/chat/header" "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar" + "github.com/charmbracelet/crush/internal/tui/components/chat/splash" + "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" "github.com/charmbracelet/crush/internal/tui/page" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" @@ -26,8 +32,6 @@ import ( var ChatPageID page.PageID = "chat" -const CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode - type ( OpenFilePickerMsg struct{} ChatFocusedMsg struct { @@ -36,104 +40,171 @@ type ( CancelTimerExpiredMsg struct{} ) +type ChatState string + +const ( + ChatStateOnboarding ChatState = "onboarding" + ChatStateInitProject ChatState = "init_project" + ChatStateNewMessage ChatState = "new_message" + ChatStateInSession ChatState = "in_session" +) + +type PanelType string + +const ( + PanelTypeChat PanelType = "chat" + PanelTypeEditor PanelType = "editor" + PanelTypeSplash PanelType = "splash" +) + +const ( + CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode + EditorHeight = 5 // Height of the editor input area including padding + SideBarWidth = 31 // Width of the sidebar + SideBarDetailsPadding = 1 // Padding for the sidebar details section + HeaderHeight = 1 // Height of the header +) + type ChatPage interface { util.Model layout.Help } -type chatPage struct { - wWidth, wHeight int // Window dimensions - app *app.App - - layout layout.SplitPaneLayout - - session session.Session +// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds +func cancelTimerCmd() tea.Cmd { + return tea.Tick(2*time.Second, func(time.Time) tea.Msg { + return CancelTimerExpiredMsg{} + }) +} - keyMap KeyMap +type chatPage struct { + width, height int + detailsWidth, detailsHeight int + app *app.App + state ChatState + session session.Session + keyMap KeyMap + focusedPane PanelType + // Compact mode + compact bool + header header.Header + showingDetails bool + + sidebar sidebar.Sidebar + chat chat.MessageListCmp + editor editor.Editor + splash splash.Splash + canceling bool + + // This will force the compact mode even in big screens + // usually triggered by the user command + // this will also be set when the user config is set to compact mode + forceCompact bool +} - chatFocused bool +func New(app *app.App) ChatPage { + return &chatPage{ + app: app, + state: ChatStateOnboarding, - compactMode bool - forceCompactMode bool // Force compact mode regardless of window size - showDetails bool // Show details in the header - header header.Header - compactSidebar layout.Container + keyMap: DefaultKeyMap(), - cancelPending bool // True if ESC was pressed once and waiting for second press + header: header.New(app.LSPClients), + sidebar: sidebar.New(app.History, app.LSPClients, false), + chat: chat.New(app), + editor: editor.New(app), + splash: splash.New(), + } } func (p *chatPage) Init() tea.Cmd { + cfg := config.Get() + if cfg.IsReady() { + if b, _ := config.ProjectNeedsInitialization(); b { + p.state = ChatStateInitProject + } else { + p.state = ChatStateNewMessage + p.focusedPane = PanelTypeEditor + } + } + + compact := cfg.Options.TUI.CompactMode + p.compact = compact + p.forceCompact = compact + p.sidebar.SetCompactMode(p.compact) return tea.Batch( - p.layout.Init(), - p.compactSidebar.Init(), - p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor), + p.header.Init(), + p.sidebar.Init(), + p.chat.Init(), + p.editor.Init(), + p.splash.Init(), ) } -// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds -func (p *chatPage) cancelTimerCmd() tea.Cmd { - return tea.Tick(2*time.Second, func(time.Time) tea.Msg { - return CancelTimerExpiredMsg{} - }) -} - func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case tea.WindowSizeMsg: + return p, p.SetSize(msg.Width, msg.Height) case CancelTimerExpiredMsg: - p.cancelPending = false + p.canceling = false return p, nil - case tea.WindowSizeMsg: - h, cmd := p.header.Update(msg) - cmds = append(cmds, cmd) - p.header = h.(header.Header) - cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0)) - // the mode is only relevant when there is a session - if p.session.ID != "" { - // Only auto-switch to compact mode if not forced - if !p.forceCompactMode { - if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint { - p.wWidth = msg.Width - p.wHeight = msg.Height - cmds = append(cmds, p.setCompactMode(true)) - return p, tea.Batch(cmds...) - } else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint { - p.wWidth = msg.Width - p.wHeight = msg.Height - return p, p.setCompactMode(false) - } - } - } - p.wWidth = msg.Width - p.wHeight = msg.Height - layoutHeight := msg.Height - if p.compactMode { - // make space for the header - layoutHeight -= 1 + case chat.SendMsg: + return p, p.sendMessage(msg.Text, msg.Attachments) + case chat.SessionSelectedMsg: + return p, p.setSession(msg) + case commands.ToggleCompactModeMsg: + p.forceCompact = !p.forceCompact + if p.forceCompact { + p.setCompactMode(true) + } else if p.width >= CompactModeBreakpoint { + p.setCompactMode(false) } - cmd = p.layout.SetSize(msg.Width, layoutHeight) + return p, p.SetSize(p.width, p.height) + case pubsub.Event[session.Session]: + // this needs to go to header/sidebar + u, cmd := p.header.Update(msg) + p.header = u.(header.Header) + cmds = append(cmds, cmd) + u, cmd = p.sidebar.Update(msg) + p.sidebar = u.(sidebar.Sidebar) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + case chat.SessionClearedMsg: + u, cmd := p.header.Update(msg) + p.header = u.(header.Header) + cmds = append(cmds, cmd) + u, cmd = p.sidebar.Update(msg) + p.sidebar = u.(sidebar.Sidebar) + cmds = append(cmds, cmd) + u, cmd = p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + case filepicker.FilePickedMsg, + completions.CompletionsClosedMsg, + completions.SelectCompletionMsg: + u, cmd := p.editor.Update(msg) + p.editor = u.(editor.Editor) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + + case pubsub.Event[message.Message], + anim.StepMsg, + spinner.TickMsg: + // this needs to go to chat + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + + case pubsub.Event[history.File], sidebar.SessionFilesMsg: + // this needs to go to sidebar + u, cmd := p.sidebar.Update(msg) + p.sidebar = u.(sidebar.Sidebar) cmds = append(cmds, cmd) return p, tea.Batch(cmds...) - case chat.SendMsg: - cmd := p.sendMessage(msg.Text, msg.Attachments) - if cmd != nil { - return p, cmd - } - case commands.ToggleCompactModeMsg: - // Only allow toggling if window width is larger than compact breakpoint - if p.wWidth > CompactModeBreakpoint { - p.forceCompactMode = !p.forceCompactMode - // If force compact mode is enabled, switch to compact mode - // If force compact mode is disabled, switch based on window size - if p.forceCompactMode { - return p, p.setCompactMode(true) - } else { - // Return to auto mode based on window size - shouldBeCompact := p.wWidth <= CompactModeBreakpoint - return p, p.setCompactMode(shouldBeCompact) - } - } case commands.CommandRunCustomMsg: // Check if the agent is busy before executing custom commands if p.app.CoderAgent.IsBusy() { @@ -145,30 +216,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return p, cmd } - case chat.SessionSelectedMsg: - if p.session.ID == "" { - cmd := p.setMessages() - if cmd != nil { - cmds = append(cmds, cmd) - } - } - needsModeChange := p.session.ID == "" - p.session = msg - p.header.SetSession(msg) - if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) { - cmds = append(cmds, p.setCompactMode(true)) - } case tea.KeyPressMsg: switch { case key.Matches(msg, p.keyMap.NewSession): - p.session = session.Session{} - return p, tea.Batch( - p.clearMessages(), - util.CmdHandler(chat.SessionClearedMsg{}), - p.setCompactMode(false), - p.layout.FocusPanel(layout.BottomPanel), - util.CmdHandler(ChatFocusedMsg{Focused: false}), - ) + return p, p.newSession() case key.Matches(msg, p.keyMap.AddAttachment): agentCfg := config.Get().Agents["coder"] model := config.Get().GetModelByType(agentCfg.Model) @@ -178,167 +229,264 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name) } case key.Matches(msg, p.keyMap.Tab): - if p.session.ID == "" { - return p, nil - } - p.chatFocused = !p.chatFocused - if p.chatFocused { - cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel)) - cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true})) - } else { - cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel)) - cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false})) - } - return p, tea.Batch(cmds...) + p.changeFocus() + return p, nil case key.Matches(msg, p.keyMap.Cancel): - if p.session.ID != "" { - if p.cancelPending { - // Second ESC press - actually cancel the session - p.cancelPending = false - p.app.CoderAgent.Cancel(p.session.ID) - return p, nil - } else { - // First ESC press - start the timer - p.cancelPending = true - return p, p.cancelTimerCmd() - } - } + return p, p.cancel() case key.Matches(msg, p.keyMap.Details): - if p.session.ID == "" || !p.compactMode { - return p, nil // No session to show details for - } - p.showDetails = !p.showDetails - p.header.SetDetailsOpen(p.showDetails) - if p.showDetails { - return p, tea.Batch() - } - + p.showDetails() return p, nil } + + // Send the key press to the focused pane + switch p.focusedPane { + case PanelTypeChat: + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + cmds = append(cmds, cmd) + case PanelTypeEditor: + u, cmd := p.editor.Update(msg) + p.editor = u.(editor.Editor) + cmds = append(cmds, cmd) + } } - u, cmd := p.layout.Update(msg) - cmds = append(cmds, cmd) - p.layout = u.(layout.SplitPaneLayout) - h, cmd := p.header.Update(msg) - p.header = h.(header.Header) - cmds = append(cmds, cmd) - s, cmd := p.compactSidebar.Update(msg) - p.compactSidebar = s.(layout.Container) - cmds = append(cmds, cmd) return p, tea.Batch(cmds...) } -func (p *chatPage) setMessages() tea.Cmd { - messagesContainer := layout.NewContainer( - chat.NewMessagesListCmp(p.app), - layout.WithPadding(1, 1, 0, 1), - ) - return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init()) -} +func (p *chatPage) View() tea.View { + var chatView tea.View + t := styles.CurrentTheme() + switch p.state { + case ChatStateOnboarding, ChatStateInitProject: + chatView = tea.NewView( + t.S().Base.Render( + p.splash.View().String(), + ), + ) + case ChatStateNewMessage: + editorView := p.editor.View() + chatView = tea.NewView( + lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Render( + p.splash.View().String(), + ), + editorView.String(), + ), + ) + chatView.SetCursor(editorView.Cursor()) + case ChatStateInSession: + messagesView := p.chat.View() + editorView := p.editor.View() + if p.compact { + headerView := p.header.View() + chatView = tea.NewView( + lipgloss.JoinVertical( + lipgloss.Left, + headerView.String(), + messagesView.String(), + editorView.String(), + ), + ) + chatView.SetCursor(editorView.Cursor()) + } else { + sidebarView := p.sidebar.View() + messages := lipgloss.JoinHorizontal( + lipgloss.Left, + messagesView.String(), + sidebarView.String(), + ) + chatView = tea.NewView( + lipgloss.JoinVertical( + lipgloss.Left, + messages, + p.editor.View().String(), + ), + ) + chatView.SetCursor(editorView.Cursor()) + } + default: + chatView = tea.NewView("Unknown chat state") + } -func (p *chatPage) setSidebar() tea.Cmd { - sidebarContainer := sidebarCmp(p.app, false, p.session) - sidebarContainer.Init() - return p.layout.SetRightPanel(sidebarContainer) -} + layers := []*lipgloss.Layer{ + lipgloss.NewLayer(chatView.String()).X(0).Y(0), + } -func (p *chatPage) clearMessages() tea.Cmd { - return p.layout.ClearLeftPanel() + if p.showingDetails { + style := t.S().Base. + Width(p.detailsWidth). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) + version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version) + details := style.Render( + lipgloss.JoinVertical( + lipgloss.Left, + p.sidebar.View().String(), + version, + ), + ) + layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1)) + } + canvas := lipgloss.NewCanvas( + layers..., + ) + view := tea.NewView(canvas.Render()) + view.SetCursor(chatView.Cursor()) + return view } -func (p *chatPage) setCompactMode(compact bool) tea.Cmd { - p.compactMode = compact - var cmds []tea.Cmd +func (p *chatPage) setCompactMode(compact bool) { + if p.compact == compact { + return + } + p.compact = compact if compact { - // add offset for the header - p.layout.SetOffset(0, 1) - // make space for the header - cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1)) - // remove the sidebar - cmds = append(cmds, p.layout.ClearRightPanel()) - return tea.Batch(cmds...) + p.compact = true + p.sidebar.SetCompactMode(true) } else { - // remove the offset for the header - p.layout.SetOffset(0, 0) - // restore the original size - cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight)) - // set the sidebar - cmds = append(cmds, p.setSidebar()) - l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session)) - p.layout = l.(layout.SplitPaneLayout) - cmds = append(cmds, cmd) + p.compact = false + p.showingDetails = false + p.sidebar.SetCompactMode(false) + } +} - return tea.Batch(cmds...) +func (p *chatPage) handleCompactMode(newWidth int) { + if p.forceCompact { + return + } + if newWidth < CompactModeBreakpoint && !p.compact { + p.setCompactMode(true) + } + if newWidth >= CompactModeBreakpoint && p.compact { + p.setCompactMode(false) } } -func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd { +func (p *chatPage) SetSize(width, height int) tea.Cmd { + p.handleCompactMode(width) + p.width = width + p.height = height var cmds []tea.Cmd - if p.session.ID == "" { - session, err := p.app.Sessions.Create(context.Background(), "New Session") - if err != nil { - return util.ReportError(err) + switch p.state { + case ChatStateOnboarding, ChatStateInitProject: + // here we should just have the splash screen + cmds = append(cmds, p.splash.SetSize(width, height)) + case ChatStateNewMessage: + cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight)) + cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) + cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight)) + case ChatStateInSession: + if p.compact { + cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight)) + // In compact mode, the sidebar is shown in the details section, the width needs to be adjusted for the padding and border + p.detailsWidth = width - 2 // because of position + cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-2, p.detailsHeight-2)) // adjust for border + cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) + cmds = append(cmds, p.header.SetWidth(width-1)) + } else { + cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight)) + cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) + cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight)) } + cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight)) + } + return tea.Batch(cmds...) +} - p.session = session - cmd := p.setMessages() - if cmd != nil { - cmds = append(cmds, cmd) - } - cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) +func (p *chatPage) newSession() tea.Cmd { + if p.state != ChatStateInSession { + // Cannot start a new session if we are not in the session state + return nil } + // blank session + p.session = session.Session{} + p.state = ChatStateNewMessage + p.focusedPane = PanelTypeEditor + p.canceling = false + // Reset the chat and editor components + return tea.Batch( + util.CmdHandler(chat.SessionClearedMsg{}), + p.SetSize(p.width, p.height), + ) +} - _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...) - if err != nil { - return util.ReportError(err) +func (p *chatPage) setSession(session session.Session) tea.Cmd { + if p.session.ID == session.ID { + return nil } - return tea.Batch(cmds...) + + var cmds []tea.Cmd + p.session = session + // We want to first resize the components + if p.state != ChatStateInSession { + p.state = ChatStateInSession + cmds = append(cmds, p.SetSize(p.width, p.height)) + } + cmds = append(cmds, p.chat.SetSession(session)) + cmds = append(cmds, p.sidebar.SetSession(session)) + cmds = append(cmds, p.header.SetSession(session)) + cmds = append(cmds, p.editor.SetSession(session)) + + return tea.Sequence(cmds...) } -func (p *chatPage) SetSize(width, height int) tea.Cmd { - return p.layout.SetSize(width, height) +func (p *chatPage) changeFocus() { + if p.state != ChatStateInSession { + // Cannot change focus if we are not in the session state + return + } + switch p.focusedPane { + case PanelTypeChat: + p.focusedPane = PanelTypeEditor + case PanelTypeEditor: + p.focusedPane = PanelTypeChat + } } -func (p *chatPage) GetSize() (int, int) { - return p.layout.GetSize() +func (p *chatPage) cancel() tea.Cmd { + if p.state != ChatStateInSession || !p.app.CoderAgent.IsBusy() { + // Cannot cancel if we are not in the session state + return nil + } + + // second press of cancel key will actually cancel the session + if p.canceling { + p.canceling = false + p.app.CoderAgent.Cancel(p.session.ID) + return nil + } + + p.canceling = true + return cancelTimerCmd() } -func (p *chatPage) View() tea.View { - if !p.compactMode || p.session.ID == "" { - // If not in compact mode or there is no session, we don't show the header - return p.layout.View() +func (p *chatPage) showDetails() { + if p.state != ChatStateInSession || !p.compact { + // Cannot show details if we are not in the session state or if we are not in compact mode + return } - layoutView := p.layout.View() - chatView := strings.Join( - []string{ - p.header.View().String(), - layoutView.String(), - }, "\n", - ) - layers := []*lipgloss.Layer{ - lipgloss.NewLayer(chatView).X(0).Y(0), + p.showingDetails = !p.showingDetails + p.header.SetDetailsOpen(p.showingDetails) +} + +func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd { + session := p.session + var cmds []tea.Cmd + if p.state != ChatStateInSession { + // branch new session + newSession, err := p.app.Sessions.Create(context.Background(), "New Session") + if err != nil { + return util.ReportError(err) + } + session = newSession + cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) } - if p.showDetails { - t := styles.CurrentTheme() - style := t.S().Base. - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus) - version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version) - details := style.Render( - lipgloss.JoinVertical( - lipgloss.Left, - p.compactSidebar.View().String(), - version, - ), - ) - layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1)) + _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...) + if err != nil { + return util.ReportError(err) } - canvas := lipgloss.NewCanvas( - layers..., - ) - view := tea.NewView(canvas.Render()) - view.SetCursor(layoutView.Cursor()) - return view + return tea.Batch(cmds...) } func (p *chatPage) Bindings() []key.Binding { @@ -348,7 +496,7 @@ func (p *chatPage) Bindings() []key.Binding { } if p.app.CoderAgent.IsBusy() { cancelBinding := p.keyMap.Cancel - if p.cancelPending { + if p.canceling { cancelBinding = key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "press again to cancel"), @@ -357,56 +505,26 @@ func (p *chatPage) Bindings() []key.Binding { bindings = append([]key.Binding{cancelBinding}, bindings...) } - if p.chatFocused { + switch p.focusedPane { + case PanelTypeChat: bindings = append([]key.Binding{ key.NewBinding( key.WithKeys("tab"), key.WithHelp("tab", "focus editor"), ), }, bindings...) - } else { + bindings = append(bindings, p.chat.Bindings()...) + case PanelTypeEditor: bindings = append([]key.Binding{ key.NewBinding( key.WithKeys("tab"), key.WithHelp("tab", "focus chat"), ), }, bindings...) + bindings = append(bindings, p.editor.Bindings()...) + case PanelTypeSplash: + bindings = append(bindings, p.splash.Bindings()...) } - bindings = append(bindings, p.layout.Bindings()...) return bindings } - -func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container { - padding := layout.WithPadding(1, 1, 1, 1) - if compact { - padding = layout.WithPadding(0, 1, 1, 1) - } - sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact) - if session.ID != "" { - sidebar.SetSession(session) - } - - return layout.NewContainer( - sidebar, - padding, - ) -} - -func NewChatPage(app *app.App) ChatPage { - editorContainer := layout.NewContainer( - editor.NewEditorCmp(app), - ) - return &chatPage{ - app: app, - layout: layout.NewSplitPane( - layout.WithRightPanel(sidebarCmp(app, false, session.Session{})), - layout.WithBottomPanel(editorContainer), - layout.WithFixedBottomHeight(5), - layout.WithFixedRightWidth(31), - ), - compactSidebar: sidebarCmp(app, true, session.Session{}), - keyMap: DefaultKeyMap(), - header: header.New(app.LSPClients), - } -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 3f46fdaad7a14fc0f481a71030a5173307477375..4ebf9f7edcf0d35cda2b7c2c6a3fa6d20cdd97b3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -419,7 +419,7 @@ func (a *appModel) View() tea.View { // New creates and initializes a new TUI application model. func New(app *app.App) tea.Model { - chatPage := chat.NewChatPage(app) + chatPage := chat.New(app) keyMap := DefaultKeyMap() keyMap.pageBindings = chatPage.Bindings() From 4941a7e490d4ffba405dc6891483337405158549 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 4 Jul 2025 13:26:45 +0200 Subject: [PATCH 02/38] wip: oboarding splashscreen --- internal/tui/components/chat/splash/keys.go | 18 +- internal/tui/components/chat/splash/splash.go | 120 +++++++++++-- .../tui/components/dialogs/models/list.go | 159 ++++++++++++++++++ .../tui/components/dialogs/models/models.go | 140 ++------------- internal/tui/page/chat/chat.go | 23 +-- 5 files changed, 303 insertions(+), 157 deletions(-) create mode 100644 internal/tui/components/dialogs/models/list.go diff --git a/internal/tui/components/chat/splash/keys.go b/internal/tui/components/chat/splash/keys.go index df715c89e86971a0f788915737bf41a212c65b5a..2a90441da52924f49f187c88744f9ea01c80e745 100644 --- a/internal/tui/components/chat/splash/keys.go +++ b/internal/tui/components/chat/splash/keys.go @@ -5,14 +5,24 @@ import ( ) type KeyMap struct { - Cancel key.Binding + Select, + Next, + Previous key.Binding } func DefaultKeyMap() KeyMap { return KeyMap{ - Cancel: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), + Select: key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + Next: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ), + Previous: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), ), } } diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 34828baf4c0b91cb71f4495f9e436a13d75ffe46..7cbc7b5fd371aa9db9cec3613ca40377a70c5d77 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -3,7 +3,10 @@ package splash import ( "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/logo" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" @@ -22,18 +25,48 @@ const ( SplashScreenPaddingY = 1 // Padding Y for the splash screen ) +type SplashScreenState string + +const ( + SplashScreenStateOnboarding SplashScreenState = "onboarding" + SplashScreenStateInitialize SplashScreenState = "initialize" + SplashScreenStateReady SplashScreenState = "ready" +) + +// OnboardingCompleteMsg is sent when onboarding is complete +type OnboardingCompleteMsg struct{} + type splashCmp struct { - width, height int - keyMap KeyMap - logoRendered string + width, height int + keyMap KeyMap + logoRendered string + state SplashScreenState + modelList *models.ModelListComponent + cursorRow, cursorCol int } func New() Splash { + keyMap := DefaultKeyMap() + listKeyMap := list.DefaultKeyMap() + listKeyMap.Down.SetEnabled(false) + listKeyMap.Up.SetEnabled(false) + listKeyMap.HalfPageDown.SetEnabled(false) + listKeyMap.HalfPageUp.SetEnabled(false) + listKeyMap.Home.SetEnabled(false) + listKeyMap.End.SetEnabled(false) + listKeyMap.DownOneItem = keyMap.Next + listKeyMap.UpOneItem = keyMap.Previous + + t := styles.CurrentTheme() + inputStyle := t.S().Base.Padding(0, 1, 0, 1) + modelList := models.NewModelListComponent(listKeyMap, inputStyle) return &splashCmp{ width: 0, height: 0, - keyMap: DefaultKeyMap(), + keyMap: keyMap, + state: SplashScreenStateOnboarding, logoRendered: "", + modelList: modelList, } } @@ -44,7 +77,14 @@ func (s *splashCmp) GetSize() (int, int) { // Init implements SplashPage. func (s *splashCmp) Init() tea.Cmd { - return nil + if config.HasInitialDataConfig() { + if b, _ := config.ProjectNeedsInitialization(); b { + s.state = SplashScreenStateInitialize + } else { + s.state = SplashScreenStateReady + } + } + return s.modelList.Init() } // SetSize implements SplashPage. @@ -52,7 +92,12 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd { s.width = width s.height = height s.logoRendered = s.logoBlock() - return nil + listHeigh := min(40, height-(SplashScreenPaddingY*2)-lipgloss.Height(s.logoRendered)-2) // -1 for the title + listWidth := min(60, width-(SplashScreenPaddingX*2)) + + // Calculate the cursor position based on the height and logo size + s.cursorRow = height - listHeigh + return s.modelList.SetSize(listWidth, listHeigh) } // Update implements SplashPage. @@ -60,6 +105,13 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: return s, s.SetSize(msg.Width, msg.Height) + case tea.KeyPressMsg: + switch { + default: + u, cmd := s.modelList.Update(msg) + s.modelList = u + return s, cmd + } } return s, nil } @@ -67,8 +119,34 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View implements SplashPage. func (s *splashCmp) View() tea.View { t := styles.CurrentTheme() - content := lipgloss.JoinVertical(lipgloss.Left, s.logoRendered) - return tea.NewView( + var cursor *tea.Cursor + + var content string + switch s.state { + case SplashScreenStateOnboarding: + // Show logo and model selector + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + modelListView := s.modelList.View() + cursor = s.moveCursor(modelListView.Cursor()) + modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( + lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Choose a Model"), + "", + modelListView.String(), + ), + ) + content = lipgloss.JoinVertical( + lipgloss.Left, + s.logoRendered, + modelSelector, + ) + default: + // Show just the logo for other states + content = s.logoRendered + } + + view := tea.NewView( t.S().Base. Width(s.width). Height(s.height). @@ -76,10 +154,11 @@ func (s *splashCmp) View() tea.View { PaddingLeft(SplashScreenPaddingX). PaddingRight(SplashScreenPaddingX). PaddingBottom(SplashScreenPaddingY). - Render( - content, - ), + Render(content), ) + + view.SetCursor(cursor) + return view } func (s *splashCmp) logoBlock() string { @@ -95,9 +174,24 @@ func (s *splashCmp) logoBlock() string { }) } +func (m *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { + if cursor == nil { + return nil + } + offset := m.cursorRow + cursor.Y += offset + cursor.X = cursor.X + 3 // 3 for padding + return cursor +} + // Bindings implements SplashPage. func (s *splashCmp) Bindings() []key.Binding { - return []key.Binding{ - s.keyMap.Cancel, + if s.state == SplashScreenStateOnboarding { + return []key.Binding{ + s.keyMap.Select, + s.keyMap.Next, + s.keyMap.Previous, + } } + return []key.Binding{} } diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go new file mode 100644 index 0000000000000000000000000000000000000000..e9da21de7725b6cb903f6d3ff7777e1b90e01a69 --- /dev/null +++ b/internal/tui/components/dialogs/models/list.go @@ -0,0 +1,159 @@ +package models + +import ( + "slices" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fur/provider" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +type ModelListComponent struct { + list list.ListModel + modelType int +} + +func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style) *ModelListComponent { + modelList := list.New( + list.WithFilterable(true), + list.WithKeyMap(keyMap), + list.WithInputStyle(inputStyle), + list.WithWrapNavigation(true), + ) + + return &ModelListComponent{ + list: modelList, + modelType: LargeModelType, + } +} + +func (m *ModelListComponent) Init() tea.Cmd { + return tea.Batch(m.list.Init(), m.SetModelType(m.modelType)) +} + +func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) { + u, cmd := m.list.Update(msg) + m.list = u.(list.ListModel) + return m, cmd +} + +func (m *ModelListComponent) View() tea.View { + return m.list.View() +} + +func (m *ModelListComponent) SetSize(width, height int) tea.Cmd { + return m.list.SetSize(width, height) +} + +func (m *ModelListComponent) Items() []util.Model { + return m.list.Items() +} + +func (m *ModelListComponent) SelectedIndex() int { + return m.list.SelectedIndex() +} + +func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { + m.modelType = modelType + + providers := config.Providers() + modelItems := []util.Model{} + selectIndex := 0 + + cfg := config.Get() + var currentModel config.PreferredModel + if m.modelType == LargeModelType { + currentModel = cfg.Models.Large + } else { + currentModel = cfg.Models.Small + } + + addedProviders := make(map[provider.InferenceProvider]bool) + + knownProviders := provider.KnownProviders() + for providerID, providerConfig := range cfg.Providers { + if providerConfig.Disabled { + continue + } + + // Check if this provider is not in the known providers list + if !slices.Contains(knownProviders, providerID) { + configProvider := provider.Provider{ + Name: string(providerID), + ID: providerID, + Models: make([]provider.Model, len(providerConfig.Models)), + } + + for i, model := range providerConfig.Models { + configProvider.Models[i] = provider.Model{ + ID: model.ID, + Name: model.Name, + CostPer1MIn: model.CostPer1MIn, + CostPer1MOut: model.CostPer1MOut, + CostPer1MInCached: model.CostPer1MInCached, + CostPer1MOutCached: model.CostPer1MOutCached, + ContextWindow: model.ContextWindow, + DefaultMaxTokens: model.DefaultMaxTokens, + CanReason: model.CanReason, + HasReasoningEffort: model.HasReasoningEffort, + DefaultReasoningEffort: model.ReasoningEffort, + SupportsImages: model.SupportsImages, + } + } + + name := configProvider.Name + if name == "" { + name = string(configProvider.ID) + } + modelItems = append(modelItems, commands.NewItemSection(name)) + for _, model := range configProvider.Models { + modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{ + Provider: configProvider, + Model: model, + })) + if model.ID == currentModel.ModelID && configProvider.ID == currentModel.Provider { + selectIndex = len(modelItems) - 1 + } + } + addedProviders[providerID] = true + } + } + + for _, provider := range providers { + if addedProviders[provider.ID] { + continue + } + + if providerConfig, exists := cfg.Providers[provider.ID]; exists && providerConfig.Disabled { + continue + } + + name := provider.Name + if name == "" { + name = string(provider.ID) + } + modelItems = append(modelItems, commands.NewItemSection(name)) + for _, model := range provider.Models { + modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{ + Provider: provider, + Model: model, + })) + if model.ID == currentModel.ModelID && provider.ID == currentModel.Provider { + selectIndex = len(modelItems) - 1 + } + } + } + + return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex)) +} + +// GetModelType returns the current model type +func (m *ModelListComponent) GetModelType() int { + return m.modelType +} + diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index dc82e2fa1c745fc46f14895680c93d30864f317a..cab864ef1dafd67fe2b5f6933e480f37fdb19498 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -1,8 +1,6 @@ package models import ( - "slices" - "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" @@ -12,7 +10,6 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -53,10 +50,9 @@ type modelDialogCmp struct { wWidth int wHeight int - modelList list.ListModel + modelList *ModelListComponent keyMap KeyMap help help.Model - modelType int } func NewModelDialogCmp() ModelDialog { @@ -75,12 +71,7 @@ func NewModelDialogCmp() ModelDialog { t := styles.CurrentTheme() inputStyle := t.S().Base.Padding(0, 1, 0, 1) - modelList := list.New( - list.WithFilterable(true), - list.WithKeyMap(listKeyMap), - list.WithInputStyle(inputStyle), - list.WithWrapNavigation(true), - ) + modelList := NewModelListComponent(listKeyMap, inputStyle) help := help.New() help.Styles = t.S().Help @@ -89,12 +80,10 @@ func NewModelDialogCmp() ModelDialog { width: defaultWidth, keyMap: DefaultKeyMap(), help: help, - modelType: LargeModelType, } } func (m *modelDialogCmp) Init() tea.Cmd { - m.SetModelType(m.modelType) return m.modelList.Init() } @@ -103,7 +92,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.wWidth = msg.Width m.wHeight = msg.Height - m.SetModelType(m.modelType) return m, m.modelList.SetSize(m.listWidth(), m.listHeight()) case tea.KeyPressMsg: switch { @@ -116,8 +104,8 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption) var modelType config.SelectedModelType - if m.modelType == LargeModelType { - modelType = config.SelectedModelTypeLarge + if m.modelList.GetModelType() == LargeModelType { + modelType = config.LargeModel } else { modelType = config.SelectedModelTypeSmall } @@ -133,16 +121,16 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }), ) case key.Matches(msg, m.keyMap.Tab): - if m.modelType == LargeModelType { - return m, m.SetModelType(SmallModelType) + if m.modelList.GetModelType() == LargeModelType { + return m, m.modelList.SetModelType(SmallModelType) } else { - return m, m.SetModelType(LargeModelType) + return m, m.modelList.SetModelType(LargeModelType) } case key.Matches(msg, m.keyMap.Close): return m, util.CmdHandler(dialogs.CloseDialogMsg{}) default: u, cmd := m.modelList.Update(msg) - m.modelList = u.(list.ListModel) + m.modelList = u return m, cmd } } @@ -181,7 +169,8 @@ func (m *modelDialogCmp) listWidth() int { } func (m *modelDialogCmp) listHeight() int { - listHeigh := len(m.modelList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections + items := m.modelList.Items() + listHeigh := len(items) + 2 + 4 return min(listHeigh, m.wHeight/2) } @@ -209,115 +198,8 @@ func (m *modelDialogCmp) modelTypeRadio() string { choices := []string{"Large Task", "Small Task"} iconSelected := "◉" iconUnselected := "○" - if m.modelType == LargeModelType { + if m.modelList.GetModelType() == LargeModelType { return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1]) } return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1]) } - -func (m *modelDialogCmp) SetModelType(modelType int) tea.Cmd { - m.modelType = modelType - - providers, err := config.Providers() - if err != nil { - return util.ReportError(err) - } - - modelItems := []util.Model{} - selectIndex := 0 - - cfg := config.Get() - var currentModel config.SelectedModel - if m.modelType == LargeModelType { - currentModel = cfg.Models[config.SelectedModelTypeLarge] - } else { - currentModel = cfg.Models[config.SelectedModelTypeSmall] - } - - // Create a map to track which providers we've already added - addedProviders := make(map[string]bool) - - // First, add any configured providers that are not in the known providers list - // These should appear at the top of the list - knownProviders := provider.KnownProviders() - for providerID, providerConfig := range cfg.Providers { - if providerConfig.Disable { - continue - } - - // Check if this provider is not in the known providers list - if !slices.Contains(knownProviders, provider.InferenceProvider(providerID)) { - // Convert config provider to provider.Provider format - configProvider := provider.Provider{ - Name: string(providerID), // Use provider ID as name for unknown providers - ID: provider.InferenceProvider(providerID), - Models: make([]provider.Model, len(providerConfig.Models)), - } - - // Convert models - for i, model := range providerConfig.Models { - configProvider.Models[i] = provider.Model{ - ID: model.ID, - Name: model.Name, - CostPer1MIn: model.CostPer1MIn, - CostPer1MOut: model.CostPer1MOut, - CostPer1MInCached: model.CostPer1MInCached, - CostPer1MOutCached: model.CostPer1MOutCached, - ContextWindow: model.ContextWindow, - DefaultMaxTokens: model.DefaultMaxTokens, - CanReason: model.CanReason, - HasReasoningEffort: model.HasReasoningEffort, - DefaultReasoningEffort: model.DefaultReasoningEffort, - SupportsImages: model.SupportsImages, - } - } - - // Add this unknown provider to the list - name := configProvider.Name - if name == "" { - name = string(configProvider.ID) - } - modelItems = append(modelItems, commands.NewItemSection(name)) - for _, model := range configProvider.Models { - modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{ - Provider: configProvider, - Model: model, - })) - if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider { - selectIndex = len(modelItems) - 1 // Set the selected index to the current model - } - } - addedProviders[providerID] = true - } - } - - // Then add the known providers from the predefined list - for _, provider := range providers { - // Skip if we already added this provider as an unknown provider - if addedProviders[string(provider.ID)] { - continue - } - - // Check if this provider is configured and not disabled - if providerConfig, exists := cfg.Providers[string(provider.ID)]; exists && providerConfig.Disable { - continue - } - - name := provider.Name - if name == "" { - name = string(provider.ID) - } - modelItems = append(modelItems, commands.NewItemSection(name)) - for _, model := range provider.Models { - modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{ - Provider: provider, - Model: model, - })) - if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { - selectIndex = len(modelItems) - 1 // Set the selected index to the current model - } - } - } - - return tea.Sequence(m.modelList.SetItems(modelItems), m.modelList.SetSelected(selectIndex)) -} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 39fee8e09b731a315338656b30bac692e58c9af9..daf6881426a6d1b59020ee98e06fb44d6c28f17e 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -109,17 +109,18 @@ func New(app *app.App) ChatPage { keyMap: DefaultKeyMap(), - header: header.New(app.LSPClients), - sidebar: sidebar.New(app.History, app.LSPClients, false), - chat: chat.New(app), - editor: editor.New(app), - splash: splash.New(), + header: header.New(app.LSPClients), + sidebar: sidebar.New(app.History, app.LSPClients, false), + chat: chat.New(app), + editor: editor.New(app), + splash: splash.New(), + focusedPane: PanelTypeSplash, } } func (p *chatPage) Init() tea.Cmd { cfg := config.Get() - if cfg.IsReady() { + if config.HasInitialDataConfig() { if b, _ := config.ProjectNeedsInitialization(); b { p.state = ChatStateInitProject } else { @@ -248,6 +249,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { u, cmd := p.editor.Update(msg) p.editor = u.(editor.Editor) cmds = append(cmds, cmd) + case PanelTypeSplash: + u, cmd := p.splash.Update(msg) + p.splash = u.(splash.Splash) + cmds = append(cmds, cmd) } } return p, tea.Batch(cmds...) @@ -258,11 +263,7 @@ func (p *chatPage) View() tea.View { t := styles.CurrentTheme() switch p.state { case ChatStateOnboarding, ChatStateInitProject: - chatView = tea.NewView( - t.S().Base.Render( - p.splash.View().String(), - ), - ) + chatView = p.splash.View() case ChatStateNewMessage: editorView := p.editor.View() chatView = tea.NewView( From fca9fbbf2619a7acc0822d6f6822382618a62337 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 7 Jul 2025 11:49:33 +0200 Subject: [PATCH 03/38] chore: changes after rebase --- internal/config/init.go | 8 ++++ internal/tui/components/chat/chat.go | 3 -- .../tui/components/dialogs/models/list.go | 46 ++++++++++++------- .../tui/components/dialogs/models/models.go | 2 +- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/internal/config/init.go b/internal/config/init.go index 200359bff2bea913940cb78588c97733efb7a142..4c8176e40e8a012824f49e3558d7e47e52d1d3a2 100644 --- a/internal/config/init.go +++ b/internal/config/init.go @@ -103,3 +103,11 @@ func MarkProjectInitialized() error { return nil } + +func HasInitialDataConfig() bool { + cfgPath := globalConfigData() + if _, err := os.Stat(cfgPath); err != nil { + return false + } + return true +} diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index f10362f30bdfad64bb8d73d16819c1a9f1129f34..22b7ef812e1cc710a9f902487dc9c304c824ff2e 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -8,7 +8,6 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/llm/agent" - "github.com/charmbracelet/crush/internal/logging" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" @@ -81,7 +80,6 @@ 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) { - logging.Info("messageListCmp.Update", "msg", msg) switch msg := msg.(type) { case SessionSelectedMsg: if msg.ID != m.session.ID { @@ -380,7 +378,6 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd // SetSession loads and displays messages for a new session. func (m *messageListCmp) SetSession(session session.Session) tea.Cmd { - logging.Info("messageListCmp.SetSession", "sessionID", session.ID) if m.session.ID == session.ID { return nil } diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index e9da21de7725b6cb903f6d3ff7777e1b90e01a69..bb489c587e2e2d77256a4737083c0cbb58300618 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -61,34 +61,43 @@ func (m *ModelListComponent) SelectedIndex() int { func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { m.modelType = modelType - providers := config.Providers() + providers, err := config.Providers() + if err != nil { + return util.ReportError(err) + } + modelItems := []util.Model{} selectIndex := 0 cfg := config.Get() - var currentModel config.PreferredModel + var currentModel config.SelectedModel if m.modelType == LargeModelType { - currentModel = cfg.Models.Large + currentModel = cfg.Models[config.SelectedModelTypeLarge] } else { - currentModel = cfg.Models.Small + currentModel = cfg.Models[config.SelectedModelTypeSmall] } - addedProviders := make(map[provider.InferenceProvider]bool) + // Create a map to track which providers we've already added + addedProviders := make(map[string]bool) + // First, add any configured providers that are not in the known providers list + // These should appear at the top of the list knownProviders := provider.KnownProviders() for providerID, providerConfig := range cfg.Providers { - if providerConfig.Disabled { + if providerConfig.Disable { continue } // Check if this provider is not in the known providers list - if !slices.Contains(knownProviders, providerID) { + if !slices.Contains(knownProviders, provider.InferenceProvider(providerID)) { + // Convert config provider to provider.Provider format configProvider := provider.Provider{ - Name: string(providerID), - ID: providerID, + Name: string(providerID), // Use provider ID as name for unknown providers + ID: provider.InferenceProvider(providerID), Models: make([]provider.Model, len(providerConfig.Models)), } + // Convert models for i, model := range providerConfig.Models { configProvider.Models[i] = provider.Model{ ID: model.ID, @@ -101,11 +110,12 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { DefaultMaxTokens: model.DefaultMaxTokens, CanReason: model.CanReason, HasReasoningEffort: model.HasReasoningEffort, - DefaultReasoningEffort: model.ReasoningEffort, + DefaultReasoningEffort: model.DefaultReasoningEffort, SupportsImages: model.SupportsImages, } } + // Add this unknown provider to the list name := configProvider.Name if name == "" { name = string(configProvider.ID) @@ -116,20 +126,23 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { Provider: configProvider, Model: model, })) - if model.ID == currentModel.ModelID && configProvider.ID == currentModel.Provider { - selectIndex = len(modelItems) - 1 + if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider { + selectIndex = len(modelItems) - 1 // Set the selected index to the current model } } addedProviders[providerID] = true } } + // Then add the known providers from the predefined list for _, provider := range providers { - if addedProviders[provider.ID] { + // Skip if we already added this provider as an unknown provider + if addedProviders[string(provider.ID)] { continue } - if providerConfig, exists := cfg.Providers[provider.ID]; exists && providerConfig.Disabled { + // Check if this provider is configured and not disabled + if providerConfig, exists := cfg.Providers[string(provider.ID)]; exists && providerConfig.Disable { continue } @@ -143,8 +156,8 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { Provider: provider, Model: model, })) - if model.ID == currentModel.ModelID && provider.ID == currentModel.Provider { - selectIndex = len(modelItems) - 1 + if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { + selectIndex = len(modelItems) - 1 // Set the selected index to the current model } } } @@ -156,4 +169,3 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { func (m *ModelListComponent) GetModelType() int { return m.modelType } - diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index cab864ef1dafd67fe2b5f6933e480f37fdb19498..a88086131476f8843a607b726675b86c9242ed03 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -105,7 +105,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var modelType config.SelectedModelType if m.modelList.GetModelType() == LargeModelType { - modelType = config.LargeModel + modelType = config.SelectedModelTypeLarge } else { modelType = config.SelectedModelTypeSmall } From 99d4b010978f42801e3858d86740d468f5dea8ad Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 7 Jul 2025 12:49:58 +0200 Subject: [PATCH 04/38] chore: show configured indicator --- internal/tui/components/chat/splash/splash.go | 2 +- internal/tui/components/core/helpers.go | 16 +++++++++++++ internal/tui/components/core/list/list.go | 5 ++++ .../tui/components/dialogs/commands/item.go | 20 ++++++++++++---- .../tui/components/dialogs/models/list.go | 24 ++++++++++++++++--- .../tui/components/dialogs/models/models.go | 7 +++++- 6 files changed, 65 insertions(+), 9 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 7cbc7b5fd371aa9db9cec3613ca40377a70c5d77..50912b283b05b6e2479fa82793e16e726a1cac8b 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -59,7 +59,7 @@ func New() Splash { t := styles.CurrentTheme() inputStyle := t.S().Base.Padding(0, 1, 0, 1) - modelList := models.NewModelListComponent(listKeyMap, inputStyle) + modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave") return &splashCmp{ width: 0, height: 0, diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go index 1c2c05a6229b98222d870694e726069bfc9c6e92..659ffd88c6b72b60933f9a19e3712093376a29bf 100644 --- a/internal/tui/components/core/helpers.go +++ b/internal/tui/components/core/helpers.go @@ -23,6 +23,22 @@ func Section(text string, width int) string { return text } +func SectionWithInfo(text string, width int, info string) string { + t := styles.CurrentTheme() + char := "─" + length := lipgloss.Width(text) + 1 + remainingWidth := width - length + + if info != "" { + remainingWidth -= lipgloss.Width(info) + 1 // 1 for the space before info + } + lineStyle := t.S().Base.Foreground(t.Border) + if remainingWidth > 0 { + text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) + " " + info + } + return text +} + func Title(title string, width int) string { t := styles.CurrentTheme() char := "╱" diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index f0887ee8aed5df7d3fcb34fe282cf916fad5920a..96127193ca62e3c4d19943dfd5e58d99f87e0d47 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -41,6 +41,7 @@ type ListModel interface { SelectedIndex() int // Get the index of the currently selected item SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it Filter(string) tea.Cmd // Filter items based on a search term + SetFilterPlaceholder(string) // Set the placeholder text for the filter input } // HasAnim interface identifies items that support animation. @@ -1355,3 +1356,7 @@ func (m *model) Focus() tea.Cmd { func (m *model) IsFocused() bool { return m.isFocused } + +func (m *model) SetFilterPlaceholder(placeholder string) { + m.input.Placeholder = placeholder +} diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go index a89a884472cff75a0051d89b637ae4f55feba527..fa385f67f9e8fb76804df147153b068434ac58fd 100644 --- a/internal/tui/components/dialogs/commands/item.go +++ b/internal/tui/components/dialogs/commands/item.go @@ -14,11 +14,12 @@ type ItemSection interface { util.Model layout.Sizeable list.SectionHeader + SetInfo(info string) } type itemSectionModel struct { - width int - title string - noPadding bool // No padding for the section header + width int + title string + info string } func NewItemSection(title string) ItemSection { @@ -40,7 +41,14 @@ func (m *itemSectionModel) View() tea.View { title := ansi.Truncate(m.title, m.width-2, "…") style := t.S().Base.Padding(1, 1, 0, 1) title = t.S().Muted.Render(title) - return tea.NewView(style.Render(core.Section(title, m.width-2))) + section := "" + if m.info != "" { + section = core.SectionWithInfo(title, m.width-2, m.info) + } else { + section = core.Section(title, m.width-2) + } + + return tea.NewView(style.Render(section)) } func (m *itemSectionModel) GetSize() (int, int) { @@ -55,3 +63,7 @@ func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd { func (m *itemSectionModel) IsSectionHeader() bool { return true } + +func (m *itemSectionModel) SetInfo(info string) { + m.info = info +} diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index bb489c587e2e2d77256a4737083c0cbb58300618..bbb23300ae218830cff76daaa8418ab1a75ff15e 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -1,6 +1,7 @@ package models import ( + "fmt" "slices" tea "github.com/charmbracelet/bubbletea/v2" @@ -9,6 +10,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" ) @@ -18,11 +20,12 @@ type ModelListComponent struct { modelType int } -func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style) *ModelListComponent { +func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent { modelList := list.New( list.WithFilterable(true), list.WithKeyMap(keyMap), list.WithInputStyle(inputStyle), + list.WithFilterPlaceholder(inputPlaceholder), list.WithWrapNavigation(true), ) @@ -59,6 +62,7 @@ func (m *ModelListComponent) SelectedIndex() int { } func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { + t := styles.CurrentTheme() m.modelType = modelType providers, err := config.Providers() @@ -77,6 +81,9 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { currentModel = cfg.Models[config.SelectedModelTypeSmall] } + configuredIcon := t.S().Base.Foreground(t.Success).Render(styles.CheckIcon) + configured := fmt.Sprintf("%s %s", configuredIcon, t.S().Subtle.Render("Configured")) + // Create a map to track which providers we've already added addedProviders := make(map[string]bool) @@ -120,7 +127,9 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { if name == "" { name = string(configProvider.ID) } - modelItems = append(modelItems, commands.NewItemSection(name)) + section := commands.NewItemSection(name) + section.SetInfo(configured) + modelItems = append(modelItems, section) for _, model := range configProvider.Models { modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{ Provider: configProvider, @@ -150,7 +159,12 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { if name == "" { name = string(provider.ID) } - modelItems = append(modelItems, commands.NewItemSection(name)) + + section := commands.NewItemSection(name) + if _, ok := cfg.Providers[string(provider.ID)]; ok { + section.SetInfo(configured) + } + modelItems = append(modelItems, section) for _, model := range provider.Models { modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{ Provider: provider, @@ -169,3 +183,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { func (m *ModelListComponent) GetModelType() int { return m.modelType } + +func (m *ModelListComponent) SetInputPlaceholder(placeholder string) { + m.list.SetFilterPlaceholder(placeholder) +} diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index a88086131476f8843a607b726675b86c9242ed03..6b23746251f01645c32da28972d060c64bf1f2a0 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -24,6 +24,9 @@ const ( const ( LargeModelType int = iota SmallModelType + + largeModelInputPlaceholder = "Choose a model for large, complex tasks" + smallModelInputPlaceholder = "Choose a model for small, simple tasks" ) // ModelSelectedMsg is sent when a model is selected @@ -71,7 +74,7 @@ func NewModelDialogCmp() ModelDialog { t := styles.CurrentTheme() inputStyle := t.S().Base.Padding(0, 1, 0, 1) - modelList := NewModelListComponent(listKeyMap, inputStyle) + modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks") help := help.New() help.Styles = t.S().Help @@ -122,8 +125,10 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ) case key.Matches(msg, m.keyMap.Tab): if m.modelList.GetModelType() == LargeModelType { + m.modelList.SetInputPlaceholder(smallModelInputPlaceholder) return m, m.modelList.SetModelType(SmallModelType) } else { + m.modelList.SetInputPlaceholder(largeModelInputPlaceholder) return m, m.modelList.SetModelType(LargeModelType) } case key.Matches(msg, m.keyMap.Close): From 849c8fe6f7feb6bbfc09738762cfe6ddfb81e594 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 7 Jul 2025 13:21:34 +0200 Subject: [PATCH 05/38] chore: add a way to persist configurations --- internal/config/config.go | 32 ++++++++++++++++++++++++++++++++ internal/tui/page/chat/chat.go | 18 +++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 2b81094ed394e669b4d293200f911a875c2deacb..a6c72fab2b5fe103c2f6d9c475d851435a466724 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,10 +2,12 @@ package config import ( "fmt" + "os" "slices" "strings" "github.com/charmbracelet/crush/internal/fur/provider" + "github.com/tidwall/sjson" ) const ( @@ -274,6 +276,14 @@ func (c *Config) SmallModel() *provider.Model { return c.GetModel(model.Provider, model.Model) } +func (c *Config) SetCompactMode(enabled bool) error { + if c.Options == nil { + c.Options = &Options{} + } + c.Options.TUI.CompactMode = enabled + return c.SetConfigField("options.tui.compact_mode", enabled) +} + func (c *Config) Resolve(key string) (string, error) { if c.resolver == nil { return "", fmt.Errorf("no variable resolver configured") @@ -287,3 +297,25 @@ func UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) erro cfg.Models[modelType] = model return nil } + +func (c *Config) SetConfigField(key string, value any) error { + configPath := globalConfigData() + // read the data + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + data = []byte("{}") + } else { + return fmt.Errorf("failed to read config file: %w", err) + } + } + + newValue, err := sjson.Set(string(data), key, value) + if err != nil { + return fmt.Errorf("failed to set config field %s: %w", key, err) + } + if err := os.WriteFile(configPath, []byte(newValue), 0o644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index daf6881426a6d1b59020ee98e06fb44d6c28f17e..73eeec6895e0591a37978c6cbc8c41fd34e74164 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -156,12 +156,15 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, p.setSession(msg) case commands.ToggleCompactModeMsg: p.forceCompact = !p.forceCompact + var cmd tea.Cmd if p.forceCompact { p.setCompactMode(true) + cmd = p.updateCompactConfig(true) } else if p.width >= CompactModeBreakpoint { p.setCompactMode(false) + cmd = p.updateCompactConfig(false) } - return p, p.SetSize(p.width, p.height) + return p, tea.Batch(p.SetSize(p.width, p.height), cmd) case pubsub.Event[session.Session]: // this needs to go to header/sidebar u, cmd := p.header.Update(msg) @@ -337,6 +340,19 @@ func (p *chatPage) View() tea.View { return view } +func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd { + return func() tea.Msg { + err := config.Get().SetCompactMode(compact) + if err != nil { + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: "Failed to update compact mode configuration: " + err.Error(), + } + } + return nil + } +} + func (p *chatPage) setCompactMode(compact bool) { if p.compact == compact { return From 7e36fcf283890ccbef8472df25a3e17f50a4b530 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 7 Jul 2025 14:29:28 +0200 Subject: [PATCH 06/38] wip: api key --- internal/config/config.go | 2 +- internal/config/init.go | 2 +- internal/config/load.go | 17 +++- .../tui/components/dialogs/models/apikey.go | 88 +++++++++++++++++++ 4 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 internal/tui/components/dialogs/models/apikey.go diff --git a/internal/config/config.go b/internal/config/config.go index a6c72fab2b5fe103c2f6d9c475d851435a466724..4d0012b66f6caab7ade4f205803c2443870360d9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -299,7 +299,7 @@ func UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) erro } func (c *Config) SetConfigField(key string, value any) error { - configPath := globalConfigData() + configPath := GlobalConfigData() // read the data data, err := os.ReadFile(configPath) if err != nil { diff --git a/internal/config/init.go b/internal/config/init.go index 4c8176e40e8a012824f49e3558d7e47e52d1d3a2..12b30efd75f88d438e0734571cbb5c634ba231bc 100644 --- a/internal/config/init.go +++ b/internal/config/init.go @@ -105,7 +105,7 @@ func MarkProjectInitialized() error { } func HasInitialDataConfig() bool { - cfgPath := globalConfigData() + cfgPath := GlobalConfigData() if _, err := os.Stat(cfgPath); err != nil { return false } diff --git a/internal/config/load.go b/internal/config/load.go index c39b896d72270d2bec12b5ca90c1d6ae7eee435c..1864a36c95bd6adcf3959f34cdf3958201312a94 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -37,7 +37,7 @@ func Load(workingDir string, debug bool) (*Config, error) { // uses default config paths configPaths := []string{ globalConfig(), - globalConfigData(), + GlobalConfigData(), filepath.Join(workingDir, fmt.Sprintf("%s.json", appName)), filepath.Join(workingDir, fmt.Sprintf(".%s.json", appName)), } @@ -510,9 +510,9 @@ func globalConfig() string { return filepath.Join(os.Getenv("HOME"), ".config", appName, fmt.Sprintf("%s.json", appName)) } -// globalConfigData returns the path to the main data directory for the application. +// GlobalConfigData returns the path to the main data directory for the application. // this config is used when the app overrides configurations instead of updating the global config. -func globalConfigData() string { +func GlobalConfigData() string { xdgDataHome := os.Getenv("XDG_DATA_HOME") if xdgDataHome != "" { return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName)) @@ -531,3 +531,14 @@ func globalConfigData() string { return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName)) } + +func HomeDir() string { + homeDir := os.Getenv("HOME") + if homeDir == "" { + homeDir = os.Getenv("USERPROFILE") // For Windows compatibility + } + if homeDir == "" { + homeDir = os.Getenv("HOMEPATH") // Fallback for some environments + } + return homeDir +} diff --git a/internal/tui/components/dialogs/models/apikey.go b/internal/tui/components/dialogs/models/apikey.go new file mode 100644 index 0000000000000000000000000000000000000000..860e7e3a0fd406997d6b76a781acf9e909404557 --- /dev/null +++ b/internal/tui/components/dialogs/models/apikey.go @@ -0,0 +1,88 @@ +package models + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" +) + +type APIKeyInput struct { + input textinput.Model + width int + height int +} + +func NewAPIKeyInput() *APIKeyInput { + t := styles.CurrentTheme() + + ti := textinput.New() + ti.Placeholder = "Enter your API key..." + ti.SetWidth(50) + ti.SetVirtualCursor(false) + ti.Prompt = "> " + ti.SetStyles(t.S().TextInput) + ti.Focus() + + return &APIKeyInput{ + input: ti, + width: 60, + } +} + +func (a *APIKeyInput) Init() tea.Cmd { + return textinput.Blink +} + +func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + a.width = msg.Width + a.height = msg.Height + } + + var cmd tea.Cmd + a.input, cmd = a.input.Update(msg) + return a, cmd +} + +func (a *APIKeyInput) View() tea.View { + t := styles.CurrentTheme() + + title := t.S().Base. + Foreground(t.Secondary). + Bold(true). + Render("Enter your Anthropic API Key") + + inputView := a.input.View() + + dataPath := config.GlobalConfigData() + dataPath = strings.Replace(dataPath, config.HomeDir(), "~", 1) + helpText := t.S().Muted. + Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath)) + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + "", + inputView, + "", + helpText, + ) + + view := tea.NewView(content) + cursor := a.input.Cursor() + if cursor != nil { + cursor.Y += 2 // Adjust for title and spacing + } + view.SetCursor(cursor) + return view +} + +func (a *APIKeyInput) Value() string { + return a.input.Value() +} From d5c6bbc10d3993914baab5cf72791450b875e367 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 7 Jul 2025 15:43:02 +0200 Subject: [PATCH 07/38] chore: go mod tidy --- go.mod | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 93c86f7050505dc9db02c8d74fbf5043ba139d7b..278cf5c603aa560a9e62cf978dc51b6a3469a26f 100644 --- a/go.mod +++ b/go.mod @@ -39,17 +39,12 @@ require ( github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/stretchr/testify v1.10.0 + github.com/tidwall/sjson v1.2.5 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 gopkg.in/natefinch/lumberjack.v2 v2.2.1 mvdan.cc/sh/v3 v3.11.0 ) -require ( - github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect -) - require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect @@ -86,6 +81,7 @@ require ( github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect @@ -113,12 +109,12 @@ require ( github.com/rivo/uniseg v0.4.7 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.7.8 // indirect @@ -140,5 +136,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/grpc v1.71.0 // indirect google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) From 20431519f48af6548bd08327145f3c064ec7fc1d Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 8 Jul 2025 13:15:53 +0200 Subject: [PATCH 08/38] chore: make agent optional --- cmd/root.go | 142 +--------------------------- internal/app/app.go | 165 +++++++++++++++++++++++++++------ internal/app/lsp.go | 4 +- internal/tui/page/chat/chat.go | 2 +- 4 files changed, 143 insertions(+), 170 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 02ebbd0104e88a59b659c322a0605189b01afaaf..8ce81d4346d77e3bf239442d695c2ea329fdcf0c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,7 +6,6 @@ import ( "io" "log/slog" "os" - "sync" "time" tea "github.com/charmbracelet/bubbletea/v2" @@ -16,7 +15,6 @@ import ( "github.com/charmbracelet/crush/internal/format" "github.com/charmbracelet/crush/internal/llm/agent" "github.com/charmbracelet/crush/internal/log" - "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/tui" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/fang" @@ -122,76 +120,17 @@ to assist developers in writing, debugging, and understanding code directly from tea.WithUniformKeyLayout(), ) - // Setup the subscriptions, this will send services events to the TUI - ch, cancelSubs := setupSubscriptions(app, ctx) + go app.Subscribe(program) - // Create a context for the TUI message handler - tuiCtx, tuiCancel := context.WithCancel(ctx) - var tuiWg sync.WaitGroup - tuiWg.Add(1) - - // Set up message handling for the TUI - go func() { - defer tuiWg.Done() - defer log.RecoverPanic("TUI-message-handler", func() { - attemptTUIRecovery(program) - }) - - for { - select { - case <-tuiCtx.Done(): - slog.Info("TUI message handler shutting down") - return - case msg, ok := <-ch: - if !ok { - slog.Info("TUI message channel closed") - return - } - program.Send(msg) - } - } - }() - - // Cleanup function for when the program exits - cleanup := func() { - // Shutdown the app - app.Shutdown() - - // Cancel subscriptions first - cancelSubs() - - // Then cancel TUI message handler - tuiCancel() - - // Wait for TUI message handler to finish - tuiWg.Wait() - - slog.Info("All goroutines cleaned up") - } - - // Run the TUI - result, err := program.Run() - cleanup() - - if err != nil { + if _, err := program.Run(); err != nil { slog.Error(fmt.Sprintf("TUI run error: %v", err)) return fmt.Errorf("TUI error: %v", err) } - - slog.Info(fmt.Sprintf("TUI exited with result: %v", result)) + app.Shutdown() return nil }, } -// attemptTUIRecovery tries to recover the TUI after a panic -func attemptTUIRecovery(program *tea.Program) { - slog.Info("Attempting to recover TUI after panic") - - // We could try to restart the TUI or gracefully exit - // For now, we'll just quit the program to avoid further issues - program.Quit() -} - func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) { go func() { defer log.RecoverPanic("MCP-goroutine", nil) @@ -206,81 +145,6 @@ func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) { }() } -func setupSubscriber[T any]( - ctx context.Context, - wg *sync.WaitGroup, - name string, - subscriber func(context.Context) <-chan pubsub.Event[T], - outputCh chan<- tea.Msg, -) { - wg.Add(1) - go func() { - defer wg.Done() - defer log.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil) - - subCh := subscriber(ctx) - - for { - select { - case event, ok := <-subCh: - if !ok { - slog.Info("subscription channel closed", "name", name) - return - } - - var msg tea.Msg = event - - select { - case outputCh <- msg: - case <-time.After(2 * time.Second): - slog.Warn("message dropped due to slow consumer", "name", name) - case <-ctx.Done(): - slog.Info("subscription cancelled", "name", name) - return - } - case <-ctx.Done(): - slog.Info("subscription cancelled", "name", name) - return - } - } - }() -} - -func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) { - ch := make(chan tea.Msg, 100) - - wg := sync.WaitGroup{} - ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context - - setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch) - setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch) - setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch) - setupSubscriber(ctx, &wg, "coderAgent", app.CoderAgent.Subscribe, ch) - setupSubscriber(ctx, &wg, "history", app.History.Subscribe, ch) - - cleanupFunc := func() { - slog.Info("Cancelling all subscriptions") - cancel() // Signal all goroutines to stop - - waitCh := make(chan struct{}) - go func() { - defer log.RecoverPanic("subscription-cleanup", nil) - wg.Wait() - close(waitCh) - }() - - select { - case <-waitCh: - slog.Info("All subscription goroutines completed successfully") - close(ch) // Only close after all writers are confirmed done - case <-time.After(5 * time.Second): - slog.Warn("Timed out waiting for some subscription goroutines to complete") - close(ch) - } - } - return ch, cleanupFunc -} - func Execute() { if err := fang.Execute( context.Background(), diff --git a/internal/app/app.go b/internal/app/app.go index da014df81367665caf1df793760e3d832c223648..c06f859e5fa083bde55bd2c4e5d07036190be8bf 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -10,11 +10,13 @@ import ( "sync" "time" + tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/format" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/message" @@ -36,9 +38,18 @@ type App struct { watcherCancelFuncs []context.CancelFunc cancelFuncsMutex sync.Mutex - watcherWG sync.WaitGroup + lspWatcherWG sync.WaitGroup config *config.Config + + serviceEventsWG *sync.WaitGroup + eventsCtx context.Context + events chan tea.Msg + tuiWG *sync.WaitGroup + + // global context and cleanup functions + globalCtx context.Context + cleanupFuncs []func() } func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { @@ -53,32 +64,29 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { History: files, Permissions: permission.NewPermissionService(cfg.WorkingDir()), LSPClients: make(map[string]*lsp.Client), - config: cfg, + + globalCtx: ctx, + + config: cfg, + + events: make(chan tea.Msg, 100), + serviceEventsWG: &sync.WaitGroup{}, + tuiWG: &sync.WaitGroup{}, } + app.setupEvents() + // Initialize LSP clients in the background go app.initLSPClients(ctx) // TODO: remove the concept of agent config most likely - coderAgentCfg := cfg.Agents["coder"] - if coderAgentCfg.ID == "" { - return nil, fmt.Errorf("coder agent configuration is missing") - } - - var err error - app.CoderAgent, err = agent.NewAgent( - coderAgentCfg, - app.Permissions, - app.Sessions, - app.Messages, - app.History, - app.LSPClients, - ) - if err != nil { - slog.Error("Failed to create coder agent", "err", err) - return nil, err + if cfg.IsConfigured() { + if err := app.InitCoderAgent(); err != nil { + return nil, fmt.Errorf("failed to initialize coder agent: %w", err) + } + } else { + slog.Warn("No agent configuration found") } - return app, nil } @@ -146,32 +154,133 @@ func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat return nil } +func (app *App) UpdateAgentModel() error { + return app.CoderAgent.UpdateModel() +} + +func (app *App) setupEvents() { + ctx, cancel := context.WithCancel(app.globalCtx) + app.eventsCtx = ctx + setupSubscriber(ctx, app.serviceEventsWG, "sessions", app.Sessions.Subscribe, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events) + cleanupFunc := func() { + cancel() + app.serviceEventsWG.Wait() + } + app.cleanupFuncs = append(app.cleanupFuncs, cleanupFunc) +} + +func setupSubscriber[T any]( + ctx context.Context, + wg *sync.WaitGroup, + name string, + subscriber func(context.Context) <-chan pubsub.Event[T], + outputCh chan<- tea.Msg, +) { + wg.Add(1) + go func() { + defer wg.Done() + subCh := subscriber(ctx) + for { + select { + case event, ok := <-subCh: + if !ok { + slog.Debug("subscription channel closed", "name", name) + return + } + var msg tea.Msg = event + select { + case outputCh <- msg: + case <-time.After(2 * time.Second): + slog.Warn("message dropped due to slow consumer", "name", name) + case <-ctx.Done(): + slog.Debug("subscription cancelled", "name", name) + return + } + case <-ctx.Done(): + slog.Debug("subscription cancelled", "name", name) + return + } + } + }() +} + +func (app *App) InitCoderAgent() error { + coderAgentCfg := app.config.Agents["coder"] + if coderAgentCfg.ID == "" { + return fmt.Errorf("coder agent configuration is missing") + } + var err error + app.CoderAgent, err = agent.NewAgent( + coderAgentCfg, + app.Permissions, + app.Sessions, + app.Messages, + app.History, + app.LSPClients, + ) + if err != nil { + slog.Error("Failed to create coder agent", "err", err) + return err + } + setupSubscriber(app.eventsCtx, app.serviceEventsWG, "coderAgent", app.CoderAgent.Subscribe, app.events) + return nil +} + +func (app *App) Subscribe(program *tea.Program) { + app.tuiWG.Add(1) + tuiCtx, tuiCancel := context.WithCancel(app.globalCtx) + app.cleanupFuncs = append(app.cleanupFuncs, func() { + slog.Debug("Cancelling TUI message handler") + tuiCancel() + app.tuiWG.Wait() + }) + defer app.tuiWG.Done() + for { + select { + case <-tuiCtx.Done(): + slog.Debug("TUI message handler shutting down") + return + case msg, ok := <-app.events: + if !ok { + slog.Debug("TUI message channel closed") + return + } + program.Send(msg) + } + } +} + // Shutdown performs a clean shutdown of the application func (app *App) Shutdown() { - // Cancel all watcher goroutines app.cancelFuncsMutex.Lock() for _, cancel := range app.watcherCancelFuncs { cancel() } app.cancelFuncsMutex.Unlock() - app.watcherWG.Wait() + app.lspWatcherWG.Wait() - // Perform additional cleanup for LSP clients app.clientsMutex.RLock() clients := make(map[string]*lsp.Client, len(app.LSPClients)) maps.Copy(clients, app.LSPClients) app.clientsMutex.RUnlock() for name, client := range clients { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second) if err := client.Shutdown(shutdownCtx); err != nil { slog.Error("Failed to shutdown LSP client", "name", name, "error", err) } cancel() } - app.CoderAgent.CancelAll() -} + if app.CoderAgent != nil { + app.CoderAgent.CancelAll() + } -func (app *App) UpdateAgentModel() error { - return app.CoderAgent.UpdateModel() + for _, cleanup := range app.cleanupFuncs { + if cleanup != nil { + cleanup() + } + } } diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 1777a653a4153dc42cc87444f6122df01e82cedd..ba98d4b3a074c2e9abcef87eb3030a21be669eab 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -71,7 +71,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman app.cancelFuncsMutex.Unlock() // Add the watcher to a WaitGroup to track active goroutines - app.watcherWG.Add(1) + app.lspWatcherWG.Add(1) // Add to map with mutex protection before starting goroutine app.clientsMutex.Lock() @@ -83,7 +83,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman // runWorkspaceWatcher executes the workspace watcher for an LSP client func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) { - defer app.watcherWG.Done() + defer app.lspWatcherWG.Done() defer log.RecoverPanic("LSP-"+name, func() { // Try to restart the client app.restartLSPClient(ctx, name) diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 73eeec6895e0591a37978c6cbc8c41fd34e74164..fc529bba8b2be2bbbab1e5c7dd5668bcf7923ab8 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -511,7 +511,7 @@ func (p *chatPage) Bindings() []key.Binding { p.keyMap.NewSession, p.keyMap.AddAttachment, } - if p.app.CoderAgent.IsBusy() { + if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() { cancelBinding := p.keyMap.Cancel if p.canceling { cancelBinding = key.NewBinding( From eeeef60efe72afc5b37757f1cdd4b4799cba8017 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 8 Jul 2025 16:50:03 +0200 Subject: [PATCH 09/38] wip: init screen and simple flow --- internal/config/config.go | 17 +- internal/config/load.go | 61 +++-- .../tui/components/chat/messages/messages.go | 7 + internal/tui/components/chat/splash/keys.go | 22 +- internal/tui/components/chat/splash/splash.go | 237 +++++++++++++++++- internal/tui/components/dialogs/init/init.go | 214 ---------------- internal/tui/components/dialogs/init/keys.go | 69 ----- .../tui/components/dialogs/models/list.go | 23 +- internal/tui/page/chat/chat.go | 18 ++ internal/tui/tui.go | 20 +- 10 files changed, 339 insertions(+), 349 deletions(-) delete mode 100644 internal/tui/components/dialogs/init/init.go delete mode 100644 internal/tui/components/dialogs/init/keys.go diff --git a/internal/config/config.go b/internal/config/config.go index 4d0012b66f6caab7ade4f205803c2443870360d9..bbf619e96ae8f6a9d96c8bcb29cbb92c717326b4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -208,7 +208,8 @@ type Config struct { // TODO: most likely remove this concept when I come back to it Agents map[string]Agent `json:"-"` // TODO: find a better way to do this this should probably not be part of the config - resolver VariableResolver + resolver VariableResolver + dataConfigDir string `json:"-"` } func (c *Config) WorkingDir() string { @@ -291,17 +292,17 @@ func (c *Config) Resolve(key string) (string, error) { return c.resolver.ResolveValue(key) } -// TODO: maybe handle this better -func UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error { - cfg := Get() - cfg.Models[modelType] = model +func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error { + c.Models[modelType] = model + if err := c.SetConfigField(fmt.Sprintf("models.%s", modelType), model); err != nil { + return fmt.Errorf("failed to update preferred model: %w", err) + } return nil } func (c *Config) SetConfigField(key string, value any) error { - configPath := GlobalConfigData() // read the data - data, err := os.ReadFile(configPath) + data, err := os.ReadFile(c.dataConfigDir) if err != nil { if os.IsNotExist(err) { data = []byte("{}") @@ -314,7 +315,7 @@ func (c *Config) SetConfigField(key string, value any) error { if err != nil { return fmt.Errorf("failed to set config field %s: %w", key, err) } - if err := os.WriteFile(configPath, []byte(newValue), 0o644); err != nil { + if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o644); err != nil { return fmt.Errorf("failed to write config file: %w", err) } return nil diff --git a/internal/config/load.go b/internal/config/load.go index 1864a36c95bd6adcf3959f34cdf3958201312a94..96006c4415237cf802c10b08ab401e42bdd73fc6 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -46,6 +46,8 @@ func Load(workingDir string, debug bool) (*Config, error) { return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err) } + cfg.dataConfigDir = GlobalConfigData() + cfg.setDefaults(workingDir) if debug { @@ -367,10 +369,11 @@ func (cfg *Config) defaultModelSelection(knownProviders []provider.Provider) (la } func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) error { - large, small, err := cfg.defaultModelSelection(knownProviders) + defaultLarge, defaultSmall, err := cfg.defaultModelSelection(knownProviders) if err != nil { return fmt.Errorf("failed to select default models: %w", err) } + large, small := defaultLarge, defaultSmall largeModelSelected, largeModelConfigured := cfg.Models[SelectedModelTypeLarge] if largeModelConfigured { @@ -381,18 +384,26 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e large.Provider = largeModelSelected.Provider } model := cfg.GetModel(large.Provider, large.Model) + slog.Info("Configuring selected large model", "provider", large.Provider, "model", large.Model) + slog.Info("MOdel configured", "model", model) if model == nil { - return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider) - } - if largeModelSelected.MaxTokens > 0 { - large.MaxTokens = largeModelSelected.MaxTokens + large = defaultLarge + // override the model type to large + err := cfg.UpdatePreferredModel(SelectedModelTypeLarge, large) + if err != nil { + return fmt.Errorf("failed to update preferred large model: %w", err) + } } else { - large.MaxTokens = model.DefaultMaxTokens - } - if largeModelSelected.ReasoningEffort != "" { - large.ReasoningEffort = largeModelSelected.ReasoningEffort + if largeModelSelected.MaxTokens > 0 { + large.MaxTokens = largeModelSelected.MaxTokens + } else { + large.MaxTokens = model.DefaultMaxTokens + } + if largeModelSelected.ReasoningEffort != "" { + large.ReasoningEffort = largeModelSelected.ReasoningEffort + } + large.Think = largeModelSelected.Think } - large.Think = largeModelSelected.Think } smallModelSelected, smallModelConfigured := cfg.Models[SelectedModelTypeSmall] if smallModelConfigured { @@ -405,25 +416,21 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e model := cfg.GetModel(small.Provider, small.Model) if model == nil { - return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider) - } - if smallModelSelected.MaxTokens > 0 { - small.MaxTokens = smallModelSelected.MaxTokens + small = defaultSmall + // override the model type to small + err := cfg.UpdatePreferredModel(SelectedModelTypeSmall, small) + if err != nil { + return fmt.Errorf("failed to update preferred small model: %w", err) + } } else { - small.MaxTokens = model.DefaultMaxTokens + if smallModelSelected.MaxTokens > 0 { + small.MaxTokens = smallModelSelected.MaxTokens + } else { + small.MaxTokens = model.DefaultMaxTokens + } + small.ReasoningEffort = smallModelSelected.ReasoningEffort + small.Think = smallModelSelected.Think } - small.ReasoningEffort = smallModelSelected.ReasoningEffort - small.Think = smallModelSelected.Think - } - - // validate the selected models - largeModel := cfg.GetModel(large.Provider, large.Model) - if largeModel == nil { - return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider) - } - smallModel := cfg.GetModel(small.Provider, small.Model) - if smallModel == nil { - return fmt.Errorf("small model %s not found for provider %s", small.Model, small.Provider) } cfg.Models[SelectedModelTypeLarge] = large cfg.Models[SelectedModelTypeSmall] = small diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 770b0729fd27a6d110605b05d6f66fae56981716..16b599ccda81c0ae77afc5a1e176845da7bebcab 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fur/provider" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/core" @@ -296,6 +297,12 @@ func (m *assistantSectionModel) View() tea.View { infoMsg := t.S().Subtle.Render(duration.String()) icon := t.S().Subtle.Render(styles.ModelIcon) model := config.Get().GetModel(m.message.Provider, m.message.Model) + if model == nil { + // This means the model is not configured anymore + model = &provider.Model{ + Name: "Unknown Model", + } + } modelFormatted := t.S().Muted.Render(model.Name) assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg) return tea.NewView( diff --git a/internal/tui/components/chat/splash/keys.go b/internal/tui/components/chat/splash/keys.go index 2a90441da52924f49f187c88744f9ea01c80e745..90b1954d9b3aa93f01bcafe9001276cc941748a6 100644 --- a/internal/tui/components/chat/splash/keys.go +++ b/internal/tui/components/chat/splash/keys.go @@ -7,7 +7,11 @@ import ( type KeyMap struct { Select, Next, - Previous key.Binding + Previous, + Yes, + No, + Tab, + LeftRight key.Binding } func DefaultKeyMap() KeyMap { @@ -24,5 +28,21 @@ func DefaultKeyMap() KeyMap { key.WithKeys("up", "ctrl+p"), key.WithHelp("↑", "previous item"), ), + Yes: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "yes"), + ), + No: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "no"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch"), + ), + LeftRight: key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "switch"), + ), } } diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 50912b283b05b6e2479fa82793e16e726a1cac8b..d06b006e60504c4517e8ef34a36eff9689ee1591 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -1,9 +1,16 @@ package splash import ( + "fmt" + "slices" + "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fur/provider" + "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" @@ -43,6 +50,7 @@ type splashCmp struct { state SplashScreenState modelList *models.ModelListComponent cursorRow, cursorCol int + selectedNo bool // true if "No" button is selected in initialize state } func New() Splash { @@ -67,6 +75,7 @@ func New() Splash { state: SplashScreenStateOnboarding, logoRendered: "", modelList: modelList, + selectedNo: false, } } @@ -83,8 +92,28 @@ func (s *splashCmp) Init() tea.Cmd { } else { s.state = SplashScreenStateReady } + } else { + providers, err := config.Providers() + if err != nil { + return util.ReportError(err) + } + filteredProviders := []provider.Provider{} + simpleProviders := []string{ + "anthropic", + "openai", + "gemini", + "xai", + "openrouter", + } + for _, p := range providers { + if slices.Contains(simpleProviders, string(p.ID)) { + filteredProviders = append(filteredProviders, p) + } + } + s.modelList.SetProviders(filteredProviders) + return s.modelList.Init() } - return s.modelList.Init() + return nil } // SetSize implements SplashPage. @@ -107,15 +136,157 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, s.SetSize(msg.Width, msg.Height) case tea.KeyPressMsg: switch { + case key.Matches(msg, s.keyMap.Select): + if s.state == SplashScreenStateOnboarding { + modelInx := s.modelList.SelectedIndex() + items := s.modelList.Items() + selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption) + if s.isProviderConfigured(string(selectedItem.Provider.ID)) { + cmd := s.setPreferredModel(selectedItem) + s.state = SplashScreenStateReady + if b, err := config.ProjectNeedsInitialization(); err != nil { + return s, tea.Batch(cmd, util.ReportError(err)) + } else if b { + s.state = SplashScreenStateInitialize + return s, cmd + } else { + s.state = SplashScreenStateReady + return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) + } + } + } else if s.state == SplashScreenStateInitialize { + return s, s.initializeProject() + } + case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight): + if s.state == SplashScreenStateInitialize { + s.selectedNo = !s.selectedNo + return s, nil + } + case key.Matches(msg, s.keyMap.Yes): + if s.state == SplashScreenStateInitialize { + return s, s.initializeProject() + } + case key.Matches(msg, s.keyMap.No): + if s.state == SplashScreenStateInitialize { + s.state = SplashScreenStateReady + return s, util.CmdHandler(OnboardingCompleteMsg{}) + } default: - u, cmd := s.modelList.Update(msg) - s.modelList = u - return s, cmd + if s.state == SplashScreenStateOnboarding { + u, cmd := s.modelList.Update(msg) + s.modelList = u + return s, cmd + } } } return s, nil } +func (s *splashCmp) initializeProject() tea.Cmd { + s.state = SplashScreenStateReady + prompt := `Please analyze this codebase and create a CRUSH.md file containing: +1. Build/lint/test commands - especially for running a single test +2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. + +The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long. +If there's already a CRUSH.md, improve it. +If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them. +Add the .crush directory to the .gitignore file if it's not already there.` + + // Mark the project as initialized + if err := config.MarkProjectInitialized(); err != nil { + return util.ReportError(err) + } + var cmds []tea.Cmd + + cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{})) + if !s.selectedNo { + cmds = append(cmds, + util.CmdHandler(chat.SessionClearedMsg{}), + util.CmdHandler(chat.SendMsg{ + Text: prompt, + }), + ) + } + return tea.Sequence(cmds...) +} + +func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd { + cfg := config.Get() + model := 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)) + } + + selectedModel := config.SelectedModel{ + Model: selectedItem.Model.ID, + Provider: string(selectedItem.Provider.ID), + ReasoningEffort: model.DefaultReasoningEffort, + MaxTokens: model.DefaultMaxTokens, + } + + err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel) + if err != nil { + return util.ReportError(err) + } + + // Now lets automatically setup the small model + knownProvider, err := s.getProvider(selectedItem.Provider.ID) + if err != nil { + return util.ReportError(err) + } + if knownProvider == nil { + // for local provider we just use the same model + err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel) + if err != nil { + return util.ReportError(err) + } + } else { + smallModel := knownProvider.DefaultSmallModelID + model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel) + // should never happen + if model == nil { + err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel) + if err != nil { + return util.ReportError(err) + } + return nil + } + smallSelectedModel := config.SelectedModel{ + Model: smallModel, + Provider: string(selectedItem.Provider.ID), + ReasoningEffort: model.DefaultReasoningEffort, + MaxTokens: model.DefaultMaxTokens, + } + err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel) + if err != nil { + return util.ReportError(err) + } + } + return nil +} + +func (s *splashCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) { + providers, err := config.Providers() + if err != nil { + return nil, err + } + for _, p := range providers { + if p.ID == providerID { + return &p, nil + } + } + return nil, nil +} + +func (s *splashCmp) isProviderConfigured(providerID string) bool { + cfg := config.Get() + if _, ok := cfg.Providers[providerID]; ok { + return true + } + return false +} + // View implements SplashPage. func (s *splashCmp) View() tea.View { t := styles.CurrentTheme() @@ -141,6 +312,56 @@ func (s *splashCmp) View() tea.View { s.logoRendered, modelSelector, ) + case SplashScreenStateInitialize: + t := styles.CurrentTheme() + + titleStyle := t.S().Base.Foreground(t.FgBase) + bodyStyle := t.S().Base.Foreground(t.FgMuted) + shortcutStyle := t.S().Base.Foreground(t.Success) + + initText := lipgloss.JoinVertical( + lipgloss.Left, + titleStyle.Render("Would you like to initialize this project?"), + "", + bodyStyle.Render("When I initialize your codebase I examine the project and put the"), + bodyStyle.Render("result into a CRUSH.md file which serves as general context."), + "", + bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."), + "", + bodyStyle.Render("Would you like to initialize now?"), + ) + + yesButton := core.SelectableButton(core.ButtonOpts{ + Text: "Yep!", + UnderlineIndex: 0, + Selected: !s.selectedNo, + }) + + noButton := core.SelectableButton(core.ButtonOpts{ + Text: "Nope", + UnderlineIndex: 0, + Selected: s.selectedNo, + }) + + buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton) + + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + + initContent := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( + lipgloss.JoinVertical( + lipgloss.Left, + initText, + "", + buttons, + ), + ) + + content = lipgloss.JoinVertical( + lipgloss.Left, + s.logoRendered, + initContent, + ) + default: // Show just the logo for other states content = s.logoRendered @@ -192,6 +413,14 @@ func (s *splashCmp) Bindings() []key.Binding { s.keyMap.Next, s.keyMap.Previous, } + } else if s.state == SplashScreenStateInitialize { + return []key.Binding{ + s.keyMap.Select, + s.keyMap.Yes, + s.keyMap.No, + s.keyMap.Tab, + s.keyMap.LeftRight, + } } return []key.Binding{} } diff --git a/internal/tui/components/dialogs/init/init.go b/internal/tui/components/dialogs/init/init.go deleted file mode 100644 index 74d0dc0b3d9d4630b28c4b240fb17fbe611ba21f..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/init/init.go +++ /dev/null @@ -1,214 +0,0 @@ -package init - -import ( - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - - "github.com/charmbracelet/crush/internal/config" - cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const InitDialogID dialogs.DialogID = "init" - -// InitDialogCmp is a component that asks the user if they want to initialize the project. -type InitDialogCmp interface { - dialogs.DialogModel -} - -type initDialogCmp struct { - wWidth, wHeight int - width, height int - selected int - keyMap KeyMap -} - -// NewInitDialogCmp creates a new InitDialogCmp. -func NewInitDialogCmp() InitDialogCmp { - return &initDialogCmp{ - selected: 0, - keyMap: DefaultKeyMap(), - } -} - -// Init implements tea.Model. -func (m *initDialogCmp) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.wWidth = msg.Width - m.wHeight = msg.Height - cmd := m.SetSize() - return m, cmd - case tea.KeyPressMsg: - switch { - case key.Matches(msg, m.keyMap.Close): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(false), - ) - case key.Matches(msg, m.keyMap.ChangeSelection): - m.selected = (m.selected + 1) % 2 - return m, nil - case key.Matches(msg, m.keyMap.Select): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(m.selected == 0), - ) - case key.Matches(msg, m.keyMap.Y): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(true), - ) - case key.Matches(msg, m.keyMap.N): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(false), - ) - } - } - return m, nil -} - -func (m *initDialogCmp) renderButtons() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - buttons := []core.ButtonOpts{ - { - Text: "Yes", - UnderlineIndex: 0, // "Y" - Selected: m.selected == 0, - }, - { - Text: "No", - UnderlineIndex: 0, // "N" - Selected: m.selected == 1, - }, - } - - content := core.SelectableButtons(buttons, " ") - - return baseStyle.AlignHorizontal(lipgloss.Right).Width(m.width - 4).Render(content) -} - -func (m *initDialogCmp) renderContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - explanation := t.S().Text. - Width(m.width - 4). - Render("Initialization generates a new CRUSH.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.") - - question := t.S().Text. - Width(m.width - 4). - Render("Would you like to initialize this project?") - - return baseStyle.Render(lipgloss.JoinVertical( - lipgloss.Left, - explanation, - "", - question, - )) -} - -func (m *initDialogCmp) render() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - title := core.Title("Initialize Project", m.width-4) - - content := m.renderContent() - buttons := m.renderButtons() - - dialogContent := lipgloss.JoinVertical( - lipgloss.Top, - title, - "", - content, - "", - buttons, - "", - ) - - return baseStyle. - Padding(0, 1). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus). - Width(m.width). - Render(dialogContent) -} - -// View implements tea.Model. -func (m *initDialogCmp) View() tea.View { - return tea.NewView(m.render()) -} - -// SetSize sets the size of the component. -func (m *initDialogCmp) SetSize() tea.Cmd { - m.width = min(90, m.wWidth) - m.height = min(15, m.wHeight) - return nil -} - -// ID implements DialogModel. -func (m *initDialogCmp) ID() dialogs.DialogID { - return InitDialogID -} - -// Position implements DialogModel. -func (m *initDialogCmp) Position() (int, int) { - row := (m.wHeight / 2) - (m.height / 2) - col := (m.wWidth / 2) - (m.width / 2) - return row, col -} - -// handleInitialization handles the initialization logic when the dialog is closed. -func (m *initDialogCmp) handleInitialization(initialize bool) tea.Cmd { - if initialize { - // Run the initialization command - prompt := `Please analyze this codebase and create a CRUSH.md file containing: -1. Build/lint/test commands - especially for running a single test -2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. - -The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long. -If there's already a CRUSH.md, improve it. -If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them. -Add the .crush directory to the .gitignore file if it's not already there.` - - // Mark the project as initialized - if err := config.MarkProjectInitialized(); err != nil { - return util.ReportError(err) - } - - return tea.Sequence( - util.CmdHandler(cmpChat.SessionClearedMsg{}), - util.CmdHandler(cmpChat.SendMsg{ - Text: prompt, - }), - ) - } else { - // Mark the project as initialized without running the command - if err := config.MarkProjectInitialized(); err != nil { - return util.ReportError(err) - } - } - return nil -} - -// CloseInitDialogMsg is a message that is sent when the init dialog is closed. -type CloseInitDialogMsg struct { - Initialize bool -} - -// ShowInitDialogMsg is a message that is sent to show the init dialog. -type ShowInitDialogMsg struct { - Show bool -} diff --git a/internal/tui/components/dialogs/init/keys.go b/internal/tui/components/dialogs/init/keys.go deleted file mode 100644 index afd82d45ea8b47630c2d5ed1450419ae8d4b4c19..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/init/keys.go +++ /dev/null @@ -1,69 +0,0 @@ -package init - -import ( - "github.com/charmbracelet/bubbles/v2/key" -) - -type KeyMap struct { - ChangeSelection, - Select, - Y, - N, - Close key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - ChangeSelection: key.NewBinding( - key.WithKeys("tab", "left", "right", "h", "l"), - key.WithHelp("tab/←/→", "toggle selection"), - ), - Select: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "confirm"), - ), - Y: key.NewBinding( - key.WithKeys("y"), - key.WithHelp("y", "yes"), - ), - N: key.NewBinding( - key.WithKeys("n"), - key.WithHelp("n", "no"), - ), - Close: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.ChangeSelection, - k.Select, - k.Y, - k.N, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.ChangeSelection, - k.Select, - k.Close, - } -} diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index bbb23300ae218830cff76daaa8418ab1a75ff15e..4a4eeb300dfb97c1db2145fcec24a81cda2fd124 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -18,6 +18,7 @@ import ( type ModelListComponent struct { list list.ListModel modelType int + providers []provider.Provider } func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent { @@ -36,7 +37,16 @@ func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputP } func (m *ModelListComponent) Init() tea.Cmd { - return tea.Batch(m.list.Init(), m.SetModelType(m.modelType)) + var cmds []tea.Cmd + if len(m.providers) == 0 { + providers, err := config.Providers() + m.providers = providers + if err != nil { + cmds = append(cmds, util.ReportError(err)) + } + } + cmds = append(cmds, m.list.Init(), m.SetModelType(m.modelType)) + return tea.Batch(cmds...) } func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) { @@ -65,11 +75,6 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { t := styles.CurrentTheme() m.modelType = modelType - providers, err := config.Providers() - if err != nil { - return util.ReportError(err) - } - modelItems := []util.Model{} selectIndex := 0 @@ -144,7 +149,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { } // Then add the known providers from the predefined list - for _, provider := range providers { + for _, provider := range m.providers { // Skip if we already added this provider as an unknown provider if addedProviders[string(provider.ID)] { continue @@ -187,3 +192,7 @@ func (m *ModelListComponent) GetModelType() int { func (m *ModelListComponent) SetInputPlaceholder(placeholder string) { m.list.SetFilterPlaceholder(placeholder) } + +func (m *ModelListComponent) SetProviders(providers []provider.Provider) { + m.providers = providers +} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index fc529bba8b2be2bbbab1e5c7dd5668bcf7923ab8..1614ed730fba6986ed513a6592bcbfc533b4ead5 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -220,6 +220,14 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return p, cmd } + case splash.OnboardingCompleteMsg: + p.state = ChatStateNewMessage + err := p.app.InitCoderAgent() + if err != nil { + return p, util.ReportError(err) + } + p.focusedPane = PanelTypeEditor + return p, p.SetSize(p.width, p.height) case tea.KeyPressMsg: switch { case key.Matches(msg, p.keyMap.NewSession): @@ -233,6 +241,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name) } case key.Matches(msg, p.keyMap.Tab): + if p.state == ChatStateOnboarding || p.state == ChatStateInitProject { + u, cmd := p.splash.Update(msg) + p.splash = u.(splash.Splash) + return p, cmd + } p.changeFocus() return p, nil case key.Matches(msg, p.keyMap.Cancel): @@ -416,6 +429,7 @@ func (p *chatPage) newSession() tea.Cmd { // Cannot start a new session if we are not in the session state return nil } + // blank session p.session = session.Session{} p.state = ChatStateNewMessage @@ -456,8 +470,12 @@ func (p *chatPage) changeFocus() { switch p.focusedPane { case PanelTypeChat: p.focusedPane = PanelTypeEditor + p.editor.Focus() + p.chat.Blur() case PanelTypeEditor: p.focusedPane = PanelTypeChat + p.chat.Focus() + p.editor.Blur() } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4ebf9f7edcf0d35cda2b7c2c6a3fa6d20cdd97b3..5aa89aa73d3fb39a556c5f407ba73144a560ea6c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -19,7 +19,6 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" - initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions" "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" @@ -65,23 +64,6 @@ func (a appModel) Init() tea.Cmd { cmd = a.status.Init() cmds = append(cmds, cmd) - // Check if we should show the init dialog - cmds = append(cmds, func() tea.Msg { - shouldShow, err := config.ProjectNeedsInitialization() - if err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to check init status: " + err.Error(), - } - } - if shouldShow { - return dialogs.OpenDialogMsg{ - Model: initDialog.NewInitDialogCmp(), - } - } - return nil - }) - return tea.Batch(cmds...) } @@ -156,7 +138,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Model Switch case models.ModelSelectedMsg: - config.UpdatePreferredModel(msg.ModelType, msg.Model) + config.Get().UpdatePreferredModel(msg.ModelType, msg.Model) // Update the agent with the new model/provider configuration if err := a.app.UpdateAgentModel(); err != nil { From 8dff4d64ff01860b3d8812d60907b1702ec34631 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 8 Jul 2025 17:38:51 +0200 Subject: [PATCH 10/38] fix: add panic recovery to TUI subscription handler - Add panic recovery with graceful TUI shutdown to app.Subscribe method - Ensures application doesn't crash if TUI subscription loop panics - Logs panic details to timestamped files for debugging - Maintains consistency with other critical goroutines in codebase --- internal/app/app.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/app/app.go b/internal/app/app.go index c06f859e5fa083bde55bd2c4e5d07036190be8bf..b47fe1c8e4ca6cb37baa7d638126f16295421e72 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -16,6 +16,7 @@ import ( "github.com/charmbracelet/crush/internal/format" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/log" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/lsp" @@ -230,6 +231,11 @@ func (app *App) InitCoderAgent() error { } func (app *App) Subscribe(program *tea.Program) { + defer log.RecoverPanic("app.Subscribe", func() { + slog.Info("TUI subscription panic - attempting graceful shutdown") + program.Quit() + }) + app.tuiWG.Add(1) tuiCtx, tuiCancel := context.WithCancel(app.globalCtx) app.cleanupFuncs = append(app.cleanupFuncs, func() { From bc1992fae831058179c155327f538e563a8ca32c Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 8 Jul 2025 17:39:15 +0200 Subject: [PATCH 11/38] refactor: replace magic numbers with named constants - Add layout constants for borders, padding, and positioning - Add timing constant for cancel timer duration - Improve code readability and maintainability - Make layout calculations self-documenting --- internal/tui/page/chat/chat.go | 208 ++++++++++++++++----------------- 1 file changed, 101 insertions(+), 107 deletions(-) diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 1614ed730fba6986ed513a6592bcbfc533b4ead5..7090d4a02f5f213f8d2aaed9d91ae46cd763da0d 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -35,20 +35,11 @@ var ChatPageID page.PageID = "chat" type ( OpenFilePickerMsg struct{} ChatFocusedMsg struct { - Focused bool // True if the chat input is focused, false otherwise + Focused bool } CancelTimerExpiredMsg struct{} ) -type ChatState string - -const ( - ChatStateOnboarding ChatState = "onboarding" - ChatStateInitProject ChatState = "init_project" - ChatStateNewMessage ChatState = "new_message" - ChatStateInSession ChatState = "in_session" -) - type PanelType string const ( @@ -63,6 +54,15 @@ const ( SideBarWidth = 31 // Width of the sidebar SideBarDetailsPadding = 1 // Padding for the sidebar details section HeaderHeight = 1 // Height of the header + + // Layout constants for borders and padding + BorderWidth = 1 // Width of component borders + LeftRightBorders = 2 // Left + right border width (1 + 1) + TopBottomBorders = 2 // Top + bottom border width (1 + 1) + DetailsPositioning = 2 // Positioning adjustment for details panel + + // Timing constants + CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires ) type ChatPage interface { @@ -70,9 +70,9 @@ type ChatPage interface { layout.Help } -// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds +// cancelTimerCmd creates a command that expires the cancel timer func cancelTimerCmd() tea.Cmd { - return tea.Tick(2*time.Second, func(time.Time) tea.Msg { + return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg { return CancelTimerExpiredMsg{} }) } @@ -81,34 +81,33 @@ type chatPage struct { width, height int detailsWidth, detailsHeight int app *app.App - state ChatState - session session.Session - keyMap KeyMap - focusedPane PanelType - // Compact mode - compact bool - header header.Header - showingDetails bool - - sidebar sidebar.Sidebar - chat chat.MessageListCmp - editor editor.Editor - splash splash.Splash - canceling bool - - // This will force the compact mode even in big screens - // usually triggered by the user command - // this will also be set when the user config is set to compact mode + + // Layout state + compact bool forceCompact bool + focusedPane PanelType + + // Session + session session.Session + keyMap KeyMap + + // Components + header header.Header + sidebar sidebar.Sidebar + chat chat.MessageListCmp + editor editor.Editor + splash splash.Splash + + // Simple state flags + showingDetails bool + isCanceling bool + splashFullScreen bool } func New(app *app.App) ChatPage { return &chatPage{ - app: app, - state: ChatStateOnboarding, - - keyMap: DefaultKeyMap(), - + app: app, + keyMap: DefaultKeyMap(), header: header.New(app.LSPClients), sidebar: sidebar.New(app.History, app.LSPClients, false), chat: chat.New(app), @@ -120,19 +119,26 @@ func New(app *app.App) ChatPage { func (p *chatPage) Init() tea.Cmd { cfg := config.Get() - if config.HasInitialDataConfig() { - if b, _ := config.ProjectNeedsInitialization(); b { - p.state = ChatStateInitProject - } else { - p.state = ChatStateNewMessage - p.focusedPane = PanelTypeEditor - } - } - compact := cfg.Options.TUI.CompactMode p.compact = compact p.forceCompact = compact p.sidebar.SetCompactMode(p.compact) + + // Set splash state based on config + if !config.HasInitialDataConfig() { + // First-time setup: show model selection + p.splash.SetOnboarding(true) + p.splashFullScreen = true + } else if b, _ := config.ProjectNeedsInitialization(); b { + // Project needs CRUSH.md initialization + p.splash.SetProjectInit(true) + p.splashFullScreen = true + } else { + // Ready to chat: focus editor, splash in background + p.focusedPane = PanelTypeEditor + p.splashFullScreen = false + } + return tea.Batch( p.header.Init(), p.sidebar.Init(), @@ -148,7 +154,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: return p, p.SetSize(msg.Width, msg.Height) case CancelTimerExpiredMsg: - p.canceling = false + p.isCanceling = false return p, nil case chat.SendMsg: return p, p.sendMessage(msg.Text, msg.Attachments) @@ -166,7 +172,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return p, tea.Batch(p.SetSize(p.width, p.height), cmd) case pubsub.Event[session.Session]: - // this needs to go to header/sidebar u, cmd := p.header.Update(msg) p.header = u.(header.Header) cmds = append(cmds, cmd) @@ -196,32 +201,33 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case pubsub.Event[message.Message], anim.StepMsg, spinner.TickMsg: - // this needs to go to chat u, cmd := p.chat.Update(msg) p.chat = u.(chat.MessageListCmp) cmds = append(cmds, cmd) return p, tea.Batch(cmds...) case pubsub.Event[history.File], sidebar.SessionFilesMsg: - // this needs to go to sidebar u, cmd := p.sidebar.Update(msg) p.sidebar = u.(sidebar.Sidebar) cmds = append(cmds, cmd) return p, tea.Batch(cmds...) case commands.CommandRunCustomMsg: - // Check if the agent is busy before executing custom commands if p.app.CoderAgent.IsBusy() { return p, util.ReportWarn("Agent is busy, please wait before executing a command...") } - // Handle custom command execution cmd := p.sendMessage(msg.Content, nil) if cmd != nil { return p, cmd } case splash.OnboardingCompleteMsg: - p.state = ChatStateNewMessage + p.splashFullScreen = false + if b, _ := config.ProjectNeedsInitialization(); b { + p.splash.SetProjectInit(true) + p.splashFullScreen = true + return p, p.SetSize(p.width, p.height) + } err := p.app.InitCoderAgent() if err != nil { return p, util.ReportError(err) @@ -241,7 +247,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name) } case key.Matches(msg, p.keyMap.Tab): - if p.state == ChatStateOnboarding || p.state == ChatStateInitProject { + if p.session.ID == "" { u, cmd := p.splash.Update(msg) p.splash = u.(splash.Splash) return p, cmd @@ -255,7 +261,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, nil } - // Send the key press to the focused pane switch p.focusedPane { case PanelTypeChat: u, cmd := p.chat.Update(msg) @@ -277,22 +282,25 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *chatPage) View() tea.View { var chatView tea.View t := styles.CurrentTheme() - switch p.state { - case ChatStateOnboarding, ChatStateInitProject: - chatView = p.splash.View() - case ChatStateNewMessage: - editorView := p.editor.View() - chatView = tea.NewView( - lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Render( - p.splash.View().String(), + + if p.session.ID == "" { + splashView := p.splash.View() + // Full screen during onboarding or project initialization + if p.splashFullScreen { + chatView = splashView + } else { + // Show splash + editor for new message state + editorView := p.editor.View() + chatView = tea.NewView( + lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Render(splashView.String()), + editorView.String(), ), - editorView.String(), - ), - ) - chatView.SetCursor(editorView.Cursor()) - case ChatStateInSession: + ) + chatView.SetCursor(editorView.Cursor()) + } + } else { messagesView := p.chat.View() editorView := p.editor.View() if p.compact { @@ -322,8 +330,6 @@ func (p *chatPage) View() tea.View { ) chatView.SetCursor(editorView.Cursor()) } - default: - chatView = tea.NewView("Unknown chat state") } layers := []*lipgloss.Layer{ @@ -398,22 +404,22 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd { p.width = width p.height = height var cmds []tea.Cmd - switch p.state { - case ChatStateOnboarding, ChatStateInitProject: - // here we should just have the splash screen - cmds = append(cmds, p.splash.SetSize(width, height)) - case ChatStateNewMessage: - cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight)) - cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) - cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight)) - case ChatStateInSession: + + if p.session.ID == "" { + if p.splashFullScreen { + cmds = append(cmds, p.splash.SetSize(width, height)) + } else { + cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight)) + cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) + cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight)) + } + } else { if p.compact { cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight)) - // In compact mode, the sidebar is shown in the details section, the width needs to be adjusted for the padding and border - p.detailsWidth = width - 2 // because of position - cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-2, p.detailsHeight-2)) // adjust for border + p.detailsWidth = width - DetailsPositioning + cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders)) cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) - cmds = append(cmds, p.header.SetWidth(width-1)) + cmds = append(cmds, p.header.SetWidth(width-BorderWidth)) } else { cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight)) cmds = append(cmds, p.editor.SetSize(width, EditorHeight)) @@ -425,17 +431,13 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd { } func (p *chatPage) newSession() tea.Cmd { - if p.state != ChatStateInSession { - // Cannot start a new session if we are not in the session state + if p.session.ID == "" { return nil } - // blank session p.session = session.Session{} - p.state = ChatStateNewMessage p.focusedPane = PanelTypeEditor - p.canceling = false - // Reset the chat and editor components + p.isCanceling = false return tea.Batch( util.CmdHandler(chat.SessionClearedMsg{}), p.SetSize(p.width, p.height), @@ -449,11 +451,8 @@ func (p *chatPage) setSession(session session.Session) tea.Cmd { var cmds []tea.Cmd p.session = session - // We want to first resize the components - if p.state != ChatStateInSession { - p.state = ChatStateInSession - cmds = append(cmds, p.SetSize(p.width, p.height)) - } + + cmds = append(cmds, p.SetSize(p.width, p.height)) cmds = append(cmds, p.chat.SetSession(session)) cmds = append(cmds, p.sidebar.SetSession(session)) cmds = append(cmds, p.header.SetSession(session)) @@ -463,8 +462,7 @@ func (p *chatPage) setSession(session session.Session) tea.Cmd { } func (p *chatPage) changeFocus() { - if p.state != ChatStateInSession { - // Cannot change focus if we are not in the session state + if p.session.ID == "" { return } switch p.focusedPane { @@ -480,25 +478,22 @@ func (p *chatPage) changeFocus() { } func (p *chatPage) cancel() tea.Cmd { - if p.state != ChatStateInSession || !p.app.CoderAgent.IsBusy() { - // Cannot cancel if we are not in the session state + if p.session.ID == "" || !p.app.CoderAgent.IsBusy() { return nil } - // second press of cancel key will actually cancel the session - if p.canceling { - p.canceling = false + if p.isCanceling { + p.isCanceling = false p.app.CoderAgent.Cancel(p.session.ID) return nil } - p.canceling = true + p.isCanceling = true return cancelTimerCmd() } func (p *chatPage) showDetails() { - if p.state != ChatStateInSession || !p.compact { - // Cannot show details if we are not in the session state or if we are not in compact mode + if p.session.ID == "" || !p.compact { return } p.showingDetails = !p.showingDetails @@ -508,8 +503,7 @@ func (p *chatPage) showDetails() { func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd { session := p.session var cmds []tea.Cmd - if p.state != ChatStateInSession { - // branch new session + if p.session.ID == "" { newSession, err := p.app.Sessions.Create(context.Background(), "New Session") if err != nil { return util.ReportError(err) @@ -531,7 +525,7 @@ func (p *chatPage) Bindings() []key.Binding { } if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() { cancelBinding := p.keyMap.Cancel - if p.canceling { + if p.isCanceling { cancelBinding = key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "press again to cancel"), From 862baeb0525dc6861694e8f376b1cd4028c7f5ff Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 8 Jul 2025 17:39:29 +0200 Subject: [PATCH 12/38] docs: add targeted documentation for state management - Document Splash interface methods for clarity - Add comments explaining state initialization logic - Clarify when splash goes full screen vs shared layout - Focus on explaining 'why' without over-documenting 'what' --- internal/tui/components/chat/splash/splash.go | 111 ++++++++---------- 1 file changed, 49 insertions(+), 62 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index d06b006e60504c4517e8ef34a36eff9689ee1591..85da89d14108708ad75cf440720a91eca0df9583 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -25,6 +25,10 @@ type Splash interface { util.Model layout.Sizeable layout.Help + // SetOnboarding controls whether the splash shows model selection UI + SetOnboarding(bool) + // SetProjectInit controls whether the splash shows project initialization prompt + SetProjectInit(bool) } const ( @@ -32,14 +36,6 @@ const ( SplashScreenPaddingY = 1 // Padding Y for the splash screen ) -type SplashScreenState string - -const ( - SplashScreenStateOnboarding SplashScreenState = "onboarding" - SplashScreenStateInitialize SplashScreenState = "initialize" - SplashScreenStateReady SplashScreenState = "ready" -) - // OnboardingCompleteMsg is sent when onboarding is complete type OnboardingCompleteMsg struct{} @@ -47,10 +43,14 @@ type splashCmp struct { width, height int keyMap KeyMap logoRendered string - state SplashScreenState + + // State + isOnboarding bool + needsProjectInit bool + selectedNo bool + modelList *models.ModelListComponent cursorRow, cursorCol int - selectedNo bool // true if "No" button is selected in initialize state } func New() Splash { @@ -69,33 +69,21 @@ func New() Splash { inputStyle := t.S().Base.Padding(0, 1, 0, 1) modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave") return &splashCmp{ - width: 0, - height: 0, - keyMap: keyMap, - state: SplashScreenStateOnboarding, - logoRendered: "", - modelList: modelList, - selectedNo: false, + width: 0, + height: 0, + keyMap: keyMap, + logoRendered: "", + modelList: modelList, + selectedNo: false, } } -// GetSize implements SplashPage. -func (s *splashCmp) GetSize() (int, int) { - return s.width, s.height -} - -// Init implements SplashPage. -func (s *splashCmp) Init() tea.Cmd { - if config.HasInitialDataConfig() { - if b, _ := config.ProjectNeedsInitialization(); b { - s.state = SplashScreenStateInitialize - } else { - s.state = SplashScreenStateReady - } - } else { +func (s *splashCmp) SetOnboarding(onboarding bool) { + s.isOnboarding = onboarding + if onboarding { providers, err := config.Providers() if err != nil { - return util.ReportError(err) + return } filteredProviders := []provider.Provider{} simpleProviders := []string{ @@ -111,9 +99,21 @@ func (s *splashCmp) Init() tea.Cmd { } } s.modelList.SetProviders(filteredProviders) - return s.modelList.Init() } - return nil +} + +func (s *splashCmp) SetProjectInit(needsInit bool) { + s.needsProjectInit = needsInit +} + +// GetSize implements SplashPage. +func (s *splashCmp) GetSize() (int, int) { + return s.width, s.height +} + +// Init implements SplashPage. +func (s *splashCmp) Init() tea.Cmd { + return s.modelList.Init() } // SetSize implements SplashPage. @@ -137,42 +137,34 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch { case key.Matches(msg, s.keyMap.Select): - if s.state == SplashScreenStateOnboarding { + if s.isOnboarding { modelInx := s.modelList.SelectedIndex() items := s.modelList.Items() selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption) if s.isProviderConfigured(string(selectedItem.Provider.ID)) { cmd := s.setPreferredModel(selectedItem) - s.state = SplashScreenStateReady - if b, err := config.ProjectNeedsInitialization(); err != nil { - return s, tea.Batch(cmd, util.ReportError(err)) - } else if b { - s.state = SplashScreenStateInitialize - return s, cmd - } else { - s.state = SplashScreenStateReady - return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) - } + s.isOnboarding = false + return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) } - } else if s.state == SplashScreenStateInitialize { + } else if s.needsProjectInit { return s, s.initializeProject() } case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight): - if s.state == SplashScreenStateInitialize { + if s.needsProjectInit { s.selectedNo = !s.selectedNo return s, nil } case key.Matches(msg, s.keyMap.Yes): - if s.state == SplashScreenStateInitialize { + if s.needsProjectInit { return s, s.initializeProject() } case key.Matches(msg, s.keyMap.No): - if s.state == SplashScreenStateInitialize { - s.state = SplashScreenStateReady + if s.needsProjectInit { + s.needsProjectInit = false return s, util.CmdHandler(OnboardingCompleteMsg{}) } default: - if s.state == SplashScreenStateOnboarding { + if s.isOnboarding { u, cmd := s.modelList.Update(msg) s.modelList = u return s, cmd @@ -183,7 +175,7 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (s *splashCmp) initializeProject() tea.Cmd { - s.state = SplashScreenStateReady + s.needsProjectInit = false prompt := `Please analyze this codebase and create a CRUSH.md file containing: 1. Build/lint/test commands - especially for running a single test 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. @@ -193,7 +185,6 @@ If there's already a CRUSH.md, improve it. If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them. Add the .crush directory to the .gitignore file if it's not already there.` - // Mark the project as initialized if err := config.MarkProjectInitialized(); err != nil { return util.ReportError(err) } @@ -293,9 +284,7 @@ func (s *splashCmp) View() tea.View { var cursor *tea.Cursor var content string - switch s.state { - case SplashScreenStateOnboarding: - // Show logo and model selector + if s.isOnboarding { remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) modelListView := s.modelList.View() cursor = s.moveCursor(modelListView.Cursor()) @@ -312,7 +301,7 @@ func (s *splashCmp) View() tea.View { s.logoRendered, modelSelector, ) - case SplashScreenStateInitialize: + } else if s.needsProjectInit { t := styles.CurrentTheme() titleStyle := t.S().Base.Foreground(t.FgBase) @@ -361,9 +350,7 @@ func (s *splashCmp) View() tea.View { s.logoRendered, initContent, ) - - default: - // Show just the logo for other states + } else { content = s.logoRendered } @@ -407,13 +394,13 @@ func (m *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { // Bindings implements SplashPage. func (s *splashCmp) Bindings() []key.Binding { - if s.state == SplashScreenStateOnboarding { + if s.isOnboarding { return []key.Binding{ s.keyMap.Select, s.keyMap.Next, s.keyMap.Previous, } - } else if s.state == SplashScreenStateInitialize { + } else if s.needsProjectInit { return []key.Binding{ s.keyMap.Select, s.keyMap.Yes, From 134ee540a4656a2bb2100286f49363a7614f2913 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 8 Jul 2025 18:17:40 +0200 Subject: [PATCH 13/38] chore: allow custom providers without api key - allow users to setup custom providers without api keys for local providers - use the name of the provider in the model list --- internal/config/config.go | 2 ++ internal/config/load.go | 12 ++++++------ internal/config/load_test.go | 9 ++++++--- internal/tui/components/dialogs/models/list.go | 2 +- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index bbf619e96ae8f6a9d96c8bcb29cbb92c717326b4..6ae26bfb0892623fd37c92f6226d7ac8b2db418f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,6 +60,8 @@ type SelectedModel struct { type ProviderConfig struct { // The provider's id. ID string `json:"id,omitempty"` + // The provider's name, used for display purposes. + Name string `json:"name,omitempty"` // The provider's API endpoint. BaseURL string `json:"base_url,omitempty"` // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai. diff --git a/internal/config/load.go b/internal/config/load.go index 96006c4415237cf802c10b08ab401e42bdd73fc6..8722a05260f94994064a69493e448154c9b57847 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -162,6 +162,7 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn } prepared := ProviderConfig{ ID: string(p.ID), + Name: p.Name, BaseURL: p.APIEndpoint, APIKey: p.APIKey, Type: p.Type, @@ -218,6 +219,9 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn // Make sure the provider ID is set providerConfig.ID = id + if providerConfig.Name == "" { + providerConfig.Name = id // Use ID as name if not set + } // default to OpenAI if not set if providerConfig.Type == "" { providerConfig.Type = provider.TypeOpenAI @@ -229,9 +233,7 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn continue } if providerConfig.APIKey == "" { - slog.Warn("Skipping custom provider due to missing API key", "provider", id) - delete(cfg.Providers, id) - continue + slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id) } if providerConfig.BaseURL == "" { slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id) @@ -251,9 +253,7 @@ func (cfg *Config) configureProviders(env env.Env, resolver VariableResolver, kn apiKey, err := resolver.ResolveValue(providerConfig.APIKey) if apiKey == "" || err != nil { - slog.Warn("Skipping custom provider due to missing API key", "provider", id, "error", err) - delete(cfg.Providers, id) - continue + slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id) } baseURL, err := resolver.ResolveValue(providerConfig.BaseURL) if baseURL == "" || err != nil { diff --git a/internal/config/load_test.go b/internal/config/load_test.go index ba1a5a7f1c52d5cd123750f93032e56939ce2cd5..0688317398108fad298d58ab9ca1db5fc7002df0 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -483,7 +483,7 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) { } func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { - t.Run("custom provider with missing API key is removed", func(t *testing.T) { + t.Run("custom provider with missing API key is allowed, but not known providers", func(t *testing.T) { cfg := &Config{ Providers: map[string]ProviderConfig{ "custom": { @@ -492,6 +492,9 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { ID: "test-model", }}, }, + "openai": { + APIKey: "$MISSING", + }, }, } cfg.setDefaults("/tmp") @@ -501,9 +504,9 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { err := cfg.configureProviders(env, resolver, []provider.Provider{}) assert.NoError(t, err) - assert.Len(t, cfg.Providers, 0) + assert.Len(t, cfg.Providers, 1) _, exists := cfg.Providers["custom"] - assert.False(t, exists) + assert.True(t, exists) }) t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) { diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index 4a4eeb300dfb97c1db2145fcec24a81cda2fd124..0c3ddeec39c30b85c2ad4d820517ab446de7a52b 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -104,7 +104,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { if !slices.Contains(knownProviders, provider.InferenceProvider(providerID)) { // Convert config provider to provider.Provider format configProvider := provider.Provider{ - Name: string(providerID), // Use provider ID as name for unknown providers + Name: providerConfig.Name, ID: provider.InferenceProvider(providerID), Models: make([]provider.Model, len(providerConfig.Models)), } From ae410209825c37d7bace1509949619334eb7258b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 9 Jul 2025 11:20:58 +0200 Subject: [PATCH 14/38] chore: lint --- internal/app/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/app.go b/internal/app/app.go index b47fe1c8e4ca6cb37baa7d638126f16295421e72..a9a503641cd473e7b53c1318c63ba2391b510b9f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -235,7 +235,7 @@ func (app *App) Subscribe(program *tea.Program) { slog.Info("TUI subscription panic - attempting graceful shutdown") program.Quit() }) - + app.tuiWG.Add(1) tuiCtx, tuiCancel := context.WithCancel(app.globalCtx) app.cleanupFuncs = append(app.cleanupFuncs, func() { From 99747b82f6054a97f8bed88e7afae302c4b9a736 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 9 Jul 2025 14:03:34 +0200 Subject: [PATCH 15/38] chore: add api key step --- internal/config/config.go | 52 +++++++- internal/config/load.go | 1 + internal/tui/components/chat/splash/keys.go | 7 +- internal/tui/components/chat/splash/splash.go | 126 ++++++++++++++++-- .../tui/components/dialogs/models/apikey.go | 20 ++- internal/tui/page/chat/chat.go | 26 +++- 6 files changed, 204 insertions(+), 28 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 3e37b376e48ae0e6fae001c75894de3bfb6cb5c0..5c978106bc49f7b5956ea1d1d6e4d994f53eae58 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -211,8 +211,9 @@ type Config struct { // TODO: most likely remove this concept when I come back to it Agents map[string]Agent `json:"-"` // TODO: find a better way to do this this should probably not be part of the config - resolver VariableResolver - dataConfigDir string `json:"-"` + resolver VariableResolver + dataConfigDir string `json:"-"` + knownProviders []provider.Provider `json:"-"` } func (c *Config) WorkingDir() string { @@ -323,3 +324,50 @@ func (c *Config) SetConfigField(key string, value any) error { } return nil } + +func (c *Config) SetProviderAPIKey(providerID, apiKey string) error { + // First save to the config file + err := c.SetConfigField("providers."+providerID+".api_key", apiKey) + if err != nil { + return fmt.Errorf("failed to save API key to config file: %w", err) + } + + if c.Providers == nil { + c.Providers = make(map[string]ProviderConfig) + } + + providerConfig, exists := c.Providers[providerID] + if exists { + providerConfig.APIKey = apiKey + c.Providers[providerID] = providerConfig + return nil + } + + var foundProvider *provider.Provider + for _, p := range c.knownProviders { + if string(p.ID) == providerID { + foundProvider = &p + break + } + } + + if foundProvider != nil { + // Create new provider config based on known provider + providerConfig = ProviderConfig{ + ID: providerID, + Name: foundProvider.Name, + BaseURL: foundProvider.APIEndpoint, + Type: foundProvider.Type, + APIKey: apiKey, + Disable: false, + ExtraHeaders: make(map[string]string), + ExtraParams: make(map[string]string), + Models: foundProvider.Models, + } + } else { + return fmt.Errorf("provider with ID %s not found in known providers", providerID) + } + // Store the updated provider config + c.Providers[providerID] = providerConfig + return nil +} diff --git a/internal/config/load.go b/internal/config/load.go index e00ab2e9814dd7f3e3e9b2af6b2f489e04506c43..cc9191fcda5ebfb875fefbac899b21c3597ef0e2 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -65,6 +65,7 @@ func Load(workingDir string, debug bool) (*Config, error) { if err != nil || len(providers) == 0 { return nil, fmt.Errorf("failed to load providers: %w", err) } + cfg.knownProviders = providers env := env.New() // Configure providers diff --git a/internal/tui/components/chat/splash/keys.go b/internal/tui/components/chat/splash/keys.go index 90b1954d9b3aa93f01bcafe9001276cc941748a6..9cf2e3124daa87b0fc62c2ea404fb1c6c86ec649 100644 --- a/internal/tui/components/chat/splash/keys.go +++ b/internal/tui/components/chat/splash/keys.go @@ -11,7 +11,8 @@ type KeyMap struct { Yes, No, Tab, - LeftRight key.Binding + LeftRight, + Back key.Binding } func DefaultKeyMap() KeyMap { @@ -44,5 +45,9 @@ func DefaultKeyMap() KeyMap { key.WithKeys("left", "right"), key.WithHelp("←/→", "switch"), ), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), } } diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index f6f24e3f0689cffd0cf50d7903bdf15dc7e1c48a..079a2246c07f583eb3d5ac2dc5f3cfe91bfb5863 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -2,6 +2,7 @@ package splash import ( "fmt" + "log/slog" "slices" "github.com/charmbracelet/bubbles/v2/key" @@ -48,10 +49,12 @@ type splashCmp struct { // State isOnboarding bool needsProjectInit bool + needsAPIKey bool selectedNo bool - modelList *models.ModelListComponent - cursorRow, cursorCol int + modelList *models.ModelListComponent + apiKeyInput *models.APIKeyInput + selectedModel *models.ModelOption } func New() Splash { @@ -69,12 +72,15 @@ func New() Splash { t := styles.CurrentTheme() inputStyle := t.S().Base.Padding(0, 1, 0, 1) modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave") + apiKeyInput := models.NewAPIKeyInput() + return &splashCmp{ width: 0, height: 0, keyMap: keyMap, logoRendered: "", modelList: modelList, + apiKeyInput: apiKeyInput, selectedNo: false, } } @@ -114,7 +120,7 @@ func (s *splashCmp) GetSize() (int, int) { // Init implements SplashPage. func (s *splashCmp) Init() tea.Cmd { - return s.modelList.Init() + return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init()) } // SetSize implements SplashPage. @@ -125,8 +131,6 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd { listHeigh := min(40, height-(SplashScreenPaddingY*2)-lipgloss.Height(s.logoRendered)-2) // -1 for the title listWidth := min(60, width-(SplashScreenPaddingX*2)) - // Calculate the cursor position based on the height and logo size - s.cursorRow = height - listHeigh return s.modelList.SetSize(listWidth, listHeigh) } @@ -137,8 +141,16 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, s.SetSize(msg.Width, msg.Height) case tea.KeyPressMsg: switch { + case key.Matches(msg, s.keyMap.Back): + slog.Info("Back key pressed in splash screen") + if s.needsAPIKey { + // Go back to model selection + s.needsAPIKey = false + s.selectedModel = nil + return s, nil + } case key.Matches(msg, s.keyMap.Select): - if s.isOnboarding { + if s.isOnboarding && !s.needsAPIKey { modelInx := s.modelList.SelectedIndex() items := s.modelList.Items() selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption) @@ -146,6 +158,18 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := s.setPreferredModel(selectedItem) s.isOnboarding = false return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) + } else { + // Provider not configured, show API key input + s.needsAPIKey = true + s.selectedModel = &selectedItem + s.apiKeyInput.SetProviderName(selectedItem.Provider.Name) + return s, nil + } + } else if s.needsAPIKey { + // Handle API key submission + apiKey := s.apiKeyInput.Value() + if apiKey != "" { + return s, s.saveAPIKeyAndContinue(apiKey) } } else if s.needsProjectInit { return s, s.initializeProject() @@ -165,16 +189,50 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, util.CmdHandler(OnboardingCompleteMsg{}) } default: - if s.isOnboarding { + if s.needsAPIKey { + u, cmd := s.apiKeyInput.Update(msg) + s.apiKeyInput = u.(*models.APIKeyInput) + return s, cmd + } else if s.isOnboarding { u, cmd := s.modelList.Update(msg) s.modelList = u return s, cmd } } + case tea.PasteMsg: + if s.needsAPIKey { + u, cmd := s.apiKeyInput.Update(msg) + s.apiKeyInput = u.(*models.APIKeyInput) + return s, cmd + } else if s.isOnboarding { + var cmd tea.Cmd + s.modelList, cmd = s.modelList.Update(msg) + return s, cmd + } } return s, nil } +func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd { + if s.selectedModel == nil { + return util.ReportError(fmt.Errorf("no model selected")) + } + + cfg := config.Get() + err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey) + if err != nil { + return util.ReportError(fmt.Errorf("failed to save API key: %w", err)) + } + + // Reset API key state and continue with model selection + s.needsAPIKey = false + cmd := s.setPreferredModel(*s.selectedModel) + s.isOnboarding = false + s.selectedModel = nil + + return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) +} + func (s *splashCmp) initializeProject() tea.Cmd { s.needsProjectInit = false prompt := `Please analyze this codebase and create a CRUSH.md file containing: @@ -283,7 +341,21 @@ func (s *splashCmp) View() string { t := styles.CurrentTheme() var content string - if s.isOnboarding { + if s.needsAPIKey { + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + apiKeyView := s.apiKeyInput.View() + apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( + lipgloss.JoinVertical( + lipgloss.Left, + apiKeyView, + ), + ) + content = lipgloss.JoinVertical( + lipgloss.Left, + s.logoRendered, + apiKeySelector, + ) + } else if s.isOnboarding { remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) modelListView := s.modelList.View() modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( @@ -363,7 +435,12 @@ func (s *splashCmp) View() string { } func (s *splashCmp) Cursor() *tea.Cursor { - if s.isOnboarding { + if s.needsAPIKey { + cursor := s.apiKeyInput.Cursor() + if cursor != nil { + return s.moveCursor(cursor) + } + } else if s.isOnboarding { cursor := s.modelList.Cursor() if cursor != nil { return s.moveCursor(cursor) @@ -391,15 +468,38 @@ func (m *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { if cursor == nil { return nil } - offset := m.cursorRow - cursor.Y += offset - cursor.X = cursor.X + 3 // 3 for padding + + // Calculate the correct Y offset based on current state + logoHeight := lipgloss.Height(m.logoRendered) + baseOffset := logoHeight + SplashScreenPaddingY + + if m.needsAPIKey { + // For API key input, position at the bottom of the remaining space + remainingHeight := m.height - logoHeight - (SplashScreenPaddingY * 2) + offset := baseOffset + remainingHeight - lipgloss.Height(m.apiKeyInput.View()) + cursor.Y += offset + // API key input already includes prompt in its cursor positioning + cursor.X = cursor.X + SplashScreenPaddingX + } else if m.isOnboarding { + // For model list, use the original calculation + listHeight := min(40, m.height-(SplashScreenPaddingY*2)-logoHeight-2) + offset := m.height - listHeight + cursor.Y += offset + // Model list doesn't have a prompt, so add padding + space for list styling + cursor.X = cursor.X + SplashScreenPaddingX + 1 + } + return cursor } // Bindings implements SplashPage. func (s *splashCmp) Bindings() []key.Binding { - if s.isOnboarding { + if s.needsAPIKey { + return []key.Binding{ + s.keyMap.Select, + s.keyMap.Back, + } + } else if s.isOnboarding { return []key.Binding{ s.keyMap.Select, s.keyMap.Next, diff --git a/internal/tui/components/dialogs/models/apikey.go b/internal/tui/components/dialogs/models/apikey.go index 3ff0dd22f432e98b087f83de7ec5d1a6467b73b5..d5aa034d133d2e4d5cbe676aed0fb7e1edde487c 100644 --- a/internal/tui/components/dialogs/models/apikey.go +++ b/internal/tui/components/dialogs/models/apikey.go @@ -12,9 +12,10 @@ import ( ) type APIKeyInput struct { - input textinput.Model - width int - height int + input textinput.Model + width int + height int + providerName string } func NewAPIKeyInput() *APIKeyInput { @@ -29,11 +30,16 @@ func NewAPIKeyInput() *APIKeyInput { ti.Focus() return &APIKeyInput{ - input: ti, - width: 60, + input: ti, + width: 60, + providerName: "Provider", } } +func (a *APIKeyInput) SetProviderName(name string) { + a.providerName = name +} + func (a *APIKeyInput) Init() tea.Cmd { return textinput.Blink } @@ -54,9 +60,9 @@ func (a *APIKeyInput) View() string { t := styles.CurrentTheme() title := t.S().Base. - Foreground(t.Secondary). + Foreground(t.Primary). Bold(true). - Render("Enter your Anthropic API Key") + Render(fmt.Sprintf("Enter your %s API Key", a.providerName)) inputView := a.input.View() diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index b0d5fce77caf8fc0398aa0b7feb4ede554ca1df4..4362ff1ea962ec76939ada4bd3fd330b521739b9 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -256,7 +256,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.changeFocus() return p, nil case key.Matches(msg, p.keyMap.Cancel): - return p, p.cancel() + if p.session.ID != "" && p.app.CoderAgent.IsBusy() { + return p, p.cancel() + } case key.Matches(msg, p.keyMap.Details): p.showDetails() return p, nil @@ -276,6 +278,24 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.splash = u.(splash.Splash) cmds = append(cmds, cmd) } + case tea.PasteMsg: + switch p.focusedPane { + case PanelTypeEditor: + u, cmd := p.editor.Update(msg) + p.editor = u.(editor.Editor) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + case PanelTypeChat: + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + case PanelTypeSplash: + u, cmd := p.splash.Update(msg) + p.splash = u.(splash.Splash) + cmds = append(cmds, cmd) + return p, tea.Batch(cmds...) + } } return p, tea.Batch(cmds...) } @@ -479,10 +499,6 @@ func (p *chatPage) changeFocus() { } func (p *chatPage) cancel() tea.Cmd { - if p.session.ID == "" || !p.app.CoderAgent.IsBusy() { - return nil - } - if p.isCanceling { p.isCanceling = false p.app.CoderAgent.Cancel(p.session.ID) From 94a4973ab96d089a268705bc9e77b92e07bfc363 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 9 Jul 2025 14:53:52 +0200 Subject: [PATCH 16/38] chore: remove unused fzf --- internal/fsext/fileutil.go | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/internal/fsext/fileutil.go b/internal/fsext/fileutil.go index c3678041de4239cf66247ebbd9cb084cb8eb6b8a..b0829f6226734966a4ec731f56ba395607ec3b75 100644 --- a/internal/fsext/fileutil.go +++ b/internal/fsext/fileutil.go @@ -17,10 +17,7 @@ import ( ignore "github.com/sabhiram/go-gitignore" ) -var ( - rgPath string - fzfPath string -) +var rgPath string func init() { var err error @@ -30,12 +27,6 @@ func init() { slog.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.") } } - fzfPath, err = exec.LookPath("fzf") - if err != nil { - if log.Initialized() { - slog.Warn("FZF not found in $PATH. Some features might be limited or slower.") - } - } } func GetRgCmd(globPattern string) *exec.Cmd { From b19276bf0d045da65fa9615ab85a0708e5a6147c Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 9 Jul 2025 14:58:23 +0200 Subject: [PATCH 17/38] chore: small message change --- internal/fsext/fileutil.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/fsext/fileutil.go b/internal/fsext/fileutil.go index b0829f6226734966a4ec731f56ba395607ec3b75..462dcc6761f261a5be02658317884eb64fb07ebc 100644 --- a/internal/fsext/fileutil.go +++ b/internal/fsext/fileutil.go @@ -24,7 +24,7 @@ func init() { rgPath, err = exec.LookPath("rg") if err != nil { if log.Initialized() { - slog.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.") + slog.Warn("Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower.") } } } From c74815335dee51eb7cde09c4f37d9baf339f16f4 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 11:57:04 +0200 Subject: [PATCH 18/38] chore: add info to the splash screen --- internal/tui/components/chat/splash/splash.go | 134 ++++++++++++++++-- 1 file changed, 122 insertions(+), 12 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 079a2246c07f583eb3d5ac2dc5f3cfe91bfb5863..2a70f17d7388f35f8859cd64fd49fdf3cf605054 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -2,8 +2,9 @@ package splash import ( "fmt" - "log/slog" + "os" "slices" + "strings" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" @@ -142,7 +143,6 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch { case key.Matches(msg, s.keyMap.Back): - slog.Info("Back key pressed in splash screen") if s.needsAPIKey { // Go back to model selection s.needsAPIKey = false @@ -339,10 +339,10 @@ func (s *splashCmp) isProviderConfigured(providerID string) bool { func (s *splashCmp) View() string { t := styles.CurrentTheme() - var content string if s.needsAPIKey { - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + infoSection := s.infoSection() + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - lipgloss.Height(infoSection) apiKeyView := s.apiKeyInput.View() apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( lipgloss.JoinVertical( @@ -353,10 +353,12 @@ func (s *splashCmp) View() string { content = lipgloss.JoinVertical( lipgloss.Left, s.logoRendered, + infoSection, apiKeySelector, ) } else if s.isOnboarding { - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + infoSection := s.infoSection() + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - lipgloss.Height(infoSection) modelListView := s.modelList.View() modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( lipgloss.JoinVertical( @@ -369,11 +371,10 @@ func (s *splashCmp) View() string { content = lipgloss.JoinVertical( lipgloss.Left, s.logoRendered, + infoSection, modelSelector, ) } else if s.needsProjectInit { - t := styles.CurrentTheme() - titleStyle := t.S().Base.Foreground(t.FgBase) bodyStyle := t.S().Base.Foreground(t.FgMuted) shortcutStyle := t.S().Base.Foreground(t.Success) @@ -403,8 +404,9 @@ func (s *splashCmp) View() string { }) buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton) + infoSection := s.infoSection() - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - lipgloss.Height(infoSection) initContent := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( lipgloss.JoinVertical( @@ -418,10 +420,15 @@ func (s *splashCmp) View() string { content = lipgloss.JoinVertical( lipgloss.Left, s.logoRendered, + infoSection, initContent, ) } else { - content = s.logoRendered + parts := []string{ + s.logoRendered, + s.infoSection(), + } + content = lipgloss.JoinVertical(lipgloss.Left, parts...) } return t.S().Base. @@ -451,6 +458,16 @@ func (s *splashCmp) Cursor() *tea.Cursor { return nil } +func (s *splashCmp) infoSection() string { + return lipgloss.JoinVertical( + lipgloss.Left, + s.cwd(), + "", + lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()), + "", + ) +} + func (s *splashCmp) logoBlock() string { t := styles.CurrentTheme() const padding = 2 @@ -471,8 +488,8 @@ func (m *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { // Calculate the correct Y offset based on current state logoHeight := lipgloss.Height(m.logoRendered) - baseOffset := logoHeight + SplashScreenPaddingY - + infoSectionHeight := lipgloss.Height(m.infoSection()) + baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight if m.needsAPIKey { // For API key input, position at the bottom of the remaining space remainingHeight := m.height - logoHeight - (SplashScreenPaddingY * 2) @@ -482,7 +499,7 @@ func (m *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { cursor.X = cursor.X + SplashScreenPaddingX } else if m.isOnboarding { // For model list, use the original calculation - listHeight := min(40, m.height-(SplashScreenPaddingY*2)-logoHeight-2) + listHeight := min(40, m.height-(SplashScreenPaddingY*2)-logoHeight-1-infoSectionHeight) offset := m.height - listHeight cursor.Y += offset // Model list doesn't have a prompt, so add padding + space for list styling @@ -516,3 +533,96 @@ func (s *splashCmp) Bindings() []key.Binding { } return []key.Binding{} } + +func (s *splashCmp) getMaxInfoWidth() int { + return min(s.width-(SplashScreenPaddingX*2), 40) +} + +func (s *splashCmp) cwd() string { + cwd := config.Get().WorkingDir() + t := styles.CurrentTheme() + homeDir, err := os.UserHomeDir() + if err == nil && cwd != homeDir { + cwd = strings.ReplaceAll(cwd, homeDir, "~") + } + maxWidth := s.getMaxInfoWidth() + return t.S().Muted.Width(maxWidth).Render(cwd) +} + +func LSPList(maxWidth int) []string { + t := styles.CurrentTheme() + lspList := []string{} + lsp := config.Get().LSP.Sorted() + if len(lsp) == 0 { + return []string{t.S().Base.Foreground(t.Border).Render("None")} + } + for _, l := range lsp { + iconColor := t.Success + if l.LSP.Disabled { + iconColor = t.FgMuted + } + lspList = append(lspList, + core.Status( + core.StatusOpts{ + IconColor: iconColor, + Title: l.Name, + Description: l.LSP.Command, + }, + maxWidth, + ), + ) + } + return lspList +} + +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)...) + return t.S().Base.Width(maxWidth).PaddingRight(1).Render( + lipgloss.JoinVertical( + lipgloss.Left, + lspList..., + ), + ) +} + +func MCPList(maxWidth int) []string { + t := styles.CurrentTheme() + mcpList := []string{} + mcps := config.Get().MCP.Sorted() + if len(mcps) == 0 { + return []string{t.S().Base.Foreground(t.Border).Render("None")} + } + for _, l := range mcps { + iconColor := t.Success + if l.MCP.Disabled { + iconColor = t.FgMuted + } + mcpList = append(mcpList, + core.Status( + core.StatusOpts{ + IconColor: iconColor, + Title: l.Name, + Description: l.MCP.Command, + }, + maxWidth, + ), + ) + } + return mcpList +} + +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)...) + return t.S().Base.Width(maxWidth).PaddingRight(1).Render( + lipgloss.JoinVertical( + lipgloss.Left, + mcpList..., + ), + ) +} From 1f16850fba6465bcc336a1309eedc076ac493b3b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 13:20:18 +0200 Subject: [PATCH 19/38] chore: improve help --- internal/tui/components/chat/editor/editor.go | 3 - internal/tui/components/chat/editor/keys.go | 9 +- internal/tui/components/chat/splash/splash.go | 13 +- .../components/core/{helpers.go => core.go} | 28 ++ .../tui/components/core/layout/container.go | 263 ------------ internal/tui/components/core/layout/layout.go | 7 +- internal/tui/components/core/layout/split.go | 375 ------------------ internal/tui/components/core/list/keys.go | 11 +- internal/tui/components/core/list/list.go | 4 + internal/tui/components/core/status/status.go | 3 +- internal/tui/keys.go | 63 +-- internal/tui/page/chat/chat.go | 240 ++++++++++- internal/tui/tui.go | 10 +- 13 files changed, 301 insertions(+), 728 deletions(-) rename internal/tui/components/core/{helpers.go => core.go} (88%) delete mode 100644 internal/tui/components/core/layout/container.go delete mode 100644 internal/tui/components/core/layout/split.go diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 038249277ffa572755de160f0d404525def803b7..1c2989683b8fb8c8cc77fbaac5c49ec8248d91e4 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -157,9 +157,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { - case tea.KeyboardEnhancementsMsg: - m.keyMap.keyboard = msg - return m, nil case filepicker.FilePickedMsg: if len(m.attachments) >= maxAttachments { return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments)) diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go index aa2ba1ee44ce7fe9928e7e812acea3898a7496e5..d0243fd3dae2fc0d13942833566510ad6a7546eb 100644 --- a/internal/tui/components/chat/editor/keys.go +++ b/internal/tui/components/chat/editor/keys.go @@ -2,7 +2,6 @@ package editor import ( "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" ) type EditorKeyMap struct { @@ -10,8 +9,6 @@ type EditorKeyMap struct { SendMessage key.Binding OpenEditor key.Binding Newline key.Binding - - keyboard tea.KeyboardEnhancementsMsg } func DefaultEditorKeyMap() EditorKeyMap { @@ -40,15 +37,11 @@ func DefaultEditorKeyMap() EditorKeyMap { // KeyBindings implements layout.KeyMapProvider func (k EditorKeyMap) KeyBindings() []key.Binding { - newline := k.Newline - if k.keyboard.SupportsKeyDisambiguation() { - newline.SetHelp("shift+enter", newline.Help().Desc) - } return []key.Binding{ k.AddFile, k.SendMessage, k.OpenEditor, - newline, + k.Newline, } } diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 2a70f17d7388f35f8859cd64fd49fdf3cf605054..66c1c596fd762fccaacdf23541a4ed6b2a05d035 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -32,6 +32,9 @@ type Splash interface { SetOnboarding(bool) // SetProjectInit controls whether the splash shows project initialization prompt SetProjectInit(bool) + + // Showing API key input + IsShowingAPIKey() bool } const ( @@ -126,9 +129,11 @@ func (s *splashCmp) Init() tea.Cmd { // SetSize implements SplashPage. func (s *splashCmp) SetSize(width int, height int) tea.Cmd { - s.width = width s.height = height - s.logoRendered = s.logoBlock() + if width != s.width { + s.width = width + s.logoRendered = s.logoBlock() + } listHeigh := min(40, height-(SplashScreenPaddingY*2)-lipgloss.Height(s.logoRendered)-2) // -1 for the title listWidth := min(60, width-(SplashScreenPaddingX*2)) @@ -626,3 +631,7 @@ func (s *splashCmp) mcpBlock() string { ), ) } + +func (s *splashCmp) IsShowingAPIKey() bool { + return s.needsAPIKey +} diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/core.go similarity index 88% rename from internal/tui/components/core/helpers.go rename to internal/tui/components/core/core.go index 659ffd88c6b72b60933f9a19e3712093376a29bf..f5605db3927a9c1e55bd5706e2059c1ba9006106 100644 --- a/internal/tui/components/core/helpers.go +++ b/internal/tui/components/core/core.go @@ -5,12 +5,40 @@ import ( "strings" "github.com/alecthomas/chroma/v2" + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/crush/internal/tui/exp/diffview" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) +type KeyMapHelp interface { + Help() help.KeyMap +} + +type simpleHelp struct { + shortList []key.Binding + fullList [][]key.Binding +} + +func NewSimpleHelp(shortList []key.Binding, fullList [][]key.Binding) help.KeyMap { + return &simpleHelp{ + shortList: shortList, + fullList: fullList, + } +} + +// FullHelp implements help.KeyMap. +func (s *simpleHelp) FullHelp() [][]key.Binding { + return s.fullList +} + +// ShortHelp implements help.KeyMap. +func (s *simpleHelp) ShortHelp() []key.Binding { + return s.shortList +} + func Section(text string, width int) string { t := styles.CurrentTheme() char := "─" diff --git a/internal/tui/components/core/layout/container.go b/internal/tui/components/core/layout/container.go deleted file mode 100644 index 9940a320e8c3a2733c8a543e09d5c25b68a103d1..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/layout/container.go +++ /dev/null @@ -1,263 +0,0 @@ -package layout - -import ( - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" -) - -type Container interface { - util.Model - Sizeable - Help - Positional - Focusable -} -type container struct { - width int - height int - isFocused bool - - x, y int - - content util.Model - - // Style options - paddingTop int - paddingRight int - paddingBottom int - paddingLeft int - - borderTop bool - borderRight bool - borderBottom bool - borderLeft bool - borderStyle lipgloss.Border -} - -type ContainerOption func(*container) - -func NewContainer(content util.Model, options ...ContainerOption) Container { - c := &container{ - content: content, - borderStyle: lipgloss.NormalBorder(), - } - - for _, option := range options { - option(c) - } - - return c -} - -func (c *container) Init() tea.Cmd { - return c.content.Init() -} - -func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - if c.IsFocused() { - u, cmd := c.content.Update(msg) - c.content = u.(util.Model) - return c, cmd - } - return c, nil - default: - u, cmd := c.content.Update(msg) - c.content = u.(util.Model) - return c, cmd - } -} - -func (c *container) Cursor() *tea.Cursor { - if cursor, ok := c.content.(util.Cursor); ok { - return cursor.Cursor() - } - return nil -} - -func (c *container) View() string { - t := styles.CurrentTheme() - width := c.width - height := c.height - - style := t.S().Base - - // Apply border if any side is enabled - if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft { - // Adjust width and height for borders - if c.borderTop { - height-- - } - if c.borderBottom { - height-- - } - if c.borderLeft { - width-- - } - if c.borderRight { - width-- - } - style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft) - style = style.BorderBackground(t.BgBase).BorderForeground(t.Border) - } - style = style. - Width(width). - Height(height). - PaddingTop(c.paddingTop). - PaddingRight(c.paddingRight). - PaddingBottom(c.paddingBottom). - PaddingLeft(c.paddingLeft) - - contentView := c.content.View() - return style.Render(contentView) -} - -func (c *container) SetSize(width, height int) tea.Cmd { - c.width = width - c.height = height - - // If the content implements Sizeable, adjust its size to account for padding and borders - if sizeable, ok := c.content.(Sizeable); ok { - // Calculate horizontal space taken by padding and borders - horizontalSpace := c.paddingLeft + c.paddingRight - if c.borderLeft { - horizontalSpace++ - } - if c.borderRight { - horizontalSpace++ - } - - // Calculate vertical space taken by padding and borders - verticalSpace := c.paddingTop + c.paddingBottom - if c.borderTop { - verticalSpace++ - } - if c.borderBottom { - verticalSpace++ - } - - // Set content size with adjusted dimensions - contentWidth := max(0, width-horizontalSpace) - contentHeight := max(0, height-verticalSpace) - return sizeable.SetSize(contentWidth, contentHeight) - } - return nil -} - -func (c *container) GetSize() (int, int) { - return c.width, c.height -} - -func (c *container) SetPosition(x, y int) tea.Cmd { - c.x = x - c.y = y - if positionable, ok := c.content.(Positional); ok { - return positionable.SetPosition(x, y) - } - return nil -} - -func (c *container) Bindings() []key.Binding { - if b, ok := c.content.(Help); ok { - return b.Bindings() - } - return nil -} - -// Blur implements Container. -func (c *container) Blur() tea.Cmd { - c.isFocused = false - if focusable, ok := c.content.(Focusable); ok { - return focusable.Blur() - } - return nil -} - -// Focus implements Container. -func (c *container) Focus() tea.Cmd { - c.isFocused = true - if focusable, ok := c.content.(Focusable); ok { - return focusable.Focus() - } - return nil -} - -// IsFocused implements Container. -func (c *container) IsFocused() bool { - isFocused := c.isFocused - if focusable, ok := c.content.(Focusable); ok { - isFocused = isFocused || focusable.IsFocused() - } - return isFocused -} - -// Padding options -func WithPadding(top, right, bottom, left int) ContainerOption { - return func(c *container) { - c.paddingTop = top - c.paddingRight = right - c.paddingBottom = bottom - c.paddingLeft = left - } -} - -func WithPaddingAll(padding int) ContainerOption { - return WithPadding(padding, padding, padding, padding) -} - -func WithPaddingHorizontal(padding int) ContainerOption { - return func(c *container) { - c.paddingLeft = padding - c.paddingRight = padding - } -} - -func WithPaddingVertical(padding int) ContainerOption { - return func(c *container) { - c.paddingTop = padding - c.paddingBottom = padding - } -} - -func WithBorder(top, right, bottom, left bool) ContainerOption { - return func(c *container) { - c.borderTop = top - c.borderRight = right - c.borderBottom = bottom - c.borderLeft = left - } -} - -func WithBorderAll() ContainerOption { - return WithBorder(true, true, true, true) -} - -func WithBorderHorizontal() ContainerOption { - return WithBorder(true, false, true, false) -} - -func WithBorderVertical() ContainerOption { - return WithBorder(false, true, false, true) -} - -func WithBorderStyle(style lipgloss.Border) ContainerOption { - return func(c *container) { - c.borderStyle = style - } -} - -func WithRoundedBorder() ContainerOption { - return WithBorderStyle(lipgloss.RoundedBorder()) -} - -func WithThickBorder() ContainerOption { - return WithBorderStyle(lipgloss.ThickBorder()) -} - -func WithDoubleBorder() ContainerOption { - return WithBorderStyle(lipgloss.DoubleBorder()) -} diff --git a/internal/tui/components/core/layout/layout.go b/internal/tui/components/core/layout/layout.go index f5f2361d72d0d41bcb898c81f00df174571cfa72..6ceb30adf45595f5d44d4b4b48d6ac0feb87a028 100644 --- a/internal/tui/components/core/layout/layout.go +++ b/internal/tui/components/core/layout/layout.go @@ -5,6 +5,8 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" ) +// TODO: move this to core + type Focusable interface { Focus() tea.Cmd Blur() tea.Cmd @@ -23,8 +25,3 @@ type Help interface { type Positional interface { SetPosition(x, y int) tea.Cmd } - -// KeyMapProvider defines an interface for types that can provide their key bindings as a slice -type KeyMapProvider interface { - KeyBindings() []key.Binding -} diff --git a/internal/tui/components/core/layout/split.go b/internal/tui/components/core/layout/split.go deleted file mode 100644 index 16fdb6d78466e4818cbf9698fecd0860908f92f6..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/layout/split.go +++ /dev/null @@ -1,375 +0,0 @@ -package layout - -import ( - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" -) - -type LayoutPanel string - -const ( - LeftPanel LayoutPanel = "left" - RightPanel LayoutPanel = "right" - BottomPanel LayoutPanel = "bottom" -) - -type SplitPaneLayout interface { - util.Model - Sizeable - Help - SetLeftPanel(panel Container) tea.Cmd - SetRightPanel(panel Container) tea.Cmd - SetBottomPanel(panel Container) tea.Cmd - - ClearLeftPanel() tea.Cmd - ClearRightPanel() tea.Cmd - ClearBottomPanel() tea.Cmd - - FocusPanel(panel LayoutPanel) tea.Cmd - SetOffset(x, y int) -} - -type splitPaneLayout struct { - width int - height int - xOffset int - yOffset int - - ratio float64 - verticalRatio float64 - - rightPanel Container - leftPanel Container - bottomPanel Container - - fixedBottomHeight int // Fixed height for the bottom panel, if any - fixedRightWidth int // Fixed width for the right panel, if any -} - -type SplitPaneOption func(*splitPaneLayout) - -func (s *splitPaneLayout) Init() tea.Cmd { - var cmds []tea.Cmd - - if s.leftPanel != nil { - cmds = append(cmds, s.leftPanel.Init()) - } - - if s.rightPanel != nil { - cmds = append(cmds, s.rightPanel.Init()) - } - - if s.bottomPanel != nil { - cmds = append(cmds, s.bottomPanel.Init()) - } - - return tea.Batch(cmds...) -} - -func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return s, s.SetSize(msg.Width, msg.Height) - } - - if s.rightPanel != nil { - u, cmd := s.rightPanel.Update(msg) - s.rightPanel = u.(Container) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - if s.leftPanel != nil { - u, cmd := s.leftPanel.Update(msg) - s.leftPanel = u.(Container) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - if s.bottomPanel != nil { - u, cmd := s.bottomPanel.Update(msg) - s.bottomPanel = u.(Container) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - return s, tea.Batch(cmds...) -} - -func (s *splitPaneLayout) Cursor() *tea.Cursor { - if s.bottomPanel != nil { - if c, ok := s.bottomPanel.(util.Cursor); ok { - return c.Cursor() - } - } else if s.rightPanel != nil { - if c, ok := s.rightPanel.(util.Cursor); ok { - return c.Cursor() - } - } else if s.leftPanel != nil { - if c, ok := s.leftPanel.(util.Cursor); ok { - return c.Cursor() - } - } - return nil -} - -func (s *splitPaneLayout) View() string { - var topSection string - - if s.leftPanel != nil && s.rightPanel != nil { - leftView := s.leftPanel.View() - rightView := s.rightPanel.View() - topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView) - } else if s.leftPanel != nil { - topSection = s.leftPanel.View() - } else if s.rightPanel != nil { - topSection = s.rightPanel.View() - } else { - topSection = "" - } - - var finalView string - - if s.bottomPanel != nil && topSection != "" { - bottomView := s.bottomPanel.View() - finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView) - } else if s.bottomPanel != nil { - finalView = s.bottomPanel.View() - } else { - finalView = topSection - } - - t := styles.CurrentTheme() - - style := t.S().Base. - Width(s.width). - Height(s.height) - - return style.Render(finalView) -} - -func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { - s.width = width - s.height = height - var topHeight, bottomHeight int - var cmds []tea.Cmd - if s.bottomPanel != nil { - if s.fixedBottomHeight > 0 { - bottomHeight = s.fixedBottomHeight - topHeight = height - bottomHeight - } else { - topHeight = int(float64(height) * s.verticalRatio) - bottomHeight = height - topHeight - if bottomHeight <= 0 { - bottomHeight = 2 - topHeight = height - bottomHeight - } - } - } else { - topHeight = height - bottomHeight = 0 - } - - var leftWidth, rightWidth int - if s.leftPanel != nil && s.rightPanel != nil { - if s.fixedRightWidth > 0 { - rightWidth = s.fixedRightWidth - leftWidth = width - rightWidth - } else { - leftWidth = int(float64(width) * s.ratio) - rightWidth = width - leftWidth - if rightWidth <= 0 { - rightWidth = 2 - leftWidth = width - rightWidth - } - } - } else if s.leftPanel != nil { - leftWidth = width - rightWidth = 0 - } else if s.rightPanel != nil { - leftWidth = 0 - rightWidth = width - } - - if s.leftPanel != nil { - cmd := s.leftPanel.SetSize(leftWidth, topHeight) - cmds = append(cmds, cmd) - if positional, ok := s.leftPanel.(Positional); ok { - cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset)) - } - } - - if s.rightPanel != nil { - cmd := s.rightPanel.SetSize(rightWidth, topHeight) - cmds = append(cmds, cmd) - if positional, ok := s.rightPanel.(Positional); ok { - cmds = append(cmds, positional.SetPosition(s.xOffset+leftWidth, s.yOffset)) - } - } - - if s.bottomPanel != nil { - cmd := s.bottomPanel.SetSize(width, bottomHeight) - cmds = append(cmds, cmd) - if positional, ok := s.bottomPanel.(Positional); ok { - cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset+topHeight)) - } - } - return tea.Batch(cmds...) -} - -func (s *splitPaneLayout) GetSize() (int, int) { - return s.width, s.height -} - -func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd { - s.leftPanel = panel - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd { - s.rightPanel = panel - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd { - s.bottomPanel = panel - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd { - s.leftPanel = nil - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) ClearRightPanel() tea.Cmd { - s.rightPanel = nil - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd { - s.bottomPanel = nil - if s.width > 0 && s.height > 0 { - return s.SetSize(s.width, s.height) - } - return nil -} - -func (s *splitPaneLayout) Bindings() []key.Binding { - if s.leftPanel != nil { - if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() { - return b.Bindings() - } - } - if s.rightPanel != nil { - if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() { - return b.Bindings() - } - } - if s.bottomPanel != nil { - if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() { - return b.Bindings() - } - } - return nil -} - -func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd { - panels := map[LayoutPanel]Container{ - LeftPanel: s.leftPanel, - RightPanel: s.rightPanel, - BottomPanel: s.bottomPanel, - } - var cmds []tea.Cmd - for p, container := range panels { - if container == nil { - continue - } - if p == panel { - cmds = append(cmds, container.Focus()) - } else { - cmds = append(cmds, container.Blur()) - } - } - return tea.Batch(cmds...) -} - -// SetOffset implements SplitPaneLayout. -func (s *splitPaneLayout) SetOffset(x int, y int) { - s.xOffset = x - s.yOffset = y -} - -func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout { - layout := &splitPaneLayout{ - ratio: 0.8, - verticalRatio: 0.92, // Default 90% for top section, 10% for bottom - } - for _, option := range options { - option(layout) - } - return layout -} - -func WithLeftPanel(panel Container) SplitPaneOption { - return func(s *splitPaneLayout) { - s.leftPanel = panel - } -} - -func WithRightPanel(panel Container) SplitPaneOption { - return func(s *splitPaneLayout) { - s.rightPanel = panel - } -} - -func WithRatio(ratio float64) SplitPaneOption { - return func(s *splitPaneLayout) { - s.ratio = ratio - } -} - -func WithBottomPanel(panel Container) SplitPaneOption { - return func(s *splitPaneLayout) { - s.bottomPanel = panel - } -} - -func WithVerticalRatio(ratio float64) SplitPaneOption { - return func(s *splitPaneLayout) { - s.verticalRatio = ratio - } -} - -func WithFixedBottomHeight(height int) SplitPaneOption { - return func(s *splitPaneLayout) { - s.fixedBottomHeight = height - } -} - -func WithFixedRightWidth(width int) SplitPaneOption { - return func(s *splitPaneLayout) { - s.fixedRightWidth = width - } -} diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go index 3da14602ed2334f21e2af2e01574ccbcad0df8d5..fb0f461d810b74039ad466bfc5ade6e4be36d56f 100644 --- a/internal/tui/components/core/list/keys.go +++ b/internal/tui/components/core/list/keys.go @@ -9,6 +9,8 @@ type KeyMap struct { Up, DownOneItem, UpOneItem, + PageDown, + PageUp, HalfPageDown, HalfPageUp, Home, @@ -37,7 +39,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("d"), key.WithHelp("d", "half page down"), ), - HalfPageUp: key.NewBinding( + PageDown: key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("f/pgdn", "page down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ), HalfPageUp: key.NewBinding( key.WithKeys("u"), key.WithHelp("u", "half page up"), ), diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index abbad6142506894f97fc275582697cee2d1b28c8..3f99eda5d979e72f0497a120e056df10aca228c3 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -332,6 +332,10 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.scrollDown(m.listHeight() / 2) case key.Matches(msg, m.keyMap.HalfPageUp): m.scrollUp(m.listHeight() / 2) + case key.Matches(msg, m.keyMap.PageDown): + m.scrollDown(m.listHeight()) + case key.Matches(msg, m.keyMap.PageUp): + m.scrollUp(m.listHeight()) case key.Matches(msg, m.keyMap.Home): return m, m.goToTop() case key.Matches(msg, m.keyMap.End): diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go index bd87b013e86780d6a52a17f648d5f7479c7d350a..b7339705649f24129dc61c28471f23044ba7dafb 100644 --- a/internal/tui/components/core/status/status.go +++ b/internal/tui/components/core/status/status.go @@ -98,13 +98,12 @@ func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) { m.keyMap = keyMap } -func NewStatusCmp(keyMap help.KeyMap) StatusCmp { +func NewStatusCmp() StatusCmp { t := styles.CurrentTheme() help := help.New() help.Styles = t.S().Help return &statusCmp{ messageTTL: 5 * time.Second, help: help, - keyMap: keyMap, } } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 22c029dd355845b3ac7e2066a8a93bfb335c1d53..d055870e5ab24816fa002d2ad4f5fc171876d56e 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -20,8 +20,8 @@ func DefaultKeyMap() KeyMap { key.WithHelp("ctrl+c", "quit"), ), Help: key.NewBinding( - key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"), - key.WithHelp("ctrl+?", "more"), + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "more"), ), Commands: key.NewBinding( key.WithKeys("ctrl+p"), @@ -33,62 +33,3 @@ func DefaultKeyMap() KeyMap { ), } } - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := []key.Binding{ - k.Commands, - k.Sessions, - k.Quit, - k.Help, - } - slice = k.prependEscAndTab(slice) - slice = append(slice, k.pageBindings...) - // remove duplicates - seen := make(map[string]bool) - cleaned := []key.Binding{} - for _, b := range slice { - if !seen[b.Help().Key] { - seen[b.Help().Key] = true - cleaned = append(cleaned, b) - } - } - - for i := 0; i < len(cleaned); i += 3 { - end := min(i+3, len(cleaned)) - m = append(m, cleaned[i:end]) - } - return m -} - -func (k KeyMap) prependEscAndTab(bindings []key.Binding) []key.Binding { - var cancel key.Binding - var tab key.Binding - for _, b := range k.pageBindings { - if b.Help().Key == "esc" { - cancel = b - } - if b.Help().Key == "tab" { - tab = b - } - } - if tab.Help().Key != "" { - bindings = append([]key.Binding{tab}, bindings...) - } - if cancel.Help().Key != "" { - bindings = append([]key.Binding{cancel}, bindings...) - } - return bindings -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - bindings := []key.Binding{ - k.Commands, - k.Sessions, - k.Quit, - k.Help, - } - return k.prependEscAndTab(bindings) -} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 5cf5618b6705a87a41661bfe1d8d9b5d481da951..fe6127048132def361a8011fd2f51c4a6b0e5c09 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -2,8 +2,10 @@ package chat import ( "context" + "runtime" "time" + "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/spinner" tea "github.com/charmbracelet/bubbletea/v2" @@ -20,6 +22,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar" "github.com/charmbracelet/crush/internal/tui/components/chat/splash" "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" @@ -82,6 +85,7 @@ type chatPage struct { width, height int detailsWidth, detailsHeight int app *app.App + keyboardEnhancements tea.KeyboardEnhancementsMsg // Layout state compact bool @@ -103,6 +107,8 @@ type chatPage struct { showingDetails bool isCanceling bool splashFullScreen bool + isOnboarding bool + isProjectInit bool } func New(app *app.App) ChatPage { @@ -129,10 +135,12 @@ func (p *chatPage) Init() tea.Cmd { if !config.HasInitialDataConfig() { // First-time setup: show model selection p.splash.SetOnboarding(true) + p.isOnboarding = true p.splashFullScreen = true } else if b, _ := config.ProjectNeedsInitialization(); b { // Project needs CRUSH.md initialization p.splash.SetProjectInit(true) + p.isProjectInit = true p.splashFullScreen = true } else { // Ready to chat: focus editor, splash in background @@ -153,9 +161,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyboardEnhancementsMsg: - m, cmd := p.editor.Update(msg) - p.editor = m.(editor.Editor) - return p, cmd + p.keyboardEnhancements = msg + return p, nil case tea.WindowSizeMsg: return p, p.SetSize(msg.Width, msg.Height) case CancelTimerExpiredMsg: @@ -237,6 +244,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return p, util.ReportError(err) } + p.isOnboarding = false + p.isProjectInit = false p.focusedPane = PanelTypeEditor return p, p.SetSize(p.width, p.height) case tea.KeyPressMsg: @@ -579,6 +588,231 @@ func (p *chatPage) Bindings() []key.Binding { return bindings } +func (a *chatPage) Help() help.KeyMap { + var shortList []key.Binding + var fullList [][]key.Binding + switch { + case a.isOnboarding && !a.splash.IsShowingAPIKey(): + shortList = append(shortList, + // Choose model + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ), + // Accept selection + key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "accept"), + ), + // Quit + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + ) + // keep them the same + for _, v := range shortList { + fullList = append(fullList, []key.Binding{v}) + } + case a.isOnboarding && a.splash.IsShowingAPIKey(): + var pasteKey key.Binding + if runtime.GOOS != "darwin" { + pasteKey = key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "paste API key"), + ) + } else { + pasteKey = key.NewBinding( + key.WithKeys("cmd+v"), + key.WithHelp("cmd+v", "paste API key"), + ) + } + shortList = append(shortList, + // Go back + key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + // Paste + pasteKey, + // Quit + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + ) + // keep them the same + for _, v := range shortList { + fullList = append(fullList, []key.Binding{v}) + } + case a.isProjectInit: + shortList = append(shortList, + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + ) + // keep them the same + for _, v := range shortList { + fullList = append(fullList, []key.Binding{v}) + } + default: + if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() { + cancelBinding := key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ) + if a.isCanceling { + cancelBinding = key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "press again to cancel"), + ) + } + shortList = append(shortList, cancelBinding) + fullList = append(fullList, + []key.Binding{ + cancelBinding, + }, + ) + } + globalBindings := []key.Binding{} + // we are in a session + if a.session.ID != "" { + tabKey := key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "focus chat"), + ) + if a.focusedPane == PanelTypeChat { + tabKey = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "focus editor"), + ) + } + shortList = append(shortList, tabKey) + globalBindings = append(globalBindings, tabKey) + } + commandsBinding := key.NewBinding( + key.WithKeys("ctrl+p"), + key.WithHelp("ctrl+p", "commands"), + ) + helpBinding := key.NewBinding( + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "more"), + ) + globalBindings = append(globalBindings, commandsBinding) + globalBindings = append(globalBindings, + key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "sessions"), + ), + ) + if a.session.ID != "" { + globalBindings = append(globalBindings, + key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "new sessions"), + )) + } + shortList = append(shortList, + // Commands + commandsBinding, + ) + fullList = append(fullList, globalBindings) + + if a.focusedPane == PanelTypeChat { + shortList = append(shortList, + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "scroll"), + ), + ) + fullList = append(fullList, + []key.Binding{ + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "scroll"), + ), + key.NewBinding( + key.WithKeys("shift+up", "shift+down"), + key.WithHelp("shift+↑↓", "next/prev item"), + ), + key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ), + key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("f/pgdn", "page down"), + ), + }, + []key.Binding{ + key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "half page up"), + ), + key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "half page down"), + ), + key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g", "hone"), + ), + key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G", "end"), + ), + }, + ) + } else if a.focusedPane == PanelTypeEditor { + newLineBinding := key.NewBinding( + key.WithKeys("shift+enter", "ctrl+j"), + // "ctrl+j" is a common keybinding for newline in many editors. If + // the terminal supports "shift+enter", we substitute the help text + // to reflect that. + key.WithHelp("ctrl+j", "newline"), + ) + if a.keyboardEnhancements.SupportsKeyDisambiguation() { + newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc) + } + shortList = append(shortList, newLineBinding) + fullList = append(fullList, + []key.Binding{ + newLineBinding, + key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "add image"), + ), + key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "add file"), + ), + key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "open editor"), + ), + }) + } + shortList = append(shortList, + // Quit + key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + // Help + helpBinding, + ) + fullList = append(fullList, []key.Binding{ + key.NewBinding( + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "less"), + ), + }) + } + + return core.NewSimpleHelp(shortList, fullList) +} + func (p *chatPage) IsChatFocused() bool { return p.focusedPane == PanelTypeChat } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f8b30c4a5f761c40bffb07667b92fc89bf631e04..c93d24dae4d16e8d132248359d43657b98a352c8 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/crush/internal/pubsub" cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/core/status" "github.com/charmbracelet/crush/internal/tui/components/dialogs" @@ -257,7 +258,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd { var cmds []tea.Cmd a.wWidth, a.wHeight = width, height if a.showingFullHelp { - height -= 4 + height -= 5 } else { height -= 2 } @@ -384,10 +385,9 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { // View renders the complete application interface including pages, dialogs, and overlays. func (a *appModel) View() tea.View { page := a.pages[a.currentPage] - if withHelp, ok := page.(layout.Help); ok { - a.keyMap.pageBindings = withHelp.Bindings() + if withHelp, ok := page.(core.KeyMapHelp); ok { + a.status.SetKeyMap(withHelp.Help()) } - a.status.SetKeyMap(a.keyMap) pageView := page.View() components := []string{ pageView, @@ -447,7 +447,7 @@ func New(app *app.App) tea.Model { model := &appModel{ currentPage: chat.ChatPageID, app: app, - status: status.NewStatusCmp(keyMap), + status: status.NewStatusCmp(), loadedPages: make(map[page.PageID]bool), keyMap: keyMap, From 5bf5eac98f17262b4ecd134d43ef3a269f10cdf2 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 13:41:39 +0200 Subject: [PATCH 20/38] chore: add completions help and small improvements --- internal/tui/components/chat/editor/editor.go | 5 +++++ .../tui/components/completions/completions.go | 10 +++++++--- internal/tui/components/completions/item.go | 1 + internal/tui/components/completions/keys.go | 2 +- internal/tui/page/chat/chat.go | 20 +++++++++++++++++++ internal/tui/tui.go | 5 ++++- 6 files changed, 38 insertions(+), 5 deletions(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 1c2989683b8fb8c8cc77fbaac5c49ec8248d91e4..87fa7ba9ca09c47d12583f89d3e9ebe8daab3544 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -35,6 +35,7 @@ type Editor interface { layout.Positional SetSession(session session.Session) tea.Cmd + IsCompletionsOpen() bool Cursor() *tea.Cursor } @@ -391,6 +392,10 @@ func (c *editorCmp) SetSession(session session.Session) tea.Cmd { return nil } +func (c *editorCmp) IsCompletionsOpen() bool { + return c.isCompletionsOpen +} + func New(app *app.App) Editor { t := styles.CurrentTheme() ta := textarea.New() diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go index 6153a76834ff697546e0c3ba38dece817bb97921..29ea86365e9f1532eab3aa1a61214ef74b7f4a05 100644 --- a/internal/tui/components/completions/completions.go +++ b/internal/tui/components/completions/completions.go @@ -70,8 +70,8 @@ func New() Completions { list.WithHideFilterInput(true), ) return &completionsCmp{ - width: 30, - height: 10, + width: 0, + height: 0, list: l, query: "", keyMap: completionsKeyMap, @@ -89,6 +89,10 @@ func (c *completionsCmp) Init() tea.Cmd { // Update implements Completions. func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.WindowSizeMsg: + c.width = min(msg.Width-c.x, 80) + c.height = min(msg.Height-c.y, 15) + return c, nil case tea.KeyPressMsg: switch { case key.Matches(msg, c.keyMap.Up): @@ -135,7 +139,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle)) items = append(items, item) } - c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height + c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height cmds := []tea.Cmd{ c.list.SetSize(c.width, c.height), c.list.SetItems(items), diff --git a/internal/tui/components/completions/item.go b/internal/tui/components/completions/item.go index d1b18a75ba1591a52524713d228a7f8b24fa1c96..414ad94b9ffaae3792f80169feb4cdfff9a71d64 100644 --- a/internal/tui/components/completions/item.go +++ b/internal/tui/components/completions/item.go @@ -90,6 +90,7 @@ func (c *completionItemCmp) View() string { if c.bgColor != nil { titleStyle = titleStyle.Background(c.bgColor) titleMatchStyle = titleMatchStyle.Background(c.bgColor) + itemStyle = itemStyle.Background(c.bgColor) } if c.focus { diff --git a/internal/tui/components/completions/keys.go b/internal/tui/components/completions/keys.go index fee3a0e574ab926cb4e80d02c0d9d19c0e614edd..530b429fe32ffd89d73c6cec1723c27de1ddd459 100644 --- a/internal/tui/components/completions/keys.go +++ b/internal/tui/components/completions/keys.go @@ -22,7 +22,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("up", "move up"), ), Select: key.NewBinding( - key.WithKeys("enter"), + key.WithKeys("enter", "tab", "ctrl+y"), key.WithHelp("enter", "select"), ), Cancel: key.NewBinding( diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index fe6127048132def361a8011fd2f51c4a6b0e5c09..2fa8013958b57d9a4babb9bc02caffab79d26196 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -657,6 +657,26 @@ func (a *chatPage) Help() help.KeyMap { fullList = append(fullList, []key.Binding{v}) } default: + if a.editor.IsCompletionsOpen() { + shortList = append(shortList, + key.NewBinding( + key.WithKeys("tab", "enter"), + key.WithHelp("tab/enter", "complete"), + ), + key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ), + ) + for _, v := range shortList { + fullList = append(fullList, []key.Binding{v}) + } + return core.NewSimpleHelp(shortList, fullList) + } if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() { cancelBinding := key.NewBinding( key.WithKeys("esc"), diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c93d24dae4d16e8d132248359d43657b98a352c8..633766a1d80bf8b0056e8d856b71df04613e1101 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -109,6 +109,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, tea.Batch(cmds...) case tea.WindowSizeMsg: + a.completions.Update(msg) return a, a.handleWindowResize(msg.Width, msg.Height) // Completions messages @@ -119,9 +120,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Dialog messages case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg: + u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{}) + a.completions = u.(completions.Completions) u, dialogCmd := a.dialog.Update(msg) a.dialog = u.(dialogs.DialogCmp) - return a, dialogCmd + return a, tea.Batch(completionCmd, dialogCmd) case commands.ShowArgumentsDialogMsg: return a, util.CmdHandler( dialogs.OpenDialogMsg{ From fa408c5c2ce68f700887b49dff3ff3f7542c3778 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 14:07:36 +0200 Subject: [PATCH 21/38] chore: improve agent tool ui --- internal/tui/components/chat/chat.go | 11 ++++++++++- .../tui/components/chat/messages/renderer.go | 17 ++++++++++++++--- internal/tui/components/chat/messages/tool.go | 4 ++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 06c9cdba9ba1e8a8475720dd26d432d4378fe2ca..0e6a95937476de9f33b1c5c0dd15e0489c645c43 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -118,7 +118,7 @@ func (m *messageListCmp) View() string { // handleChildSession handles messages from child sessions (agent tools). func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd { var cmds []tea.Cmd - if len(event.Payload.ToolCalls()) == 0 { + if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 { return nil } items := m.listCmp.Items() @@ -158,6 +158,15 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) ) } } + for _, tr := range event.Payload.ToolResults() { + for nestedInx, nestedTC := range nestedToolCalls { + if nestedTC.GetToolCall().ID == tr.ToolCallID { + nestedToolCalls[nestedInx].SetToolResult(tr) + break + } + } + } + toolCall.SetNestedToolCalls(nestedToolCalls) m.listCmp.UpdateItem( toolCallInx, diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index 2d86d103bc60f2122c3eaf58883bd6d9ac42a47e..51f3094b1df00db9206e75d582cf2b028d14bc6c 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -112,10 +112,21 @@ func (br baseRenderer) unmarshalParams(input string, target any) error { } // makeHeader builds the tool call header with status icon and parameters for a nested tool call. -func (br baseRenderer) makeNestedHeader(_ *toolCallCmp, tool string, width int, params ...string) string { +func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string { t := styles.CurrentTheme() + icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) + if v.result.ToolCallID != "" { + if v.result.IsError { + icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError) + } else { + icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess) + } + } else if v.cancelled { + icon = t.S().Muted.Render(styles.ToolPending) + } tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool) + " " - return tool + renderParamList(true, width-lipgloss.Width(tool), params...) + prefix := fmt.Sprintf("%s %s ", icon, tool) + return prefix + renderParamList(true, width-lipgloss.Width(tool), params...) } // makeHeader builds ": param (key=value)" and truncates as needed. @@ -542,7 +553,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string { if v.result.ToolCallID == "" { v.spinning = true - parts = append(parts, v.anim.View()) + parts = append(parts, "", v.anim.View()) } else { v.spinning = false } diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index fe61d44fb77f81d330447beecb9b1a7192a2a0c4..41c3d9656de59bcfae9cae3b5d9a8a07cfaf9afd 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -219,11 +219,11 @@ func (m *toolCallCmp) SetIsNested(isNested bool) { // renderPending displays the tool name with a loading animation for pending tool calls func (m *toolCallCmp) renderPending() string { t := styles.CurrentTheme() + icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) if m.isNested { tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name)) - return fmt.Sprintf("%s %s", tool, m.anim.View()) + return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View()) } - icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name)) return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View()) } From d1b2f8baa88b5ed0b00512d4f3e1cae60a0411d6 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 14:42:38 +0200 Subject: [PATCH 22/38] chore: fix cursor --- internal/tui/components/chat/splash/splash.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 66c1c596fd762fccaacdf23541a4ed6b2a05d035..121000e2cb7c08191dd443f31c8d5111f24a8e17 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -496,18 +496,14 @@ func (m *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { infoSectionHeight := lipgloss.Height(m.infoSection()) baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight if m.needsAPIKey { - // For API key input, position at the bottom of the remaining space - remainingHeight := m.height - logoHeight - (SplashScreenPaddingY * 2) - offset := baseOffset + remainingHeight - lipgloss.Height(m.apiKeyInput.View()) + remainingHeight := m.height - baseOffset - lipgloss.Height(m.apiKeyInput.View()) - SplashScreenPaddingY + offset := baseOffset + remainingHeight cursor.Y += offset - // API key input already includes prompt in its cursor positioning cursor.X = cursor.X + SplashScreenPaddingX } else if m.isOnboarding { - // For model list, use the original calculation - listHeight := min(40, m.height-(SplashScreenPaddingY*2)-logoHeight-1-infoSectionHeight) - offset := m.height - listHeight + listHeight := min(40, m.height-(SplashScreenPaddingY)-baseOffset) + offset := m.height - listHeight + 1 // +1 for the title cursor.Y += offset - // Model list doesn't have a prompt, so add padding + space for list styling cursor.X = cursor.X + SplashScreenPaddingX + 1 } From 7c288941d402b6b7901cdfe90ad9be6beef62103 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 14:50:51 +0200 Subject: [PATCH 23/38] chore: fix bash output --- internal/tui/components/chat/messages/renderer.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index 51f3094b1df00db9206e75d582cf2b028d14bc6c..e11cffc53392c1683a514130948155e423f222c7 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -645,6 +645,9 @@ func earlyState(header string, v *toolCallCmp) (string, bool) { func joinHeaderBody(header, body string) string { t := styles.CurrentTheme() + if body == "" { + return header + } body = t.S().Base.PaddingLeft(2).Render(body) return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "") } From 6d6ef4d7209a7b25d8915fe1d839ce5193b092dd Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 14:54:54 +0200 Subject: [PATCH 24/38] chore: fix cursor --- internal/tui/components/chat/splash/splash.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 121000e2cb7c08191dd443f31c8d5111f24a8e17..9f5a481d9bc75ab89ff32bc098980e04927d9dbd 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -134,7 +134,8 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd { s.width = width s.logoRendered = s.logoBlock() } - listHeigh := min(40, height-(SplashScreenPaddingY*2)-lipgloss.Height(s.logoRendered)-2) // -1 for the title + infoSectionHeight := lipgloss.Height(s.infoSection()) + listHeigh := min(40, height-(SplashScreenPaddingY*2)-lipgloss.Height(s.logoRendered)-2-infoSectionHeight) listWidth := min(60, width-(SplashScreenPaddingX*2)) return s.modelList.SetSize(listWidth, listHeigh) @@ -490,7 +491,6 @@ func (m *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { if cursor == nil { return nil } - // Calculate the correct Y offset based on current state logoHeight := lipgloss.Height(m.logoRendered) infoSectionHeight := lipgloss.Height(m.infoSection()) @@ -502,7 +502,7 @@ func (m *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { cursor.X = cursor.X + SplashScreenPaddingX } else if m.isOnboarding { listHeight := min(40, m.height-(SplashScreenPaddingY)-baseOffset) - offset := m.height - listHeight + 1 // +1 for the title + offset := m.height - listHeight + 2 cursor.Y += offset cursor.X = cursor.X + SplashScreenPaddingX + 1 } From 02d9aff8073c96c681148da8cf04cfddb79e27df Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 15:59:32 +0200 Subject: [PATCH 25/38] chore: small ux changes --- internal/tui/components/chat/splash/splash.go | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 9f5a481d9bc75ab89ff32bc098980e04927d9dbd..f802832f7ab388f5c4992d9e190f0f5035ba70e2 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -38,8 +38,9 @@ type Splash interface { } const ( - SplashScreenPaddingX = 2 // Padding X for the splash screen SplashScreenPaddingY = 1 // Padding Y for the splash screen + + LogoGap = 6 ) // OnboardingCompleteMsg is sent when onboarding is complete @@ -56,6 +57,7 @@ type splashCmp struct { needsAPIKey bool selectedNo bool + listHeight int modelList *models.ModelListComponent apiKeyInput *models.APIKeyInput selectedModel *models.ModelOption @@ -134,11 +136,10 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd { s.width = width s.logoRendered = s.logoBlock() } - infoSectionHeight := lipgloss.Height(s.infoSection()) - listHeigh := min(40, height-(SplashScreenPaddingY*2)-lipgloss.Height(s.logoRendered)-2-infoSectionHeight) - listWidth := min(60, width-(SplashScreenPaddingX*2)) - - return s.modelList.SetSize(listWidth, listHeigh) + // remove padding, logo height, gap, title space + s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2 + listWidth := min(60, width) + return s.modelList.SetSize(listWidth, s.listHeight) } // Update implements SplashPage. @@ -347,9 +348,8 @@ func (s *splashCmp) View() string { t := styles.CurrentTheme() var content string if s.needsAPIKey { - infoSection := s.infoSection() - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - lipgloss.Height(infoSection) - apiKeyView := s.apiKeyInput.View() + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View()) apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( lipgloss.JoinVertical( lipgloss.Left, @@ -359,13 +359,11 @@ func (s *splashCmp) View() string { content = lipgloss.JoinVertical( lipgloss.Left, s.logoRendered, - infoSection, apiKeySelector, ) } else if s.isOnboarding { - infoSection := s.infoSection() - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - lipgloss.Height(infoSection) modelListView := s.modelList.View() + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( lipgloss.JoinVertical( lipgloss.Left, @@ -377,7 +375,6 @@ func (s *splashCmp) View() string { content = lipgloss.JoinVertical( lipgloss.Left, s.logoRendered, - infoSection, modelSelector, ) } else if s.needsProjectInit { @@ -441,8 +438,6 @@ func (s *splashCmp) View() string { Width(s.width). Height(s.height). PaddingTop(SplashScreenPaddingY). - PaddingLeft(SplashScreenPaddingX). - PaddingRight(SplashScreenPaddingX). PaddingBottom(SplashScreenPaddingY). Render(content) } @@ -476,40 +471,47 @@ func (s *splashCmp) infoSection() string { func (s *splashCmp) logoBlock() string { t := styles.CurrentTheme() - const padding = 2 - return logo.Render(version.Version, false, logo.Opts{ - FieldColor: t.Primary, - TitleColorA: t.Secondary, - TitleColorB: t.Primary, - CharmColor: t.Secondary, - VersionColor: t.Primary, - Width: s.width - (SplashScreenPaddingX * 2), - }) + return t.S().Base.Padding(0, 2).Width(s.width).Render( + logo.Render(version.Version, false, logo.Opts{ + FieldColor: t.Primary, + TitleColorA: t.Secondary, + TitleColorB: t.Primary, + CharmColor: t.Secondary, + VersionColor: t.Primary, + Width: s.width - 4, + }), + ) } -func (m *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { +func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { if cursor == nil { return nil } // Calculate the correct Y offset based on current state - logoHeight := lipgloss.Height(m.logoRendered) - infoSectionHeight := lipgloss.Height(m.infoSection()) - baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight - if m.needsAPIKey { - remainingHeight := m.height - baseOffset - lipgloss.Height(m.apiKeyInput.View()) - SplashScreenPaddingY + logoHeight := lipgloss.Height(s.logoRendered) + if s.needsAPIKey { + infoSectionHeight := lipgloss.Height(s.infoSection()) + baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight + remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY offset := baseOffset + remainingHeight cursor.Y += offset - cursor.X = cursor.X + SplashScreenPaddingX - } else if m.isOnboarding { - listHeight := min(40, m.height-(SplashScreenPaddingY)-baseOffset) - offset := m.height - listHeight + 2 + cursor.X = cursor.X + 1 + } else if s.isOnboarding { + offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3 cursor.Y += offset - cursor.X = cursor.X + SplashScreenPaddingX + 1 + cursor.X = cursor.X + 1 } return cursor } +func (s *splashCmp) logoGap() int { + if s.height > 35 { + return LogoGap + } + return 0 +} + // Bindings implements SplashPage. func (s *splashCmp) Bindings() []key.Binding { if s.needsAPIKey { @@ -536,7 +538,7 @@ func (s *splashCmp) Bindings() []key.Binding { } func (s *splashCmp) getMaxInfoWidth() int { - return min(s.width-(SplashScreenPaddingX*2), 40) + return min(s.width, 40) } func (s *splashCmp) cwd() string { From 878ce150e7a3bc74e1f288f8ea73f7a5fd77a57b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 16:01:13 +0200 Subject: [PATCH 26/38] chore: fix initialize padding --- internal/tui/components/chat/splash/splash.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index f802832f7ab388f5c4992d9e190f0f5035ba70e2..b7d3cfa21b2a6e71bc77439367d29b25365fd388 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -411,7 +411,7 @@ func (s *splashCmp) View() string { remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - lipgloss.Height(infoSection) - initContent := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( + initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render( lipgloss.JoinVertical( lipgloss.Left, initText, From 1aefed22aafa777492c25ebddc1336112a04076f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 16:25:38 +0200 Subject: [PATCH 27/38] chore: fix padding for info --- internal/tui/components/chat/splash/splash.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index b7d3cfa21b2a6e71bc77439367d29b25365fd388..c0d959eccce503e2830c14cd24ebbe6a0403c3bd 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -460,12 +460,15 @@ func (s *splashCmp) Cursor() *tea.Cursor { } func (s *splashCmp) infoSection() string { - return lipgloss.JoinVertical( - lipgloss.Left, - s.cwd(), - "", - lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()), - "", + t := styles.CurrentTheme() + return t.S().Base.PaddingLeft(2).Render( + lipgloss.JoinVertical( + lipgloss.Left, + s.cwd(), + "", + lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()), + "", + ), ) } @@ -538,7 +541,7 @@ func (s *splashCmp) Bindings() []key.Binding { } func (s *splashCmp) getMaxInfoWidth() int { - return min(s.width, 40) + return min(s.width-2, 40) // 2 for left padding } func (s *splashCmp) cwd() string { From e005d0af9d64444314c120ac5b39b4167c9cd0c6 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 16:27:37 +0200 Subject: [PATCH 28/38] chore: fix newline with \ --- internal/tui/components/chat/editor/editor.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index f84bb0633df734d467750f3e5258eb71b1f4a387..7988861caded2e6c9a1f9a50125f62638d10a230 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -261,8 +261,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { value := m.textarea.Value() if len(value) > 0 && value[len(value)-1] == '\\' { // If the last character is a backslash, remove it and add a newline - m.textarea.SetValue(value[:len(value)-1] + "\n") - return m, nil + m.textarea.SetValue(value[:len(value)-1]) } else { // Otherwise, send the message return m, m.send() From c57cca5d6f692eed7651216588b1af479799e8ea Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 10 Jul 2025 12:20:02 -0400 Subject: [PATCH 29/38] fix(tui): permission: increase the default width of the dialog to 80% and verticalize buttons on narrow screens --- internal/tui/components/core/core.go | 15 +++++++++++++++ .../components/dialogs/permissions/permissions.go | 13 ++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/internal/tui/components/core/core.go b/internal/tui/components/core/core.go index f5605db3927a9c1e55bd5706e2059c1ba9006106..1db79e954350a11a4a843797b07a091736a1cae9 100644 --- a/internal/tui/components/core/core.go +++ b/internal/tui/components/core/core.go @@ -191,6 +191,21 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string { return lipgloss.JoinHorizontal(lipgloss.Left, parts...) } +// SelectableButtonsVertical creates a vertical row of selectable buttons +func SelectableButtonsVertical(buttons []ButtonOpts, spacing int) string { + var parts []string + for i, button := range buttons { + parts = append(parts, SelectableButton(button)) + if i < len(buttons)-1 { + for j := 0; j < spacing; j++ { + parts = append(parts, "") + } + } + } + + return lipgloss.JoinVertical(lipgloss.Center, parts...) +} + func DiffFormatter() *diffview.DiffView { t := styles.CurrentTheme() formatDiff := diffview.New() diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index e7f6dd517b1504e2f938c9310e95b8a7086cbbf0..9d8215d640826b4113e8288ea1c7f7559779a001 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -199,6 +199,13 @@ func (p *permissionDialogCmp) renderButtons() string { } content := core.SelectableButtons(buttons, " ") + if lipgloss.Width(content) > p.width-4 { + content = core.SelectableButtonsVertical(buttons, 1) + return baseStyle.AlignVertical(lipgloss.Center). + AlignHorizontal(lipgloss.Center). + Width(p.width - 4). + Render(content) + } return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content) } @@ -452,8 +459,8 @@ func (p *permissionDialogCmp) render() string { if p.supportsDiffView() { contentHelp = help.New().View(p.keyMap) } - // Calculate content height dynamically based on window size + // Calculate content height dynamically based on window size strs := []string{ title, "", @@ -491,7 +498,7 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd { switch p.permission.ToolName { case tools.BashToolName: - p.width = int(float64(p.wWidth) * 0.4) + p.width = int(float64(p.wWidth) * 0.8) p.height = int(float64(p.wHeight) * 0.3) case tools.EditToolName: p.width = int(float64(p.wWidth) * 0.8) @@ -500,7 +507,7 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd { p.width = int(float64(p.wWidth) * 0.8) p.height = int(float64(p.wHeight) * 0.8) case tools.FetchToolName: - p.width = int(float64(p.wWidth) * 0.4) + p.width = int(float64(p.wWidth) * 0.8) p.height = int(float64(p.wHeight) * 0.3) default: p.width = int(float64(p.wWidth) * 0.7) From ddeec85c24b8e752fd2ff6e209515585969d783e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 10 Jul 2025 12:42:24 -0400 Subject: [PATCH 30/38] fix(tui): logo: make sure we truncate the logo if it exceeds the width Truncate each line of the logo to fit within the specified width. --- internal/tui/components/chat/sidebar/sidebar.go | 1 + internal/tui/components/logo/logo.go | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 617c862d83c90bd9aa323b7d547591cf97cdd1bc..3fa08ce021d0fcac1ce7dc9668d46198f6d08055 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -250,6 +250,7 @@ func (m *sidebarCmp) logoBlock() string { TitleColorB: t.Primary, CharmColor: t.Secondary, VersionColor: t.Primary, + Width: m.width - 2, }) } diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go index e6db4347d62421e1260f550ec2c974018b695f58..dbd3229e9b6c49b9f59b1a477fac9a5dc1c84d6e 100644 --- a/internal/tui/components/logo/logo.go +++ b/internal/tui/components/logo/logo.go @@ -98,7 +98,16 @@ func Render(version string, compact bool, o Opts) string { // Return the wide version. const hGap = " " - return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String()) + logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String()) + if o.Width > 0 { + // Truncate the logo to the specified width. + lines := strings.Split(logo, "\n") + for i, line := range lines { + lines[i] = ansi.Truncate(line, o.Width, "") + } + logo = strings.Join(lines, "\n") + } + return logo } // renderWord renders letterforms to fork a word. From ed65d90f88cacd6f5ab8c70d48d25ea7ff65ea90 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 10 Jul 2025 12:47:27 -0400 Subject: [PATCH 31/38] fix(tui): editor: change open editor key binding from ctrl+e to ctrl+v --- internal/tui/components/chat/editor/keys.go | 4 ++-- internal/tui/page/chat/chat.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go index d0243fd3dae2fc0d13942833566510ad6a7546eb..ef002436901ed0fbad3bcbd2da7cecc08ef255c1 100644 --- a/internal/tui/components/chat/editor/keys.go +++ b/internal/tui/components/chat/editor/keys.go @@ -22,8 +22,8 @@ func DefaultEditorKeyMap() EditorKeyMap { key.WithHelp("enter", "send"), ), OpenEditor: key.NewBinding( - key.WithKeys("ctrl+e"), - key.WithHelp("ctrl+e", "open editor"), + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "open editor"), ), Newline: key.NewBinding( key.WithKeys("shift+enter", "ctrl+j"), diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 2fa8013958b57d9a4babb9bc02caffab79d26196..33267772e96662f14934a8417149259c7d22541a 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -808,8 +808,8 @@ func (a *chatPage) Help() help.KeyMap { key.WithHelp("/", "add file"), ), key.NewBinding( - key.WithKeys("ctrl+e"), - key.WithHelp("ctrl+e", "open editor"), + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "open editor"), ), }) } From bc87da9e56c3631c70e4b946ba3b60d3753c4d98 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 10 Jul 2025 13:15:16 -0400 Subject: [PATCH 32/38] chore: update vendors --- go.mod | 2 +- .../ultraviolet/screen/screen.go | 109 ++++++++++++++++++ vendor/modules.txt | 1 + 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 vendor/github.com/charmbracelet/ultraviolet/screen/screen.go diff --git a/go.mod b/go.mod index c68c8f02f9b68d8d3dfe94aec40da7e588a15939..128e04842feca31ebb4a7f8529149e13e4578f06 100644 --- a/go.mod +++ b/go.mod @@ -75,7 +75,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef github.com/charmbracelet/x/term v0.2.1 diff --git a/vendor/github.com/charmbracelet/ultraviolet/screen/screen.go b/vendor/github.com/charmbracelet/ultraviolet/screen/screen.go new file mode 100644 index 0000000000000000000000000000000000000000..fd54788456a999d1e0593b3bee9050005f7ceee2 --- /dev/null +++ b/vendor/github.com/charmbracelet/ultraviolet/screen/screen.go @@ -0,0 +1,109 @@ +// Package screen provides functions and helpers to manipulate a [uv.Screen]. +package screen + +import uv "github.com/charmbracelet/ultraviolet" + +// Clear clears the screen with empty cells. This is equivalent to filling the +// screen with empty cells. +// +// If the screen implements a [Clear] method, it will be called instead of +// filling the screen with empty cells. +func Clear(scr uv.Screen) { + if c, ok := scr.(interface { + Clear() + }); ok { + c.Clear() + return + } + Fill(scr, nil) +} + +// ClearArea clears the given area of the screen with empty cells. This is +// equivalent to filling the area with empty cells. +// +// If the screen implements a [ClearArea] method, it will be called instead of +// filling the area with empty cells. +func ClearArea(scr uv.Screen, area uv.Rectangle) { + if c, ok := scr.(interface { + ClearArea(area uv.Rectangle) + }); ok { + c.ClearArea(area) + return + } + FillArea(scr, nil, area) +} + +// Fill fills the screen with the given cell. If the cell is nil, it fills the +// screen with empty cells. +// +// If the screen implements a [Fill] method, it will be called instead of +// filling the screen with empty cells. +func Fill(scr uv.Screen, cell *uv.Cell) { + if f, ok := scr.(interface { + Fill(cell *uv.Cell) + }); ok { + f.Fill(cell) + return + } + FillArea(scr, cell, scr.Bounds()) +} + +// FillArea fills the given area of the screen with the given cell. If the cell +// is nil, it fills the area with empty cells. +// +// If the screen implements a [FillArea] method, it will be called instead of +// filling the area with empty cells. +func FillArea(scr uv.Screen, cell *uv.Cell, area uv.Rectangle) { + if f, ok := scr.(interface { + FillArea(cell *uv.Cell, area uv.Rectangle) + }); ok { + f.FillArea(cell, area) + return + } + for y := area.Min.Y; y < area.Max.Y; y++ { + for x := area.Min.X; x < area.Max.X; x++ { + scr.SetCell(x, y, cell) + } + } +} + +// CloneArea clones the given area of the screen and returns a new buffer +// with the same size as the area. The new buffer will contain the same cells +// as the area in the screen. +// Use [uv.Buffer.Draw] to draw the cloned buffer to a screen again. +// +// If the screen implements a [CloneArea] method, it will be called instead of +// cloning the area manually. +func CloneArea(scr uv.Screen, area uv.Rectangle) *uv.Buffer { + if c, ok := scr.(interface { + CloneArea(area uv.Rectangle) *uv.Buffer + }); ok { + return c.CloneArea(area) + } + buf := uv.NewBuffer(area.Dx(), area.Dy()) + for y := area.Min.Y; y < area.Max.Y; y++ { + for x := area.Min.X; x < area.Max.X; x++ { + cell := scr.CellAt(x, y) + if cell == nil || cell.IsZero() { + continue + } + buf.SetCell(x-area.Min.X, y-area.Min.Y, cell.Clone()) + } + } + return buf +} + +// Clone creates a new [uv.Buffer] clone of the given screen. The new buffer will +// have the same size as the screen and will contain the same cells. +// Use [uv.Buffer.Draw] to draw the cloned buffer to a screen again. +// +// If the screen implements a [Clone] method, it will be called instead of +// cloning the entire screen manually. +func Clone(scr uv.Screen) *uv.Buffer { + if c, ok := scr.(interface { + Clone() *uv.Buffer + }); ok { + return c.Clone() + } + return CloneArea(scr, scr.Bounds()) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index e256e639e4098adadaf25a32fa4a05937e2066d7..743324d3515946ef7be8dce0230ee691f92e680b 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -280,6 +280,7 @@ github.com/charmbracelet/log/v2 # github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 ## explicit; go 1.24.0 github.com/charmbracelet/ultraviolet +github.com/charmbracelet/ultraviolet/screen # github.com/charmbracelet/x/ansi v0.9.3 ## explicit; go 1.23.0 github.com/charmbracelet/x/ansi From 653c6a56fc15ce076e97e85240205e5670c08342 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 10 Jul 2025 13:15:29 -0400 Subject: [PATCH 33/38] fix(tui): permission: make sure the fetch content has the correct background color --- .../dialogs/permissions/permissions.go | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index e7f6dd517b1504e2f938c9310e95b8a7086cbbf0..0e26cc063ad826c2306428b0f12cbc29abc02199 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -16,6 +16,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" ) @@ -395,7 +396,22 @@ func (p *permissionDialogCmp) generateFetchContent() string { Width(p.contentViewPort.Width()). Render(renderedContent) - return finalContent + // We render the content into a buffer with the size of the viewport + // width and height of the content. Then we make sure each cell has the + // appropriate background color. + ss := uv.NewStyledString(finalContent) + scr := uv.NewScreenBuffer(p.contentViewPort.Width(), lipgloss.Height(finalContent)) + ss.Draw(scr, scr.Bounds()) + for y := 0; y < scr.Height(); y++ { + for x := 0; x < scr.Width(); x++ { + cell := scr.CellAt(x, y) + if cell != nil { + cell.Style.Bg = t.BgSubtle + } + } + } + + return scr.Render() } return "" } @@ -452,8 +468,8 @@ func (p *permissionDialogCmp) render() string { if p.supportsDiffView() { contentHelp = help.New().View(p.keyMap) } - // Calculate content height dynamically based on window size + // Calculate content height dynamically based on window size strs := []string{ title, "", From 006b3a1da1640e4d4be38c84ff6c08f725f1ff86 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 20:22:24 +0200 Subject: [PATCH 34/38] simplify fetch url --- .../dialogs/permissions/permissions.go | 31 ++----------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 0e26cc063ad826c2306428b0f12cbc29abc02199..c43d50aa6233a2acb26e5b57c0b0f60b4ee143d3 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -16,7 +16,6 @@ import ( "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" - uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" ) @@ -383,35 +382,11 @@ func (p *permissionDialogCmp) generateFetchContent() string { t := styles.CurrentTheme() baseStyle := t.S().Base.Background(t.BgSubtle) if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok { - content := fmt.Sprintf("```bash\n%s\n```", pr.URL) - - // Use the cache for markdown rendering - renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) { - r := styles.GetMarkdownRenderer(p.width - 4) - s, err := r.Render(content) - return s, err - }) - finalContent := baseStyle. + Padding(1, 1). Width(p.contentViewPort.Width()). - Render(renderedContent) - - // We render the content into a buffer with the size of the viewport - // width and height of the content. Then we make sure each cell has the - // appropriate background color. - ss := uv.NewStyledString(finalContent) - scr := uv.NewScreenBuffer(p.contentViewPort.Width(), lipgloss.Height(finalContent)) - ss.Draw(scr, scr.Bounds()) - for y := 0; y < scr.Height(); y++ { - for x := 0; x < scr.Width(); x++ { - cell := scr.CellAt(x, y) - if cell != nil { - cell.Style.Bg = t.BgSubtle - } - } - } - - return scr.Render() + Render(pr.URL) + return finalContent } return "" } From d7baec0c7227ac4c085d2d57a8d163a07ec01e3b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 20:40:07 +0200 Subject: [PATCH 35/38] chore: small padding change --- internal/tui/components/dialogs/permissions/permissions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 91c3666e06beb9b15e011109748f5b7cf4daae7f..6bac6e58b37a99b376ad936bbf19f541b999eb4b 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -390,7 +390,7 @@ func (p *permissionDialogCmp) generateFetchContent() string { baseStyle := t.S().Base.Background(t.BgSubtle) if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok { finalContent := baseStyle. - Padding(1, 1). + Padding(1, 2). Width(p.contentViewPort.Width()). Render(pr.URL) return finalContent From 27c27b9db262cb8f041aba90f77e27ede68265ba Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 10 Jul 2025 14:53:06 -0400 Subject: [PATCH 36/38] chore: bump dependencies --- go.mod | 6 +++--- go.sum | 10 ++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 60027a0dcdbafe0a7482a1cc29df65a2a8e515ae..681f6fb76fd1cad2c0906c2c893a959e8551fc72 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,9 @@ module github.com/charmbracelet/crush go 1.24.3 -replace github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891 +replace github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595 -replace github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093 +replace github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 @@ -75,7 +75,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 + github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 // indirect github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef github.com/charmbracelet/x/term v0.2.1 diff --git a/go.sum b/go.sum index 19a95dddf7907257558b02b0dece77e8482a0067..e2c6681c23b3d619447269b97866f20e302b2af8 100644 --- a/go.sum +++ b/go.sum @@ -70,16 +70,16 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= -github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891 h1:wh6N1dR4XkDh6XsiZh1/tImJAZvYB0yVLmaUKvJXvK0= -github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891/go.mod h1:SwBB+WoaQVMMOM9hknbN/7FNT86kgKG0LSHGTmLphX8= +github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595 h1:wLMjzOqrwoM7Em9UR9sGbn4375G8WuxcwFB3kjZiqHo= +github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595/go.mod h1:+Tl7rePElw6OKt382t04zXwtPFoPXxAaJzNrYmtsLds= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d7Wg= github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc= github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY= github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk= -github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093 h1:c9vOmNJQUwy/lp/pNOB5ZDMhOuXJ3Y2LL9uZMYGgJxQ= -github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093/go.mod h1:XmxjFJcMEfYIHa4Mw4ra+uMjploDkTlkKIs7wLt9v4Q= +github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb h1:lswj7CYZVYbLn2OhYJsXOMRQQGdRIfyuSnh5FdVSMr0= +github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb/go.mod h1:wEc/TRrTAIDJYjVCg3+y8WeKaN+F88gpYfGbUuP6W3A= github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE= github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM= github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 h1:+LLFCLxtb/sHegwY3zYdFAbaOgI/I9pv/pxdUlI1Q9s= @@ -88,8 +88,6 @@ github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c h1:2GELBLPgfSbHU53bsQhR9XIgNuVZ6w+Rz8RWV5Lq+A4= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= From c510f030bc35fef3052d19cb89c5c1695cee3886 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 10 Jul 2025 14:53:13 -0400 Subject: [PATCH 37/38] chore: go mod vendor --- .../charmbracelet/bubbletea/v2/tea.go | 19 ++- .../charmbracelet/lipgloss/v2/get.go | 33 ++++++ .../charmbracelet/lipgloss/v2/set.go | 27 +++++ .../charmbracelet/lipgloss/v2/style.go | 26 +++-- .../charmbracelet/lipgloss/v2/unset.go | 7 ++ .../ultraviolet/screen/screen.go | 109 ------------------ vendor/modules.txt | 11 +- 7 files changed, 105 insertions(+), 127 deletions(-) delete mode 100644 vendor/github.com/charmbracelet/ultraviolet/screen/screen.go diff --git a/vendor/github.com/charmbracelet/bubbletea/v2/tea.go b/vendor/github.com/charmbracelet/bubbletea/v2/tea.go index 5320e4463a3b673a2a980009ed2fb25bc2853c71..1a6be630366d40aaac4706f9ee1e775306091c4e 100644 --- a/vendor/github.com/charmbracelet/bubbletea/v2/tea.go +++ b/vendor/github.com/charmbracelet/bubbletea/v2/tea.go @@ -624,6 +624,10 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { switch msg.Mode { case ansi.AltScreenSaveCursorMode: p.renderer.enterAltScreen() + // Main and alternate screen have their own Kitty keyboard + // stack. We need to request keyboard enhancements again + // when entering/exiting the alternate screen. + p.requestKeyboardEnhancements() case ansi.TextCursorEnableMode: p.renderer.showCursor() case ansi.GraphemeClusteringMode: @@ -645,6 +649,10 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { switch msg.Mode { case ansi.AltScreenSaveCursorMode: p.renderer.exitAltScreen() + // Main and alternate screen have their own Kitty keyboard + // stack. We need to request keyboard enhancements again + // when entering/exiting the alternate screen. + p.requestKeyboardEnhancements() case ansi.TextCursorEnableMode: p.renderer.hideCursor() default: @@ -1410,13 +1418,16 @@ func (p *Program) stopRenderer(kill bool) { // requestKeyboardEnhancements tries to enable keyboard enhancements and read // the active keyboard enhancements from the terminal. func (p *Program) requestKeyboardEnhancements() { + // XXX: We write to the renderer directly so that we synchronize with the + // alt-screen state of the renderer. This is because the main screen and + // alternate screen have their own Kitty keyboard state stack. if p.requestedEnhancements.modifyOtherKeys > 0 { - p.execute(ansi.KeyModifierOptions(4, p.requestedEnhancements.modifyOtherKeys)) //nolint:mnd - p.execute(ansi.QueryModifyOtherKeys) + _, _ = p.renderer.writeString(ansi.KeyModifierOptions(4, p.requestedEnhancements.modifyOtherKeys)) //nolint:mnd + _, _ = p.renderer.writeString(ansi.QueryModifyOtherKeys) } if p.requestedEnhancements.kittyFlags > 0 { - p.execute(ansi.PushKittyKeyboard(p.requestedEnhancements.kittyFlags)) - p.execute(ansi.RequestKittyKeyboard) + _, _ = p.renderer.writeString(ansi.PushKittyKeyboard(p.requestedEnhancements.kittyFlags)) + _, _ = p.renderer.writeString(ansi.RequestKittyKeyboard) } } diff --git a/vendor/github.com/charmbracelet/lipgloss/v2/get.go b/vendor/github.com/charmbracelet/lipgloss/v2/get.go index c54cf647de0e60e0250880d7a3ac662e857dc3b6..350bbc79edd06d313129032a6f95d6b112c8ba93 100644 --- a/vendor/github.com/charmbracelet/lipgloss/v2/get.go +++ b/vendor/github.com/charmbracelet/lipgloss/v2/get.go @@ -135,6 +135,16 @@ func (s Style) GetPaddingLeft() int { return s.getAsInt(paddingLeftKey) } +// GetPaddingChar returns the style's padding character. If no value is set a +// space (`\u0020`) is returned. +func (s Style) GetPaddingChar() rune { + char := s.getAsRune(paddingCharKey) + if char == 0 { + return ' ' + } + return char +} + // GetHorizontalPadding returns the style's left and right padding. Unset // values are measured as 0. func (s Style) GetHorizontalPadding() int { @@ -186,6 +196,16 @@ func (s Style) GetMarginLeft() int { return s.getAsInt(marginLeftKey) } +// GetMarginChar returns the style's padding character. If no value is set a +// space (`\u0020`) is returned. +func (s Style) GetMarginChar() rune { + char := s.getAsRune(marginCharKey) + if char == 0 { + return ' ' + } + return char +} + // GetHorizontalMargins returns the style's left and right margins. Unset // values are measured as 0. func (s Style) GetHorizontalMargins() int { @@ -432,6 +452,19 @@ func (s Style) isSet(k propKey) bool { return s.props.has(k) } +func (s Style) getAsRune(k propKey) rune { + if !s.isSet(k) { + return 0 + } + switch k { //nolint:exhaustive + case paddingCharKey: + return s.paddingChar + case marginCharKey: + return s.marginChar + } + return 0 +} + func (s Style) getAsBool(k propKey, defaultVal bool) bool { if !s.isSet(k) { return defaultVal diff --git a/vendor/github.com/charmbracelet/lipgloss/v2/set.go b/vendor/github.com/charmbracelet/lipgloss/v2/set.go index 0934c9c205ba4879a844c9b42eaa994d1ca9d106..32893668b6ade932583f9151b2563eece25cef1d 100644 --- a/vendor/github.com/charmbracelet/lipgloss/v2/set.go +++ b/vendor/github.com/charmbracelet/lipgloss/v2/set.go @@ -29,6 +29,8 @@ func (s *Style) set(key propKey, value any) { s.paddingBottom = max(0, value.(int)) case paddingLeftKey: s.paddingLeft = max(0, value.(int)) + case paddingCharKey: + s.paddingChar = value.(rune) case marginTopKey: s.marginTop = max(0, value.(int)) case marginRightKey: @@ -39,6 +41,8 @@ func (s *Style) set(key propKey, value any) { s.marginLeft = max(0, value.(int)) case marginBackgroundKey: s.marginBgColor = colorOrNil(value) + case marginCharKey: + s.marginChar = value.(rune) case borderStyleKey: s.borderStyle = value.(Border) case borderTopForegroundKey: @@ -111,6 +115,8 @@ func (s *Style) setFrom(key propKey, i Style) { s.set(paddingBottomKey, i.paddingBottom) case paddingLeftKey: s.set(paddingLeftKey, i.paddingLeft) + case paddingCharKey: + s.set(paddingCharKey, i.paddingChar) case marginTopKey: s.set(marginTopKey, i.marginTop) case marginRightKey: @@ -121,6 +127,8 @@ func (s *Style) setFrom(key propKey, i Style) { s.set(marginLeftKey, i.marginLeft) case marginBackgroundKey: s.set(marginBackgroundKey, i.marginBgColor) + case marginCharKey: + s.set(marginCharKey, i.marginChar) case borderStyleKey: s.set(borderStyleKey, i.borderStyle) case borderTopForegroundKey: @@ -320,6 +328,18 @@ func (s Style) PaddingBottom(i int) Style { return s } +// PaddingChar sets the character used for padding. This is useful for +// rendering blocks with a specific character, such as a space or a dot. +// Example of using [NBSP] as padding to prevent line breaks: +// +// ```go +// s := lipgloss.NewStyle().PaddingChar(lipgloss.NBSP) +// ``` +func (s Style) PaddingChar(r rune) Style { + s.set(paddingCharKey, r) + return s +} + // ColorWhitespace determines whether or not the background color should be // applied to the padding. This is true by default as it's more than likely the // desired and expected behavior, but it can be disabled for certain graphic @@ -390,6 +410,13 @@ func (s Style) MarginBackground(c color.Color) Style { return s } +// MarginChar sets the character used for the margin. This is useful for +// rendering blocks with a specific character, such as a space or a dot. +func (s Style) MarginChar(r rune) Style { + s.set(marginCharKey, r) + return s +} + // Border is shorthand for setting the border style and which sides should // have a border at once. The variadic argument sides works as follows: // diff --git a/vendor/github.com/charmbracelet/lipgloss/v2/style.go b/vendor/github.com/charmbracelet/lipgloss/v2/style.go index 5a40a94a367c2070f73cd8815aa349302dae01aa..ef13f6a4476cd1c3c0e4619795dd62d92804d7e1 100644 --- a/vendor/github.com/charmbracelet/lipgloss/v2/style.go +++ b/vendor/github.com/charmbracelet/lipgloss/v2/style.go @@ -10,7 +10,8 @@ import ( ) const ( - nbsp = '\u00A0' + // NBSP is the non-breaking space rune. + NBSP = '\u00A0' tabWidthDefault = 4 ) @@ -44,6 +45,7 @@ const ( paddingRightKey paddingBottomKey paddingLeftKey + paddingCharKey // Margins. marginTopKey @@ -51,6 +53,7 @@ const ( marginBottomKey marginLeftKey marginBackgroundKey + marginCharKey // Border runes. borderStyleKey @@ -128,12 +131,14 @@ type Style struct { paddingRight int paddingBottom int paddingLeft int + paddingChar rune marginTop int marginRight int marginBottom int marginLeft int marginBgColor color.Color + marginChar rune borderStyle Border borderTopFgColor color.Color @@ -387,23 +392,24 @@ func (s Style) Render(strs ...string) string { // Padding if !inline { //nolint:nestif + padChar := s.paddingChar + if padChar == 0 { + padChar = ' ' + } if leftPadding > 0 { var st *ansi.Style if colorWhitespace || styleWhitespace { st = &teWhitespace } - str = padLeft(str, leftPadding, st, nbsp) + str = padLeft(str, leftPadding, st, padChar) } - // XXX: We use a non-breaking space to pad so that the padding is - // preserved when the string is copied and pasted. - if rightPadding > 0 { var st *ansi.Style if colorWhitespace || styleWhitespace { st = &teWhitespace } - str = padRight(str, rightPadding, st, nbsp) + str = padRight(str, rightPadding, st, padChar) } if topPadding > 0 { @@ -494,8 +500,12 @@ func (s Style) applyMargins(str string, inline bool) string { } // Add left and right margin - str = padLeft(str, leftMargin, &style, ' ') - str = padRight(str, rightMargin, &style, ' ') + marginChar := s.marginChar + if marginChar == 0 { + marginChar = ' ' + } + str = padLeft(str, leftMargin, &style, marginChar) + str = padRight(str, rightMargin, &style, marginChar) // Top/bottom margin if !inline { diff --git a/vendor/github.com/charmbracelet/lipgloss/v2/unset.go b/vendor/github.com/charmbracelet/lipgloss/v2/unset.go index 1086e722686bfa48a5910c5aac110057977a501d..b6f96607c0e0c8b31f23540ef492f4d25e22c346 100644 --- a/vendor/github.com/charmbracelet/lipgloss/v2/unset.go +++ b/vendor/github.com/charmbracelet/lipgloss/v2/unset.go @@ -96,6 +96,13 @@ func (s Style) UnsetPadding() Style { s.unset(paddingRightKey) s.unset(paddingTopKey) s.unset(paddingBottomKey) + s.unset(paddingCharKey) + return s +} + +// UnsetPaddingChar removes the padding character style rule, if set. +func (s Style) UnsetPaddingChar() Style { + s.unset(paddingCharKey) return s } diff --git a/vendor/github.com/charmbracelet/ultraviolet/screen/screen.go b/vendor/github.com/charmbracelet/ultraviolet/screen/screen.go deleted file mode 100644 index fd54788456a999d1e0593b3bee9050005f7ceee2..0000000000000000000000000000000000000000 --- a/vendor/github.com/charmbracelet/ultraviolet/screen/screen.go +++ /dev/null @@ -1,109 +0,0 @@ -// Package screen provides functions and helpers to manipulate a [uv.Screen]. -package screen - -import uv "github.com/charmbracelet/ultraviolet" - -// Clear clears the screen with empty cells. This is equivalent to filling the -// screen with empty cells. -// -// If the screen implements a [Clear] method, it will be called instead of -// filling the screen with empty cells. -func Clear(scr uv.Screen) { - if c, ok := scr.(interface { - Clear() - }); ok { - c.Clear() - return - } - Fill(scr, nil) -} - -// ClearArea clears the given area of the screen with empty cells. This is -// equivalent to filling the area with empty cells. -// -// If the screen implements a [ClearArea] method, it will be called instead of -// filling the area with empty cells. -func ClearArea(scr uv.Screen, area uv.Rectangle) { - if c, ok := scr.(interface { - ClearArea(area uv.Rectangle) - }); ok { - c.ClearArea(area) - return - } - FillArea(scr, nil, area) -} - -// Fill fills the screen with the given cell. If the cell is nil, it fills the -// screen with empty cells. -// -// If the screen implements a [Fill] method, it will be called instead of -// filling the screen with empty cells. -func Fill(scr uv.Screen, cell *uv.Cell) { - if f, ok := scr.(interface { - Fill(cell *uv.Cell) - }); ok { - f.Fill(cell) - return - } - FillArea(scr, cell, scr.Bounds()) -} - -// FillArea fills the given area of the screen with the given cell. If the cell -// is nil, it fills the area with empty cells. -// -// If the screen implements a [FillArea] method, it will be called instead of -// filling the area with empty cells. -func FillArea(scr uv.Screen, cell *uv.Cell, area uv.Rectangle) { - if f, ok := scr.(interface { - FillArea(cell *uv.Cell, area uv.Rectangle) - }); ok { - f.FillArea(cell, area) - return - } - for y := area.Min.Y; y < area.Max.Y; y++ { - for x := area.Min.X; x < area.Max.X; x++ { - scr.SetCell(x, y, cell) - } - } -} - -// CloneArea clones the given area of the screen and returns a new buffer -// with the same size as the area. The new buffer will contain the same cells -// as the area in the screen. -// Use [uv.Buffer.Draw] to draw the cloned buffer to a screen again. -// -// If the screen implements a [CloneArea] method, it will be called instead of -// cloning the area manually. -func CloneArea(scr uv.Screen, area uv.Rectangle) *uv.Buffer { - if c, ok := scr.(interface { - CloneArea(area uv.Rectangle) *uv.Buffer - }); ok { - return c.CloneArea(area) - } - buf := uv.NewBuffer(area.Dx(), area.Dy()) - for y := area.Min.Y; y < area.Max.Y; y++ { - for x := area.Min.X; x < area.Max.X; x++ { - cell := scr.CellAt(x, y) - if cell == nil || cell.IsZero() { - continue - } - buf.SetCell(x-area.Min.X, y-area.Min.Y, cell.Clone()) - } - } - return buf -} - -// Clone creates a new [uv.Buffer] clone of the given screen. The new buffer will -// have the same size as the screen and will contain the same cells. -// Use [uv.Buffer.Draw] to draw the cloned buffer to a screen again. -// -// If the screen implements a [Clone] method, it will be called instead of -// cloning the entire screen manually. -func Clone(scr uv.Screen) *uv.Buffer { - if c, ok := scr.(interface { - Clone() *uv.Buffer - }); ok { - return c.Clone() - } - return CloneArea(scr, scr.Bounds()) -} diff --git a/vendor/modules.txt b/vendor/modules.txt index c0f9558afa053c70d0a1db7456dd4fc16ca73650..6fffdb0186be991baa4d563f432ed60c92ca3125 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -254,8 +254,8 @@ github.com/charmbracelet/bubbles/v2/spinner github.com/charmbracelet/bubbles/v2/textarea github.com/charmbracelet/bubbles/v2/textinput github.com/charmbracelet/bubbles/v2/viewport -# github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891 -## explicit; go 1.24.3 +# github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595 +## explicit; go 1.24.0 github.com/charmbracelet/bubbletea/v2 # github.com/charmbracelet/colorprofile v0.3.1 ## explicit; go 1.23.0 @@ -269,7 +269,7 @@ github.com/charmbracelet/glamour/v2 github.com/charmbracelet/glamour/v2/ansi github.com/charmbracelet/glamour/v2/internal/autolink github.com/charmbracelet/glamour/v2/styles -# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093 +# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb ## explicit; go 1.24.2 github.com/charmbracelet/lipgloss/v2 github.com/charmbracelet/lipgloss/v2/table @@ -280,7 +280,6 @@ github.com/charmbracelet/log/v2 # github.com/charmbracelet/ultraviolet v0.0.0-20250708152637-0fe0235c8db9 ## explicit; go 1.24.0 github.com/charmbracelet/ultraviolet -github.com/charmbracelet/ultraviolet/screen # github.com/charmbracelet/x/ansi v0.9.3 ## explicit; go 1.23.0 github.com/charmbracelet/x/ansi @@ -839,5 +838,5 @@ mvdan.cc/sh/v3/fileutil mvdan.cc/sh/v3/interp mvdan.cc/sh/v3/pattern mvdan.cc/sh/v3/syntax -# github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891 -# github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708152830-0fa4ef151093 +# github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595 +# github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250710185058-03664cb9cecb From 4a2e54099c9f124ed6e066ca7f5a2796a8ea998f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 10 Jul 2025 20:59:18 +0200 Subject: [PATCH 38/38] chore: improve initalize text --- internal/llm/prompt/initialize.go | 14 ++++++++++++++ internal/tui/components/chat/splash/splash.go | 11 ++--------- .../tui/components/dialogs/commands/commands.go | 11 ++--------- 3 files changed, 18 insertions(+), 18 deletions(-) create mode 100644 internal/llm/prompt/initialize.go diff --git a/internal/llm/prompt/initialize.go b/internal/llm/prompt/initialize.go new file mode 100644 index 0000000000000000000000000000000000000000..62a0f57c6122195490e2f989874cf5660f4a0da2 --- /dev/null +++ b/internal/llm/prompt/initialize.go @@ -0,0 +1,14 @@ +package prompt + +func Initialize() string { + return `Please analyze this codebase and create a **CRUSH.md** file containing: + +- Build/lint/test commands - especially for running a single test +- Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. + +The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20-30 lines long. +If there's already a **CRUSH.md**, improve it. + +If there are Cursor rules` + " (in `.cursor/rules/` or `.cursorrules`) or Copilot rules (in `.github/copilot-instructions.md`), make sure to include them.\n" + + "Add the `.crush` directory to the `.gitignore` file if it's not already there." +} diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index c0d959eccce503e2830c14cd24ebbe6a0403c3bd..722aaea6f75c6ef0bef7e0a9ec2de319c6d71bfb 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/fur/provider" + "github.com/charmbracelet/crush/internal/llm/prompt" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" @@ -242,14 +243,6 @@ func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd { func (s *splashCmp) initializeProject() tea.Cmd { s.needsProjectInit = false - prompt := `Please analyze this codebase and create a CRUSH.md file containing: -1. Build/lint/test commands - especially for running a single test -2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. - -The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long. -If there's already a CRUSH.md, improve it. -If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them. -Add the .crush directory to the .gitignore file if it's not already there.` if err := config.MarkProjectInitialized(); err != nil { return util.ReportError(err) @@ -261,7 +254,7 @@ Add the .crush directory to the .gitignore file if it's not already there.` cmds = append(cmds, util.CmdHandler(chat.SessionClearedMsg{}), util.CmdHandler(chat.SendMsg{ - Text: prompt, + Text: prompt.Initialize(), }), ) } diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 140996fdd59af21e27e6eb4017ca5cca847cb0d9..10cdbbd539f06836550b7da6a857d35db3becd74 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -6,6 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/crush/internal/llm/prompt" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" @@ -239,16 +240,8 @@ func (c *commandDialogCmp) defaultCommands() []Command { Title: "Initialize Project", Description: "Create/Update the CRUSH.md memory file", Handler: func(cmd Command) tea.Cmd { - prompt := `Please analyze this codebase and create a CRUSH.md file containing: - 1. Build/lint/test commands - especially for running a single test - 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. - - The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long. - If there's already a CRUSH.md, improve it. - If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them. - Add the .crush directory to the .gitignore file if it's not already there.` return util.CmdHandler(chat.SendMsg{ - Text: prompt, + Text: prompt.Initialize(), }) }, },