chat.go

   1package chat
   2
   3import (
   4	"context"
   5	"errors"
   6	"fmt"
   7	"time"
   8
   9	"github.com/charmbracelet/bubbles/v2/help"
  10	"github.com/charmbracelet/bubbles/v2/key"
  11	"github.com/charmbracelet/bubbles/v2/spinner"
  12	tea "github.com/charmbracelet/bubbletea/v2"
  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) (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 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(context.TODO()); 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 && len(model.ReasoningLevels) > 0 {
 567			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
 568			return dialogs.OpenDialogMsg{
 569				Model: reasoning.NewReasoningDialog(),
 570			}
 571		}
 572		return nil
 573	}
 574}
 575
 576func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 577	return func() tea.Msg {
 578		cfg := config.Get()
 579		agentCfg := cfg.Agents[config.AgentCoder]
 580		currentModel := cfg.Models[agentCfg.Model]
 581
 582		// Update the model configuration
 583		currentModel.ReasoningEffort = effort
 584		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
 585			return util.InfoMsg{
 586				Type: util.InfoTypeError,
 587				Msg:  "Failed to update reasoning effort: " + err.Error(),
 588			}
 589		}
 590
 591		// Update the agent with the new configuration
 592		if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
 593			return util.InfoMsg{
 594				Type: util.InfoTypeError,
 595				Msg:  "Failed to update reasoning effort: " + err.Error(),
 596			}
 597		}
 598
 599		return util.InfoMsg{
 600			Type: util.InfoTypeInfo,
 601			Msg:  "Reasoning effort set to " + effort,
 602		}
 603	}
 604}
 605
 606func (p *chatPage) setCompactMode(compact bool) {
 607	if p.compact == compact {
 608		return
 609	}
 610	p.compact = compact
 611	if compact {
 612		p.sidebar.SetCompactMode(true)
 613	} else {
 614		p.setShowDetails(false)
 615	}
 616}
 617
 618func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
 619	if p.forceCompact {
 620		return
 621	}
 622	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
 623		p.setCompactMode(true)
 624	}
 625	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
 626		p.setCompactMode(false)
 627	}
 628}
 629
 630func (p *chatPage) SetSize(width, height int) tea.Cmd {
 631	p.handleCompactMode(width, height)
 632	p.width = width
 633	p.height = height
 634	var cmds []tea.Cmd
 635
 636	if p.session.ID == "" {
 637		if p.splashFullScreen {
 638			cmds = append(cmds, p.splash.SetSize(width, height))
 639		} else {
 640			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
 641			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 642			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 643		}
 644	} else {
 645		if p.compact {
 646			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
 647			p.detailsWidth = width - DetailsPositioning
 648			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
 649			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 650			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
 651		} else {
 652			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
 653			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 654			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
 655		}
 656		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 657	}
 658	return tea.Batch(cmds...)
 659}
 660
 661func (p *chatPage) newSession() tea.Cmd {
 662	if p.session.ID == "" {
 663		return nil
 664	}
 665
 666	p.session = session.Session{}
 667	p.focusedPane = PanelTypeEditor
 668	p.editor.Focus()
 669	p.chat.Blur()
 670	p.isCanceling = false
 671	return tea.Batch(
 672		util.CmdHandler(chat.SessionClearedMsg{}),
 673		p.SetSize(p.width, p.height),
 674	)
 675}
 676
 677func (p *chatPage) setSession(session session.Session) tea.Cmd {
 678	if p.session.ID == session.ID {
 679		return nil
 680	}
 681
 682	var cmds []tea.Cmd
 683	p.session = session
 684
 685	cmds = append(cmds, p.SetSize(p.width, p.height))
 686	cmds = append(cmds, p.chat.SetSession(session))
 687	cmds = append(cmds, p.sidebar.SetSession(session))
 688	cmds = append(cmds, p.header.SetSession(session))
 689	cmds = append(cmds, p.editor.SetSession(session))
 690
 691	return tea.Sequence(cmds...)
 692}
 693
 694func (p *chatPage) changeFocus() {
 695	if p.session.ID == "" {
 696		return
 697	}
 698	switch p.focusedPane {
 699	case PanelTypeChat:
 700		p.focusedPane = PanelTypeEditor
 701		p.editor.Focus()
 702		p.chat.Blur()
 703	case PanelTypeEditor:
 704		p.focusedPane = PanelTypeChat
 705		p.chat.Focus()
 706		p.editor.Blur()
 707	}
 708}
 709
 710func (p *chatPage) cancel() tea.Cmd {
 711	if p.isCanceling {
 712		p.isCanceling = false
 713		if p.app.AgentCoordinator != nil {
 714			p.app.AgentCoordinator.Cancel(p.session.ID)
 715		}
 716		return nil
 717	}
 718
 719	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 720		p.app.AgentCoordinator.ClearQueue(p.session.ID)
 721		return nil
 722	}
 723	p.isCanceling = true
 724	return cancelTimerCmd()
 725}
 726
 727func (p *chatPage) setShowDetails(show bool) {
 728	p.showingDetails = show
 729	p.header.SetDetailsOpen(p.showingDetails)
 730	if !p.compact {
 731		p.sidebar.SetCompactMode(false)
 732	}
 733}
 734
 735func (p *chatPage) toggleDetails() {
 736	if p.session.ID == "" || !p.compact {
 737		return
 738	}
 739	p.setShowDetails(!p.showingDetails)
 740}
 741
 742func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
 743	session := p.session
 744	var cmds []tea.Cmd
 745	if p.session.ID == "" {
 746		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
 747		if err != nil {
 748			return util.ReportError(err)
 749		}
 750		session = newSession
 751		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 752	}
 753	if p.app.AgentCoordinator == nil {
 754		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 755	}
 756	cmds = append(cmds, p.chat.GoToBottom())
 757	cmds = append(cmds, func() tea.Msg {
 758		_, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
 759		if err != nil {
 760			isCancelErr := errors.Is(err, context.Canceled)
 761			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
 762			if isCancelErr || isPermissionErr {
 763				return nil
 764			}
 765			return util.InfoMsg{
 766				Type: util.InfoTypeError,
 767				Msg:  err.Error(),
 768			}
 769		}
 770		return nil
 771	})
 772	return tea.Batch(cmds...)
 773}
 774
 775func (p *chatPage) Bindings() []key.Binding {
 776	bindings := []key.Binding{
 777		p.keyMap.NewSession,
 778		p.keyMap.AddAttachment,
 779	}
 780	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 781		cancelBinding := p.keyMap.Cancel
 782		if p.isCanceling {
 783			cancelBinding = key.NewBinding(
 784				key.WithKeys("esc", "alt+esc"),
 785				key.WithHelp("esc", "press again to cancel"),
 786			)
 787		}
 788		bindings = append([]key.Binding{cancelBinding}, bindings...)
 789	}
 790
 791	switch p.focusedPane {
 792	case PanelTypeChat:
 793		bindings = append([]key.Binding{
 794			key.NewBinding(
 795				key.WithKeys("tab"),
 796				key.WithHelp("tab", "focus editor"),
 797			),
 798		}, bindings...)
 799		bindings = append(bindings, p.chat.Bindings()...)
 800	case PanelTypeEditor:
 801		bindings = append([]key.Binding{
 802			key.NewBinding(
 803				key.WithKeys("tab"),
 804				key.WithHelp("tab", "focus chat"),
 805			),
 806		}, bindings...)
 807		bindings = append(bindings, p.editor.Bindings()...)
 808	case PanelTypeSplash:
 809		bindings = append(bindings, p.splash.Bindings()...)
 810	}
 811
 812	return bindings
 813}
 814
 815func (p *chatPage) Help() help.KeyMap {
 816	var shortList []key.Binding
 817	var fullList [][]key.Binding
 818	switch {
 819	case p.isOnboarding && !p.splash.IsShowingAPIKey():
 820		shortList = append(shortList,
 821			// Choose model
 822			key.NewBinding(
 823				key.WithKeys("up", "down"),
 824				key.WithHelp("↑/↓", "choose"),
 825			),
 826			// Accept selection
 827			key.NewBinding(
 828				key.WithKeys("enter", "ctrl+y"),
 829				key.WithHelp("enter", "accept"),
 830			),
 831			// Quit
 832			key.NewBinding(
 833				key.WithKeys("ctrl+c"),
 834				key.WithHelp("ctrl+c", "quit"),
 835			),
 836		)
 837		// keep them the same
 838		for _, v := range shortList {
 839			fullList = append(fullList, []key.Binding{v})
 840		}
 841	case p.isOnboarding && p.splash.IsShowingAPIKey():
 842		if p.splash.IsAPIKeyValid() {
 843			shortList = append(shortList,
 844				key.NewBinding(
 845					key.WithKeys("enter"),
 846					key.WithHelp("enter", "continue"),
 847				),
 848			)
 849		} else {
 850			shortList = append(shortList,
 851				// Go back
 852				key.NewBinding(
 853					key.WithKeys("esc", "alt+esc"),
 854					key.WithHelp("esc", "back"),
 855				),
 856			)
 857		}
 858		shortList = append(shortList,
 859			// Quit
 860			key.NewBinding(
 861				key.WithKeys("ctrl+c"),
 862				key.WithHelp("ctrl+c", "quit"),
 863			),
 864		)
 865		// keep them the same
 866		for _, v := range shortList {
 867			fullList = append(fullList, []key.Binding{v})
 868		}
 869	case p.isProjectInit:
 870		shortList = append(shortList,
 871			key.NewBinding(
 872				key.WithKeys("ctrl+c"),
 873				key.WithHelp("ctrl+c", "quit"),
 874			),
 875		)
 876		// keep them the same
 877		for _, v := range shortList {
 878			fullList = append(fullList, []key.Binding{v})
 879		}
 880	default:
 881		if p.editor.IsCompletionsOpen() {
 882			shortList = append(shortList,
 883				key.NewBinding(
 884					key.WithKeys("tab", "enter"),
 885					key.WithHelp("tab/enter", "complete"),
 886				),
 887				key.NewBinding(
 888					key.WithKeys("esc", "alt+esc"),
 889					key.WithHelp("esc", "cancel"),
 890				),
 891				key.NewBinding(
 892					key.WithKeys("up", "down"),
 893					key.WithHelp("↑/↓", "choose"),
 894				),
 895			)
 896			for _, v := range shortList {
 897				fullList = append(fullList, []key.Binding{v})
 898			}
 899			return core.NewSimpleHelp(shortList, fullList)
 900		}
 901		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 902			cancelBinding := key.NewBinding(
 903				key.WithKeys("esc", "alt+esc"),
 904				key.WithHelp("esc", "cancel"),
 905			)
 906			if p.isCanceling {
 907				cancelBinding = key.NewBinding(
 908					key.WithKeys("esc", "alt+esc"),
 909					key.WithHelp("esc", "press again to cancel"),
 910				)
 911			}
 912			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 913				cancelBinding = key.NewBinding(
 914					key.WithKeys("esc", "alt+esc"),
 915					key.WithHelp("esc", "clear queue"),
 916				)
 917			}
 918			shortList = append(shortList, cancelBinding)
 919			fullList = append(fullList,
 920				[]key.Binding{
 921					cancelBinding,
 922				},
 923			)
 924		}
 925		globalBindings := []key.Binding{}
 926		// we are in a session
 927		if p.session.ID != "" {
 928			tabKey := key.NewBinding(
 929				key.WithKeys("tab"),
 930				key.WithHelp("tab", "focus chat"),
 931			)
 932			if p.focusedPane == PanelTypeChat {
 933				tabKey = key.NewBinding(
 934					key.WithKeys("tab"),
 935					key.WithHelp("tab", "focus editor"),
 936				)
 937			}
 938			shortList = append(shortList, tabKey)
 939			globalBindings = append(globalBindings, tabKey)
 940		}
 941		commandsBinding := key.NewBinding(
 942			key.WithKeys("ctrl+p"),
 943			key.WithHelp("ctrl+p", "commands"),
 944		)
 945		helpBinding := key.NewBinding(
 946			key.WithKeys("ctrl+g"),
 947			key.WithHelp("ctrl+g", "more"),
 948		)
 949		globalBindings = append(globalBindings, commandsBinding)
 950		globalBindings = append(globalBindings,
 951			key.NewBinding(
 952				key.WithKeys("ctrl+s"),
 953				key.WithHelp("ctrl+s", "sessions"),
 954			),
 955		)
 956		if p.session.ID != "" {
 957			globalBindings = append(globalBindings,
 958				key.NewBinding(
 959					key.WithKeys("ctrl+n"),
 960					key.WithHelp("ctrl+n", "new sessions"),
 961				))
 962		}
 963		shortList = append(shortList,
 964			// Commands
 965			commandsBinding,
 966		)
 967		fullList = append(fullList, globalBindings)
 968
 969		switch p.focusedPane {
 970		case PanelTypeChat:
 971			shortList = append(shortList,
 972				key.NewBinding(
 973					key.WithKeys("up", "down"),
 974					key.WithHelp("↑↓", "scroll"),
 975				),
 976				messages.CopyKey,
 977			)
 978			fullList = append(fullList,
 979				[]key.Binding{
 980					key.NewBinding(
 981						key.WithKeys("up", "down"),
 982						key.WithHelp("↑↓", "scroll"),
 983					),
 984					key.NewBinding(
 985						key.WithKeys("shift+up", "shift+down"),
 986						key.WithHelp("shift+↑↓", "next/prev item"),
 987					),
 988					key.NewBinding(
 989						key.WithKeys("pgup", "b"),
 990						key.WithHelp("b/pgup", "page up"),
 991					),
 992					key.NewBinding(
 993						key.WithKeys("pgdown", " ", "f"),
 994						key.WithHelp("f/pgdn", "page down"),
 995					),
 996				},
 997				[]key.Binding{
 998					key.NewBinding(
 999						key.WithKeys("u"),
1000						key.WithHelp("u", "half page up"),
1001					),
1002					key.NewBinding(
1003						key.WithKeys("d"),
1004						key.WithHelp("d", "half page down"),
1005					),
1006					key.NewBinding(
1007						key.WithKeys("g", "home"),
1008						key.WithHelp("g", "home"),
1009					),
1010					key.NewBinding(
1011						key.WithKeys("G", "end"),
1012						key.WithHelp("G", "end"),
1013					),
1014				},
1015				[]key.Binding{
1016					messages.CopyKey,
1017					messages.ClearSelectionKey,
1018				},
1019			)
1020		case PanelTypeEditor:
1021			newLineBinding := key.NewBinding(
1022				key.WithKeys("shift+enter", "ctrl+j"),
1023				// "ctrl+j" is a common keybinding for newline in many editors. If
1024				// the terminal supports "shift+enter", we substitute the help text
1025				// to reflect that.
1026				key.WithHelp("ctrl+j", "newline"),
1027			)
1028			if p.keyboardEnhancements.SupportsKeyDisambiguation() {
1029				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1030			}
1031			shortList = append(shortList, newLineBinding)
1032			fullList = append(fullList,
1033				[]key.Binding{
1034					newLineBinding,
1035					key.NewBinding(
1036						key.WithKeys("ctrl+f"),
1037						key.WithHelp("ctrl+f", "add image"),
1038					),
1039					key.NewBinding(
1040						key.WithKeys("/"),
1041						key.WithHelp("/", "add file"),
1042					),
1043					key.NewBinding(
1044						key.WithKeys("ctrl+o"),
1045						key.WithHelp("ctrl+o", "open editor"),
1046					),
1047				})
1048
1049			if p.editor.HasAttachments() {
1050				fullList = append(fullList, []key.Binding{
1051					key.NewBinding(
1052						key.WithKeys("ctrl+r"),
1053						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1054					),
1055					key.NewBinding(
1056						key.WithKeys("ctrl+r", "r"),
1057						key.WithHelp("ctrl+r+r", "delete all attachments"),
1058					),
1059					key.NewBinding(
1060						key.WithKeys("esc", "alt+esc"),
1061						key.WithHelp("esc", "cancel delete mode"),
1062					),
1063				})
1064			}
1065		}
1066		shortList = append(shortList,
1067			// Quit
1068			key.NewBinding(
1069				key.WithKeys("ctrl+c"),
1070				key.WithHelp("ctrl+c", "quit"),
1071			),
1072			// Help
1073			helpBinding,
1074		)
1075		fullList = append(fullList, []key.Binding{
1076			key.NewBinding(
1077				key.WithKeys("ctrl+g"),
1078				key.WithHelp("ctrl+g", "less"),
1079			),
1080		})
1081	}
1082
1083	return core.NewSimpleHelp(shortList, fullList)
1084}
1085
1086func (p *chatPage) IsChatFocused() bool {
1087	return p.focusedPane == PanelTypeChat
1088}
1089
1090// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1091// Returns true if the mouse is over the chat area, false otherwise.
1092func (p *chatPage) isMouseOverChat(x, y int) bool {
1093	// No session means no chat area
1094	if p.session.ID == "" {
1095		return false
1096	}
1097
1098	var chatX, chatY, chatWidth, chatHeight int
1099
1100	if p.compact {
1101		// In compact mode: chat area starts after header and spans full width
1102		chatX = 0
1103		chatY = HeaderHeight
1104		chatWidth = p.width
1105		chatHeight = p.height - EditorHeight - HeaderHeight
1106	} else {
1107		// In non-compact mode: chat area spans from left edge to sidebar
1108		chatX = 0
1109		chatY = 0
1110		chatWidth = p.width - SideBarWidth
1111		chatHeight = p.height - EditorHeight
1112	}
1113
1114	// Check if mouse coordinates are within chat bounds
1115	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1116}