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()