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		var (
 935			providerID   = msg.Model.Provider
 936			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
 937			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
 938		)
 939
 940		// Attempt to import GitHub Copilot tokens from VSCode if available.
 941		if isCopilot && !isConfigured() {
 942			config.Get().ImportCopilot()
 943		}
 944
 945		if !isConfigured() {
 946			m.dialog.CloseDialog(dialog.ModelsID)
 947			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
 948				cmds = append(cmds, cmd)
 949			}
 950			break
 951		}
 952
 953		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
 954			cmds = append(cmds, uiutil.ReportError(err))
 955		}
 956
 957		// XXX: Should this be in a separate goroutine?
 958		go m.com.App.UpdateAgentModel(context.TODO())
 959
 960		modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
 961		cmds = append(cmds, uiutil.ReportInfo(modelMsg))
 962		m.dialog.CloseDialog(dialog.APIKeyInputID)
 963		m.dialog.CloseDialog(dialog.OAuthID)
 964		m.dialog.CloseDialog(dialog.ModelsID)
 965		// TODO CHANGE
 966	case dialog.ActionPermissionResponse:
 967		m.dialog.CloseDialog(dialog.PermissionsID)
 968		switch msg.Action {
 969		case dialog.PermissionAllow:
 970			m.com.App.Permissions.Grant(msg.Permission)
 971		case dialog.PermissionAllowForSession:
 972			m.com.App.Permissions.GrantPersistent(msg.Permission)
 973		case dialog.PermissionDeny:
 974			m.com.App.Permissions.Deny(msg.Permission)
 975		}
 976
 977	case dialog.ActionRunCustomCommand:
 978		if len(msg.Arguments) > 0 && msg.Args == nil {
 979			m.dialog.CloseFrontDialog()
 980			argsDialog := dialog.NewArguments(
 981				m.com,
 982				"Custom Command Arguments",
 983				"",
 984				msg.Arguments,
 985				msg, // Pass the action as the result
 986			)
 987			m.dialog.OpenDialog(argsDialog)
 988			break
 989		}
 990		content := msg.Content
 991		if msg.Args != nil {
 992			content = substituteArgs(content, msg.Args)
 993		}
 994		cmds = append(cmds, m.sendMessage(content))
 995		m.dialog.CloseFrontDialog()
 996	case dialog.ActionRunMCPPrompt:
 997		if len(msg.Arguments) > 0 && msg.Args == nil {
 998			m.dialog.CloseFrontDialog()
 999			title := msg.Title
1000			if title == "" {
1001				title = "MCP Prompt Arguments"
1002			}
1003			argsDialog := dialog.NewArguments(
1004				m.com,
1005				title,
1006				msg.Description,
1007				msg.Arguments,
1008				msg, // Pass the action as the result
1009			)
1010			m.dialog.OpenDialog(argsDialog)
1011			break
1012		}
1013		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1014	default:
1015		cmds = append(cmds, uiutil.CmdHandler(msg))
1016	}
1017
1018	return tea.Batch(cmds...)
1019}
1020
1021// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1022func substituteArgs(content string, args map[string]string) string {
1023	for name, value := range args {
1024		placeholder := "$" + name
1025		content = strings.ReplaceAll(content, placeholder, value)
1026	}
1027	return content
1028}
1029
1030func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1031	switch provider.ID {
1032	case "hyper":
1033		return m.openOAuthHyperDialog(provider, model, modelType)
1034	case catwalk.InferenceProviderCopilot:
1035		return m.openOAuthCopilotDialog(provider, model, modelType)
1036	default:
1037		return m.openAPIKeyInputDialog(provider, model, modelType)
1038	}
1039}
1040
1041func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1042	if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
1043		m.dialog.BringToFront(dialog.APIKeyInputID)
1044		return nil
1045	}
1046
1047	apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType)
1048	if err != nil {
1049		return uiutil.ReportError(err)
1050	}
1051	m.dialog.OpenDialog(apiKeyInputDialog)
1052	return nil
1053}
1054
1055func (m *UI) openOAuthHyperDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1056	if m.dialog.ContainsDialog(dialog.OAuthID) {
1057		m.dialog.BringToFront(dialog.OAuthID)
1058		return nil
1059	}
1060
1061	oAuthDialog, err := dialog.NewOAuthHyper(m.com, provider, model, modelType)
1062	if err != nil {
1063		return uiutil.ReportError(err)
1064	}
1065	m.dialog.OpenDialog(oAuthDialog)
1066
1067	return oAuthDialog.Init()
1068}
1069
1070func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1071	if m.dialog.ContainsDialog(dialog.OAuthID) {
1072		m.dialog.BringToFront(dialog.OAuthID)
1073		return nil
1074	}
1075
1076	oAuthDialog, err := dialog.NewOAuthCopilot(m.com, provider, model, modelType)
1077	if err != nil {
1078		return uiutil.ReportError(err)
1079	}
1080	m.dialog.OpenDialog(oAuthDialog)
1081
1082	return oAuthDialog.Init()
1083}
1084
1085func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1086	var cmds []tea.Cmd
1087
1088	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1089		switch {
1090		case key.Matches(msg, m.keyMap.Help):
1091			m.status.ToggleHelp()
1092			m.updateLayoutAndSize()
1093			return true
1094		case key.Matches(msg, m.keyMap.Commands):
1095			if cmd := m.openCommandsDialog(); cmd != nil {
1096				cmds = append(cmds, cmd)
1097			}
1098			return true
1099		case key.Matches(msg, m.keyMap.Models):
1100			if cmd := m.openModelsDialog(); cmd != nil {
1101				cmds = append(cmds, cmd)
1102			}
1103			return true
1104		case key.Matches(msg, m.keyMap.Sessions):
1105			if cmd := m.openSessionsDialog(); cmd != nil {
1106				cmds = append(cmds, cmd)
1107			}
1108			return true
1109		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1110			m.detailsOpen = !m.detailsOpen
1111			m.updateLayoutAndSize()
1112			return true
1113		}
1114		return false
1115	}
1116
1117	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1118		// Always handle quit keys first
1119		if cmd := m.openQuitDialog(); cmd != nil {
1120			cmds = append(cmds, cmd)
1121		}
1122
1123		return tea.Batch(cmds...)
1124	}
1125
1126	// Route all messages to dialog if one is open.
1127	if m.dialog.HasDialogs() {
1128		return m.handleDialogMsg(msg)
1129	}
1130
1131	// Handle cancel key when agent is busy.
1132	if key.Matches(msg, m.keyMap.Chat.Cancel) {
1133		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1134			if cmd := m.cancelAgent(); cmd != nil {
1135				cmds = append(cmds, cmd)
1136			}
1137			return tea.Batch(cmds...)
1138		}
1139	}
1140
1141	switch m.state {
1142	case uiConfigure:
1143		return tea.Batch(cmds...)
1144	case uiInitialize:
1145		cmds = append(cmds, m.updateInitializeView(msg)...)
1146		return tea.Batch(cmds...)
1147	case uiChat, uiLanding:
1148		switch m.focus {
1149		case uiFocusEditor:
1150			// Handle completions if open.
1151			if m.completionsOpen {
1152				if msg, ok := m.completions.Update(msg); ok {
1153					switch msg := msg.(type) {
1154					case completions.SelectionMsg:
1155						// Handle file completion selection.
1156						if item, ok := msg.Value.(completions.FileCompletionValue); ok {
1157							cmds = append(cmds, m.insertFileCompletion(item.Path))
1158						}
1159						if !msg.Insert {
1160							m.closeCompletions()
1161						}
1162					case completions.ClosedMsg:
1163						m.completionsOpen = false
1164					}
1165					return tea.Batch(cmds...)
1166				}
1167			}
1168
1169			if ok := m.attachments.Update(msg); ok {
1170				return tea.Batch(cmds...)
1171			}
1172
1173			switch {
1174			case key.Matches(msg, m.keyMap.Editor.SendMessage):
1175				value := m.textarea.Value()
1176				if before, ok := strings.CutSuffix(value, "\\"); ok {
1177					// If the last character is a backslash, remove it and add a newline.
1178					m.textarea.SetValue(before)
1179					break
1180				}
1181
1182				// Otherwise, send the message
1183				m.textarea.Reset()
1184
1185				value = strings.TrimSpace(value)
1186				if value == "exit" || value == "quit" {
1187					return m.openQuitDialog()
1188				}
1189
1190				attachments := m.attachments.List()
1191				m.attachments.Reset()
1192				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1193					return nil
1194				}
1195
1196				m.randomizePlaceholders()
1197
1198				return m.sendMessage(value, attachments...)
1199			case key.Matches(msg, m.keyMap.Chat.NewSession):
1200				if m.session == nil || m.session.ID == "" {
1201					break
1202				}
1203				if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1204					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1205					break
1206				}
1207				m.newSession()
1208			case key.Matches(msg, m.keyMap.Tab):
1209				m.focus = uiFocusMain
1210				m.textarea.Blur()
1211				m.chat.Focus()
1212				m.chat.SetSelected(m.chat.Len() - 1)
1213			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1214				if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
1215					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1216					break
1217				}
1218				cmds = append(cmds, m.openEditor(m.textarea.Value()))
1219			case key.Matches(msg, m.keyMap.Editor.Newline):
1220				m.textarea.InsertRune('\n')
1221				m.closeCompletions()
1222			default:
1223				if handleGlobalKeys(msg) {
1224					// Handle global keys first before passing to textarea.
1225					break
1226				}
1227
1228				// Check for @ trigger before passing to textarea.
1229				curValue := m.textarea.Value()
1230				curIdx := len(curValue)
1231
1232				// Trigger completions on @.
1233				if msg.String() == "@" && !m.completionsOpen {
1234					// Only show if beginning of prompt or after whitespace.
1235					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1236						m.completionsOpen = true
1237						m.completionsQuery = ""
1238						m.completionsStartIndex = curIdx
1239						m.completionsPositionStart = m.completionsPosition()
1240						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1241						cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
1242					}
1243				}
1244
1245				// remove the details if they are open when user starts typing
1246				if m.detailsOpen {
1247					m.detailsOpen = false
1248					m.updateLayoutAndSize()
1249				}
1250
1251				ta, cmd := m.textarea.Update(msg)
1252				m.textarea = ta
1253				cmds = append(cmds, cmd)
1254
1255				// After updating textarea, check if we need to filter completions.
1256				// Skip filtering on the initial @ keystroke since items are loading async.
1257				if m.completionsOpen && msg.String() != "@" {
1258					newValue := m.textarea.Value()
1259					newIdx := len(newValue)
1260
1261					// Close completions if cursor moved before start.
1262					if newIdx <= m.completionsStartIndex {
1263						m.closeCompletions()
1264					} else if msg.String() == "space" {
1265						// Close on space.
1266						m.closeCompletions()
1267					} else {
1268						// Extract current word and filter.
1269						word := m.textareaWord()
1270						if strings.HasPrefix(word, "@") {
1271							m.completionsQuery = word[1:]
1272							m.completions.Filter(m.completionsQuery)
1273						} else if m.completionsOpen {
1274							m.closeCompletions()
1275						}
1276					}
1277				}
1278			}
1279		case uiFocusMain:
1280			switch {
1281			case key.Matches(msg, m.keyMap.Tab):
1282				m.focus = uiFocusEditor
1283				cmds = append(cmds, m.textarea.Focus())
1284				m.chat.Blur()
1285			case key.Matches(msg, m.keyMap.Chat.Expand):
1286				m.chat.ToggleExpandedSelectedItem()
1287			case key.Matches(msg, m.keyMap.Chat.Up):
1288				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1289					cmds = append(cmds, cmd)
1290				}
1291				if !m.chat.SelectedItemInView() {
1292					m.chat.SelectPrev()
1293					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1294						cmds = append(cmds, cmd)
1295					}
1296				}
1297			case key.Matches(msg, m.keyMap.Chat.Down):
1298				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1299					cmds = append(cmds, cmd)
1300				}
1301				if !m.chat.SelectedItemInView() {
1302					m.chat.SelectNext()
1303					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1304						cmds = append(cmds, cmd)
1305					}
1306				}
1307			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1308				m.chat.SelectPrev()
1309				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1310					cmds = append(cmds, cmd)
1311				}
1312			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1313				m.chat.SelectNext()
1314				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1315					cmds = append(cmds, cmd)
1316				}
1317			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1318				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1319					cmds = append(cmds, cmd)
1320				}
1321				m.chat.SelectFirstInView()
1322			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1323				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1324					cmds = append(cmds, cmd)
1325				}
1326				m.chat.SelectLastInView()
1327			case key.Matches(msg, m.keyMap.Chat.PageUp):
1328				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1329					cmds = append(cmds, cmd)
1330				}
1331				m.chat.SelectFirstInView()
1332			case key.Matches(msg, m.keyMap.Chat.PageDown):
1333				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1334					cmds = append(cmds, cmd)
1335				}
1336				m.chat.SelectLastInView()
1337			case key.Matches(msg, m.keyMap.Chat.Home):
1338				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1339					cmds = append(cmds, cmd)
1340				}
1341				m.chat.SelectFirst()
1342			case key.Matches(msg, m.keyMap.Chat.End):
1343				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1344					cmds = append(cmds, cmd)
1345				}
1346				m.chat.SelectLast()
1347			default:
1348				handleGlobalKeys(msg)
1349			}
1350		default:
1351			handleGlobalKeys(msg)
1352		}
1353	default:
1354		handleGlobalKeys(msg)
1355	}
1356
1357	return tea.Batch(cmds...)
1358}
1359
1360// Draw implements [uv.Drawable] and draws the UI model.
1361func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1362	layout := m.generateLayout(area.Dx(), area.Dy())
1363
1364	if m.layout != layout {
1365		m.layout = layout
1366		m.updateSize()
1367	}
1368
1369	// Clear the screen first
1370	screen.Clear(scr)
1371
1372	switch m.state {
1373	case uiConfigure:
1374		header := uv.NewStyledString(m.header)
1375		header.Draw(scr, layout.header)
1376
1377		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1378			Height(layout.main.Dy()).
1379			Background(lipgloss.ANSIColor(rand.Intn(256))).
1380			Render(" Configure ")
1381		main := uv.NewStyledString(mainView)
1382		main.Draw(scr, layout.main)
1383
1384	case uiInitialize:
1385		header := uv.NewStyledString(m.header)
1386		header.Draw(scr, layout.header)
1387
1388		main := uv.NewStyledString(m.initializeView())
1389		main.Draw(scr, layout.main)
1390
1391	case uiLanding:
1392		header := uv.NewStyledString(m.header)
1393		header.Draw(scr, layout.header)
1394		main := uv.NewStyledString(m.landingView())
1395		main.Draw(scr, layout.main)
1396
1397		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1398		editor.Draw(scr, layout.editor)
1399
1400	case uiChat:
1401		if m.isCompact {
1402			header := uv.NewStyledString(m.header)
1403			header.Draw(scr, layout.header)
1404		} else {
1405			m.drawSidebar(scr, layout.sidebar)
1406		}
1407
1408		m.chat.Draw(scr, layout.main)
1409
1410		editorWidth := scr.Bounds().Dx()
1411		if !m.isCompact {
1412			editorWidth -= layout.sidebar.Dx()
1413		}
1414		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1415		editor.Draw(scr, layout.editor)
1416
1417		// Draw details overlay in compact mode when open
1418		if m.isCompact && m.detailsOpen {
1419			m.drawSessionDetails(scr, layout.sessionDetails)
1420		}
1421	}
1422
1423	// Add status and help layer
1424	m.status.Draw(scr, layout.status)
1425
1426	// Draw completions popup if open
1427	if m.completionsOpen && m.completions.HasItems() {
1428		w, h := m.completions.Size()
1429		x := m.completionsPositionStart.X
1430		y := m.completionsPositionStart.Y - h
1431
1432		screenW := area.Dx()
1433		if x+w > screenW {
1434			x = screenW - w
1435		}
1436		x = max(0, x)
1437		y = max(0, y)
1438
1439		completionsView := uv.NewStyledString(m.completions.Render())
1440		completionsView.Draw(scr, image.Rectangle{
1441			Min: image.Pt(x, y),
1442			Max: image.Pt(x+w, y+h),
1443		})
1444	}
1445
1446	// Debugging rendering (visually see when the tui rerenders)
1447	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1448		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1449		debug := uv.NewStyledString(debugView.String())
1450		debug.Draw(scr, image.Rectangle{
1451			Min: image.Pt(4, 1),
1452			Max: image.Pt(8, 3),
1453		})
1454	}
1455
1456	// This needs to come last to overlay on top of everything. We always pass
1457	// the full screen bounds because the dialogs will position themselves
1458	// accordingly.
1459	if m.dialog.HasDialogs() {
1460		return m.dialog.Draw(scr, scr.Bounds())
1461	}
1462
1463	switch m.focus {
1464	case uiFocusEditor:
1465		if m.layout.editor.Dy() <= 0 {
1466			// Don't show cursor if editor is not visible
1467			return nil
1468		}
1469		if m.detailsOpen && m.isCompact {
1470			// Don't show cursor if details overlay is open
1471			return nil
1472		}
1473
1474		if m.textarea.Focused() {
1475			cur := m.textarea.Cursor()
1476			cur.X++ // Adjust for app margins
1477			cur.Y += m.layout.editor.Min.Y
1478			// Offset for attachment row if present.
1479			if len(m.attachments.List()) > 0 {
1480				cur.Y++
1481			}
1482			return cur
1483		}
1484	}
1485	return nil
1486}
1487
1488// View renders the UI model's view.
1489func (m *UI) View() tea.View {
1490	var v tea.View
1491	v.AltScreen = true
1492	v.BackgroundColor = m.com.Styles.Background
1493	v.MouseMode = tea.MouseModeCellMotion
1494
1495	canvas := uv.NewScreenBuffer(m.width, m.height)
1496	v.Cursor = m.Draw(canvas, canvas.Bounds())
1497
1498	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1499	contentLines := strings.Split(content, "\n")
1500	for i, line := range contentLines {
1501		// Trim trailing spaces for concise rendering
1502		contentLines[i] = strings.TrimRight(line, " ")
1503	}
1504
1505	content = strings.Join(contentLines, "\n")
1506
1507	v.Content = content
1508	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1509		// HACK: use a random percentage to prevent ghostty from hiding it
1510		// after a timeout.
1511		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1512	}
1513
1514	return v
1515}
1516
1517// ShortHelp implements [help.KeyMap].
1518func (m *UI) ShortHelp() []key.Binding {
1519	var binds []key.Binding
1520	k := &m.keyMap
1521	tab := k.Tab
1522	commands := k.Commands
1523	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1524		commands.SetHelp("/ or ctrl+p", "commands")
1525	}
1526
1527	switch m.state {
1528	case uiInitialize:
1529		binds = append(binds, k.Quit)
1530	case uiChat:
1531		// Show cancel binding if agent is busy.
1532		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1533			cancelBinding := k.Chat.Cancel
1534			if m.isCanceling {
1535				cancelBinding.SetHelp("esc", "press again to cancel")
1536			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1537				cancelBinding.SetHelp("esc", "clear queue")
1538			}
1539			binds = append(binds, cancelBinding)
1540		}
1541
1542		if m.focus == uiFocusEditor {
1543			tab.SetHelp("tab", "focus chat")
1544		} else {
1545			tab.SetHelp("tab", "focus editor")
1546		}
1547
1548		binds = append(binds,
1549			tab,
1550			commands,
1551			k.Models,
1552		)
1553
1554		switch m.focus {
1555		case uiFocusEditor:
1556			binds = append(binds,
1557				k.Editor.Newline,
1558			)
1559		case uiFocusMain:
1560			binds = append(binds,
1561				k.Chat.UpDown,
1562				k.Chat.UpDownOneItem,
1563				k.Chat.PageUp,
1564				k.Chat.PageDown,
1565				k.Chat.Copy,
1566			)
1567		}
1568	default:
1569		// TODO: other states
1570		// if m.session == nil {
1571		// no session selected
1572		binds = append(binds,
1573			commands,
1574			k.Models,
1575			k.Editor.Newline,
1576		)
1577	}
1578
1579	binds = append(binds,
1580		k.Quit,
1581		k.Help,
1582	)
1583
1584	return binds
1585}
1586
1587// FullHelp implements [help.KeyMap].
1588func (m *UI) FullHelp() [][]key.Binding {
1589	var binds [][]key.Binding
1590	k := &m.keyMap
1591	help := k.Help
1592	help.SetHelp("ctrl+g", "less")
1593	hasAttachments := len(m.attachments.List()) > 0
1594	hasSession := m.session != nil && m.session.ID != ""
1595	commands := k.Commands
1596	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1597		commands.SetHelp("/ or ctrl+p", "commands")
1598	}
1599
1600	switch m.state {
1601	case uiInitialize:
1602		binds = append(binds,
1603			[]key.Binding{
1604				k.Quit,
1605			})
1606	case uiChat:
1607		// Show cancel binding if agent is busy.
1608		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1609			cancelBinding := k.Chat.Cancel
1610			if m.isCanceling {
1611				cancelBinding.SetHelp("esc", "press again to cancel")
1612			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1613				cancelBinding.SetHelp("esc", "clear queue")
1614			}
1615			binds = append(binds, []key.Binding{cancelBinding})
1616		}
1617
1618		mainBinds := []key.Binding{}
1619		tab := k.Tab
1620		if m.focus == uiFocusEditor {
1621			tab.SetHelp("tab", "focus chat")
1622		} else {
1623			tab.SetHelp("tab", "focus editor")
1624		}
1625
1626		mainBinds = append(mainBinds,
1627			tab,
1628			commands,
1629			k.Models,
1630			k.Sessions,
1631		)
1632		if hasSession {
1633			mainBinds = append(mainBinds, k.Chat.NewSession)
1634		}
1635
1636		binds = append(binds, mainBinds)
1637
1638		switch m.focus {
1639		case uiFocusEditor:
1640			binds = append(binds,
1641				[]key.Binding{
1642					k.Editor.Newline,
1643					k.Editor.AddImage,
1644					k.Editor.MentionFile,
1645					k.Editor.OpenEditor,
1646				},
1647			)
1648			if hasAttachments {
1649				binds = append(binds,
1650					[]key.Binding{
1651						k.Editor.AttachmentDeleteMode,
1652						k.Editor.DeleteAllAttachments,
1653						k.Editor.Escape,
1654					},
1655				)
1656			}
1657		case uiFocusMain:
1658			binds = append(binds,
1659				[]key.Binding{
1660					k.Chat.UpDown,
1661					k.Chat.UpDownOneItem,
1662					k.Chat.PageUp,
1663					k.Chat.PageDown,
1664				},
1665				[]key.Binding{
1666					k.Chat.HalfPageUp,
1667					k.Chat.HalfPageDown,
1668					k.Chat.Home,
1669					k.Chat.End,
1670				},
1671				[]key.Binding{
1672					k.Chat.Copy,
1673					k.Chat.ClearHighlight,
1674				},
1675			)
1676		}
1677	default:
1678		if m.session == nil {
1679			// no session selected
1680			binds = append(binds,
1681				[]key.Binding{
1682					commands,
1683					k.Models,
1684					k.Sessions,
1685				},
1686				[]key.Binding{
1687					k.Editor.Newline,
1688					k.Editor.AddImage,
1689					k.Editor.MentionFile,
1690					k.Editor.OpenEditor,
1691				},
1692			)
1693			if hasAttachments {
1694				binds = append(binds,
1695					[]key.Binding{
1696						k.Editor.AttachmentDeleteMode,
1697						k.Editor.DeleteAllAttachments,
1698						k.Editor.Escape,
1699					},
1700				)
1701			}
1702			binds = append(binds,
1703				[]key.Binding{
1704					help,
1705				},
1706			)
1707		}
1708	}
1709
1710	binds = append(binds,
1711		[]key.Binding{
1712			help,
1713			k.Quit,
1714		},
1715	)
1716
1717	return binds
1718}
1719
1720// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
1721func (m *UI) toggleCompactMode() tea.Cmd {
1722	m.forceCompactMode = !m.forceCompactMode
1723
1724	err := m.com.Config().SetCompactMode(m.forceCompactMode)
1725	if err != nil {
1726		return uiutil.ReportError(err)
1727	}
1728
1729	m.handleCompactMode(m.width, m.height)
1730	m.updateLayoutAndSize()
1731
1732	return nil
1733}
1734
1735// handleCompactMode updates the UI state based on window size and compact mode setting.
1736func (m *UI) handleCompactMode(newWidth, newHeight int) {
1737	if m.state == uiChat {
1738		if m.forceCompactMode {
1739			m.isCompact = true
1740			return
1741		}
1742		if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
1743			m.isCompact = true
1744		} else {
1745			m.isCompact = false
1746		}
1747	}
1748}
1749
1750// updateLayoutAndSize updates the layout and sizes of UI components.
1751func (m *UI) updateLayoutAndSize() {
1752	m.layout = m.generateLayout(m.width, m.height)
1753	m.updateSize()
1754}
1755
1756// updateSize updates the sizes of UI components based on the current layout.
1757func (m *UI) updateSize() {
1758	// Set status width
1759	m.status.SetWidth(m.layout.status.Dx())
1760
1761	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1762	m.textarea.SetWidth(m.layout.editor.Dx())
1763	m.textarea.SetHeight(m.layout.editor.Dy())
1764
1765	// Handle different app states
1766	switch m.state {
1767	case uiConfigure, uiInitialize, uiLanding:
1768		m.renderHeader(false, m.layout.header.Dx())
1769
1770	case uiChat:
1771		if m.isCompact {
1772			m.renderHeader(true, m.layout.header.Dx())
1773		} else {
1774			m.renderSidebarLogo(m.layout.sidebar.Dx())
1775		}
1776	}
1777}
1778
1779// generateLayout calculates the layout rectangles for all UI components based
1780// on the current UI state and terminal dimensions.
1781func (m *UI) generateLayout(w, h int) layout {
1782	// The screen area we're working with
1783	area := image.Rect(0, 0, w, h)
1784
1785	// The help height
1786	helpHeight := 1
1787	// The editor height
1788	editorHeight := 5
1789	// The sidebar width
1790	sidebarWidth := 30
1791	// The header height
1792	const landingHeaderHeight = 4
1793
1794	var helpKeyMap help.KeyMap = m
1795	if m.status.ShowingAll() {
1796		for _, row := range helpKeyMap.FullHelp() {
1797			helpHeight = max(helpHeight, len(row))
1798		}
1799	}
1800
1801	// Add app margins
1802	appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
1803	appRect.Min.Y += 1
1804	appRect.Max.Y -= 1
1805	helpRect.Min.Y -= 1
1806	appRect.Min.X += 1
1807	appRect.Max.X -= 1
1808
1809	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
1810		// extra padding on left and right for these states
1811		appRect.Min.X += 1
1812		appRect.Max.X -= 1
1813	}
1814
1815	layout := layout{
1816		area:   area,
1817		status: helpRect,
1818	}
1819
1820	// Handle different app states
1821	switch m.state {
1822	case uiConfigure, uiInitialize:
1823		// Layout
1824		//
1825		// header
1826		// ------
1827		// main
1828		// ------
1829		// help
1830
1831		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1832		layout.header = headerRect
1833		layout.main = mainRect
1834
1835	case uiLanding:
1836		// Layout
1837		//
1838		// header
1839		// ------
1840		// main
1841		// ------
1842		// editor
1843		// ------
1844		// help
1845		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1846		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1847		// Remove extra padding from editor (but keep it for header and main)
1848		editorRect.Min.X -= 1
1849		editorRect.Max.X += 1
1850		layout.header = headerRect
1851		layout.main = mainRect
1852		layout.editor = editorRect
1853
1854	case uiChat:
1855		if m.isCompact {
1856			// Layout
1857			//
1858			// compact-header
1859			// ------
1860			// main
1861			// ------
1862			// editor
1863			// ------
1864			// help
1865			const compactHeaderHeight = 1
1866			headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
1867			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
1868			sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
1869			layout.sessionDetails = sessionDetailsArea
1870			layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
1871			// Add one line gap between header and main content
1872			mainRect.Min.Y += 1
1873			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1874			mainRect.Max.X -= 1 // Add padding right
1875			// Add bottom margin to main
1876			mainRect.Max.Y -= 1
1877			layout.header = headerRect
1878			layout.main = mainRect
1879			layout.editor = editorRect
1880		} else {
1881			// Layout
1882			//
1883			// ------|---
1884			// main  |
1885			// ------| side
1886			// editor|
1887			// ----------
1888			// help
1889
1890			mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1891			// Add padding left
1892			sideRect.Min.X += 1
1893			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1894			mainRect.Max.X -= 1 // Add padding right
1895			// Add bottom margin to main
1896			mainRect.Max.Y -= 1
1897			layout.sidebar = sideRect
1898			layout.main = mainRect
1899			layout.editor = editorRect
1900		}
1901	}
1902
1903	if !layout.editor.Empty() {
1904		// Add editor margins 1 top and bottom
1905		layout.editor.Min.Y += 1
1906		layout.editor.Max.Y -= 1
1907	}
1908
1909	return layout
1910}
1911
1912// layout defines the positioning of UI elements.
1913type layout struct {
1914	// area is the overall available area.
1915	area uv.Rectangle
1916
1917	// header is the header shown in special cases
1918	// e.x when the sidebar is collapsed
1919	// or when in the landing page
1920	// or in init/config
1921	header uv.Rectangle
1922
1923	// main is the area for the main pane. (e.x chat, configure, landing)
1924	main uv.Rectangle
1925
1926	// editor is the area for the editor pane.
1927	editor uv.Rectangle
1928
1929	// sidebar is the area for the sidebar.
1930	sidebar uv.Rectangle
1931
1932	// status is the area for the status view.
1933	status uv.Rectangle
1934
1935	// session details is the area for the session details overlay in compact mode.
1936	sessionDetails uv.Rectangle
1937}
1938
1939func (m *UI) openEditor(value string) tea.Cmd {
1940	tmpfile, err := os.CreateTemp("", "msg_*.md")
1941	if err != nil {
1942		return uiutil.ReportError(err)
1943	}
1944	defer tmpfile.Close() //nolint:errcheck
1945	if _, err := tmpfile.WriteString(value); err != nil {
1946		return uiutil.ReportError(err)
1947	}
1948	cmd, err := editor.Command(
1949		"crush",
1950		tmpfile.Name(),
1951		editor.AtPosition(
1952			m.textarea.Line()+1,
1953			m.textarea.Column()+1,
1954		),
1955	)
1956	if err != nil {
1957		return uiutil.ReportError(err)
1958	}
1959	return tea.ExecProcess(cmd, func(err error) tea.Msg {
1960		if err != nil {
1961			return uiutil.ReportError(err)
1962		}
1963		content, err := os.ReadFile(tmpfile.Name())
1964		if err != nil {
1965			return uiutil.ReportError(err)
1966		}
1967		if len(content) == 0 {
1968			return uiutil.ReportWarn("Message is empty")
1969		}
1970		os.Remove(tmpfile.Name())
1971		return openEditorMsg{
1972			Text: strings.TrimSpace(string(content)),
1973		}
1974	})
1975}
1976
1977// setEditorPrompt configures the textarea prompt function based on whether
1978// yolo mode is enabled.
1979func (m *UI) setEditorPrompt(yolo bool) {
1980	if yolo {
1981		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1982		return
1983	}
1984	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1985}
1986
1987// normalPromptFunc returns the normal editor prompt style ("  > " on first
1988// line, "::: " on subsequent lines).
1989func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1990	t := m.com.Styles
1991	if info.LineNumber == 0 {
1992		if info.Focused {
1993			return "  > "
1994		}
1995		return "::: "
1996	}
1997	if info.Focused {
1998		return t.EditorPromptNormalFocused.Render()
1999	}
2000	return t.EditorPromptNormalBlurred.Render()
2001}
2002
2003// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2004// and colored dots.
2005func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2006	t := m.com.Styles
2007	if info.LineNumber == 0 {
2008		if info.Focused {
2009			return t.EditorPromptYoloIconFocused.Render()
2010		} else {
2011			return t.EditorPromptYoloIconBlurred.Render()
2012		}
2013	}
2014	if info.Focused {
2015		return t.EditorPromptYoloDotsFocused.Render()
2016	}
2017	return t.EditorPromptYoloDotsBlurred.Render()
2018}
2019
2020// closeCompletions closes the completions popup and resets state.
2021func (m *UI) closeCompletions() {
2022	m.completionsOpen = false
2023	m.completionsQuery = ""
2024	m.completionsStartIndex = 0
2025	m.completions.Close()
2026}
2027
2028// insertFileCompletion inserts the selected file path into the textarea,
2029// replacing the @query, and adds the file as an attachment.
2030func (m *UI) insertFileCompletion(path string) tea.Cmd {
2031	value := m.textarea.Value()
2032	word := m.textareaWord()
2033
2034	// Find the @ and query to replace.
2035	if m.completionsStartIndex > len(value) {
2036		return nil
2037	}
2038
2039	// Build the new value: everything before @, the path, everything after query.
2040	endIdx := min(m.completionsStartIndex+len(word), len(value))
2041
2042	newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
2043	m.textarea.SetValue(newValue)
2044	m.textarea.MoveToEnd()
2045	m.textarea.InsertRune(' ')
2046
2047	return func() tea.Msg {
2048		absPath, _ := filepath.Abs(path)
2049		// Skip attachment if file was already read and hasn't been modified.
2050		lastRead := filetracker.LastReadTime(absPath)
2051		if !lastRead.IsZero() {
2052			if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2053				return nil
2054			}
2055		}
2056
2057		// Add file as attachment.
2058		content, err := os.ReadFile(path)
2059		if err != nil {
2060			// If it fails, let the LLM handle it later.
2061			return nil
2062		}
2063		filetracker.RecordRead(absPath)
2064
2065		return message.Attachment{
2066			FilePath: path,
2067			FileName: filepath.Base(path),
2068			MimeType: mimeOf(content),
2069			Content:  content,
2070		}
2071	}
2072}
2073
2074// completionsPosition returns the X and Y position for the completions popup.
2075func (m *UI) completionsPosition() image.Point {
2076	cur := m.textarea.Cursor()
2077	if cur == nil {
2078		return image.Point{
2079			X: m.layout.editor.Min.X,
2080			Y: m.layout.editor.Min.Y,
2081		}
2082	}
2083	return image.Point{
2084		X: cur.X + m.layout.editor.Min.X,
2085		Y: m.layout.editor.Min.Y + cur.Y,
2086	}
2087}
2088
2089// textareaWord returns the current word at the cursor position.
2090func (m *UI) textareaWord() string {
2091	return m.textarea.Word()
2092}
2093
2094// isWhitespace returns true if the byte is a whitespace character.
2095func isWhitespace(b byte) bool {
2096	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2097}
2098
2099// mimeOf detects the MIME type of the given content.
2100func mimeOf(content []byte) string {
2101	mimeBufferSize := min(512, len(content))
2102	return http.DetectContentType(content[:mimeBufferSize])
2103}
2104
2105var readyPlaceholders = [...]string{
2106	"Ready!",
2107	"Ready...",
2108	"Ready?",
2109	"Ready for instructions",
2110}
2111
2112var workingPlaceholders = [...]string{
2113	"Working!",
2114	"Working...",
2115	"Brrrrr...",
2116	"Prrrrrrrr...",
2117	"Processing...",
2118	"Thinking...",
2119}
2120
2121// randomizePlaceholders selects random placeholder text for the textarea's
2122// ready and working states.
2123func (m *UI) randomizePlaceholders() {
2124	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2125	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2126}
2127
2128// renderEditorView renders the editor view with attachments if any.
2129func (m *UI) renderEditorView(width int) string {
2130	if len(m.attachments.List()) == 0 {
2131		return m.textarea.View()
2132	}
2133	return lipgloss.JoinVertical(
2134		lipgloss.Top,
2135		m.attachments.Render(width),
2136		m.textarea.View(),
2137	)
2138}
2139
2140// renderHeader renders and caches the header logo at the specified width.
2141func (m *UI) renderHeader(compact bool, width int) {
2142	if compact && m.session != nil && m.com.App != nil {
2143		m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2144	} else {
2145		m.header = renderLogo(m.com.Styles, compact, width)
2146	}
2147}
2148
2149// renderSidebarLogo renders and caches the sidebar logo at the specified
2150// width.
2151func (m *UI) renderSidebarLogo(width int) {
2152	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2153}
2154
2155// sendMessage sends a message with the given content and attachments.
2156func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2157	if m.com.App.AgentCoordinator == nil {
2158		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2159	}
2160
2161	var cmds []tea.Cmd
2162	if m.session == nil || m.session.ID == "" {
2163		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2164		if err != nil {
2165			return uiutil.ReportError(err)
2166		}
2167		m.state = uiChat
2168		if m.forceCompactMode {
2169			m.isCompact = true
2170		}
2171		if newSession.ID != "" {
2172			m.session = &newSession
2173			cmds = append(cmds, m.loadSession(newSession.ID))
2174		}
2175	}
2176
2177	// Capture session ID to avoid race with main goroutine updating m.session.
2178	sessionID := m.session.ID
2179	cmds = append(cmds, func() tea.Msg {
2180		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2181		if err != nil {
2182			isCancelErr := errors.Is(err, context.Canceled)
2183			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2184			if isCancelErr || isPermissionErr {
2185				return nil
2186			}
2187			return uiutil.InfoMsg{
2188				Type: uiutil.InfoTypeError,
2189				Msg:  err.Error(),
2190			}
2191		}
2192		return nil
2193	})
2194	return tea.Batch(cmds...)
2195}
2196
2197const cancelTimerDuration = 2 * time.Second
2198
2199// cancelTimerCmd creates a command that expires the cancel timer.
2200func cancelTimerCmd() tea.Cmd {
2201	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2202		return cancelTimerExpiredMsg{}
2203	})
2204}
2205
2206// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2207// and starts a timer. The second press (before the timer expires) actually
2208// cancels the agent.
2209func (m *UI) cancelAgent() tea.Cmd {
2210	if m.session == nil || m.session.ID == "" {
2211		return nil
2212	}
2213
2214	coordinator := m.com.App.AgentCoordinator
2215	if coordinator == nil {
2216		return nil
2217	}
2218
2219	if m.isCanceling {
2220		// Second escape press - actually cancel the agent.
2221		m.isCanceling = false
2222		coordinator.Cancel(m.session.ID)
2223		return nil
2224	}
2225
2226	// Check if there are queued prompts - if so, clear the queue.
2227	if coordinator.QueuedPrompts(m.session.ID) > 0 {
2228		coordinator.ClearQueue(m.session.ID)
2229		return nil
2230	}
2231
2232	// First escape press - set canceling state and start timer.
2233	m.isCanceling = true
2234	return cancelTimerCmd()
2235}
2236
2237// openDialog opens a dialog by its ID.
2238func (m *UI) openDialog(id string) tea.Cmd {
2239	var cmds []tea.Cmd
2240	switch id {
2241	case dialog.SessionsID:
2242		if cmd := m.openSessionsDialog(); cmd != nil {
2243			cmds = append(cmds, cmd)
2244		}
2245	case dialog.ModelsID:
2246		if cmd := m.openModelsDialog(); cmd != nil {
2247			cmds = append(cmds, cmd)
2248		}
2249	case dialog.CommandsID:
2250		if cmd := m.openCommandsDialog(); cmd != nil {
2251			cmds = append(cmds, cmd)
2252		}
2253	case dialog.QuitID:
2254		if cmd := m.openQuitDialog(); cmd != nil {
2255			cmds = append(cmds, cmd)
2256		}
2257	default:
2258		// Unknown dialog
2259		break
2260	}
2261	return tea.Batch(cmds...)
2262}
2263
2264// openQuitDialog opens the quit confirmation dialog.
2265func (m *UI) openQuitDialog() tea.Cmd {
2266	if m.dialog.ContainsDialog(dialog.QuitID) {
2267		// Bring to front
2268		m.dialog.BringToFront(dialog.QuitID)
2269		return nil
2270	}
2271
2272	quitDialog := dialog.NewQuit(m.com)
2273	m.dialog.OpenDialog(quitDialog)
2274	return nil
2275}
2276
2277// openModelsDialog opens the models dialog.
2278func (m *UI) openModelsDialog() tea.Cmd {
2279	if m.dialog.ContainsDialog(dialog.ModelsID) {
2280		// Bring to front
2281		m.dialog.BringToFront(dialog.ModelsID)
2282		return nil
2283	}
2284
2285	modelsDialog, err := dialog.NewModels(m.com)
2286	if err != nil {
2287		return uiutil.ReportError(err)
2288	}
2289
2290	m.dialog.OpenDialog(modelsDialog)
2291
2292	return nil
2293}
2294
2295// openCommandsDialog opens the commands dialog.
2296func (m *UI) openCommandsDialog() tea.Cmd {
2297	if m.dialog.ContainsDialog(dialog.CommandsID) {
2298		// Bring to front
2299		m.dialog.BringToFront(dialog.CommandsID)
2300		return nil
2301	}
2302
2303	sessionID := ""
2304	if m.session != nil {
2305		sessionID = m.session.ID
2306	}
2307
2308	commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2309	if err != nil {
2310		return uiutil.ReportError(err)
2311	}
2312
2313	m.dialog.OpenDialog(commands)
2314
2315	return nil
2316}
2317
2318// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2319// it brings it to the front. Otherwise, it will list all the sessions and open
2320// the dialog.
2321func (m *UI) openSessionsDialog() tea.Cmd {
2322	if m.dialog.ContainsDialog(dialog.SessionsID) {
2323		// Bring to front
2324		m.dialog.BringToFront(dialog.SessionsID)
2325		return nil
2326	}
2327
2328	selectedSessionID := ""
2329	if m.session != nil {
2330		selectedSessionID = m.session.ID
2331	}
2332
2333	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2334	if err != nil {
2335		return uiutil.ReportError(err)
2336	}
2337
2338	m.dialog.OpenDialog(dialog)
2339
2340	return nil
2341}
2342
2343// openPermissionsDialog opens the permissions dialog for a permission request.
2344func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2345	// Close any existing permissions dialog first.
2346	m.dialog.CloseDialog(dialog.PermissionsID)
2347
2348	// Get diff mode from config.
2349	var opts []dialog.PermissionsOption
2350	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2351		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2352	}
2353
2354	permDialog := dialog.NewPermissions(m.com, perm, opts...)
2355	m.dialog.OpenDialog(permDialog)
2356	return nil
2357}
2358
2359// handlePermissionNotification updates tool items when permission state changes.
2360func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2361	toolItem := m.chat.MessageItem(notification.ToolCallID)
2362	if toolItem == nil {
2363		return
2364	}
2365
2366	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2367		if notification.Granted {
2368			permItem.SetStatus(chat.ToolStatusRunning)
2369		} else {
2370			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2371		}
2372	}
2373}
2374
2375// newSession clears the current session state and prepares for a new session.
2376// The actual session creation happens when the user sends their first message.
2377func (m *UI) newSession() {
2378	if m.session == nil || m.session.ID == "" {
2379		return
2380	}
2381
2382	m.session = nil
2383	m.sessionFiles = nil
2384	m.state = uiLanding
2385	m.focus = uiFocusEditor
2386	m.textarea.Focus()
2387	m.chat.Blur()
2388	m.chat.ClearMessages()
2389}
2390
2391// handlePasteMsg handles a paste message.
2392func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2393	if m.dialog.HasDialogs() {
2394		return m.handleDialogMsg(msg)
2395	}
2396
2397	if m.focus != uiFocusEditor {
2398		return nil
2399	}
2400
2401	// If pasted text has more than 2 newlines, treat it as a file attachment.
2402	if strings.Count(msg.Content, "\n") > 2 {
2403		return func() tea.Msg {
2404			content := []byte(msg.Content)
2405			if int64(len(content)) > maxAttachmentSize {
2406				return uiutil.ReportWarn("Paste is too big (>5mb)")
2407			}
2408			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2409			mimeBufferSize := min(512, len(content))
2410			mimeType := http.DetectContentType(content[:mimeBufferSize])
2411			return message.Attachment{
2412				FileName: name,
2413				FilePath: name,
2414				MimeType: mimeType,
2415				Content:  content,
2416			}
2417		}
2418	}
2419
2420	var cmd tea.Cmd
2421	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
2422	// Try to get an image.
2423	path, err := filepath.Abs(strings.TrimSpace(path))
2424	if err != nil {
2425		m.textarea, cmd = m.textarea.Update(msg)
2426		return cmd
2427	}
2428
2429	// Check if file has an allowed image extension.
2430	isAllowedType := false
2431	lowerPath := strings.ToLower(path)
2432	for _, ext := range allowedImageTypes {
2433		if strings.HasSuffix(lowerPath, ext) {
2434			isAllowedType = true
2435			break
2436		}
2437	}
2438	if !isAllowedType {
2439		m.textarea, cmd = m.textarea.Update(msg)
2440		return cmd
2441	}
2442
2443	return func() tea.Msg {
2444		fileInfo, err := os.Stat(path)
2445		if err != nil {
2446			return uiutil.ReportError(err)
2447		}
2448		if fileInfo.Size() > maxAttachmentSize {
2449			return uiutil.ReportWarn("File is too big (>5mb)")
2450		}
2451
2452		content, err := os.ReadFile(path)
2453		if err != nil {
2454			return uiutil.ReportError(err)
2455		}
2456
2457		mimeBufferSize := min(512, len(content))
2458		mimeType := http.DetectContentType(content[:mimeBufferSize])
2459		fileName := filepath.Base(path)
2460		return message.Attachment{
2461			FilePath: path,
2462			FileName: fileName,
2463			MimeType: mimeType,
2464			Content:  content,
2465		}
2466	}
2467}
2468
2469var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2470
2471func (m *UI) pasteIdx() int {
2472	result := 0
2473	for _, at := range m.attachments.List() {
2474		found := pasteRE.FindStringSubmatch(at.FileName)
2475		if len(found) == 0 {
2476			continue
2477		}
2478		idx, err := strconv.Atoi(found[1])
2479		if err == nil {
2480			result = max(result, idx)
2481		}
2482	}
2483	return result + 1
2484}
2485
2486// drawSessionDetails draws the session details in compact mode.
2487func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2488	if m.session == nil {
2489		return
2490	}
2491
2492	s := m.com.Styles
2493
2494	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2495	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2496
2497	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2498	blocks := []string{
2499		title,
2500		"",
2501		m.modelInfo(width),
2502		"",
2503	}
2504
2505	detailsHeader := lipgloss.JoinVertical(
2506		lipgloss.Left,
2507		blocks...,
2508	)
2509
2510	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2511
2512	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2513
2514	const maxSectionWidth = 50
2515	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2516	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
2517
2518	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2519	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2520	filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2521	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2522	uv.NewStyledString(
2523		s.CompactDetails.View.
2524			Width(area.Dx()).
2525			Render(
2526				lipgloss.JoinVertical(
2527					lipgloss.Left,
2528					detailsHeader,
2529					sections,
2530					version,
2531				),
2532			),
2533	).Draw(scr, area)
2534}
2535
2536func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2537	load := func() tea.Msg {
2538		prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2539		if err != nil {
2540			// TODO: make this better
2541			return uiutil.ReportError(err)()
2542		}
2543
2544		if prompt == "" {
2545			return nil
2546		}
2547		return sendMessageMsg{
2548			Content: prompt,
2549		}
2550	}
2551
2552	var cmds []tea.Cmd
2553	if cmd := m.dialog.StartLoading(); cmd != nil {
2554		cmds = append(cmds, cmd)
2555	}
2556	cmds = append(cmds, load, func() tea.Msg {
2557		return closeDialogMsg{}
2558	})
2559
2560	return tea.Sequence(cmds...)
2561}
2562
2563// renderLogo renders the Crush logo with the given styles and dimensions.
2564func renderLogo(t *styles.Styles, compact bool, width int) string {
2565	return logo.Render(version.Version, compact, logo.Opts{
2566		FieldColor:   t.LogoFieldColor,
2567		TitleColorA:  t.LogoTitleColorA,
2568		TitleColorB:  t.LogoTitleColorB,
2569		CharmColor:   t.LogoCharmColor,
2570		VersionColor: t.LogoVersionColor,
2571		Width:        width,
2572	})
2573}