okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
cc6ab84
main
1package tui 2 3import ( 4"cmp" 5"errors" 6"fmt" 7"io" 8"os" 9"os/exec" 10"path/filepath" 11"regexp" 12"slices" 13"strconv" 14"strings" 15 16"albedo/cli/internal/daemon" 17 tea"charm.land/bubbletea/v2" 18"github.com/charmbracelet/x/ansi" 19) 20 21func (m ChatModel )openEditorCmd () tea.Cmd { 22args := strings .Fields (cmp .Or (os .Getenv ("EDITOR" ),os .Getenv ("VISUAL" ),"nano" )) 23if len (args )== 0 { 24args = []string {"nano" } 25 } 26process := & promptEditorProcess { 27editorProcess :editorProcess {exec .Command (args [0 ],args [1 :]... ),m .graphemes }, 28prompt :m .TextArea .Value (), 29 } 30sessionID ,generation := m .SessionID ,m .Generation 31return tea .Exec (process ,func (err error ) tea.Msg { 32return ChatEditorFinishedMsg {SessionID :sessionID ,Generation :generation ,Text :process .text ,Edited :process .edited ,Err :err } 33 }) 34} 35 36// promptEditorProcess owns the prompt file for the complete editor operation. 37// Its completion message carries text even when the UI no longer owns the session. 38type promptEditorProcess struct { 39editorProcess 40prompt string 41text string 42edited bool 43} 44 45func (p * promptEditorProcess )Run ()error { 46file ,err := os .CreateTemp ("" ,"albedo-prompt-*.md" ) 47if err != nil { 48return fmt .Errorf ("could not create temporary file: %w" ,err ) 49 } 50path := file .Name () 51defer os .Remove (path ) 52if _ ,err := file .WriteString (p .prompt );err != nil { 53file .Close () 54return fmt .Errorf ("could not write to temporary file: %w" ,err ) 55 } 56if err := file .Close ();err != nil { 57return fmt .Errorf ("could not close temporary file: %w" ,err ) 58 } 59 60p .Args = append (p .Args ,path ) 61commandErr ,restoreErr := p .runAndRestore () 62if commandErr != nil { 63return errors .Join (commandErr ,restoreErr ) 64 } 65data ,err := os .ReadFile (path ) 66if err != nil { 67return errors .Join (fmt .Errorf ("could not read the edited prompt: %w" ,err ),restoreErr ) 68 } 69p .text = string (data ) 70p .edited = true 71return restoreErr 72} 73 74// editorProcess runs the editor on the terminal, then hands the terminal back 75// the way Bubble Tea left it. Bubble Tea repaints from where it left the 76// cursor and trusts the column, but leaving the alternate screen puts the 77// cursor back wherever the editor found it, and a terminal reply echoed 78// before the editor took raw mode can have moved it along the row; the 79// repaint then starts mid-row, wraps the header and scrolls it off the top. 80// Bubble Tea also turns grapheme widths off for the editor and never turns 81// them back on, while it keeps measuring by grapheme. 82type editorProcess struct { 83* exec.Cmd 84graphemes bool 85} 86 87func (p editorProcess )SetStdin (r io.Reader ) {p .Stdin = r } 88func (p editorProcess )SetStdout (w io.Writer ) {p .Stdout = w } 89func (p editorProcess )SetStderr (w io.Writer ) {p .Stderr = w } 90 91func (p editorProcess )Run ()error { 92return errors .Join (p .runAndRestore ()) 93} 94 95func (p editorProcess )runAndRestore () (commandErr ,restoreErr error ) { 96commandErr = p .Cmd .Run () 97restore := "\r" 98if p .graphemes { 99restore += ansi .SetModeUnicodeCore 100 } 101written ,err := io .WriteString (p .Stdout ,restore ) 102if err == nil && written != len (restore ) { 103err = io .ErrShortWrite 104 } 105if err != nil { 106restoreErr = fmt .Errorf ("could not restore terminal output after the editor: %w" ,err ) 107 } 108return commandErr ,restoreErr 109} 110 111// FileTarget is a file location clicked in the transcript. 112type FileTarget struct { 113Path string 114Line ,Col int 115} 116 117var ( 118// pathToken is a run of text that quotes, brackets, and spaces end. 119pathToken = regexp .MustCompile ("[^\\s\"'`()\\[\\]{}<>]+" ) 120// position is a trailing :line[:col] or #Lline. 121position = regexp .MustCompile (`(?::(\d+)(?::(\d+))?|#L(\d+))$` ) 122diffGutter = regexp .MustCompile (`^\s*(\d+) [+\- ] ` ) 123) 124 125// openFileAt opens the file named at transcript position (row, col). 126func (m * ChatModel )openFileAt (row ,col int ) tea.Cmd { 127if row < 0 || row >= len (m .frameLines ) { 128return nil 129 } 130target ,ok := fileTargetAt (ansi .Strip (m .frameLines [row ]),col ,m .Workspace ) 131if ! ok { 132target ,ok = m .diffTargetAt (row ) 133 } 134if ! ok { 135return nil 136 } 137return m .openFileCmd (target ) 138} 139 140// diffTargetAt names the line of a diff row: the path is the first row above 141// it that is neither a numbered row nor a hunk header. 142func (m * ChatModel )diffTargetAt (row int ) (FileTarget ,bool ) { 143gutter := diffGutter .FindStringSubmatch (rowBody (m .frameLines [row ])) 144if gutter == nil { 145return FileTarget {},false 146 } 147for prev := row - 1 ;prev >= 0 ;prev -- { 148above := rowBody (m .frameLines [prev ]) 149if diffGutter .MatchString (above )|| strings .Contains (above ,"@@" ) { 150continue 151 } 152target ,ok := fileTargetAt (above ,0 ,m .Workspace ) 153target .Line ,_ = strconv .Atoi (gutter [1 ]) 154target .Col = 0 155return target ,ok 156 } 157return FileTarget {},false 158} 159 160// rowBody is a transcript row's plain text after its rail. 161func rowBody (row string )string { 162plain := ansi .Strip (row ) 163return ansi .Cut (plain ,railWidth ,ansi .StringWidth (plain )) 164} 165 166// fileTargetAt is the existing file named by the token under column col of 167// plain, else the only existing file plain names. 168func fileTargetAt (plain string ,col int ,workspace string ) (FileTarget ,bool ) { 169at := len (ansi .Truncate (plain ,col ,"" )) 170var found []FileTarget 171for _ ,span := range pathToken .FindAllStringIndex (plain ,- 1 ) { 172target ,ok := parseTarget (plain [span [0 ]:span [1 ]],workspace ) 173if ! ok { 174continue 175 } 176if span [0 ]<= at && at <= span [1 ] { 177return target ,true 178 } 179found = append (found ,target ) 180 } 181if len (found )== 1 { 182return found [0 ],true 183 } 184return FileTarget {},false 185} 186 187// parseTarget reads a token such as path=a/x.go:12:3 or file://x.go#L4, 188// keeping it only when it names a file that exists. 189func parseTarget (token ,workspace string ) (FileTarget ,bool ) { 190token = strings .TrimRight (token ,".,;:" ) 191if strings .Contains (token ,"://" )&& ! strings .HasPrefix (token ,"file://" ) { 192return FileTarget {},false 193 } 194token = strings .TrimPrefix (token ,"file://" ) 195if _ ,after ,ok := strings .Cut (token ,"=" );ok { 196token = after 197 } 198var target FileTarget 199if loc := position .FindStringSubmatchIndex (token );loc != nil { 200groups := position .FindStringSubmatch (token ) 201target .Line ,_ = strconv .Atoi (groups [1 ]+ groups [3 ]) 202target .Col ,_ = strconv .Atoi (groups [2 ]) 203token = token [:loc [0 ]] 204 } 205if token == "" { 206return FileTarget {},false 207 } 208home ,_ := os .UserHomeDir () 209_ ,root := daemon .SplitLocation (workspace ) 210paths := []string {token } 211// a diff's a/ and b/ prefixes 212if rest ,ok := strings .CutPrefix (token ,"a/" );ok { 213paths = append (paths ,rest ) 214 }else if rest ,ok := strings .CutPrefix (token ,"b/" );ok { 215paths = append (paths ,rest ) 216 } 217for _ ,p := range paths { 218p = expandHome (p ,home ) 219if ! filepath .IsAbs (p ) { 220p = filepath .Join (cmp .Or (root ,"." ),p ) 221 } 222if info ,err := os .Stat (p );err == nil && info .Mode ().IsRegular () { 223target .Path ,_ = filepath .Abs (p ) 224return target ,true 225 } 226 } 227return FileTarget {},false 228} 229 230func (m ChatModel )openFileCmd (target FileTarget ) tea.Cmd { 231bin ,args ,terminal := detectEditor (target ) 232sessionID := m .SessionID 233if terminal { 234process := editorProcess {exec .Command (bin ,args ... ),m .graphemes } 235return tea .Exec (process ,func (err error ) tea.Msg { 236return FileOpenResultMsg {SessionID :sessionID ,Target :target ,Err :err } 237 }) 238 } 239return func () tea.Msg { 240cmd := exec .Command (bin ,args ... ) 241err := cmd .Start () 242if err == nil { 243go cmd .Wait () 244 } 245return FileOpenResultMsg {SessionID :sessionID ,Target :target ,Err :err } 246 } 247} 248 249// guiEditors open a file in a window of their own and return at once; how 250// each takes a line is in editorArgs. 251var guiEditors = []string {"code" ,"cursor" ,"windsurf" ,"codium" ,"zed" ,"subl" } 252 253// detectEditor picks the IDE whose terminal albedo runs in, else $EDITOR, 254// and says whether the editor needs this terminal. 255func detectEditor (target FileTarget ) (bin string ,args []string ,terminal bool ) { 256if ide := terminalIDE ();ide != "" { 257if path ,err := exec .LookPath (ide );err == nil { 258return path ,editorArgs (ide ,target ),false 259 } 260 } 261fields := strings .Fields (cmp .Or (os .Getenv ("EDITOR" ),os .Getenv ("VISUAL" ),"nano" )) 262base := filepath .Base (fields [0 ]) 263return fields [0 ],append (fields [1 :],editorArgs (base ,target )... ),! slices .Contains (guiEditors ,base ) 264} 265 266// terminalIDE names the CLI of the editor whose integrated terminal this is. 267func terminalIDE ()string { 268env := os .Getenv 269switch { 270case env ("TERM_PROGRAM" )== "zed" || env ("ZED_TERM" )!= "" : 271return "zed" 272case env ("TERM_PROGRAM" )!= "vscode" && env ("VSCODE_PID" )== "" : 273return "" 274case env ("CURSOR_TRACE_ID" )!= "" || env ("CURSOR_VERSION" )!= "" : 275return "cursor" 276case env ("WINDSURF_VERSION" )!= "" : 277return "windsurf" 278 } 279return "code" 280} 281 282func editorArgs (editor string ,t FileTarget ) []string { 283at := t .Path 284if t .Line > 0 { 285at += ":" + strconv .Itoa (t .Line ) 286if t .Col > 0 { 287at += ":" + strconv .Itoa (t .Col ) 288 } 289 } 290switch { 291case t .Line == 0 : 292return []string {t .Path } 293case editor == "code" || editor == "cursor" || editor == "windsurf" || editor == "codium" : 294return []string {"-g" ,at } 295case editor == "zed" || editor == "subl" || editor == "hx" : 296return []string {at } 297 } 298// vi, nano, emacs, and most others 299return []string {"+" + strconv .Itoa (t .Line ),t .Path } 300}