detached 6a69f26

Merge pull request #147 from bnema/chore/final-polish-release

Release polish: tagline, AUR install, keycap hints, fast demo pipeline
commit
6a69f26e42892590cac90138d8c9c2cb36c78168
author
bnema · · 2026-07-29 14:00 UTC
committer
GitHub · · 2026-07-29 14:00 UTC
merge of
0ba6c09 + 4ad00a9 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +120 -33
M Makefile +20 -12
@@ -30,24 +30,32 @@ demo:
3030 ./scripts/demo/run.sh demo.tape
3131 # vhs cannot emit a transparent margin, so cut the window out after the
3232 # fact: everything outside the rounded window rect becomes GIF
33- # transparency. The geometry mirrors demo.tape (1200x750 canvas, margin
34- # 20, border radius 10); keep them in sync.
33+ # transparency, plus a 2px edge stroked along the window. The geometry
34+ # mirrors demo.tape (1200x750 canvas, margin 20, border radius 10); keep
35+ # them in sync. ffmpeg streams the clip frame by frame, so peak memory
36+ # stays flat; the previous imagemagick -coalesce pipeline needed the whole
37+ # ~22GiB uncompressed clip at once and either crawled for an hour on its
38+ # disk cache or got OOM-killed — do not go back to it.
39+ docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
40+ -size 1200x750 xc:none -fill none -stroke "#30363D" -strokewidth 2 \
41+ -draw "roundrectangle 21,21 1178,728 9,9" /out/border.png
3542 docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
3643 -size 1200x750 xc:black -fill white \
3744 -draw "roundrectangle 20,20 1179,729 10,10" /out/mask.png
38- # The 2px edge is stroked before the mask is applied so the mask clips the
39- # stroke's antialiased overflow; drawing it after would leave partly
40- # transparent pixels, which a GIF's 1-bit alpha cannot represent.
41- docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
42- -limit memory 8GiB -limit map 12GiB /out/demo.gif -coalesce \
43- -fill none -stroke "#30363D" -strokewidth 2 \
44- -draw "roundrectangle 21,21 1178,728 9,9" \
45- null: \( /out/mask.png -alpha off \) -compose CopyOpacity \
46- -layers composite /out/demo-masked.gif
45+ # alphamerge applies the mask after the border overlay so the mask clips
46+ # the stroke's antialiased overflow, which a GIF's 1-bit alpha cannot
47+ # represent; paletteuse's alpha_threshold folds the rest into that 1-bit
48+ # alpha (palettegen reserves the transparent slot).
49+ docker run --rm --entrypoint ffmpeg -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
50+ -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \
51+ -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \
52+ /out/demo-masked.gif
4753 # --lossy above 30 smears the near-identical darks of the window chrome
4854 # (window bar vs terminal background) into visible banding, and the
4955 # conserve-memory fallback for huge inputs roughly doubles the output.
5056 docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif
51- rm scripts/demo/out/demo-masked.gif scripts/demo/out/mask.png
57+ # Temp files are owned by the container's subuid, so remove them from
58+ # inside a container rather than the host.
59+ docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png'
5260 mkdir -p docs/assets
5361 cp scripts/demo/out/demo.gif docs/assets/demo.gif
@@ -30,24 +30,32 @@ demo:
30 ./scripts/demo/run.sh demo.tape30 ./scripts/demo/run.sh demo.tape
31 # vhs cannot emit a transparent margin, so cut the window out after the31 # vhs cannot emit a transparent margin, so cut the window out after the
32 # fact: everything outside the rounded window rect becomes GIF32 # fact: everything outside the rounded window rect becomes GIF
33- # transparency. The geometry mirrors demo.tape (1200x750 canvas, margin33+ # transparency, plus a 2px edge stroked along the window. The geometry
34- # 20, border radius 10); keep them in sync.34+ # mirrors demo.tape (1200x750 canvas, margin 20, border radius 10); keep
35+ # them in sync. ffmpeg streams the clip frame by frame, so peak memory
36+ # stays flat; the previous imagemagick -coalesce pipeline needed the whole
37+ # ~22GiB uncompressed clip at once and either crawled for an hour on its
38+ # disk cache or got OOM-killed — do not go back to it.
39+ docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
40+ -size 1200x750 xc:none -fill none -stroke "#30363D" -strokewidth 2 \
41+ -draw "roundrectangle 21,21 1178,728 9,9" /out/border.png
35 docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \42 docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
36 -size 1200x750 xc:black -fill white \43 -size 1200x750 xc:black -fill white \
37 -draw "roundrectangle 20,20 1179,729 10,10" /out/mask.png44 -draw "roundrectangle 20,20 1179,729 10,10" /out/mask.png
38- # The 2px edge is stroked before the mask is applied so the mask clips the45+ # alphamerge applies the mask after the border overlay so the mask clips
39- # stroke's antialiased overflow; drawing it after would leave partly46+ # the stroke's antialiased overflow, which a GIF's 1-bit alpha cannot
40- # transparent pixels, which a GIF's 1-bit alpha cannot represent.47+ # represent; paletteuse's alpha_threshold folds the rest into that 1-bit
41- docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \48+ # alpha (palettegen reserves the transparent slot).
42- -limit memory 8GiB -limit map 12GiB /out/demo.gif -coalesce \49+ docker run --rm --entrypoint ffmpeg -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
43- -fill none -stroke "#30363D" -strokewidth 2 \50+ -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \
44- -draw "roundrectangle 21,21 1178,728 9,9" \51+ -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \
45- null: \( /out/mask.png -alpha off \) -compose CopyOpacity \52+ /out/demo-masked.gif
46- -layers composite /out/demo-masked.gif
47 # --lossy above 30 smears the near-identical darks of the window chrome53 # --lossy above 30 smears the near-identical darks of the window chrome
48 # (window bar vs terminal background) into visible banding, and the54 # (window bar vs terminal background) into visible banding, and the
49 # conserve-memory fallback for huge inputs roughly doubles the output.55 # conserve-memory fallback for huge inputs roughly doubles the output.
50 docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif56 docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif
51- rm scripts/demo/out/demo-masked.gif scripts/demo/out/mask.png57+ # Temp files are owned by the container's subuid, so remove them from
58+ # inside a container rather than the host.
59+ docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png'
52 mkdir -p docs/assets60 mkdir -p docs/assets
53 cp scripts/demo/out/demo.gif docs/assets/demo.gif61 cp scripts/demo/out/demo.gif docs/assets/demo.gif
M README.md +9 -1
@@ -3,7 +3,7 @@
33 <p align="center"><em>Norwegian: to weave</em></p>
44
55 <p align="center">A terminal multiplexer for Linux and macOS.<br>
6-One Go binary, zero runtime dependencies, remote attach that survives bad networks.</p>
6+Like tmux, minus the prefix key. Like mosh, minus mosh. One binary.</p>
77
88 <p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p>
99
@@ -17,6 +17,14 @@ Install a pre-built release on Linux x86_64/arm64 or macOS Apple silicon (arm64)
1717 curl -fsSL https://raw.githubusercontent.com/bnema/vev/main/install.sh | sh
1818 ```
1919
20+On Arch Linux, install [`vev-bin`](https://aur.archlinux.org/packages/vev-bin) from the AUR:
21+
22+```sh
23+yay -S vev-bin
24+# or
25+paru -S vev-bin
26+```
27+
2028 Alternatively, build and install the binary with Go:
2129
2230 ```sh
@@ -3,7 +3,7 @@
3 <p align="center"><em>Norwegian: to weave</em></p>3 <p align="center"><em>Norwegian: to weave</em></p>
4 4
5 <p align="center">A terminal multiplexer for Linux and macOS.<br>5 <p align="center">A terminal multiplexer for Linux and macOS.<br>
6-One Go binary, zero runtime dependencies, remote attach that survives bad networks.</p>6+Like tmux, minus the prefix key. Like mosh, minus mosh. One binary.</p>
7 7
8 <p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p>8 <p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p>
9 9
@@ -17,6 +17,14 @@ Install a pre-built release on Linux x86_64/arm64 or macOS Apple silicon (arm64)
17 curl -fsSL https://raw.githubusercontent.com/bnema/vev/main/install.sh | sh17 curl -fsSL https://raw.githubusercontent.com/bnema/vev/main/install.sh | sh
18 ```18 ```
19 19
20+On Arch Linux, install [`vev-bin`](https://aur.archlinux.org/packages/vev-bin) from the AUR:
21+
22+```sh
23+yay -S vev-bin
24+# or
25+paru -S vev-bin
26+```
27+
20 Alternatively, build and install the binary with Go:28 Alternatively, build and install the binary with Go:
21 29
22 ```sh30 ```sh
M docs/assets/demo.gif +0 -0 binary file: no text diff

binary file: no text diff

M internal/usecase/daemon/snatched_render.go +45 -11
@@ -30,7 +30,15 @@ func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string
3030 message = feedback
3131 }
3232 drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase)
33- drawCenteredLine(frame, inner, inner.Y+3, "r Resume here q / Esc Quit", styles.PickerSelection)
33+ drawCenteredSegments(frame, inner, inner.Y+3, []textSegment{
34+ {" r ", styles.HintKey},
35+ {" Resume here", styles.PickerBase},
36+ {" ", styles.PickerBase},
37+ {" q ", styles.HintKey},
38+ {" / ", styles.PickerDescription},
39+ {" Esc ", styles.HintKey},
40+ {" Quit", styles.PickerBase},
41+ })
3442 return frame
3543 }
3644
@@ -39,20 +47,46 @@ func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback s
3947 if feedback != "" {
4048 status = "Session unavailable"
4149 }
42- lines := []string{status, "r Resume", "q Quit"}
43- startY := max((frame.Height-len(lines))/2, 0)
44- for i, line := range lines {
45- drawCenteredLine(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, startY+i, line, styles.PickerBase)
46- }
50+ bounds := domain.Rect{Width: frame.Width, Height: frame.Height}
51+ startY := max((frame.Height-3)/2, 0)
52+ drawCenteredLine(frame, bounds, startY, status, styles.PickerBase)
53+ drawCenteredSegments(frame, bounds, startY+1, []textSegment{
54+ {" r ", styles.HintKey},
55+ {" Resume", styles.PickerBase},
56+ })
57+ drawCenteredSegments(frame, bounds, startY+2, []textSegment{
58+ {" q ", styles.HintKey},
59+ {" Quit", styles.PickerBase},
60+ })
61+}
62+
63+// textSegment is one differently-styled run within a centered hint line.
64+type textSegment struct {
65+ text string
66+ style renderer.Style
4767 }
4868
4969 func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) {
50- width := 0
51- for _, r := range text {
52- width += renderer.RuneWidth(r)
70+ drawCenteredSegments(frame, bounds, y, []textSegment{{text, style}})
71+}
72+
73+// drawCenteredSegments draws consecutive differently-styled segments as one
74+// logical line, centered as a whole within bounds.
75+func drawCenteredSegments(frame renderer.Frame, bounds domain.Rect, y int, segments []textSegment) {
76+ total := 0
77+ for _, seg := range segments {
78+ for _, r := range seg.text {
79+ total += renderer.RuneWidth(r)
80+ }
81+ }
82+ x := bounds.X + max((bounds.Width-total)/2, 0)
83+ limit := bounds.X + bounds.Width
84+ for _, seg := range segments {
85+ ui.DrawText(frame, x, y, limit, seg.text, seg.style)
86+ for _, r := range seg.text {
87+ x += renderer.RuneWidth(r)
88+ }
5389 }
54- x := bounds.X + max((bounds.Width-width)/2, 0)
55- ui.DrawText(frame, x, y, bounds.X+bounds.Width, text, style)
5690 }
5791
5892 var errSnatchedOutputStale = errors.New("snatched output role changed")
@@ -30,7 +30,15 @@ func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string
30 message = feedback30 message = feedback
31 }31 }
32 drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase)32 drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase)
33- drawCenteredLine(frame, inner, inner.Y+3, "r Resume here q / Esc Quit", styles.PickerSelection)33+ drawCenteredSegments(frame, inner, inner.Y+3, []textSegment{
34+ {" r ", styles.HintKey},
35+ {" Resume here", styles.PickerBase},
36+ {" ", styles.PickerBase},
37+ {" q ", styles.HintKey},
38+ {" / ", styles.PickerDescription},
39+ {" Esc ", styles.HintKey},
40+ {" Quit", styles.PickerBase},
41+ })
34 return frame42 return frame
35 }43 }
36 44
@@ -39,20 +47,46 @@ func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback s
39 if feedback != "" {47 if feedback != "" {
40 status = "Session unavailable"48 status = "Session unavailable"
41 }49 }
42- lines := []string{status, "r Resume", "q Quit"}50+ bounds := domain.Rect{Width: frame.Width, Height: frame.Height}
43- startY := max((frame.Height-len(lines))/2, 0)51+ startY := max((frame.Height-3)/2, 0)
44- for i, line := range lines {52+ drawCenteredLine(frame, bounds, startY, status, styles.PickerBase)
45- drawCenteredLine(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, startY+i, line, styles.PickerBase)53+ drawCenteredSegments(frame, bounds, startY+1, []textSegment{
46- }54+ {" r ", styles.HintKey},
55+ {" Resume", styles.PickerBase},
56+ })
57+ drawCenteredSegments(frame, bounds, startY+2, []textSegment{
58+ {" q ", styles.HintKey},
59+ {" Quit", styles.PickerBase},
60+ })
61+}
62+
63+// textSegment is one differently-styled run within a centered hint line.
64+type textSegment struct {
65+ text string
66+ style renderer.Style
47 }67 }
48 68
49 func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) {69 func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) {
50- width := 070+ drawCenteredSegments(frame, bounds, y, []textSegment{{text, style}})
51- for _, r := range text {71+}
52- width += renderer.RuneWidth(r)72+
73+// drawCenteredSegments draws consecutive differently-styled segments as one
74+// logical line, centered as a whole within bounds.
75+func drawCenteredSegments(frame renderer.Frame, bounds domain.Rect, y int, segments []textSegment) {
76+ total := 0
77+ for _, seg := range segments {
78+ for _, r := range seg.text {
79+ total += renderer.RuneWidth(r)
80+ }
81+ }
82+ x := bounds.X + max((bounds.Width-total)/2, 0)
83+ limit := bounds.X + bounds.Width
84+ for _, seg := range segments {
85+ ui.DrawText(frame, x, y, limit, seg.text, seg.style)
86+ for _, r := range seg.text {
87+ x += renderer.RuneWidth(r)
88+ }
53 }89 }
54- x := bounds.X + max((bounds.Width-width)/2, 0)
55- ui.DrawText(frame, x, y, bounds.X+bounds.Width, text, style)
56 }90 }
57 91
58 var errSnatchedOutputStale = errors.New("snatched output role changed")92 var errSnatchedOutputStale = errors.New("snatched output role changed")
M internal/usecase/daemon/snatched_render_test.go +39 -7
@@ -17,9 +17,11 @@ import (
1717
1818 func TestSnatchedPanelFrame(t *testing.T) {
1919 styles := themeui.Styles{
20- PickerBase: renderer.Style{Foreground: 1},
21- PickerSelection: renderer.Style{Foreground: 2},
22- BorderMuted: renderer.Style{Foreground: 3},
20+ PickerBase: renderer.Style{Foreground: 1},
21+ PickerSelection: renderer.Style{Foreground: 2},
22+ BorderMuted: renderer.Style{Foreground: 3},
23+ HintKey: renderer.Style{Foreground: 4},
24+ PickerDescription: renderer.Style{Foreground: 5},
2325 }
2426 tests := []struct {
2527 name string
@@ -27,6 +29,8 @@ func TestSnatchedPanelFrame(t *testing.T) {
2729 feedback string
2830 wantSize domain.Size
2931 want []string
32+ wantKeys string
33+ wantSep string
3034 wantBounds domain.Rect
3135 compact bool
3236 }{
@@ -34,14 +38,17 @@ func TestSnatchedPanelFrame(t *testing.T) {
3438 name: "centered bordered panel",
3539 size: domain.Size{Cols: 80, Rows: 24},
3640 wantSize: domain.Size{Cols: 80, Rows: 24},
37- want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},
41+ want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},
42+ wantKeys: " r q Esc ",
43+ wantSep: " / ",
3844 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
3945 },
4046 {
4147 name: "compact fallback",
4248 size: domain.Size{Cols: 24, Rows: 4},
4349 wantSize: domain.Size{Cols: 24, Rows: 4},
44- want: []string{"Session snatched", "r Resume", "q Quit"},
50+ want: []string{"Session snatched", "r Resume", "q Quit"},
51+ wantKeys: " r q ",
4552 compact: true,
4653 },
4754 {
@@ -49,7 +56,8 @@ func TestSnatchedPanelFrame(t *testing.T) {
4956 size: domain.Size{Cols: 24, Rows: 4},
5057 feedback: "Session is no longer available.",
5158 wantSize: domain.Size{Cols: 24, Rows: 4},
52- want: []string{"Session unavailable", "r Resume", "q Quit"},
59+ want: []string{"Session unavailable", "r Resume", "q Quit"},
60+ wantKeys: " r q ",
5361 compact: true,
5462 },
5563 {
@@ -57,7 +65,9 @@ func TestSnatchedPanelFrame(t *testing.T) {
5765 size: domain.Size{Cols: 80, Rows: 24},
5866 feedback: "Session is no longer available.",
5967 wantSize: domain.Size{Cols: 80, Rows: 24},
60- want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},
68+ want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},
69+ wantKeys: " r q Esc ",
70+ wantSep: " / ",
6171 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
6272 },
6373 {
@@ -85,6 +95,8 @@ func TestSnatchedPanelFrame(t *testing.T) {
8595 for _, want := range tt.want {
8696 require.Contains(t, text, want)
8797 }
98+ require.Equal(t, tt.wantKeys, snatchedStyledText(frame, styles.HintKey), "keycap badges must use the HintKey style")
99+ require.Equal(t, tt.wantSep, snatchedStyledText(frame, styles.PickerDescription), "hint separator must use the muted description style")
88100
89101 if tt.compact {
90102 require.NotContains(t, text, "┌")
@@ -237,6 +249,26 @@ func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel do
237249 }
238250 }
239251
252+// snatchedStyledText concatenates, in reading order, the runes of every cell
253+// drawn with exactly the given style, so tests can assert which text runs a
254+// style covers without depending on layout coordinates.
255+func snatchedStyledText(frame renderer.Frame, style renderer.Style) string {
256+ var out strings.Builder
257+ for y := 0; y < frame.Height; y++ {
258+ for _, cell := range frame.Row(y) {
259+ if cell.Continuation || !cell.Style.Equal(style) {
260+ continue
261+ }
262+ r := cell.Rune
263+ if r == 0 {
264+ r = ' '
265+ }
266+ out.WriteRune(r)
267+ }
268+ }
269+ return out.String()
270+}
271+
240272 func snatchedFrameText(frame renderer.Frame) string {
241273 var out strings.Builder
242274 for y := 0; y < frame.Height; y++ {
@@ -17,9 +17,11 @@ import (
17 17
18 func TestSnatchedPanelFrame(t *testing.T) {18 func TestSnatchedPanelFrame(t *testing.T) {
19 styles := themeui.Styles{19 styles := themeui.Styles{
20- PickerBase: renderer.Style{Foreground: 1},20+ PickerBase: renderer.Style{Foreground: 1},
21- PickerSelection: renderer.Style{Foreground: 2},21+ PickerSelection: renderer.Style{Foreground: 2},
22- BorderMuted: renderer.Style{Foreground: 3},22+ BorderMuted: renderer.Style{Foreground: 3},
23+ HintKey: renderer.Style{Foreground: 4},
24+ PickerDescription: renderer.Style{Foreground: 5},
23 }25 }
24 tests := []struct {26 tests := []struct {
25 name string27 name string
@@ -27,6 +29,8 @@ func TestSnatchedPanelFrame(t *testing.T) {
27 feedback string29 feedback string
28 wantSize domain.Size30 wantSize domain.Size
29 want []string31 want []string
32+ wantKeys string
33+ wantSep string
30 wantBounds domain.Rect34 wantBounds domain.Rect
31 compact bool35 compact bool
32 }{36 }{
@@ -34,14 +38,17 @@ func TestSnatchedPanelFrame(t *testing.T) {
34 name: "centered bordered panel",38 name: "centered bordered panel",
35 size: domain.Size{Cols: 80, Rows: 24},39 size: domain.Size{Cols: 80, Rows: 24},
36 wantSize: domain.Size{Cols: 80, Rows: 24},40 wantSize: domain.Size{Cols: 80, Rows: 24},
37- want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},41+ want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},
42+ wantKeys: " r q Esc ",
43+ wantSep: " / ",
38 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},44 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
39 },45 },
40 {46 {
41 name: "compact fallback",47 name: "compact fallback",
42 size: domain.Size{Cols: 24, Rows: 4},48 size: domain.Size{Cols: 24, Rows: 4},
43 wantSize: domain.Size{Cols: 24, Rows: 4},49 wantSize: domain.Size{Cols: 24, Rows: 4},
44- want: []string{"Session snatched", "r Resume", "q Quit"},50+ want: []string{"Session snatched", "r Resume", "q Quit"},
51+ wantKeys: " r q ",
45 compact: true,52 compact: true,
46 },53 },
47 {54 {
@@ -49,7 +56,8 @@ func TestSnatchedPanelFrame(t *testing.T) {
49 size: domain.Size{Cols: 24, Rows: 4},56 size: domain.Size{Cols: 24, Rows: 4},
50 feedback: "Session is no longer available.",57 feedback: "Session is no longer available.",
51 wantSize: domain.Size{Cols: 24, Rows: 4},58 wantSize: domain.Size{Cols: 24, Rows: 4},
52- want: []string{"Session unavailable", "r Resume", "q Quit"},59+ want: []string{"Session unavailable", "r Resume", "q Quit"},
60+ wantKeys: " r q ",
53 compact: true,61 compact: true,
54 },62 },
55 {63 {
@@ -57,7 +65,9 @@ func TestSnatchedPanelFrame(t *testing.T) {
57 size: domain.Size{Cols: 80, Rows: 24},65 size: domain.Size{Cols: 80, Rows: 24},
58 feedback: "Session is no longer available.",66 feedback: "Session is no longer available.",
59 wantSize: domain.Size{Cols: 80, Rows: 24},67 wantSize: domain.Size{Cols: 80, Rows: 24},
60- want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},68+ want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},
69+ wantKeys: " r q Esc ",
70+ wantSep: " / ",
61 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},71 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
62 },72 },
63 {73 {
@@ -85,6 +95,8 @@ func TestSnatchedPanelFrame(t *testing.T) {
85 for _, want := range tt.want {95 for _, want := range tt.want {
86 require.Contains(t, text, want)96 require.Contains(t, text, want)
87 }97 }
98+ require.Equal(t, tt.wantKeys, snatchedStyledText(frame, styles.HintKey), "keycap badges must use the HintKey style")
99+ require.Equal(t, tt.wantSep, snatchedStyledText(frame, styles.PickerDescription), "hint separator must use the muted description style")
88 100
89 if tt.compact {101 if tt.compact {
90 require.NotContains(t, text, "┌")102 require.NotContains(t, text, "┌")
@@ -237,6 +249,26 @@ func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel do
237 }249 }
238 }250 }
239 251
252+// snatchedStyledText concatenates, in reading order, the runes of every cell
253+// drawn with exactly the given style, so tests can assert which text runs a
254+// style covers without depending on layout coordinates.
255+func snatchedStyledText(frame renderer.Frame, style renderer.Style) string {
256+ var out strings.Builder
257+ for y := 0; y < frame.Height; y++ {
258+ for _, cell := range frame.Row(y) {
259+ if cell.Continuation || !cell.Style.Equal(style) {
260+ continue
261+ }
262+ r := cell.Rune
263+ if r == 0 {
264+ r = ' '
265+ }
266+ out.WriteRune(r)
267+ }
268+ }
269+ return out.String()
270+}
271+
240 func snatchedFrameText(frame renderer.Frame) string {272 func snatchedFrameText(frame renderer.Frame) string {
241 var out strings.Builder273 var out strings.Builder
242 for y := 0; y < frame.Height; y++ {274 for y := 0; y < frame.Height; y++ {
M internal/usecase/theme/resolved_styles.go +2 -0
@@ -55,6 +55,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
5555
5656 PickerBase: renderer.DefaultStyle(),
5757 PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),
58+ HintKey: EmphasisStyle(ramp.SurfaceActive, t),
5859 PickerDescription: overlayDescription,
5960 // Separators are secondary text on the terminal background, not
6061 // non-text borders; they therefore require the normal 4.5:1 contrast.
@@ -148,6 +149,7 @@ func neutralStyles(t Theme) Styles {
148149
149150 PickerBase: renderer.DefaultStyle(),
150151 PickerSelection: EmphasisStyle(accent, t),
152+ HintKey: EmphasisStyle(accent, t),
151153 PickerDescription: overlayDescription,
152154 PickerSeparator: overlayDescription,
153155 PromptBase: renderer.DefaultStyle(),
@@ -55,6 +55,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
55 55
56 PickerBase: renderer.DefaultStyle(),56 PickerBase: renderer.DefaultStyle(),
57 PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),57 PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),
58+ HintKey: EmphasisStyle(ramp.SurfaceActive, t),
58 PickerDescription: overlayDescription,59 PickerDescription: overlayDescription,
59 // Separators are secondary text on the terminal background, not60 // Separators are secondary text on the terminal background, not
60 // non-text borders; they therefore require the normal 4.5:1 contrast.61 // non-text borders; they therefore require the normal 4.5:1 contrast.
@@ -148,6 +149,7 @@ func neutralStyles(t Theme) Styles {
148 149
149 PickerBase: renderer.DefaultStyle(),150 PickerBase: renderer.DefaultStyle(),
150 PickerSelection: EmphasisStyle(accent, t),151 PickerSelection: EmphasisStyle(accent, t),
152+ HintKey: EmphasisStyle(accent, t),
151 PickerDescription: overlayDescription,153 PickerDescription: overlayDescription,
152 PickerSeparator: overlayDescription,154 PickerSeparator: overlayDescription,
153 PromptBase: renderer.DefaultStyle(),155 PromptBase: renderer.DefaultStyle(),
M internal/usecase/theme/styles.go +5 -2
@@ -89,8 +89,11 @@ type Styles struct {
8989 TabActiveTitle renderer.Style
9090 MRURecent renderer.Style
9191
92- PickerBase renderer.Style
93- PickerSelection renderer.Style
92+ PickerBase renderer.Style
93+ PickerSelection renderer.Style
94+ // HintKey is the keycap badge style for key hints: an accent background
95+ // over " key " padding, distinct from PickerSelection's full-line emphasis.
96+ HintKey renderer.Style
9497 PickerDescription renderer.Style
9598 PickerSeparator renderer.Style
9699 PromptBase renderer.Style
@@ -89,8 +89,11 @@ type Styles struct {
89 TabActiveTitle renderer.Style89 TabActiveTitle renderer.Style
90 MRURecent renderer.Style90 MRURecent renderer.Style
91 91
92- PickerBase renderer.Style92+ PickerBase renderer.Style
93- PickerSelection renderer.Style93+ PickerSelection renderer.Style
94+ // HintKey is the keycap badge style for key hints: an accent background
95+ // over " key " padding, distinct from PickerSelection's full-line emphasis.
96+ HintKey renderer.Style
94 PickerDescription renderer.Style97 PickerDescription renderer.Style
95 PickerSeparator renderer.Style98 PickerSeparator renderer.Style
96 PromptBase renderer.Style99 PromptBase renderer.Style

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 12

Tags 11

↑↓ moveenter openesc close