fix(tui): address terminal theme review findings
- commit
- 037ba94c2a9e98f78cc57c9d2c8ebce0521482c4
- author
- brice · · 2026-08-23 11:17 UTC
- parent
- 0979a1c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 2488 | 2488 | for idx, s := range initialCfg.QuickModelShortcuts { | |
| 2489 | 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 | 2497 | theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), themePreference.Effective, 80*time.Millisecond) | |
| 2493 | 2498 | if themeErr != nil { | |
| 2494 | 2499 | 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 | + | } | |
| 2499 | 2508 | } | |
| 2500 | 2509 | } | |
| 2501 | 2510 | ||
| @@ -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 { | |||
| 1280 | 1280 | appearanceEvents := make(chan tui.InputEvent, 256) | |
| 1281 | 1281 | appearanceParser := &tui.AppearanceParser{} | |
| 1282 | 1282 | appearanceParser.SetPendingColors(true) | |
| 1283 | + | appearanceParser.SetPendingScheme(true) | |
| 1283 | 1284 | inputDone := make(chan struct{}) | |
| 1284 | 1285 | go func() { | |
| 1285 | 1286 | defer close(inputDone) | |
| @@ -1407,8 +1408,10 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1407 | 1408 | i.activeThemeSource = event.source | |
| 1408 | 1409 | i.themeWatchWarning = "" | |
| 1409 | 1410 | i.installResolvedTheme(tui.ResolveTheme(i.cfg.EffectiveThemeName, event.source, i.terminalProfile)) | |
| 1411 | + | i.mu.Lock() | |
| 1410 | 1412 | i.statusOK = "theme reloaded" | |
| 1411 | 1413 | i.statusErr = "" | |
| 1414 | + | i.mu.Unlock() | |
| 1412 | 1415 | case event.deleted: | |
| 1413 | 1416 | i.activeThemeSource = nil | |
| 1414 | 1417 | i.stopThemeWatch() | |
| @@ -1418,11 +1421,15 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1418 | 1421 | _ = i.cfg.SettingsStore.SetTheme("auto") | |
| 1419 | 1422 | } | |
| 1420 | 1423 | i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile)) | |
| 1424 | + | i.mu.Lock() | |
| 1421 | 1425 | i.statusOK = "theme removed; using auto" | |
| 1422 | 1426 | i.statusErr = "" | |
| 1427 | + | i.mu.Unlock() | |
| 1423 | 1428 | case event.err != nil && event.err.Error() != i.themeWatchWarning: | |
| 1424 | 1429 | i.themeWatchWarning = event.err.Error() | |
| 1430 | + | i.mu.Lock() | |
| 1425 | 1431 | i.statusErr = "theme reload: " + event.err.Error() | |
| 1432 | + | i.mu.Unlock() | |
| 1426 | 1433 | } | |
| 1427 | 1434 | i.invalidate() | |
| 1428 | 1435 | case event := <-appearanceEvents: | |
| @@ -1444,6 +1451,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1444 | 1451 | profileCandidate.Light = event.Scheme.Light | |
| 1445 | 1452 | profileCandidate.SchemeKnown = true | |
| 1446 | 1453 | 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 | 1462 | case event.Mode != nil && event.Mode.Mode == 2031: | |
| 1448 | 1463 | switch event.Mode.Status { | |
| 1449 | 1464 | case 1, 3: | |
| @@ -1555,6 +1570,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1555 | 1570 | now := time.Now() | |
| 1556 | 1571 | if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) { | |
| 1557 | 1572 | appearanceParser.SetPendingColors(false) | |
| 1573 | + | appearanceParser.SetPendingScheme(false) | |
| 1558 | 1574 | appearanceDeadline = time.Time{} | |
| 1559 | 1575 | } | |
| 1560 | 1576 | if profileDirty { | |
| @@ -1563,6 +1579,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1563 | 1579 | } | |
| 1564 | 1580 | if !notificationsSupported && now.Sub(lastAppearancePoll) >= 2*time.Second { | |
| 1565 | 1581 | appearanceParser.SetPendingColors(true) | |
| 1582 | + | appearanceParser.SetPendingScheme(true) | |
| 1566 | 1583 | appearanceDeadline = now.Add(500 * time.Millisecond) | |
| 1567 | 1584 | lastAppearancePoll = now | |
| 1568 | 1585 | _, _ = 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.source | 1408 | 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 = nil | 1416 | 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.Light | 1451 | profileCandidate.Light = event.Scheme.Light | ||
| 1445 | profileCandidate.SchemeKnown = true | 1452 | profileCandidate.SchemeKnown = true | ||
| 1446 | profileDirty = true | 1453 | 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 = now | 1584 | 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) { | |||
| 1228 | 1228 | i.view.InvalidateRenderCache() | |
| 1229 | 1229 | i.chatCacheValid = false | |
| 1230 | 1230 | i.stableChatCacheValid = false | |
| 1231 | - | i.mu.Unlock() | |
| 1232 | 1231 | i.ed.Prompt = th.AccentBar(th.Accent) | |
| 1232 | + | i.mu.Unlock() | |
| 1233 | 1233 | i.btwDialog.setTheme(th) | |
| 1234 | 1234 | if i.residentChildSession != nil { | |
| 1235 | 1235 | 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 = false | 1229 | i.chatCacheValid = false | ||
| 1230 | i.stableChatCacheValid = false | 1230 | 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 | |||
| 78 | 78 | } | |
| 79 | 79 | 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 | 84 | source, err := tui.LoadThemeSource(home, preference) | |
| 82 | 85 | if err == nil && source != nil { | |
| 83 | - | missingSince = time.Time{} | |
| 84 | 86 | lastError = "" | |
| 85 | 87 | if source.Path != accepted.Path || source.Digest == lastDigest { | |
| 86 | 88 | continue | |
| @@ -78,9 +78,11 @@ func watchThemeSourceTicks(ctx context.Context, home, preference string, accepte | |||||
| 78 | } | 78 | } | ||
| 79 | continue | 79 | 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 | continue | 88 | continue | ||
M packages/agent/modes/theme_watch_test.go +26 -8
| @@ -31,7 +31,7 @@ func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) { | |||
| 31 | 31 | } | |
| 32 | 32 | ctx, cancel := context.WithCancel(context.Background()) | |
| 33 | 33 | defer cancel() | |
| 34 | - | ticks := make(chan time.Time, 3) | |
| 34 | + | ticks := make(chan time.Time) | |
| 35 | 35 | events := make(chan themeWatchEvent, 3) | |
| 36 | 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 | 66 | home := t.TempDir() | |
| 67 | 67 | path := writeWatchedTheme(t, home, `{}`) | |
| 68 | 68 | accepted, err := tui.LoadThemeSource(home, "live") | |
| 69 | 69 | if err != nil { | |
| 70 | 70 | t.Fatal(err) | |
| 71 | 71 | } | |
| 72 | - | if err := os.Remove(path); err != nil { | |
| 73 | - | t.Fatal(err) | |
| 74 | - | } | |
| 75 | 72 | ctx, cancel := context.WithCancel(context.Background()) | |
| 76 | 73 | defer cancel() | |
| 77 | - | ticks := make(chan time.Time, 3) | |
| 74 | + | ticks := make(chan time.Time) | |
| 78 | 75 | events := make(chan themeWatchEvent, 1) | |
| 79 | 76 | go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events) | |
| 77 | + | ||
| 78 | + | if err := os.Remove(path); err != nil { | |
| 79 | + | t.Fatal(err) | |
| 80 | + | } | |
| 80 | 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 | 85 | ticks <- time.Unix(2, 0) | |
| 82 | 86 | select { | |
| 83 | 87 | 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) | |
| 86 | 97 | } | |
| 98 | + | ticks <- time.Unix(3, 0) | |
| 87 | 99 | ticks <- time.Unix(4, 0) | |
| 88 | 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 | 107 | case event := <-events: | |
| 90 | 108 | if !event.deleted { | |
| 91 | 109 | 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 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "strconv" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "sync" | |
| 7 | 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 | 12 | const ( | |
| 12 | 13 | SeqQueryColorScheme = "\x1b[?996n" | |
| 13 | 14 | SeqQueryMode2031 = "\x1b[?2031$p" | |
| @@ -32,8 +33,12 @@ func AppearanceQuery() string { | |||
| 32 | 33 | return b.String() | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 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 | + | } | |
| 37 | 42 | ||
| 38 | 43 | // ColorEvent is one default foreground/background or ANSI-palette reply. | |
| 39 | 44 | type ColorEvent struct { | |
| @@ -65,6 +70,7 @@ type InputEvent struct { | |||
| 65 | 70 | type AppearanceParser struct { | |
| 66 | 71 | mu sync.RWMutex | |
| 67 | 72 | pendingColors bool | |
| 73 | + | pendingScheme bool | |
| 68 | 74 | notifications bool | |
| 69 | 75 | } | |
| 70 | 76 | ||
| @@ -80,6 +86,14 @@ func (p *AppearanceParser) SetNotifications(enabled bool) { | |||
| 80 | 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 | 97 | func (p *AppearanceParser) acceptsColors() bool { | |
| 84 | 98 | p.mu.RLock() | |
| 85 | 99 | defer p.mu.RUnlock() | |
| @@ -92,6 +106,16 @@ func (p *AppearanceParser) acceptsNotifications() bool { | |||
| 92 | 106 | 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 | 119 | // ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence. | |
| 96 | 120 | // Combined OSC 4 responses produce one ColorEvent for every valid pair. | |
| 97 | 121 | func ParseAppearanceOSC(raw []byte) []ColorEvent { | |
| @@ -186,7 +210,11 @@ func (s *AppearanceSource) ReadByte() (byte, error) { | |||
| 186 | 210 | s.trackPaste(b) | |
| 187 | 211 | 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 | 218 | if err != nil || !ok || (next != ']' && next != '[') { | |
| 191 | 219 | if ok { | |
| 192 | 220 | s.pending = append(s.pending, next) | |
| @@ -213,13 +241,16 @@ func (s *AppearanceSource) ReadByte() (byte, error) { | |||
| 213 | 241 | if err != nil { | |
| 214 | 242 | return 0, err | |
| 215 | 243 | } | |
| 216 | - | if bytesEqual(sequence, []byte("\x1b[200~")) { | |
| 244 | + | if bytes.Equal(sequence, []byte("\x1b[200~")) { | |
| 217 | 245 | s.inPaste = true | |
| 218 | 246 | } | |
| 219 | 247 | 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 | + | } | |
| 223 | 254 | } | |
| 224 | 255 | if mode != nil { | |
| 225 | 256 | s.emit(InputEvent{Mode: mode}) | |
| @@ -274,15 +305,3 @@ func (s *AppearanceSource) trackPaste(b byte) { | |||
| 274 | 305 | 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 | - | } | |
| @@ -1,13 +1,14 @@ | |||||
| 1 | package tui | 1 | 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.RWMutex | 71 | mu sync.RWMutex | ||
| 67 | pendingColors bool | 72 | pendingColors bool | ||
| 73 | + | pendingScheme bool | |||
| 68 | notifications bool | 74 | 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.notifications | 106 | 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, err | 211 | 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, err | 242 | return 0, err | ||
| 215 | } | 243 | } | ||
| 216 | - | if bytesEqual(sequence, []byte("\x1b[200~")) { | 244 | + | if bytes.Equal(sequence, []byte("\x1b[200~")) { |
| 217 | s.inPaste = true | 245 | 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 | - | continue | 250 | + | 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 = nil | 305 | 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) { | |||
| 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 | 38 | func (s *appearanceBytes) read() (byte, error) { | |
| 36 | 39 | if len(s.data) == 0 { | |
| @@ -41,7 +44,10 @@ func (s *appearanceBytes) read() (byte, error) { | |||
| 41 | 44 | 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 | 51 | if len(s.data) == 0 { | |
| 46 | 52 | 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 | 119 | func TestAppearanceSourceLeavesPasteOpaque(t *testing.T) { | |
| 79 | 120 | input := &appearanceBytes{data: []byte("\x1b[200~x\x1b]11;#123456\ay\x1b[201~")} | |
| 80 | 121 | 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, nil | 44 | 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, nil | 52 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | - | "fmt" | |
| 6 | 5 | "path/filepath" | |
| 6 | + | "strconv" | |
| 7 | 7 | "strings" | |
| 8 | 8 | "sync" | |
| 9 | 9 | ||
| @@ -82,14 +82,41 @@ func formatTerminalTokens(th Theme, iterator chroma.Iterator) []string { | |||
| 82 | 82 | if italic { | |
| 83 | 83 | prefix += "\x1b[3m" | |
| 84 | 84 | } | |
| 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 | + | } | |
| 88 | 99 | } | |
| 89 | 100 | return strings.Split(strings.TrimRight(out.String(), "\n"), "\n") | |
| 90 | 101 | } | |
| 91 | 102 | ||
| 92 | 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 | 120 | switch { | |
| 94 | 121 | case typ.InCategory(chroma.Comment): | |
| 95 | 122 | 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 | 199 | // highlightResultCache is a simple LRU-ish cache: when it exceeds its | |
| 112 | 200 | // capacity we drop half of the entries (the oldest, based on insertion | |
| 113 | 201 | // order). That's good enough since tool_result text doesn't change once | |
| @@ -288,7 +376,7 @@ func (th Theme) chromaStyle() *chroma.Style { | |||
| 288 | 376 | func (th Theme) syntaxKey() string { | |
| 289 | 377 | syn := th.Syntax | |
| 290 | 378 | return strings.Join([]string{ | |
| 291 | - | fmt.Sprintf("%v:%t:%#v", th.Terminal.ColorDepth(), th.UseTerminalPalette, th.Terminal), | |
| 379 | + | th.terminalSyntaxKeyPart(), | |
| 292 | 380 | th.SyntaxBaseStyle, | |
| 293 | 381 | syn.Keyword, | |
| 294 | 382 | syn.KeywordConstant, | |
| @@ -311,6 +399,31 @@ func (th Theme) syntaxKey() string { | |||
| 311 | 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 | 427 | // extLang maps file extensions to chroma lexer names. Only | |
| 315 | 428 | // extensions that chroma supports are included. | |
| 316 | 429 | 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, true | 122 | 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 its | 199 | // highlightResultCache is a simple LRU-ish cache: when it exceeds its | ||
| 112 | // capacity we drop half of the entries (the oldest, based on insertion | 200 | // 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 once | 201 | // 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.Syntax | 377 | 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. Only | 427 | // 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) { | |||
| 27 | 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 | + | } | |
| @@ -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) { | |||
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | 97 | func TestTerminalThemeRendererInvalidationDoesNotClearScrollback(t *testing.T) { | |
| 98 | + | t.Setenv("TERM_PROGRAM", "") | |
| 98 | 99 | var buf bytes.Buffer | |
| 99 | 100 | r := NewRenderer(&buf) | |
| 100 | 101 | 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.Buffer | 99 | 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 { | |||
| 207 | 207 | Terminal: profile, | |
| 208 | 208 | SpinnerFrames: append([]string(nil), defaultSpinnerFrames...), | |
| 209 | 209 | 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{}, | |
| 212 | 213 | } | |
| 213 | 214 | if profile.HasForeground && profile.HasBackground { | |
| 214 | 215 | t.Muted = t.DimColor(profile.Foreground, 45) | |
| @@ -222,29 +223,6 @@ func TerminalTheme(profile TerminalProfile) Theme { | |||
| 222 | 223 | 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 | 226 | func withTerminalProfile(base, detected Theme) Theme { | |
| 249 | 227 | base.Terminal = detected.Terminal | |
| 250 | 228 | 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 t | 223 | 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.Terminal | 227 | base.Terminal = detected.Terminal | ||
| 250 | return base | 228 | return base | ||
M packages/tui/theme_loader_test.go +7 -1
| @@ -73,8 +73,10 @@ func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) { | |||
| 73 | 73 | ||
| 74 | 74 | func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |
| 75 | 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 | 80 | if err != nil { | |
| 79 | 81 | t.Fatal(err) | |
| 80 | 82 | } | |
| @@ -170,4 +172,8 @@ func TestResolveThemeCustomUsesReportedScheme(t *testing.T) { | |||
| 170 | 172 | if got := ResolveTheme("split", source, profile).Theme.Accent; got != Color256(21) { | |
| 171 | 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 | } | |
| @@ -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 | } | ||