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