okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
cc6ab84
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"fmt" 6"slices" 7"strings" 8"time" 9 10 tea"charm.land/bubbletea/v2" 11) 12 13func (m * ChatModel )handleInput (msg tea.Msg ) (tea.Cmd ,bool ) { 14if cmd ,handled := m .handleTerminalClipboard (msg );handled { 15return cmd ,true 16 } 17var cmds []tea.Cmd 18switch msg := msg .(type ) { 19case tea.KeyPressMsg : 20// The selector owns keys while open, before chat navigation or composer input. 21if len (m .effortOptions )> 0 { 22switch msg .String () { 23case "left" ,"up" : 24m .effortSelected = max (0 ,m .effortSelected - 1 ) 25case "right" ,"down" : 26m .effortSelected = min (len (m .effortOptions )- 1 ,m .effortSelected + 1 ) 27case "enter" ,"esc" : 28level := m .effortOptions [m .effortSelected ] 29m .effortOptions = nil 30m .syncLayout () 31if msg .String ()== "enter" { 32return func () tea.Msg {return ChatExecuteCommandMsg {Name :"/effort" ,Args :level } },true 33 } 34case "ctrl+c" : 35m .Close () 36return func () tea.Msg {return ChatQuitMsg {} },true 37 } 38return nil ,true 39 } 40 41if msg .String ()== "left" && m .TextArea .Value ()== "" { 42return func () tea.Msg {return ChatBackToSessionsMsg {} },true 43 } 44 45if msg .String ()== "ctrl+c" || (msg .String ()== "ctrl+d" && m .TextArea .Focused ()&& m .TextArea .Value ()== "" ) { 46m .Close () 47return func () tea.Msg {return ChatQuitMsg {} },true 48 } 49if msg .String ()== "ctrl+n" { 50return func () tea.Msg {return ChatNewSessionMsg {} },true 51 } 52if msg .String ()== "ctrl+o" { 53return func () tea.Msg {return ChatOpenAgentsMsg {} },true 54 } 55if msg .String ()== "esc" && m .dragAnchor != nil { 56m .dragAnchor ,m .dragDir = nil ,0 57return nil ,true 58 } 59 60inputVal := m .TextArea .Value () 61consumed := m .CommandMenu .OnKey ( 62msg , 63inputVal , 64func (replacement string ) { 65m .TextArea .SetValue (replacement ) 66 }, 67func (commandName string ) { 68m .handleSubmittedCommand (commandName ,& cmds ) 69 }, 70 ) 71if consumed { 72m .syncLayout () 73return tea .Batch (cmds ... ),true 74 } 75 76switch msg .String () { 77case "esc" : 78if m .isSending { 79m .interruptDeferred = true 80m .Stopping = true 81return m .startAnimation (),true 82 } 83if m .Status .Running || len (m .pendingUsers )> 0 { 84m .Stopping = true 85cmds = append (cmds ,m .interruptCmd (),m .startAnimation ()) 86return tea .Batch (cmds ... ),true 87 } 88case "ctrl+v" : 89return m .pasteImage (),true 90case "backspace" ,"ctrl+h" ,"ctrl+w" ,"alt+backspace" ,"ctrl+backspace" : 91if m .deleteMarker (true ) { 92return nil ,true 93 } 94case "delete" : 95if m .deleteMarker (false ) { 96return nil ,true 97 } 98case "ctrl+g" : 99return m .openEditorCmd (),true 100case "ctrl+l" : 101if m .hostAuth != nil && m .hostAuth .here { 102return m .signInCmd (),true 103 } 104case "pgup" ,"pgdown" : 105delta := m .Viewport .Height () 106if msg .String ()== "pgup" { 107delta = - delta 108 } 109m .scrollBy (delta ) 110return nil ,true 111case "up" : 112if m .TextArea .Line ()== 0 && m .TextArea .LineInfo ().RowOffset == 0 { 113m .scrollBy (- 1 ) 114return nil ,true 115 } 116case "down" : 117if m .TextArea .Line ()>= m .TextArea .LineCount ()- 1 && m .TextArea .LineInfo ().RowOffset >= m .TextArea .LineInfo ().Height - 1 { 118m .scrollBy (1 ) 119return nil ,true 120 } 121case "shift+up" ,"shift+down" : 122m .jumpToYou (msg .String ()== "shift+up" ) 123return nil ,true 124case "ctrl+home" ,"ctrl+end" : 125m .Follow = msg .String ()== "ctrl+end" 126if ! m .Follow { 127m .scrollOffset = 0 128 } 129m .refreshViewportContent () 130return nil ,true 131case "ctrl+j" ,"ctrl+k" : 132if msg .String ()== "ctrl+j" { 133m .Flags .Diffs = ! m .Flags .Diffs 134 }else { 135m .Flags .Compaction = ! m .Flags .Compaction 136 } 137m .rebuildSettledLines () 138m .refreshViewportContent () 139return nil ,true 140case "ctrl+left" : 141// Bubbles wordLeft never terminates when everything before the cursor 142// is whitespace. Move to the input start directly in that case. 143if m .wordBackwardAtStart () { 144var cmd tea.Cmd 145m .TextArea ,cmd = m .TextArea .Update (tea.KeyPressMsg {Code :tea .KeyHome ,Mod :tea .ModCtrl }) 146return cmd ,true 147 } 148case "enter" : 149trimmed := strings .TrimSpace (m .TextArea .Value ()) 150// a draft typed while connecting waits in the composer 151if m .connecting ()&& (! strings .HasPrefix (trimmed ,"/" )|| ! m .isRecognizedCommand (trimmed )) { 152return nil ,true 153 } 154if trimmed != "" { 155m .TextArea .Reset () 156m .syncLayout () 157m .submitInput (trimmed ,& cmds ) 158return tea .Batch (cmds ... ),true 159 } 160return nil ,true 161 } 162case dragScrollMsg : 163cmd := m .dragScrolled (msg ) 164return cmd ,true 165case tea.MouseMsg : 166firstRow := 2 + m .Notices .ChromeRows () 167mouse := msg .Mouse () 168point := func ()Point { 169screen := max (0 ,min (m .Viewport .Height ()- 1 ,mouse .Y - firstRow )) 170return Point {Row :m .scrollOffset + screen ,Col :max (0 ,min (m .Viewport .Width (),mouse .X - m .padding ()))} 171 } 172switch msg := msg .(type ) { 173case tea.MouseReleaseMsg : 174if m .dragAnchor == nil { 175return nil ,true 176 } 177m .dragHead = point () 178sel := Selection {Anchor :* m .dragAnchor ,Head :m .dragHead ,Gutter :railWidth } 179isCtrl := m .dragCtrl || mouse .Mod .Contains (tea .ModCtrl )|| mouse .Mod .Contains (tea .ModSuper ) 180m .dragAnchor ,m .dragDir ,m .dragCtrl = nil ,0 ,false 181if isCtrl { 182if cmd := m .openFileAt (sel .Head .Row ,sel .Head .Col );cmd != nil { 183return cmd ,true 184 } 185return nil ,true 186 } 187if sel .IsEmpty () { 188cmd := m .actAt (sel .Head .Row ) 189return cmd ,true 190 } 191if text := SelectedText (m .frameLines ,sel );text != "" { 192cmd := m .copied (text ) 193return cmd ,true 194 } 195case tea.MouseMotionMsg : 196if m .dragAnchor != nil { 197m .dragHead = point () 198cmd := m .steerDragScroll (mouse .Y - firstRow ) 199return cmd ,true 200 } 201case tea.MouseClickMsg : 202if msg .Button == tea .MouseLeft && mouse .Y >= firstRow && mouse .Y < firstRow + m .Viewport .Height () { 203pt := point () 204m .dragAnchor = & pt 205m .dragHead = pt 206m .dragCtrl = mouse .Mod .Contains (tea .ModCtrl )|| mouse .Mod .Contains (tea .ModSuper ) 207 } 208case tea.MouseWheelMsg : 209switch msg .Button { 210case tea .MouseWheelUp : 211m .scrollBy (- 3 ) 212case tea .MouseWheelDown : 213m .scrollBy (3 ) 214 } 215if m .dragAnchor != nil { 216m .dragHead = point () 217 } 218 } 219return nil ,true 220case tea.PasteMsg : 221text := strings .ReplaceAll (msg .Content ,"\r\n" ,"\n" ) 222if ! collapses (text ) { 223return nil ,false 224 } 225m .TextArea .InsertString (m .Pastes .add (text )) 226m .syncLayout () 227return nil ,true 228case ClipboardImagePastedMsg : 229if msg .SessionID != m .SessionID || msg .Generation != m .Generation { 230return nil ,true 231 } 232switch { 233case msg .Err != nil : 234m .AddError (fmt .Sprintf ("Could not paste the image: %v" ,msg .Err )) 235case msg .Image == nil : 236case len (m .Images .referenced (m .TextArea .Value ()))>= MaxPromptImages : 237m .AddError (fmt .Sprintf ("A prompt can attach at most %d images." ,MaxPromptImages )) 238default : 239m .TextArea .InsertString (m .Images .add (pastedImage {* msg .Image ,msg .thumb })) 240m .CopyStatus = msg .hint 241m .syncLayout () 242if msg .transmit != "" { 243return tea .Raw (msg .transmit ),true 244 } 245 } 246return nil ,true 247case ChatEditorFinishedMsg : 248if msg .SessionID != m .SessionID || msg .Generation != m .Generation { 249return nil ,true 250 } 251if msg .Edited { 252m .TextArea .SetValue (strings .TrimRight (msg .Text ,"\r\n" )) 253m .syncLayout () 254 } 255if msg .Err != nil { 256m .AddError ("Could not finish editing the prompt: " + msg .Err .Error ()) 257 } 258return nil ,true 259default : 260return nil ,false 261 } 262return m .updateComposer (msg ,cmds ),true 263} 264 265func (m * ChatModel )updateComposer (msg tea.Msg ,cmds []tea.Cmd ) tea.Cmd { 266oldRows := m .inputRows () 267var taCmd tea.Cmd 268m .TextArea ,taCmd = m .TextArea .Update (msg ) 269if m .inputRows ()!= oldRows { 270m .syncLayout () 271 } 272cmds = append (cmds ,taCmd ) 273 274return tea .Batch (cmds ... ) 275} 276 277// deleteMarker removes the whole image or paste marker that a deletion at 278// the cursor would cut into, reporting whether there was one. 279func (m * ChatModel )deleteMarker (backward bool )bool { 280lines := strings .Split (m .TextArea .Value (),"\n" ) 281row := m .TextArea .Line () 282if row >= len (lines ) { 283return false 284 } 285start ,end ,ok := markerSpan ([]rune (lines [row ]),m .TextArea .Column (),backward ) 286if ! ok { 287return false 288 } 289m .TextArea .SetCursorColumn (end ) 290for range end - start { 291m .TextArea ,_ = m .TextArea .Update (tea.KeyPressMsg {Code :tea .KeyBackspace }) 292 } 293m .syncLayout () 294return true 295} 296 297// wordBackwardAtStart reports whether the prompt has no word before its cursor. 298// The textarea's word-backward handler loops indefinitely in this case. 299func (m ChatModel )wordBackwardAtStart ()bool { 300lines := strings .Split (m .TextArea .Value (),"\n" ) 301row := m .TextArea .Line () 302if row < 0 || row >= len (lines ) { 303return false 304 } 305if slices .ContainsFunc (lines [:row ],func (line string )bool {return strings .TrimSpace (line )!= "" }) { 306return false 307 } 308col := m .TextArea .LineInfo ().StartColumn + m .TextArea .LineInfo ().ColumnOffset 309current := []rune (lines [row ]) 310col = max (0 ,min (col ,len (current ))) 311return strings .TrimSpace (string (current [:col ]))== "" 312} 313 314func (m * ChatModel )isRecognizedCommand (input string )bool { 315trimmed := strings .TrimSpace (input ) 316if ! strings .HasPrefix (trimmed ,"/" ) { 317return false 318 } 319fields := strings .Fields (trimmed ) 320token ,typed := fields [0 ],len (fields )> 1 321switch token { 322case "/a" ,"/agents" ,"/sessions" ,"/q" ,"/quit" ,"/exit" ,"/new" ,"/model" ,"/extensions" , 323"/plugins" ,"/tree" ,"/quota" ,"/context" ,"/t" ,"/thinking" ,"/v" ,"/verbose" , 324"/status" ,"/login" ,"/mouse" ,"/skills" ,"/instructions" ,"/mcp" ,"/cd" ,"/effort" ,"/compact" ,"/kernel" ,"/reload" , 325"/work" ,"/paperclips" ,"/webhooks" : 326return true 327 } 328// Text after a command that declares no arguments is a prompt that 329// happens to start with the command's name. 330return slices .ContainsFunc (m .CommandMenu .Catalog ,func (cmd daemon.SessionCommand )bool { 331return cmd .Name == token && (! typed || len (cmd .Arguments )> 0 ) 332 }) 333} 334 335func (m * ChatModel )submitInput (input string ,cmds * []tea.Cmd ) { 336if strings .HasPrefix (input ,"/" )&& m .isRecognizedCommand (input ) { 337m .handleSubmittedCommand (input ,cmds ) 338return 339 } 340 341if m .pendingSendCount ()>= MaxPendingUsers { 342m .AddError ("Too many messages are waiting. Wait for the current reply to finish before sending another message." ) 343m .refreshViewportContent () 344return 345 } 346 347m .ClearNotices () 348m .TurnFailed = false 349m .Stopped = false 350 351continuation := input == "." 352var images []daemon.ImageAttachment 353var pastes []string 354if ! continuation { 355var pasted []pastedImage 356input ,pasted = m .Images .resolve (input ) 357for _ ,image := range pasted { 358images = append (images ,image .ImageAttachment ) 359 } 360input ,pastes = m .Pastes .resolve (input ) 361 } 362handle ,err := m .client .PrepareTurn (input ,images ,pastes ,continuation ) 363if err != nil { 364m .AddError (err .Error ()) 365return 366 } 367cmd := m .sendCmd (handle ,input ,images ,pastes ,continuation ) 368if continuation { 369if m .pendingContinuations == nil { 370m .pendingContinuations = make (map [string ]pendingOperation ) 371 } 372m .pendingContinuations [handle .ID ()]= pendingOperation {Handle :handle } 373 } 374if ! continuation { 375m .Images .restore (nil ) 376m .Pastes .restore (nil ) 377m .pendingUsers = append (m .pendingUsers ,PendingUserTurn {Text :input ,Images :images ,Pastes :pastes ,At :time .Now ().UnixMilli (),Handle :handle ,OperationID :handle .ID ()}) 378 } 379m .isSending ,m .sentHere ,m .Follow = true ,true ,true 380m .reseedMood () 381m .refreshViewportContent () 382* cmds = append (* cmds ,cmd ,m .startAnimation ()) 383} 384 385func (m * ChatModel )handleSubmittedCommand (input string ,cmds * []tea.Cmd ) { 386m .TextArea .Reset () 387m .syncLayout () 388trimmed := strings .TrimSpace (input ) 389emit := func (msg tea.Msg ) {* cmds = append (* cmds ,func () tea.Msg {return msg }) } 390 391name ,args ,_ := strings .Cut (trimmed ," " ) 392commandIndex := slices .IndexFunc (m .CommandMenu .Catalog ,func (command daemon.SessionCommand )bool { 393return command .Name == name && command .Delivery == "input" 394 }) 395if commandIndex >= 0 { 396command := m .CommandMenu .Catalog [commandIndex ] 397if m .pendingSendCount ()>= MaxPendingUsers { 398m .AddError ("Too many messages are waiting." ) 399return 400 } 401var handle * daemon.OperationHandle 402var err error 403arguments ,err := daemon .ParseCommandArguments (command ,args ) 404if err == nil { 405handle ,err = m .client .PrepareCommand (command .CommandID ,arguments ) 406 } 407if err != nil { 408m .TextArea .SetValue (input ) 409m .AddError (err .Error ()) 410return 411 } 412m .pendingUsers = append (m .pendingUsers ,PendingUserTurn {Text :trimmed ,At :time .Now ().UnixMilli (),Handle :handle ,OperationID :handle .ID ()}) 413m .isSending ,m .sentHere ,m .Follow = true ,true ,true 414m .TurnFailed ,m .Stopped = false ,false 415* cmds = append (* cmds ,m .sendCmd (handle ,trimmed ,nil ,nil ,false ),m .startAnimation ()) 416m .refreshViewportContent () 417return 418 } 419 420switch trimmed { 421case "/agents" : 422emit (ChatOpenAgentsMsg {}) 423case "/a" ,"/sessions" : 424emit (ChatBackToSessionsMsg {}) 425case "/q" ,"/quit" ,"/exit" : 426m .Close () 427emit (ChatQuitMsg {}) 428case "/new" : 429emit (ChatNewSessionMsg {}) 430case "/model" : 431emit (ChatOpenModelPickerMsg {}) 432case "/extensions" ,"/plugins" : 433emit (ChatOpenExtensionPickerMsg {}) 434case "/tree" : 435emit (ChatOpenTreePickerMsg {}) 436case "/quota" : 437emit (ChatOpenQuotaMsg {}) 438case "/context" : 439emit (ChatOpenContextInspectorMsg {}) 440case "/ttl" ,"/requests" : 441emit (ChatOpenPageMsg {Command :trimmed }) 442case "/webhooks" : 443emit (ChatOpenWebhooksPageMsg {}) 444case "/skills" ,"/instructions" ,"/mcp" : 445emit (ChatOpenCapabilityPageMsg {Kind :trimmed [1 :]}) 446case "/t" ,"/thinking" ,"/v" ,"/verbose" : 447if trimmed == "/t" || trimmed == "/thinking" { 448m .Flags .Thinking = ! m .Flags .Thinking 449 }else { 450m .Flags .Tools = ! m .Flags .Tools 451 } 452m .rebuildSettledLines () 453m .refreshViewportContent () 454case "/status" : 455statusText := fmt .Sprintf ("session: %s\nworkspace: %s\nmodel: %s" ,m .SessionID ,m .Workspace ,m .Model ) 456if m .Effort != "" { 457statusText += "\neffort: " + m .Effort 458 } 459if m .Usage != nil { 460statusText += "\ntokens: " + formatUsage (m .Usage ) 461 } 462m .appendSettledEntry (HistoryEntry {Kind :EntryNote ,Text :statusText }) 463m .refreshViewportContent () 464default : 465switch { 466case strings .HasPrefix (trimmed ,"/model " ): 467emit (ChatExecuteCommandMsg {Name :"/model" ,Args :strings .TrimSpace (trimmed [7 :])}) 468case strings .HasPrefix (trimmed ,"/login" ): 469emit (ChatOpenLoginMsg {Name :strings .TrimSpace (strings .TrimPrefix (trimmed ,"/login" ))}) 470default : 471for _ ,cmd := range m .CommandMenu .Catalog { 472if cmd .Name == trimmed && cmd .Page != nil && * cmd .Page { 473emit (ChatOpenPageMsg {Command :trimmed }) 474return 475 } 476 } 477cmdName ,cmdArgs ,_ := strings .Cut (trimmed ," " ) 478emit (ChatExecuteCommandMsg {Name :cmdName ,Args :cmdArgs }) 479 } 480 } 481}