1package chat
  2
  3import (
  4	"context"
  5	"strings"
  6	"time"
  7
  8	"github.com/charmbracelet/bubbles/v2/key"
  9	tea "github.com/charmbracelet/bubbletea/v2"
 10	"github.com/charmbracelet/crush/internal/app"
 11	"github.com/charmbracelet/crush/internal/config"
 12	"github.com/charmbracelet/crush/internal/message"
 13	"github.com/charmbracelet/crush/internal/session"
 14	"github.com/charmbracelet/crush/internal/tui/components/chat"
 15	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
 16	"github.com/charmbracelet/crush/internal/tui/components/chat/header"
 17	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
 18	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 19	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 20	"github.com/charmbracelet/crush/internal/tui/page"
 21	"github.com/charmbracelet/crush/internal/tui/styles"
 22	"github.com/charmbracelet/crush/internal/tui/util"
 23	"github.com/charmbracelet/crush/internal/version"
 24	"github.com/charmbracelet/lipgloss/v2"
 25)
 26
 27var ChatPageID page.PageID = "chat"
 28
 29const CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
 30
 31type (
 32	OpenFilePickerMsg struct{}
 33	ChatFocusedMsg    struct {
 34		Focused bool // True if the chat input is focused, false otherwise
 35	}
 36	CancelTimerExpiredMsg struct{}
 37)
 38
 39type ChatPage interface {
 40	util.Model
 41	layout.Help
 42}
 43
 44type chatPage struct {
 45	wWidth, wHeight int // Window dimensions
 46	app             *app.App
 47
 48	layout layout.SplitPaneLayout
 49
 50	session session.Session
 51
 52	keyMap KeyMap
 53
 54	chatFocused bool
 55
 56	compactMode      bool
 57	forceCompactMode bool // Force compact mode regardless of window size
 58	showDetails      bool // Show details in the header
 59	header           header.Header
 60	compactSidebar   layout.Container
 61
 62	cancelPending bool // True if ESC was pressed once and waiting for second press
 63}
 64
 65func (p *chatPage) Init() tea.Cmd {
 66	return tea.Batch(
 67		p.layout.Init(),
 68		p.compactSidebar.Init(),
 69		p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor),
 70	)
 71}
 72
 73// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
 74func (p *chatPage) cancelTimerCmd() tea.Cmd {
 75	return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
 76		return CancelTimerExpiredMsg{}
 77	})
 78}
 79
 80func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 81	var cmds []tea.Cmd
 82	switch msg := msg.(type) {
 83	case CancelTimerExpiredMsg:
 84		p.cancelPending = false
 85		return p, nil
 86	case tea.WindowSizeMsg:
 87		h, cmd := p.header.Update(msg)
 88		cmds = append(cmds, cmd)
 89		p.header = h.(header.Header)
 90		cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0))
 91		// the mode is only relevant when there is a  session
 92		if p.session.ID != "" {
 93			// Only auto-switch to compact mode if not forced
 94			if !p.forceCompactMode {
 95				if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
 96					p.wWidth = msg.Width
 97					p.wHeight = msg.Height
 98					cmds = append(cmds, p.setCompactMode(true))
 99					return p, tea.Batch(cmds...)
100				} else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
101					p.wWidth = msg.Width
102					p.wHeight = msg.Height
103					return p, p.setCompactMode(false)
104				}
105			}
106		}
107		p.wWidth = msg.Width
108		p.wHeight = msg.Height
109		layoutHeight := msg.Height
110		if p.compactMode {
111			// make space for the header
112			layoutHeight -= 1
113		}
114		cmd = p.layout.SetSize(msg.Width, layoutHeight)
115		cmds = append(cmds, cmd)
116		return p, tea.Batch(cmds...)
117
118	case chat.SendMsg:
119		cmd := p.sendMessage(msg.Text, msg.Attachments)
120		if cmd != nil {
121			return p, cmd
122		}
123	case commands.ToggleCompactModeMsg:
124		// Only allow toggling if window width is larger than compact breakpoint
125		if p.wWidth > CompactModeBreakpoint {
126			p.forceCompactMode = !p.forceCompactMode
127			// If force compact mode is enabled, switch to compact mode
128			// If force compact mode is disabled, switch based on window size
129			if p.forceCompactMode {
130				return p, p.setCompactMode(true)
131			} else {
132				// Return to auto mode based on window size
133				shouldBeCompact := p.wWidth <= CompactModeBreakpoint
134				return p, p.setCompactMode(shouldBeCompact)
135			}
136		}
137	case commands.CommandRunCustomMsg:
138		// Check if the agent is busy before executing custom commands
139		if p.app.CoderAgent.IsBusy() {
140			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
141		}
142
143		// Handle custom command execution
144		cmd := p.sendMessage(msg.Content, nil)
145		if cmd != nil {
146			return p, cmd
147		}
148	case chat.SessionSelectedMsg:
149		if p.session.ID == "" {
150			cmd := p.setMessages()
151			if cmd != nil {
152				cmds = append(cmds, cmd)
153			}
154		}
155		needsModeChange := p.session.ID == ""
156		p.session = msg
157		p.header.SetSession(msg)
158		if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
159			cmds = append(cmds, p.setCompactMode(true))
160		}
161	case tea.KeyPressMsg:
162		switch {
163		case key.Matches(msg, p.keyMap.NewSession):
164			p.session = session.Session{}
165			return p, tea.Batch(
166				p.clearMessages(),
167				util.CmdHandler(chat.SessionClearedMsg{}),
168				p.setCompactMode(false),
169				p.layout.FocusPanel(layout.BottomPanel),
170				util.CmdHandler(ChatFocusedMsg{Focused: false}),
171			)
172		case key.Matches(msg, p.keyMap.AddAttachment):
173			model := config.GetAgentModel(config.AgentCoder)
174			if model.SupportsImages {
175				return p, util.CmdHandler(OpenFilePickerMsg{})
176			} else {
177				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
178			}
179		case key.Matches(msg, p.keyMap.Tab):
180			if p.session.ID == "" {
181				return p, nil
182			}
183			p.chatFocused = !p.chatFocused
184			if p.chatFocused {
185				cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
186				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
187			} else {
188				cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
189				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
190			}
191			return p, tea.Batch(cmds...)
192		case key.Matches(msg, p.keyMap.Cancel):
193			if p.session.ID != "" {
194				if p.cancelPending {
195					// Second ESC press - actually cancel the session
196					p.cancelPending = false
197					p.app.CoderAgent.Cancel(p.session.ID)
198					return p, nil
199				} else {
200					// First ESC press - start the timer
201					p.cancelPending = true
202					return p, p.cancelTimerCmd()
203				}
204			}
205		case key.Matches(msg, p.keyMap.Details):
206			if p.session.ID == "" || !p.compactMode {
207				return p, nil // No session to show details for
208			}
209			p.showDetails = !p.showDetails
210			p.header.SetDetailsOpen(p.showDetails)
211			if p.showDetails {
212				return p, tea.Batch()
213			}
214
215			return p, nil
216		}
217	}
218	u, cmd := p.layout.Update(msg)
219	cmds = append(cmds, cmd)
220	p.layout = u.(layout.SplitPaneLayout)
221	h, cmd := p.header.Update(msg)
222	p.header = h.(header.Header)
223	cmds = append(cmds, cmd)
224	s, cmd := p.compactSidebar.Update(msg)
225	p.compactSidebar = s.(layout.Container)
226	cmds = append(cmds, cmd)
227	return p, tea.Batch(cmds...)
228}
229
230func (p *chatPage) setMessages() tea.Cmd {
231	messagesContainer := layout.NewContainer(
232		chat.NewMessagesListCmp(p.app),
233		layout.WithPadding(1, 1, 0, 1),
234	)
235	return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
236}
237
238func (p *chatPage) setSidebar() tea.Cmd {
239	sidebarContainer := sidebarCmp(p.app, false, p.session)
240	sidebarContainer.Init()
241	return p.layout.SetRightPanel(sidebarContainer)
242}
243
244func (p *chatPage) clearMessages() tea.Cmd {
245	return p.layout.ClearLeftPanel()
246}
247
248func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
249	p.compactMode = compact
250	var cmds []tea.Cmd
251	if compact {
252		// add offset for the header
253		p.layout.SetOffset(0, 1)
254		// make space for the header
255		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
256		// remove the sidebar
257		cmds = append(cmds, p.layout.ClearRightPanel())
258		return tea.Batch(cmds...)
259	} else {
260		// remove the offset for the header
261		p.layout.SetOffset(0, 0)
262		// restore the original size
263		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
264		// set the sidebar
265		cmds = append(cmds, p.setSidebar())
266		l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session))
267		p.layout = l.(layout.SplitPaneLayout)
268		cmds = append(cmds, cmd)
269
270		return tea.Batch(cmds...)
271	}
272}
273
274func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
275	var cmds []tea.Cmd
276	if p.session.ID == "" {
277		session, err := p.app.Sessions.Create(context.Background(), "New Session")
278		if err != nil {
279			return util.ReportError(err)
280		}
281
282		p.session = session
283		cmd := p.setMessages()
284		if cmd != nil {
285			cmds = append(cmds, cmd)
286		}
287		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
288	}
289
290	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
291	if err != nil {
292		return util.ReportError(err)
293	}
294	return tea.Batch(cmds...)
295}
296
297func (p *chatPage) SetSize(width, height int) tea.Cmd {
298	return p.layout.SetSize(width, height)
299}
300
301func (p *chatPage) GetSize() (int, int) {
302	return p.layout.GetSize()
303}
304
305func (p *chatPage) View() tea.View {
306	if !p.compactMode || p.session.ID == "" {
307		// If not in compact mode or there is no session, we don't show the header
308		return p.layout.View()
309	}
310	layoutView := p.layout.View()
311	chatView := strings.Join(
312		[]string{
313			p.header.View().String(),
314			layoutView.String(),
315		}, "\n",
316	)
317	layers := []*lipgloss.Layer{
318		lipgloss.NewLayer(chatView).X(0).Y(0),
319	}
320	if p.showDetails {
321		t := styles.CurrentTheme()
322		style := t.S().Base.
323			Border(lipgloss.RoundedBorder()).
324			BorderForeground(t.BorderFocus)
325		version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
326		details := style.Render(
327			lipgloss.JoinVertical(
328				lipgloss.Left,
329				p.compactSidebar.View().String(),
330				version,
331			),
332		)
333		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
334	}
335	canvas := lipgloss.NewCanvas(
336		layers...,
337	)
338	view := tea.NewView(canvas.Render())
339	view.SetCursor(layoutView.Cursor())
340	return view
341}
342
343func (p *chatPage) Bindings() []key.Binding {
344	bindings := []key.Binding{
345		p.keyMap.NewSession,
346		p.keyMap.AddAttachment,
347	}
348	if p.app.CoderAgent.IsBusy() {
349		cancelBinding := p.keyMap.Cancel
350		if p.cancelPending {
351			cancelBinding = key.NewBinding(
352				key.WithKeys("esc"),
353				key.WithHelp("esc", "press again to cancel"),
354			)
355		}
356		bindings = append([]key.Binding{cancelBinding}, bindings...)
357	}
358
359	if p.chatFocused {
360		bindings = append([]key.Binding{
361			key.NewBinding(
362				key.WithKeys("tab"),
363				key.WithHelp("tab", "focus editor"),
364			),
365		}, bindings...)
366	} else {
367		bindings = append([]key.Binding{
368			key.NewBinding(
369				key.WithKeys("tab"),
370				key.WithHelp("tab", "focus chat"),
371			),
372		}, bindings...)
373	}
374
375	bindings = append(bindings, p.layout.Bindings()...)
376	return bindings
377}
378
379func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container {
380	padding := layout.WithPadding(1, 1, 1, 1)
381	if compact {
382		padding = layout.WithPadding(0, 1, 1, 1)
383	}
384	sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact)
385	if session.ID != "" {
386		sidebar.SetSession(session)
387	}
388
389	return layout.NewContainer(
390		sidebar,
391		padding,
392	)
393}
394
395func NewChatPage(app *app.App) ChatPage {
396	editorContainer := layout.NewContainer(
397		editor.NewEditorCmp(app),
398	)
399	return &chatPage{
400		app: app,
401		layout: layout.NewSplitPane(
402			layout.WithRightPanel(sidebarCmp(app, false, session.Session{})),
403			layout.WithBottomPanel(editorContainer),
404			layout.WithFixedBottomHeight(5),
405			layout.WithFixedRightWidth(31),
406		),
407		compactSidebar: sidebarCmp(app, true, session.Session{}),
408		keyMap:         DefaultKeyMap(),
409		header:         header.New(app.LSPClients),
410	}
411}