detached 037ba94

fix(tui): address terminal theme review findings

commit
037ba94c2a9e98f78cc57c9d2c8ebce0521482c4
author
brice · · 2026-08-23 11:17 UTC
parent
0979a1c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +303 -68
M packages/agent/cli.go +14 -5
@@ -2488,14 +2488,23 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24882488 for idx, s := range initialCfg.QuickModelShortcuts {
24892489 quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
24902490 }
2491- themePreference := tui.ResolveThemePreference(initialCfg.Theme, os.Getenv("ZUT_THEME"))
2491+ themeEnv := strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME")))
2492+ if themeEnv != "" && themeEnv != "auto" && themeEnv != "dark" && themeEnv != "light" {
2493+ fmt.Fprintf(os.Stderr, "theme override: unsupported ZUT_THEME=%q; ignoring it\n", themeEnv)
2494+ themeEnv = ""
2495+ }
2496+ themePreference := tui.ResolveThemePreference(initialCfg.Theme, themeEnv)
24922497 theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), themePreference.Effective, 80*time.Millisecond)
24932498 if themeErr != nil {
24942499 fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2495- if !themePreference.Forced && initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {
2496- initialCfg.Theme = ""
2497- themePreference = tui.ResolveThemePreference("", os.Getenv("ZUT_THEME"))
2498- _ = SaveConfig(initialCfg)
2500+ if !themePreference.Forced {
2501+ themePreference = tui.ResolveThemePreference("", themeEnv)
2502+ if initialCfg.Theme != "" {
2503+ initialCfg.Theme = ""
2504+ if err := SaveConfig(initialCfg); err != nil {
2505+ fmt.Fprintln(os.Stderr, "theme reset:", err)
2506+ }
2507+ }
24992508 }
25002509 }
25012510
@@ -2488,14 +2488,23 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2488 for idx, s := range initialCfg.QuickModelShortcuts {2488 for idx, s := range initialCfg.QuickModelShortcuts {
2489 quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}2489 quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
2490 }2490 }
2491- themePreference := tui.ResolveThemePreference(initialCfg.Theme, os.Getenv("ZUT_THEME"))2491+ themeEnv := strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME")))
2492+ if themeEnv != "" && themeEnv != "auto" && themeEnv != "dark" && themeEnv != "light" {
2493+ fmt.Fprintf(os.Stderr, "theme override: unsupported ZUT_THEME=%q; ignoring it\n", themeEnv)
2494+ themeEnv = ""
2495+ }
2496+ themePreference := tui.ResolveThemePreference(initialCfg.Theme, themeEnv)
2492 theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), themePreference.Effective, 80*time.Millisecond)2497 theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), themePreference.Effective, 80*time.Millisecond)
2493 if themeErr != nil {2498 if themeErr != nil {
2494 fmt.Fprintln(os.Stderr, "theme load:", themeErr)2499 fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2495- if !themePreference.Forced && initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {2500+ if !themePreference.Forced {
2496- initialCfg.Theme = ""2501+ themePreference = tui.ResolveThemePreference("", themeEnv)
2497- themePreference = tui.ResolveThemePreference("", os.Getenv("ZUT_THEME"))2502+ if initialCfg.Theme != "" {
2498- _ = SaveConfig(initialCfg)2503+ initialCfg.Theme = ""
2504+ if err := SaveConfig(initialCfg); err != nil {
2505+ fmt.Fprintln(os.Stderr, "theme reset:", err)
2506+ }
2507+ }
2499 }2508 }
2500 }2509 }
2501 2510
M packages/agent/modes/interactive.go +17 -0
@@ -1280,6 +1280,7 @@ func (i *Interactive) Run(ctx context.Context) error {
12801280 appearanceEvents := make(chan tui.InputEvent, 256)
12811281 appearanceParser := &tui.AppearanceParser{}
12821282 appearanceParser.SetPendingColors(true)
1283+ appearanceParser.SetPendingScheme(true)
12831284 inputDone := make(chan struct{})
12841285 go func() {
12851286 defer close(inputDone)
@@ -1407,8 +1408,10 @@ func (i *Interactive) Run(ctx context.Context) error {
14071408 i.activeThemeSource = event.source
14081409 i.themeWatchWarning = ""
14091410 i.installResolvedTheme(tui.ResolveTheme(i.cfg.EffectiveThemeName, event.source, i.terminalProfile))
1411+ i.mu.Lock()
14101412 i.statusOK = "theme reloaded"
14111413 i.statusErr = ""
1414+ i.mu.Unlock()
14121415 case event.deleted:
14131416 i.activeThemeSource = nil
14141417 i.stopThemeWatch()
@@ -1418,11 +1421,15 @@ func (i *Interactive) Run(ctx context.Context) error {
14181421 _ = i.cfg.SettingsStore.SetTheme("auto")
14191422 }
14201423 i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
1424+ i.mu.Lock()
14211425 i.statusOK = "theme removed; using auto"
14221426 i.statusErr = ""
1427+ i.mu.Unlock()
14231428 case event.err != nil && event.err.Error() != i.themeWatchWarning:
14241429 i.themeWatchWarning = event.err.Error()
1430+ i.mu.Lock()
14251431 i.statusErr = "theme reload: " + event.err.Error()
1432+ i.mu.Unlock()
14261433 }
14271434 i.invalidate()
14281435 case event := <-appearanceEvents:
@@ -1444,6 +1451,14 @@ func (i *Interactive) Run(ctx context.Context) error {
14441451 profileCandidate.Light = event.Scheme.Light
14451452 profileCandidate.SchemeKnown = true
14461453 profileDirty = true
1454+ if notificationsSupported && !event.Scheme.Solicited {
1455+ // A scheme notification means the terminal defaults and palette
1456+ // may have changed too. Refresh the complete profile atomically.
1457+ appearanceParser.SetPendingColors(true)
1458+ appearanceParser.SetPendingScheme(true)
1459+ appearanceDeadline = time.Now().Add(500 * time.Millisecond)
1460+ _, _ = term.Write([]byte(tui.AppearanceQuery()))
1461+ }
14471462 case event.Mode != nil && event.Mode.Mode == 2031:
14481463 switch event.Mode.Status {
14491464 case 1, 3:
@@ -1555,6 +1570,7 @@ func (i *Interactive) Run(ctx context.Context) error {
15551570 now := time.Now()
15561571 if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) {
15571572 appearanceParser.SetPendingColors(false)
1573+ appearanceParser.SetPendingScheme(false)
15581574 appearanceDeadline = time.Time{}
15591575 }
15601576 if profileDirty {
@@ -1563,6 +1579,7 @@ func (i *Interactive) Run(ctx context.Context) error {
15631579 }
15641580 if !notificationsSupported && now.Sub(lastAppearancePoll) >= 2*time.Second {
15651581 appearanceParser.SetPendingColors(true)
1582+ appearanceParser.SetPendingScheme(true)
15661583 appearanceDeadline = now.Add(500 * time.Millisecond)
15671584 lastAppearancePoll = now
15681585 _, _ = term.Write([]byte("\x1b]11;?\a" + tui.SeqQueryColorScheme))
@@ -1280,6 +1280,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1280 appearanceEvents := make(chan tui.InputEvent, 256)1280 appearanceEvents := make(chan tui.InputEvent, 256)
1281 appearanceParser := &tui.AppearanceParser{}1281 appearanceParser := &tui.AppearanceParser{}
1282 appearanceParser.SetPendingColors(true)1282 appearanceParser.SetPendingColors(true)
1283+ appearanceParser.SetPendingScheme(true)
1283 inputDone := make(chan struct{})1284 inputDone := make(chan struct{})
1284 go func() {1285 go func() {
1285 defer close(inputDone)1286 defer close(inputDone)
@@ -1407,8 +1408,10 @@ func (i *Interactive) Run(ctx context.Context) error {
1407 i.activeThemeSource = event.source1408 i.activeThemeSource = event.source
1408 i.themeWatchWarning = ""1409 i.themeWatchWarning = ""
1409 i.installResolvedTheme(tui.ResolveTheme(i.cfg.EffectiveThemeName, event.source, i.terminalProfile))1410 i.installResolvedTheme(tui.ResolveTheme(i.cfg.EffectiveThemeName, event.source, i.terminalProfile))
1411+ i.mu.Lock()
1410 i.statusOK = "theme reloaded"1412 i.statusOK = "theme reloaded"
1411 i.statusErr = ""1413 i.statusErr = ""
1414+ i.mu.Unlock()
1412 case event.deleted:1415 case event.deleted:
1413 i.activeThemeSource = nil1416 i.activeThemeSource = nil
1414 i.stopThemeWatch()1417 i.stopThemeWatch()
@@ -1418,11 +1421,15 @@ func (i *Interactive) Run(ctx context.Context) error {
1418 _ = i.cfg.SettingsStore.SetTheme("auto")1421 _ = i.cfg.SettingsStore.SetTheme("auto")
1419 }1422 }
1420 i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))1423 i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
1424+ i.mu.Lock()
1421 i.statusOK = "theme removed; using auto"1425 i.statusOK = "theme removed; using auto"
1422 i.statusErr = ""1426 i.statusErr = ""
1427+ i.mu.Unlock()
1423 case event.err != nil && event.err.Error() != i.themeWatchWarning:1428 case event.err != nil && event.err.Error() != i.themeWatchWarning:
1424 i.themeWatchWarning = event.err.Error()1429 i.themeWatchWarning = event.err.Error()
1430+ i.mu.Lock()
1425 i.statusErr = "theme reload: " + event.err.Error()1431 i.statusErr = "theme reload: " + event.err.Error()
1432+ i.mu.Unlock()
1426 }1433 }
1427 i.invalidate()1434 i.invalidate()
1428 case event := <-appearanceEvents:1435 case event := <-appearanceEvents:
@@ -1444,6 +1451,14 @@ func (i *Interactive) Run(ctx context.Context) error {
1444 profileCandidate.Light = event.Scheme.Light1451 profileCandidate.Light = event.Scheme.Light
1445 profileCandidate.SchemeKnown = true1452 profileCandidate.SchemeKnown = true
1446 profileDirty = true1453 profileDirty = true
1454+ if notificationsSupported && !event.Scheme.Solicited {
1455+ // A scheme notification means the terminal defaults and palette
1456+ // may have changed too. Refresh the complete profile atomically.
1457+ appearanceParser.SetPendingColors(true)
1458+ appearanceParser.SetPendingScheme(true)
1459+ appearanceDeadline = time.Now().Add(500 * time.Millisecond)
1460+ _, _ = term.Write([]byte(tui.AppearanceQuery()))
1461+ }
1447 case event.Mode != nil && event.Mode.Mode == 2031:1462 case event.Mode != nil && event.Mode.Mode == 2031:
1448 switch event.Mode.Status {1463 switch event.Mode.Status {
1449 case 1, 3:1464 case 1, 3:
@@ -1555,6 +1570,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1555 now := time.Now()1570 now := time.Now()
1556 if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) {1571 if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) {
1557 appearanceParser.SetPendingColors(false)1572 appearanceParser.SetPendingColors(false)
1573+ appearanceParser.SetPendingScheme(false)
1558 appearanceDeadline = time.Time{}1574 appearanceDeadline = time.Time{}
1559 }1575 }
1560 if profileDirty {1576 if profileDirty {
@@ -1563,6 +1579,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1563 }1579 }
1564 if !notificationsSupported && now.Sub(lastAppearancePoll) >= 2*time.Second {1580 if !notificationsSupported && now.Sub(lastAppearancePoll) >= 2*time.Second {
1565 appearanceParser.SetPendingColors(true)1581 appearanceParser.SetPendingColors(true)
1582+ appearanceParser.SetPendingScheme(true)
1566 appearanceDeadline = now.Add(500 * time.Millisecond)1583 appearanceDeadline = now.Add(500 * time.Millisecond)
1567 lastAppearancePoll = now1584 lastAppearancePoll = now
1568 _, _ = term.Write([]byte("\x1b]11;?\a" + tui.SeqQueryColorScheme))1585 _, _ = term.Write([]byte("\x1b]11;?\a" + tui.SeqQueryColorScheme))
M packages/agent/modes/interactive_settings.go +1 -1
@@ -1228,8 +1228,8 @@ func (i *Interactive) installResolvedTheme(resolution tui.ThemeResolution) {
12281228 i.view.InvalidateRenderCache()
12291229 i.chatCacheValid = false
12301230 i.stableChatCacheValid = false
1231- i.mu.Unlock()
12321231 i.ed.Prompt = th.AccentBar(th.Accent)
1232+ i.mu.Unlock()
12331233 i.btwDialog.setTheme(th)
12341234 if i.residentChildSession != nil {
12351235 i.residentChildSession.setTheme(th)
@@ -1228,8 +1228,8 @@ func (i *Interactive) installResolvedTheme(resolution tui.ThemeResolution) {
1228 i.view.InvalidateRenderCache()1228 i.view.InvalidateRenderCache()
1229 i.chatCacheValid = false1229 i.chatCacheValid = false
1230 i.stableChatCacheValid = false1230 i.stableChatCacheValid = false
1231- i.mu.Unlock()
1232 i.ed.Prompt = th.AccentBar(th.Accent)1231 i.ed.Prompt = th.AccentBar(th.Accent)
1232+ i.mu.Unlock()
1233 i.btwDialog.setTheme(th)1233 i.btwDialog.setTheme(th)
1234 if i.residentChildSession != nil {1234 if i.residentChildSession != nil {
1235 i.residentChildSession.setTheme(th)1235 i.residentChildSession.setTheme(th)
M packages/agent/modes/theme_watch.go +3 -1
@@ -78,9 +78,11 @@ func watchThemeSourceTicks(ctx context.Context, home, preference string, accepte
7878 }
7979 continue
8080 }
81+ // A present-but-invalid revision remains watchable and must restart
82+ // the deletion grace period just like a valid replacement does.
83+ missingSince = time.Time{}
8184 source, err := tui.LoadThemeSource(home, preference)
8285 if err == nil && source != nil {
83- missingSince = time.Time{}
8486 lastError = ""
8587 if source.Path != accepted.Path || source.Digest == lastDigest {
8688 continue
@@ -78,9 +78,11 @@ func watchThemeSourceTicks(ctx context.Context, home, preference string, accepte
78 }78 }
79 continue79 continue
80 }80 }
81+ // A present-but-invalid revision remains watchable and must restart
82+ // the deletion grace period just like a valid replacement does.
83+ missingSince = time.Time{}
81 source, err := tui.LoadThemeSource(home, preference)84 source, err := tui.LoadThemeSource(home, preference)
82 if err == nil && source != nil {85 if err == nil && source != nil {
83- missingSince = time.Time{}
84 lastError = ""86 lastError = ""
85 if source.Path != accepted.Path || source.Digest == lastDigest {87 if source.Path != accepted.Path || source.Digest == lastDigest {
86 continue88 continue
M packages/agent/modes/theme_watch_test.go +26 -8
@@ -31,7 +31,7 @@ func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
3131 }
3232 ctx, cancel := context.WithCancel(context.Background())
3333 defer cancel()
34- ticks := make(chan time.Time, 3)
34+ ticks := make(chan time.Time)
3535 events := make(chan themeWatchEvent, 3)
3636 go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
3737
@@ -62,30 +62,48 @@ func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
6262 }
6363 }
6464
65-func TestWatchThemeSourceTicksDelaysPersistentDeletion(t *testing.T) {
65+func TestWatchThemeSourceTicksRestartsDeletionGraceAfterInvalidReappearance(t *testing.T) {
6666 home := t.TempDir()
6767 path := writeWatchedTheme(t, home, `{}`)
6868 accepted, err := tui.LoadThemeSource(home, "live")
6969 if err != nil {
7070 t.Fatal(err)
7171 }
72- if err := os.Remove(path); err != nil {
73- t.Fatal(err)
74- }
7572 ctx, cancel := context.WithCancel(context.Background())
7673 defer cancel()
77- ticks := make(chan time.Time, 3)
74+ ticks := make(chan time.Time)
7875 events := make(chan themeWatchEvent, 1)
7976 go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
77+
78+ if err := os.Remove(path); err != nil {
79+ t.Fatal(err)
80+ }
8081 ticks <- time.Unix(1, 0)
82+ if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {
83+ t.Fatal(err)
84+ }
8185 ticks <- time.Unix(2, 0)
8286 select {
8387 case event := <-events:
84- t.Fatalf("deletion happened before grace period: %#v", event)
85- default:
88+ if event.err == nil {
89+ t.Fatalf("invalid reappearance event = %#v", event)
90+ }
91+ case <-time.After(time.Second):
92+ t.Fatal("watcher did not report invalid reappearance")
93+ }
94+
95+ if err := os.Remove(path); err != nil {
96+ t.Fatal(err)
8697 }
98+ ticks <- time.Unix(3, 0)
8799 ticks <- time.Unix(4, 0)
88100 select {
101+ case event := <-events:
102+ t.Fatalf("deletion happened before restarted grace period: %#v", event)
103+ default:
104+ }
105+ ticks <- time.Unix(6, 0)
106+ select {
89107 case event := <-events:
90108 if !event.deleted {
91109 t.Fatalf("persistent deletion event = %#v", event)
@@ -31,7 +31,7 @@ func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
31 }31 }
32 ctx, cancel := context.WithCancel(context.Background())32 ctx, cancel := context.WithCancel(context.Background())
33 defer cancel()33 defer cancel()
34- ticks := make(chan time.Time, 3)34+ ticks := make(chan time.Time)
35 events := make(chan themeWatchEvent, 3)35 events := make(chan themeWatchEvent, 3)
36 go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)36 go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
37 37
@@ -62,30 +62,48 @@ func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
62 }62 }
63 }63 }
64 64
65-func TestWatchThemeSourceTicksDelaysPersistentDeletion(t *testing.T) {65+func TestWatchThemeSourceTicksRestartsDeletionGraceAfterInvalidReappearance(t *testing.T) {
66 home := t.TempDir()66 home := t.TempDir()
67 path := writeWatchedTheme(t, home, `{}`)67 path := writeWatchedTheme(t, home, `{}`)
68 accepted, err := tui.LoadThemeSource(home, "live")68 accepted, err := tui.LoadThemeSource(home, "live")
69 if err != nil {69 if err != nil {
70 t.Fatal(err)70 t.Fatal(err)
71 }71 }
72- if err := os.Remove(path); err != nil {
73- t.Fatal(err)
74- }
75 ctx, cancel := context.WithCancel(context.Background())72 ctx, cancel := context.WithCancel(context.Background())
76 defer cancel()73 defer cancel()
77- ticks := make(chan time.Time, 3)74+ ticks := make(chan time.Time)
78 events := make(chan themeWatchEvent, 1)75 events := make(chan themeWatchEvent, 1)
79 go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)76 go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
77+
78+ if err := os.Remove(path); err != nil {
79+ t.Fatal(err)
80+ }
80 ticks <- time.Unix(1, 0)81 ticks <- time.Unix(1, 0)
82+ if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {
83+ t.Fatal(err)
84+ }
81 ticks <- time.Unix(2, 0)85 ticks <- time.Unix(2, 0)
82 select {86 select {
83 case event := <-events:87 case event := <-events:
84- t.Fatalf("deletion happened before grace period: %#v", event)88+ if event.err == nil {
85- default:89+ t.Fatalf("invalid reappearance event = %#v", event)
90+ }
91+ case <-time.After(time.Second):
92+ t.Fatal("watcher did not report invalid reappearance")
93+ }
94+
95+ if err := os.Remove(path); err != nil {
96+ t.Fatal(err)
86 }97 }
98+ ticks <- time.Unix(3, 0)
87 ticks <- time.Unix(4, 0)99 ticks <- time.Unix(4, 0)
88 select {100 select {
101+ case event := <-events:
102+ t.Fatalf("deletion happened before restarted grace period: %#v", event)
103+ default:
104+ }
105+ ticks <- time.Unix(6, 0)
106+ select {
89 case event := <-events:107 case event := <-events:
90 if !event.deleted {108 if !event.deleted {
91 t.Fatalf("persistent deletion event = %#v", event)109 t.Fatalf("persistent deletion event = %#v", event)
M packages/tui/appearance_protocol.go +39 -20
@@ -1,13 +1,14 @@
11 package tui
22
33 import (
4+ "bytes"
45 "strconv"
56 "strings"
67 "sync"
78 "time"
89 )
910
10-// SchemeEvent is a reported terminal color-scheme preference.
11+// Appearance query and DEC mode 2031 control sequences.
1112 const (
1213 SeqQueryColorScheme = "\x1b[?996n"
1314 SeqQueryMode2031 = "\x1b[?2031$p"
@@ -32,8 +33,12 @@ func AppearanceQuery() string {
3233 return b.String()
3334 }
3435
35-// SchemeEvent is a reported terminal color-scheme preference.
36-type SchemeEvent struct{ Light bool }
36+// SchemeEvent is a reported terminal color-scheme preference. Solicited is
37+// true only for the reply to a query initiated by zut.
38+type SchemeEvent struct {
39+ Light bool
40+ Solicited bool
41+}
3742
3843 // ColorEvent is one default foreground/background or ANSI-palette reply.
3944 type ColorEvent struct {
@@ -65,6 +70,7 @@ type InputEvent struct {
6570 type AppearanceParser struct {
6671 mu sync.RWMutex
6772 pendingColors bool
73+ pendingScheme bool
6874 notifications bool
6975 }
7076
@@ -80,6 +86,14 @@ func (p *AppearanceParser) SetNotifications(enabled bool) {
8086 p.mu.Unlock()
8187 }
8288
89+// SetPendingScheme permits one solicited 997 response and marks it so the
90+// event loop does not mistake it for a new terminal notification.
91+func (p *AppearanceParser) SetPendingScheme(pending bool) {
92+ p.mu.Lock()
93+ p.pendingScheme = pending
94+ p.mu.Unlock()
95+}
96+
8397 func (p *AppearanceParser) acceptsColors() bool {
8498 p.mu.RLock()
8599 defer p.mu.RUnlock()
@@ -92,6 +106,16 @@ func (p *AppearanceParser) acceptsNotifications() bool {
92106 return p.notifications
93107 }
94108
109+func (p *AppearanceParser) consumeScheme() (solicited bool, accepted bool) {
110+ p.mu.Lock()
111+ defer p.mu.Unlock()
112+ if p.pendingScheme {
113+ p.pendingScheme = false
114+ return true, true
115+ }
116+ return false, p.notifications
117+}
118+
95119 // ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence.
96120 // Combined OSC 4 responses produce one ColorEvent for every valid pair.
97121 func ParseAppearanceOSC(raw []byte) []ColorEvent {
@@ -186,7 +210,11 @@ func (s *AppearanceSource) ReadByte() (byte, error) {
186210 s.trackPaste(b)
187211 return b, err
188212 }
189- next, ok, err := s.peek(time.Millisecond)
213+ lookahead := time.Millisecond
214+ if s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) {
215+ lookahead = 50 * time.Millisecond
216+ }
217+ next, ok, err := s.peek(lookahead)
190218 if err != nil || !ok || (next != ']' && next != '[') {
191219 if ok {
192220 s.pending = append(s.pending, next)
@@ -213,13 +241,16 @@ func (s *AppearanceSource) ReadByte() (byte, error) {
213241 if err != nil {
214242 return 0, err
215243 }
216- if bytesEqual(sequence, []byte("\x1b[200~")) {
244+ if bytes.Equal(sequence, []byte("\x1b[200~")) {
217245 s.inPaste = true
218246 }
219247 scheme, mode := ParseAppearanceCSI(sequence)
220- if scheme != nil && s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) {
221- s.emit(InputEvent{Scheme: scheme})
222- continue
248+ if scheme != nil && s.parser != nil {
249+ if solicited, accepted := s.parser.consumeScheme(); accepted {
250+ scheme.Solicited = solicited
251+ s.emit(InputEvent{Scheme: scheme})
252+ continue
253+ }
223254 }
224255 if mode != nil {
225256 s.emit(InputEvent{Mode: mode})
@@ -274,15 +305,3 @@ func (s *AppearanceSource) trackPaste(b byte) {
274305 s.pasteTail = nil
275306 }
276307 }
277-
278-func bytesEqual(a, b []byte) bool {
279- if len(a) != len(b) {
280- return false
281- }
282- for i := range a {
283- if a[i] != b[i] {
284- return false
285- }
286- }
287- return true
288-}
@@ -1,13 +1,14 @@
1 package tui1 package tui
2 2
3 import (3 import (
4+ "bytes"
4 "strconv"5 "strconv"
5 "strings"6 "strings"
6 "sync"7 "sync"
7 "time"8 "time"
8 )9 )
9 10
10-// SchemeEvent is a reported terminal color-scheme preference.11+// Appearance query and DEC mode 2031 control sequences.
11 const (12 const (
12 SeqQueryColorScheme = "\x1b[?996n"13 SeqQueryColorScheme = "\x1b[?996n"
13 SeqQueryMode2031 = "\x1b[?2031$p"14 SeqQueryMode2031 = "\x1b[?2031$p"
@@ -32,8 +33,12 @@ func AppearanceQuery() string {
32 return b.String()33 return b.String()
33 }34 }
34 35
35-// SchemeEvent is a reported terminal color-scheme preference.36+// SchemeEvent is a reported terminal color-scheme preference. Solicited is
36-type SchemeEvent struct{ Light bool }37+// true only for the reply to a query initiated by zut.
38+type SchemeEvent struct {
39+ Light bool
40+ Solicited bool
41+}
37 42
38 // ColorEvent is one default foreground/background or ANSI-palette reply.43 // ColorEvent is one default foreground/background or ANSI-palette reply.
39 type ColorEvent struct {44 type ColorEvent struct {
@@ -65,6 +70,7 @@ type InputEvent struct {
65 type AppearanceParser struct {70 type AppearanceParser struct {
66 mu sync.RWMutex71 mu sync.RWMutex
67 pendingColors bool72 pendingColors bool
73+ pendingScheme bool
68 notifications bool74 notifications bool
69 }75 }
70 76
@@ -80,6 +86,14 @@ func (p *AppearanceParser) SetNotifications(enabled bool) {
80 p.mu.Unlock()86 p.mu.Unlock()
81 }87 }
82 88
89+// SetPendingScheme permits one solicited 997 response and marks it so the
90+// event loop does not mistake it for a new terminal notification.
91+func (p *AppearanceParser) SetPendingScheme(pending bool) {
92+ p.mu.Lock()
93+ p.pendingScheme = pending
94+ p.mu.Unlock()
95+}
96+
83 func (p *AppearanceParser) acceptsColors() bool {97 func (p *AppearanceParser) acceptsColors() bool {
84 p.mu.RLock()98 p.mu.RLock()
85 defer p.mu.RUnlock()99 defer p.mu.RUnlock()
@@ -92,6 +106,16 @@ func (p *AppearanceParser) acceptsNotifications() bool {
92 return p.notifications106 return p.notifications
93 }107 }
94 108
109+func (p *AppearanceParser) consumeScheme() (solicited bool, accepted bool) {
110+ p.mu.Lock()
111+ defer p.mu.Unlock()
112+ if p.pendingScheme {
113+ p.pendingScheme = false
114+ return true, true
115+ }
116+ return false, p.notifications
117+}
118+
95 // ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence.119 // ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence.
96 // Combined OSC 4 responses produce one ColorEvent for every valid pair.120 // Combined OSC 4 responses produce one ColorEvent for every valid pair.
97 func ParseAppearanceOSC(raw []byte) []ColorEvent {121 func ParseAppearanceOSC(raw []byte) []ColorEvent {
@@ -186,7 +210,11 @@ func (s *AppearanceSource) ReadByte() (byte, error) {
186 s.trackPaste(b)210 s.trackPaste(b)
187 return b, err211 return b, err
188 }212 }
189- next, ok, err := s.peek(time.Millisecond)213+ lookahead := time.Millisecond
214+ if s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) {
215+ lookahead = 50 * time.Millisecond
216+ }
217+ next, ok, err := s.peek(lookahead)
190 if err != nil || !ok || (next != ']' && next != '[') {218 if err != nil || !ok || (next != ']' && next != '[') {
191 if ok {219 if ok {
192 s.pending = append(s.pending, next)220 s.pending = append(s.pending, next)
@@ -213,13 +241,16 @@ func (s *AppearanceSource) ReadByte() (byte, error) {
213 if err != nil {241 if err != nil {
214 return 0, err242 return 0, err
215 }243 }
216- if bytesEqual(sequence, []byte("\x1b[200~")) {244+ if bytes.Equal(sequence, []byte("\x1b[200~")) {
217 s.inPaste = true245 s.inPaste = true
218 }246 }
219 scheme, mode := ParseAppearanceCSI(sequence)247 scheme, mode := ParseAppearanceCSI(sequence)
220- if scheme != nil && s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) {248+ if scheme != nil && s.parser != nil {
221- s.emit(InputEvent{Scheme: scheme})249+ if solicited, accepted := s.parser.consumeScheme(); accepted {
222- continue250+ scheme.Solicited = solicited
251+ s.emit(InputEvent{Scheme: scheme})
252+ continue
253+ }
223 }254 }
224 if mode != nil {255 if mode != nil {
225 s.emit(InputEvent{Mode: mode})256 s.emit(InputEvent{Mode: mode})
@@ -274,15 +305,3 @@ func (s *AppearanceSource) trackPaste(b byte) {
274 s.pasteTail = nil305 s.pasteTail = nil
275 }306 }
276 }307 }
277-
278-func bytesEqual(a, b []byte) bool {
279- if len(a) != len(b) {
280- return false
281- }
282- for i := range a {
283- if a[i] != b[i] {
284- return false
285- }
286- }
287- return true
288-}
M packages/tui/appearance_protocol_test.go +43 -2
@@ -30,7 +30,10 @@ func TestParseAppearanceCSI(t *testing.T) {
3030 }
3131 }
3232
33-type appearanceBytes struct{ data []byte }
33+type appearanceBytes struct {
34+ data []byte
35+ minPeek time.Duration
36+}
3437
3538 func (s *appearanceBytes) read() (byte, error) {
3639 if len(s.data) == 0 {
@@ -41,7 +44,10 @@ func (s *appearanceBytes) read() (byte, error) {
4144 return b, nil
4245 }
4346
44-func (s *appearanceBytes) peek(time.Duration) (byte, bool, error) {
47+func (s *appearanceBytes) peek(timeout time.Duration) (byte, bool, error) {
48+ if timeout < s.minPeek {
49+ return 0, false, nil
50+ }
4551 if len(s.data) == 0 {
4652 return 0, false, nil
4753 }
@@ -75,6 +81,41 @@ func TestAppearanceSourceConsumesOnlyPendingReplies(t *testing.T) {
7581 }
7682 }
7783
84+func TestAppearanceSourceAcceptsDelayedReplyWhileQueryPending(t *testing.T) {
85+ input := &appearanceBytes{data: []byte("\x1b]11;#123456\a"), minPeek: 2 * time.Millisecond}
86+ parser := &AppearanceParser{}
87+ parser.SetPendingColors(true)
88+ var events []InputEvent
89+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
90+ if _, err := source.ReadByte(); err != io.EOF {
91+ t.Fatalf("ReadByte error = %v, want EOF after consuming reply", err)
92+ }
93+ if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 {
94+ t.Fatalf("delayed reply events = %#v", events)
95+ }
96+}
97+
98+func TestAppearanceSourceMarksOnlyPendingSchemeReplySolicited(t *testing.T) {
99+ input := &appearanceBytes{data: []byte("\x1b[?997;1n\x1b[?997;2n")}
100+ parser := &AppearanceParser{}
101+ parser.SetPendingScheme(true)
102+ parser.SetNotifications(true)
103+ var events []InputEvent
104+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
105+ for {
106+ _, err := source.ReadByte()
107+ if err == io.EOF {
108+ break
109+ }
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ }
114+ if len(events) != 2 || events[0].Scheme == nil || events[1].Scheme == nil || !events[0].Scheme.Solicited || events[1].Scheme.Solicited {
115+ t.Fatalf("scheme events = %#v", events)
116+ }
117+}
118+
78119 func TestAppearanceSourceLeavesPasteOpaque(t *testing.T) {
79120 input := &appearanceBytes{data: []byte("\x1b[200~x\x1b]11;#123456\ay\x1b[201~")}
80121 parser := &AppearanceParser{}
@@ -30,7 +30,10 @@ func TestParseAppearanceCSI(t *testing.T) {
30 }30 }
31 }31 }
32 32
33-type appearanceBytes struct{ data []byte }33+type appearanceBytes struct {
34+ data []byte
35+ minPeek time.Duration
36+}
34 37
35 func (s *appearanceBytes) read() (byte, error) {38 func (s *appearanceBytes) read() (byte, error) {
36 if len(s.data) == 0 {39 if len(s.data) == 0 {
@@ -41,7 +44,10 @@ func (s *appearanceBytes) read() (byte, error) {
41 return b, nil44 return b, nil
42 }45 }
43 46
44-func (s *appearanceBytes) peek(time.Duration) (byte, bool, error) {47+func (s *appearanceBytes) peek(timeout time.Duration) (byte, bool, error) {
48+ if timeout < s.minPeek {
49+ return 0, false, nil
50+ }
45 if len(s.data) == 0 {51 if len(s.data) == 0 {
46 return 0, false, nil52 return 0, false, nil
47 }53 }
@@ -75,6 +81,41 @@ func TestAppearanceSourceConsumesOnlyPendingReplies(t *testing.T) {
75 }81 }
76 }82 }
77 83
84+func TestAppearanceSourceAcceptsDelayedReplyWhileQueryPending(t *testing.T) {
85+ input := &appearanceBytes{data: []byte("\x1b]11;#123456\a"), minPeek: 2 * time.Millisecond}
86+ parser := &AppearanceParser{}
87+ parser.SetPendingColors(true)
88+ var events []InputEvent
89+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
90+ if _, err := source.ReadByte(); err != io.EOF {
91+ t.Fatalf("ReadByte error = %v, want EOF after consuming reply", err)
92+ }
93+ if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 {
94+ t.Fatalf("delayed reply events = %#v", events)
95+ }
96+}
97+
98+func TestAppearanceSourceMarksOnlyPendingSchemeReplySolicited(t *testing.T) {
99+ input := &appearanceBytes{data: []byte("\x1b[?997;1n\x1b[?997;2n")}
100+ parser := &AppearanceParser{}
101+ parser.SetPendingScheme(true)
102+ parser.SetNotifications(true)
103+ var events []InputEvent
104+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
105+ for {
106+ _, err := source.ReadByte()
107+ if err == io.EOF {
108+ break
109+ }
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ }
114+ if len(events) != 2 || events[0].Scheme == nil || events[1].Scheme == nil || !events[0].Scheme.Solicited || events[1].Scheme.Solicited {
115+ t.Fatalf("scheme events = %#v", events)
116+ }
117+}
118+
78 func TestAppearanceSourceLeavesPasteOpaque(t *testing.T) {119 func TestAppearanceSourceLeavesPasteOpaque(t *testing.T) {
79 input := &appearanceBytes{data: []byte("\x1b[200~x\x1b]11;#123456\ay\x1b[201~")}120 input := &appearanceBytes{data: []byte("\x1b[200~x\x1b]11;#123456\ay\x1b[201~")}
80 parser := &AppearanceParser{}121 parser := &AppearanceParser{}
M packages/tui/highlight.go +118 -5
@@ -2,8 +2,8 @@ package tui
22
33 import (
44 "bytes"
5- "fmt"
65 "path/filepath"
6+ "strconv"
77 "strings"
88 "sync"
99
@@ -82,14 +82,41 @@ func formatTerminalTokens(th Theme, iterator chroma.Iterator) []string {
8282 if italic {
8383 prefix += "\x1b[3m"
8484 }
85- out.WriteString(prefix)
86- out.WriteString(token.Value)
87- out.WriteString(reset)
85+ // Coalesced Chroma tokens can span rows. Close the style before each
86+ // newline and reopen it for the next segment so every rendered row is
87+ // self-contained and cannot bleed into padding or following text.
88+ for index, segment := range strings.Split(token.Value, "\n") {
89+ if index > 0 {
90+ out.WriteByte('\n')
91+ }
92+ if segment == "" {
93+ continue
94+ }
95+ out.WriteString(prefix)
96+ out.WriteString(segment)
97+ out.WriteString(reset)
98+ }
8899 }
89100 return strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
90101 }
91102
92103 func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
104+ color, bold, italic := terminalTokenDefault(th, typ)
105+ if entry, ok := terminalSyntaxEntry(th, typ); ok {
106+ if entry.Colour.IsSet() {
107+ color = ColorRGB(int(entry.Colour.Red()), int(entry.Colour.Green()), int(entry.Colour.Blue()))
108+ }
109+ if entry.Bold != chroma.Pass {
110+ bold = entry.Bold == chroma.Yes
111+ }
112+ if entry.Italic != chroma.Pass {
113+ italic = entry.Italic == chroma.Yes
114+ }
115+ }
116+ return color, bold, italic
117+}
118+
119+func terminalTokenDefault(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
93120 switch {
94121 case typ.InCategory(chroma.Comment):
95122 return th.Muted, false, true
@@ -108,6 +135,67 @@ func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bo
108135 }
109136 }
110137
138+func terminalSyntaxEntry(th Theme, typ chroma.TokenType) (chroma.StyleEntry, bool) {
139+ if entry := terminalSyntaxOverride(th.Syntax, typ); entry != "" {
140+ parsed, err := chroma.ParseStyleEntry(entry)
141+ return parsed, err == nil
142+ }
143+ if th.SyntaxBaseStyle != "" {
144+ return styles.Get(th.SyntaxBaseStyle).Get(typ), true
145+ }
146+ return chroma.StyleEntry{}, false
147+}
148+
149+func terminalSyntaxOverride(s SyntaxTheme, typ chroma.TokenType) string {
150+ switch {
151+ case typ == chroma.KeywordConstant:
152+ return firstSyntaxOverride(s.KeywordConstant, s.Keyword)
153+ case typ == chroma.KeywordDeclaration:
154+ return firstSyntaxOverride(s.KeywordDeclaration, s.Keyword)
155+ case typ == chroma.KeywordNamespace:
156+ return firstSyntaxOverride(s.KeywordNamespace, s.Keyword)
157+ case typ == chroma.KeywordReserved:
158+ return firstSyntaxOverride(s.KeywordReserved, s.Keyword)
159+ case typ == chroma.KeywordType:
160+ return firstSyntaxOverride(s.KeywordType, s.Keyword)
161+ case typ.InCategory(chroma.Keyword):
162+ return s.Keyword
163+ case typ == chroma.NameBuiltin:
164+ return firstSyntaxOverride(s.NameBuiltin, s.NameFunction)
165+ case typ == chroma.NameFunction:
166+ return s.NameFunction
167+ case typ == chroma.NameClass:
168+ return firstSyntaxOverride(s.NameClass, s.NameFunction)
169+ case typ == chroma.NameDecorator:
170+ return firstSyntaxOverride(s.NameDecorator, s.NameFunction)
171+ case typ == chroma.LiteralStringEscape:
172+ return firstSyntaxOverride(s.LiteralStringEscape, s.LiteralString)
173+ case typ.InSubCategory(chroma.LiteralString):
174+ return s.LiteralString
175+ case typ.InSubCategory(chroma.LiteralNumber):
176+ return s.LiteralNumber
177+ case typ == chroma.CommentPreproc:
178+ return firstSyntaxOverride(s.CommentPreproc, s.Comment)
179+ case typ.InCategory(chroma.Comment):
180+ return s.Comment
181+ case typ.InCategory(chroma.Operator):
182+ return s.Operator
183+ case typ.InCategory(chroma.Punctuation):
184+ return s.Punctuation
185+ default:
186+ return s.Text
187+ }
188+}
189+
190+func firstSyntaxOverride(entries ...string) string {
191+ for _, entry := range entries {
192+ if entry != "" {
193+ return entry
194+ }
195+ }
196+ return ""
197+}
198+
111199 // highlightResultCache is a simple LRU-ish cache: when it exceeds its
112200 // capacity we drop half of the entries (the oldest, based on insertion
113201 // order). That's good enough since tool_result text doesn't change once
@@ -288,7 +376,7 @@ func (th Theme) chromaStyle() *chroma.Style {
288376 func (th Theme) syntaxKey() string {
289377 syn := th.Syntax
290378 return strings.Join([]string{
291- fmt.Sprintf("%v:%t:%#v", th.Terminal.ColorDepth(), th.UseTerminalPalette, th.Terminal),
379+ th.terminalSyntaxKeyPart(),
292380 th.SyntaxBaseStyle,
293381 syn.Keyword,
294382 syn.KeywordConstant,
@@ -311,6 +399,31 @@ func (th Theme) syntaxKey() string {
311399 }, "\x1f")
312400 }
313401
402+func (th Theme) terminalSyntaxKeyPart() string {
403+ h := fnv64aInit
404+ h = fnv64aWriteByte(h, byte(th.Terminal.ColorDepth()))
405+ if th.UseTerminalPalette {
406+ h = fnv64aWriteByte(h, 1)
407+ } else {
408+ h = fnv64aWriteByte(h, 0)
409+ }
410+ h = fnv64aWriteUint64(h, uint64(th.Terminal.PaletteKnown))
411+ for _, color := range th.Terminal.Palette {
412+ h = hashThemeColor(h, color)
413+ }
414+ for _, color := range []TerminalColor{th.Terminal.Foreground, th.Terminal.Background} {
415+ h = hashThemeColor(h, color)
416+ }
417+ for _, value := range []bool{th.Terminal.HasForeground, th.Terminal.HasBackground, th.Terminal.TrueColor, th.Terminal.Light, th.Terminal.SchemeKnown} {
418+ if value {
419+ h = fnv64aWriteByte(h, 1)
420+ } else {
421+ h = fnv64aWriteByte(h, 0)
422+ }
423+ }
424+ return strconv.FormatUint(h, 16)
425+}
426+
314427 // extLang maps file extensions to chroma lexer names. Only
315428 // extensions that chroma supports are included.
316429 var extLang = map[string]string{
@@ -2,8 +2,8 @@ package tui
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5- "fmt"
6 "path/filepath"5 "path/filepath"
6+ "strconv"
7 "strings"7 "strings"
8 "sync"8 "sync"
9 9
@@ -82,14 +82,41 @@ func formatTerminalTokens(th Theme, iterator chroma.Iterator) []string {
82 if italic {82 if italic {
83 prefix += "\x1b[3m"83 prefix += "\x1b[3m"
84 }84 }
85- out.WriteString(prefix)85+ // Coalesced Chroma tokens can span rows. Close the style before each
86- out.WriteString(token.Value)86+ // newline and reopen it for the next segment so every rendered row is
87- out.WriteString(reset)87+ // self-contained and cannot bleed into padding or following text.
88+ for index, segment := range strings.Split(token.Value, "\n") {
89+ if index > 0 {
90+ out.WriteByte('\n')
91+ }
92+ if segment == "" {
93+ continue
94+ }
95+ out.WriteString(prefix)
96+ out.WriteString(segment)
97+ out.WriteString(reset)
98+ }
88 }99 }
89 return strings.Split(strings.TrimRight(out.String(), "\n"), "\n")100 return strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
90 }101 }
91 102
92 func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {103 func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
104+ color, bold, italic := terminalTokenDefault(th, typ)
105+ if entry, ok := terminalSyntaxEntry(th, typ); ok {
106+ if entry.Colour.IsSet() {
107+ color = ColorRGB(int(entry.Colour.Red()), int(entry.Colour.Green()), int(entry.Colour.Blue()))
108+ }
109+ if entry.Bold != chroma.Pass {
110+ bold = entry.Bold == chroma.Yes
111+ }
112+ if entry.Italic != chroma.Pass {
113+ italic = entry.Italic == chroma.Yes
114+ }
115+ }
116+ return color, bold, italic
117+}
118+
119+func terminalTokenDefault(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
93 switch {120 switch {
94 case typ.InCategory(chroma.Comment):121 case typ.InCategory(chroma.Comment):
95 return th.Muted, false, true122 return th.Muted, false, true
@@ -108,6 +135,67 @@ func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bo
108 }135 }
109 }136 }
110 137
138+func terminalSyntaxEntry(th Theme, typ chroma.TokenType) (chroma.StyleEntry, bool) {
139+ if entry := terminalSyntaxOverride(th.Syntax, typ); entry != "" {
140+ parsed, err := chroma.ParseStyleEntry(entry)
141+ return parsed, err == nil
142+ }
143+ if th.SyntaxBaseStyle != "" {
144+ return styles.Get(th.SyntaxBaseStyle).Get(typ), true
145+ }
146+ return chroma.StyleEntry{}, false
147+}
148+
149+func terminalSyntaxOverride(s SyntaxTheme, typ chroma.TokenType) string {
150+ switch {
151+ case typ == chroma.KeywordConstant:
152+ return firstSyntaxOverride(s.KeywordConstant, s.Keyword)
153+ case typ == chroma.KeywordDeclaration:
154+ return firstSyntaxOverride(s.KeywordDeclaration, s.Keyword)
155+ case typ == chroma.KeywordNamespace:
156+ return firstSyntaxOverride(s.KeywordNamespace, s.Keyword)
157+ case typ == chroma.KeywordReserved:
158+ return firstSyntaxOverride(s.KeywordReserved, s.Keyword)
159+ case typ == chroma.KeywordType:
160+ return firstSyntaxOverride(s.KeywordType, s.Keyword)
161+ case typ.InCategory(chroma.Keyword):
162+ return s.Keyword
163+ case typ == chroma.NameBuiltin:
164+ return firstSyntaxOverride(s.NameBuiltin, s.NameFunction)
165+ case typ == chroma.NameFunction:
166+ return s.NameFunction
167+ case typ == chroma.NameClass:
168+ return firstSyntaxOverride(s.NameClass, s.NameFunction)
169+ case typ == chroma.NameDecorator:
170+ return firstSyntaxOverride(s.NameDecorator, s.NameFunction)
171+ case typ == chroma.LiteralStringEscape:
172+ return firstSyntaxOverride(s.LiteralStringEscape, s.LiteralString)
173+ case typ.InSubCategory(chroma.LiteralString):
174+ return s.LiteralString
175+ case typ.InSubCategory(chroma.LiteralNumber):
176+ return s.LiteralNumber
177+ case typ == chroma.CommentPreproc:
178+ return firstSyntaxOverride(s.CommentPreproc, s.Comment)
179+ case typ.InCategory(chroma.Comment):
180+ return s.Comment
181+ case typ.InCategory(chroma.Operator):
182+ return s.Operator
183+ case typ.InCategory(chroma.Punctuation):
184+ return s.Punctuation
185+ default:
186+ return s.Text
187+ }
188+}
189+
190+func firstSyntaxOverride(entries ...string) string {
191+ for _, entry := range entries {
192+ if entry != "" {
193+ return entry
194+ }
195+ }
196+ return ""
197+}
198+
111 // highlightResultCache is a simple LRU-ish cache: when it exceeds its199 // highlightResultCache is a simple LRU-ish cache: when it exceeds its
112 // capacity we drop half of the entries (the oldest, based on insertion200 // capacity we drop half of the entries (the oldest, based on insertion
113 // order). That's good enough since tool_result text doesn't change once201 // order). That's good enough since tool_result text doesn't change once
@@ -288,7 +376,7 @@ func (th Theme) chromaStyle() *chroma.Style {
288 func (th Theme) syntaxKey() string {376 func (th Theme) syntaxKey() string {
289 syn := th.Syntax377 syn := th.Syntax
290 return strings.Join([]string{378 return strings.Join([]string{
291- fmt.Sprintf("%v:%t:%#v", th.Terminal.ColorDepth(), th.UseTerminalPalette, th.Terminal),379+ th.terminalSyntaxKeyPart(),
292 th.SyntaxBaseStyle,380 th.SyntaxBaseStyle,
293 syn.Keyword,381 syn.Keyword,
294 syn.KeywordConstant,382 syn.KeywordConstant,
@@ -311,6 +399,31 @@ func (th Theme) syntaxKey() string {
311 }, "\x1f")399 }, "\x1f")
312 }400 }
313 401
402+func (th Theme) terminalSyntaxKeyPart() string {
403+ h := fnv64aInit
404+ h = fnv64aWriteByte(h, byte(th.Terminal.ColorDepth()))
405+ if th.UseTerminalPalette {
406+ h = fnv64aWriteByte(h, 1)
407+ } else {
408+ h = fnv64aWriteByte(h, 0)
409+ }
410+ h = fnv64aWriteUint64(h, uint64(th.Terminal.PaletteKnown))
411+ for _, color := range th.Terminal.Palette {
412+ h = hashThemeColor(h, color)
413+ }
414+ for _, color := range []TerminalColor{th.Terminal.Foreground, th.Terminal.Background} {
415+ h = hashThemeColor(h, color)
416+ }
417+ for _, value := range []bool{th.Terminal.HasForeground, th.Terminal.HasBackground, th.Terminal.TrueColor, th.Terminal.Light, th.Terminal.SchemeKnown} {
418+ if value {
419+ h = fnv64aWriteByte(h, 1)
420+ } else {
421+ h = fnv64aWriteByte(h, 0)
422+ }
423+ }
424+ return strconv.FormatUint(h, 16)
425+}
426+
314 // extLang maps file extensions to chroma lexer names. Only427 // extLang maps file extensions to chroma lexer names. Only
315 // extensions that chroma supports are included.428 // extensions that chroma supports are included.
316 var extLang = map[string]string{429 var extLang = map[string]string{
M packages/tui/highlight_terminal_test.go +31 -0
@@ -27,3 +27,34 @@ func TestTerminalSyntaxUsesReportedRGBPalette(t *testing.T) {
2727 t.Fatalf("highlight output did not use reported bright-blue palette: %q", got)
2828 }
2929 }
30+
31+func TestTerminalSyntaxHonorsCustomOverrides(t *testing.T) {
32+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor})
33+ th.Syntax.Keyword = "#112233 bold"
34+ got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n")
35+ if !strings.Contains(got, "\x1b[38;2;17;34;51m\x1b[1mfunc") {
36+ t.Fatalf("terminal formatter ignored custom keyword override: %q", got)
37+ }
38+}
39+
40+func TestTerminalSyntaxHonorsCustomBaseStyle(t *testing.T) {
41+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor})
42+ th.SyntaxBaseStyle = "monokai"
43+ got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n")
44+ if !strings.Contains(got, "\x1b[38;2;102;217;239m") {
45+ t.Fatalf("terminal formatter ignored custom syntax base style: %q", got)
46+ }
47+}
48+
49+func TestTerminalSyntaxBalancesMultilineTokens(t *testing.T) {
50+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthIndexed256})
51+ lines := th.HighlightCode("/* first\nsecond */", "go")
52+ if len(lines) != 2 {
53+ t.Fatalf("line count = %d, want 2: %#v", len(lines), lines)
54+ }
55+ for _, line := range lines {
56+ if !strings.HasSuffix(line, reset) {
57+ t.Fatalf("line does not reset terminal style: %q", line)
58+ }
59+ }
60+}
@@ -27,3 +27,34 @@ func TestTerminalSyntaxUsesReportedRGBPalette(t *testing.T) {
27 t.Fatalf("highlight output did not use reported bright-blue palette: %q", got)27 t.Fatalf("highlight output did not use reported bright-blue palette: %q", got)
28 }28 }
29 }29 }
30+
31+func TestTerminalSyntaxHonorsCustomOverrides(t *testing.T) {
32+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor})
33+ th.Syntax.Keyword = "#112233 bold"
34+ got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n")
35+ if !strings.Contains(got, "\x1b[38;2;17;34;51m\x1b[1mfunc") {
36+ t.Fatalf("terminal formatter ignored custom keyword override: %q", got)
37+ }
38+}
39+
40+func TestTerminalSyntaxHonorsCustomBaseStyle(t *testing.T) {
41+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor})
42+ th.SyntaxBaseStyle = "monokai"
43+ got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n")
44+ if !strings.Contains(got, "\x1b[38;2;102;217;239m") {
45+ t.Fatalf("terminal formatter ignored custom syntax base style: %q", got)
46+ }
47+}
48+
49+func TestTerminalSyntaxBalancesMultilineTokens(t *testing.T) {
50+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthIndexed256})
51+ lines := th.HighlightCode("/* first\nsecond */", "go")
52+ if len(lines) != 2 {
53+ t.Fatalf("line count = %d, want 2: %#v", len(lines), lines)
54+ }
55+ for _, line := range lines {
56+ if !strings.HasSuffix(line, reset) {
57+ t.Fatalf("line does not reset terminal style: %q", line)
58+ }
59+ }
60+}
M packages/tui/theme_inherited_test.go +1 -0
@@ -95,6 +95,7 @@ func TestTerminalPaletteSlotsDoNotAliasExplicitIndexedColors(t *testing.T) {
9595 }
9696
9797 func TestTerminalThemeRendererInvalidationDoesNotClearScrollback(t *testing.T) {
98+ t.Setenv("TERM_PROGRAM", "")
9899 var buf bytes.Buffer
99100 r := NewRenderer(&buf)
100101 r.Resize(20, 2)
@@ -95,6 +95,7 @@ func TestTerminalPaletteSlotsDoNotAliasExplicitIndexedColors(t *testing.T) {
95 }95 }
96 96
97 func TestTerminalThemeRendererInvalidationDoesNotClearScrollback(t *testing.T) {97 func TestTerminalThemeRendererInvalidationDoesNotClearScrollback(t *testing.T) {
98+ t.Setenv("TERM_PROGRAM", "")
98 var buf bytes.Buffer99 var buf bytes.Buffer
99 r := NewRenderer(&buf)100 r := NewRenderer(&buf)
100 r.Resize(20, 2)101 r.Resize(20, 2)
M packages/tui/theme_loader.go +3 -25
@@ -207,8 +207,9 @@ func TerminalTheme(profile TerminalProfile) Theme {
207207 Terminal: profile,
208208 SpinnerFrames: append([]string(nil), defaultSpinnerFrames...),
209209 SpinnerIntervalMS: 80,
210- SyntaxBaseStyle: "",
211- Syntax: terminalSyntaxTheme(),
210+ // Terminal syntax is resolved directly from semantic terminal roles.
211+ // Keep JSON syntax fields empty until a custom theme explicitly sets one.
212+ Syntax: SyntaxTheme{},
212213 }
213214 if profile.HasForeground && profile.HasBackground {
214215 t.Muted = t.DimColor(profile.Foreground, 45)
@@ -222,29 +223,6 @@ func TerminalTheme(profile TerminalProfile) Theme {
222223 return t
223224 }
224225
225-func terminalSyntaxTheme() SyntaxTheme {
226- return SyntaxTheme{
227- Keyword: "ansi:12 bold",
228- KeywordConstant: "ansi:12",
229- KeywordDeclaration: "ansi:12",
230- KeywordNamespace: "ansi:12",
231- KeywordReserved: "ansi:12 bold",
232- KeywordType: "ansi:14",
233- NameBuiltin: "ansi:14",
234- NameFunction: "ansi:10",
235- NameClass: "ansi:10 bold",
236- NameDecorator: "ansi:13",
237- LiteralString: "ansi:10",
238- LiteralStringEscape: "ansi:9",
239- LiteralNumber: "ansi:11",
240- Comment: "ansi:8 italic",
241- CommentPreproc: "ansi:13",
242- Operator: "default",
243- Punctuation: "default",
244- Text: "default",
245- }
246-}
247-
248226 func withTerminalProfile(base, detected Theme) Theme {
249227 base.Terminal = detected.Terminal
250228 return base
@@ -207,8 +207,9 @@ func TerminalTheme(profile TerminalProfile) Theme {
207 Terminal: profile,207 Terminal: profile,
208 SpinnerFrames: append([]string(nil), defaultSpinnerFrames...),208 SpinnerFrames: append([]string(nil), defaultSpinnerFrames...),
209 SpinnerIntervalMS: 80,209 SpinnerIntervalMS: 80,
210- SyntaxBaseStyle: "",210+ // Terminal syntax is resolved directly from semantic terminal roles.
211- Syntax: terminalSyntaxTheme(),211+ // Keep JSON syntax fields empty until a custom theme explicitly sets one.
212+ Syntax: SyntaxTheme{},
212 }213 }
213 if profile.HasForeground && profile.HasBackground {214 if profile.HasForeground && profile.HasBackground {
214 t.Muted = t.DimColor(profile.Foreground, 45)215 t.Muted = t.DimColor(profile.Foreground, 45)
@@ -222,29 +223,6 @@ func TerminalTheme(profile TerminalProfile) Theme {
222 return t223 return t
223 }224 }
224 225
225-func terminalSyntaxTheme() SyntaxTheme {
226- return SyntaxTheme{
227- Keyword: "ansi:12 bold",
228- KeywordConstant: "ansi:12",
229- KeywordDeclaration: "ansi:12",
230- KeywordNamespace: "ansi:12",
231- KeywordReserved: "ansi:12 bold",
232- KeywordType: "ansi:14",
233- NameBuiltin: "ansi:14",
234- NameFunction: "ansi:10",
235- NameClass: "ansi:10 bold",
236- NameDecorator: "ansi:13",
237- LiteralString: "ansi:10",
238- LiteralStringEscape: "ansi:9",
239- LiteralNumber: "ansi:11",
240- Comment: "ansi:8 italic",
241- CommentPreproc: "ansi:13",
242- Operator: "default",
243- Punctuation: "default",
244- Text: "default",
245- }
246-}
247-
248 func withTerminalProfile(base, detected Theme) Theme {226 func withTerminalProfile(base, detected Theme) Theme {
249 base.Terminal = detected.Terminal227 base.Terminal = detected.Terminal
250 return base228 return base
M packages/tui/theme_loader_test.go +7 -1
@@ -73,8 +73,10 @@ func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) {
7373
7474 func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
7575 home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`)
76+ detected := Light
77+ detected.Terminal = TerminalProfile{SchemeKnown: true, Light: true}
7678
77- th, _, err := LoadThemeFromHome(home, "darkonly", Light)
79+ th, _, err := LoadThemeFromHome(home, "darkonly", detected)
7880 if err != nil {
7981 t.Fatal(err)
8082 }
@@ -170,4 +172,8 @@ func TestResolveThemeCustomUsesReportedScheme(t *testing.T) {
170172 if got := ResolveTheme("split", source, profile).Theme.Accent; got != Color256(21) {
171173 t.Fatalf("light branch accent = %#v, want 21", got)
172174 }
175+ dark := TerminalProfile{SchemeKnown: true, Light: false}
176+ if got := ResolveTheme("split", source, dark).Theme.Accent; got != Color256(20) {
177+ t.Fatalf("dark branch accent = %#v, want 20", got)
178+ }
173179 }
@@ -73,8 +73,10 @@ func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) {
73 73
74 func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {74 func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
75 home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`)75 home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`)
76+ detected := Light
77+ detected.Terminal = TerminalProfile{SchemeKnown: true, Light: true}
76 78
77- th, _, err := LoadThemeFromHome(home, "darkonly", Light)79+ th, _, err := LoadThemeFromHome(home, "darkonly", detected)
78 if err != nil {80 if err != nil {
79 t.Fatal(err)81 t.Fatal(err)
80 }82 }
@@ -170,4 +172,8 @@ func TestResolveThemeCustomUsesReportedScheme(t *testing.T) {
170 if got := ResolveTheme("split", source, profile).Theme.Accent; got != Color256(21) {172 if got := ResolveTheme("split", source, profile).Theme.Accent; got != Color256(21) {
171 t.Fatalf("light branch accent = %#v, want 21", got)173 t.Fatalf("light branch accent = %#v, want 21", got)
172 }174 }
175+ dark := TerminalProfile{SchemeKnown: true, Light: false}
176+ if got := ResolveTheme("split", source, dark).Theme.Accent; got != Color256(20) {
177+ t.Fatalf("dark branch accent = %#v, want 20", got)
178+ }
173 }179 }

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