detached 42a5079

Merge pull request #39 from bnema/feat/modal-backdrop-dimming

Dim TUI backdrops beneath dialogs
commit
42a5079c3503cbcf81fe47583d292d268db152e9
author
bnema · · 2026-08-06 06:55 UTC
committer
GitHub · · 2026-08-06 06:55 UTC
merge of
692ebcf + 7bde0f8 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.0
8 files · +217 -15
M packages/agent/modes/interactive.go +44 -2
@@ -545,6 +545,7 @@ type Interactive struct {
545545 prevChatCols int
546546 prevChatRows int
547547 prevOverlayOpen bool
548+ cursorDimmed bool
548549
549550 // chatCache stores the built transcript/status-note rows for idle
550551 // frames. Editor typing changes only the bottom input region, so
@@ -1687,6 +1688,7 @@ func (i *Interactive) redraw() {
16871688 if len(dialog) > 0 {
16881689 dialog = padDialogFrame(dialog)
16891690 }
1691+ modalBackdrop := len(dialog) > 0
16901692
16911693 // Slash-command autocomplete: popup above the status line, only
16921694 // when the editor starts with "/" and no dialog is already open.
@@ -2067,6 +2069,17 @@ func (i *Interactive) redraw() {
20672069 }
20682070 }
20692071
2072+ // A dialog is the foreground layer. Dim every other visible surface,
2073+ // including the main input beneath it, without changing the dialog's
2074+ // own styling. Keep the source slices untouched because chat is cached.
2075+ if modalBackdrop {
2076+ chat = tui.DimLines(chat)
2077+ visibleChat = tui.DimLines(visibleChat)
2078+ dimmedBottom := tui.DimLines(bottom)
2079+ copy(dimmedBottom[1:1+len(dialog)], dialog)
2080+ bottom = dimmedBottom
2081+ }
2082+
20702083 // Default: the real terminal cursor sits on the main editor's
20712084 // input position. In main-screen log mode cursor rows are relative
20722085 // to the fixed bottom band, not the chat transcript.
@@ -2079,22 +2092,26 @@ func (i *Interactive) redraw() {
20792092 }
20802093 cursorRow := inputStartRow + inputCursorOffset + curR
20812094 cursorCol := curC + inputCursorColOffset
2095+ cursorInDialog := false
20822096 if i.btwDialog.Active() {
20832097 if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
20842098 cursorRow = dialogLead + r
20852099 cursorCol = c
2100+ cursorInDialog = true
20862101 }
20872102 }
20882103 if i.dialog.Active() {
20892104 if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
20902105 cursorRow = dialogLead + r
20912106 cursorCol = c
2107+ cursorInDialog = true
20922108 }
20932109 }
20942110 if i.llamaDialog.Active() {
20952111 if r, c := i.llamaDialog.CursorPos(); r >= 0 {
20962112 cursorRow = dialogLead + r
20972113 cursorCol = c
2114+ cursorInDialog = true
20982115 } else {
20992116 cursorRow = -1
21002117 cursorCol = 0
@@ -2104,18 +2121,21 @@ func (i *Interactive) redraw() {
21042121 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
21052122 cursorRow = dialogLead + r
21062123 cursorCol = c
2124+ cursorInDialog = true
21072125 }
21082126 }
21092127 if i.sessionTreeDialog.Active() {
21102128 if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
21112129 cursorRow = dialogLead + r
21122130 cursorCol = c
2131+ cursorInDialog = true
21132132 }
21142133 }
21152134 if i.subagentsDialog.Active() {
21162135 if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 {
21172136 cursorRow = dialogLead + r
21182137 cursorCol = c
2138+ cursorInDialog = true
21192139 } else {
21202140 // Dashboard list / transcript view has no caret. Without
21212141 // this branch the default cursorRow points at the
@@ -2129,9 +2149,14 @@ func (i *Interactive) redraw() {
21292149 cursorRow = -1
21302150 cursorCol = 0
21312151 }
2152+ i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
21322153 if rightBarActive {
21332154 rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)
2134- i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
2155+ if modalBackdrop {
2156+ i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
2157+ } else {
2158+ i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
2159+ }
21352160 } else {
21362161 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
21372162 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
@@ -3951,11 +3976,28 @@ func onOff(v bool) string {
39513976 return "disabled"
39523977 }
39533978
3979+const modalBackdropDimPercent = 50
3980+
39543981 func (i *Interactive) applyInputCursorColor() {
39553982 if i == nil || i.cfg.Terminal == nil {
39563983 return
39573984 }
3958- _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
3985+ color := tui.Color256(15)
3986+ if i.cursorDimmed {
3987+ color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)
3988+ }
3989+ _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock()))
3990+}
3991+
3992+// setInputCursorDimmed updates the terminal-owned cursor only when its layer
3993+// changes. Reapplying cursor controls on every redraw can reset its blink
3994+// timing before the terminal completes a normal blink cycle.
3995+func (i *Interactive) setInputCursorDimmed(dimmed bool) {
3996+ if i == nil || i.cursorDimmed == dimmed {
3997+ return
3998+ }
3999+ i.cursorDimmed = dimmed
4000+ i.applyInputCursorColor()
39594001 }
39604002
39614003 func reasoningSettingOptions(levels []string) []settingsOption {
@@ -545,6 +545,7 @@ type Interactive struct {
545 prevChatCols int545 prevChatCols int
546 prevChatRows int546 prevChatRows int
547 prevOverlayOpen bool547 prevOverlayOpen bool
548+ cursorDimmed bool
548 549
549 // chatCache stores the built transcript/status-note rows for idle550 // chatCache stores the built transcript/status-note rows for idle
550 // frames. Editor typing changes only the bottom input region, so551 // frames. Editor typing changes only the bottom input region, so
@@ -1687,6 +1688,7 @@ func (i *Interactive) redraw() {
1687 if len(dialog) > 0 {1688 if len(dialog) > 0 {
1688 dialog = padDialogFrame(dialog)1689 dialog = padDialogFrame(dialog)
1689 }1690 }
1691+ modalBackdrop := len(dialog) > 0
1690 1692
1691 // Slash-command autocomplete: popup above the status line, only1693 // Slash-command autocomplete: popup above the status line, only
1692 // when the editor starts with "/" and no dialog is already open.1694 // when the editor starts with "/" and no dialog is already open.
@@ -2067,6 +2069,17 @@ func (i *Interactive) redraw() {
2067 }2069 }
2068 }2070 }
2069 2071
2072+ // A dialog is the foreground layer. Dim every other visible surface,
2073+ // including the main input beneath it, without changing the dialog's
2074+ // own styling. Keep the source slices untouched because chat is cached.
2075+ if modalBackdrop {
2076+ chat = tui.DimLines(chat)
2077+ visibleChat = tui.DimLines(visibleChat)
2078+ dimmedBottom := tui.DimLines(bottom)
2079+ copy(dimmedBottom[1:1+len(dialog)], dialog)
2080+ bottom = dimmedBottom
2081+ }
2082+
2070 // Default: the real terminal cursor sits on the main editor's2083 // Default: the real terminal cursor sits on the main editor's
2071 // input position. In main-screen log mode cursor rows are relative2084 // input position. In main-screen log mode cursor rows are relative
2072 // to the fixed bottom band, not the chat transcript.2085 // to the fixed bottom band, not the chat transcript.
@@ -2079,22 +2092,26 @@ func (i *Interactive) redraw() {
2079 }2092 }
2080 cursorRow := inputStartRow + inputCursorOffset + curR2093 cursorRow := inputStartRow + inputCursorOffset + curR
2081 cursorCol := curC + inputCursorColOffset2094 cursorCol := curC + inputCursorColOffset
2095+ cursorInDialog := false
2082 if i.btwDialog.Active() {2096 if i.btwDialog.Active() {
2083 if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {2097 if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
2084 cursorRow = dialogLead + r2098 cursorRow = dialogLead + r
2085 cursorCol = c2099 cursorCol = c
2100+ cursorInDialog = true
2086 }2101 }
2087 }2102 }
2088 if i.dialog.Active() {2103 if i.dialog.Active() {
2089 if r, c := i.dialog.CursorPos(mainCols); r >= 0 {2104 if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
2090 cursorRow = dialogLead + r2105 cursorRow = dialogLead + r
2091 cursorCol = c2106 cursorCol = c
2107+ cursorInDialog = true
2092 }2108 }
2093 }2109 }
2094 if i.llamaDialog.Active() {2110 if i.llamaDialog.Active() {
2095 if r, c := i.llamaDialog.CursorPos(); r >= 0 {2111 if r, c := i.llamaDialog.CursorPos(); r >= 0 {
2096 cursorRow = dialogLead + r2112 cursorRow = dialogLead + r
2097 cursorCol = c2113 cursorCol = c
2114+ cursorInDialog = true
2098 } else {2115 } else {
2099 cursorRow = -12116 cursorRow = -1
2100 cursorCol = 02117 cursorCol = 0
@@ -2104,18 +2121,21 @@ func (i *Interactive) redraw() {
2104 if r, c := i.sessionDialog.CursorPos(); r >= 0 {2121 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
2105 cursorRow = dialogLead + r2122 cursorRow = dialogLead + r
2106 cursorCol = c2123 cursorCol = c
2124+ cursorInDialog = true
2107 }2125 }
2108 }2126 }
2109 if i.sessionTreeDialog.Active() {2127 if i.sessionTreeDialog.Active() {
2110 if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {2128 if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
2111 cursorRow = dialogLead + r2129 cursorRow = dialogLead + r
2112 cursorCol = c2130 cursorCol = c
2131+ cursorInDialog = true
2113 }2132 }
2114 }2133 }
2115 if i.subagentsDialog.Active() {2134 if i.subagentsDialog.Active() {
2116 if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 {2135 if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 {
2117 cursorRow = dialogLead + r2136 cursorRow = dialogLead + r
2118 cursorCol = c2137 cursorCol = c
2138+ cursorInDialog = true
2119 } else {2139 } else {
2120 // Dashboard list / transcript view has no caret. Without2140 // Dashboard list / transcript view has no caret. Without
2121 // this branch the default cursorRow points at the2141 // this branch the default cursorRow points at the
@@ -2129,9 +2149,14 @@ func (i *Interactive) redraw() {
2129 cursorRow = -12149 cursorRow = -1
2130 cursorCol = 02150 cursorCol = 0
2131 }2151 }
2152+ i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
2132 if rightBarActive {2153 if rightBarActive {
2133 rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)2154 rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)
2134- i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)2155+ if modalBackdrop {
2156+ i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
2157+ } else {
2158+ i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
2159+ }
2135 } else {2160 } else {
2136 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.2161 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2137 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)2162 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
@@ -3951,11 +3976,28 @@ func onOff(v bool) string {
3951 return "disabled"3976 return "disabled"
3952 }3977 }
3953 3978
3979+const modalBackdropDimPercent = 50
3980+
3954 func (i *Interactive) applyInputCursorColor() {3981 func (i *Interactive) applyInputCursorColor() {
3955 if i == nil || i.cfg.Terminal == nil {3982 if i == nil || i.cfg.Terminal == nil {
3956 return3983 return
3957 }3984 }
3958- _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))3985+ color := tui.Color256(15)
3986+ if i.cursorDimmed {
3987+ color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)
3988+ }
3989+ _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock()))
3990+}
3991+
3992+// setInputCursorDimmed updates the terminal-owned cursor only when its layer
3993+// changes. Reapplying cursor controls on every redraw can reset its blink
3994+// timing before the terminal completes a normal blink cycle.
3995+func (i *Interactive) setInputCursorDimmed(dimmed bool) {
3996+ if i == nil || i.cursorDimmed == dimmed {
3997+ return
3998+ }
3999+ i.cursorDimmed = dimmed
4000+ i.applyInputCursorColor()
3959 }4001 }
3960 4002
3961 func reasoningSettingOptions(levels []string) []settingsOption {4003 func reasoningSettingOptions(levels []string) []settingsOption {
A packages/agent/modes/modal_backdrop_test.go +42 -0
@@ -0,0 +1,42 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/tui"
8+)
9+
10+func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) {
11+ term := &alertTestTerminal{}
12+ i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
13+ i.rend.Resize(80, 24)
14+ i.ed.SetValue("draft")
15+ if !i.settingsDialog.Open([]settingsItem{{key: "test", label: "test setting"}}) {
16+ t.Fatal("settings dialog did not open")
17+ }
18+
19+ i.redraw()
20+ got := term.String()
21+
22+ input := i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) + "draft"
23+ if !strings.Contains(got, tui.Dim(input)) {
24+ t.Fatalf("settings dialog left the main input undimmed: %q", got)
25+ }
26+ if header := frameHeader(i.cfg.Theme, "settings", 80); strings.Contains(got, tui.Dim(header)) {
27+ t.Fatalf("settings dialog itself was dimmed: %q", got)
28+ }
29+ cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent))
30+ if !strings.Contains(got, cursor) {
31+ t.Fatalf("settings dialog left the main cursor bright; missing %q in %q", cursor, got)
32+ }
33+
34+ term.mu.Lock()
35+ term.data = nil
36+ term.mu.Unlock()
37+ i.settingsDialog.Close()
38+ i.redraw()
39+ if got := term.String(); !strings.Contains(got, tui.CursorColor(tui.Color256(15))) {
40+ t.Fatalf("closing settings did not restore the main cursor color: %q", got)
41+ }
42+}
@@ -0,0 +1,42 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/tui"
8+)
9+
10+func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) {
11+ term := &alertTestTerminal{}
12+ i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
13+ i.rend.Resize(80, 24)
14+ i.ed.SetValue("draft")
15+ if !i.settingsDialog.Open([]settingsItem{{key: "test", label: "test setting"}}) {
16+ t.Fatal("settings dialog did not open")
17+ }
18+
19+ i.redraw()
20+ got := term.String()
21+
22+ input := i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) + "draft"
23+ if !strings.Contains(got, tui.Dim(input)) {
24+ t.Fatalf("settings dialog left the main input undimmed: %q", got)
25+ }
26+ if header := frameHeader(i.cfg.Theme, "settings", 80); strings.Contains(got, tui.Dim(header)) {
27+ t.Fatalf("settings dialog itself was dimmed: %q", got)
28+ }
29+ cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent))
30+ if !strings.Contains(got, cursor) {
31+ t.Fatalf("settings dialog left the main cursor bright; missing %q in %q", cursor, got)
32+ }
33+
34+ term.mu.Lock()
35+ term.data = nil
36+ term.mu.Unlock()
37+ i.settingsDialog.Close()
38+ i.redraw()
39+ if got := term.String(); !strings.Contains(got, tui.CursorColor(tui.Color256(15))) {
40+ t.Fatalf("closing settings did not restore the main cursor color: %q", got)
41+ }
42+}
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()
A packages/tui/layer_test.go +47 -0
@@ -0,0 +1,47 @@
1+package tui
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func TestDimLinesPreservesDimmingAcrossStyledSegments(t *testing.T) {
9+ lines := []string{
10+ "plain",
11+ Dark.FGColor(Dark.Accent, "accent") + Dark.FGColor(Dark.Error, "error"),
12+ }
13+
14+ got := DimLines(lines)
15+ want := []string{
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+ }
19+ if !reflect.DeepEqual(got, want) {
20+ t.Fatalf("DimLines() = %#v, want %#v", got, want)
21+ }
22+ if !reflect.DeepEqual(lines, []string{
23+ "plain",
24+ Dark.FGColor(Dark.Accent, "accent") + Dark.FGColor(Dark.Error, "error"),
25+ }) {
26+ t.Fatalf("DimLines mutated input: %#v", lines)
27+ }
28+}
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+
43+func TestCursorColorUsesTerminalColor(t *testing.T) {
44+ if got, want := CursorColor(ColorRGB(1, 2, 3)), "\x1b]12;rgb:01/02/03\x07"; got != want {
45+ t.Fatalf("CursorColor() = %q, want %q", got, want)
46+ }
47+}
@@ -0,0 +1,47 @@
1+package tui
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func TestDimLinesPreservesDimmingAcrossStyledSegments(t *testing.T) {
9+ lines := []string{
10+ "plain",
11+ Dark.FGColor(Dark.Accent, "accent") + Dark.FGColor(Dark.Error, "error"),
12+ }
13+
14+ got := DimLines(lines)
15+ want := []string{
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+ }
19+ if !reflect.DeepEqual(got, want) {
20+ t.Fatalf("DimLines() = %#v, want %#v", got, want)
21+ }
22+ if !reflect.DeepEqual(lines, []string{
23+ "plain",
24+ Dark.FGColor(Dark.Accent, "accent") + Dark.FGColor(Dark.Error, "error"),
25+ }) {
26+ t.Fatalf("DimLines mutated input: %#v", lines)
27+ }
28+}
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+
43+func TestCursorColorUsesTerminalColor(t *testing.T) {
44+ if got, want := CursorColor(ColorRGB(1, 2, 3)), "\x1b]12;rgb:01/02/03\x07"; got != want {
45+ t.Fatalf("CursorColor() = %q, want %q", got, want)
46+ }
47+}
M packages/tui/layout.go +11 -3
@@ -82,11 +82,19 @@ func InputBlock(th Theme, lines []string, width int) []string {
8282 return out
8383 }
8484
85-func CursorColor256(index int) string {
86- r, g, b := xterm256RGB(index)
87- return "\x1b]12;rgb:" + hexByte(r) + "/" + hexByte(g) + "/" + hexByte(b) + "\x07"
85+// CursorColor returns OSC 12 to set the terminal cursor color. Unlike SGR
86+// styling, the cursor is terminal-owned, so modal backdrops must set this
87+// separately from their dimmed text rows.
88+func CursorColor(color TerminalColor) string {
89+ rgb, ok := rgbForTerminalColor(color)
90+ if !ok {
91+ return ""
92+ }
93+ return "\x1b]12;rgb:" + hexByte(rgb[0]) + "/" + hexByte(rgb[1]) + "/" + hexByte(rgb[2]) + "\x07"
8894 }
8995
96+func CursorColor256(index int) string { return CursorColor(Color256(index)) }
97+
9098 func ResetCursorColor() string { return "\x1b]112\x07" }
9199
92100 func CursorShapeBlock() string { return "\x1b[1 q" }
@@ -82,11 +82,19 @@ func InputBlock(th Theme, lines []string, width int) []string {
82 return out82 return out
83 }83 }
84 84
85-func CursorColor256(index int) string {85+// CursorColor returns OSC 12 to set the terminal cursor color. Unlike SGR
86- r, g, b := xterm256RGB(index)86+// styling, the cursor is terminal-owned, so modal backdrops must set this
87- return "\x1b]12;rgb:" + hexByte(r) + "/" + hexByte(g) + "/" + hexByte(b) + "\x07"87+// separately from their dimmed text rows.
88+func CursorColor(color TerminalColor) string {
89+ rgb, ok := rgbForTerminalColor(color)
90+ if !ok {
91+ return ""
92+ }
93+ return "\x1b]12;rgb:" + hexByte(rgb[0]) + "/" + hexByte(rgb[1]) + "/" + hexByte(rgb[2]) + "\x07"
88 }94 }
89 95
96+func CursorColor256(index int) string { return CursorColor(Color256(index)) }
97+
90 func ResetCursorColor() string { return "\x1b]112\x07" }98 func ResetCursorColor() string { return "\x1b]112\x07" }
91 99
92 func CursorShapeBlock() string { return "\x1b[1 q" }100 func CursorShapeBlock() string { return "\x1b[1 q" }
M packages/tui/rightbar.go +20 -2
@@ -347,6 +347,10 @@ func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *b
347347 // allowing either side to soft-wrap. The returned row occupies exactly
348348 // mainWidth + RightBarSeparatorWidth + rightBarWidth display cells.
349349 func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string {
350+ return joinRightBar(th, main, rightBar, mainWidth, rightBarWidth, false)
351+}
352+
353+func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int, dimSeparator bool) string {
350354 if mainWidth < 1 {
351355 mainWidth = 1
352356 }
@@ -361,7 +365,11 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int)
361365 if visible := visibleWidth(rightBar); visible < rightBarWidth {
362366 rightBar += strings.Repeat(" ", rightBarWidth-visible)
363367 }
364- return main + th.FGColor(th.Muted, "│") + rightBar
368+ separator := th.FGColor(th.Muted, "│")
369+ if dimSeparator {
370+ separator = Dim(separator)
371+ }
372+ return main + separator + rightBar
365373 }
366374
367375 // DrawRightBar renders an already-laid-out visible chat slice and sticky
@@ -371,6 +379,16 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int)
371379 // stale rows behind. Interactive mode still owns chat scrolling and passes
372380 // the selected visible slice here.
373381 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
382+ r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)
383+}
384+
385+// DrawRightBarDimmed renders a right-bar frame whose separator is part of a
386+// dimmed backdrop. Callers must pass already-dimmed main and right-bar rows.
387+func (r *Renderer) DrawRightBarDimmed(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
388+ r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, true)
389+}
390+
391+func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int, dimSeparator bool) {
374392 if r.cols == 0 || r.rows == 0 {
375393 return
376394 }
@@ -407,7 +425,7 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow
407425
408426 frame := make([]string, r.rows)
409427 for row := range frame {
410- frame[row] = JoinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth)
428+ frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator)
411429 }
412430
413431 // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a
@@ -347,6 +347,10 @@ func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *b
347 // allowing either side to soft-wrap. The returned row occupies exactly347 // allowing either side to soft-wrap. The returned row occupies exactly
348 // mainWidth + RightBarSeparatorWidth + rightBarWidth display cells.348 // mainWidth + RightBarSeparatorWidth + rightBarWidth display cells.
349 func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string {349 func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string {
350+ return joinRightBar(th, main, rightBar, mainWidth, rightBarWidth, false)
351+}
352+
353+func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int, dimSeparator bool) string {
350 if mainWidth < 1 {354 if mainWidth < 1 {
351 mainWidth = 1355 mainWidth = 1
352 }356 }
@@ -361,7 +365,11 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int)
361 if visible := visibleWidth(rightBar); visible < rightBarWidth {365 if visible := visibleWidth(rightBar); visible < rightBarWidth {
362 rightBar += strings.Repeat(" ", rightBarWidth-visible)366 rightBar += strings.Repeat(" ", rightBarWidth-visible)
363 }367 }
364- return main + th.FGColor(th.Muted, "│") + rightBar368+ separator := th.FGColor(th.Muted, "│")
369+ if dimSeparator {
370+ separator = Dim(separator)
371+ }
372+ return main + separator + rightBar
365 }373 }
366 374
367 // DrawRightBar renders an already-laid-out visible chat slice and sticky375 // DrawRightBar renders an already-laid-out visible chat slice and sticky
@@ -371,6 +379,16 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int)
371 // stale rows behind. Interactive mode still owns chat scrolling and passes379 // stale rows behind. Interactive mode still owns chat scrolling and passes
372 // the selected visible slice here.380 // the selected visible slice here.
373 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {381 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
382+ r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)
383+}
384+
385+// DrawRightBarDimmed renders a right-bar frame whose separator is part of a
386+// dimmed backdrop. Callers must pass already-dimmed main and right-bar rows.
387+func (r *Renderer) DrawRightBarDimmed(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
388+ r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, true)
389+}
390+
391+func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int, dimSeparator bool) {
374 if r.cols == 0 || r.rows == 0 {392 if r.cols == 0 || r.rows == 0 {
375 return393 return
376 }394 }
@@ -407,7 +425,7 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow
407 425
408 frame := make([]string, r.rows)426 frame := make([]string, r.rows)
409 for row := range frame {427 for row := range frame {
410- frame[row] = JoinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth)428+ frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator)
411 }429 }
412 430
413 // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a431 // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a
M packages/tui/rightbar_test.go +20 -0
@@ -284,3 +284,23 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
284284 t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail)
285285 }
286286 }
287+
288+func TestRendererDrawRightBarDimmedDimsSeparator(t *testing.T) {
289+ var out bytes.Buffer
290+ r := NewRenderer(&out)
291+ r.SetTheme(Dark)
292+ r.Resize(80, 5)
293+
294+ r.DrawRightBarDimmed(
295+ DimLines([]string{"chat"}),
296+ DimLines([]string{"input"}),
297+ DimLines(RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)),
298+ 0,
299+ 0,
300+ )
301+
302+ separator := Dim(Dark.FGColor(Dark.Muted, "│"))
303+ if !strings.Contains(out.String(), separator) {
304+ t.Fatalf("dimmed right bar left its separator bright; missing %q in %q", separator, out.String())
305+ }
306+}
@@ -284,3 +284,23 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
284 t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail)284 t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail)
285 }285 }
286 }286 }
287+
288+func TestRendererDrawRightBarDimmedDimsSeparator(t *testing.T) {
289+ var out bytes.Buffer
290+ r := NewRenderer(&out)
291+ r.SetTheme(Dark)
292+ r.Resize(80, 5)
293+
294+ r.DrawRightBarDimmed(
295+ DimLines([]string{"chat"}),
296+ DimLines([]string{"input"}),
297+ DimLines(RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)),
298+ 0,
299+ 0,
300+ )
301+
302+ separator := Dim(Dark.FGColor(Dark.Muted, "│"))
303+ if !strings.Contains(out.String(), separator) {
304+ t.Fatalf("dimmed right bar left its separator bright; missing %q in %q", separator, out.String())
305+ }
306+}
M packages/tui/theme.go +30 -6
@@ -405,17 +405,41 @@ func (t Theme) UserBubbleRow(content string, width int) string {
405405 return bar + t.UserBubble(content, bubbleW)
406406 }
407407
408-// Bold wraps s in bold SGR.
409-func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
408+const (
409+ reset = "\x1b[0m"
410+ dim = "\x1b[2m"
411+ normalIntensity = "\x1b[22m"
412+)
410413
411-// Dim wraps s in dim SGR.
412-func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" }
414+var dimStyleReplacer = strings.NewReplacer(
415+ reset, reset+dim,
416+ normalIntensity, normalIntensity+dim,
417+)
418+
419+// Bold wraps s in bold SGR.
420+func Bold(s string) string { return "\x1b[1m" + s + normalIntensity }
421+
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.
425+func Dim(s string) string {
426+ return dim + dimStyleReplacer.Replace(s) + normalIntensity
427+}
428+
429+// DimLines returns a dimmed copy of lines. It is suitable for content behind
430+// a modal layer: the foreground layer can retain its normal styling while
431+// every styled segment in the background stays dimmed.
432+func DimLines(lines []string) []string {
433+ out := make([]string, len(lines))
434+ for idx, line := range lines {
435+ out[idx] = Dim(line)
436+ }
437+ return out
438+}
413439
414440 // Italic wraps s in italic SGR.
415441 func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
416442
417-const reset = "\x1b[0m"
418-
419443 func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }
420444 func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
421445 func sgrBGColor(c TerminalColor) string {
@@ -405,17 +405,41 @@ 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-// Bold wraps s in bold SGR.408+const (
409-func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }409+ reset = "\x1b[0m"
410+ dim = "\x1b[2m"
411+ normalIntensity = "\x1b[22m"
412+)
410 413
411-// Dim wraps s in dim SGR.414+var dimStyleReplacer = strings.NewReplacer(
412-func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" }415+ reset, reset+dim,
416+ normalIntensity, normalIntensity+dim,
417+)
418+
419+// Bold wraps s in bold SGR.
420+func Bold(s string) string { return "\x1b[1m" + s + normalIntensity }
421+
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.
425+func Dim(s string) string {
426+ return dim + dimStyleReplacer.Replace(s) + normalIntensity
427+}
428+
429+// DimLines returns a dimmed copy of lines. It is suitable for content behind
430+// a modal layer: the foreground layer can retain its normal styling while
431+// every styled segment in the background stays dimmed.
432+func DimLines(lines []string) []string {
433+ out := make([]string, len(lines))
434+ for idx, line := range lines {
435+ out[idx] = Dim(line)
436+ }
437+ return out
438+}
413 439
414 // Italic wraps s in italic SGR.440 // Italic wraps s in italic SGR.
415 func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }441 func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
416 442
417-const reset = "\x1b[0m"
418-
419 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" }
420 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" }
421 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