chat.go

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