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("ctrl+m", "ctrl+l"),
 932			key.WithHelp("ctrl+l", "models"),
 933		)
 934		if p.keyboardEnhancements.SupportsKeyDisambiguation() {
 935			modelsBinding.SetHelp("ctrl+m", "models")
 936		}
 937		helpBinding := key.NewBinding(
 938			key.WithKeys("ctrl+g"),
 939			key.WithHelp("ctrl+g", "more"),
 940		)
 941		globalBindings = append(globalBindings, commandsBinding, modelsBinding)
 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			modelsBinding,
 959		)
 960		fullList = append(fullList, globalBindings)
 961
 962		switch p.focusedPane {
 963		case PanelTypeChat:
 964			shortList = append(shortList,
 965				key.NewBinding(
 966					key.WithKeys("up", "down"),
 967					key.WithHelp("↑↓", "scroll"),
 968				),
 969				messages.CopyKey,
 970			)
 971			fullList = append(fullList,
 972				[]key.Binding{
 973					key.NewBinding(
 974						key.WithKeys("up", "down"),
 975						key.WithHelp("↑↓", "scroll"),
 976					),
 977					key.NewBinding(
 978						key.WithKeys("shift+up", "shift+down"),
 979						key.WithHelp("shift+↑↓", "next/prev item"),
 980					),
 981					key.NewBinding(
 982						key.WithKeys("pgup", "b"),
 983						key.WithHelp("b/pgup", "page up"),
 984					),
 985					key.NewBinding(
 986						key.WithKeys("pgdown", " ", "f"),
 987						key.WithHelp("f/pgdn", "page down"),
 988					),
 989				},
 990				[]key.Binding{
 991					key.NewBinding(
 992						key.WithKeys("u"),
 993						key.WithHelp("u", "half page up"),
 994					),
 995					key.NewBinding(
 996						key.WithKeys("d"),
 997						key.WithHelp("d", "half page down"),
 998					),
 999					key.NewBinding(
1000						key.WithKeys("g", "home"),
1001						key.WithHelp("g", "home"),
1002					),
1003					key.NewBinding(
1004						key.WithKeys("G", "end"),
1005						key.WithHelp("G", "end"),
1006					),
1007				},
1008				[]key.Binding{
1009					messages.CopyKey,
1010					messages.ClearSelectionKey,
1011				},
1012			)
1013		case PanelTypeEditor:
1014			newLineBinding := key.NewBinding(
1015				key.WithKeys("shift+enter", "ctrl+j"),
1016				// "ctrl+j" is a common keybinding for newline in many editors. If
1017				// the terminal supports "shift+enter", we substitute the help text
1018				// to reflect that.
1019				key.WithHelp("ctrl+j", "newline"),
1020			)
1021			if p.keyboardEnhancements.SupportsKeyDisambiguation() {
1022				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1023			}
1024			shortList = append(shortList, newLineBinding)
1025			fullList = append(fullList,
1026				[]key.Binding{
1027					newLineBinding,
1028					key.NewBinding(
1029						key.WithKeys("ctrl+f"),
1030						key.WithHelp("ctrl+f", "add image"),
1031					),
1032					key.NewBinding(
1033						key.WithKeys("/"),
1034						key.WithHelp("/", "add file"),
1035					),
1036					key.NewBinding(
1037						key.WithKeys("ctrl+o"),
1038						key.WithHelp("ctrl+o", "open editor"),
1039					),
1040				})
1041
1042			if p.editor.HasAttachments() {
1043				fullList = append(fullList, []key.Binding{
1044					key.NewBinding(
1045						key.WithKeys("ctrl+r"),
1046						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1047					),
1048					key.NewBinding(
1049						key.WithKeys("ctrl+r", "r"),
1050						key.WithHelp("ctrl+r+r", "delete all attachments"),
1051					),
1052					key.NewBinding(
1053						key.WithKeys("esc", "alt+esc"),
1054						key.WithHelp("esc", "cancel delete mode"),
1055					),
1056				})
1057			}
1058		}
1059		shortList = append(shortList,
1060			// Quit
1061			key.NewBinding(
1062				key.WithKeys("ctrl+c"),
1063				key.WithHelp("ctrl+c", "quit"),
1064			),
1065			// Help
1066			helpBinding,
1067		)
1068		fullList = append(fullList, []key.Binding{
1069			key.NewBinding(
1070				key.WithKeys("ctrl+g"),
1071				key.WithHelp("ctrl+g", "less"),
1072			),
1073		})
1074	}
1075
1076	return core.NewSimpleHelp(shortList, fullList)
1077}
1078
1079func (p *chatPage) IsChatFocused() bool {
1080	return p.focusedPane == PanelTypeChat
1081}
1082
1083// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1084// Returns true if the mouse is over the chat area, false otherwise.
1085func (p *chatPage) isMouseOverChat(x, y int) bool {
1086	// No session means no chat area
1087	if p.session.ID == "" {
1088		return false
1089	}
1090
1091	var chatX, chatY, chatWidth, chatHeight int
1092
1093	if p.compact {
1094		// In compact mode: chat area starts after header and spans full width
1095		chatX = 0
1096		chatY = HeaderHeight
1097		chatWidth = p.width
1098		chatHeight = p.height - EditorHeight - HeaderHeight
1099	} else {
1100		// In non-compact mode: chat area spans from left edge to sidebar
1101		chatX = 0
1102		chatY = 0
1103		chatWidth = p.width - SideBarWidth
1104		chatHeight = p.height - EditorHeight
1105	}
1106
1107	// Check if mouse coordinates are within chat bounds
1108	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1109}