detached 8ae5775

Merge branch 'main' into feat/autonomous-client-broker

# Conflicts:
#	internal/app/ui_driver_acceptance_test.go
#	internal/usecase/client/client.go
commit
8ae577571419bf3113faeac551b01f0a4ea5e76b
author
brice · · 2026-09-22 07:21 UTC
merge of
ca376f7 + 41782a9 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +78 -44
M internal/usecase/client/picker_interaction.go +8 -6
@@ -415,8 +415,10 @@ func clampPreviewDimension(value int, maxValue uint16) uint16 {
415415 // client-picker frame is a local presentation overlay, not daemon output,
416416 // so its bytes never enter the output ACK window.
417417 type pickerRenderer struct {
418- renderer *ansirenderer.Renderer
419- size domain.Size
418+ renderer *ansirenderer.Renderer
419+ profile ansirenderer.ColorProfile
420+ renderStyles []picker.RenderStyles
421+ size domain.Size
420422 // prevBounds is the box drawn last: when it moves or resizes, its previous
421423 // cells are blanked together with the new ones, and the rest of the screen
422424 // stays untouched.
@@ -426,8 +428,8 @@ type pickerRenderer struct {
426428 primed bool
427429 }
428430
429-func newPickerRenderer() *pickerRenderer {
430- return &pickerRenderer{}
431+func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer {
432+ return &pickerRenderer{profile: profile}
431433 }
432434
433435 // render composes the loop model into terminal bytes for one display
@@ -447,7 +449,7 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick
447449 return nil
448450 }
449451 if r.renderer == nil || r.size != size {
450- r.renderer = ansirenderer.New(ansirenderer.Capabilities{})
452+ r.renderer = ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, r.profile)
451453 r.size = size
452454 // Keep prevBounds across a resize. The new renderer is primed with an
453455 // empty shadow below, so damaging the old rectangle explicitly clears
@@ -469,7 +471,7 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick
469471 border := renderer.DefaultStyle()
470472 border.Attrs |= renderer.AttrDim
471473 picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())
472- inner := loop.model.Render(picker.Size(presentation.Inner), preview)
474+ inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...)
473475 copyFrameRect(base, presentation.Inner, inner)
474476
475477 // Only the box is written: the session stays on screen around it. A box
@@ -415,8 +415,10 @@ func clampPreviewDimension(value int, maxValue uint16) uint16 {
415 // client-picker frame is a local presentation overlay, not daemon output,415 // client-picker frame is a local presentation overlay, not daemon output,
416 // so its bytes never enter the output ACK window.416 // so its bytes never enter the output ACK window.
417 type pickerRenderer struct {417 type pickerRenderer struct {
418- renderer *ansirenderer.Renderer418+ renderer *ansirenderer.Renderer
419- size domain.Size419+ profile ansirenderer.ColorProfile
420+ renderStyles []picker.RenderStyles
421+ size domain.Size
420 // prevBounds is the box drawn last: when it moves or resizes, its previous422 // prevBounds is the box drawn last: when it moves or resizes, its previous
421 // cells are blanked together with the new ones, and the rest of the screen423 // cells are blanked together with the new ones, and the rest of the screen
422 // stays untouched.424 // stays untouched.
@@ -426,8 +428,8 @@ type pickerRenderer struct {
426 primed bool428 primed bool
427 }429 }
428 430
429-func newPickerRenderer() *pickerRenderer {431+func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer {
430- return &pickerRenderer{}432+ return &pickerRenderer{profile: profile}
431 }433 }
432 434
433 // render composes the loop model into terminal bytes for one display435 // render composes the loop model into terminal bytes for one display
@@ -447,7 +449,7 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick
447 return nil449 return nil
448 }450 }
449 if r.renderer == nil || r.size != size {451 if r.renderer == nil || r.size != size {
450- r.renderer = ansirenderer.New(ansirenderer.Capabilities{})452+ r.renderer = ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, r.profile)
451 r.size = size453 r.size = size
452 // Keep prevBounds across a resize. The new renderer is primed with an454 // Keep prevBounds across a resize. The new renderer is primed with an
453 // empty shadow below, so damaging the old rectangle explicitly clears455 // empty shadow below, so damaging the old rectangle explicitly clears
@@ -469,7 +471,7 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick
469 border := renderer.DefaultStyle()471 border := renderer.DefaultStyle()
470 border.Attrs |= renderer.AttrDim472 border.Attrs |= renderer.AttrDim
471 picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())473 picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())
472- inner := loop.model.Render(picker.Size(presentation.Inner), preview)474+ inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...)
473 copyFrameRect(base, presentation.Inner, inner)475 copyFrameRect(base, presentation.Inner, inner)
474 476
475 // Only the box is written: the session stays on screen around it. A box477 // Only the box is written: the session stays on screen around it. A box
M internal/usecase/client/picker_interaction_test.go +34 -2
@@ -6,6 +6,7 @@ import (
66 "testing"
77
88 renderer "github.com/bnema/vev-vt"
9+ ansirenderer "github.com/bnema/vev-vt/ansi"
910 "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/protocol"
1112 "github.com/bnema/vev/internal/usecase/picker"
@@ -14,7 +15,7 @@ import (
1415
1516 func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) {
1617 loop := pickerLoopFixture(t)
17- r := &pickerRenderer{}
18+ r := newPickerRenderer(ansirenderer.ColorProfileTrueColor)
1819 screen := renderer.NewScreen(100, 30)
1920 paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S')
2021
@@ -38,9 +39,40 @@ func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) {
3839 require.Equal(t, '┌', snapshot.Row(newBounds.Y)[newBounds.X].Rune, "new picker border must be rendered")
3940 }
4041
42+func TestPickerRendererPreservesANSI256ProfileAcrossResize(t *testing.T) {
43+ loop := pickerLoopFixture(t)
44+ styles := picker.RenderStyles{
45+ Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(),
46+ Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(),
47+ Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Status: rgbPickerStyle(),
48+ SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
49+ }
50+
51+ indexed := newPickerRenderer(ansirenderer.ColorProfileANSI256)
52+ indexed.renderStyles = []picker.RenderStyles{styles}
53+ for _, size := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 80, Rows: 24}} {
54+ output := string(indexed.render(loop, size, emptyPickerPreview()))
55+ require.Regexp(t, `(?:38|48);5;`, output, "picker did not emit indexed color at %v", size)
56+ require.NotRegexp(t, `(?:38|48|58);2;`, output, "picker emitted truecolor at %v", size)
57+ }
58+
59+ truecolor := newPickerRenderer(ansirenderer.ColorProfileTrueColor)
60+ truecolor.renderStyles = []picker.RenderStyles{styles}
61+ require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())))
62+}
63+
64+func rgbPickerStyle() renderer.Style {
65+ style := renderer.DefaultStyle()
66+ style.HasForegroundRGB = true
67+ style.ForegroundRGB = renderer.RGB{R: 12, G: 120, B: 231}
68+ style.HasBackgroundRGB = true
69+ style.BackgroundRGB = renderer.RGB{R: 23, G: 45, B: 67}
70+ return style
71+}
72+
4173 func TestPickerRendererResetProtectsRepaintedSessionFromPreviousLease(t *testing.T) {
4274 loop := pickerLoopFixture(t)
43- r := &pickerRenderer{}
75+ r := newPickerRenderer(ansirenderer.ColorProfileTrueColor)
4476 screen := renderer.NewScreen(100, 30)
4577 paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S')
4678 screen.Write(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
@@ -6,6 +6,7 @@ import (
6 "testing"6 "testing"
7 7
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9+ ansirenderer "github.com/bnema/vev-vt/ansi"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/usecase/picker"12 "github.com/bnema/vev/internal/usecase/picker"
@@ -14,7 +15,7 @@ import (
14 15
15 func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) {16 func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) {
16 loop := pickerLoopFixture(t)17 loop := pickerLoopFixture(t)
17- r := &pickerRenderer{}18+ r := newPickerRenderer(ansirenderer.ColorProfileTrueColor)
18 screen := renderer.NewScreen(100, 30)19 screen := renderer.NewScreen(100, 30)
19 paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S')20 paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S')
20 21
@@ -38,9 +39,40 @@ func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) {
38 require.Equal(t, '┌', snapshot.Row(newBounds.Y)[newBounds.X].Rune, "new picker border must be rendered")39 require.Equal(t, '┌', snapshot.Row(newBounds.Y)[newBounds.X].Rune, "new picker border must be rendered")
39 }40 }
40 41
42+func TestPickerRendererPreservesANSI256ProfileAcrossResize(t *testing.T) {
43+ loop := pickerLoopFixture(t)
44+ styles := picker.RenderStyles{
45+ Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(),
46+ Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(),
47+ Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Status: rgbPickerStyle(),
48+ SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
49+ }
50+
51+ indexed := newPickerRenderer(ansirenderer.ColorProfileANSI256)
52+ indexed.renderStyles = []picker.RenderStyles{styles}
53+ for _, size := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 80, Rows: 24}} {
54+ output := string(indexed.render(loop, size, emptyPickerPreview()))
55+ require.Regexp(t, `(?:38|48);5;`, output, "picker did not emit indexed color at %v", size)
56+ require.NotRegexp(t, `(?:38|48|58);2;`, output, "picker emitted truecolor at %v", size)
57+ }
58+
59+ truecolor := newPickerRenderer(ansirenderer.ColorProfileTrueColor)
60+ truecolor.renderStyles = []picker.RenderStyles{styles}
61+ require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())))
62+}
63+
64+func rgbPickerStyle() renderer.Style {
65+ style := renderer.DefaultStyle()
66+ style.HasForegroundRGB = true
67+ style.ForegroundRGB = renderer.RGB{R: 12, G: 120, B: 231}
68+ style.HasBackgroundRGB = true
69+ style.BackgroundRGB = renderer.RGB{R: 23, G: 45, B: 67}
70+ return style
71+}
72+
41 func TestPickerRendererResetProtectsRepaintedSessionFromPreviousLease(t *testing.T) {73 func TestPickerRendererResetProtectsRepaintedSessionFromPreviousLease(t *testing.T) {
42 loop := pickerLoopFixture(t)74 loop := pickerLoopFixture(t)
43- r := &pickerRenderer{}75+ r := newPickerRenderer(ansirenderer.ColorProfileTrueColor)
44 screen := renderer.NewScreen(100, 30)76 screen := renderer.NewScreen(100, 30)
45 paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S')77 paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S')
46 screen.Write(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))78 screen.Write(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
M internal/usecase/daemon/palette_test.go +4 -8
@@ -178,13 +178,12 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
178178 forcedLight := defaults
179179 forcedLight.Theme = domain.ThemeLight
180180 tests := []struct {
181- name string
182- raw themeui.Theme
183- config domain.Config
184- indexed bool
181+ name string
182+ raw themeui.Theme
183+ config domain.Config
185184 }{
186185 {name: "truecolor accent", raw: accentTheme, config: defaults},
187- {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true},
186+ {name: "ansi256", raw: indexedTheme, config: defaults},
188187 {name: "palette off", raw: accentTheme, config: paletteOff},
189188 {name: "forced dark", raw: accentTheme, config: forcedDark},
190189 {name: "forced light", raw: accentTheme, config: forcedLight},
@@ -215,9 +214,6 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
215214 require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB)
216215 require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB)
217216 require.False(t, inactive.HasBackgroundRGB)
218- if tt.indexed {
219- require.Equal(t, 2, inactive.Foreground)
220- }
221217 require.True(t, state.overlays.palette.inner.At(31, 1).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background")
222218
223219 selected := state.overlays.palette.inner.At(4, 2).Style
@@ -178,13 +178,12 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
178 forcedLight := defaults178 forcedLight := defaults
179 forcedLight.Theme = domain.ThemeLight179 forcedLight.Theme = domain.ThemeLight
180 tests := []struct {180 tests := []struct {
181- name string181+ name string
182- raw themeui.Theme182+ raw themeui.Theme
183- config domain.Config183+ config domain.Config
184- indexed bool
185 }{184 }{
186 {name: "truecolor accent", raw: accentTheme, config: defaults},185 {name: "truecolor accent", raw: accentTheme, config: defaults},
187- {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true},186+ {name: "ansi256", raw: indexedTheme, config: defaults},
188 {name: "palette off", raw: accentTheme, config: paletteOff},187 {name: "palette off", raw: accentTheme, config: paletteOff},
189 {name: "forced dark", raw: accentTheme, config: forcedDark},188 {name: "forced dark", raw: accentTheme, config: forcedDark},
190 {name: "forced light", raw: accentTheme, config: forcedLight},189 {name: "forced light", raw: accentTheme, config: forcedLight},
@@ -215,9 +214,6 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
215 require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB)214 require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB)
216 require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB)215 require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB)
217 require.False(t, inactive.HasBackgroundRGB)216 require.False(t, inactive.HasBackgroundRGB)
218- if tt.indexed {
219- require.Equal(t, 2, inactive.Foreground)
220- }
221 require.True(t, state.overlays.palette.inner.At(31, 1).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background")217 require.True(t, state.overlays.palette.inner.At(31, 1).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background")
222 218
223 selected := state.overlays.palette.inner.At(4, 2).Style219 selected := state.overlays.palette.inner.At(4, 2).Style
M internal/usecase/palette/model_test.go +1 -5
@@ -527,7 +527,7 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T)
527527 policy domain.ThemeAccent
528528 }{
529529 {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
530- {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
530+ {name: "ansi256", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
531531 {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
532532 {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
533533 {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
@@ -551,10 +551,6 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T)
551551 require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background)
552552 require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
553553 require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
554- if tt.name == "indexed only" {
555- require.Equal(t, 2, inactive.Foreground)
556- require.False(t, inactive.HasBackgroundRGB)
557- }
558554
559555 selected := frame.At(4, 2).Style
560556 require.True(t, selected.Equal(styles.PickerSelectionMuted))
@@ -527,7 +527,7 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T)
527 policy domain.ThemeAccent527 policy domain.ThemeAccent
528 }{528 }{
529 {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},529 {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
530- {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},530+ {name: "ansi256", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
531 {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},531 {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
532 {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},532 {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
533 {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},533 {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
@@ -551,10 +551,6 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T)
551 require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background)551 require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background)
552 require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)552 require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
553 require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)553 require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
554- if tt.name == "indexed only" {
555- require.Equal(t, 2, inactive.Foreground)
556- require.False(t, inactive.HasBackgroundRGB)
557- }
558 554
559 selected := frame.At(4, 2).Style555 selected := frame.At(4, 2).Style
560 require.True(t, selected.Equal(styles.PickerSelectionMuted))556 require.True(t, selected.Equal(styles.PickerSelectionMuted))
M internal/usecase/theme/accent_resolve.go +2 -2
@@ -13,7 +13,7 @@ const (
1313
1414 // Accent is the single terminal-owned color selected for chrome. IndexedOnly
1515 // means the slot may decorate foregrounds and borders, but cannot fill RGB
16-// backgrounds because terminal defaults or truecolor support are unavailable.
16+// backgrounds because the terminal's semantic foreground or background is unknown.
1717 type Accent struct {
1818 RGB renderer.RGB
1919 Slot uint8
@@ -267,7 +267,7 @@ func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent {
267267 RGB: color,
268268 Slot: slot,
269269 Known: true,
270- IndexedOnly: !t.TrueColor || !t.HasFG || !t.HasBG,
270+ IndexedOnly: !t.HasFG || !t.HasBG,
271271 }
272272 }
273273
@@ -13,7 +13,7 @@ const (
13 13
14 // Accent is the single terminal-owned color selected for chrome. IndexedOnly14 // Accent is the single terminal-owned color selected for chrome. IndexedOnly
15 // means the slot may decorate foregrounds and borders, but cannot fill RGB15 // means the slot may decorate foregrounds and borders, but cannot fill RGB
16-// backgrounds because terminal defaults or truecolor support are unavailable.16+// backgrounds because the terminal's semantic foreground or background is unknown.
17 type Accent struct {17 type Accent struct {
18 RGB renderer.RGB18 RGB renderer.RGB
19 Slot uint819 Slot uint8
@@ -267,7 +267,7 @@ func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent {
267 RGB: color,267 RGB: color,
268 Slot: slot,268 Slot: slot,
269 Known: true,269 Known: true,
270- IndexedOnly: !t.TrueColor || !t.HasFG || !t.HasBG,270+ IndexedOnly: !t.HasFG || !t.HasBG,
271 }271 }
272 }272 }
273 273
M internal/usecase/theme/accent_resolve_test.go +4 -4
@@ -128,13 +128,13 @@ func TestResolveAccentAuto(t *testing.T) {
128128 want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true},
129129 },
130130 {
131- name: "non truecolor yields indexed fallback only",
131+ name: "known colors remain semantic without truecolor output",
132132 theme: func() Theme {
133133 t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal})
134134 t.TrueColor = false
135135 return t
136136 }(),
137- want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true},
137+ want: Accent{RGB: teal, Slot: 2, Known: true},
138138 },
139139 {
140140 name: "missing background cannot infer",
@@ -205,10 +205,10 @@ func TestResolveAccentExplicitSlot(t *testing.T) {
205205 want: Accent{},
206206 },
207207 {
208- name: "explicit known slot is indexed only without truecolor prerequisites",
208+ name: "explicit known slot remains semantic without truecolor output",
209209 theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(),
210210 policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},
211- want: Accent{RGB: teal, Slot: 3, Known: true, IndexedOnly: true},
211+ want: Accent{RGB: teal, Slot: 3, Known: true},
212212 },
213213 {
214214 name: "palette off ignores explicit slot",
@@ -128,13 +128,13 @@ func TestResolveAccentAuto(t *testing.T) {
128 want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true},128 want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true},
129 },129 },
130 {130 {
131- name: "non truecolor yields indexed fallback only",131+ name: "known colors remain semantic without truecolor output",
132 theme: func() Theme {132 theme: func() Theme {
133 t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal})133 t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal})
134 t.TrueColor = false134 t.TrueColor = false
135 return t135 return t
136 }(),136 }(),
137- want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true},137+ want: Accent{RGB: teal, Slot: 2, Known: true},
138 },138 },
139 {139 {
140 name: "missing background cannot infer",140 name: "missing background cannot infer",
@@ -205,10 +205,10 @@ func TestResolveAccentExplicitSlot(t *testing.T) {
205 want: Accent{},205 want: Accent{},
206 },206 },
207 {207 {
208- name: "explicit known slot is indexed only without truecolor prerequisites",208+ name: "explicit known slot remains semantic without truecolor output",
209 theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(),209 theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(),
210 policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},210 policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},
211- want: Accent{RGB: teal, Slot: 3, Known: true, IndexedOnly: true},211+ want: Accent{RGB: teal, Slot: 3, Known: true},
212 },212 },
213 {213 {
214 name: "palette off ignores explicit slot",214 name: "palette off ignores explicit slot",
M internal/usecase/theme/phase3_review_test.go +2 -2
@@ -24,14 +24,14 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
2424 want: Accent{Slot: 5, IndexedOnly: true},
2525 },
2626 {
27- name: "explicit known slot without truecolor is indexed only",
27+ name: "explicit known slot without truecolor remains semantic",
2828 theme: func() Theme {
2929 t := paletteTheme(map[int]renderer.RGB{5: teal})
3030 t.TrueColor = false
3131 return t
3232 }(),
3333 policy: explicit,
34- want: Accent{RGB: teal, Slot: 5, Known: true, IndexedOnly: true},
34+ want: Accent{RGB: teal, Slot: 5, Known: true},
3535 },
3636 {
3737 name: "automatic absent osc palette keeps dark scheme indexed blue fallback",
@@ -24,14 +24,14 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
24 want: Accent{Slot: 5, IndexedOnly: true},24 want: Accent{Slot: 5, IndexedOnly: true},
25 },25 },
26 {26 {
27- name: "explicit known slot without truecolor is indexed only",27+ name: "explicit known slot without truecolor remains semantic",
28 theme: func() Theme {28 theme: func() Theme {
29 t := paletteTheme(map[int]renderer.RGB{5: teal})29 t := paletteTheme(map[int]renderer.RGB{5: teal})
30 t.TrueColor = false30 t.TrueColor = false
31 return t31 return t
32 }(),32 }(),
33 policy: explicit,33 policy: explicit,
34- want: Accent{RGB: teal, Slot: 5, Known: true, IndexedOnly: true},34+ want: Accent{RGB: teal, Slot: 5, Known: true},
35 },35 },
36 {36 {
37 name: "automatic absent osc palette keeps dark scheme indexed blue fallback",37 name: "automatic absent osc palette keeps dark scheme indexed blue fallback",
M internal/usecase/theme/ramp_test.go +13 -7
@@ -153,18 +153,24 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
153153 require.True(t, resolved.Styles.PickerSelection.Bold)
154154 }
155155
156-func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) {
156+func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
157157 theme := rampTheme(false)
158158 theme.TrueColor = false
159159 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
160160 theme.PaletteKnown = 1 << 2
161161 indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
162- require.True(t, indexed.Accent.IndexedOnly)
163- require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB)
164- require.Equal(t, 2, indexed.Styles.BorderActive.Foreground)
165- require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB)
166- require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground)
167- require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB)
162+ require.False(t, indexed.Accent.IndexedOnly)
163+ for name, style := range map[string]renderer.Style{
164+ "active tab": indexed.Styles.TabActive,
165+ "active title": indexed.Styles.TabActiveTitle,
166+ "picker selection": indexed.Styles.PickerSelection,
167+ "search selection": indexed.Styles.SearchSelection,
168+ } {
169+ t.Run(name, func(t *testing.T) {
170+ require.True(t, style.HasBackgroundRGB)
171+ require.Equal(t, indexed.Styles.SurfaceActive.BackgroundRGB, style.BackgroundRGB)
172+ })
173+ }
168174
169175 theme.TrueColor = true
170176 theme.UsePalette = false
@@ -153,18 +153,24 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
153 require.True(t, resolved.Styles.PickerSelection.Bold)153 require.True(t, resolved.Styles.PickerSelection.Bold)
154 }154 }
155 155
156-func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) {156+func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
157 theme := rampTheme(false)157 theme := rampTheme(false)
158 theme.TrueColor = false158 theme.TrueColor = false
159 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}159 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
160 theme.PaletteKnown = 1 << 2160 theme.PaletteKnown = 1 << 2
161 indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})161 indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
162- require.True(t, indexed.Accent.IndexedOnly)162+ require.False(t, indexed.Accent.IndexedOnly)
163- require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB)163+ for name, style := range map[string]renderer.Style{
164- require.Equal(t, 2, indexed.Styles.BorderActive.Foreground)164+ "active tab": indexed.Styles.TabActive,
165- require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB)165+ "active title": indexed.Styles.TabActiveTitle,
166- require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground)166+ "picker selection": indexed.Styles.PickerSelection,
167- require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB)167+ "search selection": indexed.Styles.SearchSelection,
168+ } {
169+ t.Run(name, func(t *testing.T) {
170+ require.True(t, style.HasBackgroundRGB)
171+ require.Equal(t, indexed.Styles.SurfaceActive.BackgroundRGB, style.BackgroundRGB)
172+ })
173+ }
168 174
169 theme.TrueColor = true175 theme.TrueColor = true
170 theme.UsePalette = false176 theme.UsePalette = false
M internal/usecase/theme/theme.go +8 -6
@@ -19,11 +19,13 @@ type Theme struct {
1919 PaletteKnown uint16
2020 HasFG bool
2121 HasBG bool
22- TrueColor bool
23- Known bool
24- SchemeKnown bool
25- Light bool
26- UsePalette bool
22+ // TrueColor records output capability for transport compatibility. Semantic
23+ // styles remain RGB; the attachment renderer quantizes them when necessary.
24+ TrueColor bool
25+ Known bool
26+ SchemeKnown bool
27+ Light bool
28+ UsePalette bool
2729 }
2830
2931 // PaletteColor returns a palette color only when palette inheritance is
@@ -591,7 +593,7 @@ func SelectionStyle(t Theme) renderer.Style {
591593 }
592594
593595 func usable(t Theme) bool {
594- return t.Known && t.TrueColor && t.HasFG && t.HasBG
596+ return t.Known && t.HasFG && t.HasBG
595597 }
596598
597599 func inverseStyle() renderer.Style {
@@ -19,11 +19,13 @@ type Theme struct {
19 PaletteKnown uint1619 PaletteKnown uint16
20 HasFG bool20 HasFG bool
21 HasBG bool21 HasBG bool
22- TrueColor bool22+ // TrueColor records output capability for transport compatibility. Semantic
23- Known bool23+ // styles remain RGB; the attachment renderer quantizes them when necessary.
24- SchemeKnown bool24+ TrueColor bool
25- Light bool25+ Known bool
26- UsePalette bool26+ SchemeKnown bool
27+ Light bool
28+ UsePalette bool
27 }29 }
28 30
29 // PaletteColor returns a palette color only when palette inheritance is31 // PaletteColor returns a palette color only when palette inheritance is
@@ -591,7 +593,7 @@ func SelectionStyle(t Theme) renderer.Style {
591 }593 }
592 594
593 func usable(t Theme) bool {595 func usable(t Theme) bool {
594- return t.Known && t.TrueColor && t.HasFG && t.HasBG596+ return t.Known && t.HasFG && t.HasBG
595 }597 }
596 598
597 func inverseStyle() renderer.Style {599 func inverseStyle() renderer.Style {
M internal/usecase/theme/theme_test.go +2 -2
@@ -630,10 +630,10 @@ func TestEmphasisStyle(t *testing.T) {
630630 want: base,
631631 },
632632 {
633- name: "theme missing truecolor returns base unchanged",
633+ name: "known theme without truecolor applies bold",
634634 theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true},
635635 base: base,
636- want: base,
636+ want: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true},
637637 },
638638 }
639639 for _, tt := range tests {
@@ -630,10 +630,10 @@ func TestEmphasisStyle(t *testing.T) {
630 want: base,630 want: base,
631 },631 },
632 {632 {
633- name: "theme missing truecolor returns base unchanged",633+ name: "known theme without truecolor applies bold",
634 theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true},634 theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true},
635 base: base,635 base: base,
636- want: base,636+ want: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true},
637 },637 },
638 }638 }
639 for _, tt := range tests {639 for _, tt := range tests {

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 12

Tags 11

↑↓ moveenter openesc close