| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535 |
- package model
- import (
- "bytes"
- "cmp"
- "context"
- "errors"
- "fmt"
- "image"
- "log/slog"
- "math/rand"
- "net/http"
- "os"
- "path/filepath"
- "regexp"
- "slices"
- "strconv"
- "strings"
- "time"
- "charm.land/bubbles/v2/help"
- "charm.land/bubbles/v2/key"
- "charm.land/bubbles/v2/spinner"
- "charm.land/bubbles/v2/textarea"
- tea "charm.land/bubbletea/v2"
- "charm.land/catwalk/pkg/catwalk"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/agent/notify"
- agenttools "github.com/charmbracelet/crush/internal/agent/tools"
- "github.com/charmbracelet/crush/internal/agent/tools/mcp"
- "github.com/charmbracelet/crush/internal/app"
- "github.com/charmbracelet/crush/internal/commands"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/history"
- "github.com/charmbracelet/crush/internal/home"
- "github.com/charmbracelet/crush/internal/message"
- "github.com/charmbracelet/crush/internal/permission"
- "github.com/charmbracelet/crush/internal/pubsub"
- "github.com/charmbracelet/crush/internal/session"
- "github.com/charmbracelet/crush/internal/ui/anim"
- "github.com/charmbracelet/crush/internal/ui/attachments"
- "github.com/charmbracelet/crush/internal/ui/chat"
- "github.com/charmbracelet/crush/internal/ui/common"
- "github.com/charmbracelet/crush/internal/ui/completions"
- "github.com/charmbracelet/crush/internal/ui/dialog"
- fimage "github.com/charmbracelet/crush/internal/ui/image"
- "github.com/charmbracelet/crush/internal/ui/logo"
- "github.com/charmbracelet/crush/internal/ui/notification"
- "github.com/charmbracelet/crush/internal/ui/styles"
- "github.com/charmbracelet/crush/internal/ui/util"
- "github.com/charmbracelet/crush/internal/version"
- uv "github.com/charmbracelet/ultraviolet"
- "github.com/charmbracelet/ultraviolet/layout"
- "github.com/charmbracelet/ultraviolet/screen"
- "github.com/charmbracelet/x/editor"
- )
- // MouseScrollThreshold defines how many lines to scroll the chat when a mouse
- // wheel event occurs.
- const MouseScrollThreshold = 5
- // Compact mode breakpoints.
- const (
- compactModeWidthBreakpoint = 120
- compactModeHeightBreakpoint = 30
- )
- // If pasted text has more than 10 newlines, treat it as a file attachment.
- const pasteLinesThreshold = 10
- // If pasted text has more than 1000 columns, treat it as a file attachment.
- const pasteColsThreshold = 1000
- // Session details panel max height.
- const sessionDetailsMaxHeight = 20
- // uiFocusState represents the current focus state of the UI.
- type uiFocusState uint8
- // Possible uiFocusState values.
- const (
- uiFocusNone uiFocusState = iota
- uiFocusEditor
- uiFocusMain
- )
- type uiState uint8
- // Possible uiState values.
- const (
- uiOnboarding uiState = iota
- uiInitialize
- uiLanding
- uiChat
- )
- type openEditorMsg struct {
- Text string
- }
- type (
- // cancelTimerExpiredMsg is sent when the cancel timer expires.
- cancelTimerExpiredMsg struct{}
- // userCommandsLoadedMsg is sent when user commands are loaded.
- userCommandsLoadedMsg struct {
- Commands []commands.CustomCommand
- }
- // mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
- mcpPromptsLoadedMsg struct {
- Prompts []commands.MCPPrompt
- }
- // mcpStateChangedMsg is sent when there is a change in MCP client states.
- mcpStateChangedMsg struct {
- states map[string]mcp.ClientInfo
- }
- // sendMessageMsg is sent to send a message.
- // currently only used for mcp prompts.
- sendMessageMsg struct {
- Content string
- Attachments []message.Attachment
- }
- // closeDialogMsg is sent to close the current dialog.
- closeDialogMsg struct{}
- // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
- copyChatHighlightMsg struct{}
- // sessionFilesUpdatesMsg is sent when the files for this session have been updated
- sessionFilesUpdatesMsg struct {
- sessionFiles []SessionFile
- }
- )
- // UI represents the main user interface model.
- type UI struct {
- com *common.Common
- session *session.Session
- sessionFiles []SessionFile
- // keeps track of read files while we don't have a session id
- sessionFileReads []string
- // initialSessionID is set when loading a specific session on startup.
- initialSessionID string
- // continueLastSession is set to continue the most recent session on startup.
- continueLastSession bool
- lastUserMessageTime int64
- // The width and height of the terminal in cells.
- width int
- height int
- layout uiLayout
- isTransparent bool
- focus uiFocusState
- state uiState
- keyMap KeyMap
- keyenh tea.KeyboardEnhancementsMsg
- dialog *dialog.Overlay
- status *Status
- // isCanceling tracks whether the user has pressed escape once to cancel.
- isCanceling bool
- header *header
- // sendProgressBar instructs the TUI to send progress bar updates to the
- // terminal.
- sendProgressBar bool
- progressBarEnabled bool
- // caps hold different terminal capabilities that we query for.
- caps common.Capabilities
- // Editor components
- textarea textarea.Model
- // Attachment list
- attachments *attachments.Attachments
- readyPlaceholder string
- workingPlaceholder string
- // Completions state
- completions *completions.Completions
- completionsOpen bool
- completionsStartIndex int
- completionsQuery string
- completionsPositionStart image.Point // x,y where user typed '@'
- // Chat components
- chat *Chat
- // onboarding state
- onboarding struct {
- yesInitializeSelected bool
- }
- // lsp
- lspStates map[string]app.LSPClientInfo
- // mcp
- mcpStates map[string]mcp.ClientInfo
- // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
- sidebarLogo string
- // Notification state
- notifyBackend notification.Backend
- notifyWindowFocused bool
- // custom commands & mcp commands
- customCommands []commands.CustomCommand
- mcpPrompts []commands.MCPPrompt
- // forceCompactMode tracks whether compact mode is forced by user toggle
- forceCompactMode bool
- // isCompact tracks whether we're currently in compact layout mode (either
- // by user toggle or auto-switch based on window size)
- isCompact bool
- // detailsOpen tracks whether the details panel is open (in compact mode)
- detailsOpen bool
- // pills state
- pillsExpanded bool
- focusedPillSection pillSection
- promptQueue int
- pillsView string
- // Todo spinner
- todoSpinner spinner.Model
- todoIsSpinning bool
- // mouse highlighting related state
- lastClickTime time.Time
- // Prompt history for up/down navigation through previous messages.
- promptHistory struct {
- messages []string
- index int
- draft string
- }
- }
- // New creates a new instance of the [UI] model.
- func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
- // Editor components
- ta := textarea.New()
- ta.SetStyles(com.Styles.TextArea)
- ta.ShowLineNumbers = false
- ta.CharLimit = -1
- ta.SetVirtualCursor(false)
- ta.Focus()
- ch := NewChat(com)
- keyMap := DefaultKeyMap()
- // Completions component
- comp := completions.New(
- com.Styles.Completions.Normal,
- com.Styles.Completions.Focused,
- com.Styles.Completions.Match,
- )
- todoSpinner := spinner.New(
- spinner.WithSpinner(spinner.MiniDot),
- spinner.WithStyle(com.Styles.Pills.TodoSpinner),
- )
- // Attachments component
- attachments := attachments.New(
- attachments.NewRenderer(
- com.Styles.Attachments.Normal,
- com.Styles.Attachments.Deleting,
- com.Styles.Attachments.Image,
- com.Styles.Attachments.Text,
- ),
- attachments.Keymap{
- DeleteMode: keyMap.Editor.AttachmentDeleteMode,
- DeleteAll: keyMap.Editor.DeleteAllAttachments,
- Escape: keyMap.Editor.Escape,
- },
- )
- header := newHeader(com)
- ui := &UI{
- com: com,
- dialog: dialog.NewOverlay(),
- keyMap: keyMap,
- textarea: ta,
- chat: ch,
- header: header,
- completions: comp,
- attachments: attachments,
- todoSpinner: todoSpinner,
- lspStates: make(map[string]app.LSPClientInfo),
- mcpStates: make(map[string]mcp.ClientInfo),
- notifyBackend: notification.NoopBackend{},
- notifyWindowFocused: true,
- initialSessionID: initialSessionID,
- continueLastSession: continueLast,
- }
- status := NewStatus(com, ui)
- ui.setEditorPrompt(false)
- ui.randomizePlaceholders()
- ui.textarea.Placeholder = ui.readyPlaceholder
- ui.status = status
- // Initialize compact mode from config
- ui.forceCompactMode = com.Config().Options.TUI.CompactMode
- // set onboarding state defaults
- ui.onboarding.yesInitializeSelected = true
- desiredState := uiLanding
- desiredFocus := uiFocusEditor
- if !com.Config().IsConfigured() {
- desiredState = uiOnboarding
- } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
- desiredState = uiInitialize
- }
- // set initial state
- ui.setState(desiredState, desiredFocus)
- opts := com.Config().Options
- // disable indeterminate progress bar
- ui.progressBarEnabled = opts.Progress == nil || *opts.Progress
- // enable transparent mode
- ui.isTransparent = opts.TUI.Transparent != nil && *opts.TUI.Transparent
- return ui
- }
- // Init initializes the UI model.
- func (m *UI) Init() tea.Cmd {
- var cmds []tea.Cmd
- if m.state == uiOnboarding {
- if cmd := m.openModelsDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- // load the user commands async
- cmds = append(cmds, m.loadCustomCommands())
- // load prompt history async
- cmds = append(cmds, m.loadPromptHistory())
- // load initial session if specified
- if cmd := m.loadInitialSession(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- return tea.Batch(cmds...)
- }
- // loadInitialSession loads the initial session if one was specified on startup.
- func (m *UI) loadInitialSession() tea.Cmd {
- switch {
- case m.state != uiLanding:
- // Only load if we're in landing state (i.e., fully configured)
- return nil
- case m.initialSessionID != "":
- return m.loadSession(m.initialSessionID)
- case m.continueLastSession:
- return func() tea.Msg {
- sess, err := m.com.App.Sessions.GetLast(context.Background())
- if err != nil {
- return nil
- }
- return m.loadSession(sess.ID)()
- }
- default:
- return nil
- }
- }
- // sendNotification returns a command that sends a notification if allowed by policy.
- func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
- if !m.shouldSendNotification() {
- return nil
- }
- backend := m.notifyBackend
- return func() tea.Msg {
- if err := backend.Send(n); err != nil {
- slog.Error("Failed to send notification", "error", err)
- }
- return nil
- }
- }
- // shouldSendNotification returns true if notifications should be sent based on
- // current state. Focus reporting must be supported, window must not focused,
- // and notifications must not be disabled in config.
- func (m *UI) shouldSendNotification() bool {
- cfg := m.com.Config()
- if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
- return false
- }
- return m.caps.ReportFocusEvents && !m.notifyWindowFocused
- }
- // setState changes the UI state and focus.
- func (m *UI) setState(state uiState, focus uiFocusState) {
- if state == uiLanding {
- // Always turn off compact mode when going to landing
- m.isCompact = false
- }
- m.state = state
- m.focus = focus
- // Changing the state may change layout, so update it.
- m.updateLayoutAndSize()
- }
- // loadCustomCommands loads the custom commands asynchronously.
- func (m *UI) loadCustomCommands() tea.Cmd {
- return func() tea.Msg {
- customCommands, err := commands.LoadCustomCommands(m.com.Config())
- if err != nil {
- slog.Error("Failed to load custom commands", "error", err)
- }
- return userCommandsLoadedMsg{Commands: customCommands}
- }
- }
- // loadMCPrompts loads the MCP prompts asynchronously.
- func (m *UI) loadMCPrompts() tea.Msg {
- prompts, err := commands.LoadMCPPrompts()
- if err != nil {
- slog.Error("Failed to load MCP prompts", "error", err)
- }
- if prompts == nil {
- // flag them as loaded even if there is none or an error
- prompts = []commands.MCPPrompt{}
- }
- return mcpPromptsLoadedMsg{Prompts: prompts}
- }
- // Update handles updates to the UI model.
- func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- if m.hasSession() && m.isAgentBusy() {
- queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
- if queueSize != m.promptQueue {
- m.promptQueue = queueSize
- m.updateLayoutAndSize()
- }
- }
- // Update terminal capabilities
- m.caps.Update(msg)
- switch msg := msg.(type) {
- case tea.EnvMsg:
- // Is this Windows Terminal?
- if !m.sendProgressBar {
- m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
- }
- cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
- case tea.ModeReportMsg:
- if m.caps.ReportFocusEvents {
- m.notifyBackend = notification.NewNativeBackend(notification.Icon)
- }
- case tea.FocusMsg:
- m.notifyWindowFocused = true
- case tea.BlurMsg:
- m.notifyWindowFocused = false
- case pubsub.Event[notify.Notification]:
- if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case loadSessionMsg:
- if m.forceCompactMode {
- m.isCompact = true
- }
- m.setState(uiChat, m.focus)
- m.session = msg.session
- m.sessionFiles = msg.files
- cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
- msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
- if err != nil {
- cmds = append(cmds, util.ReportError(err))
- break
- }
- if cmd := m.setSessionMessages(msgs); cmd != nil {
- cmds = append(cmds, cmd)
- }
- if hasInProgressTodo(m.session.Todos) {
- // only start spinner if there is an in-progress todo
- if m.isAgentBusy() {
- m.todoIsSpinning = true
- cmds = append(cmds, m.todoSpinner.Tick)
- }
- m.updateLayoutAndSize()
- }
- // Reload prompt history for the new session.
- m.historyReset()
- cmds = append(cmds, m.loadPromptHistory())
- m.updateLayoutAndSize()
- case sessionFilesUpdatesMsg:
- m.sessionFiles = msg.sessionFiles
- var paths []string
- for _, f := range msg.sessionFiles {
- paths = append(paths, f.LatestVersion.Path)
- }
- cmds = append(cmds, m.startLSPs(paths))
- case sendMessageMsg:
- cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
- case userCommandsLoadedMsg:
- m.customCommands = msg.Commands
- dia := m.dialog.Dialog(dialog.CommandsID)
- if dia == nil {
- break
- }
- commands, ok := dia.(*dialog.Commands)
- if ok {
- commands.SetCustomCommands(m.customCommands)
- }
- case mcpStateChangedMsg:
- m.mcpStates = msg.states
- case mcpPromptsLoadedMsg:
- m.mcpPrompts = msg.Prompts
- dia := m.dialog.Dialog(dialog.CommandsID)
- if dia == nil {
- break
- }
- commands, ok := dia.(*dialog.Commands)
- if ok {
- commands.SetMCPPrompts(m.mcpPrompts)
- }
- case promptHistoryLoadedMsg:
- m.promptHistory.messages = msg.messages
- m.promptHistory.index = -1
- m.promptHistory.draft = ""
- case closeDialogMsg:
- m.dialog.CloseFrontDialog()
- case pubsub.Event[session.Session]:
- if msg.Type == pubsub.DeletedEvent {
- if m.session != nil && m.session.ID == msg.Payload.ID {
- if cmd := m.newSession(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- break
- }
- if m.session != nil && msg.Payload.ID == m.session.ID {
- prevHasInProgress := hasInProgressTodo(m.session.Todos)
- m.session = &msg.Payload
- if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
- m.todoIsSpinning = true
- cmds = append(cmds, m.todoSpinner.Tick)
- m.updateLayoutAndSize()
- }
- }
- case pubsub.Event[message.Message]:
- // Check if this is a child session message for an agent tool.
- if m.session == nil {
- break
- }
- if msg.Payload.SessionID != m.session.ID {
- // This might be a child session message from an agent tool.
- if cmd := m.handleChildSessionMessage(msg); cmd != nil {
- cmds = append(cmds, cmd)
- }
- break
- }
- switch msg.Type {
- case pubsub.CreatedEvent:
- cmds = append(cmds, m.appendSessionMessage(msg.Payload))
- case pubsub.UpdatedEvent:
- cmds = append(cmds, m.updateSessionMessage(msg.Payload))
- case pubsub.DeletedEvent:
- m.chat.RemoveMessage(msg.Payload.ID)
- }
- // start the spinner if there is a new message
- if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
- m.todoIsSpinning = true
- cmds = append(cmds, m.todoSpinner.Tick)
- }
- // stop the spinner if the agent is not busy anymore
- if m.todoIsSpinning && !m.isAgentBusy() {
- m.todoIsSpinning = false
- }
- // there is a number of things that could change the pills here so we want to re-render
- m.renderPills()
- case pubsub.Event[history.File]:
- cmds = append(cmds, m.handleFileEvent(msg.Payload))
- case pubsub.Event[app.LSPEvent]:
- m.lspStates = app.GetLSPStates()
- case pubsub.Event[mcp.Event]:
- switch msg.Payload.Type {
- case mcp.EventStateChanged:
- return m, tea.Batch(
- m.handleStateChanged(),
- m.loadMCPrompts,
- )
- case mcp.EventPromptsListChanged:
- return m, handleMCPPromptsEvent(msg.Payload.Name)
- case mcp.EventToolsListChanged:
- return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
- case mcp.EventResourcesListChanged:
- return m, handleMCPResourcesEvent(msg.Payload.Name)
- }
- case pubsub.Event[permission.PermissionRequest]:
- if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
- cmds = append(cmds, cmd)
- }
- if cmd := m.sendNotification(notification.Notification{
- Title: "Crush is waiting...",
- Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
- }); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case pubsub.Event[permission.PermissionNotification]:
- m.handlePermissionNotification(msg.Payload)
- case cancelTimerExpiredMsg:
- m.isCanceling = false
- case tea.TerminalVersionMsg:
- termVersion := strings.ToLower(msg.Name)
- // Only enable progress bar for the following terminals.
- if !m.sendProgressBar {
- m.sendProgressBar = strings.Contains(termVersion, "ghostty")
- }
- return m, nil
- case tea.WindowSizeMsg:
- m.width, m.height = msg.Width, msg.Height
- m.updateLayoutAndSize()
- if m.state == uiChat && m.chat.Follow() {
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- case tea.KeyboardEnhancementsMsg:
- m.keyenh = msg
- if msg.SupportsKeyDisambiguation() {
- m.keyMap.Models.SetHelp("ctrl+m", "models")
- m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
- }
- case copyChatHighlightMsg:
- cmds = append(cmds, m.copyChatHighlight())
- case DelayedClickMsg:
- // Handle delayed single-click action (e.g., expansion).
- m.chat.HandleDelayedClick(msg)
- case tea.MouseClickMsg:
- // Pass mouse events to dialogs first if any are open.
- if m.dialog.HasDialogs() {
- m.dialog.Update(msg)
- return m, tea.Batch(cmds...)
- }
- if cmd := m.handleClickFocus(msg); cmd != nil {
- cmds = append(cmds, cmd)
- }
- switch m.state {
- case uiChat:
- x, y := msg.X, msg.Y
- // Adjust for chat area position
- x -= m.layout.main.Min.X
- y -= m.layout.main.Min.Y
- if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
- if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
- m.lastClickTime = time.Now()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- }
- case tea.MouseMotionMsg:
- // Pass mouse events to dialogs first if any are open.
- if m.dialog.HasDialogs() {
- m.dialog.Update(msg)
- return m, tea.Batch(cmds...)
- }
- switch m.state {
- case uiChat:
- if msg.Y <= 0 {
- if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
- cmds = append(cmds, cmd)
- }
- if !m.chat.SelectedItemInView() {
- m.chat.SelectPrev()
- if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- } else if msg.Y >= m.chat.Height()-1 {
- if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
- cmds = append(cmds, cmd)
- }
- if !m.chat.SelectedItemInView() {
- m.chat.SelectNext()
- if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- x, y := msg.X, msg.Y
- // Adjust for chat area position
- x -= m.layout.main.Min.X
- y -= m.layout.main.Min.Y
- m.chat.HandleMouseDrag(x, y)
- }
- case tea.MouseReleaseMsg:
- // Pass mouse events to dialogs first if any are open.
- if m.dialog.HasDialogs() {
- m.dialog.Update(msg)
- return m, tea.Batch(cmds...)
- }
- switch m.state {
- case uiChat:
- x, y := msg.X, msg.Y
- // Adjust for chat area position
- x -= m.layout.main.Min.X
- y -= m.layout.main.Min.Y
- if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() {
- cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
- if time.Since(m.lastClickTime) >= doubleClickThreshold {
- return copyChatHighlightMsg{}
- }
- return nil
- }))
- }
- }
- case tea.MouseWheelMsg:
- // Pass mouse events to dialogs first if any are open.
- if m.dialog.HasDialogs() {
- m.dialog.Update(msg)
- return m, tea.Batch(cmds...)
- }
- // Otherwise handle mouse wheel for chat.
- switch m.state {
- case uiChat:
- switch msg.Button {
- case tea.MouseWheelUp:
- if cmd := m.chat.ScrollByAndAnimate(-MouseScrollThreshold); cmd != nil {
- cmds = append(cmds, cmd)
- }
- if !m.chat.SelectedItemInView() {
- m.chat.SelectPrev()
- if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- case tea.MouseWheelDown:
- if cmd := m.chat.ScrollByAndAnimate(MouseScrollThreshold); cmd != nil {
- cmds = append(cmds, cmd)
- }
- if !m.chat.SelectedItemInView() {
- if m.chat.AtBottom() {
- m.chat.SelectLast()
- } else {
- m.chat.SelectNext()
- }
- if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- }
- case anim.StepMsg:
- if m.state == uiChat {
- if cmd := m.chat.Animate(msg); cmd != nil {
- cmds = append(cmds, cmd)
- }
- if m.chat.Follow() {
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- case spinner.TickMsg:
- if m.dialog.HasDialogs() {
- // route to dialog
- if cmd := m.handleDialogMsg(msg); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
- var cmd tea.Cmd
- m.todoSpinner, cmd = m.todoSpinner.Update(msg)
- if cmd != nil {
- m.renderPills()
- cmds = append(cmds, cmd)
- }
- }
- case tea.KeyPressMsg:
- if cmd := m.handleKeyPressMsg(msg); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case tea.PasteMsg:
- if cmd := m.handlePasteMsg(msg); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case openEditorMsg:
- var cmd tea.Cmd
- m.textarea.SetValue(msg.Text)
- m.textarea.MoveToEnd()
- m.textarea, cmd = m.textarea.Update(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- case util.InfoMsg:
- m.status.SetInfoMsg(msg)
- ttl := msg.TTL
- if ttl <= 0 {
- ttl = DefaultStatusTTL
- }
- cmds = append(cmds, clearInfoMsgCmd(ttl))
- case util.ClearStatusMsg:
- m.status.ClearInfoMsg()
- case completions.CompletionItemsLoadedMsg:
- if m.completionsOpen {
- m.completions.SetItems(msg.Files, msg.Resources)
- }
- case uv.KittyGraphicsEvent:
- if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
- slog.Warn("Unexpected Kitty graphics response",
- "response", string(msg.Payload),
- "options", msg.Options)
- }
- default:
- if m.dialog.HasDialogs() {
- if cmd := m.handleDialogMsg(msg); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- // This logic gets triggered on any message type, but should it?
- switch m.focus {
- case uiFocusMain:
- case uiFocusEditor:
- // Textarea placeholder logic
- if m.isAgentBusy() {
- m.textarea.Placeholder = m.workingPlaceholder
- } else {
- m.textarea.Placeholder = m.readyPlaceholder
- }
- if m.com.App.Permissions.SkipRequests() {
- m.textarea.Placeholder = "Yolo mode!"
- }
- }
- // at this point this can only handle [message.Attachment] message, and we
- // should return all cmds anyway.
- _ = m.attachments.Update(msg)
- return m, tea.Batch(cmds...)
- }
- // setSessionMessages sets the messages for the current session in the chat
- func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
- var cmds []tea.Cmd
- // Build tool result map to link tool calls with their results
- msgPtrs := make([]*message.Message, len(msgs))
- for i := range msgs {
- msgPtrs[i] = &msgs[i]
- }
- toolResultMap := chat.BuildToolResultMap(msgPtrs)
- if len(msgPtrs) > 0 {
- m.lastUserMessageTime = msgPtrs[0].CreatedAt
- }
- // Add messages to chat with linked tool results
- items := make([]chat.MessageItem, 0, len(msgs)*2)
- for _, msg := range msgPtrs {
- switch msg.Role {
- case message.User:
- m.lastUserMessageTime = msg.CreatedAt
- items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
- case message.Assistant:
- items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
- if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
- infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
- items = append(items, infoItem)
- }
- default:
- items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
- }
- }
- // Load nested tool calls for agent/agentic_fetch tools.
- m.loadNestedToolCalls(items)
- // If the user switches between sessions while the agent is working we want
- // to make sure the animations are shown.
- for _, item := range items {
- if animatable, ok := item.(chat.Animatable); ok {
- if cmd := animatable.StartAnimation(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- m.chat.SetMessages(items...)
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- m.chat.SelectLast()
- return tea.Sequence(cmds...)
- }
- // loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
- func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
- for _, item := range items {
- nestedContainer, ok := item.(chat.NestedToolContainer)
- if !ok {
- continue
- }
- toolItem, ok := item.(chat.ToolMessageItem)
- if !ok {
- continue
- }
- tc := toolItem.ToolCall()
- messageID := toolItem.MessageID()
- // Get the agent tool session ID.
- agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
- // Fetch nested messages.
- nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
- if err != nil || len(nestedMsgs) == 0 {
- continue
- }
- // Build tool result map for nested messages.
- nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
- for i := range nestedMsgs {
- nestedMsgPtrs[i] = &nestedMsgs[i]
- }
- nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
- // Extract nested tool items.
- var nestedTools []chat.ToolMessageItem
- for _, nestedMsg := range nestedMsgPtrs {
- nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
- for _, nestedItem := range nestedItems {
- if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
- // Mark nested tools as simple (compact) rendering.
- if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
- simplifiable.SetCompact(true)
- }
- nestedTools = append(nestedTools, nestedToolItem)
- }
- }
- }
- // Recursively load nested tool calls for any agent tools within.
- nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
- for i, nt := range nestedTools {
- nestedMessageItems[i] = nt
- }
- m.loadNestedToolCalls(nestedMessageItems)
- // Set nested tools on the parent.
- nestedContainer.SetNestedTools(nestedTools)
- }
- }
- // appendSessionMessage appends a new message to the current session in the chat
- // if the message is a tool result it will update the corresponding tool call message
- func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
- var cmds []tea.Cmd
- existing := m.chat.MessageItem(msg.ID)
- if existing != nil {
- // message already exists, skip
- return nil
- }
- switch msg.Role {
- case message.User:
- m.lastUserMessageTime = msg.CreatedAt
- items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
- for _, item := range items {
- if animatable, ok := item.(chat.Animatable); ok {
- if cmd := animatable.StartAnimation(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- m.chat.AppendMessages(items...)
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case message.Assistant:
- items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
- for _, item := range items {
- if animatable, ok := item.(chat.Animatable); ok {
- if cmd := animatable.StartAnimation(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- m.chat.AppendMessages(items...)
- if m.chat.Follow() {
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
- infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
- m.chat.AppendMessages(infoItem)
- if m.chat.Follow() {
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- case message.Tool:
- for _, tr := range msg.ToolResults() {
- toolItem := m.chat.MessageItem(tr.ToolCallID)
- if toolItem == nil {
- // we should have an item!
- continue
- }
- if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
- toolMsgItem.SetResult(&tr)
- if m.chat.Follow() {
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- }
- }
- return tea.Sequence(cmds...)
- }
- func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
- switch {
- case m.state != uiChat:
- return nil
- case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
- return nil
- case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
- m.focus = uiFocusEditor
- cmd = m.textarea.Focus()
- m.chat.Blur()
- case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
- m.focus = uiFocusMain
- m.textarea.Blur()
- m.chat.Focus()
- }
- return cmd
- }
- // updateSessionMessage updates an existing message in the current session in the chat
- // when an assistant message is updated it may include updated tool calls as well
- // that is why we need to handle creating/updating each tool call message too
- func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
- var cmds []tea.Cmd
- existingItem := m.chat.MessageItem(msg.ID)
- if existingItem != nil {
- if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
- assistantItem.SetMessage(&msg)
- }
- }
- shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
- // if the message of the assistant does not have any response just tool calls we need to remove it
- if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
- m.chat.RemoveMessage(msg.ID)
- if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
- m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
- }
- }
- if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
- if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
- newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
- m.chat.AppendMessages(newInfoItem)
- }
- }
- var items []chat.MessageItem
- for _, tc := range msg.ToolCalls() {
- existingToolItem := m.chat.MessageItem(tc.ID)
- if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
- existingToolCall := toolItem.ToolCall()
- // only update if finished state changed or input changed
- // to avoid clearing the cache
- if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
- toolItem.SetToolCall(tc)
- }
- }
- if existingToolItem == nil {
- items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
- }
- }
- for _, item := range items {
- if animatable, ok := item.(chat.Animatable); ok {
- if cmd := animatable.StartAnimation(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- m.chat.AppendMessages(items...)
- if m.chat.Follow() {
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- m.chat.SelectLast()
- }
- return tea.Sequence(cmds...)
- }
- // handleChildSessionMessage handles messages from child sessions (agent tools).
- func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
- var cmds []tea.Cmd
- // Only process messages with tool calls or results.
- if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
- return nil
- }
- // Check if this is an agent tool session and parse it.
- childSessionID := event.Payload.SessionID
- _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
- if !ok {
- return nil
- }
- // Find the parent agent tool item.
- var agentItem chat.NestedToolContainer
- for i := 0; i < m.chat.Len(); i++ {
- item := m.chat.MessageItem(toolCallID)
- if item == nil {
- continue
- }
- if agent, ok := item.(chat.NestedToolContainer); ok {
- if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
- if toolMessageItem.ToolCall().ID == toolCallID {
- // Verify this agent belongs to the correct parent message.
- // We can't directly check parentMessageID on the item, so we trust the session parsing.
- agentItem = agent
- break
- }
- }
- }
- }
- if agentItem == nil {
- return nil
- }
- // Get existing nested tools.
- nestedTools := agentItem.NestedTools()
- // Update or create nested tool calls.
- for _, tc := range event.Payload.ToolCalls() {
- found := false
- for _, existingTool := range nestedTools {
- if existingTool.ToolCall().ID == tc.ID {
- existingTool.SetToolCall(tc)
- found = true
- break
- }
- }
- if !found {
- // Create a new nested tool item.
- nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
- if simplifiable, ok := nestedItem.(chat.Compactable); ok {
- simplifiable.SetCompact(true)
- }
- if animatable, ok := nestedItem.(chat.Animatable); ok {
- if cmd := animatable.StartAnimation(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- nestedTools = append(nestedTools, nestedItem)
- }
- }
- // Update nested tool results.
- for _, tr := range event.Payload.ToolResults() {
- for _, nestedTool := range nestedTools {
- if nestedTool.ToolCall().ID == tr.ToolCallID {
- nestedTool.SetResult(&tr)
- break
- }
- }
- }
- // Update the agent item with the new nested tools.
- agentItem.SetNestedTools(nestedTools)
- // Update the chat so it updates the index map for animations to work as expected
- m.chat.UpdateNestedToolIDs(toolCallID)
- if m.chat.Follow() {
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- m.chat.SelectLast()
- }
- return tea.Sequence(cmds...)
- }
- func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
- var cmds []tea.Cmd
- action := m.dialog.Update(msg)
- if action == nil {
- return tea.Batch(cmds...)
- }
- isOnboarding := m.state == uiOnboarding
- switch msg := action.(type) {
- // Generic dialog messages
- case dialog.ActionClose:
- if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
- break
- }
- if m.dialog.ContainsDialog(dialog.FilePickerID) {
- defer fimage.ResetCache()
- }
- m.dialog.CloseFrontDialog()
- if isOnboarding {
- if cmd := m.openModelsDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- if m.focus == uiFocusEditor {
- cmds = append(cmds, m.textarea.Focus())
- }
- case dialog.ActionCmd:
- if msg.Cmd != nil {
- cmds = append(cmds, msg.Cmd)
- }
- // Session dialog messages.
- case dialog.ActionSelectSession:
- m.dialog.CloseDialog(dialog.SessionsID)
- cmds = append(cmds, m.loadSession(msg.Session.ID))
- // Open dialog message.
- case dialog.ActionOpenDialog:
- m.dialog.CloseDialog(dialog.CommandsID)
- if cmd := m.openDialog(msg.DialogID); cmd != nil {
- cmds = append(cmds, cmd)
- }
- // Command dialog messages.
- case dialog.ActionToggleYoloMode:
- yolo := !m.com.App.Permissions.SkipRequests()
- m.com.App.Permissions.SetSkipRequests(yolo)
- m.setEditorPrompt(yolo)
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionToggleNotifications:
- cfg := m.com.Config()
- if cfg != nil && cfg.Options != nil {
- disabled := !cfg.Options.DisableNotifications
- cfg.Options.DisableNotifications = disabled
- if err := m.com.Store().SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
- cmds = append(cmds, util.ReportError(err))
- } else {
- status := "enabled"
- if disabled {
- status = "disabled"
- }
- cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
- }
- }
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionNewSession:
- if m.isAgentBusy() {
- cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
- break
- }
- if cmd := m.newSession(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionSummarize:
- if m.isAgentBusy() {
- cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
- break
- }
- cmds = append(cmds, func() tea.Msg {
- err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
- if err != nil {
- return util.ReportError(err)()
- }
- return nil
- })
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionToggleHelp:
- m.status.ToggleHelp()
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionExternalEditor:
- if m.isAgentBusy() {
- cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
- break
- }
- cmds = append(cmds, m.openEditor(m.textarea.Value()))
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionToggleCompactMode:
- cmds = append(cmds, m.toggleCompactMode())
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionTogglePills:
- if cmd := m.togglePillsExpanded(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionToggleThinking:
- cmds = append(cmds, func() tea.Msg {
- cfg := m.com.Config()
- if cfg == nil {
- return util.ReportError(errors.New("configuration not found"))()
- }
- agentCfg, ok := cfg.Agents[config.AgentCoder]
- if !ok {
- return util.ReportError(errors.New("agent configuration not found"))()
- }
- currentModel := cfg.Models[agentCfg.Model]
- currentModel.Think = !currentModel.Think
- if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
- return util.ReportError(err)()
- }
- m.com.App.UpdateAgentModel(context.TODO())
- status := "disabled"
- if currentModel.Think {
- status = "enabled"
- }
- return util.NewInfoMsg("Thinking mode " + status)
- })
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionToggleTransparentBackground:
- cmds = append(cmds, func() tea.Msg {
- cfg := m.com.Config()
- if cfg == nil {
- return util.ReportError(errors.New("configuration not found"))()
- }
- isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
- newValue := !isTransparent
- if err := m.com.Store().SetTransparentBackground(config.ScopeGlobal, newValue); err != nil {
- return util.ReportError(err)()
- }
- m.isTransparent = newValue
- status := "disabled"
- if newValue {
- status = "enabled"
- }
- return util.NewInfoMsg("Transparent background " + status)
- })
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionQuit:
- cmds = append(cmds, tea.Quit)
- case dialog.ActionEnableDockerMCP:
- m.dialog.CloseDialog(dialog.CommandsID)
- cmds = append(cmds, m.enableDockerMCP)
- case dialog.ActionDisableDockerMCP:
- m.dialog.CloseDialog(dialog.CommandsID)
- cmds = append(cmds, m.disableDockerMCP)
- case dialog.ActionInitializeProject:
- if m.isAgentBusy() {
- cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
- break
- }
- cmds = append(cmds, m.initializeProject())
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.ActionSelectModel:
- if m.isAgentBusy() {
- cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
- break
- }
- cfg := m.com.Config()
- if cfg == nil {
- cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
- break
- }
- var (
- providerID = msg.Model.Provider
- isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
- isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
- )
- // Attempt to import GitHub Copilot tokens from VSCode if available.
- if isCopilot && !isConfigured() && !msg.ReAuthenticate {
- m.com.Store().ImportCopilot()
- }
- if !isConfigured() || msg.ReAuthenticate {
- m.dialog.CloseDialog(dialog.ModelsID)
- if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
- cmds = append(cmds, cmd)
- }
- break
- }
- if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
- cmds = append(cmds, util.ReportError(err))
- } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
- // Ensure small model is set is unset.
- smallModel := m.com.App.GetDefaultSmallModel(providerID)
- if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
- cmds = append(cmds, util.ReportError(err))
- }
- }
- cmds = append(cmds, func() tea.Msg {
- if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
- return util.ReportError(err)
- }
- modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
- return util.NewInfoMsg(modelMsg)
- })
- m.dialog.CloseDialog(dialog.APIKeyInputID)
- m.dialog.CloseDialog(dialog.OAuthID)
- m.dialog.CloseDialog(dialog.ModelsID)
- if isOnboarding {
- m.setState(uiLanding, uiFocusEditor)
- m.com.Config().SetupAgents()
- if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
- cmds = append(cmds, util.ReportError(err))
- }
- }
- case dialog.ActionSelectReasoningEffort:
- if m.isAgentBusy() {
- cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
- break
- }
- cfg := m.com.Config()
- if cfg == nil {
- cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
- break
- }
- agentCfg, ok := cfg.Agents[config.AgentCoder]
- if !ok {
- cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
- break
- }
- currentModel := cfg.Models[agentCfg.Model]
- currentModel.ReasoningEffort = msg.Effort
- if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
- cmds = append(cmds, util.ReportError(err))
- break
- }
- cmds = append(cmds, func() tea.Msg {
- m.com.App.UpdateAgentModel(context.TODO())
- return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
- })
- m.dialog.CloseDialog(dialog.ReasoningID)
- case dialog.ActionPermissionResponse:
- m.dialog.CloseDialog(dialog.PermissionsID)
- switch msg.Action {
- case dialog.PermissionAllow:
- m.com.App.Permissions.Grant(msg.Permission)
- case dialog.PermissionAllowForSession:
- m.com.App.Permissions.GrantPersistent(msg.Permission)
- case dialog.PermissionDeny:
- m.com.App.Permissions.Deny(msg.Permission)
- }
- case dialog.ActionFilePickerSelected:
- cmds = append(cmds, tea.Sequence(
- msg.Cmd(),
- func() tea.Msg {
- m.dialog.CloseDialog(dialog.FilePickerID)
- return nil
- },
- func() tea.Msg {
- fimage.ResetCache()
- return nil
- },
- ))
- case dialog.ActionRunCustomCommand:
- if len(msg.Arguments) > 0 && msg.Args == nil {
- m.dialog.CloseFrontDialog()
- argsDialog := dialog.NewArguments(
- m.com,
- "Custom Command Arguments",
- "",
- msg.Arguments,
- msg, // Pass the action as the result
- )
- m.dialog.OpenDialog(argsDialog)
- break
- }
- content := msg.Content
- if msg.Args != nil {
- content = substituteArgs(content, msg.Args)
- }
- cmds = append(cmds, m.sendMessage(content))
- m.dialog.CloseFrontDialog()
- case dialog.ActionRunMCPPrompt:
- if len(msg.Arguments) > 0 && msg.Args == nil {
- m.dialog.CloseFrontDialog()
- title := cmp.Or(msg.Title, "MCP Prompt Arguments")
- argsDialog := dialog.NewArguments(
- m.com,
- title,
- msg.Description,
- msg.Arguments,
- msg, // Pass the action as the result
- )
- m.dialog.OpenDialog(argsDialog)
- break
- }
- cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
- default:
- cmds = append(cmds, util.CmdHandler(msg))
- }
- return tea.Batch(cmds...)
- }
- // substituteArgs replaces $ARG_NAME placeholders in content with actual values.
- func substituteArgs(content string, args map[string]string) string {
- for name, value := range args {
- placeholder := "$" + name
- content = strings.ReplaceAll(content, placeholder, value)
- }
- return content
- }
- func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
- var (
- dlg dialog.Dialog
- cmd tea.Cmd
- isOnboarding = m.state == uiOnboarding
- )
- switch provider.ID {
- case "hyper":
- dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
- case catwalk.InferenceProviderCopilot:
- dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
- default:
- dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
- }
- if m.dialog.ContainsDialog(dlg.ID()) {
- m.dialog.BringToFront(dlg.ID())
- return nil
- }
- m.dialog.OpenDialog(dlg)
- return cmd
- }
- func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
- var cmds []tea.Cmd
- handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
- switch {
- case key.Matches(msg, m.keyMap.Help):
- m.status.ToggleHelp()
- m.updateLayoutAndSize()
- return true
- case key.Matches(msg, m.keyMap.Commands):
- if cmd := m.openCommandsDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- return true
- case key.Matches(msg, m.keyMap.Models):
- if cmd := m.openModelsDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- return true
- case key.Matches(msg, m.keyMap.Sessions):
- if cmd := m.openSessionsDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- return true
- case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
- m.detailsOpen = !m.detailsOpen
- m.updateLayoutAndSize()
- return true
- case key.Matches(msg, m.keyMap.Chat.TogglePills):
- if m.state == uiChat && m.hasSession() {
- if cmd := m.togglePillsExpanded(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- return true
- }
- case key.Matches(msg, m.keyMap.Chat.PillLeft):
- if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
- if cmd := m.switchPillSection(-1); cmd != nil {
- cmds = append(cmds, cmd)
- }
- return true
- }
- case key.Matches(msg, m.keyMap.Chat.PillRight):
- if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
- if cmd := m.switchPillSection(1); cmd != nil {
- cmds = append(cmds, cmd)
- }
- return true
- }
- case key.Matches(msg, m.keyMap.Suspend):
- if m.isAgentBusy() {
- cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
- return true
- }
- cmds = append(cmds, tea.Suspend)
- return true
- }
- return false
- }
- if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
- // Always handle quit keys first
- if cmd := m.openQuitDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- return tea.Batch(cmds...)
- }
- // Route all messages to dialog if one is open.
- if m.dialog.HasDialogs() {
- return m.handleDialogMsg(msg)
- }
- // Handle cancel key when agent is busy.
- if key.Matches(msg, m.keyMap.Chat.Cancel) {
- if m.isAgentBusy() {
- if cmd := m.cancelAgent(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- return tea.Batch(cmds...)
- }
- }
- switch m.state {
- case uiOnboarding:
- return tea.Batch(cmds...)
- case uiInitialize:
- cmds = append(cmds, m.updateInitializeView(msg)...)
- return tea.Batch(cmds...)
- case uiChat, uiLanding:
- switch m.focus {
- case uiFocusEditor:
- // Handle completions if open.
- if m.completionsOpen {
- if msg, ok := m.completions.Update(msg); ok {
- switch msg := msg.(type) {
- case completions.SelectionMsg[completions.FileCompletionValue]:
- cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
- if !msg.KeepOpen {
- m.closeCompletions()
- }
- case completions.SelectionMsg[completions.ResourceCompletionValue]:
- cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
- if !msg.KeepOpen {
- m.closeCompletions()
- }
- case completions.ClosedMsg:
- m.completionsOpen = false
- }
- return tea.Batch(cmds...)
- }
- }
- if ok := m.attachments.Update(msg); ok {
- return tea.Batch(cmds...)
- }
- switch {
- case key.Matches(msg, m.keyMap.Editor.AddImage):
- if cmd := m.openFilesDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case key.Matches(msg, m.keyMap.Editor.PasteImage):
- cmds = append(cmds, m.pasteImageFromClipboard)
- case key.Matches(msg, m.keyMap.Editor.SendMessage):
- value := m.textarea.Value()
- if before, ok := strings.CutSuffix(value, "\\"); ok {
- // If the last character is a backslash, remove it and add a newline.
- m.textarea.SetValue(before)
- break
- }
- // Otherwise, send the message
- m.textarea.Reset()
- value = strings.TrimSpace(value)
- if value == "exit" || value == "quit" {
- return m.openQuitDialog()
- }
- attachments := m.attachments.List()
- m.attachments.Reset()
- if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
- return nil
- }
- m.randomizePlaceholders()
- m.historyReset()
- return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
- case key.Matches(msg, m.keyMap.Chat.NewSession):
- if !m.hasSession() {
- break
- }
- if m.isAgentBusy() {
- cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
- break
- }
- if cmd := m.newSession(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case key.Matches(msg, m.keyMap.Tab):
- if m.state != uiLanding {
- m.setState(m.state, uiFocusMain)
- m.textarea.Blur()
- m.chat.Focus()
- m.chat.SetSelected(m.chat.Len() - 1)
- }
- case key.Matches(msg, m.keyMap.Editor.OpenEditor):
- if m.isAgentBusy() {
- cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
- break
- }
- cmds = append(cmds, m.openEditor(m.textarea.Value()))
- case key.Matches(msg, m.keyMap.Editor.Newline):
- m.textarea.InsertRune('\n')
- m.closeCompletions()
- ta, cmd := m.textarea.Update(msg)
- m.textarea = ta
- cmds = append(cmds, cmd)
- case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
- cmd := m.handleHistoryUp(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- case key.Matches(msg, m.keyMap.Editor.HistoryNext):
- cmd := m.handleHistoryDown(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- case key.Matches(msg, m.keyMap.Editor.Escape):
- cmd := m.handleHistoryEscape(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
- if cmd := m.openCommandsDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- default:
- if handleGlobalKeys(msg) {
- // Handle global keys first before passing to textarea.
- break
- }
- // Check for @ trigger before passing to textarea.
- curValue := m.textarea.Value()
- curIdx := len(curValue)
- // Trigger completions on @.
- if msg.String() == "@" && !m.completionsOpen {
- // Only show if beginning of prompt or after whitespace.
- if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
- m.completionsOpen = true
- m.completionsQuery = ""
- m.completionsStartIndex = curIdx
- m.completionsPositionStart = m.completionsPosition()
- depth, limit := m.com.Config().Options.TUI.Completions.Limits()
- cmds = append(cmds, m.completions.Open(depth, limit))
- }
- }
- // remove the details if they are open when user starts typing
- if m.detailsOpen {
- m.detailsOpen = false
- m.updateLayoutAndSize()
- }
- ta, cmd := m.textarea.Update(msg)
- m.textarea = ta
- cmds = append(cmds, cmd)
- // Any text modification becomes the current draft.
- m.updateHistoryDraft(curValue)
- // After updating textarea, check if we need to filter completions.
- // Skip filtering on the initial @ keystroke since items are loading async.
- if m.completionsOpen && msg.String() != "@" {
- newValue := m.textarea.Value()
- newIdx := len(newValue)
- // Close completions if cursor moved before start.
- if newIdx <= m.completionsStartIndex {
- m.closeCompletions()
- } else if msg.String() == "space" {
- // Close on space.
- m.closeCompletions()
- } else {
- // Extract current word and filter.
- word := m.textareaWord()
- if strings.HasPrefix(word, "@") {
- m.completionsQuery = word[1:]
- m.completions.Filter(m.completionsQuery)
- } else if m.completionsOpen {
- m.closeCompletions()
- }
- }
- }
- }
- case uiFocusMain:
- switch {
- case key.Matches(msg, m.keyMap.Tab):
- m.focus = uiFocusEditor
- cmds = append(cmds, m.textarea.Focus())
- m.chat.Blur()
- case key.Matches(msg, m.keyMap.Chat.NewSession):
- if !m.hasSession() {
- break
- }
- if m.isAgentBusy() {
- cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
- break
- }
- m.focus = uiFocusEditor
- if cmd := m.newSession(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case key.Matches(msg, m.keyMap.Chat.Expand):
- m.chat.ToggleExpandedSelectedItem()
- case key.Matches(msg, m.keyMap.Chat.Up):
- if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
- cmds = append(cmds, cmd)
- }
- if !m.chat.SelectedItemInView() {
- m.chat.SelectPrev()
- if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- case key.Matches(msg, m.keyMap.Chat.Down):
- if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
- cmds = append(cmds, cmd)
- }
- if !m.chat.SelectedItemInView() {
- m.chat.SelectNext()
- if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- case key.Matches(msg, m.keyMap.Chat.UpOneItem):
- m.chat.SelectPrev()
- if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case key.Matches(msg, m.keyMap.Chat.DownOneItem):
- m.chat.SelectNext()
- if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
- if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
- cmds = append(cmds, cmd)
- }
- m.chat.SelectFirstInView()
- case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
- if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
- cmds = append(cmds, cmd)
- }
- m.chat.SelectLastInView()
- case key.Matches(msg, m.keyMap.Chat.PageUp):
- if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
- cmds = append(cmds, cmd)
- }
- m.chat.SelectFirstInView()
- case key.Matches(msg, m.keyMap.Chat.PageDown):
- if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
- cmds = append(cmds, cmd)
- }
- m.chat.SelectLastInView()
- case key.Matches(msg, m.keyMap.Chat.Home):
- if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- m.chat.SelectFirst()
- case key.Matches(msg, m.keyMap.Chat.End):
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- m.chat.SelectLast()
- default:
- if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
- cmds = append(cmds, cmd)
- } else {
- handleGlobalKeys(msg)
- }
- }
- default:
- handleGlobalKeys(msg)
- }
- default:
- handleGlobalKeys(msg)
- }
- return tea.Sequence(cmds...)
- }
- // drawHeader draws the header section of the UI.
- func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
- m.header.drawHeader(
- scr,
- area,
- m.session,
- m.isCompact,
- m.detailsOpen,
- area.Dx(),
- )
- }
- // Draw implements [uv.Drawable] and draws the UI model.
- func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
- layout := m.generateLayout(area.Dx(), area.Dy())
- if m.layout != layout {
- m.layout = layout
- m.updateSize()
- }
- // Clear the screen first
- screen.Clear(scr)
- switch m.state {
- case uiOnboarding:
- m.drawHeader(scr, layout.header)
- // NOTE: Onboarding flow will be rendered as dialogs below, but
- // positioned at the bottom left of the screen.
- case uiInitialize:
- m.drawHeader(scr, layout.header)
- main := uv.NewStyledString(m.initializeView())
- main.Draw(scr, layout.main)
- case uiLanding:
- m.drawHeader(scr, layout.header)
- main := uv.NewStyledString(m.landingView())
- main.Draw(scr, layout.main)
- editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
- editor.Draw(scr, layout.editor)
- case uiChat:
- if m.isCompact {
- m.drawHeader(scr, layout.header)
- } else {
- m.drawSidebar(scr, layout.sidebar)
- }
- m.chat.Draw(scr, layout.main)
- if layout.pills.Dy() > 0 && m.pillsView != "" {
- uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
- }
- editorWidth := scr.Bounds().Dx()
- if !m.isCompact {
- editorWidth -= layout.sidebar.Dx()
- }
- editor := uv.NewStyledString(m.renderEditorView(editorWidth))
- editor.Draw(scr, layout.editor)
- // Draw details overlay in compact mode when open
- if m.isCompact && m.detailsOpen {
- m.drawSessionDetails(scr, layout.sessionDetails)
- }
- }
- isOnboarding := m.state == uiOnboarding
- // Add status and help layer
- m.status.SetHideHelp(isOnboarding)
- m.status.Draw(scr, layout.status)
- // Draw completions popup if open
- if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
- w, h := m.completions.Size()
- x := m.completionsPositionStart.X
- y := m.completionsPositionStart.Y - h
- screenW := area.Dx()
- if x+w > screenW {
- x = screenW - w
- }
- x = max(0, x)
- y = max(0, y+1) // Offset for attachments row
- completionsView := uv.NewStyledString(m.completions.Render())
- completionsView.Draw(scr, image.Rectangle{
- Min: image.Pt(x, y),
- Max: image.Pt(x+w, y+h),
- })
- }
- // Debugging rendering (visually see when the tui rerenders)
- if os.Getenv("CRUSH_UI_DEBUG") == "true" {
- debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
- debug := uv.NewStyledString(debugView.String())
- debug.Draw(scr, image.Rectangle{
- Min: image.Pt(4, 1),
- Max: image.Pt(8, 3),
- })
- }
- // This needs to come last to overlay on top of everything. We always pass
- // the full screen bounds because the dialogs will position themselves
- // accordingly.
- if m.dialog.HasDialogs() {
- return m.dialog.Draw(scr, scr.Bounds())
- }
- switch m.focus {
- case uiFocusEditor:
- if m.layout.editor.Dy() <= 0 {
- // Don't show cursor if editor is not visible
- return nil
- }
- if m.detailsOpen && m.isCompact {
- // Don't show cursor if details overlay is open
- return nil
- }
- if m.textarea.Focused() {
- cur := m.textarea.Cursor()
- cur.X++ // Adjust for app margins
- cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
- return cur
- }
- }
- return nil
- }
- // View renders the UI model's view.
- func (m *UI) View() tea.View {
- var v tea.View
- v.AltScreen = true
- if !m.isTransparent {
- v.BackgroundColor = m.com.Styles.Background
- }
- v.MouseMode = tea.MouseModeCellMotion
- v.ReportFocus = m.caps.ReportFocusEvents
- v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
- canvas := uv.NewScreenBuffer(m.width, m.height)
- v.Cursor = m.Draw(canvas, canvas.Bounds())
- content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
- contentLines := strings.Split(content, "\n")
- for i, line := range contentLines {
- // Trim trailing spaces for concise rendering
- contentLines[i] = strings.TrimRight(line, " ")
- }
- content = strings.Join(contentLines, "\n")
- v.Content = content
- if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
- // HACK: use a random percentage to prevent ghostty from hiding it
- // after a timeout.
- v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
- }
- return v
- }
- // ShortHelp implements [help.KeyMap].
- func (m *UI) ShortHelp() []key.Binding {
- var binds []key.Binding
- k := &m.keyMap
- tab := k.Tab
- commands := k.Commands
- if m.focus == uiFocusEditor && m.textarea.Value() == "" {
- commands.SetHelp("/ or ctrl+p", "commands")
- }
- switch m.state {
- case uiInitialize:
- binds = append(binds, k.Quit)
- case uiChat:
- // Show cancel binding if agent is busy.
- if m.isAgentBusy() {
- cancelBinding := k.Chat.Cancel
- if m.isCanceling {
- cancelBinding.SetHelp("esc", "press again to cancel")
- } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
- cancelBinding.SetHelp("esc", "clear queue")
- }
- binds = append(binds, cancelBinding)
- }
- if m.focus == uiFocusEditor {
- tab.SetHelp("tab", "focus chat")
- } else {
- tab.SetHelp("tab", "focus editor")
- }
- binds = append(binds,
- tab,
- commands,
- k.Models,
- )
- switch m.focus {
- case uiFocusEditor:
- binds = append(binds,
- k.Editor.Newline,
- )
- case uiFocusMain:
- binds = append(binds,
- k.Chat.UpDown,
- k.Chat.UpDownOneItem,
- k.Chat.PageUp,
- k.Chat.PageDown,
- k.Chat.Copy,
- )
- if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
- binds = append(binds, k.Chat.PillLeft)
- }
- }
- default:
- // TODO: other states
- // if m.session == nil {
- // no session selected
- binds = append(binds,
- commands,
- k.Models,
- k.Editor.Newline,
- )
- }
- binds = append(binds,
- k.Quit,
- k.Help,
- )
- return binds
- }
- // FullHelp implements [help.KeyMap].
- func (m *UI) FullHelp() [][]key.Binding {
- var binds [][]key.Binding
- k := &m.keyMap
- help := k.Help
- help.SetHelp("ctrl+g", "less")
- hasAttachments := len(m.attachments.List()) > 0
- hasSession := m.hasSession()
- commands := k.Commands
- if m.focus == uiFocusEditor && m.textarea.Value() == "" {
- commands.SetHelp("/ or ctrl+p", "commands")
- }
- switch m.state {
- case uiInitialize:
- binds = append(binds,
- []key.Binding{
- k.Quit,
- })
- case uiChat:
- // Show cancel binding if agent is busy.
- if m.isAgentBusy() {
- cancelBinding := k.Chat.Cancel
- if m.isCanceling {
- cancelBinding.SetHelp("esc", "press again to cancel")
- } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
- cancelBinding.SetHelp("esc", "clear queue")
- }
- binds = append(binds, []key.Binding{cancelBinding})
- }
- mainBinds := []key.Binding{}
- tab := k.Tab
- if m.focus == uiFocusEditor {
- tab.SetHelp("tab", "focus chat")
- } else {
- tab.SetHelp("tab", "focus editor")
- }
- mainBinds = append(mainBinds,
- tab,
- commands,
- k.Models,
- k.Sessions,
- )
- if hasSession {
- mainBinds = append(mainBinds, k.Chat.NewSession)
- }
- binds = append(binds, mainBinds)
- switch m.focus {
- case uiFocusEditor:
- binds = append(binds,
- []key.Binding{
- k.Editor.Newline,
- k.Editor.AddImage,
- k.Editor.PasteImage,
- k.Editor.MentionFile,
- k.Editor.OpenEditor,
- },
- )
- if hasAttachments {
- binds = append(binds,
- []key.Binding{
- k.Editor.AttachmentDeleteMode,
- k.Editor.DeleteAllAttachments,
- k.Editor.Escape,
- },
- )
- }
- case uiFocusMain:
- binds = append(binds,
- []key.Binding{
- k.Chat.UpDown,
- k.Chat.UpDownOneItem,
- k.Chat.PageUp,
- k.Chat.PageDown,
- },
- []key.Binding{
- k.Chat.HalfPageUp,
- k.Chat.HalfPageDown,
- k.Chat.Home,
- k.Chat.End,
- },
- []key.Binding{
- k.Chat.Copy,
- k.Chat.ClearHighlight,
- },
- )
- if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
- binds = append(binds, []key.Binding{k.Chat.PillLeft})
- }
- }
- default:
- if m.session == nil {
- // no session selected
- binds = append(binds,
- []key.Binding{
- commands,
- k.Models,
- k.Sessions,
- },
- []key.Binding{
- k.Editor.Newline,
- k.Editor.AddImage,
- k.Editor.PasteImage,
- k.Editor.MentionFile,
- k.Editor.OpenEditor,
- },
- )
- if hasAttachments {
- binds = append(binds,
- []key.Binding{
- k.Editor.AttachmentDeleteMode,
- k.Editor.DeleteAllAttachments,
- k.Editor.Escape,
- },
- )
- }
- }
- }
- binds = append(binds,
- []key.Binding{
- help,
- k.Quit,
- },
- )
- return binds
- }
- // toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
- func (m *UI) toggleCompactMode() tea.Cmd {
- m.forceCompactMode = !m.forceCompactMode
- err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
- if err != nil {
- return util.ReportError(err)
- }
- m.updateLayoutAndSize()
- return nil
- }
- // updateLayoutAndSize updates the layout and sizes of UI components.
- func (m *UI) updateLayoutAndSize() {
- // Determine if we should be in compact mode
- if m.state == uiChat {
- if m.forceCompactMode {
- m.isCompact = true
- return
- }
- if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
- m.isCompact = true
- } else {
- m.isCompact = false
- }
- }
- m.layout = m.generateLayout(m.width, m.height)
- m.updateSize()
- }
- // updateSize updates the sizes of UI components based on the current layout.
- func (m *UI) updateSize() {
- // Set status width
- m.status.SetWidth(m.layout.status.Dx())
- m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
- m.textarea.SetWidth(m.layout.editor.Dx())
- // TODO: Abstract the textarea and attachments into a single editor
- // component so we don't have to manually account for the attachments
- // height here.
- m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
- m.renderPills()
- // Handle different app states
- switch m.state {
- case uiChat:
- if !m.isCompact {
- m.cacheSidebarLogo(m.layout.sidebar.Dx())
- }
- }
- }
- // generateLayout calculates the layout rectangles for all UI components based
- // on the current UI state and terminal dimensions.
- func (m *UI) generateLayout(w, h int) uiLayout {
- // The screen area we're working with
- area := image.Rect(0, 0, w, h)
- // The help height
- helpHeight := 1
- // The editor height
- editorHeight := 5
- // The sidebar width
- sidebarWidth := 30
- // The header height
- const landingHeaderHeight = 4
- var helpKeyMap help.KeyMap = m
- if m.status != nil && m.status.ShowingAll() {
- for _, row := range helpKeyMap.FullHelp() {
- helpHeight = max(helpHeight, len(row))
- }
- }
- // Add app margins
- appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
- appRect.Min.Y += 1
- appRect.Max.Y -= 1
- helpRect.Min.Y -= 1
- appRect.Min.X += 1
- appRect.Max.X -= 1
- if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
- // extra padding on left and right for these states
- appRect.Min.X += 1
- appRect.Max.X -= 1
- }
- uiLayout := uiLayout{
- area: area,
- status: helpRect,
- }
- // Handle different app states
- switch m.state {
- case uiOnboarding, uiInitialize:
- // Layout
- //
- // header
- // ------
- // main
- // ------
- // help
- headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
- uiLayout.header = headerRect
- uiLayout.main = mainRect
- case uiLanding:
- // Layout
- //
- // header
- // ------
- // main
- // ------
- // editor
- // ------
- // help
- headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
- mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
- // Remove extra padding from editor (but keep it for header and main)
- editorRect.Min.X -= 1
- editorRect.Max.X += 1
- uiLayout.header = headerRect
- uiLayout.main = mainRect
- uiLayout.editor = editorRect
- case uiChat:
- if m.isCompact {
- // Layout
- //
- // compact-header
- // ------
- // main
- // ------
- // editor
- // ------
- // help
- const compactHeaderHeight = 1
- headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
- detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
- sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
- uiLayout.sessionDetails = sessionDetailsArea
- uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
- // Add one line gap between header and main content
- mainRect.Min.Y += 1
- mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
- mainRect.Max.X -= 1 // Add padding right
- uiLayout.header = headerRect
- pillsHeight := m.pillsAreaHeight()
- if pillsHeight > 0 {
- pillsHeight = min(pillsHeight, mainRect.Dy())
- chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
- uiLayout.main = chatRect
- uiLayout.pills = pillsRect
- } else {
- uiLayout.main = mainRect
- }
- // Add bottom margin to main
- uiLayout.main.Max.Y -= 1
- uiLayout.editor = editorRect
- } else {
- // Layout
- //
- // ------|---
- // main |
- // ------| side
- // editor|
- // ----------
- // help
- mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
- // Add padding left
- sideRect.Min.X += 1
- mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
- mainRect.Max.X -= 1 // Add padding right
- uiLayout.sidebar = sideRect
- pillsHeight := m.pillsAreaHeight()
- if pillsHeight > 0 {
- pillsHeight = min(pillsHeight, mainRect.Dy())
- chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
- uiLayout.main = chatRect
- uiLayout.pills = pillsRect
- } else {
- uiLayout.main = mainRect
- }
- // Add bottom margin to main
- uiLayout.main.Max.Y -= 1
- uiLayout.editor = editorRect
- }
- }
- return uiLayout
- }
- // uiLayout defines the positioning of UI elements.
- type uiLayout struct {
- // area is the overall available area.
- area uv.Rectangle
- // header is the header shown in special cases
- // e.x when the sidebar is collapsed
- // or when in the landing page
- // or in init/config
- header uv.Rectangle
- // main is the area for the main pane. (e.x chat, configure, landing)
- main uv.Rectangle
- // pills is the area for the pills panel.
- pills uv.Rectangle
- // editor is the area for the editor pane.
- editor uv.Rectangle
- // sidebar is the area for the sidebar.
- sidebar uv.Rectangle
- // status is the area for the status view.
- status uv.Rectangle
- // session details is the area for the session details overlay in compact mode.
- sessionDetails uv.Rectangle
- }
- func (m *UI) openEditor(value string) tea.Cmd {
- tmpfile, err := os.CreateTemp("", "msg_*.md")
- if err != nil {
- return util.ReportError(err)
- }
- defer tmpfile.Close() //nolint:errcheck
- if _, err := tmpfile.WriteString(value); err != nil {
- return util.ReportError(err)
- }
- cmd, err := editor.Command(
- "crush",
- tmpfile.Name(),
- editor.AtPosition(
- m.textarea.Line()+1,
- m.textarea.Column()+1,
- ),
- )
- if err != nil {
- return util.ReportError(err)
- }
- return tea.ExecProcess(cmd, func(err error) tea.Msg {
- if err != nil {
- return util.ReportError(err)
- }
- content, err := os.ReadFile(tmpfile.Name())
- if err != nil {
- return util.ReportError(err)
- }
- if len(content) == 0 {
- return util.ReportWarn("Message is empty")
- }
- os.Remove(tmpfile.Name())
- return openEditorMsg{
- Text: strings.TrimSpace(string(content)),
- }
- })
- }
- // setEditorPrompt configures the textarea prompt function based on whether
- // yolo mode is enabled.
- func (m *UI) setEditorPrompt(yolo bool) {
- if yolo {
- m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
- return
- }
- m.textarea.SetPromptFunc(4, m.normalPromptFunc)
- }
- // normalPromptFunc returns the normal editor prompt style (" > " on first
- // line, "::: " on subsequent lines).
- func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
- t := m.com.Styles
- if info.LineNumber == 0 {
- if info.Focused {
- return " > "
- }
- return "::: "
- }
- if info.Focused {
- return t.EditorPromptNormalFocused.Render()
- }
- return t.EditorPromptNormalBlurred.Render()
- }
- // yoloPromptFunc returns the yolo mode editor prompt style with warning icon
- // and colored dots.
- func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
- t := m.com.Styles
- if info.LineNumber == 0 {
- if info.Focused {
- return t.EditorPromptYoloIconFocused.Render()
- } else {
- return t.EditorPromptYoloIconBlurred.Render()
- }
- }
- if info.Focused {
- return t.EditorPromptYoloDotsFocused.Render()
- }
- return t.EditorPromptYoloDotsBlurred.Render()
- }
- // closeCompletions closes the completions popup and resets state.
- func (m *UI) closeCompletions() {
- m.completionsOpen = false
- m.completionsQuery = ""
- m.completionsStartIndex = 0
- m.completions.Close()
- }
- // insertCompletionText replaces the @query in the textarea with the given text.
- // Returns false if the replacement cannot be performed.
- func (m *UI) insertCompletionText(text string) bool {
- value := m.textarea.Value()
- if m.completionsStartIndex > len(value) {
- return false
- }
- word := m.textareaWord()
- endIdx := min(m.completionsStartIndex+len(word), len(value))
- newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
- m.textarea.SetValue(newValue)
- m.textarea.MoveToEnd()
- m.textarea.InsertRune(' ')
- return true
- }
- // insertFileCompletion inserts the selected file path into the textarea,
- // replacing the @query, and adds the file as an attachment.
- func (m *UI) insertFileCompletion(path string) tea.Cmd {
- if !m.insertCompletionText(path) {
- return nil
- }
- return func() tea.Msg {
- absPath, _ := filepath.Abs(path)
- if m.hasSession() {
- // Skip attachment if file was already read and hasn't been modified.
- lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
- if !lastRead.IsZero() {
- if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
- return nil
- }
- }
- } else if slices.Contains(m.sessionFileReads, absPath) {
- return nil
- }
- m.sessionFileReads = append(m.sessionFileReads, absPath)
- // Add file as attachment.
- content, err := os.ReadFile(path)
- if err != nil {
- // If it fails, let the LLM handle it later.
- return nil
- }
- return message.Attachment{
- FilePath: path,
- FileName: filepath.Base(path),
- MimeType: mimeOf(content),
- Content: content,
- }
- }
- }
- // insertMCPResourceCompletion inserts the selected resource into the textarea,
- // replacing the @query, and adds the resource as an attachment.
- func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
- displayText := cmp.Or(item.Title, item.URI)
- if !m.insertCompletionText(displayText) {
- return nil
- }
- return func() tea.Msg {
- contents, err := mcp.ReadResource(
- context.Background(),
- m.com.Store(),
- item.MCPName,
- item.URI,
- )
- if err != nil {
- slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
- return nil
- }
- if len(contents) == 0 {
- return nil
- }
- content := contents[0]
- var data []byte
- if content.Text != "" {
- data = []byte(content.Text)
- } else if len(content.Blob) > 0 {
- data = content.Blob
- }
- if len(data) == 0 {
- return nil
- }
- mimeType := item.MIMEType
- if mimeType == "" && content.MIMEType != "" {
- mimeType = content.MIMEType
- }
- if mimeType == "" {
- mimeType = "text/plain"
- }
- return message.Attachment{
- FilePath: item.URI,
- FileName: displayText,
- MimeType: mimeType,
- Content: data,
- }
- }
- }
- // completionsPosition returns the X and Y position for the completions popup.
- func (m *UI) completionsPosition() image.Point {
- cur := m.textarea.Cursor()
- if cur == nil {
- return image.Point{
- X: m.layout.editor.Min.X,
- Y: m.layout.editor.Min.Y,
- }
- }
- return image.Point{
- X: cur.X + m.layout.editor.Min.X,
- Y: m.layout.editor.Min.Y + cur.Y,
- }
- }
- // textareaWord returns the current word at the cursor position.
- func (m *UI) textareaWord() string {
- return m.textarea.Word()
- }
- // isWhitespace returns true if the byte is a whitespace character.
- func isWhitespace(b byte) bool {
- return b == ' ' || b == '\t' || b == '\n' || b == '\r'
- }
- // isAgentBusy returns true if the agent coordinator exists and is currently
- // busy processing a request.
- func (m *UI) isAgentBusy() bool {
- return m.com.App != nil &&
- m.com.App.AgentCoordinator != nil &&
- m.com.App.AgentCoordinator.IsBusy()
- }
- // hasSession returns true if there is an active session with a valid ID.
- func (m *UI) hasSession() bool {
- return m.session != nil && m.session.ID != ""
- }
- // mimeOf detects the MIME type of the given content.
- func mimeOf(content []byte) string {
- mimeBufferSize := min(512, len(content))
- return http.DetectContentType(content[:mimeBufferSize])
- }
- var readyPlaceholders = [...]string{
- "Ready!",
- "Ready...",
- "Ready?",
- "Ready for instructions",
- }
- var workingPlaceholders = [...]string{
- "Working!",
- "Working...",
- "Brrrrr...",
- "Prrrrrrrr...",
- "Processing...",
- "Thinking...",
- }
- // randomizePlaceholders selects random placeholder text for the textarea's
- // ready and working states.
- func (m *UI) randomizePlaceholders() {
- m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
- m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
- }
- // renderEditorView renders the editor view with attachments if any.
- func (m *UI) renderEditorView(width int) string {
- var attachmentsView string
- if len(m.attachments.List()) > 0 {
- attachmentsView = m.attachments.Render(width)
- }
- return strings.Join([]string{
- attachmentsView,
- m.textarea.View(),
- "", // margin at bottom of editor
- }, "\n")
- }
- // cacheSidebarLogo renders and caches the sidebar logo at the specified width.
- func (m *UI) cacheSidebarLogo(width int) {
- m.sidebarLogo = renderLogo(m.com.Styles, true, width)
- }
- // sendMessage sends a message with the given content and attachments.
- func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
- if m.com.App.AgentCoordinator == nil {
- return util.ReportError(fmt.Errorf("coder agent is not initialized"))
- }
- var cmds []tea.Cmd
- if !m.hasSession() {
- newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
- if err != nil {
- return util.ReportError(err)
- }
- if m.forceCompactMode {
- m.isCompact = true
- }
- if newSession.ID != "" {
- m.session = &newSession
- cmds = append(cmds, m.loadSession(newSession.ID))
- }
- m.setState(uiChat, m.focus)
- }
- ctx := context.Background()
- cmds = append(cmds, func() tea.Msg {
- for _, path := range m.sessionFileReads {
- m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
- m.com.App.LSPManager.Start(ctx, path)
- }
- return nil
- })
- // Capture session ID to avoid race with main goroutine updating m.session.
- sessionID := m.session.ID
- cmds = append(cmds, func() tea.Msg {
- _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
- if err != nil {
- isCancelErr := errors.Is(err, context.Canceled)
- isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
- if isCancelErr || isPermissionErr {
- return nil
- }
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: err.Error(),
- }
- }
- return nil
- })
- return tea.Batch(cmds...)
- }
- const cancelTimerDuration = 2 * time.Second
- // cancelTimerCmd creates a command that expires the cancel timer.
- func cancelTimerCmd() tea.Cmd {
- return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
- return cancelTimerExpiredMsg{}
- })
- }
- // cancelAgent handles the cancel key press. The first press sets isCanceling to true
- // and starts a timer. The second press (before the timer expires) actually
- // cancels the agent.
- func (m *UI) cancelAgent() tea.Cmd {
- if !m.hasSession() {
- return nil
- }
- coordinator := m.com.App.AgentCoordinator
- if coordinator == nil {
- return nil
- }
- if m.isCanceling {
- // Second escape press - actually cancel the agent.
- m.isCanceling = false
- coordinator.Cancel(m.session.ID)
- // Stop the spinning todo indicator.
- m.todoIsSpinning = false
- m.renderPills()
- return nil
- }
- // Check if there are queued prompts - if so, clear the queue.
- if coordinator.QueuedPrompts(m.session.ID) > 0 {
- coordinator.ClearQueue(m.session.ID)
- return nil
- }
- // First escape press - set canceling state and start timer.
- m.isCanceling = true
- return cancelTimerCmd()
- }
- // openDialog opens a dialog by its ID.
- func (m *UI) openDialog(id string) tea.Cmd {
- var cmds []tea.Cmd
- switch id {
- case dialog.SessionsID:
- if cmd := m.openSessionsDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case dialog.ModelsID:
- if cmd := m.openModelsDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case dialog.CommandsID:
- if cmd := m.openCommandsDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case dialog.ReasoningID:
- if cmd := m.openReasoningDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case dialog.FilePickerID:
- if cmd := m.openFilesDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case dialog.QuitID:
- if cmd := m.openQuitDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- default:
- // Unknown dialog
- break
- }
- return tea.Batch(cmds...)
- }
- // openQuitDialog opens the quit confirmation dialog.
- func (m *UI) openQuitDialog() tea.Cmd {
- if m.dialog.ContainsDialog(dialog.QuitID) {
- // Bring to front
- m.dialog.BringToFront(dialog.QuitID)
- return nil
- }
- quitDialog := dialog.NewQuit(m.com)
- m.dialog.OpenDialog(quitDialog)
- return nil
- }
- // openModelsDialog opens the models dialog.
- func (m *UI) openModelsDialog() tea.Cmd {
- if m.dialog.ContainsDialog(dialog.ModelsID) {
- // Bring to front
- m.dialog.BringToFront(dialog.ModelsID)
- return nil
- }
- isOnboarding := m.state == uiOnboarding
- modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
- if err != nil {
- return util.ReportError(err)
- }
- m.dialog.OpenDialog(modelsDialog)
- return nil
- }
- // openCommandsDialog opens the commands dialog.
- func (m *UI) openCommandsDialog() tea.Cmd {
- if m.dialog.ContainsDialog(dialog.CommandsID) {
- // Bring to front
- m.dialog.BringToFront(dialog.CommandsID)
- return nil
- }
- var sessionID string
- hasSession := m.session != nil
- if hasSession {
- sessionID = m.session.ID
- }
- hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
- hasQueue := m.promptQueue > 0
- commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
- if err != nil {
- return util.ReportError(err)
- }
- m.dialog.OpenDialog(commands)
- return commands.InitialCmd()
- }
- // openReasoningDialog opens the reasoning effort dialog.
- func (m *UI) openReasoningDialog() tea.Cmd {
- if m.dialog.ContainsDialog(dialog.ReasoningID) {
- m.dialog.BringToFront(dialog.ReasoningID)
- return nil
- }
- reasoningDialog, err := dialog.NewReasoning(m.com)
- if err != nil {
- return util.ReportError(err)
- }
- m.dialog.OpenDialog(reasoningDialog)
- return nil
- }
- // openSessionsDialog opens the sessions dialog. If the dialog is already open,
- // it brings it to the front. Otherwise, it will list all the sessions and open
- // the dialog.
- func (m *UI) openSessionsDialog() tea.Cmd {
- if m.dialog.ContainsDialog(dialog.SessionsID) {
- // Bring to front
- m.dialog.BringToFront(dialog.SessionsID)
- return nil
- }
- selectedSessionID := ""
- if m.session != nil {
- selectedSessionID = m.session.ID
- }
- dialog, err := dialog.NewSessions(m.com, selectedSessionID)
- if err != nil {
- return util.ReportError(err)
- }
- m.dialog.OpenDialog(dialog)
- return nil
- }
- // openFilesDialog opens the file picker dialog.
- func (m *UI) openFilesDialog() tea.Cmd {
- if m.dialog.ContainsDialog(dialog.FilePickerID) {
- // Bring to front
- m.dialog.BringToFront(dialog.FilePickerID)
- return nil
- }
- filePicker, cmd := dialog.NewFilePicker(m.com)
- filePicker.SetImageCapabilities(&m.caps)
- m.dialog.OpenDialog(filePicker)
- return cmd
- }
- // openPermissionsDialog opens the permissions dialog for a permission request.
- func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
- // Close any existing permissions dialog first.
- m.dialog.CloseDialog(dialog.PermissionsID)
- // Get diff mode from config.
- var opts []dialog.PermissionsOption
- if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
- opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
- }
- permDialog := dialog.NewPermissions(m.com, perm, opts...)
- m.dialog.OpenDialog(permDialog)
- return nil
- }
- // handlePermissionNotification updates tool items when permission state changes.
- func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
- toolItem := m.chat.MessageItem(notification.ToolCallID)
- if toolItem == nil {
- return
- }
- if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
- if notification.Granted {
- permItem.SetStatus(chat.ToolStatusRunning)
- } else {
- permItem.SetStatus(chat.ToolStatusAwaitingPermission)
- }
- }
- }
- // handleAgentNotification translates domain agent events into desktop
- // notifications using the UI notification backend.
- func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
- switch n.Type {
- case notify.TypeAgentFinished:
- return m.sendNotification(notification.Notification{
- Title: "Crush is waiting...",
- Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
- })
- default:
- return nil
- }
- }
- // newSession clears the current session state and prepares for a new session.
- // The actual session creation happens when the user sends their first message.
- // Returns a command to reload prompt history.
- func (m *UI) newSession() tea.Cmd {
- if !m.hasSession() {
- return nil
- }
- m.session = nil
- m.sessionFiles = nil
- m.sessionFileReads = nil
- m.setState(uiLanding, uiFocusEditor)
- m.textarea.Focus()
- m.chat.Blur()
- m.chat.ClearMessages()
- m.pillsExpanded = false
- m.promptQueue = 0
- m.pillsView = ""
- m.historyReset()
- agenttools.ResetCache()
- return tea.Batch(
- func() tea.Msg {
- m.com.App.LSPManager.StopAll(context.Background())
- return nil
- },
- m.loadPromptHistory(),
- )
- }
- // handlePasteMsg handles a paste message.
- func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
- if m.dialog.HasDialogs() {
- return m.handleDialogMsg(msg)
- }
- if m.focus != uiFocusEditor {
- return nil
- }
- if hasPasteExceededThreshold(msg) {
- return func() tea.Msg {
- content := []byte(msg.Content)
- if int64(len(content)) > common.MaxAttachmentSize {
- return util.ReportWarn("Paste is too big (>5mb)")
- }
- name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
- mimeBufferSize := min(512, len(content))
- mimeType := http.DetectContentType(content[:mimeBufferSize])
- return message.Attachment{
- FileName: name,
- FilePath: name,
- MimeType: mimeType,
- Content: content,
- }
- }
- }
- // Attempt to parse pasted content as file paths. If possible to parse,
- // all files exist and are valid, add as attachments.
- // Otherwise, paste as text.
- paths := fsext.ParsePastedFiles(msg.Content)
- allExistsAndValid := func() bool {
- if len(paths) == 0 {
- return false
- }
- for _, path := range paths {
- if _, err := os.Stat(path); os.IsNotExist(err) {
- return false
- }
- lowerPath := strings.ToLower(path)
- isValid := false
- for _, ext := range common.AllowedImageTypes {
- if strings.HasSuffix(lowerPath, ext) {
- isValid = true
- break
- }
- }
- if !isValid {
- return false
- }
- }
- return true
- }
- if !allExistsAndValid() {
- var cmd tea.Cmd
- m.textarea, cmd = m.textarea.Update(msg)
- return cmd
- }
- var cmds []tea.Cmd
- for _, path := range paths {
- cmds = append(cmds, m.handleFilePathPaste(path))
- }
- return tea.Batch(cmds...)
- }
- func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
- var (
- lineCount = 0
- colCount = 0
- )
- for line := range strings.SplitSeq(msg.Content, "\n") {
- lineCount++
- colCount = max(colCount, len(line))
- if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
- return true
- }
- }
- return false
- }
- // handleFilePathPaste handles a pasted file path.
- func (m *UI) handleFilePathPaste(path string) tea.Cmd {
- return func() tea.Msg {
- fileInfo, err := os.Stat(path)
- if err != nil {
- return util.ReportError(err)
- }
- if fileInfo.IsDir() {
- return util.ReportWarn("Cannot attach a directory")
- }
- if fileInfo.Size() > common.MaxAttachmentSize {
- return util.ReportWarn("File is too big (>5mb)")
- }
- content, err := os.ReadFile(path)
- if err != nil {
- return util.ReportError(err)
- }
- mimeBufferSize := min(512, len(content))
- mimeType := http.DetectContentType(content[:mimeBufferSize])
- fileName := filepath.Base(path)
- return message.Attachment{
- FilePath: path,
- FileName: fileName,
- MimeType: mimeType,
- Content: content,
- }
- }
- }
- // pasteImageFromClipboard reads image data from the system clipboard and
- // creates an attachment. If no image data is found, it falls back to
- // interpreting clipboard text as a file path.
- func (m *UI) pasteImageFromClipboard() tea.Msg {
- imageData, err := readClipboard(clipboardFormatImage)
- if int64(len(imageData)) > common.MaxAttachmentSize {
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: "File too large, max 5MB",
- }
- }
- name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
- if err == nil {
- return message.Attachment{
- FilePath: name,
- FileName: name,
- MimeType: mimeOf(imageData),
- Content: imageData,
- }
- }
- textData, textErr := readClipboard(clipboardFormatText)
- if textErr != nil || len(textData) == 0 {
- return nil // Clipboard is empty or does not contain an image
- }
- path := strings.TrimSpace(string(textData))
- path = strings.ReplaceAll(path, "\\ ", " ")
- if _, statErr := os.Stat(path); statErr != nil {
- return nil // Clipboard does not contain an image or valid file path
- }
- lowerPath := strings.ToLower(path)
- isAllowed := false
- for _, ext := range common.AllowedImageTypes {
- if strings.HasSuffix(lowerPath, ext) {
- isAllowed = true
- break
- }
- }
- if !isAllowed {
- return util.NewInfoMsg("File type is not a supported image format")
- }
- fileInfo, statErr := os.Stat(path)
- if statErr != nil {
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: fmt.Sprintf("Unable to read file: %v", statErr),
- }
- }
- if fileInfo.Size() > common.MaxAttachmentSize {
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: "File too large, max 5MB",
- }
- }
- content, readErr := os.ReadFile(path)
- if readErr != nil {
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: fmt.Sprintf("Unable to read file: %v", readErr),
- }
- }
- return message.Attachment{
- FilePath: path,
- FileName: filepath.Base(path),
- MimeType: mimeOf(content),
- Content: content,
- }
- }
- var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
- func (m *UI) pasteIdx() int {
- result := 0
- for _, at := range m.attachments.List() {
- found := pasteRE.FindStringSubmatch(at.FileName)
- if len(found) == 0 {
- continue
- }
- idx, err := strconv.Atoi(found[1])
- if err == nil {
- result = max(result, idx)
- }
- }
- return result + 1
- }
- // drawSessionDetails draws the session details in compact mode.
- func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
- if m.session == nil {
- return
- }
- s := m.com.Styles
- width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
- height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
- title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
- blocks := []string{
- title,
- "",
- m.modelInfo(width),
- "",
- }
- detailsHeader := lipgloss.JoinVertical(
- lipgloss.Left,
- blocks...,
- )
- version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
- remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
- const maxSectionWidth = 50
- sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
- maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
- lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
- mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
- filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
- sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
- uv.NewStyledString(
- s.CompactDetails.View.
- Width(area.Dx()).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- detailsHeader,
- sections,
- version,
- ),
- ),
- ).Draw(scr, area)
- }
- func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
- load := func() tea.Msg {
- prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
- if err != nil {
- // TODO: make this better
- return util.ReportError(err)()
- }
- if prompt == "" {
- return nil
- }
- return sendMessageMsg{
- Content: prompt,
- }
- }
- var cmds []tea.Cmd
- if cmd := m.dialog.StartLoading(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- cmds = append(cmds, load, func() tea.Msg {
- return closeDialogMsg{}
- })
- return tea.Sequence(cmds...)
- }
- func (m *UI) handleStateChanged() tea.Cmd {
- return func() tea.Msg {
- m.com.App.UpdateAgentModel(context.Background())
- return mcpStateChangedMsg{
- states: mcp.GetStates(),
- }
- }
- }
- func handleMCPPromptsEvent(name string) tea.Cmd {
- return func() tea.Msg {
- mcp.RefreshPrompts(context.Background(), name)
- return nil
- }
- }
- func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
- return func() tea.Msg {
- mcp.RefreshTools(
- context.Background(),
- cfg,
- name,
- )
- return nil
- }
- }
- func handleMCPResourcesEvent(name string) tea.Cmd {
- return func() tea.Msg {
- mcp.RefreshResources(context.Background(), name)
- return nil
- }
- }
- func (m *UI) copyChatHighlight() tea.Cmd {
- text := m.chat.HighlightContent()
- return common.CopyToClipboardWithCallback(
- text,
- "Selected text copied to clipboard",
- func() tea.Msg {
- m.chat.ClearMouse()
- return nil
- },
- )
- }
- func (m *UI) enableDockerMCP() tea.Msg {
- store := m.com.Store()
- // Stage Docker MCP in memory first so startup and persistence can be atomic.
- mcpConfig, err := store.PrepareDockerMCPConfig()
- if err != nil {
- return util.ReportError(err)()
- }
- ctx := context.Background()
- if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
- // Roll back runtime and in-memory state when startup fails.
- disableErr := mcp.DisableSingle(store, config.DockerMCPName)
- delete(store.Config().MCP, config.DockerMCPName)
- return util.ReportError(fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr)))()
- }
- if err := store.PersistDockerMCPConfig(mcpConfig); err != nil {
- // Roll back runtime and in-memory state if persistence fails.
- disableErr := mcp.DisableSingle(store, config.DockerMCPName)
- delete(store.Config().MCP, config.DockerMCPName)
- return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr)))()
- }
- return util.NewInfoMsg("Docker MCP enabled and started successfully")
- }
- func (m *UI) disableDockerMCP() tea.Msg {
- store := m.com.Store()
- // Close the Docker MCP client.
- if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
- return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
- }
- // Remove from config and persist.
- if err := store.DisableDockerMCP(); err != nil {
- return util.ReportError(err)()
- }
- return util.NewInfoMsg("Docker MCP disabled successfully")
- }
- // renderLogo renders the Crush logo with the given styles and dimensions.
- func renderLogo(t *styles.Styles, compact bool, width int) string {
- return logo.Render(t, version.Version, compact, logo.Opts{
- FieldColor: t.LogoFieldColor,
- TitleColorA: t.LogoTitleColorA,
- TitleColorB: t.LogoTitleColorB,
- CharmColor: t.LogoCharmColor,
- VersionColor: t.LogoVersionColor,
- Width: width,
- })
- }
|