@@ -2,21 +2,30 @@ package chat
22
33import (
44 "context"
5− "strings"
5+ "runtime"
66 "time"
77
8+ "github.com/charmbracelet/bubbles/v2/help"
89 "github.com/charmbracelet/bubbles/v2/key"
10+ "github.com/charmbracelet/bubbles/v2/spinner"
911 tea "github.com/charmbracelet/bubbletea/v2"
1012 "github.com/charmbracelet/crush/internal/app"
1113 "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/history"
1215 "github.com/charmbracelet/crush/internal/message"
16+ "github.com/charmbracelet/crush/internal/pubsub"
1317 "github.com/charmbracelet/crush/internal/session"
18+ "github.com/charmbracelet/crush/internal/tui/components/anim"
1419 "github.com/charmbracelet/crush/internal/tui/components/chat"
1520 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
1621 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
1722 "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"
1826 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
1927 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
28+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
2029 "github.com/charmbracelet/crush/internal/tui/page"
2130 "github.com/charmbracelet/crush/internal/tui/styles"
2231 "github.com/charmbracelet/crush/internal/tui/util"
@@ -26,154 +35,223 @@ import (
2635
2736var ChatPageID page.PageID = "chat"
2837
29−const CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
30−
3138type (
3239 OpenFilePickerMsg struct{}
3340 ChatFocusedMsg struct {
34− Focused bool // True if the chat input is focused, false otherwise
41+ Focused bool
3542 }
3643 CancelTimerExpiredMsg struct{}
3744)
3845
46+type PanelType string
47+
48+const (
49+ PanelTypeChat PanelType = "chat"
50+ PanelTypeEditor PanelType = "editor"
51+ PanelTypeSplash PanelType = "splash"
52+)
53+
54+const (
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
60+
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
66+
67+ // Timing constants
68+ CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
69+)
70+
3971type ChatPage interface {
4072 util.Model
4173 layout.Help
4274 IsChatFocused() bool
4375}
4476
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{}
81+ })
82+}
83+
4584type chatPage struct {
46− wWidth, wHeight int // Window dimensions
47− app *app.App
85+ width, height int
86+ detailsWidth, detailsHeight int
87+ app *app.App
88+ keyboardEnhancements tea.KeyboardEnhancementsMsg
4889
49− layout layout.SplitPaneLayout
90+ // Layout state
91+ compact bool
92+ forceCompact bool
93+ focusedPane PanelType
5094
95+ // Session
5196 session session.Session
97+ keyMap KeyMap
98+
99+ // Components
100+ header header.Header
101+ sidebar sidebar.Sidebar
102+ chat chat.MessageListCmp
103+ editor editor.Editor
104+ splash splash.Splash
105+
106+ // Simple state flags
107+ showingDetails bool
108+ isCanceling bool
109+ splashFullScreen bool
110+ isOnboarding bool
111+ isProjectInit bool
112+}
52113
53− keyMap KeyMap
54−
55− chatFocused bool
56−
57− compactMode bool
58− forceCompactMode bool // Force compact mode regardless of window size
59− showDetails bool // Show details in the header
60− header header.Header
61− compactSidebar layout.Container
62−
63− cancelPending bool // True if ESC was pressed once and waiting for second press
114+func New(app *app.App) ChatPage {
115+ return &chatPage{
116+ app: app,
117+ keyMap: DefaultKeyMap(),
118+ header: header.New(app.LSPClients),
119+ sidebar: sidebar.New(app.History, app.LSPClients, false),
120+ chat: chat.New(app),
121+ editor: editor.New(app),
122+ splash: splash.New(),
123+ focusedPane: PanelTypeSplash,
124+ }
64125}
65126
66127func (p *chatPage) Init() tea.Cmd {
128+ cfg := config.Get()
129+ compact := cfg.Options.TUI.CompactMode
130+ p.compact = compact
131+ p.forceCompact = compact
132+ p.sidebar.SetCompactMode(p.compact)
133+
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
145+ } else {
146+ // Ready to chat: focus editor, splash in background
147+ p.focusedPane = PanelTypeEditor
148+ p.splashFullScreen = false
149+ }
150+
67151 return tea.Batch(
68− p.layout.Init(),
69− p.compactSidebar.Init(),
70− p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor),
152+ p.header.Init(),
153+ p.sidebar.Init(),
154+ p.chat.Init(),
155+ p.editor.Init(),
156+ p.splash.Init(),
71157 )
72158}
73159
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{}
78− })
79−}
80−
81160func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82161 var cmds []tea.Cmd
83162 switch msg := msg.(type) {
84163 case tea.KeyboardEnhancementsMsg:
85− m, cmd := p.layout.Update(msg)
86− p.layout = m.(layout.SplitPaneLayout)
87− return p, cmd
88− case CancelTimerExpiredMsg:
89− p.cancelPending = false
164+ p.keyboardEnhancements = msg
90165 return p, nil
91166 case tea.WindowSizeMsg:
92− h, cmd := p.header.Update(msg)
93− cmds = append(cmds, cmd)
94− p.header = h.(header.Header)
95− cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0))
96− // the mode is only relevant when there is a session
97− if p.session.ID != "" {
98− // Only auto-switch to compact mode if not forced
99− if !p.forceCompactMode {
100− if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
101− p.wWidth = msg.Width
102− p.wHeight = msg.Height
103− cmds = append(cmds, p.setCompactMode(true))
104− return p, tea.Batch(cmds...)
105− } else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
106− p.wWidth = msg.Width
107− p.wHeight = msg.Height
108− return p, p.setCompactMode(false)
109− }
110− }
111− }
112− p.wWidth = msg.Width
113− p.wHeight = msg.Height
114− layoutHeight := msg.Height
115− if p.compactMode {
116− // make space for the header
117− layoutHeight -= 1
167+ return p, p.SetSize(msg.Width, msg.Height)
168+ case CancelTimerExpiredMsg:
169+ p.isCanceling = false
170+ return p, nil
171+ case chat.SendMsg:
172+ return p, p.sendMessage(msg.Text, msg.Attachments)
173+ case chat.SessionSelectedMsg:
174+ return p, p.setSession(msg)
175+ case commands.ToggleCompactModeMsg:
176+ p.forceCompact = !p.forceCompact
177+ var cmd tea.Cmd
178+ if p.forceCompact {
179+ p.setCompactMode(true)
180+ cmd = p.updateCompactConfig(true)
181+ } else if p.width >= CompactModeBreakpoint {
182+ p.setCompactMode(false)
183+ cmd = p.updateCompactConfig(false)
118184 }
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...)
212+
213+ case pubsub.Event[message.Message],
214+ anim.StepMsg,
215+ spinner.TickMsg:
216+ u, cmd := p.chat.Update(msg)
217+ p.chat = u.(chat.MessageListCmp)
218+ cmds = append(cmds, cmd)
219+ return p, tea.Batch(cmds...)
220+
221+ case pubsub.Event[history.File], sidebar.SessionFilesMsg:
222+ u, cmd := p.sidebar.Update(msg)
223+ p.sidebar = u.(sidebar.Sidebar)
120224 cmds = append(cmds, cmd)
121225 return p, tea.Batch(cmds...)
122226
123− case chat.SendMsg:
124− cmd := p.sendMessage(msg.Text, msg.Attachments)
125− if cmd != nil {
126− return p, cmd
127− }
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)
136− } else {
137− // Return to auto mode based on window size
138− shouldBeCompact := p.wWidth <= CompactModeBreakpoint
139− return p, p.setCompactMode(shouldBeCompact)
140− }
141− }
142227 case commands.CommandRunCustomMsg:
143− // Check if the agent is busy before executing custom commands
144228 if p.app.CoderAgent.IsBusy() {
145229 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
146230 }
147231
148− // Handle custom command execution
149232 cmd := p.sendMessage(msg.Content, nil)
150233 if cmd != nil {
151234 return p, cmd
152235 }
153− case chat.SessionSelectedMsg:
154− if p.session.ID == "" {
155− cmd := p.setMessages()
156− if cmd != nil {
157− cmds = append(cmds, cmd)
158− }
236+ case splash.OnboardingCompleteMsg:
237+ p.splashFullScreen = false
238+ if b, _ := config.ProjectNeedsInitialization(); b {
239+ p.splash.SetProjectInit(true)
240+ p.splashFullScreen = true
241+ return p, p.SetSize(p.width, p.height)
159242 }
160− needsModeChange := p.session.ID == ""
161− p.session = msg
162− p.header.SetSession(msg)
163− if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
164− cmds = append(cmds, p.setCompactMode(true))
243+ err := p.app.InitCoderAgent()
244+ if err != nil {
245+ return p, util.ReportError(err)
165246 }
247+ p.isOnboarding = false
248+ p.isProjectInit = false
249+ p.focusedPane = PanelTypeEditor
250+ return p, p.SetSize(p.width, p.height)
166251 case tea.KeyPressMsg:
167252 switch {
168253 case key.Matches(msg, p.keyMap.NewSession):
169− p.session = session.Session{}
170− return p, tea.Batch(
171− p.clearMessages(),
172− util.CmdHandler(chat.SessionClearedMsg{}),
173− p.setCompactMode(false),
174− p.layout.FocusPanel(layout.BottomPanel),
175− util.CmdHandler(ChatFocusedMsg{Focused: false}),
176− )
254+ return p, p.newSession()
177255 case key.Matches(msg, p.keyMap.AddAttachment):
178256 agentCfg := config.Get().Agents["coder"]
179257 model := config.Get().GetModelByType(agentCfg.Model)
@@ -184,171 +262,290 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
184262 }
185263 case key.Matches(msg, p.keyMap.Tab):
186264 if p.session.ID == "" {
187− return p, nil
188− }
189− p.chatFocused = !p.chatFocused
190− if p.chatFocused {
191− cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
192− cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
193− } else {
194− cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
195− cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
265+ u, cmd := p.splash.Update(msg)
266+ p.splash = u.(splash.Splash)
267+ return p, cmd
196268 }
197− return p, tea.Batch(cmds...)
269+ p.changeFocus()
270+ return p, nil
198271 case key.Matches(msg, p.keyMap.Cancel):
199− if p.session.ID != "" {
200− if p.cancelPending {
201− // Second ESC press - actually cancel the session
202− p.cancelPending = false
203− p.app.CoderAgent.Cancel(p.session.ID)
204− return p, nil
205− } else {
206− // First ESC press - start the timer
207− p.cancelPending = true
208− return p, p.cancelTimerCmd()
209− }
272+ if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
273+ return p, p.cancel()
210274 }
211275 case key.Matches(msg, p.keyMap.Details):
212− if p.session.ID == "" || !p.compactMode {
213− return p, nil // No session to show details for
214− }
215− p.showDetails = !p.showDetails
216− p.header.SetDetailsOpen(p.showDetails)
217− if p.showDetails {
218− return p, tea.Batch()
219− }
220−
276+ p.showDetails()
221277 return p, nil
222278 }
279+
280+ switch p.focusedPane {
281+ case PanelTypeChat:
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)
293+ }
294+ case tea.PasteMsg:
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...)
301+ case PanelTypeChat:
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...)
311+ }
223312 }
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)
233313 return p, tea.Batch(cmds...)
234314}
235315
236−func (p *chatPage) setMessages() tea.Cmd {
237− messagesContainer := layout.NewContainer(
238− chat.NewMessagesListCmp(p.app),
239− layout.WithPadding(1, 1, 0, 1),
240− )
241− return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
316+func (p *chatPage) Cursor() *tea.Cursor {
317+ switch p.focusedPane {
318+ case PanelTypeEditor:
319+ return p.editor.Cursor()
320+ case PanelTypeSplash:
321+ return p.splash.Cursor()
322+ default:
323+ return nil
324+ }
242325}
243326
244−func (p *chatPage) setSidebar() tea.Cmd {
245− sidebarContainer := sidebarCmp(p.app, false, p.session)
246− sidebarContainer.Init()
247− return p.layout.SetRightPanel(sidebarContainer)
327+func (p *chatPage) View() string {
328+ var chatView string
329+ t := styles.CurrentTheme()
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
336+ } else {
337+ // Show splash + editor for new message state
338+ editorView := p.editor.View()
339+ chatView = lipgloss.JoinVertical(
340+ lipgloss.Left,
341+ t.S().Base.Render(splashView),
342+ editorView,
343+ )
344+ }
345+ } else {
346+ messagesView := p.chat.View()
347+ editorView := p.editor.View()
348+ if p.compact {
349+ headerView := p.header.View()
350+ chatView = lipgloss.JoinVertical(
351+ lipgloss.Left,
352+ headerView,
353+ messagesView,
354+ editorView,
355+ )
356+ } else {
357+ sidebarView := p.sidebar.View()
358+ messages := lipgloss.JoinHorizontal(
359+ lipgloss.Left,
360+ messagesView,
361+ sidebarView,
362+ )
363+ chatView = lipgloss.JoinVertical(
364+ lipgloss.Left,
365+ messages,
366+ p.editor.View(),
367+ )
368+ }
369+ }
370+
371+ layers := []*lipgloss.Layer{
372+ lipgloss.NewLayer(chatView).X(0).Y(0),
373+ }
374+
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(
383+ lipgloss.Left,
384+ p.sidebar.View(),
385+ version,
386+ ),
387+ )
388+ layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
389+ }
390+ canvas := lipgloss.NewCanvas(
391+ layers...,
392+ )
393+ return canvas.Render()
248394}
249395
250−func (p *chatPage) clearMessages() tea.Cmd {
251− return p.layout.ClearLeftPanel()
396+func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
397+ return func() tea.Msg {
398+ err := config.Get().SetCompactMode(compact)
399+ if err != nil {
400+ return util.InfoMsg{
401+ Type: util.InfoTypeError,
402+ Msg: "Failed to update compact mode configuration: " + err.Error(),
403+ }
404+ }
405+ return nil
406+ }
252407}
253408
254−func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
255− p.compactMode = compact
256− var cmds []tea.Cmd
409+func (p *chatPage) setCompactMode(compact bool) {
410+ if p.compact == compact {
411+ return
412+ }
413+ p.compact = compact
257414 if compact {
258− // add offset for the header
259− p.layout.SetOffset(0, 1)
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...)
415+ p.compact = true
416+ p.sidebar.SetCompactMode(true)
265417 } else {
266− // remove the offset for the header
267− p.layout.SetOffset(0, 0)
268− // restore the original size
269− cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
270− // set the sidebar
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)
418+ p.compact = false
419+ p.showingDetails = false
420+ p.sidebar.SetCompactMode(false)
421+ }
422+}
275423
276− return tea.Batch(cmds...)
424+func (p *chatPage) handleCompactMode(newWidth int) {
425+ if p.forceCompact {
426+ return
427+ }
428+ if newWidth < CompactModeBreakpoint && !p.compact {
429+ p.setCompactMode(true)
430+ }
431+ if newWidth >= CompactModeBreakpoint && p.compact {
432+ p.setCompactMode(false)
277433 }
278434}
279435
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)
438+ p.width = width
439+ p.height = height
281440 var cmds []tea.Cmd
441+
282442 if p.session.ID == "" {
283− session, err := p.app.Sessions.Create(context.Background(), "New Session")
284− if err != nil {
285− return util.ReportError(err)
443+ if p.splashFullScreen {
444+ cmds = append(cmds, p.splash.SetSize(width, height))
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))
286449 }
287−
288− p.session = session
289− cmd := p.setMessages()
290− if cmd != nil {
291− cmds = append(cmds, cmd)
450+ } else {
451+ if p.compact {
452+ cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
453+ p.detailsWidth = width - DetailsPositioning
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))
457+ } else {
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))
292461 }
293− cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
294− }
295−
296− _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
297− if err != nil {
298− return util.ReportError(err)
462+ cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
299463 }
300464 return tea.Batch(cmds...)
301465}
302466
303−func (p *chatPage) SetSize(width, height int) tea.Cmd {
304− return p.layout.SetSize(width, height)
467+func (p *chatPage) newSession() tea.Cmd {
468+ if p.session.ID == "" {
469+ return nil
470+ }
471+
472+ p.session = session.Session{}
473+ p.focusedPane = PanelTypeEditor
474+ p.isCanceling = false
475+ return tea.Batch(
476+ util.CmdHandler(chat.SessionClearedMsg{}),
477+ p.SetSize(p.width, p.height),
478+ )
305479}
306480
307−func (p *chatPage) GetSize() (int, int) {
308− return p.layout.GetSize()
481+func (p *chatPage) setSession(session session.Session) tea.Cmd {
482+ if p.session.ID == session.ID {
483+ return nil
484+ }
485+
486+ var cmds []tea.Cmd
487+ p.session = session
488+
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))
494+
495+ return tea.Sequence(cmds...)
309496}
310497
311−func (p *chatPage) View() string {
312− if !p.compactMode || 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()
498+func (p *chatPage) changeFocus() {
499+ if p.session.ID == "" {
500+ return
315501 }
316− layoutView := p.layout.View()
317− chatView := strings.Join(
318− []string{
319− p.header.View(),
320− layoutView,
321− }, "\n",
322− )
323− layers := []*lipgloss.Layer{
324− lipgloss.NewLayer(chatView).X(0).Y(0),
502+ switch p.focusedPane {
503+ case PanelTypeChat:
504+ p.focusedPane = PanelTypeEditor
505+ p.editor.Focus()
506+ p.chat.Blur()
507+ case PanelTypeEditor:
508+ p.focusedPane = PanelTypeChat
509+ p.chat.Focus()
510+ p.editor.Blur()
325511 }
326− if p.showDetails {
327− t := styles.CurrentTheme()
328− style := t.S().Base.
329− Border(lipgloss.RoundedBorder()).
330− BorderForeground(t.BorderFocus)
331− version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
332− details := style.Render(
333− lipgloss.JoinVertical(
334− lipgloss.Left,
335− p.compactSidebar.View(),
336− version,
337− ),
338− )
339− layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
512+}
513+
514+func (p *chatPage) cancel() tea.Cmd {
515+ if p.isCanceling {
516+ p.isCanceling = false
517+ p.app.CoderAgent.Cancel(p.session.ID)
518+ return nil
340519 }
341− canvas := lipgloss.NewCanvas(
342− layers...,
343− )
344− return canvas.Render()
520+
521+ p.isCanceling = true
522+ return cancelTimerCmd()
345523}
346524
347−func (p *chatPage) Cursor() *tea.Cursor {
348− if v, ok := p.layout.(util.Cursor); ok {
349− return v.Cursor()
525+func (p *chatPage) showDetails() {
526+ if p.session.ID == "" || !p.compact {
527+ return
528+ }
529+ p.showingDetails = !p.showingDetails
530+ p.header.SetDetailsOpen(p.showingDetails)
531+}
532+
533+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
534+ session := p.session
535+ var cmds []tea.Cmd
536+ if p.session.ID == "" {
537+ newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
538+ if err != nil {
539+ return util.ReportError(err)
540+ }
541+ session = newSession
542+ cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
543+ }
544+ _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
545+ if err != nil {
546+ return util.ReportError(err)
350547 }
351− return nil
548+ return tea.Batch(cmds...)
352549}
353550
354551func (p *chatPage) Bindings() []key.Binding {
@@ -356,9 +553,9 @@ func (p *chatPage) Bindings() []key.Binding {
356553 p.keyMap.NewSession,
357554 p.keyMap.AddAttachment,
358555 }
359− if p.app.CoderAgent.IsBusy() {
556+ if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
360557 cancelBinding := p.keyMap.Cancel
361− if p.cancelPending {
558+ if p.isCanceling {
362559 cancelBinding = key.NewBinding(
363560 key.WithKeys("esc"),
364561 key.WithHelp("esc", "press again to cancel"),
@@ -367,61 +564,275 @@ func (p *chatPage) Bindings() []key.Binding {
367564 bindings = append([]key.Binding{cancelBinding}, bindings...)
368565 }
369566
370− if p.chatFocused {
567+ switch p.focusedPane {
568+ case PanelTypeChat:
371569 bindings = append([]key.Binding{
372570 key.NewBinding(
373571 key.WithKeys("tab"),
374572 key.WithHelp("tab", "focus editor"),
375573 ),
376574 }, bindings...)
377− } else {
575+ bindings = append(bindings, p.chat.Bindings()...)
576+ case PanelTypeEditor:
378577 bindings = append([]key.Binding{
379578 key.NewBinding(
380579 key.WithKeys("tab"),
381580 key.WithHelp("tab", "focus chat"),
382581 ),
383582 }, bindings...)
583+ bindings = append(bindings, p.editor.Bindings()...)
584+ case PanelTypeSplash:
585+ bindings = append(bindings, p.splash.Bindings()...)
384586 }
385587
386− bindings = append(bindings, p.layout.Bindings()...)
387588 return bindings
388589}
389590
390−func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container {
391− padding := layout.WithPadding(1, 1, 1, 1)
392− if compact {
393− padding = layout.WithPadding(0, 1, 1, 1)
394− }
395− sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact)
396− if session.ID != "" {
397− sidebar.SetSession(session)
591+func (a *chatPage) Help() help.KeyMap {
592+ var shortList []key.Binding
593+ var fullList [][]key.Binding
594+ switch {
595+ case a.isOnboarding && !a.splash.IsShowingAPIKey():
596+ shortList = append(shortList,
597+ // Choose model
598+ key.NewBinding(
599+ key.WithKeys("up", "down"),
600+ key.WithHelp("↑/↓", "choose"),
601+ ),
602+ // Accept selection
603+ key.NewBinding(
604+ key.WithKeys("enter", "ctrl+y"),
605+ key.WithHelp("enter", "accept"),
606+ ),
607+ // Quit
608+ key.NewBinding(
609+ key.WithKeys("ctrl+c"),
610+ key.WithHelp("ctrl+c", "quit"),
611+ ),
612+ )
613+ // keep them the same
614+ for _, v := range shortList {
615+ fullList = append(fullList, []key.Binding{v})
616+ }
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"),
623+ )
624+ } else {
625+ pasteKey = key.NewBinding(
626+ key.WithKeys("cmd+v"),
627+ key.WithHelp("cmd+v", "paste API key"),
628+ )
629+ }
630+ shortList = append(shortList,
631+ // Go back
632+ key.NewBinding(
633+ key.WithKeys("esc"),
634+ key.WithHelp("esc", "back"),
635+ ),
636+ // Paste
637+ pasteKey,
638+ // Quit
639+ key.NewBinding(
640+ key.WithKeys("ctrl+c"),
641+ key.WithHelp("ctrl+c", "quit"),
642+ ),
643+ )
644+ // keep them the same
645+ for _, v := range shortList {
646+ fullList = append(fullList, []key.Binding{v})
647+ }
648+ case a.isProjectInit:
649+ shortList = append(shortList,
650+ key.NewBinding(
651+ key.WithKeys("ctrl+c"),
652+ key.WithHelp("ctrl+c", "quit"),
653+ ),
654+ )
655+ // keep them the same
656+ for _, v := range shortList {
657+ fullList = append(fullList, []key.Binding{v})
658+ }
659+ default:
660+ if a.editor.IsCompletionsOpen() {
661+ shortList = append(shortList,
662+ key.NewBinding(
663+ key.WithKeys("tab", "enter"),
664+ key.WithHelp("tab/enter", "complete"),
665+ ),
666+ key.NewBinding(
667+ key.WithKeys("esc"),
668+ key.WithHelp("esc", "cancel"),
669+ ),
670+ key.NewBinding(
671+ key.WithKeys("up", "down"),
672+ key.WithHelp("↑/↓", "choose"),
673+ ),
674+ )
675+ for _, v := range shortList {
676+ fullList = append(fullList, []key.Binding{v})
677+ }
678+ return core.NewSimpleHelp(shortList, fullList)
679+ }
680+ if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
681+ cancelBinding := key.NewBinding(
682+ key.WithKeys("esc"),
683+ key.WithHelp("esc", "cancel"),
684+ )
685+ if a.isCanceling {
686+ cancelBinding = key.NewBinding(
687+ key.WithKeys("esc"),
688+ key.WithHelp("esc", "press again to cancel"),
689+ )
690+ }
691+ shortList = append(shortList, cancelBinding)
692+ fullList = append(fullList,
693+ []key.Binding{
694+ cancelBinding,
695+ },
696+ )
697+ }
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"),
704+ )
705+ if a.focusedPane == PanelTypeChat {
706+ tabKey = key.NewBinding(
707+ key.WithKeys("tab"),
708+ key.WithHelp("tab", "focus editor"),
709+ )
710+ }
711+ shortList = append(shortList, tabKey)
712+ globalBindings = append(globalBindings, tabKey)
713+ }
714+ commandsBinding := key.NewBinding(
715+ key.WithKeys("ctrl+p"),
716+ key.WithHelp("ctrl+p", "commands"),
717+ )
718+ helpBinding := key.NewBinding(
719+ key.WithKeys("ctrl+g"),
720+ key.WithHelp("ctrl+g", "more"),
721+ )
722+ globalBindings = append(globalBindings, commandsBinding)
723+ globalBindings = append(globalBindings,
724+ key.NewBinding(
725+ key.WithKeys("ctrl+s"),
726+ key.WithHelp("ctrl+s", "sessions"),
727+ ),
728+ )
729+ if a.session.ID != "" {
730+ globalBindings = append(globalBindings,
731+ key.NewBinding(
732+ key.WithKeys("ctrl+n"),
733+ key.WithHelp("ctrl+n", "new sessions"),
734+ ))
735+ }
736+ shortList = append(shortList,
737+ // Commands
738+ commandsBinding,
739+ )
740+ fullList = append(fullList, globalBindings)
741+
742+ if a.focusedPane == PanelTypeChat {
743+ shortList = append(shortList,
744+ key.NewBinding(
745+ key.WithKeys("up", "down"),
746+ key.WithHelp("↑↓", "scroll"),
747+ ),
748+ )
749+ fullList = append(fullList,
750+ []key.Binding{
751+ key.NewBinding(
752+ key.WithKeys("up", "down"),
753+ key.WithHelp("↑↓", "scroll"),
754+ ),
755+ key.NewBinding(
756+ key.WithKeys("shift+up", "shift+down"),
757+ key.WithHelp("shift+↑↓", "next/prev item"),
758+ ),
759+ key.NewBinding(
760+ key.WithKeys("pgup", "b"),
761+ key.WithHelp("b/pgup", "page up"),
762+ ),
763+ key.NewBinding(
764+ key.WithKeys("pgdown", " ", "f"),
765+ key.WithHelp("f/pgdn", "page down"),
766+ ),
767+ },
768+ []key.Binding{
769+ key.NewBinding(
770+ key.WithKeys("u"),
771+ key.WithHelp("u", "half page up"),
772+ ),
773+ key.NewBinding(
774+ key.WithKeys("d"),
775+ key.WithHelp("d", "half page down"),
776+ ),
777+ key.NewBinding(
778+ key.WithKeys("g", "home"),
779+ key.WithHelp("g", "hone"),
780+ ),
781+ key.NewBinding(
782+ key.WithKeys("G", "end"),
783+ key.WithHelp("G", "end"),
784+ ),
785+ },
786+ )
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
792+ // to reflect that.
793+ key.WithHelp("ctrl+j", "newline"),
794+ )
795+ if a.keyboardEnhancements.SupportsKeyDisambiguation() {
796+ newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
797+ }
798+ shortList = append(shortList, newLineBinding)
799+ fullList = append(fullList,
800+ []key.Binding{
801+ newLineBinding,
802+ key.NewBinding(
803+ key.WithKeys("ctrl+f"),
804+ key.WithHelp("ctrl+f", "add image"),
805+ ),
806+ key.NewBinding(
807+ key.WithKeys("/"),
808+ key.WithHelp("/", "add file"),
809+ ),
810+ key.NewBinding(
811+ key.WithKeys("ctrl+v"),
812+ key.WithHelp("ctrl+v", "open editor"),
813+ ),
814+ })
815+ }
816+ shortList = append(shortList,
817+ // Quit
818+ key.NewBinding(
819+ key.WithKeys("ctrl+c"),
820+ key.WithHelp("ctrl+c", "quit"),
821+ ),
822+ // Help
823+ helpBinding,
824+ )
825+ fullList = append(fullList, []key.Binding{
826+ key.NewBinding(
827+ key.WithKeys("ctrl+g"),
828+ key.WithHelp("ctrl+g", "less"),
829+ ),
830+ })
398831 }
399832
400− return layout.NewContainer(
401− sidebar,
402− padding,
403− )
404−}
405−
406−func NewChatPage(app *app.App) ChatPage {
407− editorContainer := layout.NewContainer(
408− editor.NewEditorCmp(app),
409− )
410− return &chatPage{
411− app: 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),
417− ),
418− compactSidebar: sidebarCmp(app, true, session.Session{}),
419− keyMap: DefaultKeyMap(),
420− header: header.New(app.LSPClients),
421− }
833+ return core.NewSimpleHelp(shortList, fullList)
422834}
423835
424−// IsChatFocused returns whether the chat messages are focused (true) or editor is focused (false)
425836func (p *chatPage) IsChatFocused() bool {
426− return p.chatFocused
837+ return p.focusedPane == PanelTypeChat
427838}