detached 06ae582

feat(tui): dim backdrops beneath dialogs

commit
06ae5821b4185935333e7b3e5970caf26cf98098
author
brice · · 2026-08-06 06:46 UTC
parent
7141aff
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +188 -9
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+}
A packages/tui/layer_test.go +34 -0
@@ -0,0 +1,34 @@
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[0m",
17+ "\x1b[2m\x1b[38;5;111maccent\x1b[0m\x1b[2m\x1b[38;5;203merror\x1b[0m\x1b[2m\x1b[0m",
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 TestCursorColorUsesTerminalColor(t *testing.T) {
31+ 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)
33+ }
34+}
@@ -0,0 +1,34 @@
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[0m",
17+ "\x1b[2m\x1b[38;5;111maccent\x1b[0m\x1b[2m\x1b[38;5;203merror\x1b[0m\x1b[2m\x1b[0m",
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 TestCursorColorUsesTerminalColor(t *testing.T) {
31+ 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)
33+ }
34+}
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
@@ -336,6 +336,10 @@ func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *b
336336 // allowing either side to soft-wrap. The returned row occupies exactly
337337 // mainWidth + RightBarSeparatorWidth + rightBarWidth display cells.
338338 func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string {
339+ return joinRightBar(th, main, rightBar, mainWidth, rightBarWidth, false)
340+}
341+
342+func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int, dimSeparator bool) string {
339343 if mainWidth < 1 {
340344 mainWidth = 1
341345 }
@@ -350,7 +354,11 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int)
350354 if visible := visibleWidth(rightBar); visible < rightBarWidth {
351355 rightBar += strings.Repeat(" ", rightBarWidth-visible)
352356 }
353- return main + th.FGColor(th.Muted, "│") + rightBar
357+ separator := th.FGColor(th.Muted, "│")
358+ if dimSeparator {
359+ separator = Dim(separator)
360+ }
361+ return main + separator + rightBar
354362 }
355363
356364 // DrawRightBar renders an already-laid-out visible chat slice and sticky
@@ -360,6 +368,16 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int)
360368 // stale rows behind. Interactive mode still owns chat scrolling and passes
361369 // the selected visible slice here.
362370 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
371+ r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)
372+}
373+
374+// DrawRightBarDimmed renders a right-bar frame whose separator is part of a
375+// dimmed backdrop. Callers must pass already-dimmed main and right-bar rows.
376+func (r *Renderer) DrawRightBarDimmed(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
377+ r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, true)
378+}
379+
380+func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int, dimSeparator bool) {
363381 if r.cols == 0 || r.rows == 0 {
364382 return
365383 }
@@ -396,7 +414,7 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow
396414
397415 frame := make([]string, r.rows)
398416 for row := range frame {
399- frame[row] = JoinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth)
417+ frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator)
400418 }
401419
402420 // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a
@@ -336,6 +336,10 @@ func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *b
336 // allowing either side to soft-wrap. The returned row occupies exactly336 // allowing either side to soft-wrap. The returned row occupies exactly
337 // mainWidth + RightBarSeparatorWidth + rightBarWidth display cells.337 // mainWidth + RightBarSeparatorWidth + rightBarWidth display cells.
338 func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string {338 func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string {
339+ return joinRightBar(th, main, rightBar, mainWidth, rightBarWidth, false)
340+}
341+
342+func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int, dimSeparator bool) string {
339 if mainWidth < 1 {343 if mainWidth < 1 {
340 mainWidth = 1344 mainWidth = 1
341 }345 }
@@ -350,7 +354,11 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int)
350 if visible := visibleWidth(rightBar); visible < rightBarWidth {354 if visible := visibleWidth(rightBar); visible < rightBarWidth {
351 rightBar += strings.Repeat(" ", rightBarWidth-visible)355 rightBar += strings.Repeat(" ", rightBarWidth-visible)
352 }356 }
353- return main + th.FGColor(th.Muted, "│") + rightBar357+ separator := th.FGColor(th.Muted, "│")
358+ if dimSeparator {
359+ separator = Dim(separator)
360+ }
361+ return main + separator + rightBar
354 }362 }
355 363
356 // DrawRightBar renders an already-laid-out visible chat slice and sticky364 // DrawRightBar renders an already-laid-out visible chat slice and sticky
@@ -360,6 +368,16 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int)
360 // stale rows behind. Interactive mode still owns chat scrolling and passes368 // stale rows behind. Interactive mode still owns chat scrolling and passes
361 // the selected visible slice here.369 // the selected visible slice here.
362 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {370 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
371+ r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)
372+}
373+
374+// DrawRightBarDimmed renders a right-bar frame whose separator is part of a
375+// dimmed backdrop. Callers must pass already-dimmed main and right-bar rows.
376+func (r *Renderer) DrawRightBarDimmed(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
377+ r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, true)
378+}
379+
380+func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int, dimSeparator bool) {
363 if r.cols == 0 || r.rows == 0 {381 if r.cols == 0 || r.rows == 0 {
364 return382 return
365 }383 }
@@ -396,7 +414,7 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow
396 414
397 frame := make([]string, r.rows)415 frame := make([]string, r.rows)
398 for row := range frame {416 for row := range frame {
399- frame[row] = JoinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth)417+ frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator)
400 }418 }
401 419
402 // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a420 // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a
M packages/tui/rightbar_test.go +20 -0
@@ -259,3 +259,23 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
259259 t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail)
260260 }
261261 }
262+
263+func TestRendererDrawRightBarDimmedDimsSeparator(t *testing.T) {
264+ var out bytes.Buffer
265+ r := NewRenderer(&out)
266+ r.SetTheme(Dark)
267+ r.Resize(80, 5)
268+
269+ r.DrawRightBarDimmed(
270+ DimLines([]string{"chat"}),
271+ DimLines([]string{"input"}),
272+ DimLines(RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)),
273+ 0,
274+ 0,
275+ )
276+
277+ separator := Dim(Dark.FGColor(Dark.Muted, "│"))
278+ if !strings.Contains(out.String(), separator) {
279+ t.Fatalf("dimmed right bar left its separator bright; missing %q in %q", separator, out.String())
280+ }
281+}
@@ -259,3 +259,23 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
259 t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail)259 t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail)
260 }260 }
261 }261 }
262+
263+func TestRendererDrawRightBarDimmedDimsSeparator(t *testing.T) {
264+ var out bytes.Buffer
265+ r := NewRenderer(&out)
266+ r.SetTheme(Dark)
267+ r.Resize(80, 5)
268+
269+ r.DrawRightBarDimmed(
270+ DimLines([]string{"chat"}),
271+ DimLines([]string{"input"}),
272+ DimLines(RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)),
273+ 0,
274+ 0,
275+ )
276+
277+ separator := Dim(Dark.FGColor(Dark.Muted, "│"))
278+ if !strings.Contains(out.String(), separator) {
279+ t.Fatalf("dimmed right bar left its separator bright; missing %q in %q", separator, out.String())
280+ }
281+}
M packages/tui/theme.go +17 -2
@@ -408,8 +408,23 @@ func (t Theme) UserBubbleRow(content string, width int) string {
408408 // Bold wraps s in bold SGR.
409409 func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
410410
411-// Dim wraps s in dim SGR.
412-func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" }
411+// Dim wraps s in dim SGR. Re-apply dim after each full SGR reset so
412+// independently styled segments remain dimmed too.
413+func Dim(s string) string {
414+ const dim = "\x1b[2m"
415+ return dim + strings.ReplaceAll(s, reset, reset+dim) + reset
416+}
417+
418+// DimLines returns a dimmed copy of lines. It is suitable for content behind
419+// a modal layer: the foreground layer can retain its normal styling while
420+// every styled segment in the background stays dimmed.
421+func DimLines(lines []string) []string {
422+ out := make([]string, len(lines))
423+ for idx, line := range lines {
424+ out[idx] = Dim(line)
425+ }
426+ return out
427+}
413428
414429 // Italic wraps s in italic SGR.
415430 func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
@@ -408,8 +408,23 @@ func (t Theme) UserBubbleRow(content string, width int) string {
408 // Bold wraps s in bold SGR.408 // Bold wraps s in bold SGR.
409 func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }409 func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
410 410
411-// Dim wraps s in dim SGR.411+// Dim wraps s in dim SGR. Re-apply dim after each full SGR reset so
412-func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" }412+// independently styled segments remain dimmed too.
413+func Dim(s string) string {
414+ const dim = "\x1b[2m"
415+ return dim + strings.ReplaceAll(s, reset, reset+dim) + reset
416+}
417+
418+// DimLines returns a dimmed copy of lines. It is suitable for content behind
419+// a modal layer: the foreground layer can retain its normal styling while
420+// every styled segment in the background stays dimmed.
421+func DimLines(lines []string) []string {
422+ out := make([]string, len(lines))
423+ for idx, line := range lines {
424+ out[idx] = Dim(line)
425+ }
426+ return out
427+}
413 428
414 // Italic wraps s in italic SGR.429 // Italic wraps s in italic SGR.
415 func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }430 func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }

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