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