chat.go

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