detached 34e0352

Support text clipboard paste across inputs

commit
34e0352d43e7529dbe5ab9eef95d63190f1c9108
author
patriceckhart · · 2026-07-25 15:12 UTC
parent
ee6f014
signature
unsigned
15 files · +324 -3
M README.md +1 -1
@@ -846,7 +846,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
846846 | `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. |
847847 | `ctrl+d` | Exit on empty input. |
848848 | `ctrl+l` | Redraw the screen. |
849-| `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. |
849+| `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. On Linux this uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. In the main chat on macOS, image-only clipboard content is saved as a temporary PNG and attached to the next prompt. |
850850 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |
851851 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |
852852 | `@` | Open the file picker. Browse files and directories in the working directory. |
@@ -846,7 +846,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
846 | `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. |846 | `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. |
847 | `ctrl+d` | Exit on empty input. |847 | `ctrl+d` | Exit on empty input. |
848 | `ctrl+l` | Redraw the screen. |848 | `ctrl+l` | Redraw the screen. |
849-| `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. |849+| `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. On Linux this uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. In the main chat on macOS, image-only clipboard content is saved as a temporary PNG and attached to the next prompt. |
850 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |850 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |
851 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |851 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |
852 | `@` | Open the file picker. Browse files and directories in the working directory. |852 | `@` | Open the file picker. Browse files and directories in the working directory. |
A packages/agent/modes/clipboard_inputs_test.go +35 -0
@@ -0,0 +1,35 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/tui"
7+)
8+
9+func TestFilterInputsAcceptPaste(t *testing.T) {
10+ paste := tui.Key{Kind: tui.KeyPaste, Paste: " pasted\n query "}
11+
12+ model := &modelDialog{}
13+ model.HandleKey(paste)
14+ if model.query != "pasted query" {
15+ t.Fatalf("model query = %q", model.query)
16+ }
17+
18+ jump := &jumpDialog{}
19+ jump.HandleKey(paste)
20+ if jump.filter != "pasted query" {
21+ t.Fatalf("jump filter = %q", jump.filter)
22+ }
23+
24+ rescue := &rescueDialog{}
25+ rescue.HandleKey(paste)
26+ if rescue.query != "pasted query" {
27+ t.Fatalf("rescue query = %q", rescue.query)
28+ }
29+
30+ login := &loginDialog{step: loginStepProvider}
31+ login.HandleKey(paste)
32+ if login.providerQuery != "pasted query" {
33+ t.Fatalf("provider query = %q", login.providerQuery)
34+ }
35+}
@@ -0,0 +1,35 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/tui"
7+)
8+
9+func TestFilterInputsAcceptPaste(t *testing.T) {
10+ paste := tui.Key{Kind: tui.KeyPaste, Paste: " pasted\n query "}
11+
12+ model := &modelDialog{}
13+ model.HandleKey(paste)
14+ if model.query != "pasted query" {
15+ t.Fatalf("model query = %q", model.query)
16+ }
17+
18+ jump := &jumpDialog{}
19+ jump.HandleKey(paste)
20+ if jump.filter != "pasted query" {
21+ t.Fatalf("jump filter = %q", jump.filter)
22+ }
23+
24+ rescue := &rescueDialog{}
25+ rescue.HandleKey(paste)
26+ if rescue.query != "pasted query" {
27+ t.Fatalf("rescue query = %q", rescue.query)
28+ }
29+
30+ login := &loginDialog{step: loginStepProvider}
31+ login.HandleKey(paste)
32+ if login.providerQuery != "pasted query" {
33+ t.Fatalf("provider query = %q", login.providerQuery)
34+ }
35+}
A packages/agent/modes/clipboard_paste.go +27 -0
@@ -0,0 +1,27 @@
1+package modes
2+
3+import (
4+ "strings"
5+
6+ "github.com/patriceckhart/zot/packages/tui"
7+)
8+
9+type clipboardTextReader func() (string, bool, error)
10+
11+// resolveClipboardText turns an application-level ctrl+v event into the same
12+// KeyPaste event terminals produce for bracketed paste. Keeping that conversion
13+// before dialog routing makes clipboard paste available to every text input.
14+func resolveClipboardText(k tui.Key, read clipboardTextReader) (tui.Key, bool, error) {
15+ if k.Kind != tui.KeyPasteClipboard {
16+ return k, false, nil
17+ }
18+ text, ok, err := read()
19+ if err != nil || !ok || text == "" {
20+ return k, false, err
21+ }
22+ return tui.Key{Kind: tui.KeyPaste, Paste: text}, true, nil
23+}
24+
25+func singleLinePaste(text string) string {
26+ return strings.Join(strings.Fields(text), " ")
27+}
@@ -0,0 +1,27 @@
1+package modes
2+
3+import (
4+ "strings"
5+
6+ "github.com/patriceckhart/zot/packages/tui"
7+)
8+
9+type clipboardTextReader func() (string, bool, error)
10+
11+// resolveClipboardText turns an application-level ctrl+v event into the same
12+// KeyPaste event terminals produce for bracketed paste. Keeping that conversion
13+// before dialog routing makes clipboard paste available to every text input.
14+func resolveClipboardText(k tui.Key, read clipboardTextReader) (tui.Key, bool, error) {
15+ if k.Kind != tui.KeyPasteClipboard {
16+ return k, false, nil
17+ }
18+ text, ok, err := read()
19+ if err != nil || !ok || text == "" {
20+ return k, false, err
21+ }
22+ return tui.Key{Kind: tui.KeyPaste, Paste: text}, true, nil
23+}
24+
25+func singleLinePaste(text string) string {
26+ return strings.Join(strings.Fields(text), " ")
27+}
A packages/agent/modes/clipboard_paste_test.go +49 -0
@@ -0,0 +1,49 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/tui"
8+)
9+
10+func TestResolveClipboardTextConvertsClipboardKeyToPaste(t *testing.T) {
11+ key, resolved, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard, Ctrl: true}, func() (string, bool, error) {
12+ return "pasted\ntext", true, nil
13+ })
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ if !resolved || key.Kind != tui.KeyPaste || key.Paste != "pasted\ntext" {
18+ t.Fatalf("key = %#v, resolved = %v", key, resolved)
19+ }
20+}
21+
22+func TestResolveClipboardTextPreservesClipboardKeyWithoutText(t *testing.T) {
23+ original := tui.Key{Kind: tui.KeyPasteClipboard, Ctrl: true}
24+ key, resolved, err := resolveClipboardText(original, func() (string, bool, error) {
25+ return "", false, nil
26+ })
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ if resolved || key != original {
31+ t.Fatalf("key = %#v, resolved = %v, want original unresolved key", key, resolved)
32+ }
33+}
34+
35+func TestResolveClipboardTextReturnsReadError(t *testing.T) {
36+ want := errors.New("clipboard unavailable")
37+ _, resolved, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, func() (string, bool, error) {
38+ return "", false, want
39+ })
40+ if resolved || !errors.Is(err, want) {
41+ t.Fatalf("resolved = %v, err = %v, want %v", resolved, err, want)
42+ }
43+}
44+
45+func TestSingleLinePaste(t *testing.T) {
46+ if got := singleLinePaste(" claude\n opus\t5 "); got != "claude opus 5" {
47+ t.Fatalf("singleLinePaste = %q", got)
48+ }
49+}
@@ -0,0 +1,49 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/tui"
8+)
9+
10+func TestResolveClipboardTextConvertsClipboardKeyToPaste(t *testing.T) {
11+ key, resolved, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard, Ctrl: true}, func() (string, bool, error) {
12+ return "pasted\ntext", true, nil
13+ })
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ if !resolved || key.Kind != tui.KeyPaste || key.Paste != "pasted\ntext" {
18+ t.Fatalf("key = %#v, resolved = %v", key, resolved)
19+ }
20+}
21+
22+func TestResolveClipboardTextPreservesClipboardKeyWithoutText(t *testing.T) {
23+ original := tui.Key{Kind: tui.KeyPasteClipboard, Ctrl: true}
24+ key, resolved, err := resolveClipboardText(original, func() (string, bool, error) {
25+ return "", false, nil
26+ })
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ if resolved || key != original {
31+ t.Fatalf("key = %#v, resolved = %v, want original unresolved key", key, resolved)
32+ }
33+}
34+
35+func TestResolveClipboardTextReturnsReadError(t *testing.T) {
36+ want := errors.New("clipboard unavailable")
37+ _, resolved, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, func() (string, bool, error) {
38+ return "", false, want
39+ })
40+ if resolved || !errors.Is(err, want) {
41+ t.Fatalf("resolved = %v, err = %v, want %v", resolved, err, want)
42+ }
43+}
44+
45+func TestSingleLinePaste(t *testing.T) {
46+ if got := singleLinePaste(" claude\n opus\t5 "); got != "claude opus 5" {
47+ t.Fatalf("singleLinePaste = %q", got)
48+ }
49+}
M packages/agent/modes/interactive.go +36 -2
@@ -1933,6 +1933,28 @@ func (i *Interactive) restoreConfirmFocus() {
19331933 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
19341934 defer i.restoreConfirmFocus()
19351935
1936+ // Dialogs route keys before the main clipboard handler below. Resolve text
1937+ // here when a child interaction owns input so every editor and filter sees
1938+ // the same KeyPaste event as terminal-native bracketed paste. Main chat is
1939+ // left image-first to preserve macOS clipboard image attachments.
1940+ if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() {
1941+ resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText)
1942+ if err != nil {
1943+ i.mu.Lock()
1944+ i.statusErr = "clipboard paste failed: " + err.Error()
1945+ i.statusOK = ""
1946+ i.mu.Unlock()
1947+ return false
1948+ }
1949+ if pastedText {
1950+ k = resolved
1951+ i.mu.Lock()
1952+ i.statusErr = ""
1953+ i.statusOK = ""
1954+ i.mu.Unlock()
1955+ }
1956+ }
1957+
19361958 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
19371959 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
19381960 // unexpectedly. The hint message also goes stale; clear it.
@@ -2645,10 +2667,22 @@ func (i *Interactive) pasteClipboard() {
26452667 i.mu.Unlock()
26462668 return
26472669 }
2670+ key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
26482671 i.mu.Lock()
2649- i.statusErr = "clipboard does not contain an image"
2672+ defer i.mu.Unlock()
2673+ if err != nil {
2674+ i.statusErr = "clipboard paste failed: " + err.Error()
2675+ i.statusOK = ""
2676+ return
2677+ }
2678+ if pastedText {
2679+ i.ed.HandleKey(key)
2680+ i.statusErr = ""
2681+ i.statusOK = ""
2682+ return
2683+ }
2684+ i.statusErr = "clipboard does not contain text or an image"
26502685 i.statusOK = ""
2651- i.mu.Unlock()
26522686 }
26532687
26542688 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
@@ -1933,6 +1933,28 @@ func (i *Interactive) restoreConfirmFocus() {
1933 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {1933 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1934 defer i.restoreConfirmFocus()1934 defer i.restoreConfirmFocus()
1935 1935
1936+ // Dialogs route keys before the main clipboard handler below. Resolve text
1937+ // here when a child interaction owns input so every editor and filter sees
1938+ // the same KeyPaste event as terminal-native bracketed paste. Main chat is
1939+ // left image-first to preserve macOS clipboard image attachments.
1940+ if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() {
1941+ resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText)
1942+ if err != nil {
1943+ i.mu.Lock()
1944+ i.statusErr = "clipboard paste failed: " + err.Error()
1945+ i.statusOK = ""
1946+ i.mu.Unlock()
1947+ return false
1948+ }
1949+ if pastedText {
1950+ k = resolved
1951+ i.mu.Lock()
1952+ i.statusErr = ""
1953+ i.statusOK = ""
1954+ i.mu.Unlock()
1955+ }
1956+ }
1957+
1936 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so1958 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
1937 // pressing ctrl+c then typing then ctrl+c much later doesn't quit1959 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
1938 // unexpectedly. The hint message also goes stale; clear it.1960 // unexpectedly. The hint message also goes stale; clear it.
@@ -2645,10 +2667,22 @@ func (i *Interactive) pasteClipboard() {
2645 i.mu.Unlock()2667 i.mu.Unlock()
2646 return2668 return
2647 }2669 }
2670+ key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
2648 i.mu.Lock()2671 i.mu.Lock()
2649- i.statusErr = "clipboard does not contain an image"2672+ defer i.mu.Unlock()
2673+ if err != nil {
2674+ i.statusErr = "clipboard paste failed: " + err.Error()
2675+ i.statusOK = ""
2676+ return
2677+ }
2678+ if pastedText {
2679+ i.ed.HandleKey(key)
2680+ i.statusErr = ""
2681+ i.statusOK = ""
2682+ return
2683+ }
2684+ i.statusErr = "clipboard does not contain text or an image"
2650 i.statusOK = ""2685 i.statusOK = ""
2651- i.mu.Unlock()
2652 }2686 }
2653 2687
2654 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {2688 func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
M packages/agent/modes/jump_dialog.go +3 -0
@@ -216,6 +216,9 @@ func (d *jumpDialog) HandleKey(k tui.Key) jumpDialogAction {
216216 d.filter = string(runes[:len(runes)-1])
217217 d.applyFilter()
218218 }
219+ case tui.KeyPaste:
220+ d.filter += singleLinePaste(k.Paste)
221+ d.applyFilter()
219222 case tui.KeyRune:
220223 // Any printable rune extends the filter.
221224 d.filter += string(k.Rune)
@@ -216,6 +216,9 @@ func (d *jumpDialog) HandleKey(k tui.Key) jumpDialogAction {
216 d.filter = string(runes[:len(runes)-1])216 d.filter = string(runes[:len(runes)-1])
217 d.applyFilter()217 d.applyFilter()
218 }218 }
219+ case tui.KeyPaste:
220+ d.filter += singleLinePaste(k.Paste)
221+ d.applyFilter()
219 case tui.KeyRune:222 case tui.KeyRune:
220 // Any printable rune extends the filter.223 // Any printable rune extends the filter.
221 d.filter += string(k.Rune)224 d.filter += string(k.Rune)
M packages/agent/modes/login_dialog.go +3 -0
@@ -520,6 +520,9 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
520520 d.providerQuery = string(runes[:len(runes)-1])
521521 d.cursor = 0
522522 }
523+ case tui.KeyPaste:
524+ d.providerQuery += singleLinePaste(k.Paste)
525+ d.cursor = 0
523526 case tui.KeyRune:
524527 if !k.Alt && !k.Ctrl && k.Rune >= 0x20 && k.Rune < 0x7f {
525528 d.providerQuery += string(k.Rune)
@@ -520,6 +520,9 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
520 d.providerQuery = string(runes[:len(runes)-1])520 d.providerQuery = string(runes[:len(runes)-1])
521 d.cursor = 0521 d.cursor = 0
522 }522 }
523+ case tui.KeyPaste:
524+ d.providerQuery += singleLinePaste(k.Paste)
525+ d.cursor = 0
523 case tui.KeyRune:526 case tui.KeyRune:
524 if !k.Alt && !k.Ctrl && k.Rune >= 0x20 && k.Rune < 0x7f {527 if !k.Alt && !k.Ctrl && k.Rune >= 0x20 && k.Rune < 0x7f {
525 d.providerQuery += string(k.Rune)528 d.providerQuery += string(k.Rune)
M packages/agent/modes/model_dialog.go +3 -0
@@ -284,6 +284,9 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
284284 d.query = string(r[:len(r)-1])
285285 d.refilter()
286286 }
287+ case tui.KeyPaste:
288+ d.query += singleLinePaste(k.Paste)
289+ d.refilter()
287290 case tui.KeyRune:
288291 if k.Alt || k.Ctrl {
289292 break
@@ -284,6 +284,9 @@ func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction {
284 d.query = string(r[:len(r)-1])284 d.query = string(r[:len(r)-1])
285 d.refilter()285 d.refilter()
286 }286 }
287+ case tui.KeyPaste:
288+ d.query += singleLinePaste(k.Paste)
289+ d.refilter()
287 case tui.KeyRune:290 case tui.KeyRune:
288 if k.Alt || k.Ctrl {291 if k.Alt || k.Ctrl {
289 break292 break
M packages/agent/modes/rescue_dialog.go +3 -0
@@ -191,6 +191,9 @@ func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction {
191191 d.query = string(r[:len(r)-1])
192192 d.refilter()
193193 }
194+ case tui.KeyPaste:
195+ d.query += singleLinePaste(k.Paste)
196+ d.refilter()
194197 case tui.KeyRune:
195198 if k.Alt || k.Ctrl {
196199 break
@@ -191,6 +191,9 @@ func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction {
191 d.query = string(r[:len(r)-1])191 d.query = string(r[:len(r)-1])
192 d.refilter()192 d.refilter()
193 }193 }
194+ case tui.KeyPaste:
195+ d.query += singleLinePaste(k.Paste)
196+ d.refilter()
194 case tui.KeyRune:197 case tui.KeyRune:
195 if k.Alt || k.Ctrl {198 if k.Alt || k.Ctrl {
196 break199 break
A packages/tui/clipboard_text_command.go +44 -0
@@ -0,0 +1,44 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "os/exec"
7+ "time"
8+)
9+
10+type clipboardTextCommand struct {
11+ name string
12+ args []string
13+}
14+
15+var errClipboardCommandUnavailable = errors.New("no supported clipboard command found")
16+
17+// readClipboardTextCommands tries each installed command in order. Command
18+// stdout is the clipboard payload and is never included in returned errors.
19+func readClipboardTextCommands(commands ...clipboardTextCommand) (string, bool, error) {
20+ var lastErr error
21+ found := false
22+ for _, candidate := range commands {
23+ path, err := exec.LookPath(candidate.name)
24+ if err != nil {
25+ continue
26+ }
27+ found = true
28+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
29+ out, err := exec.CommandContext(ctx, path, candidate.args...).Output()
30+ cancel()
31+ if err != nil {
32+ lastErr = err
33+ continue
34+ }
35+ if len(out) == 0 {
36+ return "", false, nil
37+ }
38+ return string(out), true, nil
39+ }
40+ if !found {
41+ return "", false, errClipboardCommandUnavailable
42+ }
43+ return "", false, lastErr
44+}
@@ -0,0 +1,44 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "os/exec"
7+ "time"
8+)
9+
10+type clipboardTextCommand struct {
11+ name string
12+ args []string
13+}
14+
15+var errClipboardCommandUnavailable = errors.New("no supported clipboard command found")
16+
17+// readClipboardTextCommands tries each installed command in order. Command
18+// stdout is the clipboard payload and is never included in returned errors.
19+func readClipboardTextCommands(commands ...clipboardTextCommand) (string, bool, error) {
20+ var lastErr error
21+ found := false
22+ for _, candidate := range commands {
23+ path, err := exec.LookPath(candidate.name)
24+ if err != nil {
25+ continue
26+ }
27+ found = true
28+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
29+ out, err := exec.CommandContext(ctx, path, candidate.args...).Output()
30+ cancel()
31+ if err != nil {
32+ lastErr = err
33+ continue
34+ }
35+ if len(out) == 0 {
36+ return "", false, nil
37+ }
38+ return string(out), true, nil
39+ }
40+ if !found {
41+ return "", false, errClipboardCommandUnavailable
42+ }
43+ return "", false, lastErr
44+}
A packages/tui/clipboard_text_command_test.go +36 -0
@@ -0,0 +1,36 @@
1+package tui
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "runtime"
7+ "testing"
8+)
9+
10+func TestReadClipboardTextCommandsUsesAvailableCommand(t *testing.T) {
11+ if runtime.GOOS == "windows" {
12+ t.Skip("test helper is a shell script")
13+ }
14+ dir := t.TempDir()
15+ path := filepath.Join(dir, "clipboard-test")
16+ if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf 'clipboard text'\n"), 0o700); err != nil {
17+ t.Fatal(err)
18+ }
19+ t.Setenv("PATH", dir)
20+
21+ text, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "clipboard-test"})
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ if !ok || text != "clipboard text" {
26+ t.Fatalf("text = %q, ok = %v", text, ok)
27+ }
28+}
29+
30+func TestReadClipboardTextCommandsReportsUnavailable(t *testing.T) {
31+ t.Setenv("PATH", t.TempDir())
32+ _, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "missing-clipboard-command"})
33+ if ok || err != errClipboardCommandUnavailable {
34+ t.Fatalf("ok = %v, err = %v", ok, err)
35+ }
36+}
@@ -0,0 +1,36 @@
1+package tui
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "runtime"
7+ "testing"
8+)
9+
10+func TestReadClipboardTextCommandsUsesAvailableCommand(t *testing.T) {
11+ if runtime.GOOS == "windows" {
12+ t.Skip("test helper is a shell script")
13+ }
14+ dir := t.TempDir()
15+ path := filepath.Join(dir, "clipboard-test")
16+ if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf 'clipboard text'\n"), 0o700); err != nil {
17+ t.Fatal(err)
18+ }
19+ t.Setenv("PATH", dir)
20+
21+ text, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "clipboard-test"})
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ if !ok || text != "clipboard text" {
26+ t.Fatalf("text = %q, ok = %v", text, ok)
27+ }
28+}
29+
30+func TestReadClipboardTextCommandsReportsUnavailable(t *testing.T) {
31+ t.Setenv("PATH", t.TempDir())
32+ _, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "missing-clipboard-command"})
33+ if ok || err != errClipboardCommandUnavailable {
34+ t.Fatalf("ok = %v, err = %v", ok, err)
35+ }
36+}
A packages/tui/clipboard_text_darwin.go +14 -0
@@ -0,0 +1,14 @@
1+//go:build darwin
2+
3+package tui
4+
5+import "fmt"
6+
7+// ReadClipboardText reads plain text from the macOS system clipboard.
8+func ReadClipboardText() (string, bool, error) {
9+ text, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "/usr/bin/pbpaste"})
10+ if err == errClipboardCommandUnavailable {
11+ return "", false, fmt.Errorf("text clipboard unavailable: pbpaste was not found")
12+ }
13+ return text, ok, err
14+}
@@ -0,0 +1,14 @@
1+//go:build darwin
2+
3+package tui
4+
5+import "fmt"
6+
7+// ReadClipboardText reads plain text from the macOS system clipboard.
8+func ReadClipboardText() (string, bool, error) {
9+ text, ok, err := readClipboardTextCommands(clipboardTextCommand{name: "/usr/bin/pbpaste"})
10+ if err == errClipboardCommandUnavailable {
11+ return "", false, fmt.Errorf("text clipboard unavailable: pbpaste was not found")
12+ }
13+ return text, ok, err
14+}
A packages/tui/clipboard_text_linux.go +40 -0
@@ -0,0 +1,40 @@
1+//go:build linux
2+
3+package tui
4+
5+import (
6+ "fmt"
7+ "os"
8+)
9+
10+// ReadClipboardText reads plain text from a Wayland or X11 clipboard. The
11+// small command-line clients are used to preserve zot's CGO-free build.
12+func ReadClipboardText() (string, bool, error) {
13+ var commands []clipboardTextCommand
14+ if os.Getenv("WAYLAND_DISPLAY") != "" {
15+ commands = append(commands, clipboardTextCommand{name: "wl-paste", args: []string{"--no-newline", "--type", "text"}})
16+ }
17+ if os.Getenv("DISPLAY") != "" {
18+ commands = append(commands,
19+ clipboardTextCommand{name: "xclip", args: []string{"-selection", "clipboard", "-out"}},
20+ clipboardTextCommand{name: "xsel", args: []string{"--clipboard", "--output"}},
21+ )
22+ }
23+ // Environment forwarding through multiplexers is not always reliable.
24+ // Try every backend if no display variable made a preferred list.
25+ if len(commands) == 0 {
26+ commands = []clipboardTextCommand{
27+ {name: "wl-paste", args: []string{"--no-newline", "--type", "text"}},
28+ {name: "xclip", args: []string{"-selection", "clipboard", "-out"}},
29+ {name: "xsel", args: []string{"--clipboard", "--output"}},
30+ }
31+ }
32+ text, ok, err := readClipboardTextCommands(commands...)
33+ if err == errClipboardCommandUnavailable {
34+ return "", false, fmt.Errorf("text clipboard unavailable: install wl-clipboard, xclip, or xsel, or use the terminal paste shortcut")
35+ }
36+ if err != nil {
37+ return "", false, fmt.Errorf("read text clipboard: %w", err)
38+ }
39+ return text, ok, nil
40+}
@@ -0,0 +1,40 @@
1+//go:build linux
2+
3+package tui
4+
5+import (
6+ "fmt"
7+ "os"
8+)
9+
10+// ReadClipboardText reads plain text from a Wayland or X11 clipboard. The
11+// small command-line clients are used to preserve zot's CGO-free build.
12+func ReadClipboardText() (string, bool, error) {
13+ var commands []clipboardTextCommand
14+ if os.Getenv("WAYLAND_DISPLAY") != "" {
15+ commands = append(commands, clipboardTextCommand{name: "wl-paste", args: []string{"--no-newline", "--type", "text"}})
16+ }
17+ if os.Getenv("DISPLAY") != "" {
18+ commands = append(commands,
19+ clipboardTextCommand{name: "xclip", args: []string{"-selection", "clipboard", "-out"}},
20+ clipboardTextCommand{name: "xsel", args: []string{"--clipboard", "--output"}},
21+ )
22+ }
23+ // Environment forwarding through multiplexers is not always reliable.
24+ // Try every backend if no display variable made a preferred list.
25+ if len(commands) == 0 {
26+ commands = []clipboardTextCommand{
27+ {name: "wl-paste", args: []string{"--no-newline", "--type", "text"}},
28+ {name: "xclip", args: []string{"-selection", "clipboard", "-out"}},
29+ {name: "xsel", args: []string{"--clipboard", "--output"}},
30+ }
31+ }
32+ text, ok, err := readClipboardTextCommands(commands...)
33+ if err == errClipboardCommandUnavailable {
34+ return "", false, fmt.Errorf("text clipboard unavailable: install wl-clipboard, xclip, or xsel, or use the terminal paste shortcut")
35+ }
36+ if err != nil {
37+ return "", false, fmt.Errorf("read text clipboard: %w", err)
38+ }
39+ return text, ok, nil
40+}
A packages/tui/clipboard_text_stub.go +9 -0
@@ -0,0 +1,9 @@
1+//go:build !darwin && !linux && !windows
2+
3+package tui
4+
5+import "fmt"
6+
7+func ReadClipboardText() (string, bool, error) {
8+ return "", false, fmt.Errorf("text clipboard is not supported on this platform; use the terminal paste shortcut")
9+}
@@ -0,0 +1,9 @@
1+//go:build !darwin && !linux && !windows
2+
3+package tui
4+
5+import "fmt"
6+
7+func ReadClipboardText() (string, bool, error) {
8+ return "", false, fmt.Errorf("text clipboard is not supported on this platform; use the terminal paste shortcut")
9+}
A packages/tui/clipboard_text_windows.go +21 -0
@@ -0,0 +1,21 @@
1+//go:build windows
2+
3+package tui
4+
5+import "fmt"
6+
7+// ReadClipboardText reads plain text through the PowerShell installation
8+// included with supported Windows versions.
9+func ReadClipboardText() (string, bool, error) {
10+ text, ok, err := readClipboardTextCommands(
11+ clipboardTextCommand{name: "powershell.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Clipboard -Raw -Format Text"}},
12+ clipboardTextCommand{name: "pwsh.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"}},
13+ )
14+ if err == errClipboardCommandUnavailable {
15+ return "", false, fmt.Errorf("text clipboard unavailable: PowerShell was not found")
16+ }
17+ if err != nil {
18+ return "", false, fmt.Errorf("read text clipboard: %w", err)
19+ }
20+ return text, ok, nil
21+}
@@ -0,0 +1,21 @@
1+//go:build windows
2+
3+package tui
4+
5+import "fmt"
6+
7+// ReadClipboardText reads plain text through the PowerShell installation
8+// included with supported Windows versions.
9+func ReadClipboardText() (string, bool, error) {
10+ text, ok, err := readClipboardTextCommands(
11+ clipboardTextCommand{name: "powershell.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Clipboard -Raw -Format Text"}},
12+ clipboardTextCommand{name: "pwsh.exe", args: []string{"-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"}},
13+ )
14+ if err == errClipboardCommandUnavailable {
15+ return "", false, fmt.Errorf("text clipboard unavailable: PowerShell was not found")
16+ }
17+ if err != nil {
18+ return "", false, fmt.Errorf("read text clipboard: %w", err)
19+ }
20+ return text, ok, nil
21+}

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