Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 9e98c17dad fix(tui): address CodeRabbit review on the theme catalog
- CHANGELOG: reconcile the --theme bullet with the named themes (was still
  documenting {auto|dark|light}).
- /theme usage string advertises the list and auto forms (/theme list is still
  accepted) so /help matches the command surface.
- Theme-persistence test parses the written config and asserts preferences.theme
  directly instead of a raw substring, so a wrong-key regression fails.
2026-07-01 15:39:53 +05:30
Vasanthdev2004 3db4aa90f4 Merge origin/main into feat/theme-catalog
Resolves the internal/tui/theme.go conflict: darkPalette/lightPalette now live in
theme_palettes.go on this branch, and the accent restore from #351 (#759523 ->
#caff3f, the original bright lime) is carried into that palette's darkPalette.
streaming_fade_test.go (which pins the #caff3f accent endpoint) and the
exec_command output-buffer cap from #353 merge in unchanged.
2026-07-01 15:32:00 +05:30
Vasanthdev2004 d0ee963f76 feat(tui): interactive theme picker + color theme catalog
Bare `/theme` now opens a popup that live-previews each palette as the cursor
moves and applies on select (Esc reverts), matching /model and /effort. Adds ten
built-in color themes alongside the dark/light built-ins — dracula, nord, gruvbox,
tokyo-night, catppuccin, one-dark, solarized-dark, rose-pine, everforest, and
solarized-light — plus a reworked light palette. Every palette is contrast-audited
to WCAG AA; the contrast tests now loop the whole registry.

Palettes and an ordered registry live in a new internal/tui/theme_palettes.go (now
the sole home of hex literals); selection, the picker, /theme <name>, --theme, and
ZERO_THEME all resolve through it. The whole frame is painted with the active
theme's surface (tea.View.BackgroundColor) so every theme is legible on any
terminal — a light theme's dark text no longer lands invisibly on a dark terminal,
and color themes show their real surface.

The chosen theme persists to user config (preferences.theme) and is reapplied at
startup, below the --theme flag and ZERO_THEME.
2026-07-01 15:24:57 +05:30
KRATOS 2a8802e39c fix: cap unbounded exec_command output buffer to prevent OOM kill (#353)
* fix: cap unbounded exec_command output buffer to prevent OOM kill

execOutputBuffer.data accumulated every byte a backgrounded exec_command
session wrote, with no size cap, unlike buffer.recent which is correctly
bounded to recentExecOutputBytes for the /ps preview. drainString() only
clears data when the agent actually polls that session again — so a
long-lived background session nobody polls (e.g. a dev server left running
after the run that started it was cancelled) grows this buffer forever as
long as the process keeps writing, with no ceiling.

Traced this directly to a real crash: a session that had been running for
~5.5 hours (an earlier turn invoking swarm agents got cancelled mid-flight,
per that session's own event log) accumulated to ~42.7GB of compressed
memory, at which point macOS's memorystatus killed the process outright
("killing zero due to low-swap").

Fixed by capping data at maxExecOutputBufferBytes (2MiB), trimming from the
front like recent already does, and surfacing a one-time truncation notice
on the next drain so data loss is visible rather than silent.

* fix: pre-push review findings on the exec_command buffer-cap fix

Ran an adversarial review before opening the PR. It found the truncation
notice added alongside the buffer cap didn't actually work: prefixing
execOutputTruncatedMessage into drainString()'s text meant the notice
always sat ~2MiB before the end of the combined output, but the tool's
own downstream truncateExecOutput() keeps at most a 400KB tail (even at
the maximum allowed max_output_tokens) — so the notice was reliably
swallowed, or chopped into an unreadable fragment at smaller budgets.
Verified with direct reproductions against the real functions.

Fixed by moving the signal out of the text stream entirely:
- execOutputBuffer gains consumeTruncated() (read+reset, called once per
  collect()) and peekTruncated() (read-only, for status views) instead of
  injecting a marker into the drained bytes.
- collect() now returns (string, bool); both call sites thread the bool
  through to execToolResult, which sets meta["output_buffer_truncated"]
  and appends the notice to the response body *after* truncateExecOutput
  runs, so it can never be re-truncated or land in a discarded region.
- Also flagged: a session torn down without ever being polled again after
  truncation loses the signal entirely, since nothing drains/logs it on
  teardown. Full audit-trail logging felt like overkill for a soft
  transparency signal on top of a hard memory-safety fix, but exposed
  peekTruncated() on ExecSessionSnapshot and surfaced it in the /ps row
  (background_terminals.go) so it's visible any time a still-running
  session is checked — covers the common case (the review's own scenario:
  a long-lived dev server nobody's polling but that's still alive) even
  though a session reaped before ever being looked at is still a gap.

New tests: reworked TestExecOutputBufferCapsUndrainedData for the
consume/peek API, and TestExecToolResultSurfacesBufferTruncationOutsideByteBudget
reproducing the review's exact failure case (max_output_tokens=1) and
proving the notice now survives it.
2026-07-01 15:03:42 +05:30
KRATOS d53052071a revert(local): restore the original bright-lime accent color (#351)
Restores accent to #caff3f (the pre-PR#350 value), undoing the three-round
brightness reduction (#ade619 -> #8db620 -> #759523) merged in PR #350. Only
the accent token changes - the hover-highlight color (a separate amber
token) and every other PR #350 fix (subchat selection, drag-to-edge
auto-scroll, the dragging-flag fix) are untouched.

Full gate green. Local only - not pushed.
2026-07-01 13:55:35 +05:30
31 changed files with 1397 additions and 523 deletions

No files matched your search

+8 -1
View File
@@ -11,7 +11,14 @@ tagged. Until then, source builds report the version `dev`.
### Added
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
GitHub issue/PR templates.
- `--theme {auto|dark|light}` flag for the TUI (previously only the `ZERO_THEME` env var existed).
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
and applies on select (Esc reverts).
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
`ZERO_THEME` env var existed).
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
and `ZERO_NO_FADE`.
+3 -3
View File
@@ -254,9 +254,9 @@ zero update check for newer releases
| Control | Effect |
|---|---|
| `NO_COLOR=<anything>` | disables color output |
| `ZERO_THEME=auto|dark|light` | selects the startup theme |
| `--theme auto|dark|light` | selects the TUI theme from the CLI |
| `/theme auto|dark|light` | switches theme inside the TUI |
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
| `ZERO_NO_FADE=1` | disables streaming fade animation |
Meaning does not rely on color alone; diffs, permissions, and statuses also use
+12 -12
View File
@@ -217,9 +217,9 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
// --theme {auto|dark|light} selects the TUI palette non-interactively (populates
// tui.Options.Theme, which resolveThemeMode prefers over ZERO_THEME). Re-split
// --add-dir afterward so it may appear on either side of --theme.
// --theme <name> selects the TUI palette non-interactively (auto or any registered
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
theme, args, err := splitLeadingThemeFlag(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
@@ -641,6 +641,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
@@ -1033,9 +1034,10 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
return addDirs, args, nil
}
// splitLeadingThemeFlag strips a leading --theme {auto|dark|light} (space or =form)
// from the root argument list and validates it. The last occurrence wins. A value
// outside the allowed set is a loud error rather than a silent fallback.
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
// from the root argument list and validates it against the registered themes. The
// last occurrence wins. A value outside the allowed set is a loud error rather than
// a silent fallback.
func splitLeadingThemeFlag(args []string) (string, []string, error) {
theme := ""
for len(args) > 0 {
@@ -1043,7 +1045,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
switch {
case args[0] == "--theme":
if len(args) < 2 {
return "", nil, errors.New("--theme requires a value (auto, dark, or light)")
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
}
value = strings.TrimSpace(args[1])
args = args[2:]
@@ -1053,12 +1055,10 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
default:
return theme, args, nil
}
switch strings.ToLower(value) {
case "auto", "dark", "light":
theme = strings.ToLower(value)
default:
return "", nil, fmt.Errorf("--theme must be auto, dark, or light (got %q)", value)
if !tui.ValidThemeArg(value) {
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
}
theme = strings.ToLower(value)
}
return theme, args, nil
}
+1 -1
View File
@@ -1397,7 +1397,7 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
if launched {
t.Fatal("the TUI must not launch on a bad --theme value")
}
if !strings.Contains(stderr.String(), "--theme must be auto, dark, or light") {
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
}
}
+3
View File
@@ -195,6 +195,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if src.Preferences.Recaps != nil {
dst.Preferences.Recaps = src.Preferences.Recaps
}
if strings.TrimSpace(src.Preferences.Theme) != "" {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
}
+4
View File
@@ -97,6 +97,10 @@ type ToolsConfig struct {
type PreferencesConfig struct {
FavoriteModels []string `json:"favoriteModels,omitempty"`
// Theme is the persisted TUI palette preference — "auto" or a registered theme
// name (e.g. "dracula"). Applied at startup below the --theme flag and
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
Theme string `json:"theme,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
+22
View File
@@ -154,6 +154,28 @@ func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
return cfg, nil
}
// SetTheme persists the TUI theme preference, mirroring SetFavoriteModels
// (read-modify-atomic-write). A blank theme clears the stored preference.
func SetTheme(path string, theme string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg.Preferences.Theme = strings.TrimSpace(theme)
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
func normalizeFavoriteModels(models []string) []string {
seen := map[string]bool{}
favorites := make([]string, 0, len(models))
+33
View File
@@ -272,6 +272,39 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
}
}
func TestSetThemePersistsUserPreference(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
},
}, 0o600)
cfg, err := SetTheme(path, " dracula ")
if err != nil {
t.Fatalf("SetTheme() error = %v", err)
}
if cfg.Preferences.Theme != "dracula" {
t.Fatalf("Theme = %q, want dracula (trimmed)", cfg.Preferences.Theme)
}
persisted := readConfigFixture(t, path)
if persisted.Preferences.Theme != "dracula" {
t.Fatalf("persisted Theme = %q, want dracula", persisted.Preferences.Theme)
}
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
t.Fatalf("provider config was not preserved by SetTheme: %#v", persisted)
}
// A blank value clears the stored preference.
if cfg, err = SetTheme(path, ""); err != nil {
t.Fatalf("SetTheme(\"\") error = %v", err)
}
if cfg.Preferences.Theme != "" {
t.Fatalf("SetTheme(\"\") should clear the theme, got %q", cfg.Preferences.Theme)
}
}
func TestRecapsPreferenceRoundTrips(t *testing.T) {
// Default (unset) is ON.
if !(PreferencesConfig{}).RecapsEnabled() {
+134 -54
View File
@@ -28,8 +28,17 @@ const (
completedSessionRetention = 30 * time.Second
maxExecSessions = 64
recentExecOutputBytes = 4096
execSessionStopTimeout = 3 * time.Second
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
// maxExecOutputBufferBytes caps the undrained output an unpolled session can
// accumulate. Without a cap, a long-lived background session nobody polls
// again (e.g. a dev server left running after its initiating run was
// cancelled) grows this buffer forever as long as the process keeps writing
// output, with no ceiling — this previously ran a session's memory into the
// tens of gigabytes over several hours and got the whole zero process
// OOM-killed by the OS.
maxExecOutputBufferBytes = 2 * 1024 * 1024
execSessionStopTimeout = 3 * time.Second
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
execOutputBufferTruncatedMessage = "[zero] output buffer truncated: undrained output exceeded 2MiB, oldest output dropped"
)
type execSessionManager struct {
@@ -233,6 +242,10 @@ type ExecSessionSnapshot struct {
Status string
ExitCode *int
RecentOutput string
// OutputTruncated reflects the buffer's last-known truncation state (see
// execOutputBuffer.peekTruncated), so a session listing (/ps) still shows
// this even if the session is reaped before ever being polled again.
OutputTruncated bool
}
type ExecSessionController interface {
@@ -317,24 +330,26 @@ func (session *execSession) snapshot() ExecSessionSnapshot {
status = "exited"
}
return ExecSessionSnapshot{
ID: session.id,
Command: session.commandText,
Cwd: session.cwd,
RelativeCwd: session.relativeCwd,
StartedAt: startedAt,
LastUsedAt: lastUsedAt,
TTY: session.tty,
Status: status,
ExitCode: copiedExit,
RecentOutput: session.output.recentString(),
ID: session.id,
Command: session.commandText,
Cwd: session.cwd,
RelativeCwd: session.relativeCwd,
StartedAt: startedAt,
LastUsedAt: lastUsedAt,
TTY: session.tty,
Status: status,
ExitCode: copiedExit,
RecentOutput: session.output.recentString(),
OutputTruncated: session.output.peekTruncated(),
}
}
type execOutputBuffer struct {
mu sync.Mutex
data []byte
recent []byte
notify chan struct{}
mu sync.Mutex
data []byte
recent []byte
truncated bool
notify chan struct{}
}
func newExecOutputBuffer() *execOutputBuffer {
@@ -344,6 +359,10 @@ func newExecOutputBuffer() *execOutputBuffer {
func (buffer *execOutputBuffer) Write(p []byte) (int, error) {
buffer.mu.Lock()
buffer.data = append(buffer.data, p...)
if len(buffer.data) > maxExecOutputBufferBytes {
buffer.data = buffer.data[len(buffer.data)-maxExecOutputBufferBytes:]
buffer.truncated = true
}
buffer.recent = append(buffer.recent, p...)
if len(buffer.recent) > recentExecOutputBytes {
buffer.recent = buffer.recent[len(buffer.recent)-recentExecOutputBytes:]
@@ -373,6 +392,34 @@ func (buffer *execOutputBuffer) drainString() string {
return out
}
// consumeTruncated reports whether the buffer has dropped output to stay
// within maxExecOutputBufferBytes since the last call, resetting the flag.
// Kept as an out-of-band signal rather than text embedded in drainString's
// result: an earlier version prefixed a notice directly into the drained
// string, but that notice always sat ~maxExecOutputBufferBytes (2MiB) before
// the end of the combined output, far outside the byte-budget head/tail
// window truncateExecOutput keeps afterward (at most 400KB even at the
// tool's maximum max_output_tokens) — so the notice was reliably swallowed
// or chopped by that second, unrelated truncation pass. Surfacing it as a
// separate bool lets the caller report it regardless of where in the byte
// stream the overflow happened.
func (buffer *execOutputBuffer) consumeTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
truncated := buffer.truncated
buffer.truncated = false
return truncated
}
// peekTruncated reports the buffer's last-known truncation state without
// clearing it, for status views (e.g. /ps) that shouldn't consume the signal
// a subsequent poll is still meant to report via consumeTruncated.
func (buffer *execOutputBuffer) peekTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
type execCommandTool struct {
baseTool
workspaceRoot string
@@ -485,25 +532,28 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if err != nil {
return errorResult("Error starting exec_command: " + err.Error())
}
output := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
if ctx != nil && ctx.Err() != nil && !session.doneClosed() {
session.terminate()
output += session.collect(context.Background(), time.Second)
more, moreTruncated := session.collect(context.Background(), time.Second)
output += more
outputTruncated = outputTruncated || moreTruncated
}
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResult(execToolResultInput{
commandText: commandText,
output: output,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: relativeCwd,
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
commandText: commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: relativeCwd,
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
})
}
@@ -558,26 +608,39 @@ func (tool execCommandTool) startSession(commandText string, absoluteCwd string,
return session, nil
}
func (session *execSession) collect(ctx context.Context, wait time.Duration) string {
// collect drains the session's output buffer, returning the accumulated text
// and whether the buffer ever had to drop output to stay within
// maxExecOutputBufferBytes since the last collect call.
func (session *execSession) collect(ctx context.Context, wait time.Duration) (string, bool) {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(wait)
var builder strings.Builder
truncated := false
finish := func() (string, bool) {
if session.output.consumeTruncated() {
truncated = true
}
return builder.String(), truncated
}
for {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
if session.output.consumeTruncated() {
truncated = true
}
continue
}
if session.doneClosed() {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
}
return builder.String()
return finish()
}
remaining := time.Until(deadline)
if remaining <= 0 {
return builder.String()
return finish()
}
timer := time.NewTimer(remaining)
select {
@@ -602,9 +665,9 @@ func (session *execSession) collect(ctx context.Context, wait time.Duration) str
default:
}
}
return builder.String()
return finish()
case <-timer.C:
return builder.String()
return finish()
}
}
}
@@ -701,22 +764,23 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
}
}
}
output := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResult(execToolResultInput{
commandText: session.commandText,
output: output,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: session.relativeCwd,
tty: session.tty,
interrupted: interrupted,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
commandText: session.commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: session.relativeCwd,
tty: session.tty,
interrupted: interrupted,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
})
}
@@ -741,16 +805,22 @@ func shouldInterruptExecSession(chars string, tty bool) bool {
}
type execToolResultInput struct {
commandText string
output string
sessionID int
exitCode int
exited bool
relativeCwd string
tty bool
interrupted bool
plan zeroSandbox.CommandPlan
maxOutputTokens int
commandText string
output string
// outputBufferTruncated is true when the session's undrained output buffer
// had to drop bytes to stay within maxExecOutputBufferBytes since it was
// last collected — data that is gone for good, unlike the head/tail
// truncation below (which the caller can recover by polling again or
// raising max_output_tokens).
outputBufferTruncated bool
sessionID int
exitCode int
exited bool
relativeCwd string
tty bool
interrupted bool
plan zeroSandbox.CommandPlan
maxOutputTokens int
}
func execToolResult(input execToolResultInput) Result {
@@ -771,16 +841,26 @@ func execToolResult(input execToolResultInput) Result {
} else {
meta["session_id"] = strconv.Itoa(input.sessionID)
}
if input.outputBufferTruncated {
meta["output_buffer_truncated"] = "true"
}
status := StatusOK
if input.exited && ((input.exitCode != 0 && !input.interrupted) || meta[SandboxLikelyDeniedMeta] == "true") {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if input.outputBufferTruncated {
// Appended after truncateExecOutput's own head/tail slicing, not
// embedded in the text that goes through it — a marker inside that
// text can land in the discarded middle or get chopped at a small
// max_output_tokens budget. Appending here guarantees it survives.
body += "\n" + execOutputBufferTruncatedMessage
}
return Result{
Status: status,
Output: body,
Truncated: truncated,
Truncated: truncated || input.outputBufferTruncated,
Meta: meta,
Display: Display{
Summary: execDisplaySummary(input.commandText, input.sessionID, input.exited, input.exitCode),
+87
View File
@@ -363,6 +363,93 @@ func TestStartExecProcessFallsBackAfterPTYStartMutation(t *testing.T) {
}
}
// TestExecOutputBufferCapsUndrainedData: a session nobody polls must not grow
// its undrained buffer without bound — a long-lived background process that
// keeps writing while unpolled previously ran a session's memory into the
// tens of gigabytes and got the whole zero process OOM-killed by the OS.
func TestExecOutputBufferCapsUndrainedData(t *testing.T) {
buffer := newExecOutputBuffer()
chunk := strings.Repeat("x", 1024)
writes := (maxExecOutputBufferBytes / len(chunk)) + 10 // comfortably over the cap
for i := 0; i < writes; i++ {
if _, err := buffer.Write([]byte(chunk)); err != nil {
t.Fatalf("Write: %v", err)
}
}
buffer.mu.Lock()
dataLen := len(buffer.data)
buffer.mu.Unlock()
if dataLen > maxExecOutputBufferBytes {
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxExecOutputBufferBytes)
}
out := buffer.drainString()
// drainString itself carries no truncation marker: a marker embedded in
// the drained text would always sit ~maxExecOutputBufferBytes before the
// end of the string, past any realistic head/tail truncation window a
// caller applies afterward (see execToolResult), so it's reliably lost.
// The signal lives out of band on consumeTruncated/peekTruncated instead.
if !strings.HasSuffix(out, chunk) {
t.Fatal("drained output should keep the most recent bytes, not the oldest")
}
if len(out) > maxExecOutputBufferBytes {
t.Fatalf("drained output = %d bytes, want <= %d (no marker text mixed in)", len(out), maxExecOutputBufferBytes)
}
if !buffer.peekTruncated() {
t.Fatal("peekTruncated should report the overflow without clearing it")
}
if !buffer.peekTruncated() {
t.Fatal("a second peekTruncated call should still report it — peek must not consume")
}
if !buffer.consumeTruncated() {
t.Fatal("consumeTruncated should report the overflow")
}
if buffer.consumeTruncated() {
t.Fatal("consumeTruncated should reset after being read once")
}
if buffer.peekTruncated() {
t.Fatal("peekTruncated should reflect the reset state after consumeTruncated")
}
// A second drain with no intervening writes must be empty.
if got := buffer.drainString(); got != "" {
t.Fatalf("drainString after a full drain = %q, want empty", got)
}
}
// TestExecToolResultSurfacesBufferTruncationOutsideByteBudget: an earlier
// version embedded the truncation notice directly in the drained text, which
// a review found sits ~maxExecOutputBufferBytes before the end of the
// string — far past the head/tail window truncateExecOutput keeps even at
// the tool's smallest allowed max_output_tokens, so the notice was reliably
// swallowed or chopped. The notice must survive regardless of how small the
// byte budget is, and it must not count against that budget.
func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
hugeOutput := strings.Repeat("y", maxExecOutputBufferBytes+1024)
result := execToolResult(execToolResultInput{
commandText: "cmd",
output: hugeOutput,
outputBufferTruncated: true,
sessionID: 1,
exited: false,
maxOutputTokens: 1, // the schema's own declared minimum
})
if !strings.Contains(result.Output, execOutputBufferTruncatedMessage) {
t.Fatalf("result output should contain the buffer-truncation notice even at the smallest max_output_tokens, got %q", result.Output[:min(len(result.Output), 200)])
}
if result.Meta["output_buffer_truncated"] != "true" {
t.Fatalf("result meta should flag output_buffer_truncated, got %#v", result.Meta)
}
if !result.Truncated {
t.Fatal("result should report Truncated when the buffer dropped output")
}
}
// resilientTempDir is like t.TempDir() but tolerates the Windows handle-release
// lag: a SIGKILL'd child process that had the dir as its cwd may not have
// released it the instant it is reaped, so the immediate RemoveAll t.TempDir()
+3
View File
@@ -146,6 +146,9 @@ func formatBackgroundTerminalRow(session tools.ExecSessionSnapshot, now time.Tim
}
preview := compactCommandOutputText(session.RecentOutput)
prefix := fmt.Sprintf("%d · %s · %s · %s", session.ID, session.Status, age, cwd)
if session.OutputTruncated {
prefix += " · output truncated"
}
if preview != "" {
return prefix + " · " + command + " · " + preview
}
-7
View File
@@ -56,13 +56,6 @@ func pasteFromClipboardCmd() tea.Cmd {
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
// the paste; empty content is a no-op.
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// A paste is a deliberate action, same as a keypress or click — it means
// the user moved on to something else, so it disarms a stale Esc
// cancel-confirmation. tea.PasteMsg/clipboardReadMsg aren't
// tea.KeyPressMsg, so they don't go through that generic keypress hook;
// do it once here, before any early return, so both the bracketed-paste
// and right-click-paste paths are covered uniformly.
m = m.disarmCancelConfirmation()
if content == "" {
// Empty text clipboard — the user may have pasted a screenshot.
// Probe the OS clipboard for image content asynchronously.
+2 -2
View File
@@ -281,9 +281,9 @@ var commandDefinitions = []commandDefinition{
},
{
name: "/theme",
usage: "/theme [auto|dark|light]",
usage: "/theme [list|auto|name]",
group: commandGroupSession,
description: "Switch or show the color theme (auto-detects terminal background).",
description: "Pick a color theme (no arg opens the picker; auto detects the terminal background).",
kind: commandTheme,
},
{
-2
View File
@@ -122,8 +122,6 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if !transcriptContains(next.transcript, "Run cancelled.") {
t.Fatalf("expected visible cancellation marker, got %#v", next.transcript)
}
+1 -1
View File
@@ -31,7 +31,7 @@ var keybindingGroups = []keybindingGroup{
bindings: []keybinding{
{"Enter", "send the message"},
{"Alt+Enter", "insert a newline (multi-line compose)"},
{"Esc (×2)", "cancel the run / dismiss a popup / clear the input"},
{"Esc", "cancel the run / dismiss a popup / clear the input"},
{"Ctrl+C", "cancel the run, then quit"},
{"?", "show this help (on an empty input)"},
},
+52 -73
View File
@@ -40,15 +40,6 @@ const chatWheelScrollLines = 5
const ctrlCExitConfirmDuration = 3 * time.Second
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
// escCancelConfirmDuration/escCancelConfirmText guard Esc cancelling a running
// turn, mirroring ctrlCExitConfirmDuration/ctrlCExitConfirmText's pattern (same
// window, same amber footer treatment) so the two "stop it" keybinds feel
// consistent. Without this, a single stray Esc — e.g. meant to dismiss a
// suggestion overlay that had already closed — silently threw away an
// in-progress run with no chance to reconsider.
const escCancelConfirmDuration = 3 * time.Second
const escCancelConfirmText = "Press Esc again to cancel"
// dragEdgeScrollInterval/dragEdgeScrollStep drive the smooth-glide auto-scroll
// while a drag holds past the transcript edge (see edgeScrollDelta). A small step
// on a short, steady cadence reads as a smooth continuous scroll; the wheel-scroll
@@ -305,14 +296,6 @@ type model struct {
copyStatusSeq int
exitConfirmActive bool
exitConfirmSeq int
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
// (same seq-gated tea.Tick pattern) but guard a DIFFERENT action: Esc
// cancelling a running turn. The two are deliberately separate state (not a
// shared flag) since they're different actions with different consequences
// (quit the app vs. cancel the current run) that are armed by different
// keys — Ctrl+C and Esc respectively.
cancelConfirmActive bool
cancelConfirmSeq int
// edgeScrollDelta drives the smooth-glide auto-scroll while a drag holds past
// the transcript's top/bottom edge: 0 when idle, else the signed per-tick step
// (matches transcriptEdgeScrollDelta's sign convention). A self-scheduling
@@ -375,12 +358,6 @@ type exitConfirmExpiredMsg struct {
seq int
}
// cancelConfirmExpiredMsg mirrors exitConfirmExpiredMsg for the Esc
// cancel-a-run confirmation (see cancelConfirmActive).
type cancelConfirmExpiredMsg struct {
seq int
}
// dragEdgeScrollTickMsg advances the smooth-glide auto-scroll one step (see
// edgeScrollDelta). seq must match m.edgeScrollSeq or the tick is stale (the
// drag moved back into the body, released, or was otherwise stopped since this
@@ -701,7 +678,7 @@ func newModel(ctx context.Context, options Options) model {
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME")),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
userAgent: options.UserAgent,
usageTracker: usageTracker,
@@ -872,14 +849,6 @@ func (m model) disarmExitConfirmation() model {
return m
}
func (m model) disarmCancelConfirmation() model {
if m.cancelConfirmActive {
m.cancelConfirmActive = false
m.cancelConfirmSeq++
}
return m
}
// Update routes every message through updateModel, then advances the flush
// frontier for inline rendering. Alt-screen runs keep rows in the managed view
// instead of printing into terminal scrollback (see flush.go).
@@ -958,11 +927,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.exitConfirmActive = false
}
return m, nil
case cancelConfirmExpiredMsg:
if msg.seq == m.cancelConfirmSeq {
m.cancelConfirmActive = false
}
return m, nil
case dragEdgeScrollTickMsg:
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
return m, nil // stale, or the chain was stopped since this tick was scheduled
@@ -1010,13 +974,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if !keyCtrl(msg, 'c') {
m = m.disarmExitConfirmation()
}
// Mirrors the exit-confirmation reset above: any key that isn't itself
// the confirming Esc means the user moved on to something else, so a
// later, unrelated Esc must arm a fresh confirmation rather than
// silently cancel off a stale press from seconds ago.
if !keyIs(msg, tea.KeyEsc) {
m = m.disarmCancelConfirmation()
}
// The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every
// other key is swallowed so nothing types into the hidden composer.
if m.helpOverlay {
@@ -1039,15 +996,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
case keyIs(msg, tea.KeyEsc):
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
// reaching the run-cancel fallback. Capture whether this press really
// is the confirming second Esc BEFORE any of those branches can fire,
// then disarm unconditionally: an Esc that gets consumed by one of
// them wasn't a confirm, so it must not leave cancelConfirmActive
// armed for some later, unrelated Esc to silently act on.
wasConfirmingCancel := m.pending && m.cancelConfirmActive
m = m.disarmCancelConfirmation()
// Subchat view exits on Esc (returns to main chat).
if m.subchat.active {
m.chatScrollOffset = m.subchat.exit()
@@ -1099,6 +1047,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.picker.kind == pickerModel {
m.clearModelPickerLoadState()
}
if m.picker.kind == pickerTheme {
// A live theme preview was applied while navigating; restore the
// committed palette since Esc dismisses without choosing.
m.restoreCommittedTheme()
}
m.picker = nil
return m, nil
}
@@ -1108,24 +1061,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.hasQueuedMessage() {
return m.clearQueuedMessage(), nil
}
m.clearComposer()
m.clearSuggestions()
if m.pending {
if wasConfirmingCancel {
m.clearComposer()
m.cancelRun()
return m, nil
}
// First Esc only arms the confirmation — preserve whatever
// draft the user has typed, since nothing has actually been
// cancelled yet and they may not press Esc again.
m.cancelConfirmActive = true
m.cancelConfirmSeq++
seq := m.cancelConfirmSeq
return m, tea.Tick(escCancelConfirmDuration, func(time.Time) tea.Msg {
return cancelConfirmExpiredMsg{seq: seq}
})
m.cancelRun()
}
m.clearComposer()
return m, nil
case keyIs(msg, tea.KeyEnter):
if m.transcriptDetailed {
@@ -1246,6 +1186,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.picker.deleteQueryRune()
// Editing the filter changes which row is highlighted; keep the
// theme preview in sync with it (no-op for other pickers).
m.previewSelectedTheme()
return m, nil
}
// On an empty composer, Backspace removes the last attachment chip
@@ -1318,7 +1261,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.picker.move(1)
m.pickerMoved(1)
return m, nil
}
if m.suggestionsActive() {
@@ -1360,7 +1303,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.picker.move(-1)
m.pickerMoved(-1)
return m, nil
}
if m.suggestionsActive() {
@@ -1419,6 +1362,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if keyPrintable(msg) {
m.picker.appendQuery(keyRunes(msg))
// Filtering changes the highlighted row; keep the theme preview in
// sync with it (no-op for other pickers).
m.previewSelectedTheme()
}
return m, nil
}
@@ -1675,7 +1621,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
m.pending = false
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
// Fully reset the fade state at stream end. The next render
// emits the final row in solid ink (no settling animation), and
// the pending streamingFadeTickMsg that lands after this point
@@ -2000,6 +1945,17 @@ func (m model) View() tea.View {
view := tea.NewView(content)
view.AltScreen = m.altScreen
// Paint the whole frame with the active theme's surface. Zero never paints the
// terminal's own canvas, so without this a theme's text falls on the terminal
// background — fine when they share polarity, but a light theme's dark text on a
// dark terminal (or vice versa) is invisible, and a color theme never shows its
// real surface. Painting the panel makes every theme self-contained and legible
// on any terminal, and fills the transparent popup interiors (e.g. the /theme
// picker) too. Alt-screen only, so inline output never leaves a painted
// background behind in the user's scrollback after exit.
if m.altScreen {
view.BackgroundColor = zeroTheme.bgPanel
}
view.ReportFocus = m.notifier != nil
if m.wantsMouseCapture() {
// AllMotion (not CellMotion) is required for hover highlighting: it
@@ -3331,6 +3287,11 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
}
item, ok := picker.current()
if !ok {
if picker.kind == pickerTheme {
// No selectable row (e.g. the filter matched nothing): undo any live
// preview so the palette matches the committed m.themeMode.
m.restoreCommittedTheme()
}
return m, nil
}
switch picker.kind {
@@ -3355,6 +3316,18 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
case pickerTheme:
// The hovered palette is already live from the preview; handleThemeCommand
// records the choice (m.themeMode) and re-applies it, and reports the switch.
text := ""
m, text = m.handleThemeCommand(item.Value)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
if m.themeMode == themeAuto {
// Re-probe the terminal background so a committed `auto` re-detects
// light/dark instead of reusing the preview's reading — mirrors the
// text /theme dispatch (M17).
return m, tea.RequestBackgroundColor
}
}
return m, nil
}
@@ -3610,6 +3583,13 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandTheme:
// Bare `/theme` opens the popup picker (live preview on move, apply on
// Enter), matching /model and /effort. An explicit `/theme auto|dark|light`
// (or `/theme list`) still runs the text handler directly.
if strings.TrimSpace(command.text) == "" {
m.picker = m.newThemePicker()
return m, nil
}
text := ""
m, text = m.handleThemeCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
@@ -3873,8 +3853,7 @@ func (m *model) cancelRun() {
m.pending = false
m.runCancel = nil
m.activeRunID = 0
m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.pendingPermission = nil
m.pendingAskUser = nil
// The interim block renders streamingText live; a cancelled run's partial
+4 -200
View File
@@ -1108,221 +1108,25 @@ func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) {
}
}
func TestEscRequiresSecondPressToCancelPendingRun(t *testing.T) {
func TestEscCancelsPendingRun(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, cmd := m.Update(testKey(tea.KeyEsc))
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if cancelled {
t.Fatal("first Esc should not cancel the pending run")
}
if !next.pending {
t.Fatal("first Esc should leave the run pending")
}
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if cmd == nil {
t.Fatal("first Esc should schedule confirmation expiry")
}
status := plainRender(t, next.statusLine(80))
if !strings.Contains(status, escCancelConfirmText) {
t.Fatalf("status line = %q, want cancel confirmation", status)
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if !cancelled {
t.Fatal("second Esc should cancel pending run")
t.Fatal("expected Esc to cancel pending run")
}
if next.pending {
t.Fatal("expected second Esc to clear pending state")
t.Fatal("expected Esc to clear pending state")
}
if next.activeRunID != 0 || next.runCancel != nil {
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
}
if next.cancelConfirmActive {
t.Fatal("cancelling should clear cancel confirmation")
}
}
// TestEscArmingCancelConfirmationPreservesComposerDraft: the first Esc only
// arms the confirmation — nothing has actually been cancelled yet, so it
// must not destroy a draft the user is still typing. Only the confirming
// second Esc (the one that actually cancels the run) clears the composer.
func TestEscArmingCancelConfirmationPreservesComposerDraft(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
m.input.SetValue("draft prompt")
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if next.composerValue() != "draft prompt" {
t.Fatalf("first Esc should preserve the draft, got %q", next.composerValue())
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if next.pending {
t.Fatal("second Esc should cancel the pending run")
}
if next.composerValue() != "" {
t.Fatalf("the confirming second Esc should clear the draft, got %q", next.composerValue())
}
}
// TestPasteDisarmsCancelConfirmation: a paste isn't a keypress, so it never
// went through the generic "any non-Esc key disarms it" hook. Pasting is a
// deliberate action just like typing or clicking — it must disarm a stale
// confirmation too, or a later, unrelated Esc could silently cancel the run.
func TestPasteDisarmsCancelConfirmation(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
updated, _ = next.Update(testPaste("pasted text"))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("a paste should disarm the stale cancel confirmation")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after a paste should re-arm, not immediately cancel")
}
if !next.cancelConfirmActive {
t.Fatal("Esc after a paste should arm a fresh confirmation")
}
}
func TestEscCancelConfirmationExpires(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
seq := next.cancelConfirmSeq
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq - 1})
next = updated.(model)
if !next.cancelConfirmActive {
t.Fatal("stale expiry should not clear active cancel confirmation")
}
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq})
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("matching expiry should clear cancel confirmation")
}
if !next.pending {
t.Fatal("expiring the confirmation should not cancel the run")
}
}
// TestEscCancelConfirmationDisarmsOnInterveningKey mirrors
// TestCtrlCExitConfirmationDisarmsOnInterveningKey: an intervening key other
// than Esc (typing, Ctrl+O, etc.) means the user moved on to something else,
// so a later, unrelated Esc must arm a fresh confirmation instead of
// silently cancelling off a stale press from seconds ago.
func TestEscCancelConfirmationDisarmsOnInterveningKey(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
seq := next.cancelConfirmSeq
updated, _ = next.Update(testKey('h'))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("an intervening non-Esc key should disarm cancel confirmation")
}
if next.cancelConfirmSeq == seq {
t.Fatal("disarming should advance the sequence so a stale expiry tick is ignored")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after an intervening key should re-arm, not cancel")
}
if !next.cancelConfirmActive {
t.Fatal("Esc after an intervening key should arm a fresh confirmation")
}
}
// TestEscCancelConfirmationDisarmsWhenStolenByAskUser: a mid-turn ask_user
// prompt lands between the two Esc presses. The user's second Esc denies the
// questionnaire (an earlier branch in the Esc handler), not a confirm, so it
// must not leave cancelConfirmActive armed for a later, unrelated Esc to
// silently cancel the run.
func TestEscCancelConfirmationDisarmsWhenStolenByAskUser(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
request := agent.AskUserRequest{Questions: []agent.AskUserQuestion{{Question: "name?"}}}
next.pendingAskUser = &pendingAskUserPrompt{
request: request,
answer: func([]string) {},
states: newAskUserStates(request.Questions),
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("an Esc consumed by the ask-user prompt must not cancel the run")
}
if next.cancelConfirmActive {
t.Fatal("an Esc consumed by the ask-user prompt must disarm the stale cancel confirmation")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("the next Esc should arm a fresh confirmation, not immediately cancel")
}
if !next.cancelConfirmActive {
t.Fatal("the next Esc should arm a fresh confirmation")
}
}
func TestAgentResponseCompletesStuckPlan(t *testing.T) {
+5 -11
View File
@@ -40,15 +40,6 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.providerWizard != nil && m.providerWizard.oauthPending {
return m, nil
}
// A click is a deliberate action, same as a keypress — it means the user
// moved on to something else, so it disarms a stale Esc cancel-confirmation
// rather than leaving it armed for some later, unrelated Esc to act on.
// Scoped to actual clicks (not hover/motion/wheel), since motion events fire
// continuously while the mouse merely sits over the terminal and would
// otherwise defeat the confirmation window on every render tick.
if mouseLeftPress(msg) || mouseRightPress(msg) {
m = m.disarmCancelConfirmation()
}
// A right-click pastes the clipboard straight into the focused field — no
// menu. pasteFromClipboardCmd reads the clipboard off the Update goroutine; the
// clipboardReadMsg result is routed by routePaste to wherever input is focused
@@ -139,7 +130,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.picker.move(-1)
m.pickerMoved(-1)
return m, nil
}
if m.suggestionsActive() {
@@ -171,7 +162,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.picker.move(1)
m.pickerMoved(1)
return m, nil
}
if m.suggestionsActive() {
@@ -450,6 +441,9 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if hit.y == line {
index := start + offset
m.picker.selected = index
// Clicking (or hovering onto) a row live-previews it, like arrow keys,
// so the theme picker repaints before the confirming second click.
m.previewSelectedTheme()
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
}
line++
+8 -4
View File
@@ -51,10 +51,14 @@ type Options struct {
PermissionMode agent.PermissionMode
ReasoningEffort modelregistry.ReasoningEffort
ResponseStyle string
// Theme is the operator's palette preference: "auto" (default), "dark", or
// "light". Set from the --theme flag; falls back to ZERO_THEME then auto.
Theme string
UserAgent string
// Theme is the operator's palette preference: "auto" (default), a built-in
// ("dark"/"light"), or a registered color theme. Set from the --theme flag;
// falls back to ZERO_THEME, then the persisted SavedTheme, then auto.
Theme string
// SavedTheme is the theme persisted in user config (Preferences.Theme). Applied
// at startup below --theme and ZERO_THEME, so a /theme choice survives restart.
SavedTheme string
UserAgent string
// Notify configures completion / awaiting-input notifications.
Notify config.NotifyConfig
+42
View File
@@ -23,6 +23,7 @@ const (
pickerModel pickerKind = iota
pickerEffort
pickerSession
pickerTheme
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
@@ -726,3 +727,44 @@ func (m model) newEffortPicker() *commandPicker {
}
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
}
// newThemePicker lists `auto` plus every registered theme as a popup, grouped into
// Dark/Light sections (the registry is ordered dark-then-light so the group header
// changes exactly once), preselecting the active preference. Bare `/theme` opens the
// same overlay /model and /effort use. Moving the cursor live-previews each palette
// (previewSelectedTheme); Enter commits the highlighted theme (choosePicker) and Esc
// restores the previous one (Update's picker-cancel path). Items stay 1:1 with
// themeModes and in the same order, so the popup and /theme state list agree.
func (m model) newThemePicker() *commandPicker {
items := make([]pickerItem, 0, len(themeModes))
selected := 0
// `auto` sits at the top with an empty Group, so it renders header-less above
// the Dark/Light sections.
items = append(items, pickerItem{Label: string(themeAuto), Value: string(themeAuto), Meta: "match terminal"})
for _, entry := range themeRegistry {
group := "Light"
if entry.IsDark {
group = "Dark"
}
items = append(items, pickerItem{Group: group, Label: entry.Label, Value: entry.Name})
if entry.Name == string(m.themeMode) {
selected = len(items) - 1
}
}
// allItems lets the query filter restore rows on Backspace (one-way narrowing
// otherwise, since applyQuery falls back to the current items without it).
return &commandPicker{kind: pickerTheme, title: "select theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
}
// pickerMoved advances the open picker's cursor by delta and live-previews the new
// selection where the picker supports it — stepping through the /theme popup
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
// mutate through m.picker (a pointer) and the global theme, so the value receiver
// is fine.
func (m model) pickerMoved(delta int) {
if m.picker == nil {
return
}
m.picker.move(delta)
m.previewSelectedTheme()
}
+7 -4
View File
@@ -639,14 +639,17 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) {
}
}
func TestThemeCommandOpensNoPicker(t *testing.T) {
// /theme keeps the existing shell-only message; no picker opens.
func TestThemeCommandOpensPicker(t *testing.T) {
// Bare /theme opens the theme popup (live preview on move, apply on Enter),
// like /model and /effort. Full preview/commit/cancel behavior is covered in
// theme_picker_test.go; here we just pin that the no-arg command opens it.
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker != nil {
t.Fatal("/theme should not open a picker")
if m.picker == nil || m.picker.kind != pickerTheme {
t.Fatalf("expected the theme picker to open, got %#v", m.picker)
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ func pickerBusyText(name string) string {
Title: "Busy",
Lines: []string{"Can't change " + label + " while a run is in progress."},
}},
Hints: []string{"press Esc twice to cancel the run, then try again"},
Hints: []string{"press Esc to cancel the run, then try again"},
})
}
+1 -6
View File
@@ -775,8 +775,6 @@ func TestEscCancelRecordsSessionError(t *testing.T) {
t.Fatal("expected prompt submit to start an agent run")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
@@ -838,10 +836,7 @@ func TestCancelledRunFlushesCheckpointSessionEvents(t *testing.T) {
next = updated.(model)
if _, ok := runtimeMsg.(permissionRequestMsg); ok {
// Cancel mid-run via Esc while the permission prompt is pending: this
// unblocks the goroutine through ctx cancellation. Esc now requires a
// second press within the confirmation window to actually cancel.
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
// unblocks the goroutine through ctx cancellation.
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
cancelled = true
+3 -3
View File
@@ -32,9 +32,9 @@ func TestStreamingFadePaletteMonotonic(t *testing.T) {
// so per-bucket comparisons are byte-stable. The endpoint byte
// values are the contract — the hex strings are not.
fresh := rgbaOf(t, palette[0])
// Sanity: palette[0] must equal the accent endpoint (#759523).
if fresh.R != 0x75 || fresh.G != 0x95 || fresh.B != 0x23 {
t.Errorf("palette[0] RGBA = %v, want {R:0x75, G:0x95, B:0x23} (accent)", fresh)
// Sanity: palette[0] must equal the accent endpoint (#caff3f).
if fresh.R != 0xca || fresh.G != 0xff || fresh.B != 0x3f {
t.Errorf("palette[0] RGBA = %v, want {R:0xca, G:0xff, B:0x3f} (accent)", fresh)
}
last := rgbaOf(t, palette[streamingFadeSteps-1])
if last == fresh {
+6 -79
View File
@@ -12,7 +12,8 @@ import (
// may be swapped at startup (background detection / ZERO_THEME / --theme) or live
// via /theme. Colors are truecolor hex; lipgloss downsamples on limited displays
// and renders plain text when there is no TTY (tests). Every renderer consumes
// these named styles — no hex literal may appear outside this file.
// these named styles — no hex literal may appear outside theme_palettes.go (the
// palette tables + theme registry).
type tuiTheme struct {
// Base tokens.
ink lipgloss.Style // primary text
@@ -92,9 +93,10 @@ type tuiTheme struct {
}
// palette is the raw color-token table for one theme. buildTheme turns it into a
// resolved tuiTheme. darkPalette and lightPalette are the ONLY place hex literals
// live. All dark tints stay darker than ink so every pairing survives 256-color
// downsampling; the light set is dark-on-light with the same intent inverted.
// resolved tuiTheme. The palette literals and the ordered theme registry live in
// theme_palettes.go (the only place hex literals live). Dark palettes keep tints
// darker than ink so every pairing survives 256-color downsampling; light palettes
// are dark-on-light with the same intent inverted.
type palette struct {
panel string // card backgrounds (the terminal canvas itself is never painted full-bleed)
promptBg string // submitted user prompt background
@@ -125,81 +127,6 @@ type palette struct {
cardPerm string // permission card border (amber mixed into line)
}
// darkPalette is the original Lime palette: a near-black chat surface with one
// lime accent. bg (#070708) is the terminal's own canvas — deliberately never
// painted — so no token references it.
var darkPalette = palette{
panel: "#0e0e10",
promptBg: "#262626",
line: "#242429",
line2: "#414147",
ink: "#ececee",
muted: "#9a9aa2", // secondary text — lifted so it clearly out-ranks faint
faint: "#8a8a92", // hints/metadata — nudged up to separate from faintest
faintest: "#7c7c82", // line numbers/separators — pinned at the WCAG-AA floor on the dark panel
// Brand lime, softened from the original #caff3f (L 62%, S 100% — fully
// saturated, which read as glaring/neon everywhere it filled a block or
// bold-text surface: the composer prompt glyph, the permission popup's
// focused-option badge, the text-selection highlight). Two prior passes
// (#ade619 at L50/S80, #8db620 at L42/S70) were each confirmed too subtle
// in turn; settled at L 36%/S 62% — a darker, more olive lime. Contrast
// vs both black (onAccent) and the near-black panel stays comfortably
// above WCAG AA (~6.1:1 and ~5.6:1) with real margin to spare.
accent: "#759523",
green: "#5dd1a4",
red: "#ff7a7a",
amber: "#ffc25c",
blue: "#7db4ff",
gitAdd: "#7db87a",
gitDel: "#b87a7a",
addBg: "#18352c",
delBg: "#241819",
addBgWord: "#2e654d", // changed span within an added line — brighter green (sep 1.83 vs addBg, addInk 7.1:1)
delBgWord: "#502d30", // changed span within a deleted line — brighter red (sep 1.44 vs delBg, delInk 7.7:1)
permBg: "#1c1915",
selBg: "#32401b", // selected row bg — brightened from #1d2114 so the highlighted row separates from the panel (sep 1.18→1.73) while ink label contrast stays ~9.4:1
addInk: "#bdeed7",
delInk: "#f2c4c4",
onAccent: "#000000",
cardRun: "#5a6b2e",
cardErr: "#6b3434",
cardPerm: "#6b5a2e",
}
// lightPalette is dark-on-light: light-gray surfaces with near-black ink. The
// muted/faint/faintest grays get progressively LIGHTER (toward the surface) so the
// same text hierarchy reads on white; the lime accent is darkened to keep contrast
// against a light background, and the diff/permission tints become light surfaces.
var lightPalette = palette{
panel: "#ececed", // card backgrounds (slightly off-white)
promptBg: "#dcdce0", // submitted prompt background
line: "#cfcfd5", // default borders
line2: "#b0b0b8", // emphasized borders
ink: "#1b1b1d", // primary text (near-black)
muted: "#4a4a52", // secondary text — darkened so it clearly out-ranks faint on light
faint: "#57575f", // hints, metadata — nudged down to separate from faintest
faintest: "#646469", // line numbers, separators — pinned at the WCAG-AA floor on the light panel
accent: "#477006", // brand lime, darkened to AA contrast on light
green: "#1c7a4a", // success / diff add
red: "#c0322c", // errors / diff del
amber: "#8f6200", // permission / warnings
blue: "#1d5fd6", // grep locations
gitAdd: "#2f7a3a", // footer PR diff additions
gitDel: "#a83a3a", // footer PR diff deletions
addBg: "#e2f3e6", // diff added-line bg (light green)
delBg: "#fbe6e6", // diff deleted-line bg (light red)
addBgWord: "#97d3ab", // changed span within an added line — deeper green (sep 1.49 vs addBg, addInk 4.7:1)
delBgWord: "#f1b3b3", // changed span within a deleted line — deeper red (sep 1.48 vs delBg, delInk 4.6:1)
permBg: "#fbf0d8", // permission card bg (light amber)
selBg: "#cfe78f", // selected row bg (light accent) — deepened from #e7f2cd so it separates from the near-white panel (sep 1.01→1.15); ink label stays >12:1, faint ~5:1
addInk: "#1d5b37", // added-line text
delInk: "#8e2d2d", // deleted-line text
onAccent: "#ffffff", // text on the (dark) accent/amber fills
cardRun: "#94ad60", // running card border
cardErr: "#cf9a9a", // errored card border
cardPerm: "#c9ad7d", // permission card border
}
// buildTheme resolves a palette into the styles every renderer uses.
func buildTheme(p palette) tuiTheme {
col := func(s string) color.Color { return lipgloss.Color(s) }
+457
View File
@@ -0,0 +1,457 @@
package tui
import "strings"
// theme_palettes.go is the sole home of raw color hex in the TUI: every palette
// literal and the ordered theme registry live here, so theme.go stays hex-free
// (just the palette struct, buildTheme, and the resolved tuiTheme). Adding a theme
// is a new palette{...} literal plus one themeRegistry entry — nothing else.
//
// Contrast discipline: every palette must satisfy the same WCAG invariants the
// built-ins do (asserted across the whole registry in theme_select_test.go): ink
// and faintest ≥ AA on panel; onAccent ≥ AA on accent; addInk/delInk readable on
// their diff bands and word spans; the muted>faint>faintest ramp monotonic toward
// the surface; and selBg visibly separated from panel while its label stays legible.
// darkPalette is the original Lime palette: a near-black chat surface with one
// lime accent. bg (#070708) is the terminal's own canvas — deliberately never
// painted — so no token references it.
var darkPalette = palette{
panel: "#0e0e10",
promptBg: "#262626",
line: "#242429",
line2: "#414147",
ink: "#ececee",
muted: "#9a9aa2", // secondary text — lifted so it clearly out-ranks faint
faint: "#8a8a92", // hints/metadata — nudged up to separate from faintest
faintest: "#7c7c82", // line numbers/separators — pinned at the WCAG-AA floor on the dark panel
accent: "#caff3f", // original bright lime (the brand accent)
green: "#5dd1a4",
red: "#ff7a7a",
amber: "#ffc25c",
blue: "#7db4ff",
gitAdd: "#7db87a",
gitDel: "#b87a7a",
addBg: "#18352c",
delBg: "#241819",
addBgWord: "#2e654d", // changed span within an added line — brighter green (sep 1.83 vs addBg, addInk 7.1:1)
delBgWord: "#502d30", // changed span within a deleted line — brighter red (sep 1.44 vs delBg, delInk 7.7:1)
permBg: "#1c1915",
selBg: "#32401b", // selected row bg — brightened from #1d2114 so the highlighted row separates from the panel (sep 1.18→1.73) while ink label contrast stays ~9.4:1
addInk: "#bdeed7",
delInk: "#f2c4c4",
onAccent: "#000000",
cardRun: "#5a6b2e",
cardErr: "#6b3434",
cardPerm: "#6b5a2e",
}
// draculaPalette — the Dracula scheme (dracula.com): muted-violet surface, purple
// accent, high-chroma pink/green/cyan signals.
var draculaPalette = palette{
panel: "#282a36",
promptBg: "#383c4d",
line: "#363a4b",
line2: "#484c62",
ink: "#f8f8f2",
muted: "#b9bccb",
faint: "#a2a5b8",
faintest: "#9195ac",
accent: "#bd93f9",
green: "#50fa7b",
red: "#ff5555",
amber: "#ffb86c",
blue: "#8be9fd",
gitAdd: "#77c58c",
gitDel: "#d98d8d",
addBg: "#1c3b2a",
delBg: "#3a2026",
addBgWord: "#235035",
delBgWord: "#5e333b",
permBg: "#322a1e",
selBg: "#504482",
addInk: "#cbf2dd",
delInk: "#f4c9c9",
onAccent: "#000000",
cardRun: "#7c6aa6",
cardErr: "#98505a",
cardPerm: "#9a7c62",
}
// nordPalette — the Nord scheme (nordtheme.com): cool polar-night slate with a
// frost-blue accent and desaturated aurora signals.
var nordPalette = palette{
panel: "#3b4252",
promptBg: "#464f62",
line: "#434c5e",
line2: "#4c566a",
ink: "#eceff4",
muted: "#c8cfda",
faint: "#b4bdcb",
faintest: "#a5afc1",
accent: "#88c0d0",
green: "#a3be8c",
red: "#bf616a",
amber: "#d08770",
blue: "#81a1c1",
gitAdd: "#8ba077",
gitDel: "#b0757d",
addBg: "#37433a",
delBg: "#45383d",
addBgWord: "#456d46",
delBgWord: "#6d4650",
permBg: "#47413a",
selBg: "#40688a",
addInk: "#d6ecca",
delInk: "#f0c6cb",
onAccent: "#000000",
cardRun: "#4c6672",
cardErr: "#6b4a51",
cardPerm: "#6a564d",
}
// gruvboxPalette — Gruvbox dark (medium): warm retro browns with an olive-green
// accent and high-contrast cream ink.
var gruvboxPalette = palette{
panel: "#32302f",
promptBg: "#3c3836",
line: "#504945",
line2: "#665c54",
ink: "#ebdbb2",
muted: "#c9b99a",
faint: "#b7a78d",
faintest: "#a89984",
accent: "#8ec07c",
green: "#b8bb26",
red: "#fb4934",
amber: "#fabd2f",
blue: "#83a598",
gitAdd: "#98971a",
gitDel: "#cc241d",
addBg: "#2f3a29",
delBg: "#3b2b29",
addBgWord: "#3e5236",
delBgWord: "#593733",
permBg: "#38331e",
selBg: "#3d4e30",
addInk: "#c5e6b0",
delInk: "#f0c0bb",
onAccent: "#000000",
cardRun: "#6f8460",
cardErr: "#a5493d",
cardPerm: "#a5833a",
}
// tokyoNightPalette — Tokyo Night (storm): deep indigo surface, soft blue accent,
// cool high-contrast ink.
var tokyoNightPalette = palette{
panel: "#1e2030",
promptBg: "#2c3149",
line: "#262a3d",
line2: "#3b4261",
ink: "#c8d3f5",
muted: "#a9b1d0",
faint: "#9099b2",
faintest: "#838ba8",
accent: "#82aaff",
green: "#c3e88d",
red: "#ff757f",
amber: "#ffc777",
blue: "#86e1fc",
gitAdd: "#96bf7d",
gitDel: "#c77e85",
addBg: "#20303b",
delBg: "#37222c",
addBgWord: "#2b5a4a",
delBgWord: "#5c2e3a",
permBg: "#2a2419",
selBg: "#2a385b",
addInk: "#b8e4d3",
delInk: "#f3c4cb",
onAccent: "#000000",
cardRun: "#4b5d8b",
cardErr: "#7d4857",
cardPerm: "#7d6954",
}
// catppuccinPalette — Catppuccin Mocha: soft lavender surface with a mauve accent
// and pastel signals.
var catppuccinPalette = palette{
panel: "#1e1e2e",
promptBg: "#34364b",
line: "#313244",
line2: "#45475a",
ink: "#cdd6f4",
muted: "#a6adc8",
faint: "#9399b2",
faintest: "#83889f",
accent: "#cba6f7",
green: "#a6e3a1",
red: "#f38ba8",
amber: "#f9e2af",
blue: "#89b4fa",
gitAdd: "#8cbf8a",
gitDel: "#cc8a9b",
addBg: "#24312b",
delBg: "#3c2a32",
addBgWord: "#2f5140",
delBgWord: "#56333f",
permBg: "#29261b",
selBg: "#322e46",
addInk: "#c4ecd6",
delInk: "#f4cdd6",
onAccent: "#000000",
cardRun: "#7a6a99",
cardErr: "#8a5b72",
cardPerm: "#8d8274",
}
// oneDarkPalette — Atom One Dark: slate-gray surface with a blue accent.
var oneDarkPalette = palette{
panel: "#2e323b",
promptBg: "#3a3f4a",
line: "#393f4a",
line2: "#4b525f",
ink: "#abb2bf",
muted: "#a2a9b6",
faint: "#9aa1af",
faintest: "#969cab",
accent: "#61afef",
green: "#98c379",
red: "#e06c75",
amber: "#e5c07b",
blue: "#56b6c2",
gitAdd: "#82a06a",
gitDel: "#bd8087",
addBg: "#2c382b",
delBg: "#3a2d2f",
addBgWord: "#3d5a3a",
delBgWord: "#5c3e40",
permBg: "#3c3826",
selBg: "#354256",
addInk: "#cdeab3",
delInk: "#f0c3c7",
onAccent: "#000000",
cardRun: "#496c8c",
cardErr: "#7c515b",
cardPerm: "#7e735e",
}
// solarizedDarkPalette — Solarized Dark (Ethan Schoonover): the signature teal
// base03/base02 surface with a cyan accent and the fixed accent wheel.
var solarizedDarkPalette = palette{
panel: "#073642",
promptBg: "#0b3b46",
line: "#123f48",
line2: "#4b636c",
ink: "#cdd6d6",
muted: "#a9b3b3",
faint: "#9ba5a5",
faintest: "#929c9c",
accent: "#3bb3a6", // solarized cyan, brightened to clear AA on the lifted base02 panel
green: "#859900",
red: "#dc322f",
amber: "#b58900",
blue: "#268bd2",
gitAdd: "#93a05e",
gitDel: "#c67b71",
addBg: "#123f31",
delBg: "#45302e",
addBgWord: "#1e5c44",
delBgWord: "#6a3f3a",
permBg: "#2e2a18",
selBg: "#17505a",
addInk: "#c3ecd6",
delInk: "#f2cbc6",
onAccent: "#000000",
cardRun: "#1d6b6c",
cardErr: "#6d393d",
cardPerm: "#5b6028",
}
// rosePinePalette — Rosé Pine (main): muted rose-quartz base with a soft-rose
// accent and cool pine/foam signals.
var rosePinePalette = palette{
panel: "#1f1d2e",
promptBg: "#2f2b47",
line: "#2b2840",
line2: "#403d52",
ink: "#e0def4",
muted: "#a8a3c0",
faint: "#928ea9",
faintest: "#8985a0",
accent: "#ebbcba",
green: "#31748f",
red: "#eb6f92",
amber: "#f6c177",
blue: "#9ccfd8",
gitAdd: "#5e88a2",
gitDel: "#d589a7",
addBg: "#1f2d3a",
delBg: "#3a1f2d",
addBgWord: "#274b5e",
delBgWord: "#763a4f",
permBg: "#4a3e3d",
selBg: "#44415a",
addInk: "#cfe8ef",
delInk: "#f6c9cd",
onAccent: "#000000",
cardRun: "#7c6673",
cardErr: "#7c4662",
cardPerm: "#806857",
}
// everforestPalette — Everforest dark (medium): warm forest-gray surface with a
// sage-green accent.
var everforestPalette = palette{
panel: "#333c43",
promptBg: "#3d484d",
line: "#414b52",
line2: "#55636b",
ink: "#d3c6aa",
muted: "#b0bab0",
faint: "#a4aea3",
faintest: "#9ca99b",
accent: "#a7c080",
green: "#83c092",
red: "#e67e80",
amber: "#dbbc7f",
blue: "#7fbbb3",
gitAdd: "#8faa78",
gitDel: "#c08888",
addBg: "#2c3f37",
delBg: "#3e3234",
addBgWord: "#324e3b",
delBgWord: "#573a3c",
permBg: "#3c382d",
selBg: "#3b482e",
addInk: "#cfead0",
delInk: "#f4cfcd",
onAccent: "#000000",
cardRun: "#798b6b",
cardErr: "#9c676b",
cardPerm: "#96896b",
}
// lightPalette is dark-on-light: a warm cream surface (so cards lift off the
// terminal page, which Zero never paints) with near-black ink and an olive-lime
// accent that keeps the brand identity while clearing AA on the light panel. The
// muted/faint/faintest grays get progressively LIGHTER toward the surface; diff and
// permission tints are light surfaces. Replaces the old flat cool-gray light set
// whose muddy panel, sub-AA diff spans, and near-invisible selBg read as broken.
var lightPalette = palette{
panel: "#efebd4",
promptBg: "#e3ddc2",
line: "#d8d2bd",
line2: "#b7b199",
ink: "#22201a",
muted: "#4b5149",
faint: "#575e55",
faintest: "#636a61",
accent: "#54700a",
green: "#1e725c",
red: "#c02434",
amber: "#8a5f00",
blue: "#1f66c0",
gitAdd: "#3d6f46",
gitDel: "#a34a4a",
addBg: "#ddf0df",
delBg: "#f8dcdc",
addBgWord: "#a2daae",
delBgWord: "#f2b6b6",
permBg: "#f7ebc6",
selBg: "#d4e08f",
addInk: "#0c4026",
delInk: "#641a1d",
onAccent: "#ffffff",
cardRun: "#b0be7e",
cardErr: "#d8b0a8",
cardPerm: "#d6c496",
}
// solarizedLightPalette — Solarized Light: the base3/base2 cream surface with the
// same fixed accent wheel as Solarized Dark, dark-on-light.
var solarizedLightPalette = palette{
panel: "#eee8d5",
promptBg: "#e1d9be",
line: "#d8d1bc",
line2: "#c0b89e",
ink: "#304049",
muted: "#495b61",
faint: "#506469",
faintest: "#576b72",
accent: "#0c665c", // solarized cyan, darkened for AA on the cream panel (with white onAccent)
green: "#859900",
red: "#dc322f",
amber: "#7a5c00", // solarized yellow, darkened so white onAccent works on amber fills
blue: "#268bd2",
gitAdd: "#788d34",
gitDel: "#ac4f50",
addBg: "#dde8c6",
delBg: "#f1ddd2",
addBgWord: "#b9d488", // deepened so the changed span separates from addBg (sep 1.28)
delBgWord: "#edc4b4",
permBg: "#f0e6bd",
selBg: "#a6d6c4",
addInk: "#38480a",
delInk: "#6f1614",
onAccent: "#ffffff", // white — accent and amber are both dark on this light theme
cardRun: "#7fbaaf",
cardErr: "#d8837a",
cardPerm: "#c4ae63",
}
// themeEntry is one registered theme: Name is the /theme value + ZERO_THEME/--theme
// token (lowercase, kebab), Label is the picker display text, and IsDark groups the
// picker (Dark/Light sections) and drives which built-in `auto` resolves to.
type themeEntry struct {
Name string
Label string
Palette palette
IsDark bool
}
// themeRegistry is the ordered source of truth for every selectable theme. Order is
// the picker order: all Dark themes first, then all Light, with the brand
// dark/light built-ins leading their groups. themeModes (theme_select.go) prepends
// `auto` to this. Append here to add a theme — nothing else needs editing.
var themeRegistry = []themeEntry{
{Name: "dark", Label: "dark", Palette: darkPalette, IsDark: true},
{Name: "dracula", Label: "Dracula", Palette: draculaPalette, IsDark: true},
{Name: "nord", Label: "Nord", Palette: nordPalette, IsDark: true},
{Name: "gruvbox", Label: "Gruvbox", Palette: gruvboxPalette, IsDark: true},
{Name: "tokyo-night", Label: "Tokyo Night", Palette: tokyoNightPalette, IsDark: true},
{Name: "catppuccin", Label: "Catppuccin", Palette: catppuccinPalette, IsDark: true},
{Name: "one-dark", Label: "One Dark", Palette: oneDarkPalette, IsDark: true},
{Name: "solarized-dark", Label: "Solarized Dark", Palette: solarizedDarkPalette, IsDark: true},
{Name: "rose-pine", Label: "Rosé Pine", Palette: rosePinePalette, IsDark: true},
{Name: "everforest", Label: "Everforest", Palette: everforestPalette, IsDark: true},
{Name: "light", Label: "light", Palette: lightPalette, IsDark: false},
{Name: "solarized-light", Label: "Solarized Light", Palette: solarizedLightPalette, IsDark: false},
}
// themeByName indexes the registry by lowercased name for O(1) lookup. Built as a
// var initializer (not init()) so it is ready before themeModes' package-var
// initializer calls themeNames().
var themeByName = func() map[string]themeEntry {
byName := make(map[string]themeEntry, len(themeRegistry))
for _, entry := range themeRegistry {
byName[entry.Name] = entry
}
return byName
}()
// lookupTheme resolves a theme name (case/space-insensitive) to its entry.
func lookupTheme(name string) (themeEntry, bool) {
entry, ok := themeByName[strings.ToLower(strings.TrimSpace(name))]
return entry, ok
}
// themeNames returns every registered theme name in registry (picker) order.
func themeNames() []string {
names := make([]string, len(themeRegistry))
for index, entry := range themeRegistry {
names[index] = entry.Name
}
return names
}
+340
View File
@@ -0,0 +1,340 @@
package tui
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
tea "charm.land/bubbletea/v2"
)
// A committed theme is written to user config and reloaded at startup (via
// Options.SavedTheme -> resolveThemeMode), so a /theme choice survives restart.
func TestThemeChoicePersistsAcrossRestart(t *testing.T) {
defer applyTheme(themeDark, true)
cfgPath := filepath.Join(t.TempDir(), "config.json")
// First session: pick a color theme via the /theme command (same commit path the
// picker uses).
m := newModel(context.Background(), Options{UserConfigPath: cfgPath})
m, _ = m.handleThemeCommand("dracula")
if m.themeMode != themeMode("dracula") {
t.Fatalf("themeMode = %q, want dracula", m.themeMode)
}
data, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("theme commit should have written config: %v", err)
}
var cfg struct {
Preferences struct {
Theme string `json:"theme"`
} `json:"preferences"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("config is not valid JSON: %v", err)
}
if cfg.Preferences.Theme != "dracula" {
t.Fatalf("preferences.theme = %q, want dracula", cfg.Preferences.Theme)
}
// Second session: the persisted theme seeds startup (no flag / env).
restarted := newModel(context.Background(), Options{UserConfigPath: cfgPath, SavedTheme: "dracula"})
if restarted.themeMode != themeMode("dracula") {
t.Fatalf("restarted themeMode = %q, want dracula (from saved config)", restarted.themeMode)
}
}
// resolveThemeMode precedence: flag > ZERO_THEME > persisted config > auto.
func TestResolveThemeModeConfigFallback(t *testing.T) {
if got := resolveThemeMode("", "", "nord"); got != themeMode("nord") {
t.Errorf("saved-only = %q, want nord", got)
}
if got := resolveThemeMode("dracula", "", "nord"); got != themeMode("dracula") {
t.Errorf("flag should beat saved: got %q, want dracula", got)
}
if got := resolveThemeMode("", "gruvbox", "nord"); got != themeMode("gruvbox") {
t.Errorf("env should beat saved: got %q, want gruvbox", got)
}
if got := resolveThemeMode("", "", "bogus-theme"); got != themeAuto {
t.Errorf("unknown saved theme should fall back to auto, got %q", got)
}
}
// themePickerRowIndex finds the picker row whose Value is name (index-independent,
// so tests survive registry reordering / additions).
func themePickerRowIndex(t *testing.T, p *commandPicker, name string) int {
t.Helper()
for i, item := range p.items {
if item.Value == name {
return i
}
}
t.Fatalf("theme picker has no row for %q", name)
return -1
}
// assertPreviewMatchesSelection checks the live palette equals the palette of the
// currently highlighted theme row (the auto row has no palette of its own).
func assertPreviewMatchesSelection(t *testing.T, m model) {
t.Helper()
sel := m.picker.items[m.picker.selected]
entry, ok := lookupTheme(sel.Value)
if !ok {
return
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, entry.Palette.ink) {
t.Errorf("preview of %q did not apply its palette (ink mismatch)", sel.Value)
}
}
// Bare `/theme` opens the popup picker (like /model and /effort) with one row per
// theme mode, preselecting the active preference. An explicit `/theme <mode>` must
// keep taking the text path so scripts and the existing state view still work.
func TestThemePickerOpensOnBareTheme(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeLight
m.input.SetValue("/theme")
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if cmd != nil {
t.Fatalf("opening the theme picker should not emit a cmd, got %T", cmd)
}
if m.picker == nil || m.picker.kind != pickerTheme {
t.Fatalf("expected the theme picker to open, got %#v", m.picker)
}
if len(m.picker.items) != len(themeModes) {
t.Fatalf("picker has %d items, want %d", len(m.picker.items), len(themeModes))
}
for i, want := range themeModes {
if m.picker.items[i].Value != want {
t.Errorf("item %d value = %q, want %q", i, m.picker.items[i].Value, want)
}
}
if got := m.picker.items[m.picker.selected].Value; got != string(themeLight) {
t.Errorf("preselected value = %q, want the active mode %q", got, themeLight)
}
}
// An explicit mode argument runs the text handler directly and never opens the
// popup — guards the dispatch branch and keeps /theme <mode> scriptable.
func TestThemeArgSkipsPicker(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.input.SetValue("/theme dark")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker != nil {
t.Fatalf("explicit /theme dark must not open a picker, got %#v", m.picker)
}
if m.themeMode != themeDark {
t.Fatalf("after /theme dark, mode = %q", m.themeMode)
}
}
// Moving the cursor live-previews each palette: the global zeroTheme swaps as the
// selection changes, so the whole UI (and the overlay) repaints in the hovered
// theme without committing the preference.
func TestThemePickerPreviewsOnMove(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Each Down moves to the next theme and live-applies its palette; Up follows back.
for i := 0; i < 3; i++ {
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
}
updated, _ = m.Update(testKey(tea.KeyUp))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
// Preview must not have written the committed preference.
if m.themeMode != themeDark {
t.Errorf("preview mutated the committed mode to %q", m.themeMode)
}
}
// Enter on a fixed mode commits it: the palette stays applied, the preference is
// recorded, the popup closes, and the switch is noted in the transcript.
func TestThemePickerCommitAppliesAndRecords(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Choose a color theme by locating its row (index-independent), then confirm.
m.picker.selected = themePickerRowIndex(t, m.picker, "dracula")
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if cmd != nil {
t.Fatalf("committing a fixed theme should not emit a cmd, got %T", cmd)
}
if m.picker != nil {
t.Fatal("committing should close the picker")
}
if m.themeMode != themeMode("dracula") {
t.Fatalf("committed mode = %q, want dracula", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, draculaPalette.ink) {
t.Error("commit did not leave the dracula palette applied")
}
if !transcriptContains(m.transcript, "active theme") {
t.Errorf("commit should record the switch in the transcript:\n%s", transcriptText(m.transcript))
}
}
// Committing `auto` re-probes the terminal background (like the text dispatch) so
// the palette re-detects light/dark instead of reusing the preview's reading.
func TestThemePickerAutoCommitReprobesBackground(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
m.picker.selected = 0 // "auto"
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.themeMode != themeAuto {
t.Fatalf("committed mode = %q, want auto", m.themeMode)
}
if cmd == nil {
t.Fatal("committing auto must return a background re-probe cmd")
}
if reflect.ValueOf(cmd).Pointer() != reflect.ValueOf(tea.RequestBackgroundColor).Pointer() {
t.Error("committing auto must return tea.RequestBackgroundColor")
}
}
// Typing to filter the list re-previews the newly-highlighted mode, so the applied
// palette never diverges from the row the popup points at. Backspacing back to an
// empty query re-previews the (restored) selection too.
func TestThemePickerFilterRePreviews(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview light by moving, then filter to "light" — the applied palette must
// track the filtered highlight (still light here), not lag behind.
updated, _ = m.Update(testKey(tea.KeyDown)) // dark -> light preview
m = updated.(model)
updated, _ = m.Update(testKeyText("l"))
m = updated.(model)
updated, _ = m.Update(testKeyText("i"))
m = updated.(model)
if got := m.picker.items[m.picker.selected].Value; got != string(themeLight) {
t.Fatalf("filter 'li' should highlight light, got %q", got)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, lightPalette.ink) {
t.Error("filtering to light did not keep the light palette previewed")
}
// Re-filter to "dark": highlight and preview must both switch to dark.
updated, _ = m.Update(testKey(tea.KeyBackspace))
m = updated.(model)
updated, _ = m.Update(testKey(tea.KeyBackspace))
m = updated.(model)
updated, _ = m.Update(testKeyText("d"))
m = updated.(model)
if got := m.picker.items[m.picker.selected].Value; got != string(themeDark) {
t.Fatalf("filter 'd' should highlight dark, got %q", got)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("filtering to dark did not re-preview the dark palette")
}
}
// A filter that matches nothing must not strand the last preview: the palette
// falls back to the committed theme, and committing (Enter on the empty list) is a
// clean no-op that leaves the committed mode and palette in agreement.
func TestThemePickerEmptyFilterRestoresCommitted(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview another theme by moving, then type a char that matches no theme name
// ('q' appears in none) -> the list empties.
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
updated, _ = m.Update(testKeyText("q"))
m = updated.(model)
if len(m.picker.items) != 0 {
t.Fatalf("filter 'q' should match no theme, got %d items", len(m.picker.items))
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("an empty filter should restore the committed dark palette, not keep the preview")
}
// Enter on the empty list closes the picker without changing the committed
// preference, and leaves the palette matching it.
updated, _ = m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker != nil {
t.Fatal("Enter on an empty filter should close the picker")
}
if m.themeMode != themeDark {
t.Fatalf("empty-list commit must not change the mode, got %q", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("after an empty-list commit the palette must match the committed dark mode")
}
}
// Esc dismisses the picker without choosing and restores the committed palette,
// undoing whatever the live preview applied.
func TestThemePickerEscRestoresCommittedTheme(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview another theme by moving, then bail out.
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r == mustR(t, darkPalette.ink) {
t.Fatal("expected a non-dark preview to be applied before Esc")
}
updated, _ = m.Update(testKey(tea.KeyEsc))
m = updated.(model)
if m.picker != nil {
t.Fatal("Esc should close the picker")
}
if m.themeMode != themeDark {
t.Fatalf("Esc must not change the committed mode, got %q", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("Esc did not restore the committed dark palette")
}
}
+98 -28
View File
@@ -1,6 +1,10 @@
package tui
import "strings"
import (
"strings"
"github.com/Gitlawb/zero/internal/config"
)
// themeMode is the operator's palette preference.
type themeMode string
@@ -11,34 +15,49 @@ const (
themeLight themeMode = "light"
)
// themeModes lists the values /theme accepts.
var themeModes = []string{string(themeAuto), string(themeDark), string(themeLight)}
// themeModes lists the values /theme accepts, in picker order: `auto` first, then
// every registered theme (theme_palettes.go). It is the single ordered source
// feeding both the picker and the /theme state list — adding a registry entry
// extends it automatically.
var themeModes = append([]string{string(themeAuto)}, themeNames()...)
// resolveThemeMode picks the preference in precedence order: an explicit value
// (the --theme flag, threaded via Options.Theme), then ZERO_THEME, then auto.
func resolveThemeMode(flagValue, envValue string) themeMode {
for _, v := range []string{flagValue, envValue} {
switch strings.ToLower(strings.TrimSpace(v)) {
case "dark":
return themeDark
case "light":
return themeLight
case "auto":
// resolveThemeMode picks the first accepted preference from candidates in
// precedence order — the caller passes them highest-first: the --theme flag, then
// ZERO_THEME, then the persisted config theme. A value is accepted if it is `auto`
// or names a registered theme; unrecognized/blank values are skipped, and an empty
// list (or all-unrecognized) falls back to auto.
func resolveThemeMode(candidates ...string) themeMode {
for _, v := range candidates {
s := strings.ToLower(strings.TrimSpace(v))
if s == "" {
continue
}
if s == string(themeAuto) {
return themeAuto
}
if _, ok := lookupTheme(s); ok {
return themeMode(s)
}
}
return themeAuto
}
// validThemeMode reports whether s names a theme mode (for /theme validation).
// validThemeMode reports whether s names a theme mode (for /theme validation):
// `auto` or any registered theme.
func validThemeMode(s string) bool {
switch themeMode(strings.ToLower(strings.TrimSpace(s))) {
case themeAuto, themeDark, themeLight:
s = strings.ToLower(strings.TrimSpace(s))
if s == string(themeAuto) {
return true
}
return false
_, ok := lookupTheme(s)
return ok
}
// ValidThemeArg reports whether s is an acceptable --theme / ZERO_THEME value
// (`auto` or a registered theme name). Exported so the CLI flag validator shares
// this one source of truth instead of hardcoding the theme list.
func ValidThemeArg(s string) bool { return validThemeMode(s) }
// applyTheme swaps the active palette (zeroTheme) and the globals derived from it
// — the streaming-fade ramp and the static render cache — so a switch repaints
// every subsequent render. For themeAuto it resolves to dark/light from
@@ -53,12 +72,13 @@ func applyTheme(mode themeMode, hasDarkBackground bool) themeMode {
resolved = themeLight
}
}
switch resolved {
case themeLight:
zeroTheme = buildTheme(lightPalette)
default:
zeroTheme = buildTheme(darkPalette)
// Resolve the (now concrete) mode to its registered palette; an unknown name
// falls back to the dark built-in so a bad value can never leave zeroTheme unset.
entry, ok := lookupTheme(string(resolved))
if !ok {
entry, _ = lookupTheme(string(themeDark))
}
zeroTheme = buildTheme(entry.Palette)
rebuildStreamingFadePalette()
if defaultRenderCache != nil {
defaultRenderCache.clear() // old-palette entries must not be reused
@@ -66,15 +86,45 @@ func applyTheme(mode themeMode, hasDarkBackground bool) themeMode {
return resolved
}
// handleThemeCommand implements /theme [auto|dark|light]: no arg shows state, a
// valid mode switches the active palette live. Mirrors handleStyleCommand.
// previewSelectedTheme makes the live palette match the theme picker's current
// state: the highlighted mode when a row is selectable, or the committed
// m.themeMode when the filter matches nothing. Called on every change to the
// picker's selection or filter (arrow/wheel moves, mouse, and query typing) so the
// whole UI — and the overlay itself — always renders the mode the popup points at,
// and never strands on a stale preview. It only swaps the global zeroTheme via
// applyTheme; m.themeMode keeps the committed preference so Esc can restore it. A
// no-op unless a theme picker is open. Runs on the Update goroutine, like every
// zeroTheme access.
func (m model) previewSelectedTheme() {
if m.picker == nil || m.picker.kind != pickerTheme {
return
}
if item, ok := m.picker.current(); ok {
applyTheme(themeMode(item.Value), m.hasDarkBg)
return
}
// No selectable row (the filter matched nothing): fall back to the committed
// theme rather than leaving the previous preview applied.
m.restoreCommittedTheme()
}
// restoreCommittedTheme re-applies m.themeMode after a /theme preview is dismissed
// without choosing (Esc). Preview never wrote m.themeMode, so it still holds the
// real preference; re-applying repaints back to it.
func (m model) restoreCommittedTheme() {
applyTheme(m.themeMode, m.hasDarkBg)
}
// handleThemeCommand implements /theme [name]: `list` shows state, a registered
// theme name (or `auto`) switches the active palette live. Bare `/theme` opens the
// picker at the dispatch layer, so it never reaches here empty. Mirrors handleStyleCommand.
func (m model) handleThemeCommand(args string) (model, string) {
arg := strings.ToLower(strings.TrimSpace(args))
if arg == "" || arg == "list" {
return m, m.themeStateText()
}
if !validThemeMode(arg) {
return m, "Theme\nUnknown theme: " + arg + " (use auto, dark, or light)"
return m, "Theme\nUnknown theme: " + arg + " (use /theme with no argument to pick from the list)"
}
m.themeMode = themeMode(arg)
resolved := applyTheme(m.themeMode, m.hasDarkBg)
@@ -82,11 +132,31 @@ func (m model) handleThemeCommand(args string) (model, string) {
if m.themeMode == themeAuto {
active = "auto (" + string(resolved) + ")"
}
return m, strings.Join([]string{
lines := []string{
"Theme",
"active theme: " + active,
"Already-printed scrollback keeps its previous colors; new output uses the new theme.",
}, "\n")
}
// Commit path (both /theme <name> and the picker route through here): persist the
// choice so it survives restart. Previews call applyTheme directly and never reach here.
if note := m.persistThemePreference(); note != "" {
lines = append(lines, note)
}
return m, strings.Join(lines, "\n")
}
// persistThemePreference writes the committed theme to user config so it is applied
// again at startup (via Options.SavedTheme -> resolveThemeMode). Best-effort: returns
// a short note to surface on failure, or "" on success / when there is no config
// path (e.g. tests). Never called from the live-preview path.
func (m model) persistThemePreference() string {
if strings.TrimSpace(m.userConfigPath) == "" {
return ""
}
if _, err := config.SetTheme(m.userConfigPath, string(m.themeMode)); err != nil {
return "note: could not save theme preference (" + err.Error() + ")"
}
return ""
}
// themeStateText renders the /theme state view.
@@ -109,6 +179,6 @@ func (m model) themeStateText() string {
"available: " + strings.Join(themeModes, ", "),
},
}},
Hints: []string{"use /theme <auto|dark|light> to switch this TUI session"},
Hints: []string{"run /theme with no argument to open the picker, or /theme <name> to switch directly"},
})
}
+55 -26
View File
@@ -52,24 +52,19 @@ func wcagRatio(t *testing.T, fg, bg string) float64 {
// The word-level diff's brighter changed-span band must keep its text AA-readable
// and stay clearly distinct from the base add/del band, on both themes.
func TestDiffWordSpanContrast(t *testing.T) {
for _, c := range []struct {
name string
pal palette
}{
{"dark", darkPalette},
{"light", lightPalette},
} {
if r := wcagRatio(t, c.pal.addInk, c.pal.addBgWord); r < 4.5 {
t.Errorf("%s: addInk on addBgWord %.2f < 4.5 (AA)", c.name, r)
for _, entry := range themeRegistry {
name, pal := entry.Name, entry.Palette
if r := wcagRatio(t, pal.addInk, pal.addBgWord); r < 4.5 {
t.Errorf("%s: addInk on addBgWord %.2f < 4.5 (AA)", name, r)
}
if r := wcagRatio(t, c.pal.delInk, c.pal.delBgWord); r < 4.5 {
t.Errorf("%s: delInk on delBgWord %.2f < 4.5 (AA)", c.name, r)
if r := wcagRatio(t, pal.delInk, pal.delBgWord); r < 4.5 {
t.Errorf("%s: delInk on delBgWord %.2f < 4.5 (AA)", name, r)
}
if sep := wcagRatio(t, c.pal.addBgWord, c.pal.addBg); sep < 1.2 {
t.Errorf("%s: addBgWord vs addBg separation %.2f < 1.2 (span not distinct)", c.name, sep)
if sep := wcagRatio(t, pal.addBgWord, pal.addBg); sep < 1.2 {
t.Errorf("%s: addBgWord vs addBg separation %.2f < 1.2 (span not distinct)", name, sep)
}
if sep := wcagRatio(t, c.pal.delBgWord, c.pal.delBg); sep < 1.2 {
t.Errorf("%s: delBgWord vs delBg separation %.2f < 1.2 (span not distinct)", c.name, sep)
if sep := wcagRatio(t, pal.delBgWord, pal.delBg); sep < 1.2 {
t.Errorf("%s: delBgWord vs delBg separation %.2f < 1.2 (span not distinct)", name, sep)
}
}
}
@@ -78,18 +73,52 @@ func TestDiffWordSpanContrast(t *testing.T) {
// AND keep its label readable. Guards the regression this fixes: the light
// selBg (#e7f2cd) sat at 1.01 vs the panel (#ececed) — effectively invisible.
func TestSelectedRowBandIsVisibleAndReadable(t *testing.T) {
for _, c := range []struct {
name string
pal palette
}{
{"dark", darkPalette},
{"light", lightPalette},
} {
if r := wcagRatio(t, c.pal.ink, c.pal.selBg); r < 4.5 {
t.Errorf("%s: ink on selBg contrast %.2f < 4.5 — selected-row label unreadable", c.name, r)
for _, entry := range themeRegistry {
name, pal := entry.Name, entry.Palette
if r := wcagRatio(t, pal.ink, pal.selBg); r < 4.5 {
t.Errorf("%s: ink on selBg contrast %.2f < 4.5 — selected-row label unreadable", name, r)
}
if sep := wcagRatio(t, c.pal.selBg, c.pal.panel); sep < 1.10 {
t.Errorf("%s: selBg vs panel separation %.2f < 1.10 — selected row does not stand out", c.name, sep)
if sep := wcagRatio(t, pal.selBg, pal.panel); sep < 1.10 {
t.Errorf("%s: selBg vs panel separation %.2f < 1.10 — selected row does not stand out", name, sep)
}
}
}
// Every registered theme — not just the two built-ins — must clear WCAG AA on its
// text-bearing tokens against the panel and keep the muted>faint>faintest>panel
// gray ramp monotonic in its polarity (light-on-dark for dark themes, the inverse
// for light themes). Guards that a newly-added color palette can't ship illegible.
func TestAllThemesContrastAndHierarchy(t *testing.T) {
for _, entry := range themeRegistry {
pal := entry.Palette
for _, tok := range []struct {
name string
fg string
}{
{"ink", pal.ink}, {"muted", pal.muted}, {"faint", pal.faint},
{"faintest", pal.faintest}, {"accent", pal.accent},
} {
if r := wcagRatio(t, tok.fg, pal.panel); r < 4.5 {
t.Errorf("%s %s on panel %.2f < 4.5 (WCAG AA)", entry.Name, tok.name, r)
}
}
if r := wcagRatio(t, pal.onAccent, pal.accent); r < 4.5 {
t.Errorf("%s onAccent on accent %.2f < 4.5 (WCAG AA)", entry.Name, r)
}
// Gray ramp ordered ink -> muted -> faint -> faintest -> panel; luminance
// rises toward the surface on light themes, falls on dark themes.
chain := []float64{
relLum(t, pal.ink), relLum(t, pal.muted), relLum(t, pal.faint),
relLum(t, pal.faintest), relLum(t, pal.panel),
}
for i := 1; i < len(chain); i++ {
ok := chain[i] > chain[i-1]
if entry.IsDark {
ok = chain[i] < chain[i-1]
}
if !ok {
t.Errorf("%s hierarchy not monotonic toward surface at %d: %v", entry.Name, i, chain)
}
}
}
}
+5
View File
@@ -5,6 +5,11 @@ import "strings"
func (m model) toggleDetailedTranscript() model {
m.transcriptDetailed = !m.transcriptDetailed
m.clearSuggestions()
if m.picker != nil && m.picker.kind == pickerTheme {
// Dropping a theme picker mid-preview must undo the live palette, or the
// previewed theme would stick while m.themeMode still holds the old one.
m.restoreCommittedTheme()
}
m.picker = nil
return m
}
-5
View File
@@ -201,9 +201,6 @@ func (m model) statusLine(width int) string {
if m.exitConfirmActive {
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(ctrlCExitConfirmText), width)
}
if m.cancelConfirmActive {
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
}
return fitStyledLine(left, width)
}
@@ -213,8 +210,6 @@ func (m model) statusLine(width int) string {
}
if m.exitConfirmActive {
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
} else if m.cancelConfirmActive {
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
} else if summary := m.backgroundTerminalSummary(); summary != "" {
left += separator + zeroTheme.muted.Render(summary)
}