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/proto"
  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	"github.com/charmbracelet/lipgloss/v2"
  41)
  42
  43var ChatPageID page.PageID = "chat"
  44
  45type (
  46	ChatFocusedMsg struct {
  47		Focused bool
  48	}
  49	CancelTimerExpiredMsg struct{}
  50)
  51
  52type PanelType string
  53
  54const (
  55	PanelTypeChat   PanelType = "chat"
  56	PanelTypeEditor PanelType = "editor"
  57	PanelTypeSplash PanelType = "splash"
  58)
  59
  60const (
  61	CompactModeWidthBreakpoint  = 120 // Width at which the chat page switches to compact mode
  62	CompactModeHeightBreakpoint = 30  // Height at which the chat page switches to compact mode
  63	EditorHeight                = 5   // Height of the editor input area including padding
  64	SideBarWidth                = 31  // Width of the sidebar
  65	SideBarDetailsPadding       = 1   // Padding for the sidebar details section
  66	HeaderHeight                = 1   // Height of the header
  67
  68	// Layout constants for borders and padding
  69	BorderWidth        = 1 // Width of component borders
  70	LeftRightBorders   = 2 // Left + right border width (1 + 1)
  71	TopBottomBorders   = 2 // Top + bottom border width (1 + 1)
  72	DetailsPositioning = 2 // Positioning adjustment for details panel
  73
  74	// Timing constants
  75	CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
  76)
  77
  78type ChatPage interface {
  79	util.Model
  80	layout.Help
  81	IsChatFocused() bool
  82}
  83
  84// cancelTimerCmd creates a command that expires the cancel timer
  85func cancelTimerCmd() tea.Cmd {
  86	return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
  87		return CancelTimerExpiredMsg{}
  88	})
  89}
  90
  91type chatPage struct {
  92	width, height               int
  93	detailsWidth, detailsHeight int
  94	c                           *client.Client
  95	ins                         *proto.Instance
  96	keyboardEnhancements        tea.KeyboardEnhancementsMsg
  97
  98	// Layout state
  99	compact      bool
 100	forceCompact bool
 101	focusedPane  PanelType
 102
 103	// Session
 104	session session.Session
 105	keyMap  KeyMap
 106
 107	// Components
 108	header  header.Header
 109	sidebar sidebar.Sidebar
 110	chat    chat.MessageListCmp
 111	editor  editor.Editor
 112	splash  splash.Splash
 113
 114	// Simple state flags
 115	showingDetails   bool
 116	isCanceling      bool
 117	splashFullScreen bool
 118	isOnboarding     bool
 119	isProjectInit    bool
 120}
 121
 122func New(c *client.Client, ins *proto.Instance) ChatPage {
 123	return &chatPage{
 124		c:           c,
 125		ins:         ins,
 126		keyMap:      DefaultKeyMap(),
 127		header:      header.New(c, ins),
 128		sidebar:     sidebar.New(c, ins, false),
 129		chat:        chat.New(c, ins),
 130		editor:      editor.New(c, ins),
 131		splash:      splash.New(c, ins),
 132		focusedPane: PanelTypeSplash,
 133	}
 134}
 135
 136func (p *chatPage) Init() tea.Cmd {
 137	compact := p.ins.Config.Options.TUI.CompactMode
 138	p.compact = compact
 139	p.forceCompact = compact
 140	p.sidebar.SetCompactMode(p.compact)
 141
 142	// Set splash state based on config
 143	if !config.HasInitialDataConfig(p.ins.Config) {
 144		// First-time setup: show model selection
 145		p.splash.SetOnboarding(true)
 146		p.isOnboarding = true
 147		p.splashFullScreen = true
 148	} else if b, _ := config.ProjectNeedsInitialization(p.ins.Config); b {
 149		// Project needs CRUSH.md initialization
 150		p.splash.SetProjectInit(true)
 151		p.isProjectInit = true
 152		p.splashFullScreen = true
 153	} else {
 154		// Ready to chat: focus editor, splash in background
 155		p.focusedPane = PanelTypeEditor
 156		p.splashFullScreen = false
 157	}
 158
 159	return tea.Batch(
 160		p.header.Init(),
 161		p.sidebar.Init(),
 162		p.chat.Init(),
 163		p.editor.Init(),
 164		p.splash.Init(),
 165	)
 166}
 167
 168func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 169	var cmds []tea.Cmd
 170	switch msg := msg.(type) {
 171	case tea.KeyboardEnhancementsMsg:
 172		p.keyboardEnhancements = msg
 173		return p, nil
 174	case tea.MouseWheelMsg:
 175		if p.compact {
 176			msg.Y -= 1
 177		}
 178		if p.isMouseOverChat(msg.X, msg.Y) {
 179			u, cmd := p.chat.Update(msg)
 180			p.chat = u.(chat.MessageListCmp)
 181			return p, cmd
 182		}
 183		return p, nil
 184	case tea.MouseClickMsg:
 185		if p.isOnboarding {
 186			return p, nil
 187		}
 188		if p.compact {
 189			msg.Y -= 1
 190		}
 191		if p.isMouseOverChat(msg.X, msg.Y) {
 192			p.focusedPane = PanelTypeChat
 193			p.chat.Focus()
 194			p.editor.Blur()
 195		} else {
 196			p.focusedPane = PanelTypeEditor
 197			p.editor.Focus()
 198			p.chat.Blur()
 199		}
 200		u, cmd := p.chat.Update(msg)
 201		p.chat = u.(chat.MessageListCmp)
 202		return p, cmd
 203	case tea.MouseMotionMsg:
 204		if p.compact {
 205			msg.Y -= 1
 206		}
 207		if msg.Button == tea.MouseLeft {
 208			u, cmd := p.chat.Update(msg)
 209			p.chat = u.(chat.MessageListCmp)
 210			return p, cmd
 211		}
 212		return p, nil
 213	case tea.MouseReleaseMsg:
 214		if p.isOnboarding {
 215			return p, nil
 216		}
 217		if p.compact {
 218			msg.Y -= 1
 219		}
 220		if msg.Button == tea.MouseLeft {
 221			u, cmd := p.chat.Update(msg)
 222			p.chat = u.(chat.MessageListCmp)
 223			return p, cmd
 224		}
 225		return p, nil
 226	case chat.SelectionCopyMsg:
 227		u, cmd := p.chat.Update(msg)
 228		p.chat = u.(chat.MessageListCmp)
 229		return p, cmd
 230	case tea.WindowSizeMsg:
 231		u, cmd := p.editor.Update(msg)
 232		p.editor = u.(editor.Editor)
 233		return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
 234	case CancelTimerExpiredMsg:
 235		p.isCanceling = false
 236		return p, nil
 237	case editor.OpenEditorMsg:
 238		u, cmd := p.editor.Update(msg)
 239		p.editor = u.(editor.Editor)
 240		return p, cmd
 241	case chat.SendMsg:
 242		return p, p.sendMessage(msg.Text, msg.Attachments)
 243	case chat.SessionSelectedMsg:
 244		return p, p.setSession(msg)
 245	case splash.SubmitAPIKeyMsg:
 246		u, cmd := p.splash.Update(msg)
 247		p.splash = u.(splash.Splash)
 248		cmds = append(cmds, cmd)
 249		return p, tea.Batch(cmds...)
 250	case commands.ToggleCompactModeMsg:
 251		p.forceCompact = !p.forceCompact
 252		var cmd tea.Cmd
 253		if p.forceCompact {
 254			p.setCompactMode(true)
 255			cmd = p.updateCompactConfig(true)
 256		} else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
 257			p.setCompactMode(false)
 258			cmd = p.updateCompactConfig(false)
 259		}
 260		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
 261	case commands.ToggleThinkingMsg:
 262		return p, p.toggleThinking()
 263	case commands.OpenReasoningDialogMsg:
 264		return p, p.openReasoningDialog()
 265	case reasoning.ReasoningEffortSelectedMsg:
 266		return p, p.handleReasoningEffortSelected(msg.Effort)
 267	case commands.OpenExternalEditorMsg:
 268		u, cmd := p.editor.Update(msg)
 269		p.editor = u.(editor.Editor)
 270		return p, cmd
 271	case pubsub.Event[session.Session]:
 272		u, cmd := p.header.Update(msg)
 273		p.header = u.(header.Header)
 274		cmds = append(cmds, cmd)
 275		u, cmd = p.sidebar.Update(msg)
 276		p.sidebar = u.(sidebar.Sidebar)
 277		cmds = append(cmds, cmd)
 278		return p, tea.Batch(cmds...)
 279	case chat.SessionClearedMsg:
 280		u, cmd := p.header.Update(msg)
 281		p.header = u.(header.Header)
 282		cmds = append(cmds, cmd)
 283		u, cmd = p.sidebar.Update(msg)
 284		p.sidebar = u.(sidebar.Sidebar)
 285		cmds = append(cmds, cmd)
 286		u, cmd = p.chat.Update(msg)
 287		p.chat = u.(chat.MessageListCmp)
 288		cmds = append(cmds, cmd)
 289		return p, tea.Batch(cmds...)
 290	case filepicker.FilePickedMsg,
 291		completions.CompletionsClosedMsg,
 292		completions.SelectCompletionMsg:
 293		u, cmd := p.editor.Update(msg)
 294		p.editor = u.(editor.Editor)
 295		cmds = append(cmds, cmd)
 296		return p, tea.Batch(cmds...)
 297
 298	case models.APIKeyStateChangeMsg:
 299		if p.focusedPane == PanelTypeSplash {
 300			u, cmd := p.splash.Update(msg)
 301			p.splash = u.(splash.Splash)
 302			cmds = append(cmds, cmd)
 303		}
 304		return p, tea.Batch(cmds...)
 305	case pubsub.Event[message.Message],
 306		anim.StepMsg,
 307		spinner.TickMsg:
 308		if p.focusedPane == PanelTypeSplash {
 309			u, cmd := p.splash.Update(msg)
 310			p.splash = u.(splash.Splash)
 311			cmds = append(cmds, cmd)
 312		} else {
 313			u, cmd := p.chat.Update(msg)
 314			p.chat = u.(chat.MessageListCmp)
 315			cmds = append(cmds, cmd)
 316		}
 317
 318		return p, tea.Batch(cmds...)
 319	case commands.ToggleYoloModeMsg:
 320		// update the editor style
 321		u, cmd := p.editor.Update(msg)
 322		p.editor = u.(editor.Editor)
 323		return p, cmd
 324	case pubsub.Event[history.File], sidebar.SessionFilesMsg:
 325		u, cmd := p.sidebar.Update(msg)
 326		p.sidebar = u.(sidebar.Sidebar)
 327		cmds = append(cmds, cmd)
 328		return p, tea.Batch(cmds...)
 329	case pubsub.Event[permission.PermissionNotification]:
 330		u, cmd := p.chat.Update(msg)
 331		p.chat = u.(chat.MessageListCmp)
 332		cmds = append(cmds, cmd)
 333		return p, tea.Batch(cmds...)
 334
 335	case commands.CommandRunCustomMsg:
 336		info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 337		if err != nil {
 338			return p, util.ReportError(fmt.Errorf("failed to get agent info: %w", err))
 339		}
 340		if info.IsBusy {
 341			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
 342		}
 343
 344		cmd := p.sendMessage(msg.Content, nil)
 345		if cmd != nil {
 346			return p, cmd
 347		}
 348	case splash.OnboardingCompleteMsg:
 349		p.splashFullScreen = false
 350		if b, _ := config.ProjectNeedsInitialization(p.ins.Config); b {
 351			p.splash.SetProjectInit(true)
 352			p.splashFullScreen = true
 353			return p, p.SetSize(p.width, p.height)
 354		}
 355		err := p.c.InitiateAgentProcessing(context.TODO(), p.ins.ID)
 356		if err != nil {
 357			return p, util.ReportError(err)
 358		}
 359		p.isOnboarding = false
 360		p.isProjectInit = false
 361		p.focusedPane = PanelTypeEditor
 362		return p, p.SetSize(p.width, p.height)
 363	case commands.NewSessionsMsg:
 364		info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 365		if err != nil {
 366			return p, util.ReportError(fmt.Errorf("failed to get agent info: %w", err))
 367		}
 368		if info.IsBusy {
 369			return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 370		}
 371		return p, p.newSession()
 372	case tea.KeyPressMsg:
 373		switch {
 374		case key.Matches(msg, p.keyMap.NewSession):
 375			// if we have no agent do nothing
 376			info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 377			if err != nil || info.IsZero() {
 378				return p, nil
 379			}
 380			if info.IsBusy {
 381				return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 382			}
 383			return p, p.newSession()
 384		case key.Matches(msg, p.keyMap.AddAttachment):
 385			agentCfg := p.ins.Config.Agents["coder"]
 386			model := p.ins.Config.GetModelByType(agentCfg.Model)
 387			if model.SupportsImages {
 388				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
 389			} else {
 390				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
 391			}
 392		case key.Matches(msg, p.keyMap.Tab):
 393			if p.session.ID == "" {
 394				u, cmd := p.splash.Update(msg)
 395				p.splash = u.(splash.Splash)
 396				return p, cmd
 397			}
 398			p.changeFocus()
 399			return p, nil
 400		case key.Matches(msg, p.keyMap.Cancel):
 401			info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 402			if err != nil {
 403				return p, util.ReportError(fmt.Errorf("failed to get agent info: %w", err))
 404			}
 405			if p.session.ID != "" && info.IsBusy {
 406				return p, p.cancel()
 407			}
 408		case key.Matches(msg, p.keyMap.Details):
 409			p.toggleDetails()
 410			return p, nil
 411		}
 412
 413		switch p.focusedPane {
 414		case PanelTypeChat:
 415			u, cmd := p.chat.Update(msg)
 416			p.chat = u.(chat.MessageListCmp)
 417			cmds = append(cmds, cmd)
 418		case PanelTypeEditor:
 419			u, cmd := p.editor.Update(msg)
 420			p.editor = u.(editor.Editor)
 421			cmds = append(cmds, cmd)
 422		case PanelTypeSplash:
 423			u, cmd := p.splash.Update(msg)
 424			p.splash = u.(splash.Splash)
 425			cmds = append(cmds, cmd)
 426		}
 427	case tea.PasteMsg:
 428		switch p.focusedPane {
 429		case PanelTypeEditor:
 430			u, cmd := p.editor.Update(msg)
 431			p.editor = u.(editor.Editor)
 432			cmds = append(cmds, cmd)
 433			return p, tea.Batch(cmds...)
 434		case PanelTypeChat:
 435			u, cmd := p.chat.Update(msg)
 436			p.chat = u.(chat.MessageListCmp)
 437			cmds = append(cmds, cmd)
 438			return p, tea.Batch(cmds...)
 439		case PanelTypeSplash:
 440			u, cmd := p.splash.Update(msg)
 441			p.splash = u.(splash.Splash)
 442			cmds = append(cmds, cmd)
 443			return p, tea.Batch(cmds...)
 444		}
 445	}
 446	return p, tea.Batch(cmds...)
 447}
 448
 449func (p *chatPage) Cursor() *tea.Cursor {
 450	if p.header.ShowingDetails() {
 451		return nil
 452	}
 453	switch p.focusedPane {
 454	case PanelTypeEditor:
 455		return p.editor.Cursor()
 456	case PanelTypeSplash:
 457		return p.splash.Cursor()
 458	default:
 459		return nil
 460	}
 461}
 462
 463func (p *chatPage) View() string {
 464	var chatView string
 465	t := styles.CurrentTheme()
 466
 467	if p.session.ID == "" {
 468		splashView := p.splash.View()
 469		// Full screen during onboarding or project initialization
 470		if p.splashFullScreen {
 471			chatView = splashView
 472		} else {
 473			// Show splash + editor for new message state
 474			editorView := p.editor.View()
 475			chatView = lipgloss.JoinVertical(
 476				lipgloss.Left,
 477				t.S().Base.Render(splashView),
 478				editorView,
 479			)
 480		}
 481	} else {
 482		messagesView := p.chat.View()
 483		editorView := p.editor.View()
 484		if p.compact {
 485			headerView := p.header.View()
 486			chatView = lipgloss.JoinVertical(
 487				lipgloss.Left,
 488				headerView,
 489				messagesView,
 490				editorView,
 491			)
 492		} else {
 493			sidebarView := p.sidebar.View()
 494			messages := lipgloss.JoinHorizontal(
 495				lipgloss.Left,
 496				messagesView,
 497				sidebarView,
 498			)
 499			chatView = lipgloss.JoinVertical(
 500				lipgloss.Left,
 501				messages,
 502				p.editor.View(),
 503			)
 504		}
 505	}
 506
 507	layers := []*lipgloss.Layer{
 508		lipgloss.NewLayer(chatView).X(0).Y(0),
 509	}
 510
 511	if p.showingDetails {
 512		style := t.S().Base.
 513			Width(p.detailsWidth).
 514			Border(lipgloss.RoundedBorder()).
 515			BorderForeground(t.BorderFocus)
 516		version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
 517		details := style.Render(
 518			lipgloss.JoinVertical(
 519				lipgloss.Left,
 520				p.sidebar.View(),
 521				version,
 522			),
 523		)
 524		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
 525	}
 526	canvas := lipgloss.NewCanvas(
 527		layers...,
 528	)
 529	return canvas.Render()
 530}
 531
 532func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 533	return func() tea.Msg {
 534		err := p.ins.Config.SetCompactMode(compact)
 535		if err != nil {
 536			return util.InfoMsg{
 537				Type: util.InfoTypeError,
 538				Msg:  "Failed to update compact mode configuration: " + err.Error(),
 539			}
 540		}
 541		return nil
 542	}
 543}
 544
 545func (p *chatPage) toggleThinking() tea.Cmd {
 546	return func() tea.Msg {
 547		agentCfg := p.ins.Config.Agents["coder"]
 548		currentModel := p.ins.Config.Models[agentCfg.Model]
 549
 550		// Toggle the thinking mode
 551		currentModel.Think = !currentModel.Think
 552		p.ins.Config.Models[agentCfg.Model] = currentModel
 553
 554		// Update the agent with the new configuration
 555		if err := p.c.UpdateAgent(context.TODO(), p.ins.ID); err != nil {
 556			return util.InfoMsg{
 557				Type: util.InfoTypeError,
 558				Msg:  "Failed to update thinking mode: " + err.Error(),
 559			}
 560		}
 561
 562		status := "disabled"
 563		if currentModel.Think {
 564			status = "enabled"
 565		}
 566		return util.InfoMsg{
 567			Type: util.InfoTypeInfo,
 568			Msg:  "Thinking mode " + status,
 569		}
 570	}
 571}
 572
 573func (p *chatPage) openReasoningDialog() tea.Cmd {
 574	return func() tea.Msg {
 575		agentCfg := p.ins.Config.Agents["coder"]
 576		model := p.ins.Config.GetModelByType(agentCfg.Model)
 577		providerCfg := p.ins.Config.GetProviderForModel(agentCfg.Model)
 578
 579		if providerCfg != nil && model != nil &&
 580			providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
 581			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
 582			return dialogs.OpenDialogMsg{
 583				Model: reasoning.NewReasoningDialog(p.ins.Config),
 584			}
 585		}
 586		return nil
 587	}
 588}
 589
 590func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 591	return func() tea.Msg {
 592		cfg := p.ins.Config
 593		agentCfg := cfg.Agents["coder"]
 594		currentModel := cfg.Models[agentCfg.Model]
 595
 596		// Update the model configuration
 597		currentModel.ReasoningEffort = effort
 598		cfg.Models[agentCfg.Model] = currentModel
 599
 600		// Update the agent with the new configuration
 601		if err := p.c.UpdateAgent(context.TODO(), p.ins.ID); err != nil {
 602			return util.InfoMsg{
 603				Type: util.InfoTypeError,
 604				Msg:  "Failed to update reasoning effort: " + err.Error(),
 605			}
 606		}
 607
 608		return util.InfoMsg{
 609			Type: util.InfoTypeInfo,
 610			Msg:  "Reasoning effort set to " + effort,
 611		}
 612	}
 613}
 614
 615func (p *chatPage) setCompactMode(compact bool) {
 616	if p.compact == compact {
 617		return
 618	}
 619	p.compact = compact
 620	if compact {
 621		p.sidebar.SetCompactMode(true)
 622	} else {
 623		p.setShowDetails(false)
 624	}
 625}
 626
 627func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
 628	if p.forceCompact {
 629		return
 630	}
 631	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
 632		p.setCompactMode(true)
 633	}
 634	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
 635		p.setCompactMode(false)
 636	}
 637}
 638
 639func (p *chatPage) SetSize(width, height int) tea.Cmd {
 640	p.handleCompactMode(width, height)
 641	p.width = width
 642	p.height = height
 643	var cmds []tea.Cmd
 644
 645	if p.session.ID == "" {
 646		if p.splashFullScreen {
 647			cmds = append(cmds, p.splash.SetSize(width, height))
 648		} else {
 649			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
 650			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 651			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 652		}
 653	} else {
 654		if p.compact {
 655			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
 656			p.detailsWidth = width - DetailsPositioning
 657			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
 658			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 659			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
 660		} else {
 661			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
 662			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 663			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
 664		}
 665		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 666	}
 667	return tea.Batch(cmds...)
 668}
 669
 670func (p *chatPage) newSession() tea.Cmd {
 671	if p.session.ID == "" {
 672		return nil
 673	}
 674
 675	p.session = session.Session{}
 676	p.focusedPane = PanelTypeEditor
 677	p.editor.Focus()
 678	p.chat.Blur()
 679	p.isCanceling = false
 680	return tea.Batch(
 681		util.CmdHandler(chat.SessionClearedMsg{}),
 682		p.SetSize(p.width, p.height),
 683	)
 684}
 685
 686func (p *chatPage) setSession(session session.Session) tea.Cmd {
 687	if p.session.ID == session.ID {
 688		return nil
 689	}
 690
 691	var cmds []tea.Cmd
 692	p.session = session
 693
 694	cmds = append(cmds, p.SetSize(p.width, p.height))
 695	cmds = append(cmds, p.chat.SetSession(session))
 696	cmds = append(cmds, p.sidebar.SetSession(session))
 697	cmds = append(cmds, p.header.SetSession(session))
 698	cmds = append(cmds, p.editor.SetSession(session))
 699
 700	return tea.Sequence(cmds...)
 701}
 702
 703func (p *chatPage) changeFocus() {
 704	if p.session.ID == "" {
 705		return
 706	}
 707	switch p.focusedPane {
 708	case PanelTypeChat:
 709		p.focusedPane = PanelTypeEditor
 710		p.editor.Focus()
 711		p.chat.Blur()
 712	case PanelTypeEditor:
 713		p.focusedPane = PanelTypeChat
 714		p.chat.Focus()
 715		p.editor.Blur()
 716	}
 717}
 718
 719func (p *chatPage) cancel() tea.Cmd {
 720	if p.isCanceling {
 721		p.isCanceling = false
 722		_ = p.c.ClearAgentSessionQueuedPrompts(context.TODO(), p.ins.ID, p.session.ID)
 723		return nil
 724	}
 725
 726	queued, _ := p.c.GetAgentSessionQueuedPrompts(context.TODO(), p.ins.ID, p.session.ID)
 727	if queued > 0 {
 728		_ = p.c.ClearAgentSessionQueuedPrompts(context.TODO(), p.ins.ID, p.session.ID)
 729		return nil
 730	}
 731	p.isCanceling = true
 732	return cancelTimerCmd()
 733}
 734
 735func (p *chatPage) setShowDetails(show bool) {
 736	p.showingDetails = show
 737	p.header.SetDetailsOpen(p.showingDetails)
 738	if !p.compact {
 739		p.sidebar.SetCompactMode(false)
 740	}
 741}
 742
 743func (p *chatPage) toggleDetails() {
 744	if p.session.ID == "" || !p.compact {
 745		return
 746	}
 747	p.setShowDetails(!p.showingDetails)
 748}
 749
 750func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
 751	session := p.session
 752	var cmds []tea.Cmd
 753	if p.session.ID == "" {
 754		newSession, err := p.c.CreateSession(context.Background(), p.ins.ID, "New Session")
 755		if err != nil {
 756			return util.ReportError(err)
 757		}
 758		session = *newSession
 759		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 760	}
 761	info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 762	if err != nil || info.IsZero() {
 763		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 764	}
 765	if err := p.c.SendMessage(context.Background(), p.ins.ID, session.ID, text, attachments...); err != nil {
 766		return util.ReportError(err)
 767	}
 768	cmds = append(cmds, p.chat.GoToBottom())
 769	return tea.Batch(cmds...)
 770}
 771
 772func (p *chatPage) Bindings() []key.Binding {
 773	bindings := []key.Binding{
 774		p.keyMap.NewSession,
 775		p.keyMap.AddAttachment,
 776	}
 777	info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 778	if err == nil && info.IsBusy {
 779		cancelBinding := p.keyMap.Cancel
 780		if p.isCanceling {
 781			cancelBinding = key.NewBinding(
 782				key.WithKeys("esc", "alt+esc"),
 783				key.WithHelp("esc", "press again to cancel"),
 784			)
 785		}
 786		bindings = append([]key.Binding{cancelBinding}, bindings...)
 787	}
 788
 789	switch p.focusedPane {
 790	case PanelTypeChat:
 791		bindings = append([]key.Binding{
 792			key.NewBinding(
 793				key.WithKeys("tab"),
 794				key.WithHelp("tab", "focus editor"),
 795			),
 796		}, bindings...)
 797		bindings = append(bindings, p.chat.Bindings()...)
 798	case PanelTypeEditor:
 799		bindings = append([]key.Binding{
 800			key.NewBinding(
 801				key.WithKeys("tab"),
 802				key.WithHelp("tab", "focus chat"),
 803			),
 804		}, bindings...)
 805		bindings = append(bindings, p.editor.Bindings()...)
 806	case PanelTypeSplash:
 807		bindings = append(bindings, p.splash.Bindings()...)
 808	}
 809
 810	return bindings
 811}
 812
 813func (p *chatPage) Help() help.KeyMap {
 814	var shortList []key.Binding
 815	var fullList [][]key.Binding
 816	switch {
 817	case p.isOnboarding && !p.splash.IsShowingAPIKey():
 818		shortList = append(shortList,
 819			// Choose model
 820			key.NewBinding(
 821				key.WithKeys("up", "down"),
 822				key.WithHelp("↑/↓", "choose"),
 823			),
 824			// Accept selection
 825			key.NewBinding(
 826				key.WithKeys("enter", "ctrl+y"),
 827				key.WithHelp("enter", "accept"),
 828			),
 829			// Quit
 830			key.NewBinding(
 831				key.WithKeys("ctrl+c"),
 832				key.WithHelp("ctrl+c", "quit"),
 833			),
 834		)
 835		// keep them the same
 836		for _, v := range shortList {
 837			fullList = append(fullList, []key.Binding{v})
 838		}
 839	case p.isOnboarding && p.splash.IsShowingAPIKey():
 840		if p.splash.IsAPIKeyValid() {
 841			shortList = append(shortList,
 842				key.NewBinding(
 843					key.WithKeys("enter"),
 844					key.WithHelp("enter", "continue"),
 845				),
 846			)
 847		} else {
 848			shortList = append(shortList,
 849				// Go back
 850				key.NewBinding(
 851					key.WithKeys("esc", "alt+esc"),
 852					key.WithHelp("esc", "back"),
 853				),
 854			)
 855		}
 856		shortList = append(shortList,
 857			// Quit
 858			key.NewBinding(
 859				key.WithKeys("ctrl+c"),
 860				key.WithHelp("ctrl+c", "quit"),
 861			),
 862		)
 863		// keep them the same
 864		for _, v := range shortList {
 865			fullList = append(fullList, []key.Binding{v})
 866		}
 867	case p.isProjectInit:
 868		shortList = append(shortList,
 869			key.NewBinding(
 870				key.WithKeys("ctrl+c"),
 871				key.WithHelp("ctrl+c", "quit"),
 872			),
 873		)
 874		// keep them the same
 875		for _, v := range shortList {
 876			fullList = append(fullList, []key.Binding{v})
 877		}
 878	default:
 879		if p.editor.IsCompletionsOpen() {
 880			shortList = append(shortList,
 881				key.NewBinding(
 882					key.WithKeys("tab", "enter"),
 883					key.WithHelp("tab/enter", "complete"),
 884				),
 885				key.NewBinding(
 886					key.WithKeys("esc", "alt+esc"),
 887					key.WithHelp("esc", "cancel"),
 888				),
 889				key.NewBinding(
 890					key.WithKeys("up", "down"),
 891					key.WithHelp("↑/↓", "choose"),
 892				),
 893			)
 894			for _, v := range shortList {
 895				fullList = append(fullList, []key.Binding{v})
 896			}
 897			return core.NewSimpleHelp(shortList, fullList)
 898		}
 899		info, err := p.c.GetAgentInfo(context.TODO(), p.ins.ID)
 900		if err == nil && info.IsBusy {
 901			cancelBinding := key.NewBinding(
 902				key.WithKeys("esc", "alt+esc"),
 903				key.WithHelp("esc", "cancel"),
 904			)
 905			if p.isCanceling {
 906				cancelBinding = key.NewBinding(
 907					key.WithKeys("esc", "alt+esc"),
 908					key.WithHelp("esc", "press again to cancel"),
 909				)
 910			}
 911			queued, _ := p.c.GetAgentSessionQueuedPrompts(context.TODO(), p.ins.ID, p.session.ID)
 912			if queued > 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}