ui.go

   1package model
   2
   3import (
   4	"context"
   5	"errors"
   6	"fmt"
   7	"image"
   8	"log/slog"
   9	"math/rand"
  10	"net/http"
  11	"os"
  12	"path/filepath"
  13	"regexp"
  14	"slices"
  15	"strconv"
  16	"strings"
  17	"time"
  18
  19	"charm.land/bubbles/v2/help"
  20	"charm.land/bubbles/v2/key"
  21	"charm.land/bubbles/v2/spinner"
  22	"charm.land/bubbles/v2/textarea"
  23	tea "charm.land/bubbletea/v2"
  24	"charm.land/lipgloss/v2"
  25	"github.com/charmbracelet/catwalk/pkg/catwalk"
  26	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
  27	"github.com/charmbracelet/crush/internal/app"
  28	"github.com/charmbracelet/crush/internal/commands"
  29	"github.com/charmbracelet/crush/internal/config"
  30	"github.com/charmbracelet/crush/internal/filetracker"
  31	"github.com/charmbracelet/crush/internal/history"
  32	"github.com/charmbracelet/crush/internal/message"
  33	"github.com/charmbracelet/crush/internal/permission"
  34	"github.com/charmbracelet/crush/internal/pubsub"
  35	"github.com/charmbracelet/crush/internal/session"
  36	"github.com/charmbracelet/crush/internal/ui/anim"
  37	"github.com/charmbracelet/crush/internal/ui/attachments"
  38	"github.com/charmbracelet/crush/internal/ui/chat"
  39	"github.com/charmbracelet/crush/internal/ui/common"
  40	"github.com/charmbracelet/crush/internal/ui/completions"
  41	"github.com/charmbracelet/crush/internal/ui/dialog"
  42	"github.com/charmbracelet/crush/internal/ui/logo"
  43	"github.com/charmbracelet/crush/internal/ui/styles"
  44	"github.com/charmbracelet/crush/internal/uiutil"
  45	"github.com/charmbracelet/crush/internal/version"
  46	uv "github.com/charmbracelet/ultraviolet"
  47	"github.com/charmbracelet/ultraviolet/screen"
  48	"github.com/charmbracelet/x/editor"
  49)
  50
  51// Max file size set to 5M.
  52const maxAttachmentSize = int64(5 * 1024 * 1024)
  53
  54// Allowed image formats.
  55var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
  56
  57// Compact mode breakpoints.
  58const (
  59	compactModeWidthBreakpoint  = 120
  60	compactModeHeightBreakpoint = 30
  61)
  62
  63// Session details panel max height.
  64const sessionDetailsMaxHeight = 20
  65
  66// uiFocusState represents the current focus state of the UI.
  67type uiFocusState uint8
  68
  69// Possible uiFocusState values.
  70const (
  71	uiFocusNone uiFocusState = iota
  72	uiFocusEditor
  73	uiFocusMain
  74)
  75
  76type uiState uint8
  77
  78// Possible uiState values.
  79const (
  80	uiConfigure uiState = iota
  81	uiInitialize
  82	uiLanding
  83	uiChat
  84)
  85
  86type openEditorMsg struct {
  87	Text string
  88}
  89
  90type (
  91	// cancelTimerExpiredMsg is sent when the cancel timer expires.
  92	cancelTimerExpiredMsg struct{}
  93	// userCommandsLoadedMsg is sent when user commands are loaded.
  94	userCommandsLoadedMsg struct {
  95		Commands []commands.CustomCommand
  96	}
  97	// mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
  98	mcpPromptsLoadedMsg struct {
  99		Prompts []commands.MCPPrompt
 100	}
 101	// sendMessageMsg is sent to send a message.
 102	// currently only used for mcp prompts.
 103	sendMessageMsg struct {
 104		Content     string
 105		Attachments []message.Attachment
 106	}
 107
 108	// closeDialogMsg is sent to close the current dialog.
 109	closeDialogMsg struct{}
 110)
 111
 112// UI represents the main user interface model.
 113type UI struct {
 114	com          *common.Common
 115	session      *session.Session
 116	sessionFiles []SessionFile
 117
 118	// The width and height of the terminal in cells.
 119	width  int
 120	height int
 121	layout layout
 122
 123	focus uiFocusState
 124	state uiState
 125
 126	keyMap KeyMap
 127	keyenh tea.KeyboardEnhancementsMsg
 128
 129	dialog *dialog.Overlay
 130	status *Status
 131
 132	// isCanceling tracks whether the user has pressed escape once to cancel.
 133	isCanceling bool
 134
 135	// header is the last cached header logo
 136	header string
 137
 138	// sendProgressBar instructs the TUI to send progress bar updates to the
 139	// terminal.
 140	sendProgressBar bool
 141
 142	// QueryVersion instructs the TUI to query for the terminal version when it
 143	// starts.
 144	QueryVersion bool
 145
 146	// Editor components
 147	textarea textarea.Model
 148
 149	// Attachment list
 150	attachments *attachments.Attachments
 151
 152	readyPlaceholder   string
 153	workingPlaceholder string
 154
 155	// Completions state
 156	completions              *completions.Completions
 157	completionsOpen          bool
 158	completionsStartIndex    int
 159	completionsQuery         string
 160	completionsPositionStart image.Point // x,y where user typed '@'
 161
 162	// Chat components
 163	chat *Chat
 164
 165	// onboarding state
 166	onboarding struct {
 167		yesInitializeSelected bool
 168	}
 169
 170	// lsp
 171	lspStates map[string]app.LSPClientInfo
 172
 173	// mcp
 174	mcpStates map[string]mcp.ClientInfo
 175
 176	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 177	sidebarLogo string
 178
 179	// custom commands & mcp commands
 180	customCommands []commands.CustomCommand
 181	mcpPrompts     []commands.MCPPrompt
 182
 183	// forceCompactMode tracks whether compact mode is forced by user toggle
 184	forceCompactMode bool
 185
 186	// isCompact tracks whether we're currently in compact layout mode (either
 187	// by user toggle or auto-switch based on window size)
 188	isCompact bool
 189
 190	// detailsOpen tracks whether the details panel is open (in compact mode)
 191	detailsOpen bool
 192}
 193
 194// New creates a new instance of the [UI] model.
 195func New(com *common.Common) *UI {
 196	// Editor components
 197	ta := textarea.New()
 198	ta.SetStyles(com.Styles.TextArea)
 199	ta.ShowLineNumbers = false
 200	ta.CharLimit = -1
 201	ta.SetVirtualCursor(false)
 202	ta.Focus()
 203
 204	ch := NewChat(com)
 205
 206	keyMap := DefaultKeyMap()
 207
 208	// Completions component
 209	comp := completions.New(
 210		com.Styles.Completions.Normal,
 211		com.Styles.Completions.Focused,
 212		com.Styles.Completions.Match,
 213	)
 214
 215	// Attachments component
 216	attachments := attachments.New(
 217		attachments.NewRenderer(
 218			com.Styles.Attachments.Normal,
 219			com.Styles.Attachments.Deleting,
 220			com.Styles.Attachments.Image,
 221			com.Styles.Attachments.Text,
 222		),
 223		attachments.Keymap{
 224			DeleteMode: keyMap.Editor.AttachmentDeleteMode,
 225			DeleteAll:  keyMap.Editor.DeleteAllAttachments,
 226			Escape:     keyMap.Editor.Escape,
 227		},
 228	)
 229
 230	ui := &UI{
 231		com:         com,
 232		dialog:      dialog.NewOverlay(),
 233		keyMap:      keyMap,
 234		focus:       uiFocusNone,
 235		state:       uiConfigure,
 236		textarea:    ta,
 237		chat:        ch,
 238		completions: comp,
 239		attachments: attachments,
 240	}
 241
 242	status := NewStatus(com, ui)
 243
 244	// set onboarding state defaults
 245	ui.onboarding.yesInitializeSelected = true
 246
 247	// If no provider is configured show the user the provider list
 248	if !com.Config().IsConfigured() {
 249		ui.state = uiConfigure
 250		// if the project needs initialization show the user the question
 251	} else if n, _ := config.ProjectNeedsInitialization(); n {
 252		ui.state = uiInitialize
 253		// otherwise go to the landing UI
 254	} else {
 255		ui.state = uiLanding
 256		ui.focus = uiFocusEditor
 257	}
 258
 259	ui.setEditorPrompt(false)
 260	ui.randomizePlaceholders()
 261	ui.textarea.Placeholder = ui.readyPlaceholder
 262	ui.status = status
 263
 264	// Initialize compact mode from config
 265	ui.forceCompactMode = com.Config().Options.TUI.CompactMode
 266
 267	return ui
 268}
 269
 270// Init initializes the UI model.
 271func (m *UI) Init() tea.Cmd {
 272	var cmds []tea.Cmd
 273	if m.QueryVersion {
 274		cmds = append(cmds, tea.RequestTerminalVersion)
 275	}
 276	// load the user commands async
 277	cmds = append(cmds, m.loadCustomCommands())
 278	return tea.Batch(cmds...)
 279}
 280
 281// loadCustomCommands loads the custom commands asynchronously.
 282func (m *UI) loadCustomCommands() tea.Cmd {
 283	return func() tea.Msg {
 284		customCommands, err := commands.LoadCustomCommands(m.com.Config())
 285		if err != nil {
 286			slog.Error("failed to load custom commands", "error", err)
 287		}
 288		return userCommandsLoadedMsg{Commands: customCommands}
 289	}
 290}
 291
 292// loadMCPrompts loads the MCP prompts asynchronously.
 293func (m *UI) loadMCPrompts() tea.Cmd {
 294	return func() tea.Msg {
 295		prompts, err := commands.LoadMCPPrompts()
 296		if err != nil {
 297			slog.Error("failed to load mcp prompts", "error", err)
 298		}
 299		if prompts == nil {
 300			// flag them as loaded even if there is none or an error
 301			prompts = []commands.MCPPrompt{}
 302		}
 303		return mcpPromptsLoadedMsg{Prompts: prompts}
 304	}
 305}
 306
 307// Update handles updates to the UI model.
 308func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 309	var cmds []tea.Cmd
 310	switch msg := msg.(type) {
 311	case tea.EnvMsg:
 312		// Is this Windows Terminal?
 313		if !m.sendProgressBar {
 314			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 315		}
 316	case loadSessionMsg:
 317		m.state = uiChat
 318		if m.forceCompactMode {
 319			m.isCompact = true
 320		}
 321		m.session = msg.session
 322		m.sessionFiles = msg.files
 323		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
 324		if err != nil {
 325			cmds = append(cmds, uiutil.ReportError(err))
 326			break
 327		}
 328		if cmd := m.setSessionMessages(msgs); cmd != nil {
 329			cmds = append(cmds, cmd)
 330		}
 331
 332	case sendMessageMsg:
 333		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
 334
 335	case userCommandsLoadedMsg:
 336		m.customCommands = msg.Commands
 337		dia := m.dialog.Dialog(dialog.CommandsID)
 338		if dia == nil {
 339			break
 340		}
 341
 342		commands, ok := dia.(*dialog.Commands)
 343		if ok {
 344			commands.SetCustomCommands(m.customCommands)
 345		}
 346	case mcpPromptsLoadedMsg:
 347		m.mcpPrompts = msg.Prompts
 348		dia := m.dialog.Dialog(dialog.CommandsID)
 349		if dia == nil {
 350			break
 351		}
 352
 353		commands, ok := dia.(*dialog.Commands)
 354		if ok {
 355			commands.SetMCPPrompts(m.mcpPrompts)
 356		}
 357
 358	case closeDialogMsg:
 359		m.dialog.CloseFrontDialog()
 360
 361	case pubsub.Event[message.Message]:
 362		// Check if this is a child session message for an agent tool.
 363		if m.session == nil {
 364			break
 365		}
 366		if msg.Payload.SessionID != m.session.ID {
 367			// This might be a child session message from an agent tool.
 368			if cmd := m.handleChildSessionMessage(msg); cmd != nil {
 369				cmds = append(cmds, cmd)
 370			}
 371			break
 372		}
 373		switch msg.Type {
 374		case pubsub.CreatedEvent:
 375			cmds = append(cmds, m.appendSessionMessage(msg.Payload))
 376		case pubsub.UpdatedEvent:
 377			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
 378		}
 379	case pubsub.Event[history.File]:
 380		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 381	case pubsub.Event[app.LSPEvent]:
 382		m.lspStates = app.GetLSPStates()
 383	case pubsub.Event[mcp.Event]:
 384		m.mcpStates = mcp.GetStates()
 385		// check if all mcps are initialized
 386		initialized := true
 387		for _, state := range m.mcpStates {
 388			if state.State == mcp.StateStarting {
 389				initialized = false
 390				break
 391			}
 392		}
 393		if initialized && m.mcpPrompts == nil {
 394			cmds = append(cmds, m.loadMCPrompts())
 395		}
 396	case pubsub.Event[permission.PermissionRequest]:
 397		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
 398			cmds = append(cmds, cmd)
 399		}
 400	case pubsub.Event[permission.PermissionNotification]:
 401		m.handlePermissionNotification(msg.Payload)
 402	case cancelTimerExpiredMsg:
 403		m.isCanceling = false
 404	case tea.TerminalVersionMsg:
 405		termVersion := strings.ToLower(msg.Name)
 406		// Only enable progress bar for the following terminals.
 407		if !m.sendProgressBar {
 408			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
 409		}
 410		return m, nil
 411	case tea.WindowSizeMsg:
 412		m.width, m.height = msg.Width, msg.Height
 413		m.handleCompactMode(m.width, m.height)
 414		m.updateLayoutAndSize()
 415	case tea.KeyboardEnhancementsMsg:
 416		m.keyenh = msg
 417		if msg.SupportsKeyDisambiguation() {
 418			m.keyMap.Models.SetHelp("ctrl+m", "models")
 419			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
 420		}
 421	case tea.MouseClickMsg:
 422		switch m.state {
 423		case uiChat:
 424			x, y := msg.X, msg.Y
 425			// Adjust for chat area position
 426			x -= m.layout.main.Min.X
 427			y -= m.layout.main.Min.Y
 428			m.chat.HandleMouseDown(x, y)
 429		}
 430
 431	case tea.MouseMotionMsg:
 432		switch m.state {
 433		case uiChat:
 434			if msg.Y <= 0 {
 435				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
 436					cmds = append(cmds, cmd)
 437				}
 438				if !m.chat.SelectedItemInView() {
 439					m.chat.SelectPrev()
 440					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 441						cmds = append(cmds, cmd)
 442					}
 443				}
 444			} else if msg.Y >= m.chat.Height()-1 {
 445				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
 446					cmds = append(cmds, cmd)
 447				}
 448				if !m.chat.SelectedItemInView() {
 449					m.chat.SelectNext()
 450					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 451						cmds = append(cmds, cmd)
 452					}
 453				}
 454			}
 455
 456			x, y := msg.X, msg.Y
 457			// Adjust for chat area position
 458			x -= m.layout.main.Min.X
 459			y -= m.layout.main.Min.Y
 460			m.chat.HandleMouseDrag(x, y)
 461		}
 462
 463	case tea.MouseReleaseMsg:
 464		switch m.state {
 465		case uiChat:
 466			x, y := msg.X, msg.Y
 467			// Adjust for chat area position
 468			x -= m.layout.main.Min.X
 469			y -= m.layout.main.Min.Y
 470			m.chat.HandleMouseUp(x, y)
 471		}
 472	case tea.MouseWheelMsg:
 473		// Pass mouse events to dialogs first if any are open.
 474		if m.dialog.HasDialogs() {
 475			m.dialog.Update(msg)
 476			return m, tea.Batch(cmds...)
 477		}
 478
 479		// Otherwise handle mouse wheel for chat.
 480		switch m.state {
 481		case uiChat:
 482			switch msg.Button {
 483			case tea.MouseWheelUp:
 484				if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
 485					cmds = append(cmds, cmd)
 486				}
 487				if !m.chat.SelectedItemInView() {
 488					m.chat.SelectPrev()
 489					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 490						cmds = append(cmds, cmd)
 491					}
 492				}
 493			case tea.MouseWheelDown:
 494				if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
 495					cmds = append(cmds, cmd)
 496				}
 497				if !m.chat.SelectedItemInView() {
 498					m.chat.SelectNext()
 499					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 500						cmds = append(cmds, cmd)
 501					}
 502				}
 503			}
 504		}
 505	case anim.StepMsg:
 506		if m.state == uiChat {
 507			if cmd := m.chat.Animate(msg); cmd != nil {
 508				cmds = append(cmds, cmd)
 509			}
 510		}
 511	case spinner.TickMsg:
 512		if m.dialog.HasDialogs() {
 513			// route to dialog
 514			if cmd := m.handleDialogMsg(msg); cmd != nil {
 515				cmds = append(cmds, cmd)
 516			}
 517		}
 518
 519	case tea.KeyPressMsg:
 520		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
 521			cmds = append(cmds, cmd)
 522		}
 523	case tea.PasteMsg:
 524		if cmd := m.handlePasteMsg(msg); cmd != nil {
 525			cmds = append(cmds, cmd)
 526		}
 527	case openEditorMsg:
 528		m.textarea.SetValue(msg.Text)
 529		m.textarea.MoveToEnd()
 530	case uiutil.InfoMsg:
 531		m.status.SetInfoMsg(msg)
 532		ttl := msg.TTL
 533		if ttl <= 0 {
 534			ttl = DefaultStatusTTL
 535		}
 536		cmds = append(cmds, clearInfoMsgCmd(ttl))
 537	case uiutil.ClearStatusMsg:
 538		m.status.ClearInfoMsg()
 539	case completions.FilesLoadedMsg:
 540		// Handle async file loading for completions.
 541		if m.completionsOpen {
 542			m.completions.SetFiles(msg.Files)
 543		}
 544	default:
 545		if m.dialog.HasDialogs() {
 546			if cmd := m.handleDialogMsg(msg); cmd != nil {
 547				cmds = append(cmds, cmd)
 548			}
 549		}
 550	}
 551
 552	// This logic gets triggered on any message type, but should it?
 553	switch m.focus {
 554	case uiFocusMain:
 555	case uiFocusEditor:
 556		// Textarea placeholder logic
 557		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 558			m.textarea.Placeholder = m.workingPlaceholder
 559		} else {
 560			m.textarea.Placeholder = m.readyPlaceholder
 561		}
 562		if m.com.App.Permissions.SkipRequests() {
 563			m.textarea.Placeholder = "Yolo mode!"
 564		}
 565	}
 566
 567	// at this point this can only handle [message.Attachment] message, and we
 568	// should return all cmds anyway.
 569	_ = m.attachments.Update(msg)
 570	return m, tea.Batch(cmds...)
 571}
 572
 573// setSessionMessages sets the messages for the current session in the chat
 574func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 575	var cmds []tea.Cmd
 576	// Build tool result map to link tool calls with their results
 577	msgPtrs := make([]*message.Message, len(msgs))
 578	for i := range msgs {
 579		msgPtrs[i] = &msgs[i]
 580	}
 581	toolResultMap := chat.BuildToolResultMap(msgPtrs)
 582
 583	// Add messages to chat with linked tool results
 584	items := make([]chat.MessageItem, 0, len(msgs)*2)
 585	for _, msg := range msgPtrs {
 586		items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 587	}
 588
 589	// Load nested tool calls for agent/agentic_fetch tools.
 590	m.loadNestedToolCalls(items)
 591
 592	// If the user switches between sessions while the agent is working we want
 593	// to make sure the animations are shown.
 594	for _, item := range items {
 595		if animatable, ok := item.(chat.Animatable); ok {
 596			if cmd := animatable.StartAnimation(); cmd != nil {
 597				cmds = append(cmds, cmd)
 598			}
 599		}
 600	}
 601
 602	m.chat.SetMessages(items...)
 603	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 604		cmds = append(cmds, cmd)
 605	}
 606	m.chat.SelectLast()
 607	return tea.Batch(cmds...)
 608}
 609
 610// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
 611func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
 612	for _, item := range items {
 613		nestedContainer, ok := item.(chat.NestedToolContainer)
 614		if !ok {
 615			continue
 616		}
 617		toolItem, ok := item.(chat.ToolMessageItem)
 618		if !ok {
 619			continue
 620		}
 621
 622		tc := toolItem.ToolCall()
 623		messageID := toolItem.MessageID()
 624
 625		// Get the agent tool session ID.
 626		agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
 627
 628		// Fetch nested messages.
 629		nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
 630		if err != nil || len(nestedMsgs) == 0 {
 631			continue
 632		}
 633
 634		// Build tool result map for nested messages.
 635		nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
 636		for i := range nestedMsgs {
 637			nestedMsgPtrs[i] = &nestedMsgs[i]
 638		}
 639		nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
 640
 641		// Extract nested tool items.
 642		var nestedTools []chat.ToolMessageItem
 643		for _, nestedMsg := range nestedMsgPtrs {
 644			nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
 645			for _, nestedItem := range nestedItems {
 646				if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
 647					// Mark nested tools as simple (compact) rendering.
 648					if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
 649						simplifiable.SetCompact(true)
 650					}
 651					nestedTools = append(nestedTools, nestedToolItem)
 652				}
 653			}
 654		}
 655
 656		// Recursively load nested tool calls for any agent tools within.
 657		nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
 658		for i, nt := range nestedTools {
 659			nestedMessageItems[i] = nt
 660		}
 661		m.loadNestedToolCalls(nestedMessageItems)
 662
 663		// Set nested tools on the parent.
 664		nestedContainer.SetNestedTools(nestedTools)
 665	}
 666}
 667
 668// appendSessionMessage appends a new message to the current session in the chat
 669// if the message is a tool result it will update the corresponding tool call message
 670func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 671	var cmds []tea.Cmd
 672	existing := m.chat.MessageItem(msg.ID)
 673	if existing != nil {
 674		// message already exists, skip
 675		return nil
 676	}
 677	switch msg.Role {
 678	case message.User, message.Assistant:
 679		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
 680		for _, item := range items {
 681			if animatable, ok := item.(chat.Animatable); ok {
 682				if cmd := animatable.StartAnimation(); cmd != nil {
 683					cmds = append(cmds, cmd)
 684				}
 685			}
 686		}
 687		m.chat.AppendMessages(items...)
 688		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 689			cmds = append(cmds, cmd)
 690		}
 691	case message.Tool:
 692		for _, tr := range msg.ToolResults() {
 693			toolItem := m.chat.MessageItem(tr.ToolCallID)
 694			if toolItem == nil {
 695				// we should have an item!
 696				continue
 697			}
 698			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
 699				toolMsgItem.SetResult(&tr)
 700			}
 701		}
 702	}
 703	return tea.Batch(cmds...)
 704}
 705
 706// updateSessionMessage updates an existing message in the current session in the chat
 707// when an assistant message is updated it may include updated tool calls as well
 708// that is why we need to handle creating/updating each tool call message too
 709func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 710	var cmds []tea.Cmd
 711	existingItem := m.chat.MessageItem(msg.ID)
 712
 713	if existingItem != nil {
 714		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
 715			assistantItem.SetMessage(&msg)
 716		}
 717	}
 718
 719	// if the message of the assistant does not have any  response just tool calls we need to remove it
 720	if !chat.ShouldRenderAssistantMessage(&msg) && len(msg.ToolCalls()) > 0 && existingItem != nil {
 721		m.chat.RemoveMessage(msg.ID)
 722	}
 723
 724	var items []chat.MessageItem
 725	for _, tc := range msg.ToolCalls() {
 726		existingToolItem := m.chat.MessageItem(tc.ID)
 727		if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
 728			existingToolCall := toolItem.ToolCall()
 729			// only update if finished state changed or input changed
 730			// to avoid clearing the cache
 731			if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
 732				toolItem.SetToolCall(tc)
 733			}
 734		}
 735		if existingToolItem == nil {
 736			items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
 737		}
 738	}
 739
 740	for _, item := range items {
 741		if animatable, ok := item.(chat.Animatable); ok {
 742			if cmd := animatable.StartAnimation(); cmd != nil {
 743				cmds = append(cmds, cmd)
 744			}
 745		}
 746	}
 747	m.chat.AppendMessages(items...)
 748	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 749		cmds = append(cmds, cmd)
 750	}
 751
 752	return tea.Batch(cmds...)
 753}
 754
 755// handleChildSessionMessage handles messages from child sessions (agent tools).
 756func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
 757	var cmds []tea.Cmd
 758
 759	// Only process messages with tool calls or results.
 760	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
 761		return nil
 762	}
 763
 764	// Check if this is an agent tool session and parse it.
 765	childSessionID := event.Payload.SessionID
 766	_, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
 767	if !ok {
 768		return nil
 769	}
 770
 771	// Find the parent agent tool item.
 772	var agentItem chat.NestedToolContainer
 773	for i := 0; i < m.chat.Len(); i++ {
 774		item := m.chat.MessageItem(toolCallID)
 775		if item == nil {
 776			continue
 777		}
 778		if agent, ok := item.(chat.NestedToolContainer); ok {
 779			if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
 780				if toolMessageItem.ToolCall().ID == toolCallID {
 781					// Verify this agent belongs to the correct parent message.
 782					// We can't directly check parentMessageID on the item, so we trust the session parsing.
 783					agentItem = agent
 784					break
 785				}
 786			}
 787		}
 788	}
 789
 790	if agentItem == nil {
 791		return nil
 792	}
 793
 794	// Get existing nested tools.
 795	nestedTools := agentItem.NestedTools()
 796
 797	// Update or create nested tool calls.
 798	for _, tc := range event.Payload.ToolCalls() {
 799		found := false
 800		for _, existingTool := range nestedTools {
 801			if existingTool.ToolCall().ID == tc.ID {
 802				existingTool.SetToolCall(tc)
 803				found = true
 804				break
 805			}
 806		}
 807		if !found {
 808			// Create a new nested tool item.
 809			nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
 810			if simplifiable, ok := nestedItem.(chat.Compactable); ok {
 811				simplifiable.SetCompact(true)
 812			}
 813			if animatable, ok := nestedItem.(chat.Animatable); ok {
 814				if cmd := animatable.StartAnimation(); cmd != nil {
 815					cmds = append(cmds, cmd)
 816				}
 817			}
 818			nestedTools = append(nestedTools, nestedItem)
 819		}
 820	}
 821
 822	// Update nested tool results.
 823	for _, tr := range event.Payload.ToolResults() {
 824		for _, nestedTool := range nestedTools {
 825			if nestedTool.ToolCall().ID == tr.ToolCallID {
 826				nestedTool.SetResult(&tr)
 827				break
 828			}
 829		}
 830	}
 831
 832	// Update the agent item with the new nested tools.
 833	agentItem.SetNestedTools(nestedTools)
 834
 835	// Update the chat so it updates the index map for animations to work as expected
 836	m.chat.UpdateNestedToolIDs(toolCallID)
 837
 838	return tea.Batch(cmds...)
 839}
 840
 841func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 842	var cmds []tea.Cmd
 843	action := m.dialog.Update(msg)
 844	if action == nil {
 845		return tea.Batch(cmds...)
 846	}
 847
 848	switch msg := action.(type) {
 849	// Generic dialog messages
 850	case dialog.ActionClose:
 851		m.dialog.CloseFrontDialog()
 852		if m.focus == uiFocusEditor {
 853			cmds = append(cmds, m.textarea.Focus())
 854		}
 855	case dialog.ActionCmd:
 856		if msg.Cmd != nil {
 857			cmds = append(cmds, msg.Cmd)
 858		}
 859
 860	// Session dialog messages
 861	case dialog.ActionSelectSession:
 862		m.dialog.CloseDialog(dialog.SessionsID)
 863		cmds = append(cmds, m.loadSession(msg.Session.ID))
 864
 865	// Open dialog message
 866	case dialog.ActionOpenDialog:
 867		m.dialog.CloseDialog(dialog.CommandsID)
 868		if cmd := m.openDialog(msg.DialogID); cmd != nil {
 869			cmds = append(cmds, cmd)
 870		}
 871
 872	// Command dialog messages
 873	case dialog.ActionToggleYoloMode:
 874		yolo := !m.com.App.Permissions.SkipRequests()
 875		m.com.App.Permissions.SetSkipRequests(yolo)
 876		m.setEditorPrompt(yolo)
 877		m.dialog.CloseDialog(dialog.CommandsID)
 878	case dialog.ActionNewSession:
 879		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 880			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
 881			break
 882		}
 883		m.newSession()
 884		m.dialog.CloseDialog(dialog.CommandsID)
 885	case dialog.ActionSummarize:
 886		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 887			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
 888			break
 889		}
 890		err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
 891		if err != nil {
 892			cmds = append(cmds, uiutil.ReportError(err))
 893		}
 894	case dialog.ActionToggleHelp:
 895		m.status.ToggleHelp()
 896		m.dialog.CloseDialog(dialog.CommandsID)
 897	case dialog.ActionToggleCompactMode:
 898		cmds = append(cmds, m.toggleCompactMode())
 899		m.dialog.CloseDialog(dialog.CommandsID)
 900	case dialog.ActionQuit:
 901		cmds = append(cmds, tea.Quit)
 902	case dialog.ActionInitializeProject:
 903		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 904			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
 905			break
 906		}
 907		cmds = append(cmds, m.initializeProject())
 908
 909	case dialog.ActionSelectModel:
 910		if m.com.App.AgentCoordinator.IsBusy() {
 911			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
 912			break
 913		}
 914
 915		cfg := m.com.Config()
 916		if cfg == nil {
 917			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
 918			break
 919		}
 920
 921		_, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider)
 922		if !isProviderConfigured {
 923			m.dialog.CloseDialog(dialog.ModelsID)
 924			if cmd := m.openAPIKeyInputDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
 925				cmds = append(cmds, cmd)
 926			}
 927			break
 928		}
 929
 930		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
 931			cmds = append(cmds, uiutil.ReportError(err))
 932		}
 933
 934		// XXX: Should this be in a separate goroutine?
 935		go m.com.App.UpdateAgentModel(context.TODO())
 936
 937		modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
 938		cmds = append(cmds, uiutil.ReportInfo(modelMsg))
 939		m.dialog.CloseDialog(dialog.APIKeyInputID)
 940		m.dialog.CloseDialog(dialog.ModelsID)
 941		// TODO CHANGE
 942	case dialog.ActionPermissionResponse:
 943		m.dialog.CloseDialog(dialog.PermissionsID)
 944		switch msg.Action {
 945		case dialog.PermissionAllow:
 946			m.com.App.Permissions.Grant(msg.Permission)
 947		case dialog.PermissionAllowForSession:
 948			m.com.App.Permissions.GrantPersistent(msg.Permission)
 949		case dialog.PermissionDeny:
 950			m.com.App.Permissions.Deny(msg.Permission)
 951		}
 952
 953	case dialog.ActionRunCustomCommand:
 954		if len(msg.Arguments) > 0 && msg.Args == nil {
 955			m.dialog.CloseFrontDialog()
 956			argsDialog := dialog.NewArguments(
 957				m.com,
 958				"Custom Command Arguments",
 959				"",
 960				msg.Arguments,
 961				msg, // Pass the action as the result
 962			)
 963			m.dialog.OpenDialog(argsDialog)
 964			break
 965		}
 966		content := msg.Content
 967		if msg.Args != nil {
 968			content = substituteArgs(content, msg.Args)
 969		}
 970		cmds = append(cmds, m.sendMessage(content))
 971		m.dialog.CloseFrontDialog()
 972	case dialog.ActionRunMCPPrompt:
 973		if len(msg.Arguments) > 0 && msg.Args == nil {
 974			m.dialog.CloseFrontDialog()
 975			title := msg.Title
 976			if title == "" {
 977				title = "MCP Prompt Arguments"
 978			}
 979			argsDialog := dialog.NewArguments(
 980				m.com,
 981				title,
 982				msg.Description,
 983				msg.Arguments,
 984				msg, // Pass the action as the result
 985			)
 986			m.dialog.OpenDialog(argsDialog)
 987			break
 988		}
 989		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
 990	default:
 991		cmds = append(cmds, uiutil.CmdHandler(msg))
 992	}
 993
 994	return tea.Batch(cmds...)
 995}
 996
 997// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
 998func substituteArgs(content string, args map[string]string) string {
 999	for name, value := range args {
1000		placeholder := "$" + name
1001		content = strings.ReplaceAll(content, placeholder, value)
1002	}
1003	return content
1004}
1005
1006// openAPIKeyInputDialog opens the API key input dialog.
1007func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1008	if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
1009		m.dialog.BringToFront(dialog.APIKeyInputID)
1010		return nil
1011	}
1012
1013	apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType)
1014	if err != nil {
1015		return uiutil.ReportError(err)
1016	}
1017	m.dialog.OpenDialog(apiKeyInputDialog)
1018	return nil
1019}
1020
1021func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1022	var cmds []tea.Cmd
1023
1024	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1025		switch {
1026		case key.Matches(msg, m.keyMap.Help):
1027			m.status.ToggleHelp()
1028			m.updateLayoutAndSize()
1029			return true
1030		case key.Matches(msg, m.keyMap.Commands):
1031			if cmd := m.openCommandsDialog(); cmd != nil {
1032				cmds = append(cmds, cmd)
1033			}
1034			return true
1035		case key.Matches(msg, m.keyMap.Models):
1036			if cmd := m.openModelsDialog(); cmd != nil {
1037				cmds = append(cmds, cmd)
1038			}
1039			return true
1040		case key.Matches(msg, m.keyMap.Sessions):
1041			if cmd := m.openSessionsDialog(); cmd != nil {
1042				cmds = append(cmds, cmd)
1043			}
1044			return true
1045		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1046			m.detailsOpen = !m.detailsOpen
1047			m.updateLayoutAndSize()
1048			return true
1049		}
1050		return false
1051	}
1052
1053	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1054		// Always handle quit keys first
1055		if cmd := m.openQuitDialog(); cmd != nil {
1056			cmds = append(cmds, cmd)
1057		}
1058
1059		return tea.Batch(cmds...)
1060	}
1061
1062	// Route all messages to dialog if one is open.
1063	if m.dialog.HasDialogs() {
1064		return m.handleDialogMsg(msg)
1065	}
1066
1067	// Handle cancel key when agent is busy.
1068	if key.Matches(msg, m.keyMap.Chat.Cancel) {
1069		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1070			if cmd := m.cancelAgent(); cmd != nil {
1071				cmds = append(cmds, cmd)
1072			}
1073			return tea.Batch(cmds...)
1074		}
1075	}
1076
1077	switch m.state {
1078	case uiConfigure:
1079		return tea.Batch(cmds...)
1080	case uiInitialize:
1081		cmds = append(cmds, m.updateInitializeView(msg)...)
1082		return tea.Batch(cmds...)
1083	case uiChat, uiLanding:
1084		switch m.focus {
1085		case uiFocusEditor:
1086			// Handle completions if open.
1087			if m.completionsOpen {
1088				if msg, ok := m.completions.Update(msg); ok {
1089					switch msg := msg.(type) {
1090					case completions.SelectionMsg:
1091						// Handle file completion selection.
1092						if item, ok := msg.Value.(completions.FileCompletionValue); ok {
1093							cmds = append(cmds, m.insertFileCompletion(item.Path))
1094						}
1095						if !msg.Insert {
1096							m.closeCompletions()
1097						}
1098					case completions.ClosedMsg:
1099						m.completionsOpen = false
1100					}
1101					return tea.Batch(cmds...)
1102				}
1103			}
1104
1105			if ok := m.attachments.Update(msg); ok {
1106				return tea.Batch(cmds...)
1107			}
1108
1109			switch {
1110			case key.Matches(msg, m.keyMap.Editor.SendMessage):
1111				value := m.textarea.Value()
1112				if before, ok := strings.CutSuffix(value, "\\"); ok {
1113					// If the last character is a backslash, remove it and add a newline.
1114					m.textarea.SetValue(before)
1115					break
1116				}
1117
1118				// Otherwise, send the message
1119				m.textarea.Reset()
1120
1121				value = strings.TrimSpace(value)
1122				if value == "exit" || value == "quit" {
1123					return m.openQuitDialog()
1124				}
1125
1126				attachments := m.attachments.List()
1127				m.attachments.Reset()
1128				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1129					return nil
1130				}
1131
1132				m.randomizePlaceholders()
1133
1134				return m.sendMessage(value, attachments...)
1135			case key.Matches(msg, m.keyMap.Chat.NewSession):
1136				if m.session == nil || m.session.ID == "" {
1137					break
1138				}
1139				if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1140					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1141					break
1142				}
1143				m.newSession()
1144			case key.Matches(msg, m.keyMap.Tab):
1145				m.focus = uiFocusMain
1146				m.textarea.Blur()
1147				m.chat.Focus()
1148				m.chat.SetSelected(m.chat.Len() - 1)
1149			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1150				if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
1151					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1152					break
1153				}
1154				cmds = append(cmds, m.openEditor(m.textarea.Value()))
1155			case key.Matches(msg, m.keyMap.Editor.Newline):
1156				m.textarea.InsertRune('\n')
1157				m.closeCompletions()
1158			default:
1159				if handleGlobalKeys(msg) {
1160					// Handle global keys first before passing to textarea.
1161					break
1162				}
1163
1164				// Check for @ trigger before passing to textarea.
1165				curValue := m.textarea.Value()
1166				curIdx := len(curValue)
1167
1168				// Trigger completions on @.
1169				if msg.String() == "@" && !m.completionsOpen {
1170					// Only show if beginning of prompt or after whitespace.
1171					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1172						m.completionsOpen = true
1173						m.completionsQuery = ""
1174						m.completionsStartIndex = curIdx
1175						m.completionsPositionStart = m.completionsPosition()
1176						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1177						cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
1178					}
1179				}
1180
1181				// remove the details if they are open when user starts typing
1182				if m.detailsOpen {
1183					m.detailsOpen = false
1184					m.updateLayoutAndSize()
1185				}
1186
1187				ta, cmd := m.textarea.Update(msg)
1188				m.textarea = ta
1189				cmds = append(cmds, cmd)
1190
1191				// After updating textarea, check if we need to filter completions.
1192				// Skip filtering on the initial @ keystroke since items are loading async.
1193				if m.completionsOpen && msg.String() != "@" {
1194					newValue := m.textarea.Value()
1195					newIdx := len(newValue)
1196
1197					// Close completions if cursor moved before start.
1198					if newIdx <= m.completionsStartIndex {
1199						m.closeCompletions()
1200					} else if msg.String() == "space" {
1201						// Close on space.
1202						m.closeCompletions()
1203					} else {
1204						// Extract current word and filter.
1205						word := m.textareaWord()
1206						if strings.HasPrefix(word, "@") {
1207							m.completionsQuery = word[1:]
1208							m.completions.Filter(m.completionsQuery)
1209						} else if m.completionsOpen {
1210							m.closeCompletions()
1211						}
1212					}
1213				}
1214			}
1215		case uiFocusMain:
1216			switch {
1217			case key.Matches(msg, m.keyMap.Tab):
1218				m.focus = uiFocusEditor
1219				cmds = append(cmds, m.textarea.Focus())
1220				m.chat.Blur()
1221			case key.Matches(msg, m.keyMap.Chat.Expand):
1222				m.chat.ToggleExpandedSelectedItem()
1223			case key.Matches(msg, m.keyMap.Chat.Up):
1224				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1225					cmds = append(cmds, cmd)
1226				}
1227				if !m.chat.SelectedItemInView() {
1228					m.chat.SelectPrev()
1229					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1230						cmds = append(cmds, cmd)
1231					}
1232				}
1233			case key.Matches(msg, m.keyMap.Chat.Down):
1234				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1235					cmds = append(cmds, cmd)
1236				}
1237				if !m.chat.SelectedItemInView() {
1238					m.chat.SelectNext()
1239					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1240						cmds = append(cmds, cmd)
1241					}
1242				}
1243			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1244				m.chat.SelectPrev()
1245				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1246					cmds = append(cmds, cmd)
1247				}
1248			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1249				m.chat.SelectNext()
1250				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1251					cmds = append(cmds, cmd)
1252				}
1253			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1254				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1255					cmds = append(cmds, cmd)
1256				}
1257				m.chat.SelectFirstInView()
1258			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1259				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1260					cmds = append(cmds, cmd)
1261				}
1262				m.chat.SelectLastInView()
1263			case key.Matches(msg, m.keyMap.Chat.PageUp):
1264				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1265					cmds = append(cmds, cmd)
1266				}
1267				m.chat.SelectFirstInView()
1268			case key.Matches(msg, m.keyMap.Chat.PageDown):
1269				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1270					cmds = append(cmds, cmd)
1271				}
1272				m.chat.SelectLastInView()
1273			case key.Matches(msg, m.keyMap.Chat.Home):
1274				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1275					cmds = append(cmds, cmd)
1276				}
1277				m.chat.SelectFirst()
1278			case key.Matches(msg, m.keyMap.Chat.End):
1279				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1280					cmds = append(cmds, cmd)
1281				}
1282				m.chat.SelectLast()
1283			default:
1284				handleGlobalKeys(msg)
1285			}
1286		default:
1287			handleGlobalKeys(msg)
1288		}
1289	default:
1290		handleGlobalKeys(msg)
1291	}
1292
1293	return tea.Batch(cmds...)
1294}
1295
1296// Draw implements [uv.Drawable] and draws the UI model.
1297func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1298	layout := m.generateLayout(area.Dx(), area.Dy())
1299
1300	if m.layout != layout {
1301		m.layout = layout
1302		m.updateSize()
1303	}
1304
1305	// Clear the screen first
1306	screen.Clear(scr)
1307
1308	switch m.state {
1309	case uiConfigure:
1310		header := uv.NewStyledString(m.header)
1311		header.Draw(scr, layout.header)
1312
1313		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1314			Height(layout.main.Dy()).
1315			Background(lipgloss.ANSIColor(rand.Intn(256))).
1316			Render(" Configure ")
1317		main := uv.NewStyledString(mainView)
1318		main.Draw(scr, layout.main)
1319
1320	case uiInitialize:
1321		header := uv.NewStyledString(m.header)
1322		header.Draw(scr, layout.header)
1323
1324		main := uv.NewStyledString(m.initializeView())
1325		main.Draw(scr, layout.main)
1326
1327	case uiLanding:
1328		header := uv.NewStyledString(m.header)
1329		header.Draw(scr, layout.header)
1330		main := uv.NewStyledString(m.landingView())
1331		main.Draw(scr, layout.main)
1332
1333		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1334		editor.Draw(scr, layout.editor)
1335
1336	case uiChat:
1337		if m.isCompact {
1338			header := uv.NewStyledString(m.header)
1339			header.Draw(scr, layout.header)
1340		} else {
1341			m.drawSidebar(scr, layout.sidebar)
1342		}
1343
1344		m.chat.Draw(scr, layout.main)
1345
1346		editorWidth := scr.Bounds().Dx()
1347		if !m.isCompact {
1348			editorWidth -= layout.sidebar.Dx()
1349		}
1350		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1351		editor.Draw(scr, layout.editor)
1352
1353		// Draw details overlay in compact mode when open
1354		if m.isCompact && m.detailsOpen {
1355			m.drawSessionDetails(scr, layout.sessionDetails)
1356		}
1357	}
1358
1359	// Add status and help layer
1360	m.status.Draw(scr, layout.status)
1361
1362	// Draw completions popup if open
1363	if m.completionsOpen && m.completions.HasItems() {
1364		w, h := m.completions.Size()
1365		x := m.completionsPositionStart.X
1366		y := m.completionsPositionStart.Y - h
1367
1368		screenW := area.Dx()
1369		if x+w > screenW {
1370			x = screenW - w
1371		}
1372		x = max(0, x)
1373		y = max(0, y)
1374
1375		completionsView := uv.NewStyledString(m.completions.Render())
1376		completionsView.Draw(scr, image.Rectangle{
1377			Min: image.Pt(x, y),
1378			Max: image.Pt(x+w, y+h),
1379		})
1380	}
1381
1382	// Debugging rendering (visually see when the tui rerenders)
1383	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1384		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1385		debug := uv.NewStyledString(debugView.String())
1386		debug.Draw(scr, image.Rectangle{
1387			Min: image.Pt(4, 1),
1388			Max: image.Pt(8, 3),
1389		})
1390	}
1391
1392	// This needs to come last to overlay on top of everything. We always pass
1393	// the full screen bounds because the dialogs will position themselves
1394	// accordingly.
1395	if m.dialog.HasDialogs() {
1396		return m.dialog.Draw(scr, scr.Bounds())
1397	}
1398
1399	switch m.focus {
1400	case uiFocusEditor:
1401		if m.layout.editor.Dy() <= 0 {
1402			// Don't show cursor if editor is not visible
1403			return nil
1404		}
1405		if m.detailsOpen && m.isCompact {
1406			// Don't show cursor if details overlay is open
1407			return nil
1408		}
1409
1410		if m.textarea.Focused() {
1411			cur := m.textarea.Cursor()
1412			cur.X++ // Adjust for app margins
1413			cur.Y += m.layout.editor.Min.Y
1414			// Offset for attachment row if present.
1415			if len(m.attachments.List()) > 0 {
1416				cur.Y++
1417			}
1418			return cur
1419		}
1420	}
1421	return nil
1422}
1423
1424// View renders the UI model's view.
1425func (m *UI) View() tea.View {
1426	var v tea.View
1427	v.AltScreen = true
1428	v.BackgroundColor = m.com.Styles.Background
1429	v.MouseMode = tea.MouseModeCellMotion
1430
1431	canvas := uv.NewScreenBuffer(m.width, m.height)
1432	v.Cursor = m.Draw(canvas, canvas.Bounds())
1433
1434	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1435	contentLines := strings.Split(content, "\n")
1436	for i, line := range contentLines {
1437		// Trim trailing spaces for concise rendering
1438		contentLines[i] = strings.TrimRight(line, " ")
1439	}
1440
1441	content = strings.Join(contentLines, "\n")
1442
1443	v.Content = content
1444	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1445		// HACK: use a random percentage to prevent ghostty from hiding it
1446		// after a timeout.
1447		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1448	}
1449
1450	return v
1451}
1452
1453// ShortHelp implements [help.KeyMap].
1454func (m *UI) ShortHelp() []key.Binding {
1455	var binds []key.Binding
1456	k := &m.keyMap
1457	tab := k.Tab
1458	commands := k.Commands
1459	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1460		commands.SetHelp("/ or ctrl+p", "commands")
1461	}
1462
1463	switch m.state {
1464	case uiInitialize:
1465		binds = append(binds, k.Quit)
1466	case uiChat:
1467		// Show cancel binding if agent is busy.
1468		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1469			cancelBinding := k.Chat.Cancel
1470			if m.isCanceling {
1471				cancelBinding.SetHelp("esc", "press again to cancel")
1472			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1473				cancelBinding.SetHelp("esc", "clear queue")
1474			}
1475			binds = append(binds, cancelBinding)
1476		}
1477
1478		if m.focus == uiFocusEditor {
1479			tab.SetHelp("tab", "focus chat")
1480		} else {
1481			tab.SetHelp("tab", "focus editor")
1482		}
1483
1484		binds = append(binds,
1485			tab,
1486			commands,
1487			k.Models,
1488		)
1489
1490		switch m.focus {
1491		case uiFocusEditor:
1492			binds = append(binds,
1493				k.Editor.Newline,
1494			)
1495		case uiFocusMain:
1496			binds = append(binds,
1497				k.Chat.UpDown,
1498				k.Chat.UpDownOneItem,
1499				k.Chat.PageUp,
1500				k.Chat.PageDown,
1501				k.Chat.Copy,
1502			)
1503		}
1504	default:
1505		// TODO: other states
1506		// if m.session == nil {
1507		// no session selected
1508		binds = append(binds,
1509			commands,
1510			k.Models,
1511			k.Editor.Newline,
1512		)
1513	}
1514
1515	binds = append(binds,
1516		k.Quit,
1517		k.Help,
1518	)
1519
1520	return binds
1521}
1522
1523// FullHelp implements [help.KeyMap].
1524func (m *UI) FullHelp() [][]key.Binding {
1525	var binds [][]key.Binding
1526	k := &m.keyMap
1527	help := k.Help
1528	help.SetHelp("ctrl+g", "less")
1529	hasAttachments := len(m.attachments.List()) > 0
1530	hasSession := m.session != nil && m.session.ID != ""
1531	commands := k.Commands
1532	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1533		commands.SetHelp("/ or ctrl+p", "commands")
1534	}
1535
1536	switch m.state {
1537	case uiInitialize:
1538		binds = append(binds,
1539			[]key.Binding{
1540				k.Quit,
1541			})
1542	case uiChat:
1543		// Show cancel binding if agent is busy.
1544		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1545			cancelBinding := k.Chat.Cancel
1546			if m.isCanceling {
1547				cancelBinding.SetHelp("esc", "press again to cancel")
1548			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1549				cancelBinding.SetHelp("esc", "clear queue")
1550			}
1551			binds = append(binds, []key.Binding{cancelBinding})
1552		}
1553
1554		mainBinds := []key.Binding{}
1555		tab := k.Tab
1556		if m.focus == uiFocusEditor {
1557			tab.SetHelp("tab", "focus chat")
1558		} else {
1559			tab.SetHelp("tab", "focus editor")
1560		}
1561
1562		mainBinds = append(mainBinds,
1563			tab,
1564			commands,
1565			k.Models,
1566			k.Sessions,
1567		)
1568		if hasSession {
1569			mainBinds = append(mainBinds, k.Chat.NewSession)
1570		}
1571
1572		binds = append(binds, mainBinds)
1573
1574		switch m.focus {
1575		case uiFocusEditor:
1576			binds = append(binds,
1577				[]key.Binding{
1578					k.Editor.Newline,
1579					k.Editor.AddImage,
1580					k.Editor.MentionFile,
1581					k.Editor.OpenEditor,
1582				},
1583			)
1584			if hasAttachments {
1585				binds = append(binds,
1586					[]key.Binding{
1587						k.Editor.AttachmentDeleteMode,
1588						k.Editor.DeleteAllAttachments,
1589						k.Editor.Escape,
1590					},
1591				)
1592			}
1593		case uiFocusMain:
1594			binds = append(binds,
1595				[]key.Binding{
1596					k.Chat.UpDown,
1597					k.Chat.UpDownOneItem,
1598					k.Chat.PageUp,
1599					k.Chat.PageDown,
1600				},
1601				[]key.Binding{
1602					k.Chat.HalfPageUp,
1603					k.Chat.HalfPageDown,
1604					k.Chat.Home,
1605					k.Chat.End,
1606				},
1607				[]key.Binding{
1608					k.Chat.Copy,
1609					k.Chat.ClearHighlight,
1610				},
1611			)
1612		}
1613	default:
1614		if m.session == nil {
1615			// no session selected
1616			binds = append(binds,
1617				[]key.Binding{
1618					commands,
1619					k.Models,
1620					k.Sessions,
1621				},
1622				[]key.Binding{
1623					k.Editor.Newline,
1624					k.Editor.AddImage,
1625					k.Editor.MentionFile,
1626					k.Editor.OpenEditor,
1627				},
1628			)
1629			if hasAttachments {
1630				binds = append(binds,
1631					[]key.Binding{
1632						k.Editor.AttachmentDeleteMode,
1633						k.Editor.DeleteAllAttachments,
1634						k.Editor.Escape,
1635					},
1636				)
1637			}
1638			binds = append(binds,
1639				[]key.Binding{
1640					help,
1641				},
1642			)
1643		}
1644	}
1645
1646	binds = append(binds,
1647		[]key.Binding{
1648			help,
1649			k.Quit,
1650		},
1651	)
1652
1653	return binds
1654}
1655
1656// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
1657func (m *UI) toggleCompactMode() tea.Cmd {
1658	m.forceCompactMode = !m.forceCompactMode
1659
1660	err := m.com.Config().SetCompactMode(m.forceCompactMode)
1661	if err != nil {
1662		return uiutil.ReportError(err)
1663	}
1664
1665	m.handleCompactMode(m.width, m.height)
1666	m.updateLayoutAndSize()
1667
1668	return nil
1669}
1670
1671// handleCompactMode updates the UI state based on window size and compact mode setting.
1672func (m *UI) handleCompactMode(newWidth, newHeight int) {
1673	if m.state == uiChat {
1674		if m.forceCompactMode {
1675			m.isCompact = true
1676			return
1677		}
1678		if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
1679			m.isCompact = true
1680		} else {
1681			m.isCompact = false
1682		}
1683	}
1684}
1685
1686// updateLayoutAndSize updates the layout and sizes of UI components.
1687func (m *UI) updateLayoutAndSize() {
1688	m.layout = m.generateLayout(m.width, m.height)
1689	m.updateSize()
1690}
1691
1692// updateSize updates the sizes of UI components based on the current layout.
1693func (m *UI) updateSize() {
1694	// Set status width
1695	m.status.SetWidth(m.layout.status.Dx())
1696
1697	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1698	m.textarea.SetWidth(m.layout.editor.Dx())
1699	m.textarea.SetHeight(m.layout.editor.Dy())
1700
1701	// Handle different app states
1702	switch m.state {
1703	case uiConfigure, uiInitialize, uiLanding:
1704		m.renderHeader(false, m.layout.header.Dx())
1705
1706	case uiChat:
1707		if m.isCompact {
1708			m.renderHeader(true, m.layout.header.Dx())
1709		} else {
1710			m.renderSidebarLogo(m.layout.sidebar.Dx())
1711		}
1712	}
1713}
1714
1715// generateLayout calculates the layout rectangles for all UI components based
1716// on the current UI state and terminal dimensions.
1717func (m *UI) generateLayout(w, h int) layout {
1718	// The screen area we're working with
1719	area := image.Rect(0, 0, w, h)
1720
1721	// The help height
1722	helpHeight := 1
1723	// The editor height
1724	editorHeight := 5
1725	// The sidebar width
1726	sidebarWidth := 30
1727	// The header height
1728	const landingHeaderHeight = 4
1729
1730	var helpKeyMap help.KeyMap = m
1731	if m.status.ShowingAll() {
1732		for _, row := range helpKeyMap.FullHelp() {
1733			helpHeight = max(helpHeight, len(row))
1734		}
1735	}
1736
1737	// Add app margins
1738	appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
1739	appRect.Min.Y += 1
1740	appRect.Max.Y -= 1
1741	helpRect.Min.Y -= 1
1742	appRect.Min.X += 1
1743	appRect.Max.X -= 1
1744
1745	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
1746		// extra padding on left and right for these states
1747		appRect.Min.X += 1
1748		appRect.Max.X -= 1
1749	}
1750
1751	layout := layout{
1752		area:   area,
1753		status: helpRect,
1754	}
1755
1756	// Handle different app states
1757	switch m.state {
1758	case uiConfigure, uiInitialize:
1759		// Layout
1760		//
1761		// header
1762		// ------
1763		// main
1764		// ------
1765		// help
1766
1767		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1768		layout.header = headerRect
1769		layout.main = mainRect
1770
1771	case uiLanding:
1772		// Layout
1773		//
1774		// header
1775		// ------
1776		// main
1777		// ------
1778		// editor
1779		// ------
1780		// help
1781		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1782		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1783		// Remove extra padding from editor (but keep it for header and main)
1784		editorRect.Min.X -= 1
1785		editorRect.Max.X += 1
1786		layout.header = headerRect
1787		layout.main = mainRect
1788		layout.editor = editorRect
1789
1790	case uiChat:
1791		if m.isCompact {
1792			// Layout
1793			//
1794			// compact-header
1795			// ------
1796			// main
1797			// ------
1798			// editor
1799			// ------
1800			// help
1801			const compactHeaderHeight = 1
1802			headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
1803			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
1804			sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
1805			layout.sessionDetails = sessionDetailsArea
1806			layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
1807			// Add one line gap between header and main content
1808			mainRect.Min.Y += 1
1809			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1810			mainRect.Max.X -= 1 // Add padding right
1811			// Add bottom margin to main
1812			mainRect.Max.Y -= 1
1813			layout.header = headerRect
1814			layout.main = mainRect
1815			layout.editor = editorRect
1816		} else {
1817			// Layout
1818			//
1819			// ------|---
1820			// main  |
1821			// ------| side
1822			// editor|
1823			// ----------
1824			// help
1825
1826			mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1827			// Add padding left
1828			sideRect.Min.X += 1
1829			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1830			mainRect.Max.X -= 1 // Add padding right
1831			// Add bottom margin to main
1832			mainRect.Max.Y -= 1
1833			layout.sidebar = sideRect
1834			layout.main = mainRect
1835			layout.editor = editorRect
1836		}
1837	}
1838
1839	if !layout.editor.Empty() {
1840		// Add editor margins 1 top and bottom
1841		layout.editor.Min.Y += 1
1842		layout.editor.Max.Y -= 1
1843	}
1844
1845	return layout
1846}
1847
1848// layout defines the positioning of UI elements.
1849type layout struct {
1850	// area is the overall available area.
1851	area uv.Rectangle
1852
1853	// header is the header shown in special cases
1854	// e.x when the sidebar is collapsed
1855	// or when in the landing page
1856	// or in init/config
1857	header uv.Rectangle
1858
1859	// main is the area for the main pane. (e.x chat, configure, landing)
1860	main uv.Rectangle
1861
1862	// editor is the area for the editor pane.
1863	editor uv.Rectangle
1864
1865	// sidebar is the area for the sidebar.
1866	sidebar uv.Rectangle
1867
1868	// status is the area for the status view.
1869	status uv.Rectangle
1870
1871	// session details is the area for the session details overlay in compact mode.
1872	sessionDetails uv.Rectangle
1873}
1874
1875func (m *UI) openEditor(value string) tea.Cmd {
1876	tmpfile, err := os.CreateTemp("", "msg_*.md")
1877	if err != nil {
1878		return uiutil.ReportError(err)
1879	}
1880	defer tmpfile.Close() //nolint:errcheck
1881	if _, err := tmpfile.WriteString(value); err != nil {
1882		return uiutil.ReportError(err)
1883	}
1884	cmd, err := editor.Command(
1885		"crush",
1886		tmpfile.Name(),
1887		editor.AtPosition(
1888			m.textarea.Line()+1,
1889			m.textarea.Column()+1,
1890		),
1891	)
1892	if err != nil {
1893		return uiutil.ReportError(err)
1894	}
1895	return tea.ExecProcess(cmd, func(err error) tea.Msg {
1896		if err != nil {
1897			return uiutil.ReportError(err)
1898		}
1899		content, err := os.ReadFile(tmpfile.Name())
1900		if err != nil {
1901			return uiutil.ReportError(err)
1902		}
1903		if len(content) == 0 {
1904			return uiutil.ReportWarn("Message is empty")
1905		}
1906		os.Remove(tmpfile.Name())
1907		return openEditorMsg{
1908			Text: strings.TrimSpace(string(content)),
1909		}
1910	})
1911}
1912
1913// setEditorPrompt configures the textarea prompt function based on whether
1914// yolo mode is enabled.
1915func (m *UI) setEditorPrompt(yolo bool) {
1916	if yolo {
1917		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1918		return
1919	}
1920	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1921}
1922
1923// normalPromptFunc returns the normal editor prompt style ("  > " on first
1924// line, "::: " on subsequent lines).
1925func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1926	t := m.com.Styles
1927	if info.LineNumber == 0 {
1928		if info.Focused {
1929			return "  > "
1930		}
1931		return "::: "
1932	}
1933	if info.Focused {
1934		return t.EditorPromptNormalFocused.Render()
1935	}
1936	return t.EditorPromptNormalBlurred.Render()
1937}
1938
1939// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1940// and colored dots.
1941func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1942	t := m.com.Styles
1943	if info.LineNumber == 0 {
1944		if info.Focused {
1945			return t.EditorPromptYoloIconFocused.Render()
1946		} else {
1947			return t.EditorPromptYoloIconBlurred.Render()
1948		}
1949	}
1950	if info.Focused {
1951		return t.EditorPromptYoloDotsFocused.Render()
1952	}
1953	return t.EditorPromptYoloDotsBlurred.Render()
1954}
1955
1956// closeCompletions closes the completions popup and resets state.
1957func (m *UI) closeCompletions() {
1958	m.completionsOpen = false
1959	m.completionsQuery = ""
1960	m.completionsStartIndex = 0
1961	m.completions.Close()
1962}
1963
1964// insertFileCompletion inserts the selected file path into the textarea,
1965// replacing the @query, and adds the file as an attachment.
1966func (m *UI) insertFileCompletion(path string) tea.Cmd {
1967	value := m.textarea.Value()
1968	word := m.textareaWord()
1969
1970	// Find the @ and query to replace.
1971	if m.completionsStartIndex > len(value) {
1972		return nil
1973	}
1974
1975	// Build the new value: everything before @, the path, everything after query.
1976	endIdx := min(m.completionsStartIndex+len(word), len(value))
1977
1978	newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
1979	m.textarea.SetValue(newValue)
1980	m.textarea.MoveToEnd()
1981	m.textarea.InsertRune(' ')
1982
1983	return func() tea.Msg {
1984		absPath, _ := filepath.Abs(path)
1985		// Skip attachment if file was already read and hasn't been modified.
1986		lastRead := filetracker.LastReadTime(absPath)
1987		if !lastRead.IsZero() {
1988			if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
1989				return nil
1990			}
1991		}
1992
1993		// Add file as attachment.
1994		content, err := os.ReadFile(path)
1995		if err != nil {
1996			// If it fails, let the LLM handle it later.
1997			return nil
1998		}
1999		filetracker.RecordRead(absPath)
2000
2001		return message.Attachment{
2002			FilePath: path,
2003			FileName: filepath.Base(path),
2004			MimeType: mimeOf(content),
2005			Content:  content,
2006		}
2007	}
2008}
2009
2010// completionsPosition returns the X and Y position for the completions popup.
2011func (m *UI) completionsPosition() image.Point {
2012	cur := m.textarea.Cursor()
2013	if cur == nil {
2014		return image.Point{
2015			X: m.layout.editor.Min.X,
2016			Y: m.layout.editor.Min.Y,
2017		}
2018	}
2019	return image.Point{
2020		X: cur.X + m.layout.editor.Min.X,
2021		Y: m.layout.editor.Min.Y + cur.Y,
2022	}
2023}
2024
2025// textareaWord returns the current word at the cursor position.
2026func (m *UI) textareaWord() string {
2027	return m.textarea.Word()
2028}
2029
2030// isWhitespace returns true if the byte is a whitespace character.
2031func isWhitespace(b byte) bool {
2032	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2033}
2034
2035// mimeOf detects the MIME type of the given content.
2036func mimeOf(content []byte) string {
2037	mimeBufferSize := min(512, len(content))
2038	return http.DetectContentType(content[:mimeBufferSize])
2039}
2040
2041var readyPlaceholders = [...]string{
2042	"Ready!",
2043	"Ready...",
2044	"Ready?",
2045	"Ready for instructions",
2046}
2047
2048var workingPlaceholders = [...]string{
2049	"Working!",
2050	"Working...",
2051	"Brrrrr...",
2052	"Prrrrrrrr...",
2053	"Processing...",
2054	"Thinking...",
2055}
2056
2057// randomizePlaceholders selects random placeholder text for the textarea's
2058// ready and working states.
2059func (m *UI) randomizePlaceholders() {
2060	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2061	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2062}
2063
2064// renderEditorView renders the editor view with attachments if any.
2065func (m *UI) renderEditorView(width int) string {
2066	if len(m.attachments.List()) == 0 {
2067		return m.textarea.View()
2068	}
2069	return lipgloss.JoinVertical(
2070		lipgloss.Top,
2071		m.attachments.Render(width),
2072		m.textarea.View(),
2073	)
2074}
2075
2076// renderHeader renders and caches the header logo at the specified width.
2077func (m *UI) renderHeader(compact bool, width int) {
2078	if compact && m.session != nil && m.com.App != nil {
2079		m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2080	} else {
2081		m.header = renderLogo(m.com.Styles, compact, width)
2082	}
2083}
2084
2085// renderSidebarLogo renders and caches the sidebar logo at the specified
2086// width.
2087func (m *UI) renderSidebarLogo(width int) {
2088	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2089}
2090
2091// sendMessage sends a message with the given content and attachments.
2092func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2093	if m.com.App.AgentCoordinator == nil {
2094		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2095	}
2096
2097	var cmds []tea.Cmd
2098	if m.session == nil || m.session.ID == "" {
2099		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2100		if err != nil {
2101			return uiutil.ReportError(err)
2102		}
2103		m.state = uiChat
2104		if m.forceCompactMode {
2105			m.isCompact = true
2106		}
2107		if newSession.ID != "" {
2108			m.session = &newSession
2109			cmds = append(cmds, m.loadSession(newSession.ID))
2110		}
2111	}
2112
2113	// Capture session ID to avoid race with main goroutine updating m.session.
2114	sessionID := m.session.ID
2115	cmds = append(cmds, func() tea.Msg {
2116		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2117		if err != nil {
2118			isCancelErr := errors.Is(err, context.Canceled)
2119			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2120			if isCancelErr || isPermissionErr {
2121				return nil
2122			}
2123			return uiutil.InfoMsg{
2124				Type: uiutil.InfoTypeError,
2125				Msg:  err.Error(),
2126			}
2127		}
2128		return nil
2129	})
2130	return tea.Batch(cmds...)
2131}
2132
2133const cancelTimerDuration = 2 * time.Second
2134
2135// cancelTimerCmd creates a command that expires the cancel timer.
2136func cancelTimerCmd() tea.Cmd {
2137	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2138		return cancelTimerExpiredMsg{}
2139	})
2140}
2141
2142// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2143// and starts a timer. The second press (before the timer expires) actually
2144// cancels the agent.
2145func (m *UI) cancelAgent() tea.Cmd {
2146	if m.session == nil || m.session.ID == "" {
2147		return nil
2148	}
2149
2150	coordinator := m.com.App.AgentCoordinator
2151	if coordinator == nil {
2152		return nil
2153	}
2154
2155	if m.isCanceling {
2156		// Second escape press - actually cancel the agent.
2157		m.isCanceling = false
2158		coordinator.Cancel(m.session.ID)
2159		return nil
2160	}
2161
2162	// Check if there are queued prompts - if so, clear the queue.
2163	if coordinator.QueuedPrompts(m.session.ID) > 0 {
2164		coordinator.ClearQueue(m.session.ID)
2165		return nil
2166	}
2167
2168	// First escape press - set canceling state and start timer.
2169	m.isCanceling = true
2170	return cancelTimerCmd()
2171}
2172
2173// openDialog opens a dialog by its ID.
2174func (m *UI) openDialog(id string) tea.Cmd {
2175	var cmds []tea.Cmd
2176	switch id {
2177	case dialog.SessionsID:
2178		if cmd := m.openSessionsDialog(); cmd != nil {
2179			cmds = append(cmds, cmd)
2180		}
2181	case dialog.ModelsID:
2182		if cmd := m.openModelsDialog(); cmd != nil {
2183			cmds = append(cmds, cmd)
2184		}
2185	case dialog.CommandsID:
2186		if cmd := m.openCommandsDialog(); cmd != nil {
2187			cmds = append(cmds, cmd)
2188		}
2189	case dialog.QuitID:
2190		if cmd := m.openQuitDialog(); cmd != nil {
2191			cmds = append(cmds, cmd)
2192		}
2193	default:
2194		// Unknown dialog
2195		break
2196	}
2197	return tea.Batch(cmds...)
2198}
2199
2200// openQuitDialog opens the quit confirmation dialog.
2201func (m *UI) openQuitDialog() tea.Cmd {
2202	if m.dialog.ContainsDialog(dialog.QuitID) {
2203		// Bring to front
2204		m.dialog.BringToFront(dialog.QuitID)
2205		return nil
2206	}
2207
2208	quitDialog := dialog.NewQuit(m.com)
2209	m.dialog.OpenDialog(quitDialog)
2210	return nil
2211}
2212
2213// openModelsDialog opens the models dialog.
2214func (m *UI) openModelsDialog() tea.Cmd {
2215	if m.dialog.ContainsDialog(dialog.ModelsID) {
2216		// Bring to front
2217		m.dialog.BringToFront(dialog.ModelsID)
2218		return nil
2219	}
2220
2221	modelsDialog, err := dialog.NewModels(m.com)
2222	if err != nil {
2223		return uiutil.ReportError(err)
2224	}
2225
2226	m.dialog.OpenDialog(modelsDialog)
2227
2228	return nil
2229}
2230
2231// openCommandsDialog opens the commands dialog.
2232func (m *UI) openCommandsDialog() tea.Cmd {
2233	if m.dialog.ContainsDialog(dialog.CommandsID) {
2234		// Bring to front
2235		m.dialog.BringToFront(dialog.CommandsID)
2236		return nil
2237	}
2238
2239	sessionID := ""
2240	if m.session != nil {
2241		sessionID = m.session.ID
2242	}
2243
2244	commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2245	if err != nil {
2246		return uiutil.ReportError(err)
2247	}
2248
2249	m.dialog.OpenDialog(commands)
2250
2251	return nil
2252}
2253
2254// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2255// it brings it to the front. Otherwise, it will list all the sessions and open
2256// the dialog.
2257func (m *UI) openSessionsDialog() tea.Cmd {
2258	if m.dialog.ContainsDialog(dialog.SessionsID) {
2259		// Bring to front
2260		m.dialog.BringToFront(dialog.SessionsID)
2261		return nil
2262	}
2263
2264	selectedSessionID := ""
2265	if m.session != nil {
2266		selectedSessionID = m.session.ID
2267	}
2268
2269	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2270	if err != nil {
2271		return uiutil.ReportError(err)
2272	}
2273
2274	m.dialog.OpenDialog(dialog)
2275
2276	return nil
2277}
2278
2279// openPermissionsDialog opens the permissions dialog for a permission request.
2280func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2281	// Close any existing permissions dialog first.
2282	m.dialog.CloseDialog(dialog.PermissionsID)
2283
2284	// Get diff mode from config.
2285	var opts []dialog.PermissionsOption
2286	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2287		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2288	}
2289
2290	permDialog := dialog.NewPermissions(m.com, perm, opts...)
2291	m.dialog.OpenDialog(permDialog)
2292	return nil
2293}
2294
2295// handlePermissionNotification updates tool items when permission state changes.
2296func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2297	toolItem := m.chat.MessageItem(notification.ToolCallID)
2298	if toolItem == nil {
2299		return
2300	}
2301
2302	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2303		if notification.Granted {
2304			permItem.SetStatus(chat.ToolStatusRunning)
2305		} else {
2306			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2307		}
2308	}
2309}
2310
2311// newSession clears the current session state and prepares for a new session.
2312// The actual session creation happens when the user sends their first message.
2313func (m *UI) newSession() {
2314	if m.session == nil || m.session.ID == "" {
2315		return
2316	}
2317
2318	m.session = nil
2319	m.sessionFiles = nil
2320	m.state = uiLanding
2321	m.focus = uiFocusEditor
2322	m.textarea.Focus()
2323	m.chat.Blur()
2324	m.chat.ClearMessages()
2325}
2326
2327// handlePasteMsg handles a paste message.
2328func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2329	if m.dialog.HasDialogs() {
2330		return m.handleDialogMsg(msg)
2331	}
2332
2333	if m.focus != uiFocusEditor {
2334		return nil
2335	}
2336
2337	// If pasted text has more than 2 newlines, treat it as a file attachment.
2338	if strings.Count(msg.Content, "\n") > 2 {
2339		return func() tea.Msg {
2340			content := []byte(msg.Content)
2341			if int64(len(content)) > maxAttachmentSize {
2342				return uiutil.ReportWarn("Paste is too big (>5mb)")
2343			}
2344			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2345			mimeBufferSize := min(512, len(content))
2346			mimeType := http.DetectContentType(content[:mimeBufferSize])
2347			return message.Attachment{
2348				FileName: name,
2349				FilePath: name,
2350				MimeType: mimeType,
2351				Content:  content,
2352			}
2353		}
2354	}
2355
2356	var cmd tea.Cmd
2357	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
2358	// Try to get an image.
2359	path, err := filepath.Abs(strings.TrimSpace(path))
2360	if err != nil {
2361		m.textarea, cmd = m.textarea.Update(msg)
2362		return cmd
2363	}
2364
2365	// Check if file has an allowed image extension.
2366	isAllowedType := false
2367	lowerPath := strings.ToLower(path)
2368	for _, ext := range allowedImageTypes {
2369		if strings.HasSuffix(lowerPath, ext) {
2370			isAllowedType = true
2371			break
2372		}
2373	}
2374	if !isAllowedType {
2375		m.textarea, cmd = m.textarea.Update(msg)
2376		return cmd
2377	}
2378
2379	return func() tea.Msg {
2380		fileInfo, err := os.Stat(path)
2381		if err != nil {
2382			return uiutil.ReportError(err)
2383		}
2384		if fileInfo.Size() > maxAttachmentSize {
2385			return uiutil.ReportWarn("File is too big (>5mb)")
2386		}
2387
2388		content, err := os.ReadFile(path)
2389		if err != nil {
2390			return uiutil.ReportError(err)
2391		}
2392
2393		mimeBufferSize := min(512, len(content))
2394		mimeType := http.DetectContentType(content[:mimeBufferSize])
2395		fileName := filepath.Base(path)
2396		return message.Attachment{
2397			FilePath: path,
2398			FileName: fileName,
2399			MimeType: mimeType,
2400			Content:  content,
2401		}
2402	}
2403}
2404
2405var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2406
2407func (m *UI) pasteIdx() int {
2408	result := 0
2409	for _, at := range m.attachments.List() {
2410		found := pasteRE.FindStringSubmatch(at.FileName)
2411		if len(found) == 0 {
2412			continue
2413		}
2414		idx, err := strconv.Atoi(found[1])
2415		if err == nil {
2416			result = max(result, idx)
2417		}
2418	}
2419	return result + 1
2420}
2421
2422// drawSessionDetails draws the session details in compact mode.
2423func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2424	if m.session == nil {
2425		return
2426	}
2427
2428	s := m.com.Styles
2429
2430	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2431	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2432
2433	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2434	blocks := []string{
2435		title,
2436		"",
2437		m.modelInfo(width),
2438		"",
2439	}
2440
2441	detailsHeader := lipgloss.JoinVertical(
2442		lipgloss.Left,
2443		blocks...,
2444	)
2445
2446	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2447
2448	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2449
2450	const maxSectionWidth = 50
2451	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2452	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
2453
2454	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2455	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2456	filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2457	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2458	uv.NewStyledString(
2459		s.CompactDetails.View.
2460			Width(area.Dx()).
2461			Render(
2462				lipgloss.JoinVertical(
2463					lipgloss.Left,
2464					detailsHeader,
2465					sections,
2466					version,
2467				),
2468			),
2469	).Draw(scr, area)
2470}
2471
2472func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2473	load := func() tea.Msg {
2474		prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2475		if err != nil {
2476			// TODO: make this better
2477			return uiutil.ReportError(err)()
2478		}
2479
2480		if prompt == "" {
2481			return nil
2482		}
2483		return sendMessageMsg{
2484			Content: prompt,
2485		}
2486	}
2487
2488	var cmds []tea.Cmd
2489	if cmd := m.dialog.StartLoading(); cmd != nil {
2490		cmds = append(cmds, cmd)
2491	}
2492	cmds = append(cmds, load, func() tea.Msg {
2493		return closeDialogMsg{}
2494	})
2495
2496	return tea.Sequence(cmds...)
2497}
2498
2499// renderLogo renders the Crush logo with the given styles and dimensions.
2500func renderLogo(t *styles.Styles, compact bool, width int) string {
2501	return logo.Render(version.Version, compact, logo.Opts{
2502		FieldColor:   t.LogoFieldColor,
2503		TitleColorA:  t.LogoTitleColorA,
2504		TitleColorB:  t.LogoTitleColorB,
2505		CharmColor:   t.LogoCharmColor,
2506		VersionColor: t.LogoVersionColor,
2507		Width:        width,
2508	})
2509}