chat.go

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