fix(tui): preserve modal backdrop dimming
- commit
- 7bde0f8a3ecdfd646cab9b5acb79af70128d65db
- author
- brice · · 2026-08-06 06:54 UTC
- parent
- 06ae582
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 239 | 239 | ||
| 240 | 240 | i.Submit("new branch work") | |
| 241 | 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 | 245 | if got := client.requestCount(); got != 2 { | |
| 244 | 246 | t.Fatalf("provider request count = %d, want main + hidden title", got) | |
| 245 | 247 | } | |
| 246 | - | waitInteractiveTitle(t, i, "new branch title") | |
| 247 | 248 | i.mu.Lock() | |
| 248 | 249 | seen := i.titleRealPromptSeen | |
| 249 | 250 | 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.titleRealPromptSeen | 249 | 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) { | |||
| 13 | 13 | ||
| 14 | 14 | got := DimLines(lines) | |
| 15 | 15 | 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", | |
| 18 | 18 | } | |
| 19 | 19 | if !reflect.DeepEqual(got, want) { | |
| 20 | 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 | 43 | func TestCursorColorUsesTerminalColor(t *testing.T) { | |
| 31 | 44 | if got, want := CursorColor(ColorRGB(1, 2, 3)), "\x1b]12;rgb:01/02/03\x07"; got != want { | |
| 32 | 45 | 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 { | |||
| 405 | 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 | 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 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. | |
| 413 | 425 | 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 | |
| 416 | 427 | } | |
| 417 | 428 | ||
| 418 | 429 | // DimLines returns a dimmed copy of lines. It is suitable for content behind | |
| @@ -429,8 +440,6 @@ func DimLines(lines []string) []string { | |||
| 429 | 440 | // Italic wraps s in italic SGR. | |
| 430 | 441 | func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" } | |
| 431 | 442 | ||
| 432 | - | const reset = "\x1b[0m" | |
| 433 | - | ||
| 434 | 443 | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" } | |
| 435 | 444 | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" } | |
| 436 | 445 | 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 so | 422 | + | // 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 behind | 429 | // 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 { | ||