feat(tui): dim backdrops beneath dialogs
- commit
- 06ae5821b4185935333e7b3e5970caf26cf98098
- author
- brice · · 2026-08-06 06:46 UTC
- parent
- 7141aff
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 545 | 545 | prevChatCols int | |
| 546 | 546 | prevChatRows int | |
| 547 | 547 | prevOverlayOpen bool | |
| 548 | + | cursorDimmed bool | |
| 548 | 549 | ||
| 549 | 550 | // chatCache stores the built transcript/status-note rows for idle | |
| 550 | 551 | // frames. Editor typing changes only the bottom input region, so | |
| @@ -1687,6 +1688,7 @@ func (i *Interactive) redraw() { | |||
| 1687 | 1688 | if len(dialog) > 0 { | |
| 1688 | 1689 | dialog = padDialogFrame(dialog) | |
| 1689 | 1690 | } | |
| 1691 | + | modalBackdrop := len(dialog) > 0 | |
| 1690 | 1692 | ||
| 1691 | 1693 | // Slash-command autocomplete: popup above the status line, only | |
| 1692 | 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 | 2083 | // Default: the real terminal cursor sits on the main editor's | |
| 2071 | 2084 | // input position. In main-screen log mode cursor rows are relative | |
| 2072 | 2085 | // to the fixed bottom band, not the chat transcript. | |
| @@ -2079,22 +2092,26 @@ func (i *Interactive) redraw() { | |||
| 2079 | 2092 | } | |
| 2080 | 2093 | cursorRow := inputStartRow + inputCursorOffset + curR | |
| 2081 | 2094 | cursorCol := curC + inputCursorColOffset | |
| 2095 | + | cursorInDialog := false | |
| 2082 | 2096 | if i.btwDialog.Active() { | |
| 2083 | 2097 | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | |
| 2084 | 2098 | cursorRow = dialogLead + r | |
| 2085 | 2099 | cursorCol = c | |
| 2100 | + | cursorInDialog = true | |
| 2086 | 2101 | } | |
| 2087 | 2102 | } | |
| 2088 | 2103 | if i.dialog.Active() { | |
| 2089 | 2104 | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | |
| 2090 | 2105 | cursorRow = dialogLead + r | |
| 2091 | 2106 | cursorCol = c | |
| 2107 | + | cursorInDialog = true | |
| 2092 | 2108 | } | |
| 2093 | 2109 | } | |
| 2094 | 2110 | if i.llamaDialog.Active() { | |
| 2095 | 2111 | if r, c := i.llamaDialog.CursorPos(); r >= 0 { | |
| 2096 | 2112 | cursorRow = dialogLead + r | |
| 2097 | 2113 | cursorCol = c | |
| 2114 | + | cursorInDialog = true | |
| 2098 | 2115 | } else { | |
| 2099 | 2116 | cursorRow = -1 | |
| 2100 | 2117 | cursorCol = 0 | |
| @@ -2104,18 +2121,21 @@ func (i *Interactive) redraw() { | |||
| 2104 | 2121 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | |
| 2105 | 2122 | cursorRow = dialogLead + r | |
| 2106 | 2123 | cursorCol = c | |
| 2124 | + | cursorInDialog = true | |
| 2107 | 2125 | } | |
| 2108 | 2126 | } | |
| 2109 | 2127 | if i.sessionTreeDialog.Active() { | |
| 2110 | 2128 | if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 { | |
| 2111 | 2129 | cursorRow = dialogLead + r | |
| 2112 | 2130 | cursorCol = c | |
| 2131 | + | cursorInDialog = true | |
| 2113 | 2132 | } | |
| 2114 | 2133 | } | |
| 2115 | 2134 | if i.subagentsDialog.Active() { | |
| 2116 | 2135 | if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 { | |
| 2117 | 2136 | cursorRow = dialogLead + r | |
| 2118 | 2137 | cursorCol = c | |
| 2138 | + | cursorInDialog = true | |
| 2119 | 2139 | } else { | |
| 2120 | 2140 | // Dashboard list / transcript view has no caret. Without | |
| 2121 | 2141 | // this branch the default cursorRow points at the | |
| @@ -2129,9 +2149,14 @@ func (i *Interactive) redraw() { | |||
| 2129 | 2149 | cursorRow = -1 | |
| 2130 | 2150 | cursorCol = 0 | |
| 2131 | 2151 | } | |
| 2152 | + | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | |
| 2132 | 2153 | if rightBarActive { | |
| 2133 | 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 | 2160 | } else { | |
| 2136 | 2161 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 2137 | 2162 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| @@ -3951,11 +3976,28 @@ func onOff(v bool) string { | |||
| 3951 | 3976 | return "disabled" | |
| 3952 | 3977 | } | |
| 3953 | 3978 | ||
| 3979 | + | const modalBackdropDimPercent = 50 | |
| 3980 | + | ||
| 3954 | 3981 | func (i *Interactive) applyInputCursorColor() { | |
| 3955 | 3982 | if i == nil || i.cfg.Terminal == nil { | |
| 3956 | 3983 | 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 | 4003 | func reasoningSettingOptions(levels []string) []settingsOption { | |
| @@ -545,6 +545,7 @@ type Interactive struct { | |||||
| 545 | prevChatCols int | 545 | prevChatCols int | ||
| 546 | prevChatRows int | 546 | prevChatRows int | ||
| 547 | prevOverlayOpen bool | 547 | prevOverlayOpen bool | ||
| 548 | + | cursorDimmed bool | |||
| 548 | 549 | ||||
| 549 | // chatCache stores the built transcript/status-note rows for idle | 550 | // chatCache stores the built transcript/status-note rows for idle | ||
| 550 | // frames. Editor typing changes only the bottom input region, so | 551 | // 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, only | 1693 | // 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's | 2083 | // Default: the real terminal cursor sits on the main editor's | ||
| 2071 | // input position. In main-screen log mode cursor rows are relative | 2084 | // 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 + curR | 2093 | cursorRow := inputStartRow + inputCursorOffset + curR | ||
| 2081 | cursorCol := curC + inputCursorColOffset | 2094 | 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 + r | 2098 | cursorRow = dialogLead + r | ||
| 2085 | cursorCol = c | 2099 | 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 + r | 2105 | cursorRow = dialogLead + r | ||
| 2091 | cursorCol = c | 2106 | 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 + r | 2112 | cursorRow = dialogLead + r | ||
| 2097 | cursorCol = c | 2113 | cursorCol = c | ||
| 2114 | + | cursorInDialog = true | |||
| 2098 | } else { | 2115 | } else { | ||
| 2099 | cursorRow = -1 | 2116 | cursorRow = -1 | ||
| 2100 | cursorCol = 0 | 2117 | 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 + r | 2122 | cursorRow = dialogLead + r | ||
| 2106 | cursorCol = c | 2123 | 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 + r | 2129 | cursorRow = dialogLead + r | ||
| 2112 | cursorCol = c | 2130 | 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 + r | 2136 | cursorRow = dialogLead + r | ||
| 2118 | cursorCol = c | 2137 | cursorCol = c | ||
| 2138 | + | cursorInDialog = true | |||
| 2119 | } else { | 2139 | } else { | ||
| 2120 | // Dashboard list / transcript view has no caret. Without | 2140 | // Dashboard list / transcript view has no caret. Without | ||
| 2121 | // this branch the default cursorRow points at the | 2141 | // this branch the default cursorRow points at the | ||
| @@ -2129,9 +2149,14 @@ func (i *Interactive) redraw() { | |||||
| 2129 | cursorRow = -1 | 2149 | cursorRow = -1 | ||
| 2130 | cursorCol = 0 | 2150 | 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 | return | 3983 | 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 { | |||
| 82 | 82 | return out | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 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" | |
| 88 | 94 | } | |
| 89 | 95 | ||
| 96 | + | func CursorColor256(index int) string { return CursorColor(Color256(index)) } | |
| 97 | + | ||
| 90 | 98 | func ResetCursorColor() string { return "\x1b]112\x07" } | |
| 91 | 99 | ||
| 92 | 100 | func CursorShapeBlock() string { return "\x1b[1 q" } | |
| @@ -82,11 +82,19 @@ func InputBlock(th Theme, lines []string, width int) []string { | |||||
| 82 | return out | 82 | 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 | |||
| 336 | 336 | // allowing either side to soft-wrap. The returned row occupies exactly | |
| 337 | 337 | // mainWidth + RightBarSeparatorWidth + rightBarWidth display cells. | |
| 338 | 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 | 343 | if mainWidth < 1 { | |
| 340 | 344 | mainWidth = 1 | |
| 341 | 345 | } | |
| @@ -350,7 +354,11 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) | |||
| 350 | 354 | if visible := visibleWidth(rightBar); visible < rightBarWidth { | |
| 351 | 355 | rightBar += strings.Repeat(" ", rightBarWidth-visible) | |
| 352 | 356 | } | |
| 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 | |
| 354 | 362 | } | |
| 355 | 363 | ||
| 356 | 364 | // 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 | 368 | // stale rows behind. Interactive mode still owns chat scrolling and passes | |
| 361 | 369 | // the selected visible slice here. | |
| 362 | 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 | 381 | if r.cols == 0 || r.rows == 0 { | |
| 364 | 382 | return | |
| 365 | 383 | } | |
| @@ -396,7 +414,7 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow | |||
| 396 | 414 | ||
| 397 | 415 | frame := make([]string, r.rows) | |
| 398 | 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 | 420 | // 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 exactly | 336 | // 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 = 1 | 344 | 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, "│") + rightBar | 357 | + | 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 sticky | 364 | // 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 passes | 368 | // 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 | return | 382 | 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 a | 420 | // 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) { | |||
| 259 | 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 | + | } | |
| @@ -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 { | |||
| 408 | 408 | // Bold wraps s in bold SGR. | |
| 409 | 409 | func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" } | |
| 410 | 410 | ||
| 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 | + | } | |
| 413 | 428 | ||
| 414 | 429 | // Italic wraps s in italic SGR. | |
| 415 | 430 | 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" } | ||