okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: probe the terminal for kitty clipboard support before pasting an image3704fc3

main
7.1 KiB225 linesraw
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 {
29	id       string
30	probing  bool
31	mime     string
32	data     []byte
33	received int
34	started  bool
35}
36
37type terminalClipboardProbeTimeoutMsg struct {
38	SessionID  string
39	ID         string
40	Generation 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 {
48	if m.terminalClipboard.id != "" {
49		return nil
50	}
51	id := newClipboardID()
52	m.terminalClipboard = terminalClipboardRead{id: id, probing: true}
53	timeout := terminalClipboardProbeTimeoutMsg{m.SessionID, id, m.Generation}
54	return tea.Batch(
55		terminalRaw(ansi.RequestMode(kittyClipboardMode)),
56		tea.Tick(clipboardProbeTimeout, func(time.Time) tea.Msg { return timeout }),
57	)
58}
59
60func (m *ChatModel) finishClipboardProbe(supported bool) tea.Cmd {
61	m.terminalClipboard = terminalClipboardRead{}
62	if supported || isRemote() {
63		return m.readTerminalClipboard()
64	}
65	return PasteClipboardImageCmd(m.SessionID, m.Generation)
66}
67
68func newClipboardID() string { return fmt.Sprintf("albedo-%d", nextPageGeneration()) }
69
70func terminalRaw(sequence string) tea.Cmd {
71	if os.Getenv("TMUX") != "" {
72		sequence = ansi.TmuxPassthrough(sequence)
73	}
74	return tea.Raw(sequence)
75}
76
77type terminalClipboardTimeoutMsg struct {
78	SessionID  string
79	ID         string
80	Generation int64
81}
82
83func (m *ChatModel) readTerminalClipboard() tea.Cmd {
84	if m.terminalClipboard.id != "" {
85		return nil
86	}
87	id := newClipboardID()
88	m.terminalClipboard = terminalClipboardRead{id: id}
89	request := "\x1b]5522;type=read:id=" + id + ";" +
90		base64.StdEncoding.EncodeToString([]byte("image/png image/jpeg")) + "\x1b\\"
91	timeout := terminalClipboardTimeoutMsg{m.SessionID, id, m.Generation}
92	return 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) {
96	switch msg := msg.(type) {
97	case terminalClipboardProbeTimeoutMsg:
98		if msg.SessionID != m.SessionID || msg.Generation != m.Generation || msg.ID != m.terminalClipboard.id || !m.terminalClipboard.probing {
99			return nil, true
100		}
101		return m.finishClipboardProbe(false), true
102	case tea.ModeReportMsg:
103		if msg.Mode != kittyClipboardMode {
104			return nil, false
105		}
106		if !m.terminalClipboard.probing {
107			return nil, true
108		}
109		switch msg.Value {
110		case ansi.ModeSet, ansi.ModeReset, ansi.ModePermanentlySet:
111			return m.finishClipboardProbe(true), true
112		}
113		return m.finishClipboardProbe(false), true
114	case terminalClipboardTimeoutMsg:
115		if msg.SessionID != m.SessionID || msg.Generation != m.Generation || msg.ID != m.terminalClipboard.id {
116			return nil, true
117		}
118		err := 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")
120		return m.finishTerminalClipboard(err), true
121	case uv.UnknownOscEvent:
122		packet, ok := strings.CutPrefix(string(msg), "\x1b]5522;")
123		if !ok {
124			return nil, false
125		}
126		packet = strings.TrimSuffix(strings.TrimSuffix(packet, "\x1b\\"), "\a")
127		metadata, payload, _ := strings.Cut(packet, ";")
128		if len(metadata) > 1024 {
129			return nil, true
130		}
131		fields := map[string]string{}
132		for field := range strings.SplitSeq(metadata, ":") {
133			key, value, ok := strings.Cut(field, "=")
134			if ok {
135				fields[key] = value
136			}
137		}
138		r := &m.terminalClipboard
139		if r.id == "" || fields["id"] != r.id || fields["type"] != "read" {
140			return nil, true
141		}
142		if len(packet) > 8192 {
143			return m.finishTerminalClipboard(errors.New("oversized terminal clipboard response")), true
144		}
145		done, err := r.accept(fields, payload)
146		if done {
147			return m.finishTerminalClipboard(err), true
148		}
149		return nil, true
150	}
151	return nil, false
152}
153
154func (r *terminalClipboardRead) accept(fields map[string]string, payload string) (bool, error) {
155	switch fields["status"] {
156	case "OK":
157		if r.started {
158			return true, errors.New("duplicate clipboard response")
159		}
160		r.started = true
161	case "DATA":
162		if !r.started {
163			return true, errors.New("clipboard data arrived before its acknowledgement")
164		}
165		mime, err := decodeClipboardChunk(fields["mime"], 64)
166		if err != nil || (string(mime) != "image/png" && string(mime) != "image/jpeg") {
167			return true, errors.New("unexpected clipboard image type")
168		}
169		data, err := decodeClipboardChunk(payload, 4096)
170		if err != nil {
171			return true, err
172		}
173		if r.received+len(data) > MaxClipboardImageBytes {
174			return true, fmt.Errorf("clipboard image exceeds %d byte limit", MaxClipboardImageBytes)
175		}
176		r.received += len(data)
177		if r.mime == "" {
178			r.mime = string(mime)
179		}
180		if r.mime == string(mime) {
181			r.data = append(r.data, data...)
182		}
183	case "DONE":
184		if !r.started {
185			return true, errors.New("clipboard response ended before its acknowledgement")
186		}
187		return true, nil
188	case "EPERM":
189		return true, errors.New("clipboard access denied; allow the clipboard read in your terminal and try again")
190	case "ENOSYS":
191		return true, errors.New("the terminal does not support image clipboard reads")
192	case "EBUSY":
193		return true, errors.New("the terminal clipboard is busy; try again")
194	default:
195		return true, errors.New("invalid terminal clipboard response")
196	}
197	return false, nil
198}
199
200func decodeClipboardChunk(encoded string, limit int) ([]byte, error) {
201	if len(encoded) > base64.StdEncoding.EncodedLen(limit) || strings.ContainsAny(encoded, "\r\n") {
202		return nil, errors.New("invalid or oversized clipboard chunk")
203	}
204	data, err := base64.StdEncoding.Strict().DecodeString(encoded)
205	if err != nil || len(data) > limit {
206		return nil, errors.New("invalid or oversized clipboard chunk")
207	}
208	return data, nil
209}
210
211func (m *ChatModel) finishTerminalClipboard(err error) tea.Cmd {
212	data := m.terminalClipboard.data
213	m.terminalClipboard = terminalClipboardRead{}
214	sessionID, gen := m.SessionID, m.Generation
215	return func() tea.Msg {
216		if err != nil {
217			return clipboardImagePasted(sessionID, gen, nil, err)
218		}
219		if len(data) == 0 {
220			return clipboardImagePasted(sessionID, gen, nil, errors.New("the local clipboard has no PNG or JPEG image"))
221		}
222		img, err := clipboardImage(data)
223		return clipboardImagePasted(sessionID, gen, img, err)
224	}
225}