chat.go

   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/catwalk/pkg/catwalk"
  13	"github.com/charmbracelet/crush/internal/app"
  14	"github.com/charmbracelet/crush/internal/config"
  15	"github.com/charmbracelet/crush/internal/history"
  16	"github.com/charmbracelet/crush/internal/message"
  17	"github.com/charmbracelet/crush/internal/permission"
  18	"github.com/charmbracelet/crush/internal/pubsub"
  19	"github.com/charmbracelet/crush/internal/session"
  20	"github.com/charmbracelet/crush/internal/tui/components/anim"
  21	"github.com/charmbracelet/crush/internal/tui/components/chat"
  22	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
  23	"github.com/charmbracelet/crush/internal/tui/components/chat/header"
  24	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
  25	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
  26	"github.com/charmbracelet/crush/internal/tui/components/chat/splash"
  27	"github.com/charmbracelet/crush/internal/tui/components/completions"
  28	"github.com/charmbracelet/crush/internal/tui/components/core"
  29	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
  30	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
  31	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
  32	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
  33	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
  34	"github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
  35	"github.com/charmbracelet/crush/internal/tui/page"
  36	"github.com/charmbracelet/crush/internal/tui/styles"
  37	"github.com/charmbracelet/crush/internal/tui/util"
  38	"github.com/charmbracelet/crush/internal/version"
  39	"github.com/charmbracelet/lipgloss/v2"
  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 CRUSH.md 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) (tea.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.CoderAgent.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()
 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.CoderAgent.IsBusy() {
 359			return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 360		}
 361		return p, p.newSession()
 362	case tea.KeyPressMsg:
 363		switch {
 364		case key.Matches(msg, p.keyMap.NewSession):
 365			// if we have no agent do nothing
 366			if p.app.CoderAgent == nil {
 367				return p, nil
 368			}
 369			if p.app.CoderAgent.IsBusy() {
 370				return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 371			}
 372			return p, p.newSession()
 373		case key.Matches(msg, p.keyMap.AddAttachment):
 374			agentCfg := config.Get().Agents["coder"]
 375			model := config.Get().GetModelByType(agentCfg.Model)
 376			if model.SupportsImages {
 377				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
 378			} else {
 379				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
 380			}
 381		case key.Matches(msg, p.keyMap.Tab):
 382			if p.session.ID == "" {
 383				u, cmd := p.splash.Update(msg)
 384				p.splash = u.(splash.Splash)
 385				return p, cmd
 386			}
 387			p.changeFocus()
 388			return p, nil
 389		case key.Matches(msg, p.keyMap.Cancel):
 390			if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
 391				return p, p.cancel()
 392			}
 393		case key.Matches(msg, p.keyMap.Details):
 394			p.toggleDetails()
 395			return p, nil
 396		}
 397
 398		switch p.focusedPane {
 399		case PanelTypeChat:
 400			u, cmd := p.chat.Update(msg)
 401			p.chat = u.(chat.MessageListCmp)
 402			cmds = append(cmds, cmd)
 403		case PanelTypeEditor:
 404			u, cmd := p.editor.Update(msg)
 405			p.editor = u.(editor.Editor)
 406			cmds = append(cmds, cmd)
 407		case PanelTypeSplash:
 408			u, cmd := p.splash.Update(msg)
 409			p.splash = u.(splash.Splash)
 410			cmds = append(cmds, cmd)
 411		}
 412	case tea.PasteMsg:
 413		switch p.focusedPane {
 414		case PanelTypeEditor:
 415			u, cmd := p.editor.Update(msg)
 416			p.editor = u.(editor.Editor)
 417			cmds = append(cmds, cmd)
 418			return p, tea.Batch(cmds...)
 419		case PanelTypeChat:
 420			u, cmd := p.chat.Update(msg)
 421			p.chat = u.(chat.MessageListCmp)
 422			cmds = append(cmds, cmd)
 423			return p, tea.Batch(cmds...)
 424		case PanelTypeSplash:
 425			u, cmd := p.splash.Update(msg)
 426			p.splash = u.(splash.Splash)
 427			cmds = append(cmds, cmd)
 428			return p, tea.Batch(cmds...)
 429		}
 430	}
 431	return p, tea.Batch(cmds...)
 432}
 433
 434func (p *chatPage) Cursor() *tea.Cursor {
 435	if p.header.ShowingDetails() {
 436		return nil
 437	}
 438	switch p.focusedPane {
 439	case PanelTypeEditor:
 440		return p.editor.Cursor()
 441	case PanelTypeSplash:
 442		return p.splash.Cursor()
 443	default:
 444		return nil
 445	}
 446}
 447
 448func (p *chatPage) View() string {
 449	var chatView string
 450	t := styles.CurrentTheme()
 451
 452	if p.session.ID == "" {
 453		splashView := p.splash.View()
 454		// Full screen during onboarding or project initialization
 455		if p.splashFullScreen {
 456			chatView = splashView
 457		} else {
 458			// Show splash + editor for new message state
 459			editorView := p.editor.View()
 460			chatView = lipgloss.JoinVertical(
 461				lipgloss.Left,
 462				t.S().Base.Render(splashView),
 463				editorView,
 464			)
 465		}
 466	} else {
 467		messagesView := p.chat.View()
 468		editorView := p.editor.View()
 469		if p.compact {
 470			headerView := p.header.View()
 471			chatView = lipgloss.JoinVertical(
 472				lipgloss.Left,
 473				headerView,
 474				messagesView,
 475				editorView,
 476			)
 477		} else {
 478			sidebarView := p.sidebar.View()
 479			messages := lipgloss.JoinHorizontal(
 480				lipgloss.Left,
 481				messagesView,
 482				sidebarView,
 483			)
 484			chatView = lipgloss.JoinVertical(
 485				lipgloss.Left,
 486				messages,
 487				p.editor.View(),
 488			)
 489		}
 490	}
 491
 492	layers := []*lipgloss.Layer{
 493		lipgloss.NewLayer(chatView).X(0).Y(0),
 494	}
 495
 496	if p.showingDetails {
 497		style := t.S().Base.
 498			Width(p.detailsWidth).
 499			Border(lipgloss.RoundedBorder()).
 500			BorderForeground(t.BorderFocus)
 501		version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
 502		details := style.Render(
 503			lipgloss.JoinVertical(
 504				lipgloss.Left,
 505				p.sidebar.View(),
 506				version,
 507			),
 508		)
 509		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
 510	}
 511	canvas := lipgloss.NewCanvas(
 512		layers...,
 513	)
 514	return canvas.Render()
 515}
 516
 517func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 518	return func() tea.Msg {
 519		err := config.Get().SetCompactMode(compact)
 520		if err != nil {
 521			return util.InfoMsg{
 522				Type: util.InfoTypeError,
 523				Msg:  "Failed to update compact mode configuration: " + err.Error(),
 524			}
 525		}
 526		return nil
 527	}
 528}
 529
 530func (p *chatPage) toggleThinking() tea.Cmd {
 531	return func() tea.Msg {
 532		cfg := config.Get()
 533		agentCfg := cfg.Agents["coder"]
 534		currentModel := cfg.Models[agentCfg.Model]
 535
 536		// Toggle the thinking mode
 537		currentModel.Think = !currentModel.Think
 538		cfg.Models[agentCfg.Model] = currentModel
 539
 540		// Update the agent with the new configuration
 541		if err := p.app.UpdateAgentModel(); err != nil {
 542			return util.InfoMsg{
 543				Type: util.InfoTypeError,
 544				Msg:  "Failed to update thinking mode: " + err.Error(),
 545			}
 546		}
 547
 548		status := "disabled"
 549		if currentModel.Think {
 550			status = "enabled"
 551		}
 552		return util.InfoMsg{
 553			Type: util.InfoTypeInfo,
 554			Msg:  "Thinking mode " + status,
 555		}
 556	}
 557}
 558
 559func (p *chatPage) openReasoningDialog() tea.Cmd {
 560	return func() tea.Msg {
 561		cfg := config.Get()
 562		agentCfg := cfg.Agents["coder"]
 563		model := cfg.GetModelByType(agentCfg.Model)
 564		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
 565
 566		if providerCfg != nil && model != nil &&
 567			providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
 568			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
 569			return dialogs.OpenDialogMsg{
 570				Model: reasoning.NewReasoningDialog(),
 571			}
 572		}
 573		return nil
 574	}
 575}
 576
 577func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 578	return func() tea.Msg {
 579		cfg := config.Get()
 580		agentCfg := cfg.Agents["coder"]
 581		currentModel := cfg.Models[agentCfg.Model]
 582
 583		// Update the model configuration
 584		currentModel.ReasoningEffort = effort
 585		cfg.Models[agentCfg.Model] = currentModel
 586
 587		// Update the agent with the new configuration
 588		if err := p.app.UpdateAgentModel(); err != nil {
 589			return util.InfoMsg{
 590				Type: util.InfoTypeError,
 591				Msg:  "Failed to update reasoning effort: " + err.Error(),
 592			}
 593		}
 594
 595		return util.InfoMsg{
 596			Type: util.InfoTypeInfo,
 597			Msg:  "Reasoning effort set to " + effort,
 598		}
 599	}
 600}
 601
 602func (p *chatPage) setCompactMode(compact bool) {
 603	if p.compact == compact {
 604		return
 605	}
 606	p.compact = compact
 607	if compact {
 608		p.sidebar.SetCompactMode(true)
 609	} else {
 610		p.setShowDetails(false)
 611	}
 612}
 613
 614func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
 615	if p.forceCompact {
 616		return
 617	}
 618	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
 619		p.setCompactMode(true)
 620	}
 621	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
 622		p.setCompactMode(false)
 623	}
 624}
 625
 626func (p *chatPage) SetSize(width, height int) tea.Cmd {
 627	p.handleCompactMode(width, height)
 628	p.width = width
 629	p.height = height
 630	var cmds []tea.Cmd
 631
 632	if p.session.ID == "" {
 633		if p.splashFullScreen {
 634			cmds = append(cmds, p.splash.SetSize(width, height))
 635		} else {
 636			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
 637			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 638			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 639		}
 640	} else {
 641		if p.compact {
 642			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
 643			p.detailsWidth = width - DetailsPositioning
 644			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
 645			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 646			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
 647		} else {
 648			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
 649			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 650			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
 651		}
 652		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 653	}
 654	return tea.Batch(cmds...)
 655}
 656
 657func (p *chatPage) newSession() tea.Cmd {
 658	if p.session.ID == "" {
 659		return nil
 660	}
 661
 662	p.session = session.Session{}
 663	p.focusedPane = PanelTypeEditor
 664	p.editor.Focus()
 665	p.chat.Blur()
 666	p.isCanceling = false
 667	return tea.Batch(
 668		util.CmdHandler(chat.SessionClearedMsg{}),
 669		p.SetSize(p.width, p.height),
 670	)
 671}
 672
 673func (p *chatPage) setSession(session session.Session) tea.Cmd {
 674	if p.session.ID == session.ID {
 675		return nil
 676	}
 677
 678	var cmds []tea.Cmd
 679	p.session = session
 680
 681	cmds = append(cmds, p.SetSize(p.width, p.height))
 682	cmds = append(cmds, p.chat.SetSession(session))
 683	cmds = append(cmds, p.sidebar.SetSession(session))
 684	cmds = append(cmds, p.header.SetSession(session))
 685	cmds = append(cmds, p.editor.SetSession(session))
 686
 687	return tea.Sequence(cmds...)
 688}
 689
 690func (p *chatPage) changeFocus() {
 691	if p.session.ID == "" {
 692		return
 693	}
 694	switch p.focusedPane {
 695	case PanelTypeChat:
 696		p.focusedPane = PanelTypeEditor
 697		p.editor.Focus()
 698		p.chat.Blur()
 699	case PanelTypeEditor:
 700		p.focusedPane = PanelTypeChat
 701		p.chat.Focus()
 702		p.editor.Blur()
 703	}
 704}
 705
 706func (p *chatPage) cancel() tea.Cmd {
 707	if p.isCanceling {
 708		p.isCanceling = false
 709		if p.app.CoderAgent != nil {
 710			p.app.CoderAgent.Cancel(p.session.ID)
 711		}
 712		return nil
 713	}
 714
 715	if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
 716		p.app.CoderAgent.ClearQueue(p.session.ID)
 717		return nil
 718	}
 719	p.isCanceling = true
 720	return cancelTimerCmd()
 721}
 722
 723func (p *chatPage) setShowDetails(show bool) {
 724	p.showingDetails = show
 725	p.header.SetDetailsOpen(p.showingDetails)
 726	if !p.compact {
 727		p.sidebar.SetCompactMode(false)
 728	}
 729}
 730
 731func (p *chatPage) toggleDetails() {
 732	if p.session.ID == "" || !p.compact {
 733		return
 734	}
 735	p.setShowDetails(!p.showingDetails)
 736}
 737
 738func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
 739	session := p.session
 740	var cmds []tea.Cmd
 741	if p.session.ID == "" {
 742		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
 743		if err != nil {
 744			return util.ReportError(err)
 745		}
 746		session = newSession
 747		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 748	}
 749	if p.app.CoderAgent == nil {
 750		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 751	}
 752	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
 753	if err != nil {
 754		return util.ReportError(err)
 755	}
 756	cmds = append(cmds, p.chat.GoToBottom())
 757	return tea.Batch(cmds...)
 758}
 759
 760func (p *chatPage) Bindings() []key.Binding {
 761	bindings := []key.Binding{
 762		p.keyMap.NewSession,
 763		p.keyMap.AddAttachment,
 764	}
 765	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
 766		cancelBinding := p.keyMap.Cancel
 767		if p.isCanceling {
 768			cancelBinding = key.NewBinding(
 769				key.WithKeys("esc", "alt+esc"),
 770				key.WithHelp("esc", "press again to cancel"),
 771			)
 772		}
 773		bindings = append([]key.Binding{cancelBinding}, bindings...)
 774	}
 775
 776	switch p.focusedPane {
 777	case PanelTypeChat:
 778		bindings = append([]key.Binding{
 779			key.NewBinding(
 780				key.WithKeys("tab"),
 781				key.WithHelp("tab", "focus editor"),
 782			),
 783		}, bindings...)
 784		bindings = append(bindings, p.chat.Bindings()...)
 785	case PanelTypeEditor:
 786		bindings = append([]key.Binding{
 787			key.NewBinding(
 788				key.WithKeys("tab"),
 789				key.WithHelp("tab", "focus chat"),
 790			),
 791		}, bindings...)
 792		bindings = append(bindings, p.editor.Bindings()...)
 793	case PanelTypeSplash:
 794		bindings = append(bindings, p.splash.Bindings()...)
 795	}
 796
 797	return bindings
 798}
 799
 800func (p *chatPage) Help() help.KeyMap {
 801	var shortList []key.Binding
 802	var fullList [][]key.Binding
 803	switch {
 804	case p.isOnboarding && !p.splash.IsShowingAPIKey():
 805		shortList = append(shortList,
 806			// Choose model
 807			key.NewBinding(
 808				key.WithKeys("up", "down"),
 809				key.WithHelp("↑/↓", "choose"),
 810			),
 811			// Accept selection
 812			key.NewBinding(
 813				key.WithKeys("enter", "ctrl+y"),
 814				key.WithHelp("enter", "accept"),
 815			),
 816			// Quit
 817			key.NewBinding(
 818				key.WithKeys("ctrl+c"),
 819				key.WithHelp("ctrl+c", "quit"),
 820			),
 821		)
 822		// keep them the same
 823		for _, v := range shortList {
 824			fullList = append(fullList, []key.Binding{v})
 825		}
 826	case p.isOnboarding && p.splash.IsShowingAPIKey():
 827		if p.splash.IsAPIKeyValid() {
 828			shortList = append(shortList,
 829				key.NewBinding(
 830					key.WithKeys("enter"),
 831					key.WithHelp("enter", "continue"),
 832				),
 833			)
 834		} else {
 835			shortList = append(shortList,
 836				// Go back
 837				key.NewBinding(
 838					key.WithKeys("esc", "alt+esc"),
 839					key.WithHelp("esc", "back"),
 840				),
 841			)
 842		}
 843		shortList = append(shortList,
 844			// Quit
 845			key.NewBinding(
 846				key.WithKeys("ctrl+c"),
 847				key.WithHelp("ctrl+c", "quit"),
 848			),
 849		)
 850		// keep them the same
 851		for _, v := range shortList {
 852			fullList = append(fullList, []key.Binding{v})
 853		}
 854	case p.isProjectInit:
 855		shortList = append(shortList,
 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	default:
 866		if p.editor.IsCompletionsOpen() {
 867			shortList = append(shortList,
 868				key.NewBinding(
 869					key.WithKeys("tab", "enter"),
 870					key.WithHelp("tab/enter", "complete"),
 871				),
 872				key.NewBinding(
 873					key.WithKeys("esc", "alt+esc"),
 874					key.WithHelp("esc", "cancel"),
 875				),
 876				key.NewBinding(
 877					key.WithKeys("up", "down"),
 878					key.WithHelp("↑/↓", "choose"),
 879				),
 880			)
 881			for _, v := range shortList {
 882				fullList = append(fullList, []key.Binding{v})
 883			}
 884			return core.NewSimpleHelp(shortList, fullList)
 885		}
 886		if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
 887			cancelBinding := key.NewBinding(
 888				key.WithKeys("esc", "alt+esc"),
 889				key.WithHelp("esc", "cancel"),
 890			)
 891			if p.isCanceling {
 892				cancelBinding = key.NewBinding(
 893					key.WithKeys("esc", "alt+esc"),
 894					key.WithHelp("esc", "press again to cancel"),
 895				)
 896			}
 897			if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
 898				cancelBinding = key.NewBinding(
 899					key.WithKeys("esc", "alt+esc"),
 900					key.WithHelp("esc", "clear queue"),
 901				)
 902			}
 903			shortList = append(shortList, cancelBinding)
 904			fullList = append(fullList,
 905				[]key.Binding{
 906					cancelBinding,
 907				},
 908			)
 909		}
 910		globalBindings := []key.Binding{}
 911		// we are in a session
 912		if p.session.ID != "" {
 913			tabKey := key.NewBinding(
 914				key.WithKeys("tab"),
 915				key.WithHelp("tab", "focus chat"),
 916			)
 917			if p.focusedPane == PanelTypeChat {
 918				tabKey = key.NewBinding(
 919					key.WithKeys("tab"),
 920					key.WithHelp("tab", "focus editor"),
 921				)
 922			}
 923			shortList = append(shortList, tabKey)
 924			globalBindings = append(globalBindings, tabKey)
 925		}
 926		commandsBinding := key.NewBinding(
 927			key.WithKeys("ctrl+p"),
 928			key.WithHelp("ctrl+p", "commands"),
 929		)
 930		modelsBinding := key.NewBinding(
 931			key.WithKeys("alt+m"),
 932			key.WithHelp("alt+m", "models"),
 933		)
 934		helpBinding := key.NewBinding(
 935			key.WithKeys("ctrl+g"),
 936			key.WithHelp("ctrl+g", "more"),
 937		)
 938		globalBindings = append(globalBindings, commandsBinding, modelsBinding)
 939		globalBindings = append(globalBindings,
 940			key.NewBinding(
 941				key.WithKeys("ctrl+s"),
 942				key.WithHelp("ctrl+s", "sessions"),
 943			),
 944		)
 945		if p.session.ID != "" {
 946			globalBindings = append(globalBindings,
 947				key.NewBinding(
 948					key.WithKeys("ctrl+n"),
 949					key.WithHelp("ctrl+n", "new sessions"),
 950				))
 951		}
 952		shortList = append(shortList,
 953			// Commands
 954			commandsBinding,
 955			modelsBinding,
 956		)
 957		fullList = append(fullList, globalBindings)
 958
 959		switch p.focusedPane {
 960		case PanelTypeChat:
 961			shortList = append(shortList,
 962				key.NewBinding(
 963					key.WithKeys("up", "down"),
 964					key.WithHelp("↑↓", "scroll"),
 965				),
 966				messages.CopyKey,
 967			)
 968			fullList = append(fullList,
 969				[]key.Binding{
 970					key.NewBinding(
 971						key.WithKeys("up", "down"),
 972						key.WithHelp("↑↓", "scroll"),
 973					),
 974					key.NewBinding(
 975						key.WithKeys("shift+up", "shift+down"),
 976						key.WithHelp("shift+↑↓", "next/prev item"),
 977					),
 978					key.NewBinding(
 979						key.WithKeys("pgup", "b"),
 980						key.WithHelp("b/pgup", "page up"),
 981					),
 982					key.NewBinding(
 983						key.WithKeys("pgdown", " ", "f"),
 984						key.WithHelp("f/pgdn", "page down"),
 985					),
 986				},
 987				[]key.Binding{
 988					key.NewBinding(
 989						key.WithKeys("u"),
 990						key.WithHelp("u", "half page up"),
 991					),
 992					key.NewBinding(
 993						key.WithKeys("d"),
 994						key.WithHelp("d", "half page down"),
 995					),
 996					key.NewBinding(
 997						key.WithKeys("g", "home"),
 998						key.WithHelp("g", "home"),
 999					),
1000					key.NewBinding(
1001						key.WithKeys("G", "end"),
1002						key.WithHelp("G", "end"),
1003					),
1004				},
1005				[]key.Binding{
1006					messages.CopyKey,
1007					messages.ClearSelectionKey,
1008				},
1009			)
1010		case PanelTypeEditor:
1011			newLineBinding := key.NewBinding(
1012				key.WithKeys("shift+enter", "ctrl+j"),
1013				// "ctrl+j" is a common keybinding for newline in many editors. If
1014				// the terminal supports "shift+enter", we substitute the help text
1015				// to reflect that.
1016				key.WithHelp("ctrl+j", "newline"),
1017			)
1018			if p.keyboardEnhancements.SupportsKeyDisambiguation() {
1019				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1020			}
1021			shortList = append(shortList, newLineBinding)
1022			fullList = append(fullList,
1023				[]key.Binding{
1024					newLineBinding,
1025					key.NewBinding(
1026						key.WithKeys("ctrl+f"),
1027						key.WithHelp("ctrl+f", "add image"),
1028					),
1029					key.NewBinding(
1030						key.WithKeys("/"),
1031						key.WithHelp("/", "add file"),
1032					),
1033					key.NewBinding(
1034						key.WithKeys("ctrl+o"),
1035						key.WithHelp("ctrl+o", "open editor"),
1036					),
1037				})
1038
1039			if p.editor.HasAttachments() {
1040				fullList = append(fullList, []key.Binding{
1041					key.NewBinding(
1042						key.WithKeys("ctrl+r"),
1043						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1044					),
1045					key.NewBinding(
1046						key.WithKeys("ctrl+r", "r"),
1047						key.WithHelp("ctrl+r+r", "delete all attachments"),
1048					),
1049					key.NewBinding(
1050						key.WithKeys("esc", "alt+esc"),
1051						key.WithHelp("esc", "cancel delete mode"),
1052					),
1053				})
1054			}
1055		}
1056		shortList = append(shortList,
1057			// Quit
1058			key.NewBinding(
1059				key.WithKeys("ctrl+c"),
1060				key.WithHelp("ctrl+c", "quit"),
1061			),
1062			// Help
1063			helpBinding,
1064		)
1065		fullList = append(fullList, []key.Binding{
1066			key.NewBinding(
1067				key.WithKeys("ctrl+g"),
1068				key.WithHelp("ctrl+g", "less"),
1069			),
1070		})
1071	}
1072
1073	return core.NewSimpleHelp(shortList, fullList)
1074}
1075
1076func (p *chatPage) IsChatFocused() bool {
1077	return p.focusedPane == PanelTypeChat
1078}
1079
1080// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1081// Returns true if the mouse is over the chat area, false otherwise.
1082func (p *chatPage) isMouseOverChat(x, y int) bool {
1083	// No session means no chat area
1084	if p.session.ID == "" {
1085		return false
1086	}
1087
1088	var chatX, chatY, chatWidth, chatHeight int
1089
1090	if p.compact {
1091		// In compact mode: chat area starts after header and spans full width
1092		chatX = 0
1093		chatY = HeaderHeight
1094		chatWidth = p.width
1095		chatHeight = p.height - EditorHeight - HeaderHeight
1096	} else {
1097		// In non-compact mode: chat area spans from left edge to sidebar
1098		chatX = 0
1099		chatY = 0
1100		chatWidth = p.width - SideBarWidth
1101		chatHeight = p.height - EditorHeight
1102	}
1103
1104	// Check if mouse coordinates are within chat bounds
1105	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1106}