chat.go

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