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