ui.go 99 KB

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