ui.go

   1package model
   2
   3import (
   4	"context"
   5	"image"
   6	"math/rand"
   7	"os"
   8	"slices"
   9	"strings"
  10	"time"
  11
  12	"charm.land/bubbles/v2/help"
  13	"charm.land/bubbles/v2/key"
  14	"charm.land/bubbles/v2/textarea"
  15	tea "charm.land/bubbletea/v2"
  16	"charm.land/lipgloss/v2"
  17	"github.com/charmbracelet/crush/internal/agent"
  18	"github.com/charmbracelet/crush/internal/agent/tools"
  19	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
  20	"github.com/charmbracelet/crush/internal/app"
  21	"github.com/charmbracelet/crush/internal/config"
  22	"github.com/charmbracelet/crush/internal/history"
  23	"github.com/charmbracelet/crush/internal/message"
  24	"github.com/charmbracelet/crush/internal/pubsub"
  25	"github.com/charmbracelet/crush/internal/session"
  26	"github.com/charmbracelet/crush/internal/ui/chat"
  27	"github.com/charmbracelet/crush/internal/ui/common"
  28	"github.com/charmbracelet/crush/internal/ui/common/anim"
  29	"github.com/charmbracelet/crush/internal/ui/dialog"
  30	"github.com/charmbracelet/crush/internal/ui/logo"
  31	"github.com/charmbracelet/crush/internal/ui/styles"
  32	"github.com/charmbracelet/crush/internal/version"
  33	uv "github.com/charmbracelet/ultraviolet"
  34	"github.com/charmbracelet/ultraviolet/screen"
  35)
  36
  37// uiFocusState represents the current focus state of the UI.
  38type uiFocusState uint8
  39
  40// Possible uiFocusState values.
  41const (
  42	uiFocusNone uiFocusState = iota
  43	uiFocusEditor
  44	uiFocusMain
  45)
  46
  47type uiState uint8
  48
  49// Possible uiState values.
  50const (
  51	uiConfigure uiState = iota
  52	uiInitialize
  53	uiLanding
  54	uiChat
  55	uiChatCompact
  56)
  57
  58// sessionsLoadedMsg is a message indicating that sessions have been loaded.
  59type sessionsLoadedMsg struct {
  60	sessions []session.Session
  61}
  62
  63type sessionLoadedMsg struct {
  64	sess session.Session
  65}
  66
  67type sessionFilesLoadedMsg struct {
  68	files []SessionFile
  69}
  70
  71// UI represents the main user interface model.
  72type UI struct {
  73	com          *common.Common
  74	session      *session.Session
  75	sessionFiles []SessionFile
  76
  77	// The width and height of the terminal in cells.
  78	width  int
  79	height int
  80	layout layout
  81
  82	focus uiFocusState
  83	state uiState
  84
  85	keyMap KeyMap
  86	keyenh tea.KeyboardEnhancementsMsg
  87
  88	dialog *dialog.Overlay
  89	help   help.Model
  90
  91	// header is the last cached header logo
  92	header string
  93
  94	// sendProgressBar instructs the TUI to send progress bar updates to the
  95	// terminal.
  96	sendProgressBar bool
  97
  98	// QueryVersion instructs the TUI to query for the terminal version when it
  99	// starts.
 100	QueryVersion bool
 101
 102	// Editor components
 103	textarea textarea.Model
 104
 105	attachments []any // TODO: Implement attachments
 106
 107	readyPlaceholder   string
 108	workingPlaceholder string
 109
 110	// Chat components
 111	chat *chat.Chat
 112
 113	// onboarding state
 114	onboarding struct {
 115		yesInitializeSelected bool
 116	}
 117
 118	// lsp
 119	lspStates map[string]app.LSPClientInfo
 120
 121	// mcp
 122	mcpStates map[string]mcp.ClientInfo
 123
 124	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 125	sidebarLogo string
 126
 127	// lastUserMessageTime tracks the timestamp of the last user message for
 128	// calculating response duration.
 129	lastUserMessageTime int64
 130}
 131
 132// New creates a new instance of the [UI] model.
 133func New(com *common.Common) *UI {
 134	// Editor components
 135	ta := textarea.New()
 136	ta.SetStyles(com.Styles.TextArea)
 137	ta.ShowLineNumbers = false
 138	ta.CharLimit = -1
 139	ta.SetVirtualCursor(false)
 140	ta.Focus()
 141
 142	ch := chat.NewChat(com)
 143
 144	ui := &UI{
 145		com:      com,
 146		dialog:   dialog.NewOverlay(),
 147		keyMap:   DefaultKeyMap(),
 148		help:     help.New(),
 149		focus:    uiFocusNone,
 150		state:    uiConfigure,
 151		textarea: ta,
 152		chat:     ch,
 153	}
 154
 155	// set onboarding state defaults
 156	ui.onboarding.yesInitializeSelected = true
 157
 158	// If no provider is configured show the user the provider list
 159	if !com.Config().IsConfigured() {
 160		ui.state = uiConfigure
 161		// if the project needs initialization show the user the question
 162	} else if n, _ := config.ProjectNeedsInitialization(); n {
 163		ui.state = uiInitialize
 164		// otherwise go to the landing UI
 165	} else {
 166		ui.state = uiLanding
 167		ui.focus = uiFocusEditor
 168	}
 169
 170	ui.setEditorPrompt()
 171	ui.randomizePlaceholders()
 172	ui.textarea.Placeholder = ui.readyPlaceholder
 173	ui.help.Styles = com.Styles.Help
 174
 175	return ui
 176}
 177
 178// Init initializes the UI model.
 179func (m *UI) Init() tea.Cmd {
 180	var cmds []tea.Cmd
 181	if m.QueryVersion {
 182		cmds = append(cmds, tea.RequestTerminalVersion)
 183	}
 184	return tea.Batch(cmds...)
 185}
 186
 187// sessionLoadedDoneMsg indicates that session loading and message appending is
 188// done.
 189type sessionLoadedDoneMsg struct{}
 190
 191// Update handles updates to the UI model.
 192func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 193	var cmds []tea.Cmd
 194	switch msg := msg.(type) {
 195	case tea.EnvMsg:
 196		// Is this Windows Terminal?
 197		if !m.sendProgressBar {
 198			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 199		}
 200	case sessionsLoadedMsg:
 201		sessions := dialog.NewSessions(m.com, msg.sessions...)
 202		sessions.SetSize(min(120, m.width-8), 30)
 203		m.dialog.AddDialog(sessions)
 204	case dialog.SessionSelectedMsg:
 205		m.dialog.RemoveDialog(dialog.SessionDialogID)
 206		cmds = append(cmds,
 207			m.loadSession(msg.Session.ID),
 208			m.loadSessionFiles(msg.Session.ID),
 209		)
 210	case sessionLoadedMsg:
 211		m.state = uiChat
 212		m.session = &msg.sess
 213		// TODO: handle error.
 214		msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID)
 215
 216		m.chat.SetMessages(m.convertChatMessages(msgs)...)
 217
 218		// Notify that session loading is done to scroll to bottom. This is
 219		// needed because we need to draw the chat list first before we can
 220		// scroll to bottom.
 221		cmds = append(cmds, func() tea.Msg {
 222			return sessionLoadedDoneMsg{}
 223		})
 224	case sessionLoadedDoneMsg:
 225		m.chat.ScrollToBottom()
 226		m.chat.SelectLast()
 227	case sessionFilesLoadedMsg:
 228		m.sessionFiles = msg.files
 229	case pubsub.Event[history.File]:
 230		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 231	case pubsub.Event[app.LSPEvent]:
 232		m.lspStates = app.GetLSPStates()
 233	case pubsub.Event[mcp.Event]:
 234		m.mcpStates = mcp.GetStates()
 235	case pubsub.Event[message.Message]:
 236		cmds = append(cmds, m.handleMessageEvent(msg))
 237	case anim.StepMsg:
 238		// Forward animation updates to chat items.
 239		cmds = append(cmds, m.chat.UpdateItems(msg))
 240	case tea.TerminalVersionMsg:
 241		termVersion := strings.ToLower(msg.Name)
 242		// Only enable progress bar for the following terminals.
 243		if !m.sendProgressBar {
 244			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
 245		}
 246		return m, nil
 247	case tea.WindowSizeMsg:
 248		m.width, m.height = msg.Width, msg.Height
 249		m.updateLayoutAndSize()
 250	case tea.KeyboardEnhancementsMsg:
 251		m.keyenh = msg
 252		if msg.SupportsKeyDisambiguation() {
 253			m.keyMap.Models.SetHelp("ctrl+m", "models")
 254			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
 255		}
 256	case tea.MouseClickMsg:
 257		switch m.state {
 258		case uiChat:
 259			x, y := msg.X, msg.Y
 260			// Adjust for chat area position
 261			x -= m.layout.main.Min.X
 262			y -= m.layout.main.Min.Y
 263			m.chat.HandleMouseDown(x, y)
 264		}
 265
 266	case tea.MouseMotionMsg:
 267		switch m.state {
 268		case uiChat:
 269			if msg.Y <= 0 {
 270				m.chat.ScrollBy(-1)
 271				if !m.chat.SelectedItemInView() {
 272					m.chat.SelectPrev()
 273					m.chat.ScrollToSelected()
 274				}
 275			} else if msg.Y >= m.chat.Height()-1 {
 276				m.chat.ScrollBy(1)
 277				if !m.chat.SelectedItemInView() {
 278					m.chat.SelectNext()
 279					m.chat.ScrollToSelected()
 280				}
 281			}
 282
 283			x, y := msg.X, msg.Y
 284			// Adjust for chat area position
 285			x -= m.layout.main.Min.X
 286			y -= m.layout.main.Min.Y
 287			m.chat.HandleMouseDrag(x, y)
 288		}
 289
 290	case tea.MouseReleaseMsg:
 291		switch m.state {
 292		case uiChat:
 293			x, y := msg.X, msg.Y
 294			// Adjust for chat area position
 295			x -= m.layout.main.Min.X
 296			y -= m.layout.main.Min.Y
 297			m.chat.HandleMouseUp(x, y)
 298		}
 299	case tea.MouseWheelMsg:
 300		switch m.state {
 301		case uiChat:
 302			switch msg.Button {
 303			case tea.MouseWheelUp:
 304				m.chat.ScrollBy(-5)
 305				if !m.chat.SelectedItemInView() {
 306					m.chat.SelectPrev()
 307					m.chat.ScrollToSelected()
 308				}
 309			case tea.MouseWheelDown:
 310				m.chat.ScrollBy(5)
 311				if !m.chat.SelectedItemInView() {
 312					m.chat.SelectNext()
 313					m.chat.ScrollToSelected()
 314				}
 315			}
 316		}
 317	case tea.KeyPressMsg:
 318		cmds = append(cmds, m.handleKeyPressMsg(msg)...)
 319	}
 320
 321	// This logic gets triggered on any message type, but should it?
 322	switch m.focus {
 323	case uiFocusMain:
 324	case uiFocusEditor:
 325		// Textarea placeholder logic
 326		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 327			m.textarea.Placeholder = m.workingPlaceholder
 328		} else {
 329			m.textarea.Placeholder = m.readyPlaceholder
 330		}
 331		if m.com.App.Permissions.SkipRequests() {
 332			m.textarea.Placeholder = "Yolo mode!"
 333		}
 334	}
 335
 336	return m, tea.Batch(cmds...)
 337}
 338
 339func (m *UI) loadSession(sessionID string) tea.Cmd {
 340	return func() tea.Msg {
 341		// TODO: handle error
 342		session, _ := m.com.App.Sessions.Get(context.Background(), sessionID)
 343		return sessionLoadedMsg{session}
 344	}
 345}
 346
 347func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 348	handleQuitKeys := func(msg tea.KeyPressMsg) bool {
 349		switch {
 350		case key.Matches(msg, m.keyMap.Quit):
 351			if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
 352				m.dialog.AddDialog(dialog.NewQuit(m.com))
 353				return true
 354			}
 355		}
 356		return false
 357	}
 358
 359	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
 360		if handleQuitKeys(msg) {
 361			return true
 362		}
 363		switch {
 364		case key.Matches(msg, m.keyMap.Tab):
 365		case key.Matches(msg, m.keyMap.Help):
 366			m.help.ShowAll = !m.help.ShowAll
 367			m.updateLayoutAndSize()
 368			return true
 369		case key.Matches(msg, m.keyMap.Commands):
 370			// TODO: Implement me
 371		case key.Matches(msg, m.keyMap.Models):
 372			// TODO: Implement me
 373		case key.Matches(msg, m.keyMap.Sessions):
 374			if m.dialog.ContainsDialog(dialog.SessionDialogID) {
 375				// Bring to front
 376				m.dialog.BringToFront(dialog.SessionDialogID)
 377			} else {
 378				cmds = append(cmds, m.loadSessionsCmd)
 379			}
 380			return true
 381		}
 382		return false
 383	}
 384
 385	if m.dialog.HasDialogs() {
 386		// Always handle quit keys first
 387		if handleQuitKeys(msg) {
 388			return cmds
 389		}
 390
 391		updatedDialog, cmd := m.dialog.Update(msg)
 392		m.dialog = updatedDialog
 393		cmds = append(cmds, cmd)
 394		return cmds
 395	}
 396
 397	switch m.state {
 398	case uiChat:
 399		switch {
 400		case key.Matches(msg, m.keyMap.Chat.NewSession):
 401			if m.com.App.AgentCoordinator == nil {
 402				return cmds
 403			}
 404			if m.com.App.AgentCoordinator.IsBusy() {
 405				// TODO: Show warning message once we have a message/toast system
 406				return cmds
 407			}
 408			cmds = append(cmds, m.newSession())
 409		case key.Matches(msg, m.keyMap.Tab):
 410			if m.focus == uiFocusMain {
 411				m.focus = uiFocusEditor
 412				cmds = append(cmds, m.textarea.Focus())
 413				m.chat.Blur()
 414			} else {
 415				m.focus = uiFocusMain
 416				m.textarea.Blur()
 417				m.chat.Focus()
 418				m.chat.SetSelected(m.chat.Len() - 1)
 419			}
 420		case key.Matches(msg, m.keyMap.Chat.Up) && m.focus == uiFocusMain:
 421			m.chat.ScrollBy(-1)
 422			if !m.chat.SelectedItemInView() {
 423				m.chat.SelectPrev()
 424				m.chat.ScrollToSelected()
 425			}
 426		case key.Matches(msg, m.keyMap.Chat.Down) && m.focus == uiFocusMain:
 427			m.chat.ScrollBy(1)
 428			if !m.chat.SelectedItemInView() {
 429				m.chat.SelectNext()
 430				m.chat.ScrollToSelected()
 431			}
 432		case key.Matches(msg, m.keyMap.Chat.UpOneItem) && m.focus == uiFocusMain:
 433			m.chat.SelectPrev()
 434			m.chat.ScrollToSelected()
 435		case key.Matches(msg, m.keyMap.Chat.DownOneItem) && m.focus == uiFocusMain:
 436			m.chat.SelectNext()
 437			m.chat.ScrollToSelected()
 438		case key.Matches(msg, m.keyMap.Chat.HalfPageUp) && m.focus == uiFocusMain:
 439			m.chat.ScrollBy(-m.chat.Height() / 2)
 440			m.chat.SelectFirstInView()
 441		case key.Matches(msg, m.keyMap.Chat.HalfPageDown) && m.focus == uiFocusMain:
 442			m.chat.ScrollBy(m.chat.Height() / 2)
 443			m.chat.SelectLastInView()
 444		case key.Matches(msg, m.keyMap.Chat.PageUp) && m.focus == uiFocusMain:
 445			m.chat.ScrollBy(-m.chat.Height())
 446			m.chat.SelectFirstInView()
 447		case key.Matches(msg, m.keyMap.Chat.PageDown) && m.focus == uiFocusMain:
 448			m.chat.ScrollBy(m.chat.Height())
 449			m.chat.SelectLastInView()
 450		case key.Matches(msg, m.keyMap.Chat.Home) && m.focus == uiFocusMain:
 451			m.chat.ScrollToTop()
 452			m.chat.SelectFirst()
 453		case key.Matches(msg, m.keyMap.Chat.End) && m.focus == uiFocusMain:
 454			m.chat.ScrollToBottom()
 455			m.chat.SelectLast()
 456		default:
 457			// Try to handle key press in focused item (for expansion, etc.)
 458			if m.focus == uiFocusMain && m.chat.HandleKeyPress(msg) {
 459				return cmds
 460			}
 461			handleGlobalKeys(msg)
 462		}
 463	default:
 464		handleGlobalKeys(msg)
 465	}
 466
 467	cmds = append(cmds, m.updateFocused(msg)...)
 468	return cmds
 469}
 470
 471// Draw implements [tea.Layer] and draws the UI model.
 472func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 473	layout := generateLayout(m, area.Dx(), area.Dy())
 474
 475	if m.layout != layout {
 476		m.layout = layout
 477		m.updateSize()
 478	}
 479
 480	// Clear the screen first
 481	screen.Clear(scr)
 482
 483	switch m.state {
 484	case uiConfigure:
 485		header := uv.NewStyledString(m.header)
 486		header.Draw(scr, layout.header)
 487
 488		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
 489			Height(layout.main.Dy()).
 490			Background(lipgloss.ANSIColor(rand.Intn(256))).
 491			Render(" Configure ")
 492		main := uv.NewStyledString(mainView)
 493		main.Draw(scr, layout.main)
 494
 495	case uiInitialize:
 496		header := uv.NewStyledString(m.header)
 497		header.Draw(scr, layout.header)
 498
 499		main := uv.NewStyledString(m.initializeView())
 500		main.Draw(scr, layout.main)
 501
 502	case uiLanding:
 503		header := uv.NewStyledString(m.header)
 504		header.Draw(scr, layout.header)
 505		main := uv.NewStyledString(m.landingView())
 506		main.Draw(scr, layout.main)
 507
 508		editor := uv.NewStyledString(m.textarea.View())
 509		editor.Draw(scr, layout.editor)
 510
 511	case uiChat:
 512		m.chat.Draw(scr, layout.main)
 513
 514		header := uv.NewStyledString(m.header)
 515		header.Draw(scr, layout.header)
 516		m.drawSidebar(scr, layout.sidebar)
 517
 518		editor := uv.NewStyledString(m.textarea.View())
 519		editor.Draw(scr, layout.editor)
 520
 521	case uiChatCompact:
 522		header := uv.NewStyledString(m.header)
 523		header.Draw(scr, layout.header)
 524
 525		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
 526			Height(layout.main.Dy()).
 527			Background(lipgloss.ANSIColor(rand.Intn(256))).
 528			Render(" Compact Chat Messages ")
 529		main := uv.NewStyledString(mainView)
 530		main.Draw(scr, layout.main)
 531
 532		editor := uv.NewStyledString(m.textarea.View())
 533		editor.Draw(scr, layout.editor)
 534	}
 535
 536	// Add help layer
 537	help := uv.NewStyledString(m.help.View(m))
 538	help.Draw(scr, layout.help)
 539
 540	// Debugging rendering (visually see when the tui rerenders)
 541	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
 542		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
 543		debug := uv.NewStyledString(debugView.String())
 544		debug.Draw(scr, image.Rectangle{
 545			Min: image.Pt(4, 1),
 546			Max: image.Pt(8, 3),
 547		})
 548	}
 549
 550	// This needs to come last to overlay on top of everything
 551	if m.dialog.HasDialogs() {
 552		dialogLayers := m.dialog.Layers()
 553		layers := make([]*lipgloss.Layer, 0)
 554		for _, layer := range dialogLayers {
 555			if layer == nil {
 556				continue
 557			}
 558			layerW, layerH := layer.Width(), layer.Height()
 559			layerArea := common.CenterRect(area, layerW, layerH)
 560			layers = append(layers, layer.X(layerArea.Min.X).Y(layerArea.Min.Y))
 561		}
 562
 563		comp := lipgloss.NewCompositor(layers...)
 564		comp.Draw(scr, area)
 565	}
 566}
 567
 568// Cursor returns the cursor position and properties for the UI model. It
 569// returns nil if the cursor should not be shown.
 570func (m *UI) Cursor() *tea.Cursor {
 571	if m.layout.editor.Dy() <= 0 {
 572		// Don't show cursor if editor is not visible
 573		return nil
 574	}
 575	if m.focus == uiFocusEditor && m.textarea.Focused() {
 576		cur := m.textarea.Cursor()
 577		cur.X++ // Adjust for app margins
 578		cur.Y += m.layout.editor.Min.Y
 579		return cur
 580	}
 581	return nil
 582}
 583
 584// View renders the UI model's view.
 585func (m *UI) View() tea.View {
 586	var v tea.View
 587	v.AltScreen = true
 588	v.BackgroundColor = m.com.Styles.Background
 589	v.Cursor = m.Cursor()
 590	v.MouseMode = tea.MouseModeCellMotion
 591
 592	canvas := uv.NewScreenBuffer(m.width, m.height)
 593	m.Draw(canvas, canvas.Bounds())
 594
 595	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
 596	contentLines := strings.Split(content, "\n")
 597	for i, line := range contentLines {
 598		// Trim trailing spaces for concise rendering
 599		contentLines[i] = strings.TrimRight(line, " ")
 600	}
 601
 602	content = strings.Join(contentLines, "\n")
 603
 604	v.Content = content
 605	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
 606		// HACK: use a random percentage to prevent ghostty from hiding it
 607		// after a timeout.
 608		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
 609	}
 610
 611	return v
 612}
 613
 614// ShortHelp implements [help.KeyMap].
 615func (m *UI) ShortHelp() []key.Binding {
 616	var binds []key.Binding
 617	k := &m.keyMap
 618
 619	switch m.state {
 620	case uiInitialize:
 621		binds = append(binds, k.Quit)
 622	default:
 623		// TODO: other states
 624		// if m.session == nil {
 625		// no session selected
 626		binds = append(binds,
 627			k.Commands,
 628			k.Models,
 629			k.Editor.Newline,
 630			k.Quit,
 631			k.Help,
 632		)
 633		// }
 634		// else {
 635		// we have a session
 636		// }
 637
 638		// switch m.state {
 639		// case uiChat:
 640		// case uiEdit:
 641		// 	binds = append(binds,
 642		// 		k.Editor.AddFile,
 643		// 		k.Editor.SendMessage,
 644		// 		k.Editor.OpenEditor,
 645		// 		k.Editor.Newline,
 646		// 	)
 647		//
 648		// 	if len(m.attachments) > 0 {
 649		// 		binds = append(binds,
 650		// 			k.Editor.AttachmentDeleteMode,
 651		// 			k.Editor.DeleteAllAttachments,
 652		// 			k.Editor.Escape,
 653		// 		)
 654		// 	}
 655		// }
 656	}
 657
 658	return binds
 659}
 660
 661// FullHelp implements [help.KeyMap].
 662func (m *UI) FullHelp() [][]key.Binding {
 663	var binds [][]key.Binding
 664	k := &m.keyMap
 665	help := k.Help
 666	help.SetHelp("ctrl+g", "less")
 667
 668	switch m.state {
 669	case uiInitialize:
 670		binds = append(binds,
 671			[]key.Binding{
 672				k.Quit,
 673			})
 674	default:
 675		if m.session == nil {
 676			// no session selected
 677			binds = append(binds,
 678				[]key.Binding{
 679					k.Commands,
 680					k.Models,
 681					k.Sessions,
 682				},
 683				[]key.Binding{
 684					k.Editor.Newline,
 685					k.Editor.AddImage,
 686					k.Editor.MentionFile,
 687					k.Editor.OpenEditor,
 688				},
 689				[]key.Binding{
 690					help,
 691				},
 692			)
 693		}
 694		// else {
 695		// we have a session
 696		// }
 697	}
 698
 699	// switch m.state {
 700	// case uiChat:
 701	// case uiEdit:
 702	// 	binds = append(binds, m.ShortHelp())
 703	// }
 704
 705	return binds
 706}
 707
 708// updateFocused updates the focused model (chat or editor) with the given message
 709// and appends any resulting commands to the cmds slice.
 710func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 711	switch m.state {
 712	case uiConfigure:
 713		return cmds
 714	case uiInitialize:
 715		return append(cmds, m.updateInitializeView(msg)...)
 716	case uiChat, uiLanding, uiChatCompact:
 717		switch m.focus {
 718		case uiFocusMain:
 719		case uiFocusEditor:
 720			switch {
 721			case key.Matches(msg, m.keyMap.Editor.SendMessage):
 722				text := strings.TrimSpace(m.textarea.Value())
 723				if text != "" {
 724					cmds = append(cmds, m.sendMessage(text, nil))
 725				}
 726				return cmds
 727			case key.Matches(msg, m.keyMap.Editor.Newline):
 728				m.textarea.InsertRune('\n')
 729			}
 730
 731			ta, cmd := m.textarea.Update(msg)
 732			m.textarea = ta
 733			cmds = append(cmds, cmd)
 734			return cmds
 735		}
 736	}
 737	return cmds
 738}
 739
 740// updateLayoutAndSize updates the layout and sizes of UI components.
 741func (m *UI) updateLayoutAndSize() {
 742	m.layout = generateLayout(m, m.width, m.height)
 743	m.updateSize()
 744}
 745
 746// updateSize updates the sizes of UI components based on the current layout.
 747func (m *UI) updateSize() {
 748	// Set help width
 749	m.help.SetWidth(m.layout.help.Dx())
 750
 751	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
 752	m.textarea.SetWidth(m.layout.editor.Dx())
 753	m.textarea.SetHeight(m.layout.editor.Dy())
 754
 755	// Handle different app states
 756	switch m.state {
 757	case uiConfigure, uiInitialize, uiLanding:
 758		m.renderHeader(false, m.layout.header.Dx())
 759
 760	case uiChat:
 761		m.renderSidebarLogo(m.layout.sidebar.Dx())
 762
 763	case uiChatCompact:
 764		// TODO: set the width and heigh of the chat component
 765		m.renderHeader(true, m.layout.header.Dx())
 766	}
 767}
 768
 769// generateLayout calculates the layout rectangles for all UI components based
 770// on the current UI state and terminal dimensions.
 771func generateLayout(m *UI, w, h int) layout {
 772	// The screen area we're working with
 773	area := image.Rect(0, 0, w, h)
 774
 775	// The help height
 776	helpHeight := 1
 777	// The editor height
 778	editorHeight := 5
 779	// The sidebar width
 780	sidebarWidth := 30
 781	// The header height
 782	// TODO: handle compact
 783	headerHeight := 4
 784
 785	var helpKeyMap help.KeyMap = m
 786	if m.help.ShowAll {
 787		for _, row := range helpKeyMap.FullHelp() {
 788			helpHeight = max(helpHeight, len(row))
 789		}
 790	}
 791
 792	// Add app margins
 793	appRect := area
 794	appRect.Min.X += 1
 795	appRect.Min.Y += 1
 796	appRect.Max.X -= 1
 797	appRect.Max.Y -= 1
 798
 799	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
 800		// extra padding on left and right for these states
 801		appRect.Min.X += 1
 802		appRect.Max.X -= 1
 803	}
 804
 805	appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
 806
 807	layout := layout{
 808		area: area,
 809		help: helpRect,
 810	}
 811
 812	// Handle different app states
 813	switch m.state {
 814	case uiConfigure, uiInitialize:
 815		// Layout
 816		//
 817		// header
 818		// ------
 819		// main
 820		// ------
 821		// help
 822
 823		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
 824		layout.header = headerRect
 825		layout.main = mainRect
 826
 827	case uiLanding:
 828		// Layout
 829		//
 830		// header
 831		// ------
 832		// main
 833		// ------
 834		// editor
 835		// ------
 836		// help
 837		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
 838		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
 839		// Remove extra padding from editor (but keep it for header and main)
 840		editorRect.Min.X -= 1
 841		editorRect.Max.X += 1
 842		layout.header = headerRect
 843		layout.main = mainRect
 844		layout.editor = editorRect
 845
 846	case uiChat:
 847		// Layout
 848		//
 849		// ------|---
 850		// main  |
 851		// ------| side
 852		// editor|
 853		// ----------
 854		// help
 855
 856		mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
 857		// Add padding left
 858		sideRect.Min.X += 1
 859		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
 860		mainRect.Max.X -= 1 // Add padding right
 861		// Add bottom margin to main
 862		mainRect.Max.Y -= 1
 863		layout.sidebar = sideRect
 864		layout.main = mainRect
 865		layout.editor = editorRect
 866
 867	case uiChatCompact:
 868		// Layout
 869		//
 870		// compact-header
 871		// ------
 872		// main
 873		// ------
 874		// editor
 875		// ------
 876		// help
 877		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
 878		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
 879		layout.header = headerRect
 880		layout.main = mainRect
 881		layout.editor = editorRect
 882	}
 883
 884	if !layout.editor.Empty() {
 885		// Add editor margins 1 top and bottom
 886		layout.editor.Min.Y += 1
 887		layout.editor.Max.Y -= 1
 888	}
 889
 890	return layout
 891}
 892
 893// layout defines the positioning of UI elements.
 894type layout struct {
 895	// area is the overall available area.
 896	area uv.Rectangle
 897
 898	// header is the header shown in special cases
 899	// e.x when the sidebar is collapsed
 900	// or when in the landing page
 901	// or in init/config
 902	header uv.Rectangle
 903
 904	// main is the area for the main pane. (e.x chat, configure, landing)
 905	main uv.Rectangle
 906
 907	// editor is the area for the editor pane.
 908	editor uv.Rectangle
 909
 910	// sidebar is the area for the sidebar.
 911	sidebar uv.Rectangle
 912
 913	// help is the area for the help view.
 914	help uv.Rectangle
 915}
 916
 917// setEditorPrompt configures the textarea prompt function based on whether
 918// yolo mode is enabled.
 919func (m *UI) setEditorPrompt() {
 920	if m.com.App.Permissions.SkipRequests() {
 921		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
 922		return
 923	}
 924	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
 925}
 926
 927// normalPromptFunc returns the normal editor prompt style ("  > " on first
 928// line, "::: " on subsequent lines).
 929func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
 930	t := m.com.Styles
 931	if info.LineNumber == 0 {
 932		return "  > "
 933	}
 934	if info.Focused {
 935		return t.EditorPromptNormalFocused.Render()
 936	}
 937	return t.EditorPromptNormalBlurred.Render()
 938}
 939
 940// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
 941// and colored dots.
 942func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
 943	t := m.com.Styles
 944	if info.LineNumber == 0 {
 945		if info.Focused {
 946			return t.EditorPromptYoloIconFocused.Render()
 947		} else {
 948			return t.EditorPromptYoloIconBlurred.Render()
 949		}
 950	}
 951	if info.Focused {
 952		return t.EditorPromptYoloDotsFocused.Render()
 953	}
 954	return t.EditorPromptYoloDotsBlurred.Render()
 955}
 956
 957var readyPlaceholders = [...]string{
 958	"Ready!",
 959	"Ready...",
 960	"Ready?",
 961	"Ready for instructions",
 962}
 963
 964var workingPlaceholders = [...]string{
 965	"Working!",
 966	"Working...",
 967	"Brrrrr...",
 968	"Prrrrrrrr...",
 969	"Processing...",
 970	"Thinking...",
 971}
 972
 973// randomizePlaceholders selects random placeholder text for the textarea's
 974// ready and working states.
 975func (m *UI) randomizePlaceholders() {
 976	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
 977	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
 978}
 979
 980// renderHeader renders and caches the header logo at the specified width.
 981func (m *UI) renderHeader(compact bool, width int) {
 982	// TODO: handle the compact case differently
 983	m.header = renderLogo(m.com.Styles, compact, width)
 984}
 985
 986// renderSidebarLogo renders and caches the sidebar logo at the specified
 987// width.
 988func (m *UI) renderSidebarLogo(width int) {
 989	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
 990}
 991
 992// loadSessionsCmd loads the list of sessions and returns a command that sends
 993// a sessionFilesLoadedMsg when done.
 994func (m *UI) loadSessionsCmd() tea.Msg {
 995	allSessions, _ := m.com.App.Sessions.List(context.TODO())
 996	return sessionsLoadedMsg{sessions: allSessions}
 997}
 998
 999// convertChatMessages converts messages to chat message items
