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