detached 6899695

Merge pull request #43 from mi-skam/clipboard-paste-support

commit
689969532020d9cf8d705e717e33ace046a3d42a
author
patriceckhart · · 2026-06-22 05:54 UTC
merge of
821f51a + c56e04a · diff against the first parent
signature
unsigned
refs
tag: v0.2.43
9 files · +491 -6
M README.md +1 -0
@@ -576,6 +576,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
576576 | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |
577577 | `ctrl+d` | Exit on empty input. |
578578 | `ctrl+l` | Redraw the screen. |
579+| `ctrl+v` | Paste an image from the system clipboard through zot on macOS. Images are saved as temporary PNGs and attached to the next prompt. Other platforms currently use a no-op stub. Use your terminal/OS paste shortcut for text. |
579580 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |
580581 | `@` | Open the file picker. Browse files and directories in the working directory. |
581582
@@ -576,6 +576,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
576 | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |576 | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |
577 | `ctrl+d` | Exit on empty input. |577 | `ctrl+d` | Exit on empty input. |
578 | `ctrl+l` | Redraw the screen. |578 | `ctrl+l` | Redraw the screen. |
579+| `ctrl+v` | Paste an image from the system clipboard through zot on macOS. Images are saved as temporary PNGs and attached to the next prompt. Other platforms currently use a no-op stub. Use your terminal/OS paste shortcut for text. |
579 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |580 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |
580 | `@` | Open the file picker. Browse files and directories in the working directory. |581 | `@` | Open the file picker. Browse files and directories in the working directory. |
581 582
M packages/agent/modes/btw_dialog.go +6 -4
@@ -378,12 +378,14 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
378378 }
379379 // Reproduce render's structure to find where the editor sits.
380380 // Note: the parent (interactive.go) wraps every dialog with
381- // padDialogFrame, which injects a blank row right after the
382- // frame header. We have to count that injected row here too;
383- // otherwise the reported cursor lands one row above the editor.
381+ // padDialogFrame. It only injects a blank row after the frame
382+ // header when Render did not already put one there. With existing
383+ // turns or a loading spinner, Render's first body row is already
384+ // blank, so counting an extra pad row would place the cursor one
385+ // row too low.
384386 editorOffset := 1 // header
385- editorOffset++ // padDialogFrame's post-header blank
386387 if len(d.turns) == 0 && !d.loading {
388+ editorOffset++ // padDialogFrame's post-header blank
387389 editorOffset++ // muted "ask anything..." line
388390 }
389391 for _, t := range d.turns {
@@ -378,12 +378,14 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
378 }378 }
379 // Reproduce render's structure to find where the editor sits.379 // Reproduce render's structure to find where the editor sits.
380 // Note: the parent (interactive.go) wraps every dialog with380 // Note: the parent (interactive.go) wraps every dialog with
381- // padDialogFrame, which injects a blank row right after the381+ // padDialogFrame. It only injects a blank row after the frame
382- // frame header. We have to count that injected row here too;382+ // header when Render did not already put one there. With existing
383- // otherwise the reported cursor lands one row above the editor.383+ // turns or a loading spinner, Render's first body row is already
384+ // blank, so counting an extra pad row would place the cursor one
385+ // row too low.
384 editorOffset := 1 // header386 editorOffset := 1 // header
385- editorOffset++ // padDialogFrame's post-header blank
386 if len(d.turns) == 0 && !d.loading {387 if len(d.turns) == 0 && !d.loading {
388+ editorOffset++ // padDialogFrame's post-header blank
387 editorOffset++ // muted "ask anything..." line389 editorOffset++ // muted "ask anything..." line
388 }390 }
389 for _, t := range d.turns {391 for _, t := range d.turns {
A packages/agent/modes/clipboard_images.go +61 -0
@@ -0,0 +1,61 @@
1+package modes
2+
3+import (
4+ "strings"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
8+
9+type clipboardImageAttachment struct {
10+ Marker string
11+ Image provider.ImageBlock
12+}
13+
14+func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) {
15+ if len(pending) == 0 {
16+ return text, nil
17+ }
18+
19+ out := text
20+ images := make([]provider.ImageBlock, 0, len(pending))
21+ for _, item := range pending {
22+ if item.Marker == "" || !strings.Contains(out, item.Marker) {
23+ continue
24+ }
25+ out = removeClipboardMarker(out, item.Marker)
26+ images = append(images, item.Image)
27+ }
28+ return strings.TrimSpace(out), images
29+}
30+
31+func removeClipboardMarker(text, marker string) string {
32+ for {
33+ idx := strings.Index(text, marker)
34+ if idx < 0 {
35+ return text
36+ }
37+ end := idx + len(marker)
38+ prevInline := idx > 0 && isInlineWhitespace(text[idx-1])
39+ nextInline := end < len(text) && isInlineWhitespace(text[end])
40+ prevLineBreak := idx == 0 || idx > 0 && isLineBreak(text[idx-1])
41+ nextLineBreak := end == len(text) || end < len(text) && isLineBreak(text[end])
42+ switch {
43+ case prevInline && nextInline:
44+ text = text[:idx] + text[end+1:]
45+ case prevInline && nextLineBreak:
46+ text = text[:idx-1] + text[end:]
47+ case prevLineBreak && nextInline:
48+ text = text[:idx] + text[end+1:]
49+ default:
50+ text = text[:idx] + text[end:]
51+ }
52+ }
53+}
54+
55+func isInlineWhitespace(b byte) bool {
56+ return b == ' ' || b == '\t'
57+}
58+
59+func isLineBreak(b byte) bool {
60+ return b == '\n' || b == '\r'
61+}
@@ -0,0 +1,61 @@
1+package modes
2+
3+import (
4+ "strings"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
8+
9+type clipboardImageAttachment struct {
10+ Marker string
11+ Image provider.ImageBlock
12+}
13+
14+func preparePromptWithClipboardImages(text string, pending []clipboardImageAttachment) (string, []provider.ImageBlock) {
15+ if len(pending) == 0 {
16+ return text, nil
17+ }
18+
19+ out := text
20+ images := make([]provider.ImageBlock, 0, len(pending))
21+ for _, item := range pending {
22+ if item.Marker == "" || !strings.Contains(out, item.Marker) {
23+ continue
24+ }
25+ out = removeClipboardMarker(out, item.Marker)
26+ images = append(images, item.Image)
27+ }
28+ return strings.TrimSpace(out), images
29+}
30+
31+func removeClipboardMarker(text, marker string) string {
32+ for {
33+ idx := strings.Index(text, marker)
34+ if idx < 0 {
35+ return text
36+ }
37+ end := idx + len(marker)
38+ prevInline := idx > 0 && isInlineWhitespace(text[idx-1])
39+ nextInline := end < len(text) && isInlineWhitespace(text[end])
40+ prevLineBreak := idx == 0 || idx > 0 && isLineBreak(text[idx-1])
41+ nextLineBreak := end == len(text) || end < len(text) && isLineBreak(text[end])
42+ switch {
43+ case prevInline && nextInline:
44+ text = text[:idx] + text[end+1:]
45+ case prevInline && nextLineBreak:
46+ text = text[:idx-1] + text[end:]
47+ case prevLineBreak && nextInline:
48+ text = text[:idx] + text[end+1:]
49+ default:
50+ text = text[:idx] + text[end:]
51+ }
52+ }
53+}
54+
55+func isInlineWhitespace(b byte) bool {
56+ return b == ' ' || b == '\t'
57+}
58+
59+func isLineBreak(b byte) bool {
60+ return b == '\n' || b == '\r'
61+}
A packages/agent/modes/clipboard_images_test.go +120 -0
@@ -0,0 +1,120 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+func testClipboardImage(marker string, data string) clipboardImageAttachment {
11+ return clipboardImageAttachment{
12+ Marker: marker,
13+ Image: provider.ImageBlock{MimeType: "image/png", Data: []byte(data)},
14+ }
15+}
16+
17+func TestPreparePromptWithClipboardImagesStripsPresentMarker(t *testing.T) {
18+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
19+
20+ text, images := preparePromptWithClipboardImages("describe this [clipboard image #1] please", pending)
21+
22+ if text != "describe this please" {
23+ t.Fatalf("text = %q, want %q", text, "describe this please")
24+ }
25+ if len(images) != 1 {
26+ t.Fatalf("len(images) = %d, want 1", len(images))
27+ }
28+ if string(images[0].Data) != "png-1" {
29+ t.Fatalf("image data = %q, want png-1", string(images[0].Data))
30+ }
31+}
32+
33+func TestPreparePromptWithClipboardImagesAllowsImageOnlyPrompt(t *testing.T) {
34+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
35+
36+ text, images := preparePromptWithClipboardImages("[clipboard image #1]", pending)
37+
38+ if text != "" {
39+ t.Fatalf("text = %q, want empty", text)
40+ }
41+ if len(images) != 1 {
42+ t.Fatalf("len(images) = %d, want 1", len(images))
43+ }
44+}
45+
46+func TestPreparePromptWithClipboardImagesIgnoresDeletedMarker(t *testing.T) {
47+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
48+
49+ text, images := preparePromptWithClipboardImages("describe this from memory", pending)
50+
51+ if text != "describe this from memory" {
52+ t.Fatalf("text = %q, want unchanged", text)
53+ }
54+ if len(images) != 0 {
55+ t.Fatalf("len(images) = %d, want 0", len(images))
56+ }
57+}
58+
59+func TestPreparePromptWithClipboardImagesHandlesMultipleImagesInPasteOrder(t *testing.T) {
60+ pending := []clipboardImageAttachment{
61+ testClipboardImage("[clipboard image #1]", "png-1"),
62+ testClipboardImage("[clipboard image #2]", "png-2"),
63+ }
64+
65+ text, images := preparePromptWithClipboardImages("compare [clipboard image #2] with [clipboard image #1]", pending)
66+
67+ if text != "compare with" {
68+ t.Fatalf("text = %q, want %q", text, "compare with")
69+ }
70+ if len(images) != 2 {
71+ t.Fatalf("len(images) = %d, want 2", len(images))
72+ }
73+ if string(images[0].Data) != "png-1" || string(images[1].Data) != "png-2" {
74+ t.Fatalf("images not attached in paste order: %q, %q", string(images[0].Data), string(images[1].Data))
75+ }
76+}
77+
78+func TestPreparePromptWithClipboardImagesDuplicateMarkerAttachesOnce(t *testing.T) {
79+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
80+
81+ text, images := preparePromptWithClipboardImages("[clipboard image #1] and again [clipboard image #1]", pending)
82+
83+ if strings.Contains(text, "[clipboard image #1]") {
84+ t.Fatalf("text still contains marker: %q", text)
85+ }
86+ if text != "and again" {
87+ t.Fatalf("text = %q, want %q", text, "and again")
88+ }
89+ if len(images) != 1 {
90+ t.Fatalf("len(images) = %d, want 1", len(images))
91+ }
92+}
93+
94+func TestPreparePromptWithClipboardImagesNoPendingPreservesWhitespace(t *testing.T) {
95+ input := "please review:\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n"
96+
97+ text, images := preparePromptWithClipboardImages(input, nil)
98+
99+ if text != input {
100+ t.Fatalf("text changed without pending images:\n got %q\nwant %q", text, input)
101+ }
102+ if images != nil {
103+ t.Fatalf("images = %#v, want nil", images)
104+ }
105+}
106+
107+func TestPreparePromptWithClipboardImagesPreservesMultilinePrompt(t *testing.T) {
108+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
109+ input := "compare this image [clipboard image #1]\n\nwith this code:\n\treturn 1"
110+ want := "compare this image\n\nwith this code:\n\treturn 1"
111+
112+ text, images := preparePromptWithClipboardImages(input, pending)
113+
114+ if text != want {
115+ t.Fatalf("text = %q, want %q", text, want)
116+ }
117+ if len(images) != 1 {
118+ t.Fatalf("len(images) = %d, want 1", len(images))
119+ }
120+}
@@ -0,0 +1,120 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+func testClipboardImage(marker string, data string) clipboardImageAttachment {
11+ return clipboardImageAttachment{
12+ Marker: marker,
13+ Image: provider.ImageBlock{MimeType: "image/png", Data: []byte(data)},
14+ }
15+}
16+
17+func TestPreparePromptWithClipboardImagesStripsPresentMarker(t *testing.T) {
18+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
19+
20+ text, images := preparePromptWithClipboardImages("describe this [clipboard image #1] please", pending)
21+
22+ if text != "describe this please" {
23+ t.Fatalf("text = %q, want %q", text, "describe this please")
24+ }
25+ if len(images) != 1 {
26+ t.Fatalf("len(images) = %d, want 1", len(images))
27+ }
28+ if string(images[0].Data) != "png-1" {
29+ t.Fatalf("image data = %q, want png-1", string(images[0].Data))
30+ }
31+}
32+
33+func TestPreparePromptWithClipboardImagesAllowsImageOnlyPrompt(t *testing.T) {
34+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
35+
36+ text, images := preparePromptWithClipboardImages("[clipboard image #1]", pending)
37+
38+ if text != "" {
39+ t.Fatalf("text = %q, want empty", text)
40+ }
41+ if len(images) != 1 {
42+ t.Fatalf("len(images) = %d, want 1", len(images))
43+ }
44+}
45+
46+func TestPreparePromptWithClipboardImagesIgnoresDeletedMarker(t *testing.T) {
47+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
48+
49+ text, images := preparePromptWithClipboardImages("describe this from memory", pending)
50+
51+ if text != "describe this from memory" {
52+ t.Fatalf("text = %q, want unchanged", text)
53+ }
54+ if len(images) != 0 {
55+ t.Fatalf("len(images) = %d, want 0", len(images))
56+ }
57+}
58+
59+func TestPreparePromptWithClipboardImagesHandlesMultipleImagesInPasteOrder(t *testing.T) {
60+ pending := []clipboardImageAttachment{
61+ testClipboardImage("[clipboard image #1]", "png-1"),
62+ testClipboardImage("[clipboard image #2]", "png-2"),
63+ }
64+
65+ text, images := preparePromptWithClipboardImages("compare [clipboard image #2] with [clipboard image #1]", pending)
66+
67+ if text != "compare with" {
68+ t.Fatalf("text = %q, want %q", text, "compare with")
69+ }
70+ if len(images) != 2 {
71+ t.Fatalf("len(images) = %d, want 2", len(images))
72+ }
73+ if string(images[0].Data) != "png-1" || string(images[1].Data) != "png-2" {
74+ t.Fatalf("images not attached in paste order: %q, %q", string(images[0].Data), string(images[1].Data))
75+ }
76+}
77+
78+func TestPreparePromptWithClipboardImagesDuplicateMarkerAttachesOnce(t *testing.T) {
79+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
80+
81+ text, images := preparePromptWithClipboardImages("[clipboard image #1] and again [clipboard image #1]", pending)
82+
83+ if strings.Contains(text, "[clipboard image #1]") {
84+ t.Fatalf("text still contains marker: %q", text)
85+ }
86+ if text != "and again" {
87+ t.Fatalf("text = %q, want %q", text, "and again")
88+ }
89+ if len(images) != 1 {
90+ t.Fatalf("len(images) = %d, want 1", len(images))
91+ }
92+}
93+
94+func TestPreparePromptWithClipboardImagesNoPendingPreservesWhitespace(t *testing.T) {
95+ input := "please review:\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n"
96+
97+ text, images := preparePromptWithClipboardImages(input, nil)
98+
99+ if text != input {
100+ t.Fatalf("text changed without pending images:\n got %q\nwant %q", text, input)
101+ }
102+ if images != nil {
103+ t.Fatalf("images = %#v, want nil", images)
104+ }
105+}
106+
107+func TestPreparePromptWithClipboardImagesPreservesMultilinePrompt(t *testing.T) {
108+ pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
109+ input := "compare this image [clipboard image #1]\n\nwith this code:\n\treturn 1"
110+ want := "compare this image\n\nwith this code:\n\treturn 1"
111+
112+ text, images := preparePromptWithClipboardImages(input, pending)
113+
114+ if text != want {
115+ t.Fatalf("text = %q, want %q", text, want)
116+ }
117+ if len(images) != 1 {
118+ t.Fatalf("len(images) = %d, want 1", len(images))
119+ }
120+}
M packages/agent/modes/interactive.go +48 -2
@@ -424,6 +424,8 @@ type Interactive struct {
424424 // them (or when the dialog is dismissed via esc).
425425 pendingRescuePrompt string
426426 pendingRescueImages []provider.ImageBlock
427+
428+ clipboardImages []clipboardImageAttachment
427429 }
428430
429431 // welcomeVersionDuration is how long the welcome banner shows the
@@ -1952,6 +1954,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
19521954 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
19531955 if hadInput {
19541956 i.ed.Clear()
1957+ i.clipboardImages = nil
19551958 i.suggest.Reset()
19561959 if ag != nil {
19571960 ag.DrainQueuedMessages()
@@ -2021,6 +2024,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
20212024 i.rend.Clear()
20222025 i.invalidate()
20232026 return false
2027+ case tui.KeyPasteClipboard:
2028+ i.pasteClipboard()
2029+ return false
20242030 case tui.KeyCtrlO:
20252031 // Toggle expansion of collapsed tool results. Affects every tool
20262032 // call in the transcript — press again to re-collapse.
@@ -2215,6 +2221,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22152221 i.inputHistoryIndex = -1
22162222 }
22172223
2224+ if k.Kind == tui.KeyEsc {
2225+ i.clipboardImages = nil
2226+ }
22182227 if submit := i.ed.HandleKey(k); submit {
22192228 // SubmitValue() expands any [pasted text #N +L lines]
22202229 // placeholders back into their bodies; the raw Value()
@@ -2222,9 +2231,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22222231 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
22232232 // Expand [file:name] and [dir:name/] chips to full paths.
22242233 text = expandFileChips(text, i.cfg.CWD)
2225- if text == "" {
2234+ text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
2235+ if text == "" && len(images) == 0 {
22262236 return false
22272237 }
2238+ i.clipboardImages = nil
22282239 i.ed.Clear()
22292240 i.inputHistoryIndex = -1
22302241 i.suggest.Reset()
@@ -2291,6 +2302,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22912302 ag := i.agent
22922303 i.mu.Unlock()
22932304 if busy {
2305+ if len(images) > 0 {
2306+ i.mu.Lock()
2307+ i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
2308+ i.mu.Unlock()
2309+ i.invalidate()
2310+ return false
2311+ }
22942312 if ag != nil {
22952313 ag.QueueMessage(text)
22962314 } else {
@@ -2301,11 +2319,39 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
23012319 i.invalidate()
23022320 return false
23032321 }
2304- i.startTurn(ctx, text)
2322+ i.startTurnWithImages(ctx, text, images)
23052323 }
23062324 return false
23072325 }
23082326
2327+func (i *Interactive) pasteClipboard() {
2328+ _, data, ok, err := tui.ReadClipboardImagePNG()
2329+ if err != nil {
2330+ i.mu.Lock()
2331+ i.statusErr = "clipboard paste failed: " + err.Error()
2332+ i.statusOK = ""
2333+ i.mu.Unlock()
2334+ return
2335+ }
2336+ if ok {
2337+ i.mu.Lock()
2338+ marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
2339+ i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
2340+ Marker: marker,
2341+ Image: provider.ImageBlock{MimeType: "image/png", Data: data},
2342+ })
2343+ i.ed.Insert(marker + " ")
2344+ i.statusOK = ""
2345+ i.statusErr = ""
2346+ i.mu.Unlock()
2347+ return
2348+ }
2349+ i.mu.Lock()
2350+ i.statusErr = "clipboard does not contain an image"
2351+ i.statusOK = ""
2352+ i.mu.Unlock()
2353+}
2354+
23092355 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
23102356 if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
23112357 return false
@@ -424,6 +424,8 @@ type Interactive struct {
424 // them (or when the dialog is dismissed via esc).424 // them (or when the dialog is dismissed via esc).
425 pendingRescuePrompt string425 pendingRescuePrompt string
426 pendingRescueImages []provider.ImageBlock426 pendingRescueImages []provider.ImageBlock
427+
428+ clipboardImages []clipboardImageAttachment
427 }429 }
428 430
429 // welcomeVersionDuration is how long the welcome banner shows the431 // welcomeVersionDuration is how long the welcome banner shows the
@@ -1952,6 +1954,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1952 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 01954 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
1953 if hadInput {1955 if hadInput {
1954 i.ed.Clear()1956 i.ed.Clear()
1957+ i.clipboardImages = nil
1955 i.suggest.Reset()1958 i.suggest.Reset()
1956 if ag != nil {1959 if ag != nil {
1957 ag.DrainQueuedMessages()1960 ag.DrainQueuedMessages()
@@ -2021,6 +2024,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2021 i.rend.Clear()2024 i.rend.Clear()
2022 i.invalidate()2025 i.invalidate()
2023 return false2026 return false
2027+ case tui.KeyPasteClipboard:
2028+ i.pasteClipboard()
2029+ return false
2024 case tui.KeyCtrlO:2030 case tui.KeyCtrlO:
2025 // Toggle expansion of collapsed tool results. Affects every tool2031 // Toggle expansion of collapsed tool results. Affects every tool
2026 // call in the transcript — press again to re-collapse.2032 // call in the transcript — press again to re-collapse.
@@ -2215,6 +2221,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2215 i.inputHistoryIndex = -12221 i.inputHistoryIndex = -1
2216 }2222 }
2217 2223
2224+ if k.Kind == tui.KeyEsc {
2225+ i.clipboardImages = nil
2226+ }
2218 if submit := i.ed.HandleKey(k); submit {2227 if submit := i.ed.HandleKey(k); submit {
2219 // SubmitValue() expands any [pasted text #N +L lines]2228 // SubmitValue() expands any [pasted text #N +L lines]
2220 // placeholders back into their bodies; the raw Value()2229 // placeholders back into their bodies; the raw Value()
@@ -2222,9 +2231,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2222 text := strings.TrimRight(i.ed.SubmitValue(), "\n")2231 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
2223 // Expand [file:name] and [dir:name/] chips to full paths.2232 // Expand [file:name] and [dir:name/] chips to full paths.
2224 text = expandFileChips(text, i.cfg.CWD)2233 text = expandFileChips(text, i.cfg.CWD)
2225- if text == "" {2234+ text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
2235+ if text == "" && len(images) == 0 {
2226 return false2236 return false
2227 }2237 }
2238+ i.clipboardImages = nil
2228 i.ed.Clear()2239 i.ed.Clear()
2229 i.inputHistoryIndex = -12240 i.inputHistoryIndex = -1
2230 i.suggest.Reset()2241 i.suggest.Reset()
@@ -2291,6 +2302,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2291 ag := i.agent2302 ag := i.agent
2292 i.mu.Unlock()2303 i.mu.Unlock()
2293 if busy {2304 if busy {
2305+ if len(images) > 0 {
2306+ i.mu.Lock()
2307+ i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
2308+ i.mu.Unlock()
2309+ i.invalidate()
2310+ return false
2311+ }
2294 if ag != nil {2312 if ag != nil {
2295 ag.QueueMessage(text)2313 ag.QueueMessage(text)
2296 } else {2314 } else {
@@ -2301,11 +2319,39 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2301 i.invalidate()2319 i.invalidate()
2302 return false2320 return false
2303 }2321 }
2304- i.startTurn(ctx, text)2322+ i.startTurnWithImages(ctx, text, images)
2305 }2323 }
2306 return false2324 return false
2307 }2325 }
2308 2326
2327+func (i *Interactive) pasteClipboard() {
2328+ _, data, ok, err := tui.ReadClipboardImagePNG()
2329+ if err != nil {
2330+ i.mu.Lock()
2331+ i.statusErr = "clipboard paste failed: " + err.Error()
2332+ i.statusOK = ""
2333+ i.mu.Unlock()
2334+ return
2335+ }
2336+ if ok {
2337+ i.mu.Lock()
2338+ marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
2339+ i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
2340+ Marker: marker,
2341+ Image: provider.ImageBlock{MimeType: "image/png", Data: data},
2342+ })
2343+ i.ed.Insert(marker + " ")
2344+ i.statusOK = ""
2345+ i.statusErr = ""
2346+ i.mu.Unlock()
2347+ return
2348+ }
2349+ i.mu.Lock()
2350+ i.statusErr = "clipboard does not contain an image"
2351+ i.statusOK = ""
2352+ i.mu.Unlock()
2353+}
2354+
2309 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {2355 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2310 if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {2356 if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
2311 return false2357 return false
A packages/tui/clipboard_darwin.go +229 -0
@@ -0,0 +1,229 @@
1+//go:build darwin
2+
3+package tui
4+
5+import (
6+ "crypto/rand"
7+ "encoding/hex"
8+ "fmt"
9+ "image/png"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "time"
15+
16+ "golang.org/x/image/tiff"
17+)
18+
19+const readClipboardImageScript = `
20+on run argv
21+ set outPath to item 1 of argv
22+ try
23+ set imgData to the clipboard as «class PNGf»
24+ set imgKind to "png"
25+ on error
26+ try
27+ set imgData to the clipboard as «class TIFF»
28+ set imgKind to "tiff"
29+ on error
30+ return "NO_IMAGE"
31+ end try
32+ end try
33+
34+ set outFile to POSIX file outPath
35+ set fileRef to open for access outFile with write permission
36+ try
37+ set eof of fileRef to 0
38+ write imgData to fileRef
39+ close access fileRef
40+ on error errMsg number errNum
41+ try
42+ close access fileRef
43+ end try
44+ error errMsg number errNum
45+ end try
46+ return imgKind
47+end run
48+`
49+
50+func ReadClipboardImagePNG() (string, []byte, bool, error) {
51+ dir := clipboardImageDir()
52+ if err := os.MkdirAll(dir, 0o700); err != nil {
53+ return "", nil, false, err
54+ }
55+ path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")
56+ rawPath := path + ".raw"
57+ defer os.Remove(rawPath)
58+
59+ kind, err := writeClipboardImageData(rawPath)
60+ if err != nil {
61+ return "", nil, false, err
62+ }
63+ if kind == "" {
64+ return "", nil, false, nil
65+ }
66+
67+ switch kind {
68+ case "png":
69+ if err := os.Rename(rawPath, path); err != nil {
70+ return "", nil, false, err
71+ }
72+ case "tiff":
73+ if err := convertTIFFFileToPNG(rawPath, path); err != nil {
74+ return "", nil, false, err
75+ }
76+ default:
77+ clipPath, ok := findClipboardImagePath(kind)
78+ if !ok {
79+ return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
80+ }
81+ if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {
82+ return "", nil, false, err
83+ }
84+ }
85+
86+ data, err := os.ReadFile(path)
87+ if err != nil {
88+ return "", nil, false, err
89+ }
90+ return path, data, true, nil
91+}
92+
93+func writeClipboardImageData(path string) (string, error) {
94+ cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path)
95+ out, err := cmd.CombinedOutput()
96+ trimmed := strings.TrimSpace(string(out))
97+ if err != nil {
98+ if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") {
99+ return "", nil
100+ }
101+ if kind, ok := clipboardImageKind(trimmed); ok {
102+ return kind, nil
103+ }
104+ if path, ok := findClipboardImagePath(trimmed); ok {
105+ return path, nil
106+ }
107+ if trimmed == "" {
108+ return "", fmt.Errorf("osascript failed: %w", err)
109+ }
110+ return "", fmt.Errorf("osascript failed: %s", trimmed)
111+ }
112+ if trimmed == "NO_IMAGE" {
113+ return "", nil
114+ }
115+ if kind, ok := clipboardImageKind(trimmed); ok {
116+ return kind, nil
117+ }
118+ return trimmed, nil
119+}
120+
121+func clipboardImageKind(s string) (string, bool) {
122+ for _, line := range strings.Split(s, "\n") {
123+ switch strings.TrimSpace(line) {
124+ case "png":
125+ return "png", true
126+ case "tiff":
127+ return "tiff", true
128+ }
129+ }
130+ return "", false
131+}
132+
133+func copyClipboardImageFileToPNG(srcPath, dstPath string) error {
134+ switch strings.ToLower(filepath.Ext(srcPath)) {
135+ case ".png":
136+ data, err := os.ReadFile(srcPath)
137+ if err != nil {
138+ return err
139+ }
140+ return os.WriteFile(dstPath, data, 0o600)
141+ case ".tif", ".tiff":
142+ return convertTIFFFileToPNG(srcPath, dstPath)
143+ default:
144+ return fmt.Errorf("clipboard file is not a supported image type: %s", srcPath)
145+ }
146+}
147+
148+func findClipboardImagePath(s string) (string, bool) {
149+ if p, ok := clipboardImagePath(s); ok {
150+ return p, true
151+ }
152+ for _, ext := range []string{".png", ".tiff", ".tif"} {
153+ lower := strings.ToLower(s)
154+ end := strings.Index(lower, ext)
155+ if end < 0 {
156+ continue
157+ }
158+ end += len(ext)
159+ start := strings.LastIndex(s[:end], "/")
160+ if start < 0 {
161+ continue
162+ }
163+ for start > 0 {
164+ c := s[start-1]
165+ if c == '\'' || c == '"' || c == '\n' || c == '\r' || c == '\t' {
166+ break
167+ }
168+ start--
169+ }
170+ if p, ok := clipboardImagePath(s[start:end]); ok {
171+ return p, true
172+ }
173+ }
174+ return "", false
175+}
176+
177+func clipboardImagePath(s string) (string, bool) {
178+ p := strings.TrimSpace(s)
179+ p = strings.Trim(p, "'\"")
180+ if p == "" {
181+ return "", false
182+ }
183+ info, err := os.Stat(p)
184+ if err != nil || info.IsDir() {
185+ return "", false
186+ }
187+ switch strings.ToLower(filepath.Ext(p)) {
188+ case ".png", ".tif", ".tiff":
189+ return p, true
190+ default:
191+ return "", false
192+ }
193+}
194+
195+func convertTIFFFileToPNG(srcPath, dstPath string) error {
196+ in, err := os.Open(srcPath)
197+ if err != nil {
198+ return err
199+ }
200+ defer in.Close()
201+
202+ img, err := tiff.Decode(in)
203+ if err != nil {
204+ return err
205+ }
206+
207+ out, err := os.Create(dstPath)
208+ if err != nil {
209+ return err
210+ }
211+ defer out.Close()
212+
213+ return png.Encode(out, img)
214+}
215+
216+func clipboardImageDir() string {
217+ if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
218+ return filepath.Join("/tmp", "zot-clipboard-images")
219+ }
220+ return filepath.Join(os.TempDir(), "zot-clipboard-images")
221+}
222+
223+func randomHex(n int) string {
224+ buf := make([]byte, n)
225+ if _, err := rand.Read(buf); err != nil {
226+ return fmt.Sprintf("%d", time.Now().UnixNano())
227+ }
228+ return hex.EncodeToString(buf)
229+}
@@ -0,0 +1,229 @@
1+//go:build darwin
2+
3+package tui
4+
5+import (
6+ "crypto/rand"
7+ "encoding/hex"
8+ "fmt"
9+ "image/png"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "time"
15+
16+ "golang.org/x/image/tiff"
17+)
18+
19+const readClipboardImageScript = `
20+on run argv
21+ set outPath to item 1 of argv
22+ try
23+ set imgData to the clipboard as «class PNGf»
24+ set imgKind to "png"
25+ on error
26+ try
27+ set imgData to the clipboard as «class TIFF»
28+ set imgKind to "tiff"
29+ on error
30+ return "NO_IMAGE"
31+ end try
32+ end try
33+
34+ set outFile to POSIX file outPath
35+ set fileRef to open for access outFile with write permission
36+ try
37+ set eof of fileRef to 0
38+ write imgData to fileRef
39+ close access fileRef
40+ on error errMsg number errNum
41+ try
42+ close access fileRef
43+ end try
44+ error errMsg number errNum
45+ end try
46+ return imgKind
47+end run
48+`
49+
50+func ReadClipboardImagePNG() (string, []byte, bool, error) {
51+ dir := clipboardImageDir()
52+ if err := os.MkdirAll(dir, 0o700); err != nil {
53+ return "", nil, false, err
54+ }
55+ path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")
56+ rawPath := path + ".raw"
57+ defer os.Remove(rawPath)
58+
59+ kind, err := writeClipboardImageData(rawPath)
60+ if err != nil {
61+ return "", nil, false, err
62+ }
63+ if kind == "" {
64+ return "", nil, false, nil
65+ }
66+
67+ switch kind {
68+ case "png":
69+ if err := os.Rename(rawPath, path); err != nil {
70+ return "", nil, false, err
71+ }
72+ case "tiff":
73+ if err := convertTIFFFileToPNG(rawPath, path); err != nil {
74+ return "", nil, false, err
75+ }
76+ default:
77+ clipPath, ok := findClipboardImagePath(kind)
78+ if !ok {
79+ return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
80+ }
81+ if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {
82+ return "", nil, false, err
83+ }
84+ }
85+
86+ data, err := os.ReadFile(path)
87+ if err != nil {
88+ return "", nil, false, err
89+ }
90+ return path, data, true, nil
91+}
92+
93+func writeClipboardImageData(path string) (string, error) {
94+ cmd := exec.Command("/usr/bin/osascript", "-e", readClipboardImageScript, path)
95+ out, err := cmd.CombinedOutput()
96+ trimmed := strings.TrimSpace(string(out))
97+ if err != nil {
98+ if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") {
99+ return "", nil
100+ }
101+ if kind, ok := clipboardImageKind(trimmed); ok {
102+ return kind, nil
103+ }
104+ if path, ok := findClipboardImagePath(trimmed); ok {
105+ return path, nil
106+ }
107+ if trimmed == "" {
108+ return "", fmt.Errorf("osascript failed: %w", err)
109+ }
110+ return "", fmt.Errorf("osascript failed: %s", trimmed)
111+ }
112+ if trimmed == "NO_IMAGE" {
113+ return "", nil
114+ }
115+ if kind, ok := clipboardImageKind(trimmed); ok {
116+ return kind, nil
117+ }
118+ return trimmed, nil
119+}
120+
121+func clipboardImageKind(s string) (string, bool) {
122+ for _, line := range strings.Split(s, "\n") {
123+ switch strings.TrimSpace(line) {
124+ case "png":
125+ return "png", true
126+ case "tiff":
127+ return "tiff", true
128+ }
129+ }
130+ return "", false
131+}
132+
133+func copyClipboardImageFileToPNG(srcPath, dstPath string) error {
134+ switch strings.ToLower(filepath.Ext(srcPath)) {
135+ case ".png":
136+ data, err := os.ReadFile(srcPath)
137+ if err != nil {
138+ return err
139+ }
140+ return os.WriteFile(dstPath, data, 0o600)
141+ case ".tif", ".tiff":
142+ return convertTIFFFileToPNG(srcPath, dstPath)
143+ default:
144+ return fmt.Errorf("clipboard file is not a supported image type: %s", srcPath)
145+ }
146+}
147+
148+func findClipboardImagePath(s string) (string, bool) {
149+ if p, ok := clipboardImagePath(s); ok {
150+ return p, true
151+ }
152+ for _, ext := range []string{".png", ".tiff", ".tif"} {
153+ lower := strings.ToLower(s)
154+ end := strings.Index(lower, ext)
155+ if end < 0 {
156+ continue
157+ }
158+ end += len(ext)
159+ start := strings.LastIndex(s[:end], "/")
160+ if start < 0 {
161+ continue
162+ }
163+ for start > 0 {
164+ c := s[start-1]
165+ if c == '\'' || c == '"' || c == '\n' || c == '\r' || c == '\t' {
166+ break
167+ }
168+ start--
169+ }
170+ if p, ok := clipboardImagePath(s[start:end]); ok {
171+ return p, true
172+ }
173+ }
174+ return "", false
175+}
176+
177+func clipboardImagePath(s string) (string, bool) {
178+ p := strings.TrimSpace(s)
179+ p = strings.Trim(p, "'\"")
180+ if p == "" {
181+ return "", false
182+ }
183+ info, err := os.Stat(p)
184+ if err != nil || info.IsDir() {
185+ return "", false
186+ }
187+ switch strings.ToLower(filepath.Ext(p)) {
188+ case ".png", ".tif", ".tiff":
189+ return p, true
190+ default:
191+ return "", false
192+ }
193+}
194+
195+func convertTIFFFileToPNG(srcPath, dstPath string) error {
196+ in, err := os.Open(srcPath)
197+ if err != nil {
198+ return err
199+ }
200+ defer in.Close()
201+
202+ img, err := tiff.Decode(in)
203+ if err != nil {
204+ return err
205+ }
206+
207+ out, err := os.Create(dstPath)
208+ if err != nil {
209+ return err
210+ }
211+ defer out.Close()
212+
213+ return png.Encode(out, img)
214+}
215+
216+func clipboardImageDir() string {
217+ if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
218+ return filepath.Join("/tmp", "zot-clipboard-images")
219+ }
220+ return filepath.Join(os.TempDir(), "zot-clipboard-images")
221+}
222+
223+func randomHex(n int) string {
224+ buf := make([]byte, n)
225+ if _, err := rand.Read(buf); err != nil {
226+ return fmt.Sprintf("%d", time.Now().UnixNano())
227+ }
228+ return hex.EncodeToString(buf)
229+}
A packages/tui/clipboard_stub.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build !darwin
2+
3+package tui
4+
5+func ReadClipboardImagePNG() (string, []byte, bool, error) {
6+ return "", nil, false, nil
7+}
@@ -0,0 +1,7 @@
1+//go:build !darwin
2+
3+package tui
4+
5+func ReadClipboardImagePNG() (string, []byte, bool, error) {
6+ return "", nil, false, nil
7+}
M packages/tui/input.go +5 -0
@@ -44,6 +44,7 @@ const (
4444 KeyCtrlW
4545 KeyCtrlO
4646 KeyPaste
47+ KeyPasteClipboard
4748 KeyMouseWheelUp
4849 KeyMouseWheelDown
4950 KeyUnknown
@@ -90,6 +91,8 @@ func (r *Reader) Read() (Key, error) {
9091 return Key{Kind: KeyCtrlW}, nil
9192 case b == 0x0f:
9293 return Key{Kind: KeyCtrlO}, nil
94+ case b == 0x16:
95+ return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil
9396 case b == '\r', b == '\n':
9497 return Key{Kind: KeyEnter}, nil
9598 case b == '\t':
@@ -379,6 +382,8 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
379382 return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true
380383 case 'o', 'O':
381384 return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true
385+ case 'v', 'V':
386+ return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true
382387 }
383388 }
384389 return Key{}, false
@@ -44,6 +44,7 @@ const (
44 KeyCtrlW44 KeyCtrlW
45 KeyCtrlO45 KeyCtrlO
46 KeyPaste46 KeyPaste
47+ KeyPasteClipboard
47 KeyMouseWheelUp48 KeyMouseWheelUp
48 KeyMouseWheelDown49 KeyMouseWheelDown
49 KeyUnknown50 KeyUnknown
@@ -90,6 +91,8 @@ func (r *Reader) Read() (Key, error) {
90 return Key{Kind: KeyCtrlW}, nil91 return Key{Kind: KeyCtrlW}, nil
91 case b == 0x0f:92 case b == 0x0f:
92 return Key{Kind: KeyCtrlO}, nil93 return Key{Kind: KeyCtrlO}, nil
94+ case b == 0x16:
95+ return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil
93 case b == '\r', b == '\n':96 case b == '\r', b == '\n':
94 return Key{Kind: KeyEnter}, nil97 return Key{Kind: KeyEnter}, nil
95 case b == '\t':98 case b == '\t':
@@ -379,6 +382,8 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
379 return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true382 return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true
380 case 'o', 'O':383 case 'o', 'O':
381 return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true384 return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true
385+ case 'v', 'V':
386+ return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true
382 }387 }
383 }388 }
384 return Key{}, false389 return Key{}, false
M packages/tui/input_test.go +14 -0
@@ -23,6 +23,20 @@ func TestReaderParsesCSIUCtrlC(t *testing.T) {
2323 }
2424 }
2525
26+func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
27+ k := readKey(t, "\x16")
28+ if k.Kind != KeyPasteClipboard || !k.Ctrl {
29+ t.Fatalf("Read kind=%v ctrl=%v, want ctrl+v clipboard paste", k.Kind, k.Ctrl)
30+ }
31+}
32+
33+func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) {
34+ k := readKey(t, "\x1b[118;5u")
35+ if k.Kind != KeyPasteClipboard || !k.Ctrl {
36+ t.Fatalf("Read kind=%v ctrl=%v, want enhanced ctrl+v clipboard paste", k.Kind, k.Ctrl)
37+ }
38+}
39+
2640 func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) {
2741 k := readKey(t, "\x1b[27;5;99~")
2842 if k.Kind != KeyCtrlC || !k.Ctrl {
@@ -23,6 +23,20 @@ func TestReaderParsesCSIUCtrlC(t *testing.T) {
23 }23 }
24 }24 }
25 25
26+func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
27+ k := readKey(t, "\x16")
28+ if k.Kind != KeyPasteClipboard || !k.Ctrl {
29+ t.Fatalf("Read kind=%v ctrl=%v, want ctrl+v clipboard paste", k.Kind, k.Ctrl)
30+ }
31+}
32+
33+func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) {
34+ k := readKey(t, "\x1b[118;5u")
35+ if k.Kind != KeyPasteClipboard || !k.Ctrl {
36+ t.Fatalf("Read kind=%v ctrl=%v, want enhanced ctrl+v clipboard paste", k.Kind, k.Ctrl)
37+ }
38+}
39+
26 func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) {40 func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) {
27 k := readKey(t, "\x1b[27;5;99~")41 k := readKey(t, "\x1b[27;5;99~")
28 if k.Kind != KeyCtrlC || !k.Ctrl {42 if k.Kind != KeyCtrlC || !k.Ctrl {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close