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.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()
 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 tea.KeyPressMsg:
 363		switch {
 364		case key.Matches(msg, p.keyMap.NewSession):
 365			// if we have no agent do nothing
 366			if p.app.AgentCoordinator == nil {
 367				return p, nil
 368			}
 369			if p.app.AgentCoordinator.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[config.AgentCoder]
 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.AgentCoordinator.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[config.AgentCoder]
 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[config.AgentCoder]
 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[config.AgentCoder]
 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.AgentCoordinator != nil {
 710			p.app.AgentCoordinator.Cancel(p.session.ID)
 711		}
 712		return nil
 713	}
 714
 715	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 716		p.app.AgentCoordinator.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.AgentCoordinator == nil {
 750		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 751	}
 752	cmds = append(cmds, p.chat.GoToBottom())
 753	cmds = append(cmds, func() tea.Msg {
 754		_, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
 755		if err != nil {
 756			return util.InfoMsg{
 757				Type: util.InfoTypeError,
 758				Msg:  err.Error(),
 759			}
 760		}
 761		return nil
 762	})
 763	return tea.Batch(cmds...)
 764}
 765
 766func (p *chatPage) Bindings() []key.Binding {
 767	bindings := []key.Binding{
 768		p.keyMap.NewSession,
 769		p.keyMap.AddAttachment,
 770	}
 771	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 772		cancelBinding := p.keyMap.Cancel
 773		if p.isCanceling {
 774			cancelBinding = key.NewBinding(
 775				key.WithKeys("esc", "alt+esc"),
 776				key.WithHelp("esc", "press again to cancel"),
 777			)
 778		}
 779		bindings = append([]key.Binding{cancelBinding}, bindings...)
 780	}
 781
 782	switch p.focusedPane {
 783	case PanelTypeChat:
 784		bindings = append([]key.Binding{
 785			key.NewBinding(
 786				key.WithKeys("tab"),
 787				key.WithHelp("tab", "focus editor"),
 788			),
 789		}, bindings...)
 790		bindings = append(bindings, p.chat.Bindings()...)
 791	case PanelTypeEditor:
 792		bindings = append([]key.Binding{
 793			key.NewBinding(
 794				key.WithKeys("tab"),
 795				key.WithHelp("tab", "focus chat"),
 796			),
 797		}, bindings...)
 798		bindings = append(bindings, p.editor.Bindings()...)
 799	case PanelTypeSplash:
 800		bindings = append(bindings, p.splash.Bindings()...)
 801	}
 802
 803	return bindings
 804}
 805
 806func (p *chatPage) Help() help.KeyMap {
 807	var shortList []key.Binding
 808	var fullList [][]key.Binding
 809	switch {
 810	case p.isOnboarding && !p.splash.IsShowingAPIKey():
 811		shortList = append(shortList,
 812			// Choose model
 813			key.NewBinding(
 814				key.WithKeys("up", "down"),
 815				key.WithHelp("↑/↓", "choose"),
 816			),
 817			// Accept selection
 818			key.NewBinding(
 819				key.WithKeys("enter", "ctrl+y"),
 820				key.WithHelp("enter", "accept"),
 821			),
 822			// Quit
 823			key.NewBinding(
 824				key.WithKeys("ctrl+c"),
 825				key.WithHelp("ctrl+c", "quit"),
 826			),
 827		)
 828		// keep them the same
 829		for _, v := range shortList {
 830			fullList = append(fullList, []key.Binding{v})
 831		}
 832	case p.isOnboarding && p.splash.IsShowingAPIKey():
 833		if p.splash.IsAPIKeyValid() {
 834			shortList = append(shortList,
 835				key.NewBinding(
 836					key.WithKeys("enter"),
 837					key.WithHelp("enter", "continue"),
 838				),
 839			)
 840		} else {
 841			shortList = append(shortList,
 842				// Go back
 843				key.NewBinding(
 844					key.WithKeys("esc", "alt+esc"),
 845					key.WithHelp("esc", "back"),
 846				),
 847			)
 848		}
 849		shortList = append(shortList,
 850			// Quit
 851			key.NewBinding(
 852				key.WithKeys("ctrl+c"),
 853				key.WithHelp("ctrl+c", "quit"),
 854			),
 855		)
 856		// keep them the same
 857		for _, v := range shortList {
 858			fullList = append(fullList, []key.Binding{v})
 859		}
 860	case p.isProjectInit:
 861		shortList = append(shortList,
 862			key.NewBinding(
 863				key.WithKeys("ctrl+c"),
 864				key.WithHelp("ctrl+c", "quit"),
 865			),
 866		)
 867		// keep them the same
 868		for _, v := range shortList {
 869			fullList = append(fullList, []key.Binding{v})
 870		}
 871	default:
 872		if p.editor.IsCompletionsOpen() {
 873			shortList = append(shortList,
 874				key.NewBinding(
 875					key.WithKeys("tab", "enter"),
 876					key.WithHelp("tab/enter", "complete"),
 877				),
 878				key.NewBinding(
 879					key.WithKeys("esc", "alt+esc"),
 880					key.WithHelp("esc", "cancel"),
 881				),
 882				key.NewBinding(
 883					key.WithKeys("up", "down"),
 884					key.WithHelp("↑/↓", "choose"),
 885				),
 886			)
 887			for _, v := range shortList {
 888				fullList = append(fullList, []key.Binding{v})
 889			}
 890			return core.NewSimpleHelp(shortList, fullList)
 891		}
 892		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 893			cancelBinding := key.NewBinding(
 894				key.WithKeys("esc", "alt+esc"),
 895				key.WithHelp("esc", "cancel"),
 896			)
 897			if p.isCanceling {
 898				cancelBinding = key.NewBinding(
 899					key.WithKeys("esc", "alt+esc"),
 900					key.WithHelp("esc", "press again to cancel"),
 901				)
 902			}
 903			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 904				cancelBinding = key.NewBinding(
 905					key.WithKeys("esc", "alt+esc"),
 906					key.WithHelp("esc", "clear queue"),
 907				)
 908			}
 909			shortList = append(shortList, cancelBinding)
 910			fullList = append(fullList,
 911				[]key.Binding{
 912					cancelBinding,
 913				},
 914			)
 915		}
 916		globalBindings := []key.Binding{}
 917		// we are in a session
 918		if p.session.ID != "" {
 919			tabKey := key.NewBinding(
 920				key.WithKeys("tab"),
 921				key.WithHelp("tab", "focus chat"),
 922			)
 923			if p.focusedPane == PanelTypeChat {
 924				tabKey = key.NewBinding(
 925					key.WithKeys("tab"),
 926					key.WithHelp("tab", "focus editor"),
 927				)
 928			}
 929			shortList = append(shortList, tabKey)
 930			globalBindings = append(globalBindings, tabKey)
 931		}
 932		commandsBinding := key.NewBinding(
 933			key.WithKeys("ctrl+p"),
 934			key.WithHelp("ctrl+p", "commands"),
 935		)
 936		helpBinding := key.NewBinding(
 937			key.WithKeys("ctrl+g"),
 938			key.WithHelp("ctrl+g", "more"),
 939		)
 940		globalBindings = append(globalBindings, commandsBinding)
 941		globalBindings = append(globalBindings,
 942			key.NewBinding(
 943				key.WithKeys("ctrl+s"),
 944				key.WithHelp("ctrl+s", "sessions"),
 945			),
 946		)
 947		if p.session.ID != "" {
 948			globalBindings = append(globalBindings,
 949				key.NewBinding(
 950					key.WithKeys("ctrl+n"),
 951					key.WithHelp("ctrl+n", "new sessions"),
 952				))
 953		}
 954		shortList = append(shortList,
 955			// Commands
 956			commandsBinding,
 957		)
 958		fullList = append(fullList, globalBindings)
 959
 960		switch p.focusedPane {
 961		case PanelTypeChat:
 962			shortList = append(shortList,
 963				key.NewBinding(
 964					key.WithKeys("up", "down"),
 965					key.WithHelp("↑↓", "scroll"),
 966				),
 967				messages.CopyKey,
 968			)
 969			fullList = append(fullList,
 970				[]key.Binding{
 971					key.NewBinding(
 972						key.WithKeys("up", "down"),
 973						key.WithHelp("↑↓", "scroll"),
 974					),
 975					key.NewBinding(
 976						key.WithKeys("shift+up", "shift+down"),
 977						key.WithHelp("shift+↑↓", "next/prev item"),
 978					),
 979					key.NewBinding(
 980						key.WithKeys("pgup", "b"),
 981						key.WithHelp("b/pgup", "page up"),
 982					),
 983					key.NewBinding(
 984						key.WithKeys("pgdown", " ", "f"),
 985						key.WithHelp("f/pgdn", "page down"),
 986					),
 987				},
 988				[]key.Binding{
 989					key.NewBinding(
 990						key.WithKeys("u"),
 991						key.WithHelp("u", "half page up"),
 992					),
 993					key.NewBinding(
 994						key.WithKeys("d"),
 995						key.WithHelp("d", "half page down"),
 996					),
 997					key.NewBinding(
 998						key.WithKeys("g", "home"),
 999						key.WithHelp("g", "home"),
1000					),
1001					key.NewBinding(
1002						key.WithKeys("G", "end"),
1003						key.WithHelp("G", "end"),
1004					),
1005				},
1006				[]key.Binding{
1007					messages.CopyKey,
1008					messages.ClearSelectionKey,
1009				},
1010			)
1011		case PanelTypeEditor:
1012			newLineBinding := key.NewBinding(
1013				key.WithKeys("shift+enter", "ctrl+j"),
1014				// "ctrl+j" is a common keybinding for newline in many editors. If
1015				// the terminal supports "shift+enter", we substitute the help text
1016				// to reflect that.
1017				key.WithHelp("ctrl+j", "newline"),
1018			)
1019			if p.keyboardEnhancements.SupportsKeyDisambiguation() {
1020				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1021			}
1022			shortList = append(shortList, newLineBinding)
1023			fullList = append(fullList,
1024				[]key.Binding{
1025					newLineBinding,
1026					key.NewBinding(
1027						key.WithKeys("ctrl+f"),
1028						key.WithHelp("ctrl+f", "add image"),
1029					),
1030					key.NewBinding(
1031						key.WithKeys("/"),
1032						key.WithHelp("/", "add file"),
1033					),
1034					key.NewBinding(
1035						key.WithKeys("ctrl+o"),
1036						key.WithHelp("ctrl+o", "open editor"),
1037					),
1038				})
1039
1040			if p.editor.HasAttachments() {
1041				fullList = append(fullList, []key.Binding{
1042					key.NewBinding(
1043						key.WithKeys("ctrl+r"),
1044						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1045					),
1046					key.NewBinding(
1047						key.WithKeys("ctrl+r", "r"),
1048						key.WithHelp("ctrl+r+r", "delete all attachments"),
1049					),
1050					key.NewBinding(
1051						key.WithKeys("esc", "alt+esc"),
1052						key.WithHelp("esc", "cancel delete mode"),
1053					),
1054				})
1055			}
1056		}
1057		shortList = append(shortList,
1058			// Quit
1059			key.NewBinding(
1060				key.WithKeys("ctrl+c"),
1061				key.WithHelp("ctrl+c", "quit"),
1062			),
1063			// Help
1064			helpBinding,
1065		)
1066		fullList = append(fullList, []key.Binding{
1067			key.NewBinding(
1068				key.WithKeys("ctrl+g"),
1069				key.WithHelp("ctrl+g", "less"),
1070			),
1071		})
1072	}
1073
1074	return core.NewSimpleHelp(shortList, fullList)
1075}
1076
1077func (p *chatPage) IsChatFocused() bool {
1078	return p.focusedPane == PanelTypeChat
1079}
1080
1081// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1082// Returns true if the mouse is over the chat area, false otherwise.
1083func (p *chatPage) isMouseOverChat(x, y int) bool {
1084	// No session means no chat area
1085	if p.session.ID == "" {
1086		return false
1087	}
1088
1089	var chatX, chatY, chatWidth, chatHeight int
1090
1091	if p.compact {
1092		// In compact mode: chat area starts after header and spans full width
1093		chatX = 0
1094		chatY = HeaderHeight
1095		chatWidth = p.width
1096		chatHeight = p.height - EditorHeight - HeaderHeight
1097	} else {
1098		// In non-compact mode: chat area spans from left edge to sidebar
1099		chatX = 0
1100		chatY = 0
1101		chatWidth = p.width - SideBarWidth
1102		chatHeight = p.height - EditorHeight
1103	}
1104
1105	// Check if mouse coordinates are within chat bounds
1106	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1107}