chat.go

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