ui.go 94 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535
  1. package model
  2. import (
  3. "bytes"
  4. "cmp"
  5. "context"
  6. "errors"
  7. "fmt"
  8. "image"
  9. "log/slog"
  10. "math/rand"
  11. "net/http"
  12. "os"
  13. "path/filepath"
  14. "regexp"
  15. "slices"
  16. "strconv"
  17. "strings"
  18. "time"
  19. "charm.land/bubbles/v2/help"
  20. "charm.land/bubbles/v2/key"
  21. "charm.land/bubbles/v2/spinner"
  22. "charm.land/bubbles/v2/textarea"
  23. tea "charm.land/bubbletea/v2"
  24. "charm.land/catwalk/pkg/catwalk"
  25. "charm.land/lipgloss/v2"
  26. "github.com/charmbracelet/crush/internal/agent/notify"
  27. agenttools "github.com/charmbracelet/crush/internal/agent/tools"
  28. "github.com/charmbracelet/crush/internal/agent/tools/mcp"
  29. "github.com/charmbracelet/crush/internal/app"
  30. "github.com/charmbracelet/crush/internal/commands"
  31. "github.com/charmbracelet/crush/internal/config"
  32. "github.com/charmbracelet/crush/internal/fsext"
  33. "github.com/charmbracelet/crush/internal/history"
  34. "github.com/charmbracelet/crush/internal/home"
  35. "github.com/charmbracelet/crush/internal/message"
  36. "github.com/charmbracelet/crush/internal/permission"
  37. "github.com/charmbracelet/crush/internal/pubsub"
  38. "github.com/charmbracelet/crush/internal/session"
  39. "github.com/charmbracelet/crush/internal/ui/anim"
  40. "github.com/charmbracelet/crush/internal/ui/attachments"
  41. "github.com/charmbracelet/crush/internal/ui/chat"
  42. "github.com/charmbracelet/crush/internal/ui/common"
  43. "github.com/charmbracelet/crush/internal/ui/completions"
  44. "github.com/charmbracelet/crush/internal/ui/dialog"
  45. fimage "github.com/charmbracelet/crush/internal/ui/image"
  46. "github.com/charmbracelet/crush/internal/ui/logo"
  47. "github.com/charmbracelet/crush/internal/ui/notification"
  48. "github.com/charmbracelet/crush/internal/ui/styles"
  49. "github.com/charmbracelet/crush/internal/ui/util"
  50. "github.com/charmbracelet/crush/internal/version"
  51. uv "github.com/charmbracelet/ultraviolet"
  52. "github.com/charmbracelet/ultraviolet/layout"
  53. "github.com/charmbracelet/ultraviolet/screen"
  54. "github.com/charmbracelet/x/editor"
  55. )
  56. // MouseScrollThreshold defines how many lines to scroll the chat when a mouse
  57. // wheel event occurs.
  58. const MouseScrollThreshold = 5
  59. // Compact mode breakpoints.
  60. const (
  61. compactModeWidthBreakpoint = 120
  62. compactModeHeightBreakpoint = 30
  63. )
  64. // If pasted text has more than 10 newlines, treat it as a file attachment.
  65. const pasteLinesThreshold = 10
  66. // If pasted text has more than 1000 columns, treat it as a file attachment.
  67. const pasteColsThreshold = 1000
  68. // Session details panel max height.
  69. const sessionDetailsMaxHeight = 20
  70. // uiFocusState represents the current focus state of the UI.
  71. type uiFocusState uint8
  72. // Possible uiFocusState values.
  73. const (
  74. uiFocusNone uiFocusState = iota
  75. uiFocusEditor
  76. uiFocusMain
  77. )
  78. type uiState uint8
  79. // Possible uiState values.
  80. const (
  81. uiOnboarding uiState = iota
  82. uiInitialize
  83. uiLanding
  84. uiChat
  85. )
  86. type openEditorMsg struct {
  87. Text string
  88. }
  89. type (
  90. // cancelTimerExpiredMsg is sent when the cancel timer expires.
  91. cancelTimerExpiredMsg struct{}
  92. // userCommandsLoadedMsg is sent when user commands are loaded.
  93. userCommandsLoadedMsg struct {
  94. Commands []commands.CustomCommand
  95. }
  96. // mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
  97. mcpPromptsLoadedMsg struct {
  98. Prompts []commands.MCPPrompt
  99. }
  100. // mcpStateChangedMsg is sent when there is a change in MCP client states.
  101. mcpStateChangedMsg struct {
  102. states map[string]mcp.ClientInfo
  103. }
  104. // sendMessageMsg is sent to send a message.
  105. // currently only used for mcp prompts.
  106. sendMessageMsg struct {
  107. Content string
  108. Attachments []message.Attachment
  109. }
  110. // closeDialogMsg is sent to close the current dialog.
  111. closeDialogMsg struct{}
  112. // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
  113. copyChatHighlightMsg struct{}
  114. // sessionFilesUpdatesMsg is sent when the files for this session have been updated
  115. sessionFilesUpdatesMsg struct {
  116. sessionFiles []SessionFile
  117. }
  118. )
  119. // UI represents the main user interface model.
  120. type UI struct {
  121. com *common.Common
  122. session *session.Session
  123. sessionFiles []SessionFile
  124. // keeps track of read files while we don't have a session id
  125. sessionFileReads []string
  126. // initialSessionID is set when loading a specific session on startup.
  127. initialSessionID string
  128. // continueLastSession is set to continue the most recent session on startup.
  129. continueLastSession bool
  130. lastUserMessageTime int64
  131. // The width and height of the terminal in cells.
  132. width int
  133. height int
  134. layout uiLayout
  135. isTransparent bool
  136. focus uiFocusState
  137. state uiState
  138. keyMap KeyMap
  139. keyenh tea.KeyboardEnhancementsMsg
  140. dialog *dialog.Overlay
  141. status *Status
  142. // isCanceling tracks whether the user has pressed escape once to cancel.
  143. isCanceling bool
  144. header *header
  145. // sendProgressBar instructs the TUI to send progress bar updates to the
  146. // terminal.
  147. sendProgressBar bool
  148. progressBarEnabled bool
  149. // caps hold different terminal capabilities that we query for.
  150. caps common.Capabilities
  151. // Editor components
  152. textarea textarea.Model
  153. // Attachment list
  154. attachments *attachments.Attachments
  155. readyPlaceholder string
  156. workingPlaceholder string
  157. // Completions state
  158. completions *completions.Completions
  159. completionsOpen bool
  160. completionsStartIndex int
  161. completionsQuery string
  162. completionsPositionStart image.Point // x,y where user typed '@'
  163. // Chat components
  164. chat *Chat
  165. // onboarding state
  166. onboarding struct {
  167. yesInitializeSelected bool
  168. }
  169. // lsp
  170. lspStates map[string]app.LSPClientInfo
  171. // mcp
  172. mcpStates map[string]mcp.ClientInfo
  173. // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
  174. sidebarLogo string
  175. // Notification state
  176. notifyBackend notification.Backend
  177. notifyWindowFocused bool
  178. // custom commands & mcp commands
  179. customCommands []commands.CustomCommand
  180. mcpPrompts []commands.MCPPrompt
  181. // forceCompactMode tracks whether compact mode is forced by user toggle
  182. forceCompactMode bool
  183. // isCompact tracks whether we're currently in compact layout mode (either
  184. // by user toggle or auto-switch based on window size)
  185. isCompact bool
  186. // detailsOpen tracks whether the details panel is open (in compact mode)
  187. detailsOpen bool
  188. // pills state
  189. pillsExpanded bool
  190. focusedPillSection pillSection
  191. promptQueue int
  192. pillsView string
  193. // Todo spinner
  194. todoSpinner spinner.Model
  195. todoIsSpinning bool
  196. // mouse highlighting related state
  197. lastClickTime time.Time
  198. // Prompt history for up/down navigation through previous messages.
  199. promptHistory struct {
  200. messages []string
  201. index int
  202. draft string
  203. }
  204. }
  205. // New creates a new instance of the [UI] model.
  206. func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
  207. // Editor components
  208. ta := textarea.New()
  209. ta.SetStyles(com.Styles.TextArea)
  210. ta.ShowLineNumbers = false
  211. ta.CharLimit = -1
  212. ta.SetVirtualCursor(false)
  213. ta.Focus()
  214. ch := NewChat(com)
  215. keyMap := DefaultKeyMap()
  216. // Completions component
  217. comp := completions.New(
  218. com.Styles.Completions.Normal,
  219. com.Styles.Completions.Focused,
  220. com.Styles.Completions.Match,
  221. )
  222. todoSpinner := spinner.New(
  223. spinner.WithSpinner(spinner.MiniDot),
  224. spinner.WithStyle(com.Styles.Pills.TodoSpinner),
  225. )
  226. // Attachments component
  227. attachments := attachments.New(
  228. attachments.NewRenderer(
  229. com.Styles.Attachments.Normal,
  230. com.Styles.Attachments.Deleting,
  231. com.Styles.Attachments.Image,
  232. com.Styles.Attachments.Text,
  233. ),
  234. attachments.Keymap{
  235. DeleteMode: keyMap.Editor.AttachmentDeleteMode,
  236. DeleteAll: keyMap.Editor.DeleteAllAttachments,
  237. Escape: keyMap.Editor.Escape,
  238. },
  239. )
  240. header := newHeader(com)
  241. ui := &UI{
  242. com: com,
  243. dialog: dialog.NewOverlay(),
  244. keyMap: keyMap,
  245. textarea: ta,
  246. chat: ch,
  247. header: header,
  248. completions: comp,
  249. attachments: attachments,
  250. todoSpinner: todoSpinner,
  251. lspStates: make(map[string]app.LSPClientInfo),
  252. mcpStates: make(map[string]mcp.ClientInfo),
  253. notifyBackend: notification.NoopBackend{},
  254. notifyWindowFocused: true,
  255. initialSessionID: initialSessionID,
  256. continueLastSession: continueLast,
  257. }
  258. status := NewStatus(com, ui)
  259. ui.setEditorPrompt(false)
  260. ui.randomizePlaceholders()
  261. ui.textarea.Placeholder = ui.readyPlaceholder
  262. ui.status = status
  263. // Initialize compact mode from config
  264. ui.forceCompactMode = com.Config().Options.TUI.CompactMode
  265. // set onboarding state defaults
  266. ui.onboarding.yesInitializeSelected = true
  267. desiredState := uiLanding
  268. desiredFocus := uiFocusEditor
  269. if !com.Config().IsConfigured() {
  270. desiredState = uiOnboarding
  271. } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
  272. desiredState = uiInitialize
  273. }
  274. // set initial state
  275. ui.setState(desiredState, desiredFocus)
  276. opts := com.Config().Options
  277. // disable indeterminate progress bar
  278. ui.progressBarEnabled = opts.Progress == nil || *opts.Progress
  279. // enable transparent mode
  280. ui.isTransparent = opts.TUI.Transparent != nil && *opts.TUI.Transparent
  281. return ui
  282. }
  283. // Init initializes the UI model.
  284. func (m *UI) Init() tea.Cmd {
  285. var cmds []tea.Cmd
  286. if m.state == uiOnboarding {
  287. if cmd := m.openModelsDialog(); cmd != nil {
  288. cmds = append(cmds, cmd)
  289. }
  290. }
  291. // load the user commands async
  292. cmds = append(cmds, m.loadCustomCommands())
  293. // load prompt history async
  294. cmds = append(cmds, m.loadPromptHistory())
  295. // load initial session if specified
  296. if cmd := m.loadInitialSession(); cmd != nil {
  297. cmds = append(cmds, cmd)
  298. }
  299. return tea.Batch(cmds...)
  300. }
  301. // loadInitialSession loads the initial session if one was specified on startup.
  302. func (m *UI) loadInitialSession() tea.Cmd {
  303. switch {
  304. case m.state != uiLanding:
  305. // Only load if we're in landing state (i.e., fully configured)
  306. return nil
  307. case m.initialSessionID != "":
  308. return m.loadSession(m.initialSessionID)
  309. case m.continueLastSession:
  310. return func() tea.Msg {
  311. sess, err := m.com.App.Sessions.GetLast(context.Background())
  312. if err != nil {
  313. return nil
  314. }
  315. return m.loadSession(sess.ID)()
  316. }
  317. default:
  318. return nil
  319. }
  320. }
  321. // sendNotification returns a command that sends a notification if allowed by policy.
  322. func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
  323. if !m.shouldSendNotification() {
  324. return nil
  325. }
  326. backend := m.notifyBackend
  327. return func() tea.Msg {
  328. if err := backend.Send(n); err != nil {
  329. slog.Error("Failed to send notification", "error", err)
  330. }
  331. return nil
  332. }
  333. }
  334. // shouldSendNotification returns true if notifications should be sent based on
  335. // current state. Focus reporting must be supported, window must not focused,
  336. // and notifications must not be disabled in config.
  337. func (m *UI) shouldSendNotification() bool {
  338. cfg := m.com.Config()
  339. if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
  340. return false
  341. }
  342. return m.caps.ReportFocusEvents && !m.notifyWindowFocused
  343. }
  344. // setState changes the UI state and focus.
  345. func (m *UI) setState(state uiState, focus uiFocusState) {
  346. if state == uiLanding {
  347. // Always turn off compact mode when going to landing
  348. m.isCompact = false
  349. }
  350. m.state = state
  351. m.focus = focus
  352. // Changing the state may change layout, so update it.
  353. m.updateLayoutAndSize()
  354. }
  355. // loadCustomCommands loads the custom commands asynchronously.
  356. func (m *UI) loadCustomCommands() tea.Cmd {
  357. return func() tea.Msg {
  358. customCommands, err := commands.LoadCustomCommands(m.com.Config())
  359. if err != nil {
  360. slog.Error("Failed to load custom commands", "error", err)
  361. }
  362. return userCommandsLoadedMsg{Commands: customCommands}
  363. }
  364. }
  365. // loadMCPrompts loads the MCP prompts asynchronously.
  366. func (m *UI) loadMCPrompts() tea.Msg {
  367. prompts, err := commands.LoadMCPPrompts()
  368. if err != nil {
  369. slog.Error("Failed to load MCP prompts", "error", err)
  370. }
  371. if prompts == nil {
  372. // flag them as loaded even if there is none or an error
  373. prompts = []commands.MCPPrompt{}
  374. }
  375. return mcpPromptsLoadedMsg{Prompts: prompts}
  376. }
  377. // Update handles updates to the UI model.
  378. func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  379. var cmds []tea.Cmd
  380. if m.hasSession() && m.isAgentBusy() {
  381. queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
  382. if queueSize != m.promptQueue {
  383. m.promptQueue = queueSize
  384. m.updateLayoutAndSize()
  385. }
  386. }
  387. // Update terminal capabilities
  388. m.caps.Update(msg)
  389. switch msg := msg.(type) {
  390. case tea.EnvMsg:
  391. // Is this Windows Terminal?
  392. if !m.sendProgressBar {
  393. m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
  394. }
  395. cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
  396. case tea.ModeReportMsg:
  397. if m.caps.ReportFocusEvents {
  398. m.notifyBackend = notification.NewNativeBackend(notification.Icon)
  399. }
  400. case tea.FocusMsg:
  401. m.notifyWindowFocused = true
  402. case tea.BlurMsg:
  403. m.notifyWindowFocused = false
  404. case pubsub.Event[notify.Notification]:
  405. if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
  406. cmds = append(cmds, cmd)
  407. }
  408. case loadSessionMsg:
  409. if m.forceCompactMode {
  410. m.isCompact = true
  411. }
  412. m.setState(uiChat, m.focus)
  413. m.session = msg.session
  414. m.sessionFiles = msg.files
  415. cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
  416. msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
  417. if err != nil {
  418. cmds = append(cmds, util.ReportError(err))
  419. break
  420. }
  421. if cmd := m.setSessionMessages(msgs); cmd != nil {
  422. cmds = append(cmds, cmd)
  423. }
  424. if hasInProgressTodo(m.session.Todos) {
  425. // only start spinner if there is an in-progress todo
  426. if m.isAgentBusy() {
  427. m.todoIsSpinning = true
  428. cmds = append(cmds, m.todoSpinner.Tick)
  429. }
  430. m.updateLayoutAndSize()
  431. }
  432. // Reload prompt history for the new session.
  433. m.historyReset()
  434. cmds = append(cmds, m.loadPromptHistory())
  435. m.updateLayoutAndSize()
  436. case sessionFilesUpdatesMsg:
  437. m.sessionFiles = msg.sessionFiles
  438. var paths []string
  439. for _, f := range msg.sessionFiles {
  440. paths = append(paths, f.LatestVersion.Path)
  441. }
  442. cmds = append(cmds, m.startLSPs(paths))
  443. case sendMessageMsg:
  444. cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
  445. case userCommandsLoadedMsg:
  446. m.customCommands = msg.Commands
  447. dia := m.dialog.Dialog(dialog.CommandsID)
  448. if dia == nil {
  449. break
  450. }
  451. commands, ok := dia.(*dialog.Commands)
  452. if ok {
  453. commands.SetCustomCommands(m.customCommands)
  454. }
  455. case mcpStateChangedMsg:
  456. m.mcpStates = msg.states
  457. case mcpPromptsLoadedMsg:
  458. m.mcpPrompts = msg.Prompts
  459. dia := m.dialog.Dialog(dialog.CommandsID)
  460. if dia == nil {
  461. break
  462. }
  463. commands, ok := dia.(*dialog.Commands)
  464. if ok {
  465. commands.SetMCPPrompts(m.mcpPrompts)
  466. }
  467. case promptHistoryLoadedMsg:
  468. m.promptHistory.messages = msg.messages
  469. m.promptHistory.index = -1
  470. m.promptHistory.draft = ""
  471. case closeDialogMsg:
  472. m.dialog.CloseFrontDialog()
  473. case pubsub.Event[session.Session]:
  474. if msg.Type == pubsub.DeletedEvent {
  475. if m.session != nil && m.session.ID == msg.Payload.ID {
  476. if cmd := m.newSession(); cmd != nil {
  477. cmds = append(cmds, cmd)
  478. }
  479. }
  480. break
  481. }
  482. if m.session != nil && msg.Payload.ID == m.session.ID {
  483. prevHasInProgress := hasInProgressTodo(m.session.Todos)
  484. m.session = &msg.Payload
  485. if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
  486. m.todoIsSpinning = true
  487. cmds = append(cmds, m.todoSpinner.Tick)
  488. m.updateLayoutAndSize()
  489. }
  490. }
  491. case pubsub.Event[message.Message]:
  492. // Check if this is a child session message for an agent tool.
  493. if m.session == nil {
  494. break
  495. }
  496. if msg.Payload.SessionID != m.session.ID {
  497. // This might be a child session message from an agent tool.
  498. if cmd := m.handleChildSessionMessage(msg); cmd != nil {
  499. cmds = append(cmds, cmd)
  500. }
  501. break
  502. }
  503. switch msg.Type {
  504. case pubsub.CreatedEvent:
  505. cmds = append(cmds, m.appendSessionMessage(msg.Payload))
  506. case pubsub.UpdatedEvent:
  507. cmds = append(cmds, m.updateSessionMessage(msg.Payload))
  508. case pubsub.DeletedEvent:
  509. m.chat.RemoveMessage(msg.Payload.ID)
  510. }
  511. // start the spinner if there is a new message
  512. if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
  513. m.todoIsSpinning = true
  514. cmds = append(cmds, m.todoSpinner.Tick)
  515. }
  516. // stop the spinner if the agent is not busy anymore
  517. if m.todoIsSpinning && !m.isAgentBusy() {
  518. m.todoIsSpinning = false
  519. }
  520. // there is a number of things that could change the pills here so we want to re-render
  521. m.renderPills()
  522. case pubsub.Event[history.File]:
  523. cmds = append(cmds, m.handleFileEvent(msg.Payload))
  524. case pubsub.Event[app.LSPEvent]:
  525. m.lspStates = app.GetLSPStates()
  526. case pubsub.Event[mcp.Event]:
  527. switch msg.Payload.Type {
  528. case mcp.EventStateChanged:
  529. return m, tea.Batch(
  530. m.handleStateChanged(),
  531. m.loadMCPrompts,
  532. )
  533. case mcp.EventPromptsListChanged:
  534. return m, handleMCPPromptsEvent(msg.Payload.Name)
  535. case mcp.EventToolsListChanged:
  536. return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
  537. case mcp.EventResourcesListChanged:
  538. return m, handleMCPResourcesEvent(msg.Payload.Name)
  539. }
  540. case pubsub.Event[permission.PermissionRequest]:
  541. if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
  542. cmds = append(cmds, cmd)
  543. }
  544. if cmd := m.sendNotification(notification.Notification{
  545. Title: "Crush is waiting...",
  546. Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
  547. }); cmd != nil {
  548. cmds = append(cmds, cmd)
  549. }
  550. case pubsub.Event[permission.PermissionNotification]:
  551. m.handlePermissionNotification(msg.Payload)
  552. case cancelTimerExpiredMsg:
  553. m.isCanceling = false
  554. case tea.TerminalVersionMsg:
  555. termVersion := strings.ToLower(msg.Name)
  556. // Only enable progress bar for the following terminals.
  557. if !m.sendProgressBar {
  558. m.sendProgressBar = strings.Contains(termVersion, "ghostty")
  559. }
  560. return m, nil
  561. case tea.WindowSizeMsg:
  562. m.width, m.height = msg.Width, msg.Height
  563. m.updateLayoutAndSize()
  564. if m.state == uiChat && m.chat.Follow() {
  565. if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
  566. cmds = append(cmds, cmd)
  567. }
  568. }
  569. case tea.KeyboardEnhancementsMsg:
  570. m.keyenh = msg
  571. if msg.SupportsKeyDisambiguation() {
  572. m.keyMap.Models.SetHelp("ctrl+m", "models")
  573. m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
  574. }
  575. case copyChatHighlightMsg:
  576. cmds = append(cmds, m.copyChatHighlight())
  577. case DelayedClickMsg:
  578. // Handle delayed single-click action (e.g., expansion).
  579. m.chat.HandleDelayedClick(msg)
  580. case tea.MouseClickMsg:
  581. // Pass mouse events to dialogs first if any are open.
  582. if m.dialog.HasDialogs() {
  583. m.dialog.Update(msg)
  584. return m, tea.Batch(cmds...)
  585. }
  586. if cmd := m.handleClickFocus(msg); cmd != nil {
  587. cmds = append(cmds, cmd)
  588. }
  589. switch m.state {
  590. case uiChat:
  591. x, y := msg.X, msg.Y
  592. // Adjust for chat area position
  593. x -= m.layout.main.Min.X
  594. y -= m.layout.main.Min.Y
  595. if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
  596. if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
  597. m.lastClickTime = time.Now()
  598. if cmd != nil {
  599. cmds = append(cmds, cmd)
  600. }
  601. }
  602. }
  603. }
  604. case tea.MouseMotionMsg:
  605. // Pass mouse events to dialogs first if any are open.
  606. if m.dialog.HasDialogs() {
  607. m.dialog.Update(msg)
  608. return m, tea.Batch(cmds...)
  609. }
  610. switch m.state {
  611. case uiChat:
  612. if msg.Y <= 0 {
  613. if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
  614. cmds = append(cmds, cmd)
  615. }
  616. if !m.chat.SelectedItemInView() {
  617. m.chat.SelectPrev()
  618. if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
  619. cmds = append(cmds, cmd)
  620. }
  621. }
  622. } else if msg.Y >= m.chat.Height()-1 {
  623. if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
  624. cmds = append(cmds, cmd)
  625. }
  626. if !m.chat.SelectedItemInView() {
  627. m.chat.SelectNext()
  628. if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
  629. cmds = append(cmds, cmd)
  630. }
  631. }
  632. }
  633. x, y := msg.X, msg.Y
  634. // Adjust for chat area position
  635. x -= m.layout.main.Min.X
  636. y -= m.layout.main.Min.Y
  637. m.chat.HandleMouseDrag(x, y)
  638. }
  639. case tea.MouseReleaseMsg:
  640. // Pass mouse events to dialogs first if any are open.
  641. if m.dialog.HasDialogs() {
  642. m.dialog.Update(msg)
  643. return m, tea.Batch(cmds...)
  644. }
  645. switch m.state {
  646. case uiChat:
  647. x, y := msg.X, msg.Y
  648. // Adjust for chat area position
  649. x -= m.layout.main.Min.X
  650. y -= m.layout.main.Min.Y
  651. if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() {
  652. cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
  653. if time.Since(m.lastClickTime) >= doubleClickThreshold {
  654. return copyChatHighlightMsg{}
  655. }
  656. return nil
  657. }))
  658. }
  659. }
  660. case tea.MouseWheelMsg:
  661. // Pass mouse events to dialogs first if any are open.
  662. if m.dialog.HasDialogs() {
  663. m.dialog.Update(msg)
  664. return m, tea.Batch(cmds...)
  665. }
  666. // Otherwise handle mouse wheel for chat.
  667. switch m.state {
  668. case uiChat:
  669. switch msg.Button {
  670. case tea.MouseWheelUp:
  671. if cmd := m.chat.ScrollByAndAnimate(-MouseScrollThreshold); cmd != nil {
  672. cmds = append(cmds, cmd)
  673. }
  674. if !m.chat.SelectedItemInView() {
  675. m.chat.SelectPrev()
  676. if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
  677. cmds = append(cmds, cmd)
  678. }
  679. }
  680. case tea.MouseWheelDown:
  681. if cmd := m.chat.ScrollByAndAnimate(MouseScrollThreshold); cmd != nil {
  682. cmds = append(cmds, cmd)
  683. }
  684. if !m.chat.SelectedItemInView() {
  685. if m.chat.AtBottom() {
  686. m.chat.SelectLast()
  687. } else {
  688. m.chat.SelectNext()
  689. }
  690. if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
  691. cmds = append(cmds, cmd)
  692. }
  693. }
  694. }
  695. }
  696. case anim.StepMsg:
  697. if m.state == uiChat {
  698. if cmd := m.chat.Animate(msg); cmd != nil {
  699. cmds = append(cmds, cmd)
  700. }
  701. if m.chat.Follow() {
  702. if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
  703. cmds = append(cmds, cmd)
  704. }
  705. }
  706. }
  707. case spinner.TickMsg:
  708. if m.dialog.HasDialogs() {
  709. // route to dialog
  710. if cmd := m.handleDialogMsg(msg); cmd != nil {
  711. cmds = append(cmds, cmd)
  712. }
  713. }
  714. if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
  715. var cmd tea.Cmd
  716. m.todoSpinner, cmd = m.todoSpinner.Update(msg)
  717. if cmd != nil {
  718. m.renderPills()
  719. cmds = append(cmds, cmd)
  720. }
  721. }
  722. case tea.KeyPressMsg:
  723. if cmd := m.handleKeyPressMsg(msg); cmd != nil {
  724. cmds = append(cmds, cmd)
  725. }
  726. case tea.PasteMsg:
  727. if cmd := m.handlePasteMsg(msg); cmd != nil {
  728. cmds = append(cmds, cmd)
  729. }
  730. case openEditorMsg:
  731. var cmd tea.Cmd
  732. m.textarea.SetValue(msg.Text)
  733. m.textarea.MoveToEnd()
  734. m.textarea, cmd = m.textarea.Update(msg)
  735. if cmd != nil {
  736. cmds = append(cmds, cmd)
  737. }
  738. case util.InfoMsg:
  739. m.status.SetInfoMsg(msg)
  740. ttl := msg.TTL
  741. if ttl <= 0 {
  742. ttl = DefaultStatusTTL
  743. }
  744. cmds = append(cmds, clearInfoMsgCmd(ttl))
  745. case util.ClearStatusMsg:
  746. m.status.ClearInfoMsg()
  747. case completions.CompletionItemsLoadedMsg:
  748. if m.completionsOpen {
  749. m.completions.SetItems(msg.Files, msg.Resources)
  750. }
  751. case uv.KittyGraphicsEvent:
  752. if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
  753. slog.Warn("Unexpected Kitty graphics response",
  754. "response", string(msg.Payload),
  755. "options", msg.Options)
  756. }
  757. default:
  758. if m.dialog.HasDialogs() {
  759. if cmd := m.handleDialogMsg(msg); cmd != nil {
  760. cmds = append(cmds, cmd)
  761. }
  762. }
  763. }
  764. // This logic gets triggered on any message type, but should it?
  765. switch m.focus {
  766. case uiFocusMain:
  767. case uiFocusEditor:
  768. // Textarea placeholder logic
  769. if m.isAgentBusy() {
  770. m.textarea.Placeholder = m.workingPlaceholder
  771. } else {
  772. m.textarea.Placeholder = m.readyPlaceholder
  773. }
  774. if m.com.App.Permissions.SkipRequests() {
  775. m.textarea.Placeholder = "Yolo mode!"
  776. }
  777. }
  778. // at this point this can only handle [message.Attachment] message, and we
  779. // should return all cmds anyway.
  780. _ = m.attachments.Update(msg)
  781. return m, tea.Batch(cmds...)
  782. }
  783. // setSessionMessages sets the messages for the current session in the chat
  784. func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
  785. var cmds []tea.Cmd
  786. // Build tool result map to link tool calls with their results
  787. msgPtrs := make([]*message.Message, len(msgs))
  788. for i := range msgs {
  789. msgPtrs[i] = &msgs[i]
  790. }
  791. toolResultMap := chat.BuildToolResultMap(msgPtrs)
  792. if len(msgPtrs) > 0 {
  793. m.lastUserMessageTime = msgPtrs[0].CreatedAt
  794. }
  795. // Add messages to chat with linked tool results
  796. items := make([]chat.MessageItem, 0, len(msgs)*2)
  797. for _, msg := range msgPtrs {
  798. switch msg.Role {
  799. case message.User:
  800. m.lastUserMessageTime = msg.CreatedAt
  801. items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
  802. case message.Assistant:
  803. items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
  804. if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
  805. infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
  806. items = append(items, infoItem)
  807. }
  808. default:
  809. items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
  810. }
  811. }
  812. // Load nested tool calls for agent/agentic_fetch tools.
  813. m.loadNestedToolCalls(items)
  814. // If the user switches between sessions while the agent is working we want
  815. // to make sure the animations are shown.
  816. for _, item := range items {
  817. if animatable, ok := item.(chat.Animatable); ok {
  818. if cmd := animatable.StartAnimation(); cmd != nil {
  819. cmds = append(cmds, cmd)
  820. }
  821. }
  822. }
  823. m.chat.SetMessages(items...)
  824. if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
  825. cmds = append(cmds, cmd)
  826. }
  827. m.chat.SelectLast()
  828. return tea.Sequence(cmds...)
  829. }
  830. // loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
  831. func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
  832. for _, item := range items {
  833. nestedContainer, ok := item.(chat.NestedToolContainer)
  834. if !ok {
  835. continue
  836. }
  837. toolItem, ok := item.(chat.ToolMessageItem)
  838. if !ok {
  839. continue
  840. }
  841. tc := toolItem.ToolCall()
  842. messageID := toolItem.MessageID()
  843. // Get the agent tool session ID.
  844. agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
  845. // Fetch nested messages.
  846. nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
  847. if err != nil || len(nestedMsgs) == 0 {
  848. continue
  849. }
  850. // Build tool result map for nested messages.
  851. nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
  852. for i := range nestedMsgs {
  853. nestedMsgPtrs[i] = &nestedMsgs[i]
  854. }
  855. nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
  856. // Extract nested tool items.
  857. var nestedTools []chat.ToolMessageItem
  858. for _, nestedMsg := range nestedMsgPtrs {
  859. nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
  860. for _, nestedItem := range nestedItems {
  861. if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
  862. // Mark nested tools as simple (compact) rendering.
  863. if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
  864. simplifiable.SetCompact(true)
  865. }
  866. nestedTools = append(nestedTools, nestedToolItem)
  867. }
  868. }
  869. }
  870. // Recursively load nested tool calls for any agent tools within.
  871. nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
  872. for i, nt := range nestedTools {
  873. nestedMessageItems[i] = nt
  874. }
  875. m.loadNestedToolCalls(nestedMessageItems)
  876. // Set nested tools on the parent.
  877. nestedContainer.SetNestedTools(nestedTools)
  878. }
  879. }
  880. // appendSessionMessage appends a new message to the current session in the chat
  881. // if the message is a tool result it will update the corresponding tool call message
  882. func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
  883. var cmds []tea.Cmd
  884. existing := m.chat.MessageItem(msg.ID)
  885. if existing != nil {
  886. // message already exists, skip
  887. return nil
  888. }
  889. switch msg.Role {
  890. case message.User:
  891. m.lastUserMessageTime = msg.CreatedAt
  892. items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
  893. for _, item := range items {
  894. if animatable, ok := item.(chat.Animatable); ok {
  895. if cmd := animatable.StartAnimation(); cmd != nil {
  896. cmds = append(cmds, cmd)
  897. }
  898. }
  899. }
  900. m.chat.AppendMessages(items...)
  901. if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
  902. cmds = append(cmds, cmd)
  903. }
  904. case message.Assistant:
  905. items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
  906. for _, item := range items {
  907. if animatable, ok := item.(chat.Animatable); ok {
  908. if cmd := animatable.StartAnimation(); cmd != nil {
  909. cmds = append(cmds, cmd)
  910. }
  911. }
  912. }
  913. m.chat.AppendMessages(items...)
  914. if m.chat.Follow() {
  915. if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
  916. cmds = append(cmds, cmd)
  917. }
  918. }
  919. if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
  920. infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
  921. m.chat.AppendMessages(infoItem)
  922. if m.chat.Follow() {
  923. if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
  924. cmds = append(cmds, cmd)
  925. }
  926. }
  927. }
  928. case message.Tool:
  929. for _, tr := range msg.ToolResults() {
  930. toolItem := m.chat.MessageItem(tr.ToolCallID)
  931. if toolItem == nil {
  932. // we should have an item!
  933. continue
  934. }
  935. if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
  936. toolMsgItem.SetResult(&tr)
  937. if m.chat.Follow() {
  938. if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
  939. cmds = append(cmds, cmd)
  940. }
  941. }
  942. }
  943. }
  944. }
  945. return tea.Sequence(cmds...)
  946. }
  947. func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
  948. switch {
  949. case m.state != uiChat:
  950. return nil
  951. case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
  952. return nil
  953. case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
  954. m.focus = uiFocusEditor
  955. cmd = m.textarea.Focus()
  956. m.chat.Blur()
  957. case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
  958. m.focus = uiFocusMain
  959. m.textarea.Blur()
  960. m.chat.Focus()
  961. }
  962. return cmd
  963. }
  964. // updateSessionMessage updates an existing message in the current session in the chat
  965. // when an assistant message is updated it may include updated tool calls as well
  966. // that is why we need to handle creating/updating each tool call message too
  967. func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
  968. var cmds []tea.Cmd
  969. existingItem := m.chat.MessageItem(msg.ID)
  970. if existingItem != nil {
  971. if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
  972. assistantItem.SetMessage(&msg)
  973. }
  974. }
  975. shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
  976. // if the message of the assistant does not have any response just tool calls we need to remove it
  977. if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
  978. m.chat.RemoveMessage(msg.ID)
  979. if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
  980. m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
  981. }
  982. }
  983. if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
  984. if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
  985. newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
  986. m.chat.AppendMessages(newInfoItem)
  987. }
  988. }
  989. var items []chat.MessageItem
  990. for _, tc := range msg.ToolCalls() {
  991. existingToolItem := m.chat.MessageItem(tc.ID)
  992. if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
  993. existingToolCall := toolItem.ToolCall()
  994. // only update if finished state changed or input changed
  995. // to avoid clearing the cache
  996. if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
  997. toolItem.SetToolCall(tc)
  998. }
  999. }
  1000. if existingToolItem == nil {
  1001. items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
  1002. }
  1003. }
  1004. for _, item := range items {
  1005. if animatable, ok := item.(chat.Animatable); ok {
  1006. if cmd := animatable.StartAnimation(); cmd != nil {
  1007. cmds = append(cmds, cmd)
  1008. }
  1009. }
  1010. }
  1011. m.chat.AppendMessages(items...)
  1012. if m.chat.Follow() {
  1013. if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
  1014. cmds = append(cmds, cmd)
  1015. }
  1016. m.chat.SelectLast()
  1017. }
  1018. return tea.Sequence(cmds...)
  1019. }
  1020. // handleChildSessionMessage handles messages from child sessions (agent tools).
  1021. func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
  1022. var cmds []tea.Cmd
  1023. // Only process messages with tool calls or results.
  1024. if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
  1025. return nil
  1026. }
  1027. // Check if this is an agent tool session and parse it.
  1028. childSessionID := event.Payload.SessionID
  1029. _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
  1030. if !ok {
  1031. return nil
  1032. }
  1033. // Find the parent agent tool item.
  1034. var agentItem chat.NestedToolContainer
  1035. for i := 0; i < m.chat.Len(); i++ {
  1036. item := m.chat.MessageItem(toolCallID)
  1037. if item == nil {
  1038. continue
  1039. }
  1040. if agent, ok := item.(chat.NestedToolContainer); ok {
  1041. if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
  1042. if toolMessageItem.ToolCall().ID == toolCallID {
  1043. // Verify this agent belongs to the correct parent message.
  1044. // We can't directly check parentMessageID on the item, so we trust the session parsing.
  1045. agentItem = agent
  1046. break
  1047. }
  1048. }
  1049. }
  1050. }
  1051. if agentItem == nil {
  1052. return nil
  1053. }
  1054. // Get existing nested tools.
  1055. nestedTools := agentItem.NestedTools()
  1056. // Update or create nested tool calls.
  1057. for _, tc := range event.Payload.ToolCalls() {
  1058. found := false
  1059. for _, existingTool := range nestedTools {
  1060. if existingTool.ToolCall().ID == tc.ID {
  1061. existingTool.SetToolCall(tc)
  1062. found = true
  1063. break
  1064. }
  1065. }
  1066. if !found {
  1067. // Create a new nested tool item.
  1068. nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
  1069. if simplifiable, ok := nestedItem.(chat.Compactable); ok {
  1070. simplifiable.SetCompact(true)
  1071. }
  1072. if animatable, ok := nestedItem.(chat.Animatable); ok {
  1073. if cmd := animatable.StartAnimation(); cmd != nil {
  1074. cmds = append(cmds, cmd)
  1075. }
  1076. }
  1077. nestedTools = append(nestedTools, nestedItem)
  1078. }
  1079. }
  1080. // Update nested tool results.
  1081. for _, tr := range event.Payload.ToolResults() {
  1082. for _, nestedTool := range nestedTools {
  1083. if nestedTool.ToolCall().ID == tr.ToolCallID {
  1084. nestedTool.SetResult(&tr)
  1085. break
  1086. }
  1087. }
  1088. }
  1089. // Update the agent item with the new nested tools.
  1090. agentItem.SetNestedTools(nestedTools)
  1091. // Update the chat so it updates the index map for animations to work as expected
  1092. m.chat.UpdateNestedToolIDs(toolCallID)
  1093. if m.chat.Follow() {
  1094. if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
  1095. cmds = append(cmds, cmd)
  1096. }
  1097. m.chat.SelectLast()
  1098. }
  1099. return tea.Sequence(cmds...)
  1100. }
  1101. func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
  1102. var cmds []tea.Cmd
  1103. action := m.dialog.Update(msg)
  1104. if action == nil {
  1105. return tea.Batch(cmds...)
  1106. }
  1107. isOnboarding := m.state == uiOnboarding
  1108. switch msg := action.(type) {
  1109. // Generic dialog messages
  1110. case dialog.ActionClose:
  1111. if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
  1112. break
  1113. }
  1114. if m.dialog.ContainsDialog(dialog.FilePickerID) {
  1115. defer fimage.ResetCache()
  1116. }
  1117. m.dialog.CloseFrontDialog()
  1118. if isOnboarding {
  1119. if cmd := m.openModelsDialog(); cmd != nil {
  1120. cmds = append(cmds, cmd)
  1121. }
  1122. }
  1123. if m.focus == uiFocusEditor {
  1124. cmds = append(cmds, m.textarea.Focus())
  1125. }
  1126. case dialog.ActionCmd:
  1127. if msg.Cmd != nil {
  1128. cmds = append(cmds, msg.Cmd)
  1129. }
  1130. // Session dialog messages.
  1131. case dialog.ActionSelectSession:
  1132. m.dialog.CloseDialog(dialog.SessionsID)
  1133. cmds = append(cmds, m.loadSession(msg.Session.ID))
  1134. // Open dialog message.
  1135. case dialog.ActionOpenDialog:
  1136. m.dialog.CloseDialog(dialog.CommandsID)
  1137. if cmd := m.openDialog(msg.DialogID); cmd != nil {
  1138. cmds = append(cmds, cmd)
  1139. }
  1140. // Command dialog messages.
  1141. case dialog.ActionToggleYoloMode:
  1142. yolo := !m.com.App.Permissions.SkipRequests()
  1143. m.com.App.Permissions.SetSkipRequests(yolo)
  1144. m.setEditorPrompt(yolo)
  1145. m.dialog.CloseDialog(dialog.CommandsID)
  1146. case dialog.ActionToggleNotifications:
  1147. cfg := m.com.Config()
  1148. if cfg != nil && cfg.Options != nil {
  1149. disabled := !cfg.Options.DisableNotifications
  1150. cfg.Options.DisableNotifications = disabled
  1151. if err := m.com.Store().SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
  1152. cmds = append(cmds, util.ReportError(err))
  1153. } else {
  1154. status := "enabled"
  1155. if disabled {
  1156. status = "disabled"
  1157. }
  1158. cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
  1159. }
  1160. }
  1161. m.dialog.CloseDialog(dialog.CommandsID)
  1162. case dialog.ActionNewSession:
  1163. if m.isAgentBusy() {
  1164. cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
  1165. break
  1166. }
  1167. if cmd := m.newSession(); cmd != nil {
  1168. cmds = append(cmds, cmd)
  1169. }
  1170. m.dialog.CloseDialog(dialog.CommandsID)
  1171. case dialog.ActionSummarize:
  1172. if m.isAgentBusy() {
  1173. cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
  1174. break
  1175. }
  1176. cmds = append(cmds, func() tea.Msg {
  1177. err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
  1178. if err != nil {
  1179. return util.ReportError(err)()
  1180. }
  1181. return nil
  1182. })
  1183. m.dialog.CloseDialog(dialog.CommandsID)
  1184. case dialog.ActionToggleHelp:
  1185. m.status.ToggleHelp()
  1186. m.dialog.CloseDialog(dialog.CommandsID)
  1187. case dialog.ActionExternalEditor:
  1188. if m.isAgentBusy() {
  1189. cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
  1190. break
  1191. }
  1192. cmds = append(cmds, m.openEditor(m.textarea.Value()))
  1193. m.dialog.CloseDialog(dialog.CommandsID)
  1194. case dialog.ActionToggleCompactMode:
  1195. cmds = append(cmds, m.toggleCompactMode())
  1196. m.dialog.CloseDialog(dialog.CommandsID)
  1197. case dialog.ActionTogglePills:
  1198. if cmd := m.togglePillsExpanded(); cmd != nil {
  1199. cmds = append(cmds, cmd)
  1200. }
  1201. m.dialog.CloseDialog(dialog.CommandsID)
  1202. case dialog.ActionToggleThinking:
  1203. cmds = append(cmds, func() tea.Msg {
  1204. cfg := m.com.Config()
  1205. if cfg == nil {
  1206. return util.ReportError(errors.New("configuration not found"))()
  1207. }
  1208. agentCfg, ok := cfg.Agents[config.AgentCoder]
  1209. if !ok {
  1210. return util.ReportError(errors.New("agent configuration not found"))()
  1211. }
  1212. currentModel := cfg.Models[agentCfg.Model]
  1213. currentModel.Think = !currentModel.Think
  1214. if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
  1215. return util.ReportError(err)()
  1216. }
  1217. m.com.App.UpdateAgentModel(context.TODO())
  1218. status := "disabled"
  1219. if currentModel.Think {
  1220. status = "enabled"
  1221. }
  1222. return util.NewInfoMsg("Thinking mode " + status)
  1223. })
  1224. m.dialog.CloseDialog(dialog.CommandsID)
  1225. case dialog.ActionToggleTransparentBackground:
  1226. cmds = append(cmds, func() tea.Msg {
  1227. cfg := m.com.Config()
  1228. if cfg == nil {
  1229. return util.ReportError(errors.New("configuration not found"))()
  1230. }
  1231. isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
  1232. newValue := !isTransparent
  1233. if err := m.com.Store().SetTransparentBackground(config.ScopeGlobal, newValue); err != nil {
  1234. return util.ReportError(err)()
  1235. }
  1236. m.isTransparent = newValue
  1237. status := "disabled"
  1238. if newValue {
  1239. status = "enabled"
  1240. }
  1241. return util.NewInfoMsg("Transparent background " + status)
  1242. })
  1243. m.dialog.CloseDialog(dialog.CommandsID)
  1244. case dialog.ActionQuit:
  1245. cmds = append(cmds, tea.Quit)
  1246. case dialog.ActionEnableDockerMCP:
  1247. m.dialog.CloseDialog(dialog.CommandsID)
  1248. cmds = append(cmds, m.enableDockerMCP)
  1249. case dialog.ActionDisableDockerMCP:
  1250. m.dialog.CloseDialog(dialog.CommandsID)
  1251. cmds = append(cmds, m.disableDockerMCP)
  1252. case dialog.ActionInitializeProject:
  1253. if m.isAgentBusy() {
  1254. cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
  1255. break
  1256. }
  1257. cmds = append(cmds, m.initializeProject())
  1258. m.dialog.CloseDialog(dialog.CommandsID)
  1259. case dialog.ActionSelectModel:
  1260. if m.isAgentBusy() {
  1261. cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
  1262. break
  1263. }
  1264. cfg := m.com.Config()
  1265. if cfg == nil {
  1266. cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
  1267. break
  1268. }
  1269. var (
  1270. providerID = msg.Model.Provider
  1271. isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
  1272. isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
  1273. )
  1274. // Attempt to import GitHub Copilot tokens from VSCode if available.
  1275. if isCopilot && !isConfigured() && !msg.ReAuthenticate {
  1276. m.com.Store().ImportCopilot()
  1277. }
  1278. if !isConfigured() || msg.ReAuthenticate {
  1279. m.dialog.CloseDialog(dialog.ModelsID)
  1280. if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
  1281. cmds = append(cmds, cmd)
  1282. }
  1283. break
  1284. }
  1285. if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
  1286. cmds = append(cmds, util.ReportError(err))
  1287. } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
  1288. // Ensure small model is set is unset.
  1289. smallModel := m.com.App.GetDefaultSmallModel(providerID)
  1290. if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
  1291. cmds = append(cmds, util.ReportError(err))
  1292. }
  1293. }
  1294. cmds = append(cmds, func() tea.Msg {
  1295. if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
  1296. return util.ReportError(err)
  1297. }
  1298. modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
  1299. return util.NewInfoMsg(modelMsg)
  1300. })
  1301. m.dialog.CloseDialog(dialog.APIKeyInputID)
  1302. m.dialog.CloseDialog(dialog.OAuthID)
  1303. m.dialog.CloseDialog(dialog.ModelsID)
  1304. if isOnboarding {
  1305. m.setState(uiLanding, uiFocusEditor)
  1306. m.com.Config().SetupAgents()
  1307. if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
  1308. cmds = append(cmds, util.ReportError(err))
  1309. }
  1310. }
  1311. case dialog.ActionSelectReasoningEffort:
  1312. if m.isAgentBusy() {
  1313. cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
  1314. break
  1315. }
  1316. cfg := m.com.Config()
  1317. if cfg == nil {
  1318. cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
  1319. break
  1320. }
  1321. agentCfg, ok := cfg.Agents[config.AgentCoder]
  1322. if !ok {
  1323. cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
  1324. break
  1325. }
  1326. currentModel := cfg.Models[agentCfg.Model]
  1327. currentModel.ReasoningEffort = msg.Effort
  1328. if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
  1329. cmds = append(cmds, util.ReportError(err))
  1330. break
  1331. }
  1332. cmds = append(cmds, func() tea.Msg {
  1333. m.com.App.UpdateAgentModel(context.TODO())
  1334. return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
  1335. })
  1336. m.dialog.CloseDialog(dialog.ReasoningID)
  1337. case dialog.ActionPermissionResponse:
  1338. m.dialog.CloseDialog(dialog.PermissionsID)
  1339. switch msg.Action {
  1340. case dialog.PermissionAllow:
  1341. m.com.App.Permissions.Grant(msg.Permission)
  1342. case dialog.PermissionAllowForSession:
  1343. m.com.App.Permissions.GrantPersistent(msg.Permission)
  1344. case dialog.PermissionDeny:
  1345. m.com.App.Permissions.Deny(msg.Permission)
  1346. }
  1347. case dialog.ActionFilePickerSelected:
  1348. cmds = append(cmds, tea.Sequence(
  1349. msg.Cmd(),
  1350. func() tea.Msg {
  1351. m.dialog.CloseDialog(dialog.FilePickerID)
  1352. return nil
  1353. },
  1354. func() tea.Msg {
  1355. fimage.ResetCache()
  1356. return nil
  1357. },
  1358. ))
  1359. case dialog.ActionRunCustomCommand:
  1360. if len(msg.Arguments) > 0 && msg.Args == nil {
  1361. m.dialog.CloseFrontDialog()
  1362. argsDialog := dialog.NewArguments(
  1363. m.com,
  1364. "Custom Command Arguments",
  1365. "",
  1366. msg.Arguments,
  1367. msg, // Pass the action as the result
  1368. )
  1369. m.dialog.OpenDialog(argsDialog)
  1370. break
  1371. }
  1372. content := msg.Content
  1373. if msg.Args != nil {
  1374. content = substituteArgs(content, msg.Args)
  1375. }
  1376. cmds = append(cmds, m.sendMessage(content))
  1377. m.dialog.CloseFrontDialog()
  1378. case dialog.ActionRunMCPPrompt:
  1379. if len(msg.Arguments) > 0 && msg.Args == nil {
  1380. m.dialog.CloseFrontDialog()
  1381. title := cmp.Or(msg.Title, "MCP Prompt Arguments")
  1382. argsDialog := dialog.NewArguments(
  1383. m.com,
  1384. title,
  1385. msg.Description,
  1386. msg.Arguments,
  1387. msg, // Pass the action as the result
  1388. )
  1389. m.dialog.OpenDialog(argsDialog)
  1390. break
  1391. }
  1392. cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
  1393. default:
  1394. cmds = append(cmds, util.CmdHandler(msg))
  1395. }
  1396. return tea.Batch(cmds...)
  1397. }
  1398. // substituteArgs replaces $ARG_NAME placeholders in content with actual values.
  1399. func substituteArgs(content string, args map[string]string) string {
  1400. for name, value := range args {
  1401. placeholder := "$" + name
  1402. content = strings.ReplaceAll(content, placeholder, value)
  1403. }
  1404. return content
  1405. }
  1406. func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
  1407. var (
  1408. dlg dialog.Dialog
  1409. cmd tea.Cmd
  1410. isOnboarding = m.state == uiOnboarding
  1411. )
  1412. switch provider.ID {
  1413. case "hyper":
  1414. dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
  1415. case catwalk.InferenceProviderCopilot:
  1416. dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
  1417. default:
  1418. dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
  1419. }
  1420. if m.dialog.ContainsDialog(dlg.ID()) {
  1421. m.dialog.BringToFront(dlg.ID())
  1422. return nil
  1423. }
  1424. m.dialog.OpenDialog(dlg)
  1425. return cmd
  1426. }
  1427. func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
  1428. var cmds []tea.Cmd
  1429. handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
  1430. switch {
  1431. case key.Matches(msg, m.keyMap.Help):
  1432. m.status.ToggleHelp()
  1433. m.updateLayoutAndSize()
  1434. return true
  1435. case key.Matches(msg, m.keyMap.Commands):
  1436. if cmd := m.openCommandsDialog(); cmd != nil {
  1437. cmds = append(cmds, cmd)
  1438. }
  1439. return true
  1440. case key.Matches(msg, m.keyMap.Models):
  1441. if cmd := m.openModelsDialog(); cmd != nil {
  1442. cmds = append(cmds, cmd)
  1443. }
  1444. return true
  1445. case key.Matches(msg, m.keyMap.Sessions):
  1446. if cmd := m.openSessionsDialog(); cmd != nil {
  1447. cmds = append(cmds, cmd)
  1448. }
  1449. return true
  1450. case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
  1451. m.detailsOpen = !m.detailsOpen
  1452. m.updateLayoutAndSize()
  1453. return true
  1454. case key.Matches(msg, m.keyMap.Chat.TogglePills):
  1455. if m.state == uiChat && m.hasSession() {
  1456. if cmd := m.togglePillsExpanded(); cmd != nil {
  1457. cmds = append(cmds, cmd)
  1458. }
  1459. return true
  1460. }
  1461. case key.Matches(msg, m.keyMap.Chat.PillLeft):
  1462. if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
  1463. if cmd := m.switchPillSection(-1); cmd != nil {
  1464. cmds = append(cmds, cmd)
  1465. }
  1466. return true
  1467. }
  1468. case key.Matches(msg, m.keyMap.Chat.PillRight):
  1469. if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
  1470. if cmd := m.switchPillSection(1); cmd != nil {
  1471. cmds = append(cmds, cmd)
  1472. }
  1473. return true
  1474. }
  1475. case key.Matches(msg, m.keyMap.Suspend):
  1476. if m.isAgentBusy() {
  1477. cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
  1478. return true
  1479. }
  1480. cmds = append(cmds, tea.Suspend)
  1481. return true
  1482. }
  1483. return false
  1484. }
  1485. if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
  1486. // Always handle quit keys first
  1487. if cmd := m.openQuitDialog(); cmd != nil {
  1488. cmds = append(cmds, cmd)
  1489. }
  1490. return tea.Batch(cmds...)
  1491. }
  1492. // Route all messages to dialog if one is open.
  1493. if m.dialog.HasDialogs() {
  1494. return m.handleDialogMsg(msg)
  1495. }
  1496. // Handle cancel key when agent is busy.
  1497. if key.Matches(msg, m.keyMap.Chat.Cancel) {
  1498. if m.isAgentBusy() {
  1499. if cmd := m.cancelAgent(); cmd != nil {
  1500. cmds = append(cmds, cmd)
  1501. }
  1502. return tea.Batch(cmds...)
  1503. }
  1504. }
  1505. switch m.state {
  1506. case uiOnboarding:
  1507. return tea.Batch(cmds...)
  1508. case uiInitialize:
  1509. cmds = append(cmds, m.updateInitializeView(msg)...)
  1510. return tea.Batch(cmds...)
  1511. case uiChat, uiLanding:
  1512. switch m.focus {
  1513. case uiFocusEditor:
  1514. // Handle completions if open.
  1515. if m.completionsOpen {
  1516. if msg, ok := m.completions.Update(msg); ok {
  1517. switch msg := msg.(type) {
  1518. case completions.SelectionMsg[completions.FileCompletionValue]:
  1519. cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
  1520. if !msg.KeepOpen {
  1521. m.closeCompletions()
  1522. }
  1523. case completions.SelectionMsg[completions.ResourceCompletionValue]:
  1524. cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
  1525. if !msg.KeepOpen {
  1526. m.closeCompletions()
  1527. }
  1528. case completions.ClosedMsg:
  1529. m.completionsOpen = false
  1530. }
  1531. return tea.Batch(cmds...)
  1532. }
  1533. }
  1534. if ok := m.attachments.Update(msg); ok {
  1535. return tea.Batch(cmds...)
  1536. }
  1537. switch {
  1538. case key.Matches(msg, m.keyMap.Editor.AddImage):
  1539. if cmd := m.openFilesDialog(); cmd != nil {
  1540. cmds = append(cmds, cmd)
  1541. }
  1542. case key.Matches(msg, m.keyMap.Editor.PasteImage):
  1543. cmds = append(cmds, m.pasteImageFromClipboard)
  1544. case key.Matches(msg, m.keyMap.Editor.SendMessage):
  1545. value := m.textarea.Value()
  1546. if before, ok := strings.CutSuffix(value, "\\"); ok {
  1547. // If the last character is a backslash, remove it and add a newline.
  1548. m.textarea.SetValue(before)
  1549. break
  1550. }
  1551. // Otherwise, send the message
  1552. m.textarea.Reset()
  1553. value = strings.TrimSpace(value)
  1554. if value == "exit" || value == "quit" {
  1555. return m.openQuitDialog()
  1556. }
  1557. attachments := m.attachments.List()
  1558. m.attachments.Reset()
  1559. if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
  1560. return nil
  1561. }
  1562. m.randomizePlaceholders()
  1563. m.historyReset()
  1564. return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
  1565. case key.Matches(msg, m.keyMap.Chat.NewSession):
  1566. if !m.hasSession() {
  1567. break
  1568. }
  1569. if m.isAgentBusy() {
  1570. cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
  1571. break
  1572. }
  1573. if cmd := m.newSession(); cmd != nil {
  1574. cmds = append(cmds, cmd)
  1575. }
  1576. case key.Matches(msg, m.keyMap.Tab):
  1577. if m.state != uiLanding {
  1578. m.setState(m.state, uiFocusMain)
  1579. m.textarea.Blur()
  1580. m.chat.Focus()
  1581. m.chat.SetSelected(m.chat.Len() - 1)
  1582. }
  1583. case key.Matches(msg, m.keyMap.Editor.OpenEditor):
  1584. if m.isAgentBusy() {
  1585. cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
  1586. break
  1587. }
  1588. cmds = append(cmds, m.openEditor(m.textarea.Value()))
  1589. case key.Matches(msg, m.keyMap.Editor.Newline):
  1590. m.textarea.InsertRune('\n')
  1591. m.closeCompletions()
  1592. ta, cmd := m.textarea.Update(msg)
  1593. m.textarea = ta
  1594. cmds = append(cmds, cmd)
  1595. case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
  1596. cmd := m.handleHistoryUp(msg)
  1597. if cmd != nil {
  1598. cmds = append(cmds, cmd)
  1599. }
  1600. case key.Matches(msg, m.keyMap.Editor.HistoryNext):
  1601. cmd := m.handleHistoryDown(msg)
  1602. if cmd != nil {
  1603. cmds = append(cmds, cmd)
  1604. }
  1605. case key.Matches(msg, m.keyMap.Editor.Escape):
  1606. cmd := m.handleHistoryEscape(msg)
  1607. if cmd != nil {
  1608. cmds = append(cmds, cmd)
  1609. }
  1610. case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
  1611. if cmd := m.openCommandsDialog(); cmd != nil {
  1612. cmds = append(cmds, cmd)
  1613. }
  1614. default:
  1615. if handleGlobalKeys(msg) {
  1616. // Handle global keys first before passing to textarea.
  1617. break
  1618. }
  1619. // Check for @ trigger before passing to textarea.
  1620. curValue := m.textarea.Value()
  1621. curIdx := len(curValue)
  1622. // Trigger completions on @.
  1623. if msg.String() == "@" && !m.completionsOpen {
  1624. // Only show if beginning of prompt or after whitespace.
  1625. if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
  1626. m.completionsOpen = true
  1627. m.completionsQuery = ""
  1628. m.completionsStartIndex = curIdx
  1629. m.completionsPositionStart = m.completionsPosition()
  1630. depth, limit := m.com.Config().Options.TUI.Completions.Limits()
  1631. cmds = append(cmds, m.completions.Open(depth, limit))
  1632. }
  1633. }
  1634. // remove the details if they are open when user starts typing
  1635. if m.detailsOpen {
  1636. m.detailsOpen = false
  1637. m.updateLayoutAndSize()
  1638. }
  1639. ta, cmd := m.textarea.Update(msg)
  1640. m.textarea = ta
  1641. cmds = append(cmds, cmd)
  1642. // Any text modification becomes the current draft.
  1643. m.updateHistoryDraft(curValue)
  1644. // After updating textarea, check if we need to filter completions.
  1645. // Skip filtering on the initial @ keystroke since items are loading async.
  1646. if m.completionsOpen && msg.String() != "@" {
  1647. newValue := m.textarea.Value()
  1648. newIdx := len(newValue)
  1649. // Close completions if cursor moved before start.
  1650. if newIdx <= m.completionsStartIndex {
  1651. m.closeCompletions()
  1652. } else if msg.String() == "space" {
  1653. // Close on space.
  1654. m.closeCompletions()
  1655. } else {
  1656. // Extract current word and filter.
  1657. word := m.textareaWord()
  1658. if strings.HasPrefix(word, "@") {
  1659. m.completionsQuery = word[1:]
  1660. m.completions.Filter(m.completionsQuery)
  1661. } else if m.completionsOpen {
  1662. m.closeCompletions()
  1663. }
  1664. }
  1665. }
  1666. }
  1667. case uiFocusMain:
  1668. switch {
  1669. case key.Matches(msg, m.keyMap.Tab):
  1670. m.focus = uiFocusEditor
  1671. cmds = append(cmds, m.textarea.Focus())
  1672. m.chat.Blur()
  1673. case key.Matches(msg, m.keyMap.Chat.NewSession):
  1674. if !m.hasSession() {
  1675. break
  1676. }
  1677. if m.isAgentBusy() {
  1678. cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
  1679. break
  1680. }
  1681. m.focus = uiFocusEditor
  1682. if cmd := m.newSession(); cmd != nil {
  1683. cmds = append(cmds, cmd)
  1684. }
  1685. case key.Matches(msg, m.keyMap.Chat.Expand):
  1686. m.chat.ToggleExpandedSelectedItem()
  1687. case key.Matches(msg, m.keyMap.Chat.Up):
  1688. if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
  1689. cmds = append(cmds, cmd)
  1690. }
  1691. if !m.chat.SelectedItemInView() {
  1692. m.chat.SelectPrev()
  1693. if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
  1694. cmds = append(cmds, cmd)
  1695. }
  1696. }
  1697. case key.Matches(msg, m.keyMap.Chat.Down):
  1698. if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
  1699. cmds = append(cmds, cmd)
  1700. }
  1701. if !m.chat.SelectedItemInView() {
  1702. m.chat.SelectNext()
  1703. if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
  1704. cmds = append(cmds, cmd)
  1705. }
  1706. }
  1707. case key.Matches(msg, m.keyMap.Chat.UpOneItem):
  1708. m.chat.SelectPrev()
  1709. if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
  1710. cmds = append(cmds, cmd)
  1711. }
  1712. case key.Matches(msg, m.keyMap.Chat.DownOneItem):
  1713. m.chat.SelectNext()
  1714. if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
  1715. cmds = append(cmds, cmd)
  1716. }
  1717. case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
  1718. if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
  1719. cmds = append(cmds, cmd)
  1720. }
  1721. m.chat.SelectFirstInView()
  1722. case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
  1723. if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
  1724. cmds = append(cmds, cmd)
  1725. }
  1726. m.chat.SelectLastInView()
  1727. case key.Matches(msg, m.keyMap.Chat.PageUp):
  1728. if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
  1729. cmds = append(cmds, cmd)
  1730. }
  1731. m.chat.SelectFirstInView()
  1732. case key.Matches(msg, m.keyMap.Chat.PageDown):
  1733. if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
  1734. cmds = append(cmds, cmd)
  1735. }
  1736. m.chat.SelectLastInView()
  1737. case key.Matches(msg, m.keyMap.Chat.Home):
  1738. if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
  1739. cmds = append(cmds, cmd)
  1740. }
  1741. m.chat.SelectFirst()
  1742. case key.Matches(msg, m.keyMap.Chat.End):
  1743. if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
  1744. cmds = append(cmds, cmd)
  1745. }
  1746. m.chat.SelectLast()
  1747. default:
  1748. if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
  1749. cmds = append(cmds, cmd)
  1750. } else {
  1751. handleGlobalKeys(msg)
  1752. }
  1753. }
  1754. default:
  1755. handleGlobalKeys(msg)
  1756. }
  1757. default:
  1758. handleGlobalKeys(msg)
  1759. }
  1760. return tea.Sequence(cmds...)
  1761. }
  1762. // drawHeader draws the header section of the UI.
  1763. func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
  1764. m.header.drawHeader(
  1765. scr,
  1766. area,
  1767. m.session,
  1768. m.isCompact,
  1769. m.detailsOpen,
  1770. area.Dx(),
  1771. )
  1772. }
  1773. // Draw implements [uv.Drawable] and draws the UI model.
  1774. func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
  1775. layout := m.generateLayout(area.Dx(), area.Dy())
  1776. if m.layout != layout {
  1777. m.layout = layout
  1778. m.updateSize()
  1779. }
  1780. // Clear the screen first
  1781. screen.Clear(scr)
  1782. switch m.state {
  1783. case uiOnboarding:
  1784. m.drawHeader(scr, layout.header)
  1785. // NOTE: Onboarding flow will be rendered as dialogs below, but
  1786. // positioned at the bottom left of the screen.
  1787. case uiInitialize:
  1788. m.drawHeader(scr, layout.header)
  1789. main := uv.NewStyledString(m.initializeView())
  1790. main.Draw(scr, layout.main)
  1791. case uiLanding:
  1792. m.drawHeader(scr, layout.header)
  1793. main := uv.NewStyledString(m.landingView())
  1794. main.Draw(scr, layout.main)
  1795. editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
  1796. editor.Draw(scr, layout.editor)
  1797. case uiChat:
  1798. if m.isCompact {
  1799. m.drawHeader(scr, layout.header)
  1800. } else {
  1801. m.drawSidebar(scr, layout.sidebar)
  1802. }
  1803. m.chat.Draw(scr, layout.main)
  1804. if layout.pills.Dy() > 0 && m.pillsView != "" {
  1805. uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
  1806. }
  1807. editorWidth := scr.Bounds().Dx()
  1808. if !m.isCompact {
  1809. editorWidth -= layout.sidebar.Dx()
  1810. }
  1811. editor := uv.NewStyledString(m.renderEditorView(editorWidth))
  1812. editor.Draw(scr, layout.editor)
  1813. // Draw details overlay in compact mode when open
  1814. if m.isCompact && m.detailsOpen {
  1815. m.drawSessionDetails(scr, layout.sessionDetails)
  1816. }
  1817. }
  1818. isOnboarding := m.state == uiOnboarding
  1819. // Add status and help layer
  1820. m.status.SetHideHelp(isOnboarding)
  1821. m.status.Draw(scr, layout.status)
  1822. // Draw completions popup if open
  1823. if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
  1824. w, h := m.completions.Size()
  1825. x := m.completionsPositionStart.X
  1826. y := m.completionsPositionStart.Y - h
  1827. screenW := area.Dx()
  1828. if x+w > screenW {
  1829. x = screenW - w
  1830. }
  1831. x = max(0, x)
  1832. y = max(0, y+1) // Offset for attachments row
  1833. completionsView := uv.NewStyledString(m.completions.Render())
  1834. completionsView.Draw(scr, image.Rectangle{
  1835. Min: image.Pt(x, y),
  1836. Max: image.Pt(x+w, y+h),
  1837. })
  1838. }
  1839. // Debugging rendering (visually see when the tui rerenders)
  1840. if os.Getenv("CRUSH_UI_DEBUG") == "true" {
  1841. debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
  1842. debug := uv.NewStyledString(debugView.String())
  1843. debug.Draw(scr, image.Rectangle{
  1844. Min: image.Pt(4, 1),
  1845. Max: image.Pt(8, 3),
  1846. })
  1847. }
  1848. // This needs to come last to overlay on top of everything. We always pass
  1849. // the full screen bounds because the dialogs will position themselves
  1850. // accordingly.
  1851. if m.dialog.HasDialogs() {
  1852. return m.dialog.Draw(scr, scr.Bounds())
  1853. }
  1854. switch m.focus {
  1855. case uiFocusEditor:
  1856. if m.layout.editor.Dy() <= 0 {
  1857. // Don't show cursor if editor is not visible
  1858. return nil
  1859. }
  1860. if m.detailsOpen && m.isCompact {
  1861. // Don't show cursor if details overlay is open
  1862. return nil
  1863. }
  1864. if m.textarea.Focused() {
  1865. cur := m.textarea.Cursor()
  1866. cur.X++ // Adjust for app margins
  1867. cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
  1868. return cur
  1869. }
  1870. }
  1871. return nil
  1872. }
  1873. // View renders the UI model's view.
  1874. func (m *UI) View() tea.View {
  1875. var v tea.View
  1876. v.AltScreen = true
  1877. if !m.isTransparent {
  1878. v.BackgroundColor = m.com.Styles.Background
  1879. }
  1880. v.MouseMode = tea.MouseModeCellMotion
  1881. v.ReportFocus = m.caps.ReportFocusEvents
  1882. v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
  1883. canvas := uv.NewScreenBuffer(m.width, m.height)
  1884. v.Cursor = m.Draw(canvas, canvas.Bounds())
  1885. content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
  1886. contentLines := strings.Split(content, "\n")
  1887. for i, line := range contentLines {
  1888. // Trim trailing spaces for concise rendering
  1889. contentLines[i] = strings.TrimRight(line, " ")
  1890. }
  1891. content = strings.Join(contentLines, "\n")
  1892. v.Content = content
  1893. if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
  1894. // HACK: use a random percentage to prevent ghostty from hiding it
  1895. // after a timeout.
  1896. v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
  1897. }
  1898. return v
  1899. }
  1900. // ShortHelp implements [help.KeyMap].
  1901. func (m *UI) ShortHelp() []key.Binding {
  1902. var binds []key.Binding
  1903. k := &m.keyMap
  1904. tab := k.Tab
  1905. commands := k.Commands
  1906. if m.focus == uiFocusEditor && m.textarea.Value() == "" {
  1907. commands.SetHelp("/ or ctrl+p", "commands")
  1908. }
  1909. switch m.state {
  1910. case uiInitialize:
  1911. binds = append(binds, k.Quit)
  1912. case uiChat:
  1913. // Show cancel binding if agent is busy.
  1914. if m.isAgentBusy() {
  1915. cancelBinding := k.Chat.Cancel
  1916. if m.isCanceling {
  1917. cancelBinding.SetHelp("esc", "press again to cancel")
  1918. } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
  1919. cancelBinding.SetHelp("esc", "clear queue")
  1920. }
  1921. binds = append(binds, cancelBinding)
  1922. }
  1923. if m.focus == uiFocusEditor {
  1924. tab.SetHelp("tab", "focus chat")
  1925. } else {
  1926. tab.SetHelp("tab", "focus editor")
  1927. }
  1928. binds = append(binds,
  1929. tab,
  1930. commands,
  1931. k.Models,
  1932. )
  1933. switch m.focus {
  1934. case uiFocusEditor:
  1935. binds = append(binds,
  1936. k.Editor.Newline,
  1937. )
  1938. case uiFocusMain:
  1939. binds = append(binds,
  1940. k.Chat.UpDown,
  1941. k.Chat.UpDownOneItem,
  1942. k.Chat.PageUp,
  1943. k.Chat.PageDown,
  1944. k.Chat.Copy,
  1945. )
  1946. if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
  1947. binds = append(binds, k.Chat.PillLeft)
  1948. }
  1949. }
  1950. default:
  1951. // TODO: other states
  1952. // if m.session == nil {
  1953. // no session selected
  1954. binds = append(binds,
  1955. commands,
  1956. k.Models,
  1957. k.Editor.Newline,
  1958. )
  1959. }
  1960. binds = append(binds,
  1961. k.Quit,
  1962. k.Help,
  1963. )
  1964. return binds
  1965. }
  1966. // FullHelp implements [help.KeyMap].
  1967. func (m *UI) FullHelp() [][]key.Binding {
  1968. var binds [][]key.Binding
  1969. k := &m.keyMap
  1970. help := k.Help
  1971. help.SetHelp("ctrl+g", "less")
  1972. hasAttachments := len(m.attachments.List()) > 0
  1973. hasSession := m.hasSession()
  1974. commands := k.Commands
  1975. if m.focus == uiFocusEditor && m.textarea.Value() == "" {
  1976. commands.SetHelp("/ or ctrl+p", "commands")
  1977. }
  1978. switch m.state {
  1979. case uiInitialize:
  1980. binds = append(binds,
  1981. []key.Binding{
  1982. k.Quit,
  1983. })
  1984. case uiChat:
  1985. // Show cancel binding if agent is busy.
  1986. if m.isAgentBusy() {
  1987. cancelBinding := k.Chat.Cancel
  1988. if m.isCanceling {
  1989. cancelBinding.SetHelp("esc", "press again to cancel")
  1990. } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
  1991. cancelBinding.SetHelp("esc", "clear queue")
  1992. }
  1993. binds = append(binds, []key.Binding{cancelBinding})
  1994. }
  1995. mainBinds := []key.Binding{}
  1996. tab := k.Tab
  1997. if m.focus == uiFocusEditor {
  1998. tab.SetHelp("tab", "focus chat")
  1999. } else {
  2000. tab.SetHelp("tab", "focus editor")
  2001. }
  2002. mainBinds = append(mainBinds,
  2003. tab,
  2004. commands,
  2005. k.Models,
  2006. k.Sessions,
  2007. )
  2008. if hasSession {
  2009. mainBinds = append(mainBinds, k.Chat.NewSession)
  2010. }
  2011. binds = append(binds, mainBinds)
  2012. switch m.focus {
  2013. case uiFocusEditor:
  2014. binds = append(binds,
  2015. []key.Binding{
  2016. k.Editor.Newline,
  2017. k.Editor.AddImage,
  2018. k.Editor.PasteImage,
  2019. k.Editor.MentionFile,
  2020. k.Editor.OpenEditor,
  2021. },
  2022. )
  2023. if hasAttachments {
  2024. binds = append(binds,
  2025. []key.Binding{
  2026. k.Editor.AttachmentDeleteMode,
  2027. k.Editor.DeleteAllAttachments,
  2028. k.Editor.Escape,
  2029. },
  2030. )
  2031. }
  2032. case uiFocusMain:
  2033. binds = append(binds,
  2034. []key.Binding{
  2035. k.Chat.UpDown,
  2036. k.Chat.UpDownOneItem,
  2037. k.Chat.PageUp,
  2038. k.Chat.PageDown,
  2039. },
  2040. []key.Binding{
  2041. k.Chat.HalfPageUp,
  2042. k.Chat.HalfPageDown,
  2043. k.Chat.Home,
  2044. k.Chat.End,
  2045. },
  2046. []key.Binding{
  2047. k.Chat.Copy,
  2048. k.Chat.ClearHighlight,
  2049. },
  2050. )
  2051. if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
  2052. binds = append(binds, []key.Binding{k.Chat.PillLeft})
  2053. }
  2054. }
  2055. default:
  2056. if m.session == nil {
  2057. // no session selected
  2058. binds = append(binds,
  2059. []key.Binding{
  2060. commands,
  2061. k.Models,
  2062. k.Sessions,
  2063. },
  2064. []key.Binding{
  2065. k.Editor.Newline,
  2066. k.Editor.AddImage,
  2067. k.Editor.PasteImage,
  2068. k.Editor.MentionFile,
  2069. k.Editor.OpenEditor,
  2070. },
  2071. )
  2072. if hasAttachments {
  2073. binds = append(binds,
  2074. []key.Binding{
  2075. k.Editor.AttachmentDeleteMode,
  2076. k.Editor.DeleteAllAttachments,
  2077. k.Editor.Escape,
  2078. },
  2079. )
  2080. }
  2081. }
  2082. }
  2083. binds = append(binds,
  2084. []key.Binding{
  2085. help,
  2086. k.Quit,
  2087. },
  2088. )
  2089. return binds
  2090. }
  2091. // toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
  2092. func (m *UI) toggleCompactMode() tea.Cmd {
  2093. m.forceCompactMode = !m.forceCompactMode
  2094. err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
  2095. if err != nil {
  2096. return util.ReportError(err)
  2097. }
  2098. m.updateLayoutAndSize()
  2099. return nil
  2100. }
  2101. // updateLayoutAndSize updates the layout and sizes of UI components.
  2102. func (m *UI) updateLayoutAndSize() {
  2103. // Determine if we should be in compact mode
  2104. if m.state == uiChat {
  2105. if m.forceCompactMode {
  2106. m.isCompact = true
  2107. return
  2108. }
  2109. if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
  2110. m.isCompact = true
  2111. } else {
  2112. m.isCompact = false
  2113. }
  2114. }
  2115. m.layout = m.generateLayout(m.width, m.height)
  2116. m.updateSize()
  2117. }
  2118. // updateSize updates the sizes of UI components based on the current layout.
  2119. func (m *UI) updateSize() {
  2120. // Set status width
  2121. m.status.SetWidth(m.layout.status.Dx())
  2122. m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
  2123. m.textarea.SetWidth(m.layout.editor.Dx())
  2124. // TODO: Abstract the textarea and attachments into a single editor
  2125. // component so we don't have to manually account for the attachments
  2126. // height here.
  2127. m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
  2128. m.renderPills()
  2129. // Handle different app states
  2130. switch m.state {
  2131. case uiChat:
  2132. if !m.isCompact {
  2133. m.cacheSidebarLogo(m.layout.sidebar.Dx())
  2134. }
  2135. }
  2136. }
  2137. // generateLayout calculates the layout rectangles for all UI components based
  2138. // on the current UI state and terminal dimensions.
  2139. func (m *UI) generateLayout(w, h int) uiLayout {
  2140. // The screen area we're working with
  2141. area := image.Rect(0, 0, w, h)
  2142. // The help height
  2143. helpHeight := 1
  2144. // The editor height
  2145. editorHeight := 5
  2146. // The sidebar width
  2147. sidebarWidth := 30
  2148. // The header height
  2149. const landingHeaderHeight = 4
  2150. var helpKeyMap help.KeyMap = m
  2151. if m.status != nil && m.status.ShowingAll() {
  2152. for _, row := range helpKeyMap.FullHelp() {
  2153. helpHeight = max(helpHeight, len(row))
  2154. }
  2155. }
  2156. // Add app margins
  2157. appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
  2158. appRect.Min.Y += 1
  2159. appRect.Max.Y -= 1
  2160. helpRect.Min.Y -= 1
  2161. appRect.Min.X += 1
  2162. appRect.Max.X -= 1
  2163. if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
  2164. // extra padding on left and right for these states
  2165. appRect.Min.X += 1
  2166. appRect.Max.X -= 1
  2167. }
  2168. uiLayout := uiLayout{
  2169. area: area,
  2170. status: helpRect,
  2171. }
  2172. // Handle different app states
  2173. switch m.state {
  2174. case uiOnboarding, uiInitialize:
  2175. // Layout
  2176. //
  2177. // header
  2178. // ------
  2179. // main
  2180. // ------
  2181. // help
  2182. headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
  2183. uiLayout.header = headerRect
  2184. uiLayout.main = mainRect
  2185. case uiLanding:
  2186. // Layout
  2187. //
  2188. // header
  2189. // ------
  2190. // main
  2191. // ------
  2192. // editor
  2193. // ------
  2194. // help
  2195. headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
  2196. mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
  2197. // Remove extra padding from editor (but keep it for header and main)
  2198. editorRect.Min.X -= 1
  2199. editorRect.Max.X += 1
  2200. uiLayout.header = headerRect
  2201. uiLayout.main = mainRect
  2202. uiLayout.editor = editorRect
  2203. case uiChat:
  2204. if m.isCompact {
  2205. // Layout
  2206. //
  2207. // compact-header
  2208. // ------
  2209. // main
  2210. // ------
  2211. // editor
  2212. // ------
  2213. // help
  2214. const compactHeaderHeight = 1
  2215. headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
  2216. detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
  2217. sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
  2218. uiLayout.sessionDetails = sessionDetailsArea
  2219. uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
  2220. // Add one line gap between header and main content
  2221. mainRect.Min.Y += 1
  2222. mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
  2223. mainRect.Max.X -= 1 // Add padding right
  2224. uiLayout.header = headerRect
  2225. pillsHeight := m.pillsAreaHeight()
  2226. if pillsHeight > 0 {
  2227. pillsHeight = min(pillsHeight, mainRect.Dy())
  2228. chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
  2229. uiLayout.main = chatRect
  2230. uiLayout.pills = pillsRect
  2231. } else {
  2232. uiLayout.main = mainRect
  2233. }
  2234. // Add bottom margin to main
  2235. uiLayout.main.Max.Y -= 1
  2236. uiLayout.editor = editorRect
  2237. } else {
  2238. // Layout
  2239. //
  2240. // ------|---
  2241. // main |
  2242. // ------| side
  2243. // editor|
  2244. // ----------
  2245. // help
  2246. mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
  2247. // Add padding left
  2248. sideRect.Min.X += 1
  2249. mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
  2250. mainRect.Max.X -= 1 // Add padding right
  2251. uiLayout.sidebar = sideRect
  2252. pillsHeight := m.pillsAreaHeight()
  2253. if pillsHeight > 0 {
  2254. pillsHeight = min(pillsHeight, mainRect.Dy())
  2255. chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
  2256. uiLayout.main = chatRect
  2257. uiLayout.pills = pillsRect
  2258. } else {
  2259. uiLayout.main = mainRect
  2260. }
  2261. // Add bottom margin to main
  2262. uiLayout.main.Max.Y -= 1
  2263. uiLayout.editor = editorRect
  2264. }
  2265. }
  2266. return uiLayout
  2267. }
  2268. // uiLayout defines the positioning of UI elements.
  2269. type uiLayout struct {
  2270. // area is the overall available area.
  2271. area uv.Rectangle
  2272. // header is the header shown in special cases
  2273. // e.x when the sidebar is collapsed
  2274. // or when in the landing page
  2275. // or in init/config
  2276. header uv.Rectangle
  2277. // main is the area for the main pane. (e.x chat, configure, landing)
  2278. main uv.Rectangle
  2279. // pills is the area for the pills panel.
  2280. pills uv.Rectangle
  2281. // editor is the area for the editor pane.
  2282. editor uv.Rectangle
  2283. // sidebar is the area for the sidebar.
  2284. sidebar uv.Rectangle
  2285. // status is the area for the status view.
  2286. status uv.Rectangle
  2287. // session details is the area for the session details overlay in compact mode.
  2288. sessionDetails uv.Rectangle
  2289. }
  2290. func (m *UI) openEditor(value string) tea.Cmd {
  2291. tmpfile, err := os.CreateTemp("", "msg_*.md")
  2292. if err != nil {
  2293. return util.ReportError(err)
  2294. }
  2295. defer tmpfile.Close() //nolint:errcheck
  2296. if _, err := tmpfile.WriteString(value); err != nil {
  2297. return util.ReportError(err)
  2298. }
  2299. cmd, err := editor.Command(
  2300. "crush",
  2301. tmpfile.Name(),
  2302. editor.AtPosition(
  2303. m.textarea.Line()+1,
  2304. m.textarea.Column()+1,
  2305. ),
  2306. )
  2307. if err != nil {
  2308. return util.ReportError(err)
  2309. }
  2310. return tea.ExecProcess(cmd, func(err error) tea.Msg {
  2311. if err != nil {
  2312. return util.ReportError(err)
  2313. }
  2314. content, err := os.ReadFile(tmpfile.Name())
  2315. if err != nil {
  2316. return util.ReportError(err)
  2317. }
  2318. if len(content) == 0 {
  2319. return util.ReportWarn("Message is empty")
  2320. }
  2321. os.Remove(tmpfile.Name())
  2322. return openEditorMsg{
  2323. Text: strings.TrimSpace(string(content)),
  2324. }
  2325. })
  2326. }
  2327. // setEditorPrompt configures the textarea prompt function based on whether
  2328. // yolo mode is enabled.
  2329. func (m *UI) setEditorPrompt(yolo bool) {
  2330. if yolo {
  2331. m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
  2332. return
  2333. }
  2334. m.textarea.SetPromptFunc(4, m.normalPromptFunc)
  2335. }
  2336. // normalPromptFunc returns the normal editor prompt style (" > " on first
  2337. // line, "::: " on subsequent lines).
  2338. func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
  2339. t := m.com.Styles
  2340. if info.LineNumber == 0 {
  2341. if info.Focused {
  2342. return " > "
  2343. }
  2344. return "::: "
  2345. }
  2346. if info.Focused {
  2347. return t.EditorPromptNormalFocused.Render()
  2348. }
  2349. return t.EditorPromptNormalBlurred.Render()
  2350. }
  2351. // yoloPromptFunc returns the yolo mode editor prompt style with warning icon
  2352. // and colored dots.
  2353. func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
  2354. t := m.com.Styles
  2355. if info.LineNumber == 0 {
  2356. if info.Focused {
  2357. return t.EditorPromptYoloIconFocused.Render()
  2358. } else {
  2359. return t.EditorPromptYoloIconBlurred.Render()
  2360. }
  2361. }
  2362. if info.Focused {
  2363. return t.EditorPromptYoloDotsFocused.Render()
  2364. }
  2365. return t.EditorPromptYoloDotsBlurred.Render()
  2366. }
  2367. // closeCompletions closes the completions popup and resets state.
  2368. func (m *UI) closeCompletions() {
  2369. m.completionsOpen = false
  2370. m.completionsQuery = ""
  2371. m.completionsStartIndex = 0
  2372. m.completions.Close()
  2373. }
  2374. // insertCompletionText replaces the @query in the textarea with the given text.
  2375. // Returns false if the replacement cannot be performed.
  2376. func (m *UI) insertCompletionText(text string) bool {
  2377. value := m.textarea.Value()
  2378. if m.completionsStartIndex > len(value) {
  2379. return false
  2380. }
  2381. word := m.textareaWord()
  2382. endIdx := min(m.completionsStartIndex+len(word), len(value))
  2383. newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
  2384. m.textarea.SetValue(newValue)
  2385. m.textarea.MoveToEnd()
  2386. m.textarea.InsertRune(' ')
  2387. return true
  2388. }
  2389. // insertFileCompletion inserts the selected file path into the textarea,
  2390. // replacing the @query, and adds the file as an attachment.
  2391. func (m *UI) insertFileCompletion(path string) tea.Cmd {
  2392. if !m.insertCompletionText(path) {
  2393. return nil
  2394. }
  2395. return func() tea.Msg {
  2396. absPath, _ := filepath.Abs(path)
  2397. if m.hasSession() {
  2398. // Skip attachment if file was already read and hasn't been modified.
  2399. lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
  2400. if !lastRead.IsZero() {
  2401. if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
  2402. return nil
  2403. }
  2404. }
  2405. } else if slices.Contains(m.sessionFileReads, absPath) {
  2406. return nil
  2407. }
  2408. m.sessionFileReads = append(m.sessionFileReads, absPath)
  2409. // Add file as attachment.
  2410. content, err := os.ReadFile(path)
  2411. if err != nil {
  2412. // If it fails, let the LLM handle it later.
  2413. return nil
  2414. }
  2415. return message.Attachment{
  2416. FilePath: path,
  2417. FileName: filepath.Base(path),
  2418. MimeType: mimeOf(content),
  2419. Content: content,
  2420. }
  2421. }
  2422. }
  2423. // insertMCPResourceCompletion inserts the selected resource into the textarea,
  2424. // replacing the @query, and adds the resource as an attachment.
  2425. func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
  2426. displayText := cmp.Or(item.Title, item.URI)
  2427. if !m.insertCompletionText(displayText) {
  2428. return nil
  2429. }
  2430. return func() tea.Msg {
  2431. contents, err := mcp.ReadResource(
  2432. context.Background(),
  2433. m.com.Store(),
  2434. item.MCPName,
  2435. item.URI,
  2436. )
  2437. if err != nil {
  2438. slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
  2439. return nil
  2440. }
  2441. if len(contents) == 0 {
  2442. return nil
  2443. }
  2444. content := contents[0]
  2445. var data []byte
  2446. if content.Text != "" {
  2447. data = []byte(content.Text)
  2448. } else if len(content.Blob) > 0 {
  2449. data = content.Blob
  2450. }
  2451. if len(data) == 0 {
  2452. return nil
  2453. }
  2454. mimeType := item.MIMEType
  2455. if mimeType == "" && content.MIMEType != "" {
  2456. mimeType = content.MIMEType
  2457. }
  2458. if mimeType == "" {
  2459. mimeType = "text/plain"
  2460. }
  2461. return message.Attachment{
  2462. FilePath: item.URI,
  2463. FileName: displayText,
  2464. MimeType: mimeType,
  2465. Content: data,
  2466. }
  2467. }
  2468. }
  2469. // completionsPosition returns the X and Y position for the completions popup.
  2470. func (m *UI) completionsPosition() image.Point {
  2471. cur := m.textarea.Cursor()
  2472. if cur == nil {
  2473. return image.Point{
  2474. X: m.layout.editor.Min.X,
  2475. Y: m.layout.editor.Min.Y,
  2476. }
  2477. }
  2478. return image.Point{
  2479. X: cur.X + m.layout.editor.Min.X,
  2480. Y: m.layout.editor.Min.Y + cur.Y,
  2481. }
  2482. }
  2483. // textareaWord returns the current word at the cursor position.
  2484. func (m *UI) textareaWord() string {
  2485. return m.textarea.Word()
  2486. }
  2487. // isWhitespace returns true if the byte is a whitespace character.
  2488. func isWhitespace(b byte) bool {
  2489. return b == ' ' || b == '\t' || b == '\n' || b == '\r'
  2490. }
  2491. // isAgentBusy returns true if the agent coordinator exists and is currently
  2492. // busy processing a request.
  2493. func (m *UI) isAgentBusy() bool {
  2494. return m.com.App != nil &&
  2495. m.com.App.AgentCoordinator != nil &&
  2496. m.com.App.AgentCoordinator.IsBusy()
  2497. }
  2498. // hasSession returns true if there is an active session with a valid ID.
  2499. func (m *UI) hasSession() bool {
  2500. return m.session != nil && m.session.ID != ""
  2501. }
  2502. // mimeOf detects the MIME type of the given content.
  2503. func mimeOf(content []byte) string {
  2504. mimeBufferSize := min(512, len(content))
  2505. return http.DetectContentType(content[:mimeBufferSize])
  2506. }
  2507. var readyPlaceholders = [...]string{
  2508. "Ready!",
  2509. "Ready...",
  2510. "Ready?",
  2511. "Ready for instructions",
  2512. }
  2513. var workingPlaceholders = [...]string{
  2514. "Working!",
  2515. "Working...",
  2516. "Brrrrr...",
  2517. "Prrrrrrrr...",
  2518. "Processing...",
  2519. "Thinking...",
  2520. }
  2521. // randomizePlaceholders selects random placeholder text for the textarea's
  2522. // ready and working states.
  2523. func (m *UI) randomizePlaceholders() {
  2524. m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
  2525. m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
  2526. }
  2527. // renderEditorView renders the editor view with attachments if any.
  2528. func (m *UI) renderEditorView(width int) string {
  2529. var attachmentsView string
  2530. if len(m.attachments.List()) > 0 {
  2531. attachmentsView = m.attachments.Render(width)
  2532. }
  2533. return strings.Join([]string{
  2534. attachmentsView,
  2535. m.textarea.View(),
  2536. "", // margin at bottom of editor
  2537. }, "\n")
  2538. }
  2539. // cacheSidebarLogo renders and caches the sidebar logo at the specified width.
  2540. func (m *UI) cacheSidebarLogo(width int) {
  2541. m.sidebarLogo = renderLogo(m.com.Styles, true, width)
  2542. }
  2543. // sendMessage sends a message with the given content and attachments.
  2544. func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
  2545. if m.com.App.AgentCoordinator == nil {
  2546. return util.ReportError(fmt.Errorf("coder agent is not initialized"))
  2547. }
  2548. var cmds []tea.Cmd
  2549. if !m.hasSession() {
  2550. newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
  2551. if err != nil {
  2552. return util.ReportError(err)
  2553. }
  2554. if m.forceCompactMode {
  2555. m.isCompact = true
  2556. }
  2557. if newSession.ID != "" {
  2558. m.session = &newSession
  2559. cmds = append(cmds, m.loadSession(newSession.ID))
  2560. }
  2561. m.setState(uiChat, m.focus)
  2562. }
  2563. ctx := context.Background()
  2564. cmds = append(cmds, func() tea.Msg {
  2565. for _, path := range m.sessionFileReads {
  2566. m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
  2567. m.com.App.LSPManager.Start(ctx, path)
  2568. }
  2569. return nil
  2570. })
  2571. // Capture session ID to avoid race with main goroutine updating m.session.
  2572. sessionID := m.session.ID
  2573. cmds = append(cmds, func() tea.Msg {
  2574. _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
  2575. if err != nil {
  2576. isCancelErr := errors.Is(err, context.Canceled)
  2577. isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
  2578. if isCancelErr || isPermissionErr {
  2579. return nil
  2580. }
  2581. return util.InfoMsg{
  2582. Type: util.InfoTypeError,
  2583. Msg: err.Error(),
  2584. }
  2585. }
  2586. return nil
  2587. })
  2588. return tea.Batch(cmds...)
  2589. }
  2590. const cancelTimerDuration = 2 * time.Second
  2591. // cancelTimerCmd creates a command that expires the cancel timer.
  2592. func cancelTimerCmd() tea.Cmd {
  2593. return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
  2594. return cancelTimerExpiredMsg{}
  2595. })
  2596. }
  2597. // cancelAgent handles the cancel key press. The first press sets isCanceling to true
  2598. // and starts a timer. The second press (before the timer expires) actually
  2599. // cancels the agent.
  2600. func (m *UI) cancelAgent() tea.Cmd {
  2601. if !m.hasSession() {
  2602. return nil
  2603. }
  2604. coordinator := m.com.App.AgentCoordinator
  2605. if coordinator == nil {
  2606. return nil
  2607. }
  2608. if m.isCanceling {
  2609. // Second escape press - actually cancel the agent.
  2610. m.isCanceling = false
  2611. coordinator.Cancel(m.session.ID)
  2612. // Stop the spinning todo indicator.
  2613. m.todoIsSpinning = false
  2614. m.renderPills()
  2615. return nil
  2616. }
  2617. // Check if there are queued prompts - if so, clear the queue.
  2618. if coordinator.QueuedPrompts(m.session.ID) > 0 {
  2619. coordinator.ClearQueue(m.session.ID)
  2620. return nil
  2621. }
  2622. // First escape press - set canceling state and start timer.
  2623. m.isCanceling = true
  2624. return cancelTimerCmd()
  2625. }
  2626. // openDialog opens a dialog by its ID.
  2627. func (m *UI) openDialog(id string) tea.Cmd {
  2628. var cmds []tea.Cmd
  2629. switch id {
  2630. case dialog.SessionsID:
  2631. if cmd := m.openSessionsDialog(); cmd != nil {
  2632. cmds = append(cmds, cmd)
  2633. }
  2634. case dialog.ModelsID:
  2635. if cmd := m.openModelsDialog(); cmd != nil {
  2636. cmds = append(cmds, cmd)
  2637. }
  2638. case dialog.CommandsID:
  2639. if cmd := m.openCommandsDialog(); cmd != nil {
  2640. cmds = append(cmds, cmd)
  2641. }
  2642. case dialog.ReasoningID:
  2643. if cmd := m.openReasoningDialog(); cmd != nil {
  2644. cmds = append(cmds, cmd)
  2645. }
  2646. case dialog.FilePickerID:
  2647. if cmd := m.openFilesDialog(); cmd != nil {
  2648. cmds = append(cmds, cmd)
  2649. }
  2650. case dialog.QuitID:
  2651. if cmd := m.openQuitDialog(); cmd != nil {
  2652. cmds = append(cmds, cmd)
  2653. }
  2654. default:
  2655. // Unknown dialog
  2656. break
  2657. }
  2658. return tea.Batch(cmds...)
  2659. }
  2660. // openQuitDialog opens the quit confirmation dialog.
  2661. func (m *UI) openQuitDialog() tea.Cmd {
  2662. if m.dialog.ContainsDialog(dialog.QuitID) {
  2663. // Bring to front
  2664. m.dialog.BringToFront(dialog.QuitID)
  2665. return nil
  2666. }
  2667. quitDialog := dialog.NewQuit(m.com)
  2668. m.dialog.OpenDialog(quitDialog)
  2669. return nil
  2670. }
  2671. // openModelsDialog opens the models dialog.
  2672. func (m *UI) openModelsDialog() tea.Cmd {
  2673. if m.dialog.ContainsDialog(dialog.ModelsID) {
  2674. // Bring to front
  2675. m.dialog.BringToFront(dialog.ModelsID)
  2676. return nil
  2677. }
  2678. isOnboarding := m.state == uiOnboarding
  2679. modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
  2680. if err != nil {
  2681. return util.ReportError(err)
  2682. }
  2683. m.dialog.OpenDialog(modelsDialog)
  2684. return nil
  2685. }
  2686. // openCommandsDialog opens the commands dialog.
  2687. func (m *UI) openCommandsDialog() tea.Cmd {
  2688. if m.dialog.ContainsDialog(dialog.CommandsID) {
  2689. // Bring to front
  2690. m.dialog.BringToFront(dialog.CommandsID)
  2691. return nil
  2692. }
  2693. var sessionID string
  2694. hasSession := m.session != nil
  2695. if hasSession {
  2696. sessionID = m.session.ID
  2697. }
  2698. hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
  2699. hasQueue := m.promptQueue > 0
  2700. commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
  2701. if err != nil {
  2702. return util.ReportError(err)
  2703. }
  2704. m.dialog.OpenDialog(commands)
  2705. return commands.InitialCmd()
  2706. }
  2707. // openReasoningDialog opens the reasoning effort dialog.
  2708. func (m *UI) openReasoningDialog() tea.Cmd {
  2709. if m.dialog.ContainsDialog(dialog.ReasoningID) {
  2710. m.dialog.BringToFront(dialog.ReasoningID)
  2711. return nil
  2712. }
  2713. reasoningDialog, err := dialog.NewReasoning(m.com)
  2714. if err != nil {
  2715. return util.ReportError(err)
  2716. }
  2717. m.dialog.OpenDialog(reasoningDialog)
  2718. return nil
  2719. }
  2720. // openSessionsDialog opens the sessions dialog. If the dialog is already open,
  2721. // it brings it to the front. Otherwise, it will list all the sessions and open
  2722. // the dialog.
  2723. func (m *UI) openSessionsDialog() tea.Cmd {
  2724. if m.dialog.ContainsDialog(dialog.SessionsID) {
  2725. // Bring to front
  2726. m.dialog.BringToFront(dialog.SessionsID)
  2727. return nil
  2728. }
  2729. selectedSessionID := ""
  2730. if m.session != nil {
  2731. selectedSessionID = m.session.ID
  2732. }
  2733. dialog, err := dialog.NewSessions(m.com, selectedSessionID)
  2734. if err != nil {
  2735. return util.ReportError(err)
  2736. }
  2737. m.dialog.OpenDialog(dialog)
  2738. return nil
  2739. }
  2740. // openFilesDialog opens the file picker dialog.
  2741. func (m *UI) openFilesDialog() tea.Cmd {
  2742. if m.dialog.ContainsDialog(dialog.FilePickerID) {
  2743. // Bring to front
  2744. m.dialog.BringToFront(dialog.FilePickerID)
  2745. return nil
  2746. }
  2747. filePicker, cmd := dialog.NewFilePicker(m.com)
  2748. filePicker.SetImageCapabilities(&m.caps)
  2749. m.dialog.OpenDialog(filePicker)
  2750. return cmd
  2751. }
  2752. // openPermissionsDialog opens the permissions dialog for a permission request.
  2753. func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
  2754. // Close any existing permissions dialog first.
  2755. m.dialog.CloseDialog(dialog.PermissionsID)
  2756. // Get diff mode from config.
  2757. var opts []dialog.PermissionsOption
  2758. if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
  2759. opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
  2760. }
  2761. permDialog := dialog.NewPermissions(m.com, perm, opts...)
  2762. m.dialog.OpenDialog(permDialog)
  2763. return nil
  2764. }
  2765. // handlePermissionNotification updates tool items when permission state changes.
  2766. func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
  2767. toolItem := m.chat.MessageItem(notification.ToolCallID)
  2768. if toolItem == nil {
  2769. return
  2770. }
  2771. if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
  2772. if notification.Granted {
  2773. permItem.SetStatus(chat.ToolStatusRunning)
  2774. } else {
  2775. permItem.SetStatus(chat.ToolStatusAwaitingPermission)
  2776. }
  2777. }
  2778. }
  2779. // handleAgentNotification translates domain agent events into desktop
  2780. // notifications using the UI notification backend.
  2781. func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
  2782. switch n.Type {
  2783. case notify.TypeAgentFinished:
  2784. return m.sendNotification(notification.Notification{
  2785. Title: "Crush is waiting...",
  2786. Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
  2787. })
  2788. default:
  2789. return nil
  2790. }
  2791. }
  2792. // newSession clears the current session state and prepares for a new session.
  2793. // The actual session creation happens when the user sends their first message.
  2794. // Returns a command to reload prompt history.
  2795. func (m *UI) newSession() tea.Cmd {
  2796. if !m.hasSession() {
  2797. return nil
  2798. }
  2799. m.session = nil
  2800. m.sessionFiles = nil
  2801. m.sessionFileReads = nil
  2802. m.setState(uiLanding, uiFocusEditor)
  2803. m.textarea.Focus()
  2804. m.chat.Blur()
  2805. m.chat.ClearMessages()
  2806. m.pillsExpanded = false
  2807. m.promptQueue = 0
  2808. m.pillsView = ""
  2809. m.historyReset()
  2810. agenttools.ResetCache()
  2811. return tea.Batch(
  2812. func() tea.Msg {
  2813. m.com.App.LSPManager.StopAll(context.Background())
  2814. return nil
  2815. },
  2816. m.loadPromptHistory(),
  2817. )
  2818. }
  2819. // handlePasteMsg handles a paste message.
  2820. func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
  2821. if m.dialog.HasDialogs() {
  2822. return m.handleDialogMsg(msg)
  2823. }
  2824. if m.focus != uiFocusEditor {
  2825. return nil
  2826. }
  2827. if hasPasteExceededThreshold(msg) {
  2828. return func() tea.Msg {
  2829. content := []byte(msg.Content)
  2830. if int64(len(content)) > common.MaxAttachmentSize {
  2831. return util.ReportWarn("Paste is too big (>5mb)")
  2832. }
  2833. name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
  2834. mimeBufferSize := min(512, len(content))
  2835. mimeType := http.DetectContentType(content[:mimeBufferSize])
  2836. return message.Attachment{
  2837. FileName: name,
  2838. FilePath: name,
  2839. MimeType: mimeType,
  2840. Content: content,
  2841. }
  2842. }
  2843. }
  2844. // Attempt to parse pasted content as file paths. If possible to parse,
  2845. // all files exist and are valid, add as attachments.
  2846. // Otherwise, paste as text.
  2847. paths := fsext.ParsePastedFiles(msg.Content)
  2848. allExistsAndValid := func() bool {
  2849. if len(paths) == 0 {
  2850. return false
  2851. }
  2852. for _, path := range paths {
  2853. if _, err := os.Stat(path); os.IsNotExist(err) {
  2854. return false
  2855. }
  2856. lowerPath := strings.ToLower(path)
  2857. isValid := false
  2858. for _, ext := range common.AllowedImageTypes {
  2859. if strings.HasSuffix(lowerPath, ext) {
  2860. isValid = true
  2861. break
  2862. }
  2863. }
  2864. if !isValid {
  2865. return false
  2866. }
  2867. }
  2868. return true
  2869. }
  2870. if !allExistsAndValid() {
  2871. var cmd tea.Cmd
  2872. m.textarea, cmd = m.textarea.Update(msg)
  2873. return cmd
  2874. }
  2875. var cmds []tea.Cmd
  2876. for _, path := range paths {
  2877. cmds = append(cmds, m.handleFilePathPaste(path))
  2878. }
  2879. return tea.Batch(cmds...)
  2880. }
  2881. func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
  2882. var (
  2883. lineCount = 0
  2884. colCount = 0
  2885. )
  2886. for line := range strings.SplitSeq(msg.Content, "\n") {
  2887. lineCount++
  2888. colCount = max(colCount, len(line))
  2889. if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
  2890. return true
  2891. }
  2892. }
  2893. return false
  2894. }
  2895. // handleFilePathPaste handles a pasted file path.
  2896. func (m *UI) handleFilePathPaste(path string) tea.Cmd {
  2897. return func() tea.Msg {
  2898. fileInfo, err := os.Stat(path)
  2899. if err != nil {
  2900. return util.ReportError(err)
  2901. }
  2902. if fileInfo.IsDir() {
  2903. return util.ReportWarn("Cannot attach a directory")
  2904. }
  2905. if fileInfo.Size() > common.MaxAttachmentSize {
  2906. return util.ReportWarn("File is too big (>5mb)")
  2907. }
  2908. content, err := os.ReadFile(path)
  2909. if err != nil {
  2910. return util.ReportError(err)
  2911. }
  2912. mimeBufferSize := min(512, len(content))
  2913. mimeType := http.DetectContentType(content[:mimeBufferSize])
  2914. fileName := filepath.Base(path)
  2915. return message.Attachment{
  2916. FilePath: path,
  2917. FileName: fileName,
  2918. MimeType: mimeType,
  2919. Content: content,
  2920. }
  2921. }
  2922. }
  2923. // pasteImageFromClipboard reads image data from the system clipboard and
  2924. // creates an attachment. If no image data is found, it falls back to
  2925. // interpreting clipboard text as a file path.
  2926. func (m *UI) pasteImageFromClipboard() tea.Msg {
  2927. imageData, err := readClipboard(clipboardFormatImage)
  2928. if int64(len(imageData)) > common.MaxAttachmentSize {
  2929. return util.InfoMsg{
  2930. Type: util.InfoTypeError,
  2931. Msg: "File too large, max 5MB",
  2932. }
  2933. }
  2934. name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
  2935. if err == nil {
  2936. return message.Attachment{
  2937. FilePath: name,
  2938. FileName: name,
  2939. MimeType: mimeOf(imageData),
  2940. Content: imageData,
  2941. }
  2942. }
  2943. textData, textErr := readClipboard(clipboardFormatText)
  2944. if textErr != nil || len(textData) == 0 {
  2945. return nil // Clipboard is empty or does not contain an image
  2946. }
  2947. path := strings.TrimSpace(string(textData))
  2948. path = strings.ReplaceAll(path, "\\ ", " ")
  2949. if _, statErr := os.Stat(path); statErr != nil {
  2950. return nil // Clipboard does not contain an image or valid file path
  2951. }
  2952. lowerPath := strings.ToLower(path)
  2953. isAllowed := false
  2954. for _, ext := range common.AllowedImageTypes {
  2955. if strings.HasSuffix(lowerPath, ext) {
  2956. isAllowed = true
  2957. break
  2958. }
  2959. }
  2960. if !isAllowed {
  2961. return util.NewInfoMsg("File type is not a supported image format")
  2962. }
  2963. fileInfo, statErr := os.Stat(path)
  2964. if statErr != nil {
  2965. return util.InfoMsg{
  2966. Type: util.InfoTypeError,
  2967. Msg: fmt.Sprintf("Unable to read file: %v", statErr),
  2968. }
  2969. }
  2970. if fileInfo.Size() > common.MaxAttachmentSize {
  2971. return util.InfoMsg{
  2972. Type: util.InfoTypeError,
  2973. Msg: "File too large, max 5MB",
  2974. }
  2975. }
  2976. content, readErr := os.ReadFile(path)
  2977. if readErr != nil {
  2978. return util.InfoMsg{
  2979. Type: util.InfoTypeError,
  2980. Msg: fmt.Sprintf("Unable to read file: %v", readErr),
  2981. }
  2982. }
  2983. return message.Attachment{
  2984. FilePath: path,
  2985. FileName: filepath.Base(path),
  2986. MimeType: mimeOf(content),
  2987. Content: content,
  2988. }
  2989. }
  2990. var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
  2991. func (m *UI) pasteIdx() int {
  2992. result := 0
  2993. for _, at := range m.attachments.List() {
  2994. found := pasteRE.FindStringSubmatch(at.FileName)
  2995. if len(found) == 0 {
  2996. continue
  2997. }
  2998. idx, err := strconv.Atoi(found[1])
  2999. if err == nil {
  3000. result = max(result, idx)
  3001. }
  3002. }
  3003. return result + 1
  3004. }
  3005. // drawSessionDetails draws the session details in compact mode.
  3006. func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
  3007. if m.session == nil {
  3008. return
  3009. }
  3010. s := m.com.Styles
  3011. width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
  3012. height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
  3013. title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
  3014. blocks := []string{
  3015. title,
  3016. "",
  3017. m.modelInfo(width),
  3018. "",
  3019. }
  3020. detailsHeader := lipgloss.JoinVertical(
  3021. lipgloss.Left,
  3022. blocks...,
  3023. )
  3024. version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
  3025. remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
  3026. const maxSectionWidth = 50
  3027. sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
  3028. maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
  3029. lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
  3030. mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
  3031. filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
  3032. sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
  3033. uv.NewStyledString(
  3034. s.CompactDetails.View.
  3035. Width(area.Dx()).
  3036. Render(
  3037. lipgloss.JoinVertical(
  3038. lipgloss.Left,
  3039. detailsHeader,
  3040. sections,
  3041. version,
  3042. ),
  3043. ),
  3044. ).Draw(scr, area)
  3045. }
  3046. func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
  3047. load := func() tea.Msg {
  3048. prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
  3049. if err != nil {
  3050. // TODO: make this better
  3051. return util.ReportError(err)()
  3052. }
  3053. if prompt == "" {
  3054. return nil
  3055. }
  3056. return sendMessageMsg{
  3057. Content: prompt,
  3058. }
  3059. }
  3060. var cmds []tea.Cmd
  3061. if cmd := m.dialog.StartLoading(); cmd != nil {
  3062. cmds = append(cmds, cmd)
  3063. }
  3064. cmds = append(cmds, load, func() tea.Msg {
  3065. return closeDialogMsg{}
  3066. })
  3067. return tea.Sequence(cmds...)
  3068. }
  3069. func (m *UI) handleStateChanged() tea.Cmd {
  3070. return func() tea.Msg {
  3071. m.com.App.UpdateAgentModel(context.Background())
  3072. return mcpStateChangedMsg{
  3073. states: mcp.GetStates(),
  3074. }
  3075. }
  3076. }
  3077. func handleMCPPromptsEvent(name string) tea.Cmd {
  3078. return func() tea.Msg {
  3079. mcp.RefreshPrompts(context.Background(), name)
  3080. return nil
  3081. }
  3082. }
  3083. func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
  3084. return func() tea.Msg {
  3085. mcp.RefreshTools(
  3086. context.Background(),
  3087. cfg,
  3088. name,
  3089. )
  3090. return nil
  3091. }
  3092. }
  3093. func handleMCPResourcesEvent(name string) tea.Cmd {
  3094. return func() tea.Msg {
  3095. mcp.RefreshResources(context.Background(), name)
  3096. return nil
  3097. }
  3098. }
  3099. func (m *UI) copyChatHighlight() tea.Cmd {
  3100. text := m.chat.HighlightContent()
  3101. return common.CopyToClipboardWithCallback(
  3102. text,
  3103. "Selected text copied to clipboard",
  3104. func() tea.Msg {
  3105. m.chat.ClearMouse()
  3106. return nil
  3107. },
  3108. )
  3109. }
  3110. func (m *UI) enableDockerMCP() tea.Msg {
  3111. store := m.com.Store()
  3112. // Stage Docker MCP in memory first so startup and persistence can be atomic.
  3113. mcpConfig, err := store.PrepareDockerMCPConfig()
  3114. if err != nil {
  3115. return util.ReportError(err)()
  3116. }
  3117. ctx := context.Background()
  3118. if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
  3119. // Roll back runtime and in-memory state when startup fails.
  3120. disableErr := mcp.DisableSingle(store, config.DockerMCPName)
  3121. delete(store.Config().MCP, config.DockerMCPName)
  3122. return util.ReportError(fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr)))()
  3123. }
  3124. if err := store.PersistDockerMCPConfig(mcpConfig); err != nil {
  3125. // Roll back runtime and in-memory state if persistence fails.
  3126. disableErr := mcp.DisableSingle(store, config.DockerMCPName)
  3127. delete(store.Config().MCP, config.DockerMCPName)
  3128. return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr)))()
  3129. }
  3130. return util.NewInfoMsg("Docker MCP enabled and started successfully")
  3131. }
  3132. func (m *UI) disableDockerMCP() tea.Msg {
  3133. store := m.com.Store()
  3134. // Close the Docker MCP client.
  3135. if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
  3136. return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
  3137. }
  3138. // Remove from config and persist.
  3139. if err := store.DisableDockerMCP(); err != nil {
  3140. return util.ReportError(err)()
  3141. }
  3142. return util.NewInfoMsg("Docker MCP disabled successfully")
  3143. }
  3144. // renderLogo renders the Crush logo with the given styles and dimensions.
  3145. func renderLogo(t *styles.Styles, compact bool, width int) string {
  3146. return logo.Render(t, version.Version, compact, logo.Opts{
  3147. FieldColor: t.LogoFieldColor,
  3148. TitleColorA: t.LogoTitleColorA,
  3149. TitleColorB: t.LogoTitleColorB,
  3150. CharmColor: t.LogoCharmColor,
  3151. VersionColor: t.LogoVersionColor,
  3152. Width: width,
  3153. })
  3154. }