chat.go

  1package chat
  2
  3import (
  4	"context"
  5	"time"
  6
  7	"github.com/charmbracelet/bubbles/v2/key"
  8	"github.com/charmbracelet/bubbles/v2/spinner"
  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/history"
 13	"github.com/charmbracelet/crush/internal/message"
 14	"github.com/charmbracelet/crush/internal/pubsub"
 15	"github.com/charmbracelet/crush/internal/session"
 16	"github.com/charmbracelet/crush/internal/tui/components/anim"
 17	"github.com/charmbracelet/crush/internal/tui/components/chat"
 18	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
 19	"github.com/charmbracelet/crush/internal/tui/components/chat/header"
 20	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
 21	"github.com/charmbracelet/crush/internal/tui/components/chat/splash"
 22	"github.com/charmbracelet/crush/internal/tui/components/completions"
 23	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 24	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 25	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 26	"github.com/charmbracelet/crush/internal/tui/page"
 27	"github.com/charmbracelet/crush/internal/tui/styles"
 28	"github.com/charmbracelet/crush/internal/tui/util"
 29	"github.com/charmbracelet/crush/internal/version"
 30	"github.com/charmbracelet/lipgloss/v2"
 31)
 32
 33var ChatPageID page.PageID = "chat"
 34
 35type (
 36	OpenFilePickerMsg struct{}
 37	ChatFocusedMsg    struct {
 38		Focused bool
 39	}
 40	CancelTimerExpiredMsg struct{}
 41)
 42
 43type PanelType string
 44
 45const (
 46	PanelTypeChat   PanelType = "chat"
 47	PanelTypeEditor PanelType = "editor"
 48	PanelTypeSplash PanelType = "splash"
 49)
 50
 51const (
 52	CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
 53	EditorHeight          = 5   // Height of the editor input area including padding
 54	SideBarWidth          = 31  // Width of the sidebar
 55	SideBarDetailsPadding = 1   // Padding for the sidebar details section
 56	HeaderHeight          = 1   // Height of the header
 57
 58	// Layout constants for borders and padding
 59	BorderWidth        = 1 // Width of component borders
 60	LeftRightBorders   = 2 // Left + right border width (1 + 1)
 61	TopBottomBorders   = 2 // Top + bottom border width (1 + 1)
 62	DetailsPositioning = 2 // Positioning adjustment for details panel
 63
 64	// Timing constants
 65	CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
 66)
 67
 68type ChatPage interface {
 69	util.Model
 70	layout.Help
 71	IsChatFocused() bool
 72}
 73
 74// cancelTimerCmd creates a command that expires the cancel timer
 75func cancelTimerCmd() tea.Cmd {
 76	return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
 77		return CancelTimerExpiredMsg{}
 78	})
 79}
 80
 81type chatPage struct {
 82	width, height               int
 83	detailsWidth, detailsHeight int
 84	app                         *app.App
 85
 86	// Layout state
 87	compact      bool
 88	forceCompact bool
 89	focusedPane  PanelType
 90
 91	// Session
 92	session session.Session
 93	keyMap  KeyMap
 94
 95	// Components
 96	header  header.Header
 97	sidebar sidebar.Sidebar
 98	chat    chat.MessageListCmp
 99	editor  editor.Editor
100	splash  splash.Splash
101
102	// Simple state flags
103	showingDetails   bool
104	isCanceling      bool
105	splashFullScreen bool
106}
107
108func New(app *app.App) ChatPage {
109	return &chatPage{
110		app:         app,
111		keyMap:      DefaultKeyMap(),
112		header:      header.New(app.LSPClients),
113		sidebar:     sidebar.New(app.History, app.LSPClients, false),
114		chat:        chat.New(app),
115		editor:      editor.New(app),
116		splash:      splash.New(),
117		focusedPane: PanelTypeSplash,
118	}
119}
120
121func (p *chatPage) Init() tea.Cmd {
122	cfg := config.Get()
123	compact := cfg.Options.TUI.CompactMode
124	p.compact = compact
125	p.forceCompact = compact
126	p.sidebar.SetCompactMode(p.compact)
127
128	// Set splash state based on config
129	if !config.HasInitialDataConfig() {
130		// First-time setup: show model selection
131		p.splash.SetOnboarding(true)
132		p.splashFullScreen = true
133	} else if b, _ := config.ProjectNeedsInitialization(); b {
134		// Project needs CRUSH.md initialization
135		p.splash.SetProjectInit(true)
136		p.splashFullScreen = true
137	} else {
138		// Ready to chat: focus editor, splash in background
139		p.focusedPane = PanelTypeEditor
140		p.splashFullScreen = false
141	}
142
143	return tea.Batch(
144		p.header.Init(),
145		p.sidebar.Init(),
146		p.chat.Init(),
147		p.editor.Init(),
148		p.splash.Init(),
149	)
150}
151
152func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153	var cmds []tea.Cmd
154	switch msg := msg.(type) {
155	case tea.WindowSizeMsg:
156		return p, p.SetSize(msg.Width, msg.Height)
157	case CancelTimerExpiredMsg:
158		p.isCanceling = false
159		return p, nil
160	case chat.SendMsg:
161		return p, p.sendMessage(msg.Text, msg.Attachments)
162	case chat.SessionSelectedMsg:
163		return p, p.setSession(msg)
164	case commands.ToggleCompactModeMsg:
165		p.forceCompact = !p.forceCompact
166		var cmd tea.Cmd
167		if p.forceCompact {
168			p.setCompactMode(true)
169			cmd = p.updateCompactConfig(true)
170		} else if p.width >= CompactModeBreakpoint {
171			p.setCompactMode(false)
172			cmd = p.updateCompactConfig(false)
173		}
174		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
175	case pubsub.Event[session.Session]:
176		u, cmd := p.header.Update(msg)
177		p.header = u.(header.Header)
178		cmds = append(cmds, cmd)
179		u, cmd = p.sidebar.Update(msg)
180		p.sidebar = u.(sidebar.Sidebar)
181		cmds = append(cmds, cmd)
182		return p, tea.Batch(cmds...)
183	case chat.SessionClearedMsg:
184		u, cmd := p.header.Update(msg)
185		p.header = u.(header.Header)
186		cmds = append(cmds, cmd)
187		u, cmd = p.sidebar.Update(msg)
188		p.sidebar = u.(sidebar.Sidebar)
189		cmds = append(cmds, cmd)
190		u, cmd = p.chat.Update(msg)
191		p.chat = u.(chat.MessageListCmp)
192		cmds = append(cmds, cmd)
193		return p, tea.Batch(cmds...)
194	case filepicker.FilePickedMsg,
195		completions.CompletionsClosedMsg,
196		completions.SelectCompletionMsg:
197		u, cmd := p.editor.Update(msg)
198		p.editor = u.(editor.Editor)
199		cmds = append(cmds, cmd)
200		return p, tea.Batch(cmds...)
201
202	case pubsub.Event[message.Message],
203		anim.StepMsg,
204		spinner.TickMsg:
205		u, cmd := p.chat.Update(msg)
206		p.chat = u.(chat.MessageListCmp)
207		cmds = append(cmds, cmd)
208		return p, tea.Batch(cmds...)
209
210	case pubsub.Event[history.File], sidebar.SessionFilesMsg:
211		u, cmd := p.sidebar.Update(msg)
212		p.sidebar = u.(sidebar.Sidebar)
213		cmds = append(cmds, cmd)
214		return p, tea.Batch(cmds...)
215
216	case commands.CommandRunCustomMsg:
217		if p.app.CoderAgent.IsBusy() {
218			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
219		}
220
221		cmd := p.sendMessage(msg.Content, nil)
222		if cmd != nil {
223			return p, cmd
224		}
225	case splash.OnboardingCompleteMsg:
226		p.splashFullScreen = false
227		if b, _ := config.ProjectNeedsInitialization(); b {
228			p.splash.SetProjectInit(true)
229			p.splashFullScreen = true
230			return p, p.SetSize(p.width, p.height)
231		}
232		err := p.app.InitCoderAgent()
233		if err != nil {
234			return p, util.ReportError(err)
235		}
236		p.focusedPane = PanelTypeEditor
237		return p, p.SetSize(p.width, p.height)
238	case tea.KeyPressMsg:
239		switch {
240		case key.Matches(msg, p.keyMap.NewSession):
241			return p, p.newSession()
242		case key.Matches(msg, p.keyMap.AddAttachment):
243			agentCfg := config.Get().Agents["coder"]
244			model := config.Get().GetModelByType(agentCfg.Model)
245			if model.SupportsImages {
246				return p, util.CmdHandler(OpenFilePickerMsg{})
247			} else {
248				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Model)
249			}
250		case key.Matches(msg, p.keyMap.Tab):
251			if p.session.ID == "" {
252				u, cmd := p.splash.Update(msg)
253				p.splash = u.(splash.Splash)
254				return p, cmd
255			}
256			p.changeFocus()
257			return p, nil
258		case key.Matches(msg, p.keyMap.Cancel):
259			return p, p.cancel()
260		case key.Matches(msg, p.keyMap.Details):
261			p.showDetails()
262			return p, nil
263		}
264
265		switch p.focusedPane {
266		case PanelTypeChat:
267			u, cmd := p.chat.Update(msg)
268			p.chat = u.(chat.MessageListCmp)
269			cmds = append(cmds, cmd)
270		case PanelTypeEditor:
271			u, cmd := p.editor.Update(msg)
272			p.editor = u.(editor.Editor)
273			cmds = append(cmds, cmd)
274		case PanelTypeSplash:
275			u, cmd := p.splash.Update(msg)
276			p.splash = u.(splash.Splash)
277			cmds = append(cmds, cmd)
278		}
279	}
280	return p, tea.Batch(cmds...)
281}
282
283func (p *chatPage) Cursor() *tea.Cursor {
284	switch p.focusedPane {
285	case PanelTypeEditor:
286		return p.editor.Cursor()
287	case PanelTypeSplash:
288		return p.splash.Cursor()
289	default:
290		return nil
291	}
292}
293
294func (p *chatPage) View() string {
295	var chatView string
296	t := styles.CurrentTheme()
297
298	if p.session.ID == "" {
299		splashView := p.splash.View()
300		// Full screen during onboarding or project initialization
301		if p.splashFullScreen {
302			chatView = splashView
303		} else {
304			// Show splash + editor for new message state
305			editorView := p.editor.View()
306			chatView = lipgloss.JoinVertical(
307				lipgloss.Left,
308				t.S().Base.Render(splashView),
309				editorView,
310			)
311		}
312	} else {
313		messagesView := p.chat.View()
314		editorView := p.editor.View()
315		if p.compact {
316			headerView := p.header.View()
317			chatView = lipgloss.JoinVertical(
318				lipgloss.Left,
319				headerView,
320				messagesView,
321				editorView,
322			)
323		} else {
324			sidebarView := p.sidebar.View()
325			messages := lipgloss.JoinHorizontal(
326				lipgloss.Left,
327				messagesView,
328				sidebarView,
329			)
330			chatView = lipgloss.JoinVertical(
331				lipgloss.Left,
332				messages,
333				p.editor.View(),
334			)
335		}
336	}
337
338	layers := []*lipgloss.Layer{
339		lipgloss.NewLayer(chatView).X(0).Y(0),
340	}
341
342	if p.showingDetails {
343		style := t.S().Base.
344			Width(p.detailsWidth).
345			Border(lipgloss.RoundedBorder()).
346			BorderForeground(t.BorderFocus)
347		version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
348		details := style.Render(
349			lipgloss.JoinVertical(
350				lipgloss.Left,
351				p.sidebar.View(),
352				version,
353			),
354		)
355		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
356	}
357	canvas := lipgloss.NewCanvas(
358		layers...,
359	)
360	return canvas.Render()
361}
362
363func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
364	return func() tea.Msg {
365		err := config.Get().SetCompactMode(compact)
366		if err != nil {
367			return util.InfoMsg{
368				Type: util.InfoTypeError,
369				Msg:  "Failed to update compact mode configuration: " + err.Error(),
370			}
371		}
372		return nil
373	}
374}
375
376func (p *chatPage) setCompactMode(compact bool) {
377	if p.compact == compact {
378		return
379	}
380	p.compact = compact
381	if compact {
382		p.compact = true
383		p.sidebar.SetCompactMode(true)
384	} else {
385		p.compact = false
386		p.showingDetails = false
387		p.sidebar.SetCompactMode(false)
388	}
389}
390
391func (p *chatPage) handleCompactMode(newWidth int) {
392	if p.forceCompact {
393		return
394	}
395	if newWidth < CompactModeBreakpoint && !p.compact {
396		p.setCompactMode(true)
397	}
398	if newWidth >= CompactModeBreakpoint && p.compact {
399		p.setCompactMode(false)
400	}
401}
402
403func (p *chatPage) SetSize(width, height int) tea.Cmd {
404	p.handleCompactMode(width)
405	p.width = width
406	p.height = height
407	var cmds []tea.Cmd
408
409	if p.session.ID == "" {
410		if p.splashFullScreen {
411			cmds = append(cmds, p.splash.SetSize(width, height))
412		} else {
413			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
414			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
415			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
416		}
417	} else {
418		if p.compact {
419			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
420			p.detailsWidth = width - DetailsPositioning
421			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
422			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
423			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
424		} else {
425			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
426			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
427			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
428		}
429		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
430	}
431	return tea.Batch(cmds...)
432}
433
434func (p *chatPage) newSession() tea.Cmd {
435	if p.session.ID == "" {
436		return nil
437	}
438
439	p.session = session.Session{}
440	p.focusedPane = PanelTypeEditor
441	p.isCanceling = false
442	return tea.Batch(
443		util.CmdHandler(chat.SessionClearedMsg{}),
444		p.SetSize(p.width, p.height),
445	)
446}
447
448func (p *chatPage) setSession(session session.Session) tea.Cmd {
449	if p.session.ID == session.ID {
450		return nil
451	}
452
453	var cmds []tea.Cmd
454	p.session = session
455
456	cmds = append(cmds, p.SetSize(p.width, p.height))
457	cmds = append(cmds, p.chat.SetSession(session))
458	cmds = append(cmds, p.sidebar.SetSession(session))
459	cmds = append(cmds, p.header.SetSession(session))
460	cmds = append(cmds, p.editor.SetSession(session))
461
462	return tea.Sequence(cmds...)
463}
464
465func (p *chatPage) changeFocus() {
466	if p.session.ID == "" {
467		return
468	}
469	switch p.focusedPane {
470	case PanelTypeChat:
471		p.focusedPane = PanelTypeEditor
472		p.editor.Focus()
473		p.chat.Blur()
474	case PanelTypeEditor:
475		p.focusedPane = PanelTypeChat
476		p.chat.Focus()
477		p.editor.Blur()
478	}
479}
480
481func (p *chatPage) cancel() tea.Cmd {
482	if p.session.ID == "" || !p.app.CoderAgent.IsBusy() {
483		return nil
484	}
485
486	if p.isCanceling {
487		p.isCanceling = false
488		p.app.CoderAgent.Cancel(p.session.ID)
489		return nil
490	}
491
492	p.isCanceling = true
493	return cancelTimerCmd()
494}
495
496func (p *chatPage) showDetails() {
497	if p.session.ID == "" || !p.compact {
498		return
499	}
500	p.showingDetails = !p.showingDetails
501	p.header.SetDetailsOpen(p.showingDetails)
502}
503
504func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
505	session := p.session
506	var cmds []tea.Cmd
507	if p.session.ID == "" {
508		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
509		if err != nil {
510			return util.ReportError(err)
511		}
512		session = newSession
513		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
514	}
515	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
516	if err != nil {
517		return util.ReportError(err)
518	}
519	return tea.Batch(cmds...)
520}
521
522func (p *chatPage) Bindings() []key.Binding {
523	bindings := []key.Binding{
524		p.keyMap.NewSession,
525		p.keyMap.AddAttachment,
526	}
527	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
528		cancelBinding := p.keyMap.Cancel
529		if p.isCanceling {
530			cancelBinding = key.NewBinding(
531				key.WithKeys("esc"),
532				key.WithHelp("esc", "press again to cancel"),
533			)
534		}
535		bindings = append([]key.Binding{cancelBinding}, bindings...)
536	}
537
538	switch p.focusedPane {
539	case PanelTypeChat:
540		bindings = append([]key.Binding{
541			key.NewBinding(
542				key.WithKeys("tab"),
543				key.WithHelp("tab", "focus editor"),
544			),
545		}, bindings...)
546		bindings = append(bindings, p.chat.Bindings()...)
547	case PanelTypeEditor:
548		bindings = append([]key.Binding{
549			key.NewBinding(
550				key.WithKeys("tab"),
551				key.WithHelp("tab", "focus chat"),
552			),
553		}, bindings...)
554		bindings = append(bindings, p.editor.Bindings()...)
555	case PanelTypeSplash:
556		bindings = append(bindings, p.splash.Bindings()...)
557	}
558
559	return bindings
560}
561
562func (p *chatPage) IsChatFocused() bool {
563	return p.focusedPane == PanelTypeChat
564}