1package chat
   2
   3import (
   4	"context"
   5	"time"
   6
   7	"github.com/charmbracelet/bubbles/v2/help"
   8	"github.com/charmbracelet/bubbles/v2/key"
   9	"github.com/charmbracelet/bubbles/v2/spinner"
  10	tea "github.com/charmbracelet/bubbletea/v2"
  11	"github.com/charmbracelet/crush/internal/app"
  12	"github.com/charmbracelet/crush/internal/config"
  13	"github.com/charmbracelet/crush/internal/history"
  14	"github.com/charmbracelet/crush/internal/message"
  15	"github.com/charmbracelet/crush/internal/permission"
  16	"github.com/charmbracelet/crush/internal/pubsub"
  17	"github.com/charmbracelet/crush/internal/session"
  18	"github.com/charmbracelet/crush/internal/tui/components/anim"
  19	"github.com/charmbracelet/crush/internal/tui/components/chat"
  20	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
  21	"github.com/charmbracelet/crush/internal/tui/components/chat/header"
  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"
  26	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
  27	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
  28	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
  29	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
  30	"github.com/charmbracelet/crush/internal/tui/page"
  31	"github.com/charmbracelet/crush/internal/tui/styles"
  32	"github.com/charmbracelet/crush/internal/tui/util"
  33	"github.com/charmbracelet/crush/internal/version"
  34	"github.com/charmbracelet/lipgloss/v2"
  35)
  36
  37var ChatPageID page.PageID = "chat"
  38
  39type (
  40	ChatFocusedMsg struct {
  41		Focused bool
  42	}
  43	CancelTimerExpiredMsg struct{}
  44)
  45
  46type PanelType string
  47
  48const (
  49	PanelTypeChat   PanelType = "chat"
  50	PanelTypeEditor PanelType = "editor"
  51	PanelTypeSplash PanelType = "splash"
  52)
  53
  54const (
  55	CompactModeWidthBreakpoint  = 120 // Width at which the chat page switches to compact mode
  56	CompactModeHeightBreakpoint = 30  // Height at which the chat page switches to compact mode
  57	EditorHeight                = 5   // Height of the editor input area including padding
  58	SideBarWidth                = 31  // Width of the sidebar
  59	SideBarDetailsPadding       = 1   // Padding for the sidebar details section
  60	HeaderHeight                = 1   // Height of the header
  61
  62	// Layout constants for borders and padding
  63	BorderWidth        = 1 // Width of component borders
  64	LeftRightBorders   = 2 // Left + right border width (1 + 1)
  65	TopBottomBorders   = 2 // Top + bottom border width (1 + 1)
  66	DetailsPositioning = 2 // Positioning adjustment for details panel
  67
  68	// Timing constants
  69	CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
  70)
  71
  72type ChatPage interface {
  73	util.Model
  74	layout.Help
  75	IsChatFocused() bool
  76}
  77
  78// cancelTimerCmd creates a command that expires the cancel timer
  79func cancelTimerCmd() tea.Cmd {
  80	return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
  81		return CancelTimerExpiredMsg{}
  82	})
  83}
  84
  85type chatPage struct {
  86	width, height               int
  87	detailsWidth, detailsHeight int
  88	app                         *app.App
  89	keyboardEnhancements        tea.KeyboardEnhancementsMsg
  90
  91	// Layout state
  92	compact      bool
  93	forceCompact bool
  94	focusedPane  PanelType
  95
  96	// Session
  97	session session.Session
  98	keyMap  KeyMap
  99
 100	// Components
 101	header  header.Header
 102	sidebar sidebar.Sidebar
 103	chat    chat.MessageListCmp
 104	editor  editor.Editor
 105	splash  splash.Splash
 106
 107	// Simple state flags
 108	showingDetails   bool
 109	isCanceling      bool
 110	splashFullScreen bool
 111	isOnboarding     bool
 112	isProjectInit    bool
 113}
 114
 115func New(app *app.App) ChatPage {
 116	return &chatPage{
 117		app:         app,
 118		keyMap:      DefaultKeyMap(),
 119		header:      header.New(app.LSPClients),
 120		sidebar:     sidebar.New(app.History, app.LSPClients, false),
 121		chat:        chat.New(app),
 122		editor:      editor.New(app),
 123		splash:      splash.New(),
 124		focusedPane: PanelTypeSplash,
 125	}
 126}
 127
 128func (p *chatPage) Init() tea.Cmd {
 129	cfg := config.Get()
 130	compact := cfg.Options.TUI.CompactMode
 131	p.compact = compact
 132	p.forceCompact = compact
 133	p.sidebar.SetCompactMode(p.compact)
 134
 135	// Set splash state based on config
 136	if !config.HasInitialDataConfig() {
 137		// First-time setup: show model selection
 138		p.splash.SetOnboarding(true)
 139		p.isOnboarding = true
 140		p.splashFullScreen = true
 141	} else if b, _ := config.ProjectNeedsInitialization(); b {
 142		// Project needs CRUSH.md initialization
 143		p.splash.SetProjectInit(true)
 144		p.isProjectInit = true
 145		p.splashFullScreen = true
 146	} else {
 147		// Ready to chat: focus editor, splash in background
 148		p.focusedPane = PanelTypeEditor
 149		p.splashFullScreen = false
 150	}
 151
 152	return tea.Batch(
 153		p.header.Init(),
 154		p.sidebar.Init(),
 155		p.chat.Init(),
 156		p.editor.Init(),
 157		p.splash.Init(),
 158	)
 159}
 160
 161func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 162	var cmds []tea.Cmd
 163	switch msg := msg.(type) {
 164	case tea.KeyboardEnhancementsMsg:
 165		p.keyboardEnhancements = msg
 166		return p, nil
 167	case tea.MouseWheelMsg:
 168		if p.isMouseOverChat(msg.X, msg.Y) {
 169			u, cmd := p.chat.Update(msg)
 170			p.chat = u.(chat.MessageListCmp)
 171			return p, cmd
 172		}
 173		return p, nil
 174	case tea.MouseClickMsg:
 175		if msg.Button == tea.MouseLeft {
 176			if p.isMouseOverChat(msg.X, msg.Y) {
 177				u, cmd := p.chat.Update(msg)
 178				p.chat = u.(chat.MessageListCmp)
 179				return p, cmd
 180			}
 181		}
 182		return p, nil
 183	case tea.MouseMotionMsg:
 184		if msg.Button == tea.MouseLeft {
 185			if p.isMouseOverChat(msg.X, msg.Y) {
 186				u, cmd := p.chat.Update(msg)
 187				p.chat = u.(chat.MessageListCmp)
 188				return p, cmd
 189			}
 190		}
 191		return p, nil
 192	case tea.MouseReleaseMsg:
 193		if msg.Button == tea.MouseLeft {
 194			if p.isMouseOverChat(msg.X, msg.Y) {
 195				u, cmd := p.chat.Update(msg)
 196				p.chat = u.(chat.MessageListCmp)
 197				return p, cmd
 198			}
 199		}
 200		return p, nil
 201	case tea.WindowSizeMsg:
 202		u, cmd := p.editor.Update(msg)
 203		p.editor = u.(editor.Editor)
 204		return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
 205	case CancelTimerExpiredMsg:
 206		p.isCanceling = false
 207		return p, nil
 208	case editor.OpenEditorMsg:
 209		u, cmd := p.editor.Update(msg)
 210		p.editor = u.(editor.Editor)
 211		return p, cmd
 212	case chat.SendMsg:
 213		return p, p.sendMessage(msg.Text, msg.Attachments)
 214	case chat.SessionSelectedMsg:
 215		return p, p.setSession(msg)
 216	case splash.SubmitAPIKeyMsg:
 217		u, cmd := p.splash.Update(msg)
 218		p.splash = u.(splash.Splash)
 219		cmds = append(cmds, cmd)
 220		return p, tea.Batch(cmds...)
 221	case commands.ToggleCompactModeMsg:
 222		p.forceCompact = !p.forceCompact
 223		var cmd tea.Cmd
 224		if p.forceCompact {
 225			p.setCompactMode(true)
 226			cmd = p.updateCompactConfig(true)
 227		} else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
 228			p.setCompactMode(false)
 229			cmd = p.updateCompactConfig(false)
 230		}
 231		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
 232	case commands.ToggleThinkingMsg:
 233		return p, p.toggleThinking()
 234	case commands.OpenExternalEditorMsg:
 235		u, cmd := p.editor.Update(msg)
 236		p.editor = u.(editor.Editor)
 237		return p, cmd
 238	case pubsub.Event[session.Session]:
 239		u, cmd := p.header.Update(msg)
 240		p.header = u.(header.Header)
 241		cmds = append(cmds, cmd)
 242		u, cmd = p.sidebar.Update(msg)
 243		p.sidebar = u.(sidebar.Sidebar)
 244		cmds = append(cmds, cmd)
 245		return p, tea.Batch(cmds...)
 246	case chat.SessionClearedMsg:
 247		u, cmd := p.header.Update(msg)
 248		p.header = u.(header.Header)
 249		cmds = append(cmds, cmd)
 250		u, cmd = p.sidebar.Update(msg)
 251		p.sidebar = u.(sidebar.Sidebar)
 252		cmds = append(cmds, cmd)
 253		u, cmd = p.chat.Update(msg)
 254		p.chat = u.(chat.MessageListCmp)
 255		cmds = append(cmds, cmd)
 256		return p, tea.Batch(cmds...)
 257	case filepicker.FilePickedMsg,
 258		completions.CompletionsClosedMsg,
 259		completions.SelectCompletionMsg:
 260		u, cmd := p.editor.Update(msg)
 261		p.editor = u.(editor.Editor)
 262		cmds = append(cmds, cmd)
 263		return p, tea.Batch(cmds...)
 264
 265	case models.APIKeyStateChangeMsg:
 266		if p.focusedPane == PanelTypeSplash {
 267			u, cmd := p.splash.Update(msg)
 268			p.splash = u.(splash.Splash)
 269			cmds = append(cmds, cmd)
 270		}
 271		return p, tea.Batch(cmds...)
 272	case pubsub.Event[message.Message],
 273		anim.StepMsg,
 274		spinner.TickMsg:
 275		if p.focusedPane == PanelTypeSplash {
 276			u, cmd := p.splash.Update(msg)
 277			p.splash = u.(splash.Splash)
 278			cmds = append(cmds, cmd)
 279		} else {
 280			u, cmd := p.chat.Update(msg)
 281			p.chat = u.(chat.MessageListCmp)
 282			cmds = append(cmds, cmd)
 283		}
 284
 285		return p, tea.Batch(cmds...)
 286
 287	case pubsub.Event[history.File], sidebar.SessionFilesMsg:
 288		u, cmd := p.sidebar.Update(msg)
 289		p.sidebar = u.(sidebar.Sidebar)
 290		cmds = append(cmds, cmd)
 291		return p, tea.Batch(cmds...)
 292	case pubsub.Event[permission.PermissionNotification]:
 293		u, cmd := p.chat.Update(msg)
 294		p.chat = u.(chat.MessageListCmp)
 295		cmds = append(cmds, cmd)
 296		return p, tea.Batch(cmds...)
 297
 298	case commands.CommandRunCustomMsg:
 299		if p.app.CoderAgent.IsBusy() {
 300			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
 301		}
 302
 303		cmd := p.sendMessage(msg.Content, nil)
 304		if cmd != nil {
 305			return p, cmd
 306		}
 307	case splash.OnboardingCompleteMsg:
 308		p.splashFullScreen = false
 309		if b, _ := config.ProjectNeedsInitialization(); b {
 310			p.splash.SetProjectInit(true)
 311			p.splashFullScreen = true
 312			return p, p.SetSize(p.width, p.height)
 313		}
 314		err := p.app.InitCoderAgent()
 315		if err != nil {
 316			return p, util.ReportError(err)
 317		}
 318		p.isOnboarding = false
 319		p.isProjectInit = false
 320		p.focusedPane = PanelTypeEditor
 321		return p, p.SetSize(p.width, p.height)
 322	case commands.NewSessionsMsg:
 323		if p.app.CoderAgent.IsBusy() {
 324			return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 325		}
 326		return p, p.newSession()
 327	case tea.KeyPressMsg:
 328		switch {
 329		case key.Matches(msg, p.keyMap.NewSession):
 330			// if we have no agent do nothing
 331			if p.app.CoderAgent == nil {
 332				return p, nil
 333			}
 334			if p.app.CoderAgent.IsBusy() {
 335				return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 336			}
 337			return p, p.newSession()
 338		case key.Matches(msg, p.keyMap.AddAttachment):
 339			agentCfg := config.Get().Agents["coder"]
 340			model := config.Get().GetModelByType(agentCfg.Model)
 341			if model.SupportsImages {
 342				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
 343			} else {
 344				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
 345			}
 346		case key.Matches(msg, p.keyMap.Tab):
 347			if p.session.ID == "" {
 348				u, cmd := p.splash.Update(msg)
 349				p.splash = u.(splash.Splash)
 350				return p, cmd
 351			}
 352			p.changeFocus()
 353			return p, nil
 354		case key.Matches(msg, p.keyMap.Cancel):
 355			if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
 356				return p, p.cancel()
 357			}
 358		case key.Matches(msg, p.keyMap.Details):
 359			p.toggleDetails()
 360			return p, nil
 361		}
 362
 363		switch p.focusedPane {
 364		case PanelTypeChat:
 365			u, cmd := p.chat.Update(msg)
 366			p.chat = u.(chat.MessageListCmp)
 367			cmds = append(cmds, cmd)
 368		case PanelTypeEditor:
 369			u, cmd := p.editor.Update(msg)
 370			p.editor = u.(editor.Editor)
 371			cmds = append(cmds, cmd)
 372		case PanelTypeSplash:
 373			u, cmd := p.splash.Update(msg)
 374			p.splash = u.(splash.Splash)
 375			cmds = append(cmds, cmd)
 376		}
 377	case tea.PasteMsg:
 378		switch p.focusedPane {
 379		case PanelTypeEditor:
 380			u, cmd := p.editor.Update(msg)
 381			p.editor = u.(editor.Editor)
 382			cmds = append(cmds, cmd)
 383			return p, tea.Batch(cmds...)
 384		case PanelTypeChat:
 385			u, cmd := p.chat.Update(msg)
 386			p.chat = u.(chat.MessageListCmp)
 387			cmds = append(cmds, cmd)
 388			return p, tea.Batch(cmds...)
 389		case PanelTypeSplash:
 390			u, cmd := p.splash.Update(msg)
 391			p.splash = u.(splash.Splash)
 392			cmds = append(cmds, cmd)
 393			return p, tea.Batch(cmds...)
 394		}
 395	}
 396	return p, tea.Batch(cmds...)
 397}
 398
 399func (p *chatPage) Cursor() *tea.Cursor {
 400	if p.header.ShowingDetails() {
 401		return nil
 402	}
 403	switch p.focusedPane {
 404	case PanelTypeEditor:
 405		return p.editor.Cursor()
 406	case PanelTypeSplash:
 407		return p.splash.Cursor()
 408	default:
 409		return nil
 410	}
 411}
 412
 413func (p *chatPage) View() string {
 414	var chatView string
 415	t := styles.CurrentTheme()
 416
 417	if p.session.ID == "" {
 418		splashView := p.splash.View()
 419		// Full screen during onboarding or project initialization
 420		if p.splashFullScreen {
 421			chatView = splashView
 422		} else {
 423			// Show splash + editor for new message state
 424			editorView := p.editor.View()
 425			chatView = lipgloss.JoinVertical(
 426				lipgloss.Left,
 427				t.S().Base.Render(splashView),
 428				editorView,
 429			)
 430		}
 431	} else {
 432		messagesView := p.chat.View()
 433		editorView := p.editor.View()
 434		if p.compact {
 435			headerView := p.header.View()
 436			chatView = lipgloss.JoinVertical(
 437				lipgloss.Left,
 438				headerView,
 439				messagesView,
 440				editorView,
 441			)
 442		} else {
 443			sidebarView := p.sidebar.View()
 444			messages := lipgloss.JoinHorizontal(
 445				lipgloss.Left,
 446				messagesView,
 447				sidebarView,
 448			)
 449			chatView = lipgloss.JoinVertical(
 450				lipgloss.Left,
 451				messages,
 452				p.editor.View(),
 453			)
 454		}
 455	}
 456
 457	layers := []*lipgloss.Layer{
 458		lipgloss.NewLayer(chatView).X(0).Y(0),
 459	}
 460
 461	if p.showingDetails {
 462		style := t.S().Base.
 463			Width(p.detailsWidth).
 464			Border(lipgloss.RoundedBorder()).
 465			BorderForeground(t.BorderFocus)
 466		version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
 467		details := style.Render(
 468			lipgloss.JoinVertical(
 469				lipgloss.Left,
 470				p.sidebar.View(),
 471				version,
 472			),
 473		)
 474		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
 475	}
 476	canvas := lipgloss.NewCanvas(
 477		layers...,
 478	)
 479	return canvas.Render()
 480}
 481
 482func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 483	return func() tea.Msg {
 484		err := config.Get().SetCompactMode(compact)
 485		if err != nil {
 486			return util.InfoMsg{
 487				Type: util.InfoTypeError,
 488				Msg:  "Failed to update compact mode configuration: " + err.Error(),
 489			}
 490		}
 491		return nil
 492	}
 493}
 494
 495func (p *chatPage) toggleThinking() tea.Cmd {
 496	return func() tea.Msg {
 497		cfg := config.Get()
 498		agentCfg := cfg.Agents["coder"]
 499		currentModel := cfg.Models[agentCfg.Model]
 500
 501		// Toggle the thinking mode
 502		currentModel.Think = !currentModel.Think
 503		cfg.Models[agentCfg.Model] = currentModel
 504
 505		// Update the agent with the new configuration
 506		if err := p.app.UpdateAgentModel(); err != nil {
 507			return util.InfoMsg{
 508				Type: util.InfoTypeError,
 509				Msg:  "Failed to update thinking mode: " + err.Error(),
 510			}
 511		}
 512
 513		status := "disabled"
 514		if currentModel.Think {
 515			status = "enabled"
 516		}
 517		return util.InfoMsg{
 518			Type: util.InfoTypeInfo,
 519			Msg:  "Thinking mode " + status,
 520		}
 521	}
 522}
 523
 524func (p *chatPage) setCompactMode(compact bool) {
 525	if p.compact == compact {
 526		return
 527	}
 528	p.compact = compact
 529	if compact {
 530		p.sidebar.SetCompactMode(true)
 531	} else {
 532		p.setShowDetails(false)
 533	}
 534}
 535
 536func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
 537	if p.forceCompact {
 538		return
 539	}
 540	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
 541		p.setCompactMode(true)
 542	}
 543	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
 544		p.setCompactMode(false)
 545	}
 546}
 547
 548func (p *chatPage) SetSize(width, height int) tea.Cmd {
 549	p.handleCompactMode(width, height)
 550	p.width = width
 551	p.height = height
 552	var cmds []tea.Cmd
 553
 554	if p.session.ID == "" {
 555		if p.splashFullScreen {
 556			cmds = append(cmds, p.splash.SetSize(width, height))
 557		} else {
 558			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
 559			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 560			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 561		}
 562	} else {
 563		if p.compact {
 564			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
 565			p.detailsWidth = width - DetailsPositioning
 566			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
 567			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 568			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
 569		} else {
 570			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
 571			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 572			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
 573		}
 574		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 575	}
 576	return tea.Batch(cmds...)
 577}
 578
 579func (p *chatPage) newSession() tea.Cmd {
 580	if p.session.ID == "" {
 581		return nil
 582	}
 583
 584	p.session = session.Session{}
 585	p.focusedPane = PanelTypeEditor
 586	p.editor.Focus()
 587	p.chat.Blur()
 588	p.isCanceling = false
 589	return tea.Batch(
 590		util.CmdHandler(chat.SessionClearedMsg{}),
 591		p.SetSize(p.width, p.height),
 592	)
 593}
 594
 595func (p *chatPage) setSession(session session.Session) tea.Cmd {
 596	if p.session.ID == session.ID {
 597		return nil
 598	}
 599
 600	var cmds []tea.Cmd
 601	p.session = session
 602
 603	cmds = append(cmds, p.SetSize(p.width, p.height))
 604	cmds = append(cmds, p.chat.SetSession(session))
 605	cmds = append(cmds, p.sidebar.SetSession(session))
 606	cmds = append(cmds, p.header.SetSession(session))
 607	cmds = append(cmds, p.editor.SetSession(session))
 608
 609	return tea.Sequence(cmds...)
 610}
 611
 612func (p *chatPage) changeFocus() {
 613	if p.session.ID == "" {
 614		return
 615	}
 616	switch p.focusedPane {
 617	case PanelTypeChat:
 618		p.focusedPane = PanelTypeEditor
 619		p.editor.Focus()
 620		p.chat.Blur()
 621	case PanelTypeEditor:
 622		p.focusedPane = PanelTypeChat
 623		p.chat.Focus()
 624		p.editor.Blur()
 625	}
 626}
 627
 628func (p *chatPage) cancel() tea.Cmd {
 629	if p.isCanceling {
 630		p.isCanceling = false
 631		p.app.CoderAgent.Cancel(p.session.ID)
 632		return nil
 633	}
 634
 635	p.isCanceling = true
 636	return cancelTimerCmd()
 637}
 638
 639func (p *chatPage) setShowDetails(show bool) {
 640	p.showingDetails = show
 641	p.header.SetDetailsOpen(p.showingDetails)
 642	if !p.compact {
 643		p.sidebar.SetCompactMode(false)
 644	}
 645}
 646
 647func (p *chatPage) toggleDetails() {
 648	if p.session.ID == "" || !p.compact {
 649		return
 650	}
 651	p.setShowDetails(!p.showingDetails)
 652}
 653
 654func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
 655	session := p.session
 656	var cmds []tea.Cmd
 657	if p.session.ID == "" {
 658		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
 659		if err != nil {
 660			return util.ReportError(err)
 661		}
 662		session = newSession
 663		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 664	}
 665	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
 666	if err != nil {
 667		return util.ReportError(err)
 668	}
 669	cmds = append(cmds, p.chat.GoToBottom())
 670	return tea.Batch(cmds...)
 671}
 672
 673func (p *chatPage) Bindings() []key.Binding {
 674	bindings := []key.Binding{
 675		p.keyMap.NewSession,
 676		p.keyMap.AddAttachment,
 677	}
 678	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
 679		cancelBinding := p.keyMap.Cancel
 680		if p.isCanceling {
 681			cancelBinding = key.NewBinding(
 682				key.WithKeys("esc"),
 683				key.WithHelp("esc", "press again to cancel"),
 684			)
 685		}
 686		bindings = append([]key.Binding{cancelBinding}, bindings...)
 687	}
 688
 689	switch p.focusedPane {
 690	case PanelTypeChat:
 691		bindings = append([]key.Binding{
 692			key.NewBinding(
 693				key.WithKeys("tab"),
 694				key.WithHelp("tab", "focus editor"),
 695			),
 696		}, bindings...)
 697		bindings = append(bindings, p.chat.Bindings()...)
 698	case PanelTypeEditor:
 699		bindings = append([]key.Binding{
 700			key.NewBinding(
 701				key.WithKeys("tab"),
 702				key.WithHelp("tab", "focus chat"),
 703			),
 704		}, bindings...)
 705		bindings = append(bindings, p.editor.Bindings()...)
 706	case PanelTypeSplash:
 707		bindings = append(bindings, p.splash.Bindings()...)
 708	}
 709
 710	return bindings
 711}
 712
 713func (p *chatPage) Help() help.KeyMap {
 714	var shortList []key.Binding
 715	var fullList [][]key.Binding
 716	switch {
 717	case p.isOnboarding && !p.splash.IsShowingAPIKey():
 718		shortList = append(shortList,
 719			// Choose model
 720			key.NewBinding(
 721				key.WithKeys("up", "down"),
 722				key.WithHelp("↑/↓", "choose"),
 723			),
 724			// Accept selection
 725			key.NewBinding(
 726				key.WithKeys("enter", "ctrl+y"),
 727				key.WithHelp("enter", "accept"),
 728			),
 729			// Quit
 730			key.NewBinding(
 731				key.WithKeys("ctrl+c"),
 732				key.WithHelp("ctrl+c", "quit"),
 733			),
 734		)
 735		// keep them the same
 736		for _, v := range shortList {
 737			fullList = append(fullList, []key.Binding{v})
 738		}
 739	case p.isOnboarding && p.splash.IsShowingAPIKey():
 740		if p.splash.IsAPIKeyValid() {
 741			shortList = append(shortList,
 742				key.NewBinding(
 743					key.WithKeys("enter"),
 744					key.WithHelp("enter", "continue"),
 745				),
 746			)
 747		} else {
 748			shortList = append(shortList,
 749				// Go back
 750				key.NewBinding(
 751					key.WithKeys("esc"),
 752					key.WithHelp("esc", "back"),
 753				),
 754			)
 755		}
 756		shortList = append(shortList,
 757			// Quit
 758			key.NewBinding(
 759				key.WithKeys("ctrl+c"),
 760				key.WithHelp("ctrl+c", "quit"),
 761			),
 762		)
 763		// keep them the same
 764		for _, v := range shortList {
 765			fullList = append(fullList, []key.Binding{v})
 766		}
 767	case p.isProjectInit:
 768		shortList = append(shortList,
 769			key.NewBinding(
 770				key.WithKeys("ctrl+c"),
 771				key.WithHelp("ctrl+c", "quit"),
 772			),
 773		)
 774		// keep them the same
 775		for _, v := range shortList {
 776			fullList = append(fullList, []key.Binding{v})
 777		}
 778	default:
 779		if p.editor.IsCompletionsOpen() {
 780			shortList = append(shortList,
 781				key.NewBinding(
 782					key.WithKeys("tab", "enter"),
 783					key.WithHelp("tab/enter", "complete"),
 784				),
 785				key.NewBinding(
 786					key.WithKeys("esc"),
 787					key.WithHelp("esc", "cancel"),
 788				),
 789				key.NewBinding(
 790					key.WithKeys("up", "down"),
 791					key.WithHelp("↑/↓", "choose"),
 792				),
 793			)
 794			for _, v := range shortList {
 795				fullList = append(fullList, []key.Binding{v})
 796			}
 797			return core.NewSimpleHelp(shortList, fullList)
 798		}
 799		if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
 800			cancelBinding := key.NewBinding(
 801				key.WithKeys("esc"),
 802				key.WithHelp("esc", "cancel"),
 803			)
 804			if p.isCanceling {
 805				cancelBinding = key.NewBinding(
 806					key.WithKeys("esc"),
 807					key.WithHelp("esc", "press again to cancel"),
 808				)
 809			}
 810			shortList = append(shortList, cancelBinding)
 811			fullList = append(fullList,
 812				[]key.Binding{
 813					cancelBinding,
 814				},
 815			)
 816		}
 817		globalBindings := []key.Binding{}
 818		// we are in a session
 819		if p.session.ID != "" {
 820			tabKey := key.NewBinding(
 821				key.WithKeys("tab"),
 822				key.WithHelp("tab", "focus chat"),
 823			)
 824			if p.focusedPane == PanelTypeChat {
 825				tabKey = key.NewBinding(
 826					key.WithKeys("tab"),
 827					key.WithHelp("tab", "focus editor"),
 828				)
 829			}
 830			shortList = append(shortList, tabKey)
 831			globalBindings = append(globalBindings, tabKey)
 832		}
 833		commandsBinding := key.NewBinding(
 834			key.WithKeys("ctrl+p"),
 835			key.WithHelp("ctrl+p", "commands"),
 836		)
 837		helpBinding := key.NewBinding(
 838			key.WithKeys("ctrl+g"),
 839			key.WithHelp("ctrl+g", "more"),
 840		)
 841		globalBindings = append(globalBindings, commandsBinding)
 842		globalBindings = append(globalBindings,
 843			key.NewBinding(
 844				key.WithKeys("ctrl+s"),
 845				key.WithHelp("ctrl+s", "sessions"),
 846			),
 847		)
 848		if p.session.ID != "" {
 849			globalBindings = append(globalBindings,
 850				key.NewBinding(
 851					key.WithKeys("ctrl+n"),
 852					key.WithHelp("ctrl+n", "new sessions"),
 853				))
 854		}
 855		shortList = append(shortList,
 856			// Commands
 857			commandsBinding,
 858		)
 859		fullList = append(fullList, globalBindings)
 860
 861		switch p.focusedPane {
 862		case PanelTypeChat:
 863			shortList = append(shortList,
 864				key.NewBinding(
 865					key.WithKeys("up", "down"),
 866					key.WithHelp("↑↓", "scroll"),
 867				),
 868				key.NewBinding(
 869					key.WithKeys("c", "y"),
 870					key.WithHelp("c/y", "copy"),
 871				),
 872			)
 873			fullList = append(fullList,
 874				[]key.Binding{
 875					key.NewBinding(
 876						key.WithKeys("up", "down"),
 877						key.WithHelp("↑↓", "scroll"),
 878					),
 879					key.NewBinding(
 880						key.WithKeys("shift+up", "shift+down"),
 881						key.WithHelp("shift+↑↓", "next/prev item"),
 882					),
 883					key.NewBinding(
 884						key.WithKeys("pgup", "b"),
 885						key.WithHelp("b/pgup", "page up"),
 886					),
 887					key.NewBinding(
 888						key.WithKeys("pgdown", " ", "f"),
 889						key.WithHelp("f/pgdn", "page down"),
 890					),
 891				},
 892				[]key.Binding{
 893					key.NewBinding(
 894						key.WithKeys("u"),
 895						key.WithHelp("u", "half page up"),
 896					),
 897					key.NewBinding(
 898						key.WithKeys("d"),
 899						key.WithHelp("d", "half page down"),
 900					),
 901					key.NewBinding(
 902						key.WithKeys("g", "home"),
 903						key.WithHelp("g", "home"),
 904					),
 905					key.NewBinding(
 906						key.WithKeys("G", "end"),
 907						key.WithHelp("G", "end"),
 908					),
 909				},
 910			)
 911		case PanelTypeEditor:
 912			newLineBinding := key.NewBinding(
 913				key.WithKeys("shift+enter", "ctrl+j"),
 914				// "ctrl+j" is a common keybinding for newline in many editors. If
 915				// the terminal supports "shift+enter", we substitute the help text
 916				// to reflect that.
 917				key.WithHelp("ctrl+j", "newline"),
 918			)
 919			if p.keyboardEnhancements.SupportsKeyDisambiguation() {
 920				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
 921			}
 922			shortList = append(shortList, newLineBinding)
 923			fullList = append(fullList,
 924				[]key.Binding{
 925					newLineBinding,
 926					key.NewBinding(
 927						key.WithKeys("ctrl+f"),
 928						key.WithHelp("ctrl+f", "add image"),
 929					),
 930					key.NewBinding(
 931						key.WithKeys("/"),
 932						key.WithHelp("/", "add file"),
 933					),
 934					key.NewBinding(
 935						key.WithKeys("ctrl+o"),
 936						key.WithHelp("ctrl+o", "open editor"),
 937					),
 938				})
 939
 940			if p.editor.HasAttachments() {
 941				fullList = append(fullList, []key.Binding{
 942					key.NewBinding(
 943						key.WithKeys("ctrl+r"),
 944						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
 945					),
 946					key.NewBinding(
 947						key.WithKeys("ctrl+r", "r"),
 948						key.WithHelp("ctrl+r+r", "delete all attachments"),
 949					),
 950					key.NewBinding(
 951						key.WithKeys("esc"),
 952						key.WithHelp("esc", "cancel delete mode"),
 953					),
 954				})
 955			}
 956		}
 957		shortList = append(shortList,
 958			// Quit
 959			key.NewBinding(
 960				key.WithKeys("ctrl+c"),
 961				key.WithHelp("ctrl+c", "quit"),
 962			),
 963			// Help
 964			helpBinding,
 965		)
 966		fullList = append(fullList, []key.Binding{
 967			key.NewBinding(
 968				key.WithKeys("ctrl+g"),
 969				key.WithHelp("ctrl+g", "less"),
 970			),
 971		})
 972	}
 973
 974	return core.NewSimpleHelp(shortList, fullList)
 975}
 976
 977func (p *chatPage) IsChatFocused() bool {
 978	return p.focusedPane == PanelTypeChat
 979}
 980
 981// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
 982// Returns true if the mouse is over the chat area, false otherwise.
 983func (p *chatPage) isMouseOverChat(x, y int) bool {
 984	// No session means no chat area
 985	if p.session.ID == "" {
 986		return false
 987	}
 988
 989	var chatX, chatY, chatWidth, chatHeight int
 990
 991	if p.compact {
 992		// In compact mode: chat area starts after header and spans full width
 993		chatX = 0
 994		chatY = HeaderHeight
 995		chatWidth = p.width
 996		chatHeight = p.height - EditorHeight - HeaderHeight
 997	} else {
 998		// In non-compact mode: chat area spans from left edge to sidebar
 999		chatX = 0
1000		chatY = 0
1001		chatWidth = p.width - SideBarWidth
1002		chatHeight = p.height - EditorHeight
1003	}
1004
1005	// Check if mouse coordinates are within chat bounds
1006	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1007}