@@ -2,21 +2,30 @@ package chat
8+ "github.com/charmbracelet/bubbles/v2/help"
8 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/key"
10+ "github.com/charmbracelet/bubbles/v2/spinner"
9 tea "github.com/charmbracelet/bubbletea/v2"
11 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/crush/internal/app"
12 "github.com/charmbracelet/crush/internal/app"
11 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/history"
12 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/message"
16+ "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/session"
17 "github.com/charmbracelet/crush/internal/session"
18+ "github.com/charmbracelet/crush/internal/tui/components/anim"
14 "github.com/charmbracelet/crush/internal/tui/components/chat"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
15 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
20 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
16 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
21 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
17 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
22 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
23+ "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
24+ "github.com/charmbracelet/crush/internal/tui/components/completions"
25+ "github.com/charmbracelet/crush/internal/tui/components/core"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
26 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
28+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
20 "github.com/charmbracelet/crush/internal/tui/page"
29 "github.com/charmbracelet/crush/internal/tui/page"
21 "github.com/charmbracelet/crush/internal/tui/styles"
30 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/util"
31 "github.com/charmbracelet/crush/internal/tui/util"
@@ -26,154 +35,223 @@ import (
27var ChatPageID page.PageID = "chat"
36var ChatPageID page.PageID = "chat"
29−const CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
32 OpenFilePickerMsg struct{}
39 OpenFilePickerMsg struct{}
33 ChatFocusedMsg struct {
40 ChatFocusedMsg struct {
34− Focused bool // True if the chat input is focused, false otherwise
41+ Focused bool
36 CancelTimerExpiredMsg struct{}
43 CancelTimerExpiredMsg struct{}
49+ PanelTypeChat PanelType = "chat"
50+ PanelTypeEditor PanelType = "editor"
51+ PanelTypeSplash PanelType = "splash"
55+ CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
56+ EditorHeight = 5 // Height of the editor input area including padding
57+ SideBarWidth = 31 // Width of the sidebar
58+ SideBarDetailsPadding = 1 // Padding for the sidebar details section
59+ HeaderHeight = 1 // Height of the header
61+ // Layout constants for borders and padding
62+ BorderWidth = 1 // Width of component borders
63+ LeftRightBorders = 2 // Left + right border width (1 + 1)
64+ TopBottomBorders = 2 // Top + bottom border width (1 + 1)
65+ DetailsPositioning = 2 // Positioning adjustment for details panel
68+ CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
39type ChatPage interface {
71type ChatPage interface {
40 util.Model
72 util.Model
41 layout.Help
73 layout.Help
42 IsChatFocused() bool
74 IsChatFocused() bool
77+// cancelTimerCmd creates a command that expires the cancel timer
78+func cancelTimerCmd() tea.Cmd {
79+ return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
80+ return CancelTimerExpiredMsg{}
45type chatPage struct {
84type chatPage struct {
46− wWidth, wHeight int // Window dimensions
85+ width, height int
86+ detailsWidth, detailsHeight int
47− app *app.App
87+ app *app.App
88+ keyboardEnhancements tea.KeyboardEnhancementsMsg
49− layout layout.SplitPaneLayout
90+ // Layout state
93+ focusedPane PanelType
51 session session.Session
96 session session.Session
100+ header header.Header
101+ sidebar sidebar.Sidebar
102+ chat chat.MessageListCmp
103+ editor editor.Editor
104+ splash splash.Splash
106+ // Simple state flags
109+ splashFullScreen bool
53− keyMap KeyMap
114+func New(app *app.App) ChatPage {
54−
115+ return &chatPage{
55− chatFocused bool
116+ app: app,
56−
117+ keyMap: DefaultKeyMap(),
57− compactMode bool
118+ header: header.New(app.LSPClients),
58− forceCompactMode bool // Force compact mode regardless of window size
119+ sidebar: sidebar.New(app.History, app.LSPClients, false),
59− showDetails bool // Show details in the header
120+ chat: chat.New(app),
60− header header.Header
121+ editor: editor.New(app),
61− compactSidebar layout.Container
122+ splash: splash.New(),
62−
123+ focusedPane: PanelTypeSplash,
63− cancelPending bool // True if ESC was pressed once and waiting for second press
124+ }
66func (p *chatPage) Init() tea.Cmd {
127func (p *chatPage) Init() tea.Cmd {
129+ compact := cfg.Options.TUI.CompactMode
131+ p.forceCompact = compact
132+ p.sidebar.SetCompactMode(p.compact)
134+ // Set splash state based on config
135+ if !config.HasInitialDataConfig() {
136+ // First-time setup: show model selection
137+ p.splash.SetOnboarding(true)
138+ p.isOnboarding = true
139+ p.splashFullScreen = true
140+ } else if b, _ := config.ProjectNeedsInitialization(); b {
141+ // Project needs CRUSH.md initialization
142+ p.splash.SetProjectInit(true)
143+ p.isProjectInit = true
144+ p.splashFullScreen = true
146+ // Ready to chat: focus editor, splash in background
147+ p.focusedPane = PanelTypeEditor
148+ p.splashFullScreen = false
67 return tea.Batch(
151 return tea.Batch(
68− p.layout.Init(),
152+ p.header.Init(),
69− p.compactSidebar.Init(),
153+ p.sidebar.Init(),
70− p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor),
154+ p.chat.Init(),
74−// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
75−func (p *chatPage) cancelTimerCmd() tea.Cmd {
76− return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
77− return CancelTimerExpiredMsg{}
81func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
160func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82 var cmds []tea.Cmd
161 var cmds []tea.Cmd
83 switch msg := msg.(type) {
162 switch msg := msg.(type) {
84 case tea.KeyboardEnhancementsMsg:
163 case tea.KeyboardEnhancementsMsg:
85− m, cmd := p.layout.Update(msg)
164+ p.keyboardEnhancements = msg
86− p.layout = m.(layout.SplitPaneLayout)
88− case CancelTimerExpiredMsg:
89− p.cancelPending = false
90 return p, nil
165 return p, nil
91 case tea.WindowSizeMsg:
166 case tea.WindowSizeMsg:
92− h, cmd := p.header.Update(msg)
167+ return p, p.SetSize(msg.Width, msg.Height)
93− cmds = append(cmds, cmd)
168+ case CancelTimerExpiredMsg:
94− p.header = h.(header.Header)
169+ p.isCanceling = false
95− cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0))
170+ return p, nil
96− // the mode is only relevant when there is a session
171+ case chat.SendMsg:
97− if p.session.ID != "" {
172+ return p, p.sendMessage(msg.Text, msg.Attachments)
98− // Only auto-switch to compact mode if not forced
173+ case chat.SessionSelectedMsg:
99− if !p.forceCompactMode {
174+ return p, p.setSession(msg)
100− if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
175+ case commands.ToggleCompactModeMsg:
101− p.wWidth = msg.Width
176+ p.forceCompact = !p.forceCompact
102− p.wHeight = msg.Height
177+ var cmd tea.Cmd
103− cmds = append(cmds, p.setCompactMode(true))
178+ if p.forceCompact {
104− return p, tea.Batch(cmds...)
179+ p.setCompactMode(true)
180+ cmd = p.updateCompactConfig(true)
105− } else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
181+ } else if p.width >= CompactModeBreakpoint {
106− p.wWidth = msg.Width
107− p.wHeight = msg.Height
108− return p, p.setCompactMode(false)
182+ p.setCompactMode(false)
109− }
183+ cmd = p.updateCompactConfig(false)
112− p.wWidth = msg.Width
113− p.wHeight = msg.Height
114− layoutHeight := msg.Height
116− // make space for the header
119− cmd = p.layout.SetSize(msg.Width, layoutHeight)
185+ return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
186+ case pubsub.Event[session.Session]:
187+ u, cmd := p.header.Update(msg)
188+ p.header = u.(header.Header)
189+ cmds = append(cmds, cmd)
190+ u, cmd = p.sidebar.Update(msg)
191+ p.sidebar = u.(sidebar.Sidebar)
192+ cmds = append(cmds, cmd)
193+ return p, tea.Batch(cmds...)
194+ case chat.SessionClearedMsg:
195+ u, cmd := p.header.Update(msg)
196+ p.header = u.(header.Header)
197+ cmds = append(cmds, cmd)
198+ u, cmd = p.sidebar.Update(msg)
199+ p.sidebar = u.(sidebar.Sidebar)
200+ cmds = append(cmds, cmd)
201+ u, cmd = p.chat.Update(msg)
202+ p.chat = u.(chat.MessageListCmp)
203+ cmds = append(cmds, cmd)
204+ return p, tea.Batch(cmds...)
205+ case filepicker.FilePickedMsg,
206+ completions.CompletionsClosedMsg,
207+ completions.SelectCompletionMsg:
208+ u, cmd := p.editor.Update(msg)
209+ p.editor = u.(editor.Editor)
210+ cmds = append(cmds, cmd)
211+ return p, tea.Batch(cmds...)
213+ case pubsub.Event[message.Message],
216+ u, cmd := p.chat.Update(msg)
217+ p.chat = u.(chat.MessageListCmp)
218+ cmds = append(cmds, cmd)
219+ return p, tea.Batch(cmds...)
221+ case pubsub.Event[history.File], sidebar.SessionFilesMsg:
222+ u, cmd := p.sidebar.Update(msg)
223+ p.sidebar = u.(sidebar.Sidebar)
120 cmds = append(cmds, cmd)
224 cmds = append(cmds, cmd)
121 return p, tea.Batch(cmds...)
225 return p, tea.Batch(cmds...)
124− cmd := p.sendMessage(msg.Text, msg.Attachments)
128− case commands.ToggleCompactModeMsg:
129− // Only allow toggling if window width is larger than compact breakpoint
130− if p.wWidth > CompactModeBreakpoint {
131− p.forceCompactMode = !p.forceCompactMode
132− // If force compact mode is enabled, switch to compact mode
133− // If force compact mode is disabled, switch based on window size
134− if p.forceCompactMode {
135− return p, p.setCompactMode(true)
137− // Return to auto mode based on window size
138− shouldBeCompact := p.wWidth <= CompactModeBreakpoint
139− return p, p.setCompactMode(shouldBeCompact)
142 case commands.CommandRunCustomMsg:
227 case commands.CommandRunCustomMsg:
143− // Check if the agent is busy before executing custom commands
144 if p.app.CoderAgent.IsBusy() {
228 if p.app.CoderAgent.IsBusy() {
145 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
229 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
148− // Handle custom command execution
149 cmd := p.sendMessage(msg.Content, nil)
232 cmd := p.sendMessage(msg.Content, nil)
150 if cmd != nil {
233 if cmd != nil {
151 return p, cmd
234 return p, cmd
153− case chat.SessionSelectedMsg:
236+ case splash.OnboardingCompleteMsg:
154− if p.session.ID == "" {
237+ p.splashFullScreen = false
155− cmd := p.setMessages()
238+ if b, _ := config.ProjectNeedsInitialization(); b {
156− if cmd != nil {
239+ p.splash.SetProjectInit(true)
157− cmds = append(cmds, cmd)
240+ p.splashFullScreen = true
158− }
241+ return p, p.SetSize(p.width, p.height)
160− needsModeChange := p.session.ID == ""
243+ err := p.app.InitCoderAgent()
161− p.session = msg
244+ if err != nil {
162− p.header.SetSession(msg)
245+ return p, util.ReportError(err)
163− if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
164− cmds = append(cmds, p.setCompactMode(true))
247+ p.isOnboarding = false
248+ p.isProjectInit = false
249+ p.focusedPane = PanelTypeEditor
250+ return p, p.SetSize(p.width, p.height)
166 case tea.KeyPressMsg:
251 case tea.KeyPressMsg:
168 case key.Matches(msg, p.keyMap.NewSession):
253 case key.Matches(msg, p.keyMap.NewSession):
169− p.session = session.Session{}
170− return p, tea.Batch(
254+ return p, p.newSession()
172− util.CmdHandler(chat.SessionClearedMsg{}),
173− p.setCompactMode(false),
174− p.layout.FocusPanel(layout.BottomPanel),
175− util.CmdHandler(ChatFocusedMsg{Focused: false}),
177 case key.Matches(msg, p.keyMap.AddAttachment):
255 case key.Matches(msg, p.keyMap.AddAttachment):
178 agentCfg := config.Get().Agents["coder"]
256 agentCfg := config.Get().Agents["coder"]
179 model := config.Get().GetModelByType(agentCfg.Model)
257 model := config.Get().GetModelByType(agentCfg.Model)
@@ -184,171 +262,290 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
185 case key.Matches(msg, p.keyMap.Tab):
263 case key.Matches(msg, p.keyMap.Tab):
186 if p.session.ID == "" {
264 if p.session.ID == "" {
265+ u, cmd := p.splash.Update(msg)
266+ p.splash = u.(splash.Splash)
187− return p, nil
267+ return p, cmd
189− p.chatFocused = !p.chatFocused
191− cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
192− cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
194− cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
195− cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
197− return p, tea.Batch(cmds...)
269+ p.changeFocus()
198 case key.Matches(msg, p.keyMap.Cancel):
271 case key.Matches(msg, p.keyMap.Cancel):
199− if p.session.ID != "" {
272+ if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
200− if p.cancelPending {
201− // Second ESC press - actually cancel the session
202− p.cancelPending = false
203− p.app.CoderAgent.Cancel(p.session.ID)
206− // First ESC press - start the timer
207− p.cancelPending = true
208− return p, p.cancelTimerCmd()
273+ return p, p.cancel()
211 case key.Matches(msg, p.keyMap.Details):
275 case key.Matches(msg, p.keyMap.Details):
212− if p.session.ID == "" || !p.compactMode {
213− return p, nil // No session to show details for
215− p.showDetails = !p.showDetails
276+ p.showDetails()
216− p.header.SetDetailsOpen(p.showDetails)
218− return p, tea.Batch()
221 return p, nil
277 return p, nil
280+ switch p.focusedPane {
282+ u, cmd := p.chat.Update(msg)
283+ p.chat = u.(chat.MessageListCmp)
284+ cmds = append(cmds, cmd)
285+ case PanelTypeEditor:
286+ u, cmd := p.editor.Update(msg)
287+ p.editor = u.(editor.Editor)
288+ cmds = append(cmds, cmd)
289+ case PanelTypeSplash:
290+ u, cmd := p.splash.Update(msg)
291+ p.splash = u.(splash.Splash)
292+ cmds = append(cmds, cmd)
295+ switch p.focusedPane {
296+ case PanelTypeEditor:
297+ u, cmd := p.editor.Update(msg)
298+ p.editor = u.(editor.Editor)
299+ cmds = append(cmds, cmd)
300+ return p, tea.Batch(cmds...)
302+ u, cmd := p.chat.Update(msg)
303+ p.chat = u.(chat.MessageListCmp)
304+ cmds = append(cmds, cmd)
305+ return p, tea.Batch(cmds...)
306+ case PanelTypeSplash:
307+ u, cmd := p.splash.Update(msg)
308+ p.splash = u.(splash.Splash)
309+ cmds = append(cmds, cmd)
310+ return p, tea.Batch(cmds...)
224− u, cmd := p.layout.Update(msg)
225− cmds = append(cmds, cmd)
226− p.layout = u.(layout.SplitPaneLayout)
227− h, cmd := p.header.Update(msg)
228− p.header = h.(header.Header)
229− cmds = append(cmds, cmd)
230− s, cmd := p.compactSidebar.Update(msg)
231− p.compactSidebar = s.(layout.Container)
232− cmds = append(cmds, cmd)
233 return p, tea.Batch(cmds...)
313 return p, tea.Batch(cmds...)
236−func (p *chatPage) setMessages() tea.Cmd {
316+func (p *chatPage) Cursor() *tea.Cursor {
237− messagesContainer := layout.NewContainer(
317+ switch p.focusedPane {
238− chat.NewMessagesListCmp(p.app),
318+ case PanelTypeEditor:
239− layout.WithPadding(1, 1, 0, 1),
319+ return p.editor.Cursor()
240− )
320+ case PanelTypeSplash:
241− return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
321+ return p.splash.Cursor()
244−func (p *chatPage) setSidebar() tea.Cmd {
327+func (p *chatPage) View() string {
245− sidebarContainer := sidebarCmp(p.app, false, p.session)
328+ var chatView string
246− sidebarContainer.Init()
329+ t := styles.CurrentTheme()
247− return p.layout.SetRightPanel(sidebarContainer)
330+
331+ if p.session.ID == "" {
332+ splashView := p.splash.View()
333+ // Full screen during onboarding or project initialization
334+ if p.splashFullScreen {
335+ chatView = splashView
337+ // Show splash + editor for new message state
338+ editorView := p.editor.View()
339+ chatView = lipgloss.JoinVertical(
341+ t.S().Base.Render(splashView),
346+ messagesView := p.chat.View()
347+ editorView := p.editor.View()
349+ headerView := p.header.View()
350+ chatView = lipgloss.JoinVertical(
357+ sidebarView := p.sidebar.View()
358+ messages := lipgloss.JoinHorizontal(
363+ chatView = lipgloss.JoinVertical(
371+ layers := []*lipgloss.Layer{
372+ lipgloss.NewLayer(chatView).X(0).Y(0),
375+ if p.showingDetails {
376+ style := t.S().Base.
377+ Width(p.detailsWidth).
378+ Border(lipgloss.RoundedBorder()).
379+ BorderForeground(t.BorderFocus)
380+ version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
381+ details := style.Render(
382+ lipgloss.JoinVertical(
388+ layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
390+ canvas := lipgloss.NewCanvas(
393+ return canvas.Render()
250−func (p *chatPage) clearMessages() tea.Cmd {
396+func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
251− return p.layout.ClearLeftPanel()
397+ return func() tea.Msg {
398+ err := config.Get().SetCompactMode(compact)
400+ return util.InfoMsg{
401+ Type: util.InfoTypeError,
402+ Msg: "Failed to update compact mode configuration: " + err.Error(),
254−func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
409+func (p *chatPage) setCompactMode(compact bool) {
410+ if p.compact == compact {
255− p.compactMode = compact
413+ p.compact = compact
257 if compact {
414 if compact {
258− // add offset for the header
415+ p.compact = true
259− p.layout.SetOffset(0, 1)
416+ p.sidebar.SetCompactMode(true)
260− // make space for the header
261− cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
262− // remove the sidebar
263− cmds = append(cmds, p.layout.ClearRightPanel())
264− return tea.Batch(cmds...)
266− // remove the offset for the header
418+ p.compact = false
267− p.layout.SetOffset(0, 0)
419+ p.showingDetails = false
268− // restore the original size
420+ p.sidebar.SetCompactMode(false)
269− cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
421+ }
270− // set the sidebar
422+}
271− cmds = append(cmds, p.setSidebar())
272− l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session))
273− p.layout = l.(layout.SplitPaneLayout)
274− cmds = append(cmds, cmd)
424+func (p *chatPage) handleCompactMode(newWidth int) {
276− return tea.Batch(cmds...)
426+ return
428+ if newWidth < CompactModeBreakpoint && !p.compact {
429+ p.setCompactMode(true)
431+ if newWidth >= CompactModeBreakpoint && p.compact {
432+ p.setCompactMode(false)
280−func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
436+func (p *chatPage) SetSize(width, height int) tea.Cmd {
437+ p.handleCompactMode(width)
281 var cmds []tea.Cmd
440 var cmds []tea.Cmd
282 if p.session.ID == "" {
442 if p.session.ID == "" {
283− session, err := p.app.Sessions.Create(context.Background(), "New Session")
443+ if p.splashFullScreen {
284− if err != nil {
444+ cmds = append(cmds, p.splash.SetSize(width, height))
285− return util.ReportError(err)
445+ } else {
446+ cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
447+ cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
448+ cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
288− p.session = session
451+ if p.compact {
289− cmd := p.setMessages()
452+ cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
290− if cmd != nil {
453+ p.detailsWidth = width - DetailsPositioning
291− cmds = append(cmds, cmd)
454+ cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
455+ cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
456+ cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
458+ cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
459+ cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
460+ cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
293− cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
462+ cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
296− _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
298− return util.ReportError(err)
300 return tea.Batch(cmds...)
464 return tea.Batch(cmds...)
303−func (p *chatPage) SetSize(width, height int) tea.Cmd {
467+func (p *chatPage) newSession() tea.Cmd {
468+ if p.session.ID == "" {
472+ p.session = session.Session{}
473+ p.focusedPane = PanelTypeEditor
474+ p.isCanceling = false
476+ util.CmdHandler(chat.SessionClearedMsg{}),
304− return p.layout.SetSize(width, height)
477+ p.SetSize(p.width, p.height),
307−func (p *chatPage) GetSize() (int, int) {
481+func (p *chatPage) setSession(session session.Session) tea.Cmd {
308− return p.layout.GetSize()
482+ if p.session.ID == session.ID {
489+ cmds = append(cmds, p.SetSize(p.width, p.height))
490+ cmds = append(cmds, p.chat.SetSession(session))
491+ cmds = append(cmds, p.sidebar.SetSession(session))
492+ cmds = append(cmds, p.header.SetSession(session))
493+ cmds = append(cmds, p.editor.SetSession(session))
495+ return tea.Sequence(cmds...)
311−func (p *chatPage) View() string {
498+func (p *chatPage) changeFocus() {
312− if !p.compactMode || p.session.ID == "" {
499+ if p.session.ID == "" {
313− // If not in compact mode or there is no session, we don't show the header
314− return p.layout.View()
500+ return
316− layoutView := p.layout.View()
502+ switch p.focusedPane {
317− chatView := strings.Join(
503+ case PanelTypeChat:
318− []string{
504+ p.focusedPane = PanelTypeEditor
319− p.header.View(),
505+ p.editor.Focus()
320− layoutView,
506+ p.chat.Blur()
321− }, "\n",
507+ case PanelTypeEditor:
322− )
508+ p.focusedPane = PanelTypeChat
323− layers := []*lipgloss.Layer{
509+ p.chat.Focus()
324− lipgloss.NewLayer(chatView).X(0).Y(0),
510+ p.editor.Blur()
326− if p.showDetails {
512+}
327− t := styles.CurrentTheme()
513+
328− style := t.S().Base.
514+func (p *chatPage) cancel() tea.Cmd {
329− Border(lipgloss.RoundedBorder()).
515+ if p.isCanceling {
330− BorderForeground(t.BorderFocus)
516+ p.isCanceling = false
331− version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
517+ p.app.CoderAgent.Cancel(p.session.ID)
332− details := style.Render(
518+ return nil
333− lipgloss.JoinVertical(
335− p.compactSidebar.View(),
339− layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
341− canvas := lipgloss.NewCanvas(
520+
342− layers...,
521+ p.isCanceling = true
343− )
522+ return cancelTimerCmd()
344− return canvas.Render()
347−func (p *chatPage) Cursor() *tea.Cursor {
525+func (p *chatPage) showDetails() {
348− if v, ok := p.layout.(util.Cursor); ok {
526+ if p.session.ID == "" || !p.compact {
349− return v.Cursor()
527+ return
529+ p.showingDetails = !p.showingDetails
530+ p.header.SetDetailsOpen(p.showingDetails)
533+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
534+ session := p.session
536+ if p.session.ID == "" {
537+ newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
539+ return util.ReportError(err)
541+ session = newSession
542+ cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
544+ _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
546+ return util.ReportError(err)
351− return nil
548+ return tea.Batch(cmds...)
354func (p *chatPage) Bindings() []key.Binding {
551func (p *chatPage) Bindings() []key.Binding {
@@ -356,9 +553,9 @@ func (p *chatPage) Bindings() []key.Binding {
356 p.keyMap.NewSession,
553 p.keyMap.NewSession,
357 p.keyMap.AddAttachment,
554 p.keyMap.AddAttachment,
359− if p.app.CoderAgent.IsBusy() {
556+ if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
360 cancelBinding := p.keyMap.Cancel
557 cancelBinding := p.keyMap.Cancel
361− if p.cancelPending {
558+ if p.isCanceling {
362 cancelBinding = key.NewBinding(
559 cancelBinding = key.NewBinding(
363 key.WithKeys("esc"),
560 key.WithKeys("esc"),
364 key.WithHelp("esc", "press again to cancel"),
561 key.WithHelp("esc", "press again to cancel"),
@@ -367,61 +564,275 @@ func (p *chatPage) Bindings() []key.Binding {
367 bindings = append([]key.Binding{cancelBinding}, bindings...)
564 bindings = append([]key.Binding{cancelBinding}, bindings...)
370− if p.chatFocused {
567+ switch p.focusedPane {
371 bindings = append([]key.Binding{
569 bindings = append([]key.Binding{
372 key.NewBinding(
570 key.NewBinding(
373 key.WithKeys("tab"),
571 key.WithKeys("tab"),
374 key.WithHelp("tab", "focus editor"),
572 key.WithHelp("tab", "focus editor"),
376 }, bindings...)
574 }, bindings...)
377− } else {
575+ bindings = append(bindings, p.chat.Bindings()...)
576+ case PanelTypeEditor:
378 bindings = append([]key.Binding{
577 bindings = append([]key.Binding{
379 key.NewBinding(
578 key.NewBinding(
380 key.WithKeys("tab"),
579 key.WithKeys("tab"),
381 key.WithHelp("tab", "focus chat"),
580 key.WithHelp("tab", "focus chat"),
383 }, bindings...)
582 }, bindings...)
583+ bindings = append(bindings, p.editor.Bindings()...)
584+ case PanelTypeSplash:
585+ bindings = append(bindings, p.splash.Bindings()...)
386− bindings = append(bindings, p.layout.Bindings()...)
387 return bindings
588 return bindings
390−func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container {
591+func (a *chatPage) Help() help.KeyMap {
391− padding := layout.WithPadding(1, 1, 1, 1)
592+ var shortList []key.Binding
392− if compact {
593+ var fullList [][]key.Binding
393− padding = layout.WithPadding(0, 1, 1, 1)
594+ switch {
394− }
595+ case a.isOnboarding && !a.splash.IsShowingAPIKey():
395− sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact)
596+ shortList = append(shortList,
396− if session.ID != "" {
597+ // Choose model
397− sidebar.SetSession(session)
598+ key.NewBinding(
599+ key.WithKeys("up", "down"),
600+ key.WithHelp("↑/↓", "choose"),
604+ key.WithKeys("enter", "ctrl+y"),
605+ key.WithHelp("enter", "accept"),
609+ key.WithKeys("ctrl+c"),
610+ key.WithHelp("ctrl+c", "quit"),
613+ // keep them the same
614+ for _, v := range shortList {
615+ fullList = append(fullList, []key.Binding{v})
617+ case a.isOnboarding && a.splash.IsShowingAPIKey():
618+ var pasteKey key.Binding
619+ if runtime.GOOS != "darwin" {
620+ pasteKey = key.NewBinding(
621+ key.WithKeys("ctrl+v"),
622+ key.WithHelp("ctrl+v", "paste API key"),
625+ pasteKey = key.NewBinding(
626+ key.WithKeys("cmd+v"),
627+ key.WithHelp("cmd+v", "paste API key"),
630+ shortList = append(shortList,
633+ key.WithKeys("esc"),
634+ key.WithHelp("esc", "back"),
640+ key.WithKeys("ctrl+c"),
641+ key.WithHelp("ctrl+c", "quit"),
644+ // keep them the same
645+ for _, v := range shortList {
646+ fullList = append(fullList, []key.Binding{v})
648+ case a.isProjectInit:
649+ shortList = append(shortList,
651+ key.WithKeys("ctrl+c"),
652+ key.WithHelp("ctrl+c", "quit"),
655+ // keep them the same
656+ for _, v := range shortList {
657+ fullList = append(fullList, []key.Binding{v})
660+ if a.editor.IsCompletionsOpen() {
661+ shortList = append(shortList,
663+ key.WithKeys("tab", "enter"),
664+ key.WithHelp("tab/enter", "complete"),
667+ key.WithKeys("esc"),
668+ key.WithHelp("esc", "cancel"),
671+ key.WithKeys("up", "down"),
672+ key.WithHelp("↑/↓", "choose"),
675+ for _, v := range shortList {
676+ fullList = append(fullList, []key.Binding{v})
678+ return core.NewSimpleHelp(shortList, fullList)
680+ if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
681+ cancelBinding := key.NewBinding(
682+ key.WithKeys("esc"),
683+ key.WithHelp("esc", "cancel"),
686+ cancelBinding = key.NewBinding(
687+ key.WithKeys("esc"),
688+ key.WithHelp("esc", "press again to cancel"),
691+ shortList = append(shortList, cancelBinding)
692+ fullList = append(fullList,
698+ globalBindings := []key.Binding{}
699+ // we are in a session
700+ if a.session.ID != "" {
701+ tabKey := key.NewBinding(
702+ key.WithKeys("tab"),
703+ key.WithHelp("tab", "focus chat"),
705+ if a.focusedPane == PanelTypeChat {
706+ tabKey = key.NewBinding(
707+ key.WithKeys("tab"),
708+ key.WithHelp("tab", "focus editor"),
711+ shortList = append(shortList, tabKey)
712+ globalBindings = append(globalBindings, tabKey)
714+ commandsBinding := key.NewBinding(
715+ key.WithKeys("ctrl+p"),
716+ key.WithHelp("ctrl+p", "commands"),
718+ helpBinding := key.NewBinding(
719+ key.WithKeys("ctrl+g"),
720+ key.WithHelp("ctrl+g", "more"),
722+ globalBindings = append(globalBindings, commandsBinding)
723+ globalBindings = append(globalBindings,
725+ key.WithKeys("ctrl+s"),
726+ key.WithHelp("ctrl+s", "sessions"),
729+ if a.session.ID != "" {
730+ globalBindings = append(globalBindings,
732+ key.WithKeys("ctrl+n"),
733+ key.WithHelp("ctrl+n", "new sessions"),
736+ shortList = append(shortList,
740+ fullList = append(fullList, globalBindings)
742+ if a.focusedPane == PanelTypeChat {
743+ shortList = append(shortList,
745+ key.WithKeys("up", "down"),
746+ key.WithHelp("↑↓", "scroll"),
749+ fullList = append(fullList,
752+ key.WithKeys("up", "down"),
753+ key.WithHelp("↑↓", "scroll"),
756+ key.WithKeys("shift+up", "shift+down"),
757+ key.WithHelp("shift+↑↓", "next/prev item"),
760+ key.WithKeys("pgup", "b"),
761+ key.WithHelp("b/pgup", "page up"),
764+ key.WithKeys("pgdown", " ", "f"),
765+ key.WithHelp("f/pgdn", "page down"),
771+ key.WithHelp("u", "half page up"),
775+ key.WithHelp("d", "half page down"),
778+ key.WithKeys("g", "home"),
779+ key.WithHelp("g", "hone"),
782+ key.WithKeys("G", "end"),
783+ key.WithHelp("G", "end"),
787+ } else if a.focusedPane == PanelTypeEditor {
788+ newLineBinding := key.NewBinding(
789+ key.WithKeys("shift+enter", "ctrl+j"),
790+ // "ctrl+j" is a common keybinding for newline in many editors. If
791+ // the terminal supports "shift+enter", we substitute the help text
793+ key.WithHelp("ctrl+j", "newline"),
795+ if a.keyboardEnhancements.SupportsKeyDisambiguation() {
796+ newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
798+ shortList = append(shortList, newLineBinding)
799+ fullList = append(fullList,
803+ key.WithKeys("ctrl+f"),
804+ key.WithHelp("ctrl+f", "add image"),
808+ key.WithHelp("/", "add file"),
811+ key.WithKeys("ctrl+v"),
812+ key.WithHelp("ctrl+v", "open editor"),
816+ shortList = append(shortList,
819+ key.WithKeys("ctrl+c"),
820+ key.WithHelp("ctrl+c", "quit"),
825+ fullList = append(fullList, []key.Binding{
827+ key.WithKeys("ctrl+g"),
828+ key.WithHelp("ctrl+g", "less"),
400− return layout.NewContainer(
833+ return core.NewSimpleHelp(shortList, fullList)
406−func NewChatPage(app *app.App) ChatPage {
407− editorContainer := layout.NewContainer(
408− editor.NewEditorCmp(app),
412− layout: layout.NewSplitPane(
413− layout.WithRightPanel(sidebarCmp(app, false, session.Session{})),
414− layout.WithBottomPanel(editorContainer),
415− layout.WithFixedBottomHeight(5),
416− layout.WithFixedRightWidth(31),
418− compactSidebar: sidebarCmp(app, true, session.Session{}),
419− keyMap: DefaultKeyMap(),
420− header: header.New(app.LSPClients),
424−// IsChatFocused returns whether the chat messages are focused (true) or editor is focused (false)
425func (p *chatPage) IsChatFocused() bool {
836func (p *chatPage) IsChatFocused() bool {
426− return p.chatFocused
837+ return p.focusedPane == PanelTypeChat