detached 4a6d691

Add clipboard paste support

commit
4a6d6915caa5c39ba024f96ff59577602b295496
author
mi-skam · · 2026-06-21 12:45 UTC
committer
mi-skam · · 2026-06-21 12:47 UTC
parent
1a24a20
signature
SSH UnverifiedSHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0w The key is unknown
8 files · +238 -4
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 from the system clipboard through zot. Text is inserted normally; images are saved as temporary PNGs and attached to the next prompt. |
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 from the system clipboard through zot. Text is inserted normally; images are saved as temporary PNGs and attached to the next prompt. |
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/interactive.go +55 -1
@@ -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 []provider.ImageBlock
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()
@@ -2225,6 +2234,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22252234 if text == "" {
22262235 return false
22272236 }
2237+ images := append([]provider.ImageBlock(nil), i.clipboardImages...)
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,47 @@ 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+ path, 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+ i.clipboardImages = append(i.clipboardImages, provider.ImageBlock{MimeType: "image/png", Data: data})
2339+ i.ed.Insert("[image:" + path + "]")
2340+ i.statusOK = "pasted clipboard image to " + path
2341+ i.statusErr = ""
2342+ i.mu.Unlock()
2343+ return
2344+ }
2345+ text, textOK, err := tui.ReadClipboardText()
2346+ i.mu.Lock()
2347+ defer i.mu.Unlock()
2348+ if err != nil {
2349+ i.statusErr = "clipboard paste failed: " + err.Error()
2350+ i.statusOK = ""
2351+ return
2352+ }
2353+ if !textOK || text == "" {
2354+ i.statusErr = "clipboard does not contain text or an image"
2355+ i.statusOK = ""
2356+ return
2357+ }
2358+ i.ed.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: text})
2359+ i.statusOK = ""
2360+ i.statusErr = ""
2361+}
2362+
23092363 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
23102364 if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
23112365 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 []provider.ImageBlock
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()
@@ -2225,6 +2234,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2225 if text == "" {2234 if text == "" {
2226 return false2235 return false
2227 }2236 }
2237+ images := append([]provider.ImageBlock(nil), i.clipboardImages...)
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,47 @@ 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+ path, 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+ i.clipboardImages = append(i.clipboardImages, provider.ImageBlock{MimeType: "image/png", Data: data})
2339+ i.ed.Insert("[image:" + path + "]")
2340+ i.statusOK = "pasted clipboard image to " + path
2341+ i.statusErr = ""
2342+ i.mu.Unlock()
2343+ return
2344+ }
2345+ text, textOK, err := tui.ReadClipboardText()
2346+ i.mu.Lock()
2347+ defer i.mu.Unlock()
2348+ if err != nil {
2349+ i.statusErr = "clipboard paste failed: " + err.Error()
2350+ i.statusOK = ""
2351+ return
2352+ }
2353+ if !textOK || text == "" {
2354+ i.statusErr = "clipboard does not contain text or an image"
2355+ i.statusOK = ""
2356+ return
2357+ }
2358+ i.ed.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: text})
2359+ i.statusOK = ""
2360+ i.statusErr = ""
2361+}
2362+
2309 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {2363 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2310 if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {2364 if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
2311 return false2365 return false
A packages/tui/clipboard_darwin.go +115 -0
@@ -0,0 +1,115 @@
1+//go:build darwin && cgo
2+
3+package tui
4+
5+/*
6+#cgo CFLAGS: -x objective-c
7+#cgo LDFLAGS: -framework AppKit
8+#import <AppKit/AppKit.h>
9+#include <stdlib.h>
10+#include <string.h>
11+
12+static char* zot_strdup(const char *s) {
13+ if (s == NULL) return NULL;
14+ char *out = malloc(strlen(s) + 1);
15+ if (out != NULL) strcpy(out, s);
16+ return out;
17+}
18+
19+static int ZotWriteClipboardPNG(const char *path, char **err) {
20+ @autoreleasepool {
21+ NSPasteboard *pb = [NSPasteboard generalPasteboard];
22+ NSArray *classes = @[[NSImage class]];
23+ NSDictionary *options = @{};
24+ NSArray *objects = [pb readObjectsForClasses:classes options:options];
25+ NSImage *image = nil;
26+ if ([objects count] > 0 && [[objects objectAtIndex:0] isKindOfClass:[NSImage class]]) {
27+ image = (NSImage *)[objects objectAtIndex:0];
28+ }
29+ if (image == nil) {
30+ NSData *tiff = [pb dataForType:NSPasteboardTypeTIFF];
31+ if (tiff != nil) image = [[NSImage alloc] initWithData:tiff];
32+ }
33+ if (image == nil) return 1;
34+
35+ NSData *tiff = [image TIFFRepresentation];
36+ if (tiff == nil) {
37+ if (err != NULL) *err = zot_strdup("clipboard image has no TIFF representation");
38+ return 2;
39+ }
40+ NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:tiff];
41+ if (rep == nil) {
42+ if (err != NULL) *err = zot_strdup("clipboard image could not be converted to a bitmap");
43+ return 2;
44+ }
45+ NSData *png = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}];
46+ if (png == nil) {
47+ if (err != NULL) *err = zot_strdup("clipboard image could not be encoded as PNG");
48+ return 2;
49+ }
50+ NSString *dst = [NSString stringWithUTF8String:path];
51+ NSError *writeErr = nil;
52+ if (![png writeToFile:dst options:NSDataWritingAtomic error:&writeErr]) {
53+ if (err != NULL) *err = zot_strdup([[writeErr localizedDescription] UTF8String]);
54+ return 2;
55+ }
56+ return 0;
57+ }
58+}
59+*/
60+import "C"
61+
62+import (
63+ "crypto/rand"
64+ "encoding/hex"
65+ "fmt"
66+ "os"
67+ "path/filepath"
68+ "time"
69+ "unsafe"
70+)
71+
72+func ReadClipboardImagePNG() (string, []byte, bool, error) {
73+ dir := clipboardImageDir()
74+ if err := os.MkdirAll(dir, 0o700); err != nil {
75+ return "", nil, false, err
76+ }
77+ path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")
78+ cpath := C.CString(path)
79+ defer C.free(unsafe.Pointer(cpath))
80+ var cerr *C.char
81+ code := C.ZotWriteClipboardPNG(cpath, &cerr)
82+ if cerr != nil {
83+ defer C.free(unsafe.Pointer(cerr))
84+ }
85+ if code == 1 {
86+ return "", nil, false, nil
87+ }
88+ if code != 0 {
89+ msg := "could not read image from clipboard"
90+ if cerr != nil {
91+ msg = C.GoString(cerr)
92+ }
93+ return "", nil, false, fmt.Errorf("%s", msg)
94+ }
95+ data, err := os.ReadFile(path)
96+ if err != nil {
97+ return "", nil, false, err
98+ }
99+ return path, data, true, nil
100+}
101+
102+func clipboardImageDir() string {
103+ if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
104+ return filepath.Join("/tmp", "zot-clipboard-images")
105+ }
106+ return filepath.Join(os.TempDir(), "zot-clipboard-images")
107+}
108+
109+func randomHex(n int) string {
110+ buf := make([]byte, n)
111+ if _, err := rand.Read(buf); err != nil {
112+ return fmt.Sprintf("%d", time.Now().UnixNano())
113+ }
114+ return hex.EncodeToString(buf)
115+}
@@ -0,0 +1,115 @@
1+//go:build darwin && cgo
2+
3+package tui
4+
5+/*
6+#cgo CFLAGS: -x objective-c
7+#cgo LDFLAGS: -framework AppKit
8+#import <AppKit/AppKit.h>
9+#include <stdlib.h>
10+#include <string.h>
11+
12+static char* zot_strdup(const char *s) {
13+ if (s == NULL) return NULL;
14+ char *out = malloc(strlen(s) + 1);
15+ if (out != NULL) strcpy(out, s);
16+ return out;
17+}
18+
19+static int ZotWriteClipboardPNG(const char *path, char **err) {
20+ @autoreleasepool {
21+ NSPasteboard *pb = [NSPasteboard generalPasteboard];
22+ NSArray *classes = @[[NSImage class]];
23+ NSDictionary *options = @{};
24+ NSArray *objects = [pb readObjectsForClasses:classes options:options];
25+ NSImage *image = nil;
26+ if ([objects count] > 0 && [[objects objectAtIndex:0] isKindOfClass:[NSImage class]]) {
27+ image = (NSImage *)[objects objectAtIndex:0];
28+ }
29+ if (image == nil) {
30+ NSData *tiff = [pb dataForType:NSPasteboardTypeTIFF];
31+ if (tiff != nil) image = [[NSImage alloc] initWithData:tiff];
32+ }
33+ if (image == nil) return 1;
34+
35+ NSData *tiff = [image TIFFRepresentation];
36+ if (tiff == nil) {
37+ if (err != NULL) *err = zot_strdup("clipboard image has no TIFF representation");
38+ return 2;
39+ }
40+ NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:tiff];
41+ if (rep == nil) {
42+ if (err != NULL) *err = zot_strdup("clipboard image could not be converted to a bitmap");
43+ return 2;
44+ }
45+ NSData *png = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}];
46+ if (png == nil) {
47+ if (err != NULL) *err = zot_strdup("clipboard image could not be encoded as PNG");
48+ return 2;
49+ }
50+ NSString *dst = [NSString stringWithUTF8String:path];
51+ NSError *writeErr = nil;
52+ if (![png writeToFile:dst options:NSDataWritingAtomic error:&writeErr]) {
53+ if (err != NULL) *err = zot_strdup([[writeErr localizedDescription] UTF8String]);
54+ return 2;
55+ }
56+ return 0;
57+ }
58+}
59+*/
60+import "C"
61+
62+import (
63+ "crypto/rand"
64+ "encoding/hex"
65+ "fmt"
66+ "os"
67+ "path/filepath"
68+ "time"
69+ "unsafe"
70+)
71+
72+func ReadClipboardImagePNG() (string, []byte, bool, error) {
73+ dir := clipboardImageDir()
74+ if err := os.MkdirAll(dir, 0o700); err != nil {
75+ return "", nil, false, err
76+ }
77+ path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")
78+ cpath := C.CString(path)
79+ defer C.free(unsafe.Pointer(cpath))
80+ var cerr *C.char
81+ code := C.ZotWriteClipboardPNG(cpath, &cerr)
82+ if cerr != nil {
83+ defer C.free(unsafe.Pointer(cerr))
84+ }
85+ if code == 1 {
86+ return "", nil, false, nil
87+ }
88+ if code != 0 {
89+ msg := "could not read image from clipboard"
90+ if cerr != nil {
91+ msg = C.GoString(cerr)
92+ }
93+ return "", nil, false, fmt.Errorf("%s", msg)
94+ }
95+ data, err := os.ReadFile(path)
96+ if err != nil {
97+ return "", nil, false, err
98+ }
99+ return path, data, true, nil
100+}
101+
102+func clipboardImageDir() string {
103+ if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
104+ return filepath.Join("/tmp", "zot-clipboard-images")
105+ }
106+ return filepath.Join(os.TempDir(), "zot-clipboard-images")
107+}
108+
109+func randomHex(n int) string {
110+ buf := make([]byte, n)
111+ if _, err := rand.Read(buf); err != nil {
112+ return fmt.Sprintf("%d", time.Now().UnixNano())
113+ }
114+ return hex.EncodeToString(buf)
115+}
A packages/tui/clipboard_stub.go +11 -0
@@ -0,0 +1,11 @@
1+//go:build !darwin || !cgo
2+
3+package tui
4+
5+func ReadClipboardImagePNG() (string, []byte, bool, error) {
6+ return "", nil, false, nil
7+}
8+
9+func ReadClipboardText() (string, bool, error) {
10+ return "", false, nil
11+}
@@ -0,0 +1,11 @@
1+//go:build !darwin || !cgo
2+
3+package tui
4+
5+func ReadClipboardImagePNG() (string, []byte, bool, error) {
6+ return "", nil, false, nil
7+}
8+
9+func ReadClipboardText() (string, bool, error) {
10+ return "", false, nil
11+}
A packages/tui/clipboard_text_darwin.go +36 -0
@@ -0,0 +1,36 @@
1+//go:build darwin && cgo
2+
3+package tui
4+
5+/*
6+#cgo CFLAGS: -x objective-c
7+#cgo LDFLAGS: -framework AppKit
8+#import <AppKit/AppKit.h>
9+#include <stdlib.h>
10+#include <string.h>
11+
12+static char* ZotReadClipboardString(void) {
13+ @autoreleasepool {
14+ NSPasteboard *pb = [NSPasteboard generalPasteboard];
15+ NSString *str = [pb stringForType:NSPasteboardTypeString];
16+ if (str == nil) return NULL;
17+ const char *utf8 = [str UTF8String];
18+ if (utf8 == NULL) return NULL;
19+ char *out = malloc(strlen(utf8) + 1);
20+ if (out != NULL) strcpy(out, utf8);
21+ return out;
22+ }
23+}
24+*/
25+import "C"
26+
27+import "unsafe"
28+
29+func ReadClipboardText() (string, bool, error) {
30+ cstr := C.ZotReadClipboardString()
31+ if cstr == nil {
32+ return "", false, nil
33+ }
34+ defer C.free(unsafe.Pointer(cstr))
35+ return C.GoString(cstr), true, nil
36+}
@@ -0,0 +1,36 @@
1+//go:build darwin && cgo
2+
3+package tui
4+
5+/*
6+#cgo CFLAGS: -x objective-c
7+#cgo LDFLAGS: -framework AppKit
8+#import <AppKit/AppKit.h>
9+#include <stdlib.h>
10+#include <string.h>
11+
12+static char* ZotReadClipboardString(void) {
13+ @autoreleasepool {
14+ NSPasteboard *pb = [NSPasteboard generalPasteboard];
15+ NSString *str = [pb stringForType:NSPasteboardTypeString];
16+ if (str == nil) return NULL;
17+ const char *utf8 = [str UTF8String];
18+ if (utf8 == NULL) return NULL;
19+ char *out = malloc(strlen(utf8) + 1);
20+ if (out != NULL) strcpy(out, utf8);
21+ return out;
22+ }
23+}
24+*/
25+import "C"
26+
27+import "unsafe"
28+
29+func ReadClipboardText() (string, bool, error) {
30+ cstr := C.ZotReadClipboardString()
31+ if cstr == nil {
32+ return "", false, nil
33+ }
34+ defer C.free(unsafe.Pointer(cstr))
35+ return C.GoString(cstr), true, nil
36+}
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 {
M packages/tui/view.go +1 -3
@@ -631,9 +631,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
631631 }
632632 row := func(content string) string {
633633 inner := strings.Repeat(" ", leftGutter) + content
634- padded := v.Theme.UserBubble(inner, width-2)
635- bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
636- return bar + padded
634+ return v.Theme.UserBubbleRow(inner, width)
637635 }
638636 var bubble []string
639637 for _, c := range m.Content {
@@ -631,9 +631,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
631 }631 }
632 row := func(content string) string {632 row := func(content string) string {
633 inner := strings.Repeat(" ", leftGutter) + content633 inner := strings.Repeat(" ", leftGutter) + content
634- padded := v.Theme.UserBubble(inner, width-2)634+ return v.Theme.UserBubbleRow(inner, width)
635- bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
636- return bar + padded
637 }635 }
638 var bubble []string636 var bubble []string
639 for _, c := range m.Content {637 for _, c := range m.Content {

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