chat.go

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