detached b16caf0

feat(palette): autocomplete shortcodes with tab

commit
b16caf04b704d3351ca08405fcc2f68c1195648c
author
brice · · 2026-07-14 11:32 UTC
parent
4b73ee6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +66 -0
M internal/usecase/daemon/palette.go +1 -0
@@ -125,6 +125,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
125125 ac.overlays.palette.Backspace()
126126 changed = true
127127 },
128+ tab: func() { changed = ac.overlays.palette.CompleteSelected() },
128129 up: func() { ac.overlays.palette.Up(); changed = true },
129130 down: func() { ac.overlays.palette.Down(); changed = true },
130131 cancel: func() { cancel = true },
@@ -125,6 +125,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
125 ac.overlays.palette.Backspace()125 ac.overlays.palette.Backspace()
126 changed = true126 changed = true
127 },127 },
128+ tab: func() { changed = ac.overlays.palette.CompleteSelected() },
128 up: func() { ac.overlays.palette.Up(); changed = true },129 up: func() { ac.overlays.palette.Up(); changed = true },
129 down: func() { ac.overlays.palette.Down(); changed = true },130 down: func() { ac.overlays.palette.Down(); changed = true },
130 cancel: func() { cancel = true },131 cancel: func() { cancel = true },
M internal/usecase/daemon/palette_test.go +65 -0
@@ -4,6 +4,7 @@ import (
44 "errors"
55 "sync"
66 "testing"
7+ "time"
78
89 "github.com/stretchr/testify/mock"
910 "github.com/stretchr/testify/require"
@@ -530,6 +531,70 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
530531 wg.Wait()
531532 }
532533
534+func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) {
535+ tests := []struct {
536+ name string
537+ config map[string]string
538+ recent string
539+ query string
540+ want string
541+ wantRepaint bool
542+ setupRepaints int
543+ }{
544+ {name: "changed completion repaints once", query: "NX", want: "NXT", wantRepaint: true},
545+ {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1", wantRepaint: false},
546+ {name: "empty query starts at registry first", want: "CNT", wantRepaint: true},
547+ {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantRepaint: true},
548+ {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantRepaint: true, setupRepaints: 1},
549+ {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantRepaint: true, setupRepaints: 1},
550+ }
551+
552+ for _, tt := range tests {
553+ t.Run(tt.name, func(t *testing.T) {
554+ writes := make(chan []byte, 1)
555+ p, release := newBlockingPTYWithWrites(t, writes)
556+ defer release()
557+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
558+ if tt.recent != "" {
559+ d.recordPaletteUse(tt.recent)
560+ }
561+ if tt.config != nil {
562+ d.ApplyConfig(domain.Config{Codes: tt.config})
563+ for range tt.setupRepaints {
564+ awaitFrame(t, sends, ports.MsgOutput)
565+ }
566+ }
567+
568+ d.handleInput(sess, ac, []byte("\x1b "))
569+ awaitFrame(t, sends, ports.MsgOutput) // drain palette open repaint
570+ if tt.query != "" {
571+ d.handleInput(sess, ac, []byte(tt.query))
572+ awaitFrame(t, sends, ports.MsgOutput) // drain typed-query repaint
573+ }
574+
575+ d.handleInput(sess, ac, []byte("\t"))
576+ ac.overlays.paletteMu.Lock()
577+ got := ac.overlays.palette.Query()
578+ ac.overlays.paletteMu.Unlock()
579+ require.Equal(t, tt.want, got)
580+ if tt.wantRepaint {
581+ awaitFrame(t, sends, ports.MsgOutput)
582+ }
583+ requireNoPaletteFrame(t, sends)
584+ requireNoPTYWrite(t, writes)
585+ })
586+ }
587+}
588+
589+func requireNoPaletteFrame(t *testing.T, sends <-chan ports.Frame) {
590+ t.Helper()
591+ select {
592+ case frame := <-sends:
593+ t.Fatalf("unexpected frame after no-op Tab: %#v", frame)
594+ case <-time.After(50 * time.Millisecond):
595+ }
596+}
597+
533598 func TestPaletteExecMethods(t *testing.T) {
534599 p1, release1 := newBlockingPTY(t)
535600 p2, release2 := newBlockingPTY(t)
@@ -4,6 +4,7 @@ import (
4 "errors"4 "errors"
5 "sync"5 "sync"
6 "testing"6 "testing"
7+ "time"
7 8
8 "github.com/stretchr/testify/mock"9 "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -530,6 +531,70 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
530 wg.Wait()531 wg.Wait()
531 }532 }
532 533
534+func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) {
535+ tests := []struct {
536+ name string
537+ config map[string]string
538+ recent string
539+ query string
540+ want string
541+ wantRepaint bool
542+ setupRepaints int
543+ }{
544+ {name: "changed completion repaints once", query: "NX", want: "NXT", wantRepaint: true},
545+ {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1", wantRepaint: false},
546+ {name: "empty query starts at registry first", want: "CNT", wantRepaint: true},
547+ {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantRepaint: true},
548+ {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantRepaint: true, setupRepaints: 1},
549+ {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantRepaint: true, setupRepaints: 1},
550+ }
551+
552+ for _, tt := range tests {
553+ t.Run(tt.name, func(t *testing.T) {
554+ writes := make(chan []byte, 1)
555+ p, release := newBlockingPTYWithWrites(t, writes)
556+ defer release()
557+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
558+ if tt.recent != "" {
559+ d.recordPaletteUse(tt.recent)
560+ }
561+ if tt.config != nil {
562+ d.ApplyConfig(domain.Config{Codes: tt.config})
563+ for range tt.setupRepaints {
564+ awaitFrame(t, sends, ports.MsgOutput)
565+ }
566+ }
567+
568+ d.handleInput(sess, ac, []byte("\x1b "))
569+ awaitFrame(t, sends, ports.MsgOutput) // drain palette open repaint
570+ if tt.query != "" {
571+ d.handleInput(sess, ac, []byte(tt.query))
572+ awaitFrame(t, sends, ports.MsgOutput) // drain typed-query repaint
573+ }
574+
575+ d.handleInput(sess, ac, []byte("\t"))
576+ ac.overlays.paletteMu.Lock()
577+ got := ac.overlays.palette.Query()
578+ ac.overlays.paletteMu.Unlock()
579+ require.Equal(t, tt.want, got)
580+ if tt.wantRepaint {
581+ awaitFrame(t, sends, ports.MsgOutput)
582+ }
583+ requireNoPaletteFrame(t, sends)
584+ requireNoPTYWrite(t, writes)
585+ })
586+ }
587+}
588+
589+func requireNoPaletteFrame(t *testing.T, sends <-chan ports.Frame) {
590+ t.Helper()
591+ select {
592+ case frame := <-sends:
593+ t.Fatalf("unexpected frame after no-op Tab: %#v", frame)
594+ case <-time.After(50 * time.Millisecond):
595+ }
596+}
597+
533 func TestPaletteExecMethods(t *testing.T) {598 func TestPaletteExecMethods(t *testing.T) {
534 p1, release1 := newBlockingPTY(t)599 p1, release1 := newBlockingPTY(t)
535 p2, release2 := newBlockingPTY(t)600 p2, release2 := newBlockingPTY(t)

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 2

Tags 13

↑↓ moveenter openesc close