1000func (m *UI) convertChatMessages(msgs []message.Message) []chat.MessageItem {
1001	items := make([]chat.MessageItem, 0)
1002
1003	// Build tool result map for efficient lookup.
1004	toolResultMap := m.buildToolResultMap(msgs)
1005
1006	var lastUserMessageTime time.Time
1007
1008	for _, msg := range msgs {
1009		switch msg.Role {
1010		case message.User:
1011			lastUserMessageTime = time.Unix(msg.CreatedAt, 0)
1012			items = append(items, chat.NewUserMessage(msg.ID, msg.Content().Text, msg.BinaryContent(), m.com.Styles))
1013		case message.Assistant:
1014			// Add assistant message and its tool calls.
1015			assistantItems := m.convertAssistantMessage(msg, toolResultMap)
1016			items = append(items, assistantItems...)
1017
1018			// Add section separator if assistant finished with EndTurn.
1019			if msg.FinishReason() == message.FinishReasonEndTurn {
1020				modelName := m.getModelName(msg)
1021				items = append(items, chat.NewSectionItem(msg, lastUserMessageTime, modelName, m.com.Styles))
1022			}
1023		}
1024	}
1025	return items
1026}
1027
1028// getModelName returns the display name for a model, or "Unknown Model" if not found.
1029func (m *UI) getModelName(msg message.Message) string {
1030	model := m.com.Config().GetModel(msg.Provider, msg.Model)
1031	if model == nil {
1032		return "Unknown Model"
1033	}
1034	return model.Name
1035}
1036
1037// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
1038func (m *UI) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
1039	toolResultMap := make(map[string]message.ToolResult)
1040	for _, msg := range messages {
1041		for _, tr := range msg.ToolResults() {
1042			toolResultMap[tr.ToolCallID] = tr
1043		}
1044	}
1045	return toolResultMap
1046}
1047
1048// convertAssistantMessage converts an assistant message and its tool calls to UI items.
1049func (m *UI) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []chat.MessageItem {
1050	var items []chat.MessageItem
1051
1052	// Add assistant text/thinking message if it has content.
1053	content := strings.TrimSpace(msg.Content().Text)
1054	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
1055	isError := msg.FinishReason() == message.FinishReasonError
1056	isCancelled := msg.FinishReason() == message.FinishReasonCanceled
1057
1058	hasToolCalls := len(msg.ToolCalls()) > 0
1059	reasoning := msg.ReasoningContent()
1060
1061	// Show when: no tool calls yet, OR has content, OR has thinking, OR is in thinking state.
1062	shouldShow := !hasToolCalls || content != "" || thinking != "" || msg.IsThinking()
1063	if shouldShow || isError || isCancelled {
1064		var finish message.Finish
1065		if fp := msg.FinishPart(); fp != nil {
1066			finish = *fp
1067		}
1068
1069		items = append(items, chat.NewAssistantMessage(
1070			msg.ID,
1071			content,
1072			thinking,
1073			msg.IsFinished(),
1074			finish,
1075			hasToolCalls,
1076			msg.IsSummaryMessage,
1077			reasoning.StartedAt,
1078			reasoning.FinishedAt,
1079			m.com.Styles,
1080		))
1081	}
1082
1083	// Add tool call items.
1084	for _, tc := range msg.ToolCalls() {
1085		ctx := m.buildToolCallContext(tc, msg, toolResultMap)
1086
1087		// Handle nested tool calls for agent/agentic_fetch.
1088		if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
1089			ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID)
1090		}
1091
1092		items = append(items, chat.NewToolItem(ctx))
1093	}
1094
1095	return items
1096}
1097
1098// buildToolCallContext creates a ToolCallContext from a tool call and its result.
1099func (m *UI) buildToolCallContext(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) chat.ToolCallContext {
1100	ctx := chat.ToolCallContext{
1101		Call:      tc,
1102		Styles:    m.com.Styles,
1103		IsNested:  false,
1104		Cancelled: msg.FinishReason() == message.FinishReasonCanceled,
1105	}
1106
1107	// Add tool result if available.
1108	if tr, ok := toolResultMap[tc.ID]; ok {
1109		ctx.Result = &tr
1110	}
1111
1112	// TODO: Add permission tracking when we have permission service.
1113	// ctx.PermissionRequested = ...
1114	// ctx.PermissionGranted = ...
1115
1116	return ctx
1117}
1118
1119// loadNestedToolCalls loads nested tool calls for agent/agentic_fetch tools.
1120func (m *UI) loadNestedToolCalls(msgID, toolCallID string) []chat.ToolCallContext {
1121	agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(msgID, toolCallID)
1122	nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
1123	if err != nil || len(nestedMsgs) == 0 {
1124		return nil
1125	}
1126
1127	// Build nested tool result map.
1128	nestedToolResultMap := m.buildToolResultMap(nestedMsgs)
1129
1130	var nestedContexts []chat.ToolCallContext
1131	for _, nestedMsg := range nestedMsgs {
1132		for _, nestedTC := range nestedMsg.ToolCalls() {
1133			ctx := chat.ToolCallContext{
1134				Call:      nestedTC,
1135				Styles:    m.com.Styles,
1136				IsNested:  true,
1137				Cancelled: nestedMsg.FinishReason() == message.FinishReasonCanceled,
1138			}
1139
1140			if tr, ok := nestedToolResultMap[nestedTC.ID]; ok {
1141				ctx.Result = &tr
1142			}
1143
1144			nestedContexts = append(nestedContexts, ctx)
1145		}
1146	}
1147
1148	return nestedContexts
1149}
1150
1151// newSession clears the current session and resets the UI to a fresh state.
1152func (m *UI) newSession() tea.Cmd {
1153	if m.session == nil || m.session.ID == "" {
1154		return nil
1155	}
1156
1157	m.session = nil
1158	m.state = uiLanding
1159	m.focus = uiFocusEditor
1160	m.chat.SetMessages()
1161	m.sessionFiles = nil
1162	return m.textarea.Focus()
1163}
1164
1165// renderLogo renders the Crush logo with the given styles and dimensions.
1166func renderLogo(t *styles.Styles, compact bool, width int) string {
1167	return logo.Render(version.Version, compact, logo.Opts{
1168		FieldColor:   t.LogoFieldColor,
1169		TitleColorA:  t.LogoTitleColorA,
1170		TitleColorB:  t.LogoTitleColorB,
1171		CharmColor:   t.LogoCharmColor,
1172		VersionColor: t.LogoVersionColor,
1173		Width:        width,
1174	})
1175}
1176
1177// -----------------------------------------------------------------------------
1178// Message Event Handling
1179// -----------------------------------------------------------------------------
1180
1181// handleMessageEvent processes pubsub message events (created/updated/deleted).
1182func (m *UI) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
1183	// Ignore events for other sessions.
1184	if m.session == nil || event.Payload.SessionID != m.session.ID {
1185		return m.handleChildSessionEvent(event)
1186	}
1187
1188	switch event.Type {
1189	case pubsub.CreatedEvent:
1190		if m.chat.GetMessage(event.Payload.ID) != nil {
1191			return nil // Already exists.
1192		}
1193		return m.handleNewMessage(event.Payload)
1194	case pubsub.UpdatedEvent:
1195		return m.handleUpdateMessage(event.Payload)
1196	case pubsub.DeletedEvent:
1197		m.chat.DeleteMessage(event.Payload.ID)
1198	}
1199	return nil
1200}
1201
1202// handleChildSessionEvent handles messages from child sessions (agent tools).
1203func (m *UI) handleChildSessionEvent(event pubsub.Event[message.Message]) tea.Cmd {
1204	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1205		return nil
1206	}
1207
1208	// Check if this is an agent tool session.
1209	childSessionID := event.Payload.SessionID
1210	parentMessageID, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
1211	if !ok {
1212		return nil
1213	}
1214
1215	// Find and update the parent tool call with new nested calls.
1216	if tool := m.chat.GetToolItem(toolCallID); tool != nil {
1217		tool.SetNestedCalls(m.loadNestedToolCalls(parentMessageID, toolCallID))
1218		m.chat.InvalidateItem(toolCallID)
1219	}
1220	return nil
1221}
1222
1223// handleNewMessage routes new messages to appropriate handlers based on role.
1224func (m *UI) handleNewMessage(msg message.Message) tea.Cmd {
1225	switch msg.Role {
1226	case message.User:
1227		return m.handleNewUserMessage(msg)
1228	case message.Assistant:
1229		return m.handleNewAssistantMessage(msg)
1230	case message.Tool:
1231		return m.handleToolResultMessage(msg)
1232	}
1233	return nil
1234}
1235
1236// handleNewUserMessage adds a new user message to the chat.
1237func (m *UI) handleNewUserMessage(msg message.Message) tea.Cmd {
1238	m.lastUserMessageTime = msg.CreatedAt
1239	userItem := chat.NewUserMessage(msg.ID, msg.Content().Text, msg.BinaryContent(), m.com.Styles)
1240	m.chat.AppendMessages(userItem)
1241	m.chat.ScrollToBottom()
1242	return nil
1243}
1244
1245// handleNewAssistantMessage adds a new assistant message and its tool calls.
1246func (m *UI) handleNewAssistantMessage(msg message.Message) tea.Cmd {
1247	var cmds []tea.Cmd
1248
1249	content := strings.TrimSpace(msg.Content().Text)
1250	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
1251	hasToolCalls := len(msg.ToolCalls()) > 0
1252
1253	// Add assistant message if it has content to display.
1254	if m.shouldShowAssistantMessage(msg) {
1255		assistantItem := m.createAssistantItem(msg, content, thinking, hasToolCalls)
1256		m.chat.AppendMessages(assistantItem)
1257		cmds = append(cmds, assistantItem.InitAnimation())
1258	}
1259
1260	// Add tool call items.
1261	for _, tc := range msg.ToolCalls() {
1262		ctx := m.buildToolCallContext(tc, msg, nil)
1263		if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
1264			ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID)
1265		}
1266		toolItem := chat.NewToolItem(ctx)
1267		m.chat.AppendMessages(toolItem)
1268		if animatable, ok := toolItem.(chat.Animatable); ok {
1269			cmds = append(cmds, animatable.InitAnimation())
1270		}
1271	}
1272
1273	m.chat.ScrollToBottom()
1274	return tea.Batch(cmds...)
1275}
1276
1277// handleUpdateMessage routes update messages based on role.
1278func (m *UI) handleUpdateMessage(msg message.Message) tea.Cmd {
1279	switch msg.Role {
1280	case message.Assistant:
1281		return m.handleUpdateAssistantMessage(msg)
1282	case message.Tool:
1283		return m.handleToolResultMessage(msg)
1284	}
1285	return nil
1286}
1287
1288// handleUpdateAssistantMessage updates existing assistant message and tool calls.
1289func (m *UI) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
1290	var cmds []tea.Cmd
1291
1292	content := strings.TrimSpace(msg.Content().Text)
1293	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
1294	hasToolCalls := len(msg.ToolCalls()) > 0
1295	shouldShow := m.shouldShowAssistantMessage(msg)
1296
1297	// Update or create/delete assistant message item.
1298	existingItem := m.chat.GetMessage(msg.ID)
1299	if existingItem != nil {
1300		if shouldShow {
1301			// Update existing message.
1302			if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
1303				assistantItem.SetContent(content, thinking, msg.IsFinished(), msg.FinishPart(), hasToolCalls, msg.IsSummaryMessage, msg.ReasoningContent())
1304				m.chat.InvalidateItem(msg.ID)
1305			}
1306
1307			// Add section separator when assistant finishes with EndTurn.
1308			if msg.FinishReason() == message.FinishReasonEndTurn {
1309				modelName := m.getModelName(msg)
1310				sectionItem := chat.NewSectionItem(msg, time.Unix(m.lastUserMessageTime, 0), modelName, m.com.Styles)
1311				m.chat.AppendMessages(sectionItem)
1312			}
1313		} else if hasToolCalls && content == "" && thinking == "" {
1314			// Remove if it's now just tool calls with no content.
1315			m.chat.DeleteMessage(msg.ID)
1316		}
1317	}
1318
1319	// Update existing or add new tool calls.
1320	for _, tc := range msg.ToolCalls() {
1321		if tool := m.chat.GetToolItem(tc.ID); tool != nil {
1322			// Update existing tool call.
1323			tool.UpdateCall(tc)
1324			if msg.FinishReason() == message.FinishReasonCanceled {
1325				tool.SetCancelled()
1326			}
1327			if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
1328				tool.SetNestedCalls(m.loadNestedToolCalls(msg.ID, tc.ID))
1329			}
1330			m.chat.InvalidateItem(tc.ID)
1331		} else {
1332			// Add new tool call.
1333			ctx := m.buildToolCallContext(tc, msg, nil)
1334			if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
1335				ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID)
1336			}
1337			toolItem := chat.NewToolItem(ctx)
1338			m.chat.AppendMessages(toolItem)
1339			if animatable, ok := toolItem.(chat.Animatable); ok {
1340				cmds = append(cmds, animatable.InitAnimation())
1341			}
1342		}
1343	}
1344
1345	return tea.Batch(cmds...)
1346}
1347
1348// handleToolResultMessage updates tool calls with their results.
1349func (m *UI) handleToolResultMessage(msg message.Message) tea.Cmd {
1350	for _, tr := range msg.ToolResults() {
1351		if tool := m.chat.GetToolItem(tr.ToolCallID); tool != nil {
1352			tool.SetResult(tr)
1353			m.chat.InvalidateItem(tr.ToolCallID)
1354		}
1355	}
1356	return nil
1357}
1358
1359// shouldShowAssistantMessage returns true if the message should be displayed.
1360func (m *UI) shouldShowAssistantMessage(msg message.Message) bool {
1361	hasToolCalls := len(msg.ToolCalls()) > 0
1362	content := strings.TrimSpace(msg.Content().Text)
1363	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
1364	return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking()
1365}
1366
1367// createAssistantItem creates a new assistant message item.
1368func (m *UI) createAssistantItem(msg message.Message, content, thinking string, hasToolCalls bool) *chat.AssistantMessageItem {
1369	var finish message.Finish
1370	if fp := msg.FinishPart(); fp != nil {
1371		finish = *fp
1372	}
1373	reasoning := msg.ReasoningContent()
1374
1375	return chat.NewAssistantMessage(
1376		msg.ID,
1377		content,
1378		thinking,
1379		msg.IsFinished(),
1380		finish,
1381		hasToolCalls,
1382		msg.IsSummaryMessage,
1383		reasoning.StartedAt,
1384		reasoning.FinishedAt,
1385		m.com.Styles,
1386	)
1387}
1388
1389// sendMessage sends a user message to the agent coordinator.
1390func (m *UI) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
1391	sess := m.session
1392
1393	// Create a new session if we don't have one.
1394	if sess == nil || sess.ID == "" {
1395		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1396		if err != nil {
1397			return nil
1398		}
1399		sess = &newSession
1400		m.session = sess
1401		m.state = uiChat
1402	}
1403
1404	// Check if the agent coordinator is available.
1405	if m.com.App.AgentCoordinator == nil {
1406		return nil
1407	}
1408
1409	// Clear the textarea.
1410	m.textarea.Reset()
1411	m.randomizePlaceholders()
1412
1413	// Run the agent in a goroutine.
1414	sessionID := sess.ID
1415	return func() tea.Msg {
1416		_, _ = m.com.App.AgentCoordinator.Run(context.Background(), sessionID, text, attachments...)
1417		return nil
1418	}
1419}