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