ui.go

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