detached 7bde0f8

fix(tui): preserve modal backdrop dimming

commit
7bde0f8a3ecdfd646cab9b5acb79af70128d65db
author
brice · · 2026-08-06 06:54 UTC
parent
06ae582
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +34 -11
M packages/agent/modes/session_title_test.go +3 -2
@@ -239,11 +239,12 @@ func TestFreshBranchTitleIgnoresCopiedPrefix(t *testing.T) {
239239
240240 i.Submit("new branch work")
241241 waitInteractiveIdle(t, i)
242-
242+ // The hidden title request is deliberately asynchronous, so wait for its
243+ // visible result rather than assuming it finishes before the main turn.
244+ waitInteractiveTitle(t, i, "new branch title")
243245 if got := client.requestCount(); got != 2 {
244246 t.Fatalf("provider request count = %d, want main + hidden title", got)
245247 }
246- waitInteractiveTitle(t, i, "new branch title")
247248 i.mu.Lock()
248249 seen := i.titleRealPromptSeen
249250 i.mu.Unlock()
@@ -239,11 +239,12 @@ func TestFreshBranchTitleIgnoresCopiedPrefix(t *testing.T) {
239 239
240 i.Submit("new branch work")240 i.Submit("new branch work")
241 waitInteractiveIdle(t, i)241 waitInteractiveIdle(t, i)
242-242+ // The hidden title request is deliberately asynchronous, so wait for its
243+ // visible result rather than assuming it finishes before the main turn.
244+ waitInteractiveTitle(t, i, "new branch title")
243 if got := client.requestCount(); got != 2 {245 if got := client.requestCount(); got != 2 {
244 t.Fatalf("provider request count = %d, want main + hidden title", got)246 t.Fatalf("provider request count = %d, want main + hidden title", got)
245 }247 }
246- waitInteractiveTitle(t, i, "new branch title")
247 i.mu.Lock()248 i.mu.Lock()
248 seen := i.titleRealPromptSeen249 seen := i.titleRealPromptSeen
249 i.mu.Unlock()250 i.mu.Unlock()
M packages/tui/layer_test.go +15 -2
@@ -13,8 +13,8 @@ func TestDimLinesPreservesDimmingAcrossStyledSegments(t *testing.T) {
1313
1414 got := DimLines(lines)
1515 want := []string{
16- "\x1b[2mplain\x1b[0m",
17- "\x1b[2m\x1b[38;5;111maccent\x1b[0m\x1b[2m\x1b[38;5;203merror\x1b[0m\x1b[2m\x1b[0m",
16+ "\x1b[2mplain\x1b[22m",
17+ "\x1b[2m\x1b[38;5;111maccent\x1b[0m\x1b[2m\x1b[38;5;203merror\x1b[0m\x1b[2m\x1b[22m",
1818 }
1919 if !reflect.DeepEqual(got, want) {
2020 t.Fatalf("DimLines() = %#v, want %#v", got, want)
@@ -27,6 +27,19 @@ func TestDimLinesPreservesDimmingAcrossStyledSegments(t *testing.T) {
2727 }
2828 }
2929
30+func TestDimRetainsFaintAfterBold(t *testing.T) {
31+ const (
32+ dim = "\x1b[2m"
33+ off = "\x1b[22m"
34+ )
35+ if got, want := Dim("before "+Bold("bold")+" after"), dim+"before \x1b[1mbold"+off+dim+" after"+off; got != want {
36+ t.Fatalf("Dim() = %q, want %q", got, want)
37+ }
38+ if got, want := "\x1b[31m"+Dim("faint")+" tail", "\x1b[31m"+dim+"faint"+off+" tail"; got != want {
39+ t.Fatalf("Dim() reset its caller's color: %q, want %q", got, want)
40+ }
41+}
42+
3043 func TestCursorColorUsesTerminalColor(t *testing.T) {
3144 if got, want := CursorColor(ColorRGB(1, 2, 3)), "\x1b]12;rgb:01/02/03\x07"; got != want {
3245 t.Fatalf("CursorColor() = %q, want %q", got, want)
@@ -13,8 +13,8 @@ func TestDimLinesPreservesDimmingAcrossStyledSegments(t *testing.T) {
13 13
14 got := DimLines(lines)14 got := DimLines(lines)
15 want := []string{15 want := []string{
16- "\x1b[2mplain\x1b[0m",16+ "\x1b[2mplain\x1b[22m",
17- "\x1b[2m\x1b[38;5;111maccent\x1b[0m\x1b[2m\x1b[38;5;203merror\x1b[0m\x1b[2m\x1b[0m",17+ "\x1b[2m\x1b[38;5;111maccent\x1b[0m\x1b[2m\x1b[38;5;203merror\x1b[0m\x1b[2m\x1b[22m",
18 }18 }
19 if !reflect.DeepEqual(got, want) {19 if !reflect.DeepEqual(got, want) {
20 t.Fatalf("DimLines() = %#v, want %#v", got, want)20 t.Fatalf("DimLines() = %#v, want %#v", got, want)
@@ -27,6 +27,19 @@ func TestDimLinesPreservesDimmingAcrossStyledSegments(t *testing.T) {
27 }27 }
28 }28 }
29 29
30+func TestDimRetainsFaintAfterBold(t *testing.T) {
31+ const (
32+ dim = "\x1b[2m"
33+ off = "\x1b[22m"
34+ )
35+ if got, want := Dim("before "+Bold("bold")+" after"), dim+"before \x1b[1mbold"+off+dim+" after"+off; got != want {
36+ t.Fatalf("Dim() = %q, want %q", got, want)
37+ }
38+ if got, want := "\x1b[31m"+Dim("faint")+" tail", "\x1b[31m"+dim+"faint"+off+" tail"; got != want {
39+ t.Fatalf("Dim() reset its caller's color: %q, want %q", got, want)
40+ }
41+}
42+
30 func TestCursorColorUsesTerminalColor(t *testing.T) {43 func TestCursorColorUsesTerminalColor(t *testing.T) {
31 if got, want := CursorColor(ColorRGB(1, 2, 3)), "\x1b]12;rgb:01/02/03\x07"; got != want {44 if got, want := CursorColor(ColorRGB(1, 2, 3)), "\x1b]12;rgb:01/02/03\x07"; got != want {
32 t.Fatalf("CursorColor() = %q, want %q", got, want)45 t.Fatalf("CursorColor() = %q, want %q", got, want)
M packages/tui/theme.go +16 -7
@@ -405,14 +405,25 @@ func (t Theme) UserBubbleRow(content string, width int) string {
405405 return bar + t.UserBubble(content, bubbleW)
406406 }
407407
408+const (
409+ reset = "\x1b[0m"
410+ dim = "\x1b[2m"
411+ normalIntensity = "\x1b[22m"
412+)
413+
414+var dimStyleReplacer = strings.NewReplacer(
415+ reset, reset+dim,
416+ normalIntensity, normalIntensity+dim,
417+)
418+
408419 // Bold wraps s in bold SGR.
409-func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
420+func Bold(s string) string { return "\x1b[1m" + s + normalIntensity }
410421
411-// Dim wraps s in dim SGR. Re-apply dim after each full SGR reset so
412-// independently styled segments remain dimmed too.
422+// Dim wraps s in dim SGR. Re-apply dim after sequences that clear faint so
423+// independently styled segments remain dimmed too. Like Bold and Italic, it
424+// restores only its own attribute so callers can safely compose styles.
413425 func Dim(s string) string {
414- const dim = "\x1b[2m"
415- return dim + strings.ReplaceAll(s, reset, reset+dim) + reset
426+ return dim + dimStyleReplacer.Replace(s) + normalIntensity
416427 }
417428
418429 // DimLines returns a dimmed copy of lines. It is suitable for content behind
@@ -429,8 +440,6 @@ func DimLines(lines []string) []string {
429440 // Italic wraps s in italic SGR.
430441 func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
431442
432-const reset = "\x1b[0m"
433-
434443 func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }
435444 func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
436445 func sgrBGColor(c TerminalColor) string {
@@ -405,14 +405,25 @@ func (t Theme) UserBubbleRow(content string, width int) string {
405 return bar + t.UserBubble(content, bubbleW)405 return bar + t.UserBubble(content, bubbleW)
406 }406 }
407 407
408+const (
409+ reset = "\x1b[0m"
410+ dim = "\x1b[2m"
411+ normalIntensity = "\x1b[22m"
412+)
413+
414+var dimStyleReplacer = strings.NewReplacer(
415+ reset, reset+dim,
416+ normalIntensity, normalIntensity+dim,
417+)
418+
408 // Bold wraps s in bold SGR.419 // Bold wraps s in bold SGR.
409-func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }420+func Bold(s string) string { return "\x1b[1m" + s + normalIntensity }
410 421
411-// Dim wraps s in dim SGR. Re-apply dim after each full SGR reset so422+// Dim wraps s in dim SGR. Re-apply dim after sequences that clear faint so
412-// independently styled segments remain dimmed too.423+// independently styled segments remain dimmed too. Like Bold and Italic, it
424+// restores only its own attribute so callers can safely compose styles.
413 func Dim(s string) string {425 func Dim(s string) string {
414- const dim = "\x1b[2m"426+ return dim + dimStyleReplacer.Replace(s) + normalIntensity
415- return dim + strings.ReplaceAll(s, reset, reset+dim) + reset
416 }427 }
417 428
418 // DimLines returns a dimmed copy of lines. It is suitable for content behind429 // DimLines returns a dimmed copy of lines. It is suitable for content behind
@@ -429,8 +440,6 @@ func DimLines(lines []string) []string {
429 // Italic wraps s in italic SGR.440 // Italic wraps s in italic SGR.
430 func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }441 func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
431 442
432-const reset = "\x1b[0m"
433-
434 func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }443 func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }
435 func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }444 func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
436 func sgrBGColor(c TerminalColor) string {445 func sgrBGColor(c TerminalColor) string {

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