detached 50c5c14

feat(ui): render snatched-modal key hints as keycap badges

Replace the fully accent-highlighted hint line with per-key badges: the key gets an accent background over ' key ' padding via the new theme style HintKey, labels stay plain, and the q/Esc pair renders as two badges with a muted slash. Applies to both the bordered modal and the compact fallback.
commit
50c5c14f2044dcc4e856ae87e94397223263a1cc
author
brice · · 2026-07-29 13:50 UTC
parent
fc46d25
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +56 -17
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 +4 -4
@@ -34,14 +34,14 @@ func TestSnatchedPanelFrame(t *testing.T) {
3434 name: "centered bordered panel",
3535 size: domain.Size{Cols: 80, Rows: 24},
3636 wantSize: domain.Size{Cols: 80, Rows: 24},
37- want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},
37+ want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},
3838 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
3939 },
4040 {
4141 name: "compact fallback",
4242 size: domain.Size{Cols: 24, Rows: 4},
4343 wantSize: domain.Size{Cols: 24, Rows: 4},
44- want: []string{"Session snatched", "r Resume", "q Quit"},
44+ want: []string{"Session snatched", "r Resume", "q Quit"},
4545 compact: true,
4646 },
4747 {
@@ -49,7 +49,7 @@ func TestSnatchedPanelFrame(t *testing.T) {
4949 size: domain.Size{Cols: 24, Rows: 4},
5050 feedback: "Session is no longer available.",
5151 wantSize: domain.Size{Cols: 24, Rows: 4},
52- want: []string{"Session unavailable", "r Resume", "q Quit"},
52+ want: []string{"Session unavailable", "r Resume", "q Quit"},
5353 compact: true,
5454 },
5555 {
@@ -57,7 +57,7 @@ func TestSnatchedPanelFrame(t *testing.T) {
5757 size: domain.Size{Cols: 80, Rows: 24},
5858 feedback: "Session is no longer available.",
5959 wantSize: domain.Size{Cols: 80, Rows: 24},
60- want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},
60+ want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},
6161 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
6262 },
6363 {
@@ -34,14 +34,14 @@ func TestSnatchedPanelFrame(t *testing.T) {
34 name: "centered bordered panel",34 name: "centered bordered panel",
35 size: domain.Size{Cols: 80, Rows: 24},35 size: domain.Size{Cols: 80, Rows: 24},
36 wantSize: domain.Size{Cols: 80, Rows: 24},36 wantSize: domain.Size{Cols: 80, Rows: 24},
37- want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},37+ want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},
38 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},38 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
39 },39 },
40 {40 {
41 name: "compact fallback",41 name: "compact fallback",
42 size: domain.Size{Cols: 24, Rows: 4},42 size: domain.Size{Cols: 24, Rows: 4},
43 wantSize: domain.Size{Cols: 24, Rows: 4},43 wantSize: domain.Size{Cols: 24, Rows: 4},
44- want: []string{"Session snatched", "r Resume", "q Quit"},44+ want: []string{"Session snatched", "r Resume", "q Quit"},
45 compact: true,45 compact: true,
46 },46 },
47 {47 {
@@ -49,7 +49,7 @@ func TestSnatchedPanelFrame(t *testing.T) {
49 size: domain.Size{Cols: 24, Rows: 4},49 size: domain.Size{Cols: 24, Rows: 4},
50 feedback: "Session is no longer available.",50 feedback: "Session is no longer available.",
51 wantSize: domain.Size{Cols: 24, Rows: 4},51 wantSize: domain.Size{Cols: 24, Rows: 4},
52- want: []string{"Session unavailable", "r Resume", "q Quit"},52+ want: []string{"Session unavailable", "r Resume", "q Quit"},
53 compact: true,53 compact: true,
54 },54 },
55 {55 {
@@ -57,7 +57,7 @@ func TestSnatchedPanelFrame(t *testing.T) {
57 size: domain.Size{Cols: 80, Rows: 24},57 size: domain.Size{Cols: 80, Rows: 24},
58 feedback: "Session is no longer available.",58 feedback: "Session is no longer available.",
59 wantSize: domain.Size{Cols: 80, Rows: 24},59 wantSize: domain.Size{Cols: 80, Rows: 24},
60- want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},60+ want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},
61 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},61 wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
62 },62 },
63 {63 {
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