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