okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
3704fc3
main
1package tui 2 3import ( 4"encoding/base64" 5"errors" 6"fmt" 7"os" 8"strings" 9"time" 10 11 tea"charm.land/bubbletea/v2" 12 uv"github.com/charmbracelet/ultraviolet" 13"github.com/charmbracelet/x/ansi" 14) 15 16// kittyClipboardMode is the DEC private mode a terminal reports (DECRQM) when it 17// implements the kitty clipboard protocol and its paste events. 18const kittyClipboardMode ansi.DECMode = 5522 19 20// A local terminal answers the probe within milliseconds. Terminals that never 21// answer fall back to the environment, so waiting longer buys nothing. 22const clipboardProbeTimeout = 500 * time .Millisecond 23 24// terminalClipboardRead is the one pending paste: first the capability probe, 25// then the read. OSC 5522 replies travel through Bubble Tea's reader, never a 26// second TTY reader. 27// https://sw.kovidgoyal.net/kitty/clipboard/ 28type terminalClipboardRead struct { 29id string 30probing bool 31mime string 32data []byte 33received int 34started bool 35} 36 37type terminalClipboardProbeTimeoutMsg struct { 38SessionID string 39ID string 40Generation int64 41} 42 43// pasteImage asks the terminal whether it can hand over its clipboard, because 44// that terminal, not this machine, holds what the user copied: over ssh, mosh, 45// or an attach from another device the two differ. A terminal that does not 46// answer is trusted to be local unless the environment says otherwise. 47func (m * ChatModel )pasteImage () tea.Cmd { 48if m .terminalClipboard .id != "" { 49return nil 50 } 51id := newClipboardID () 52m .terminalClipboard = terminalClipboardRead {id :id ,probing :true } 53timeout := terminalClipboardProbeTimeoutMsg {m .SessionID ,id ,m .Generation } 54return tea .Batch ( 55terminalRaw (ansi .RequestMode (kittyClipboardMode )), 56tea .Tick (clipboardProbeTimeout ,func (time.Time ) tea.Msg {return timeout }), 57 ) 58} 59 60func (m * ChatModel )finishClipboardProbe (supported bool ) tea.Cmd { 61m .terminalClipboard = terminalClipboardRead {} 62if supported || isRemote () { 63return m .readTerminalClipboard () 64 } 65return PasteClipboardImageCmd (m .SessionID ,m .Generation ) 66} 67 68func newClipboardID ()string {return fmt .Sprintf ("albedo-%d" ,nextPageGeneration ()) } 69 70func terminalRaw (sequence string ) tea.Cmd { 71if os .Getenv ("TMUX" )!= "" { 72sequence = ansi .TmuxPassthrough (sequence ) 73 } 74return tea .Raw (sequence ) 75} 76 77type terminalClipboardTimeoutMsg struct { 78SessionID string 79ID string 80Generation int64 81} 82 83func (m * ChatModel )readTerminalClipboard () tea.Cmd { 84if m .terminalClipboard .id != "" { 85return nil 86 } 87id := newClipboardID () 88m .terminalClipboard = terminalClipboardRead {id :id } 89request := "\x1b]5522;type=read:id=" + id + ";" + 90base64 .StdEncoding .EncodeToString ([]byte ("image/png image/jpeg" ))+ "\x1b\\" 91timeout := terminalClipboardTimeoutMsg {m .SessionID ,id ,m .Generation } 92return tea .Batch (terminalRaw (request ),tea .Tick (30 * time .Second ,func (time.Time ) tea.Msg {return timeout })) 93} 94 95func (m * ChatModel )handleTerminalClipboard (msg tea.Msg ) (tea.Cmd ,bool ) { 96switch msg := msg .(type ) { 97case terminalClipboardProbeTimeoutMsg : 98if msg .SessionID != m .SessionID || msg .Generation != m .Generation || msg .ID != m .terminalClipboard .id || ! m .terminalClipboard .probing { 99return nil ,true 100 } 101return m .finishClipboardProbe (false ),true 102case tea.ModeReportMsg : 103if msg .Mode != kittyClipboardMode { 104return nil ,false 105 } 106if ! m .terminalClipboard .probing { 107return nil ,true 108 } 109switch msg .Value { 110case ansi .ModeSet ,ansi .ModeReset ,ansi .ModePermanentlySet : 111return m .finishClipboardProbe (true ),true 112 } 113return m .finishClipboardProbe (false ),true 114case terminalClipboardTimeoutMsg : 115if msg .SessionID != m .SessionID || msg .Generation != m .Generation || msg .ID != m .terminalClipboard .id { 116return nil ,true 117 } 118err := errors .New ("image paste timed out; use a terminal with OSC 5522 support (such as kitty) " + 119"and allow clipboard reads; in tmux, set -g allow-passthrough on" ) 120return m .finishTerminalClipboard (err ),true 121case uv.UnknownOscEvent : 122packet ,ok := strings .CutPrefix (string (msg ),"\x1b]5522;" ) 123if ! ok { 124return nil ,false 125 } 126packet = strings .TrimSuffix (strings .TrimSuffix (packet ,"\x1b\\" ),"\a" ) 127metadata ,payload ,_ := strings .Cut (packet ,";" ) 128if len (metadata )> 1024 { 129return nil ,true 130 } 131fields := map [string ]string {} 132for field := range strings .SplitSeq (metadata ,":" ) { 133key ,value ,ok := strings .Cut (field ,"=" ) 134if ok { 135fields [key ]= value 136 } 137 } 138r := & m .terminalClipboard 139if r .id == "" || fields ["id" ]!= r .id || fields ["type" ]!= "read" { 140return nil ,true 141 } 142if len (packet )> 8192 { 143return m .finishTerminalClipboard (errors .New ("oversized terminal clipboard response" )),true 144 } 145done ,err := r .accept (fields ,payload ) 146if done { 147return m .finishTerminalClipboard (err ),true 148 } 149return nil ,true 150 } 151return nil ,false 152} 153 154func (r * terminalClipboardRead )accept (fields map [string ]string ,payload string ) (bool ,error ) { 155switch fields ["status" ] { 156case "OK" : 157if r .started { 158return true ,errors .New ("duplicate clipboard response" ) 159 } 160r .started = true 161case "DATA" : 162if ! r .started { 163return true ,errors .New ("clipboard data arrived before its acknowledgement" ) 164 } 165mime ,err := decodeClipboardChunk (fields ["mime" ],64 ) 166if err != nil || (string (mime )!= "image/png" && string (mime )!= "image/jpeg" ) { 167return true ,errors .New ("unexpected clipboard image type" ) 168 } 169data ,err := decodeClipboardChunk (payload ,4096 ) 170if err != nil { 171return true ,err 172 } 173if r .received + len (data )> MaxClipboardImageBytes { 174return true ,fmt .Errorf ("clipboard image exceeds %d byte limit" ,MaxClipboardImageBytes ) 175 } 176r .received += len (data ) 177if r .mime == "" { 178r .mime = string (mime ) 179 } 180if r .mime == string (mime ) { 181r .data = append (r .data ,data ... ) 182 } 183case "DONE" : 184if ! r .started { 185return true ,errors .New ("clipboard response ended before its acknowledgement" ) 186 } 187return true ,nil 188case "EPERM" : 189return true ,errors .New ("clipboard access denied; allow the clipboard read in your terminal and try again" ) 190case "ENOSYS" : 191return true ,errors .New ("the terminal does not support image clipboard reads" ) 192case "EBUSY" : 193return true ,errors .New ("the terminal clipboard is busy; try again" ) 194default : 195return true ,errors .New ("invalid terminal clipboard response" ) 196 } 197return false ,nil 198} 199 200func decodeClipboardChunk (encoded string ,limit int ) ([]byte ,error ) { 201if len (encoded )> base64 .StdEncoding .EncodedLen (limit )|| strings .ContainsAny (encoded ,"\r\n" ) { 202return nil ,errors .New ("invalid or oversized clipboard chunk" ) 203 } 204data ,err := base64 .StdEncoding .Strict ().DecodeString (encoded ) 205if err != nil || len (data )> limit { 206return nil ,errors .New ("invalid or oversized clipboard chunk" ) 207 } 208return data ,nil 209} 210 211func (m * ChatModel )finishTerminalClipboard (err error ) tea.Cmd { 212data := m .terminalClipboard .data 213m .terminalClipboard = terminalClipboardRead {} 214sessionID ,gen := m .SessionID ,m .Generation 215return func () tea.Msg { 216if err != nil { 217return clipboardImagePasted (sessionID ,gen ,nil ,err ) 218 } 219if len (data )== 0 { 220return clipboardImagePasted (sessionID ,gen ,nil ,errors .New ("the local clipboard has no PNG or JPEG image" )) 221 } 222img ,err := clipboardImage (data ) 223return clipboardImagePasted (sessionID ,gen ,img ,err ) 224 } 225}