From bcde35a13a043bdfaeb3a21aa735063192f98a4e Mon Sep 17 00:00:00 2001 From: Darren Cheng Date: Thu, 1 Oct 2026 22:14:39 -0700 Subject: [PATCH 1/5] Add Codex session limits to status line --- AGENTS.md | 4 +- README.md | 2 +- cli/commands/install/agents.go | 3 + cli/commands/install/codex_statusline.go | 277 ++++++++++++++++++ cli/commands/install/codex_statusline_test.go | 117 ++++++++ .../proposal.md | 13 + .../specs/agent-config-install/spec.md | 19 ++ .../tasks.md | 5 + openspec/specs/agent-config-install/spec.md | 27 +- 9 files changed, 462 insertions(+), 5 deletions(-) create mode 100644 cli/commands/install/codex_statusline.go create mode 100644 cli/commands/install/codex_statusline_test.go create mode 100644 openspec/changes/archive/2026-10-02-add-codex-status-limits/proposal.md create mode 100644 openspec/changes/archive/2026-10-02-add-codex-status-limits/specs/agent-config-install/spec.md create mode 100644 openspec/changes/archive/2026-10-02-add-codex-status-limits/tasks.md diff --git a/AGENTS.md b/AGENTS.md index 829907b5..0092b9f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,7 +136,7 @@ in this file. Populated and maintained via the `/improve` skill. | hammerspoon | Window management | | tools | Devbox, Claude Code, Codex | | osx | macOS defaults | -| agents | Agent skills, custom agents, hooks, and status line (symlinks agents/skills → ~/.claude/skills + ~/.agents/skills, agents/custom → ~/.claude/agents, agents/AGENTS.md → ~/.claude/CLAUDE.md, registers hooks and status line in ~/.claude/settings.json) | +| agents | Agent skills, custom agents, hooks, and status lines (symlinks agents/skills → ~/.claude/skills + ~/.agents/skills, agents/custom → ~/.claude/agents, agents/AGENTS.md → ~/.claude/CLAUDE.md, registers Claude hooks and status line in ~/.claude/settings.json, and configures Codex's native status line with model, context remaining, 5-hour usage, and weekly usage in $CODEX_HOME/config.toml or ~/.codex/config.toml) | | pi | pi.dev coding agent CLI + config (installs pi via curl pi.dev/install.sh, symlinks pi/agent/models.json → ~/.pi/agent/models.json, and seeds defaultProvider/defaultModel for Ollama qwen3:32b in ~/.pi/agent/settings.json; auth.json and sessions/ stay local) | ## Writing Skills / Slash Commands @@ -302,4 +302,4 @@ If `go install`/`go test`/`go vet` fails with `failed to initialize build cache - Installation is destructive (no backups) - Requires macOS, Homebrew, Go 1.15+ - Uses map-based dispatch for dynamic component installation -- CI runs on GitHub Actions (macOS, Go 1.24.10) \ No newline at end of file +- CI runs on GitHub Actions (macOS, Go 1.24.10) diff --git a/README.md b/README.md index 99d3f41c..6c80f874 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ dots docker stop-all # Stop all Docker containers | Component | What it installs | |-----------|------------------| -| `agents` | Agent skills, custom agents, hooks, and status line (symlinks `agents/skills/` → `~/.claude/skills/` + `~/.agents/skills/`, `agents/custom/` → `~/.claude/agents/`, registers SessionStart/PostToolUse hooks and status line in `~/.claude/settings.json`; one SessionStart hook prepends dev-tool bin dirs — `go/bin`, cargo, asdf shims — onto the Bash tool's `PATH` so binaries like `tts` resolve by bare name) | +| `agents` | Agent skills, custom agents, hooks, and status lines (symlinks `agents/skills/` → `~/.claude/skills/` + `~/.agents/skills/`, `agents/custom/` → `~/.claude/agents/`, registers Claude hooks and status line in `~/.claude/settings.json`, and configures Codex's native status line with model, context remaining, 5-hour usage, and weekly usage in `$CODEX_HOME/config.toml` or `~/.codex/config.toml`; one SessionStart hook prepends dev-tool bin dirs — `go/bin`, cargo, asdf shims — onto the Bash tool's `PATH` so binaries like `tts` resolve by bare name) | | `bin` | Custom shell scripts and Go utilities to `~/bin` | | `git` | `.gitconfig`, `.gitignore_global`, git extensions | | `home` | Dotfiles symlinked to `~/` (`.zshrc`, `.vimrc`, `.tmux.conf`, `.gitconfig`, etc.) | diff --git a/cli/commands/install/agents.go b/cli/commands/install/agents.go index dc101f50..4e44cbd4 100644 --- a/cli/commands/install/agents.go +++ b/cli/commands/install/agents.go @@ -51,6 +51,9 @@ func Agents() { // Register status line registerStatusLine() + // Configure Codex's native status line to show model, context, and limits + registerCodexStatusLine() + // Manage local session transcript retention registerCleanupPeriod() } diff --git a/cli/commands/install/codex_statusline.go b/cli/commands/install/codex_statusline.go new file mode 100644 index 00000000..2f9e9456 --- /dev/null +++ b/cli/commands/install/codex_statusline.go @@ -0,0 +1,277 @@ +package install + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/drn/dots/pkg/log" + "github.com/drn/dots/pkg/path" +) + +var codexStatusLineItems = []string{ + "model-with-reasoning", + "context-remaining", + "five-hour-limit", + "weekly-limit", +} + +func registerCodexStatusLine() { + configPath := codexConfigPath() + data, err := os.ReadFile(configPath) + if err != nil && !os.IsNotExist(err) { + log.Warning("Failed to read Codex config: %s", err.Error()) + return + } + + updated, changed, err := ensureCodexStatusLine(string(data)) + if err != nil { + log.Warning("Failed to update Codex status line: %s", err.Error()) + return + } + if !changed { + return + } + + mode := os.FileMode(0600) + if info, statErr := os.Stat(configPath); statErr == nil { + mode = info.Mode().Perm() + } + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + log.Warning("Failed to create Codex config directory: %s", err.Error()) + return + } + if err := writeFileAtomic(configPath, []byte(updated), mode); err != nil { + log.Warning("Failed to write Codex config: %s", err.Error()) + return + } + log.Success("Registered Codex status line (model, context, and limits)") +} + +func codexConfigPath() string { + codexHome := os.Getenv("CODEX_HOME") + if codexHome == "" { + codexHome = path.FromHome(".codex") + } + return filepath.Join(codexHome, "config.toml") +} + +func ensureCodexStatusLine(config string) (string, bool, error) { + location := findCodexStatusLine(config) + if location.targetStart >= 0 { + return updateCodexStatusLine(config, location.targetStart) + } + + var missing []string + appendMissingStatusLineItems(&missing) + value := "status_line = " + formatTomlStringArray(missing) + "\n" + if location.tuiStart >= 0 { + return config[:location.tuiEnd] + value + config[location.tuiEnd:], true, nil + } + + // Put a dotted key before any table so it remains in the root TOML scope. + if location.firstTable < 0 { + if config != "" && !strings.HasSuffix(config, "\n") { + config += "\n" + } + return config + "[tui]\n" + value, true, nil + } + return config[:location.firstTable] + "tui.status_line = " + formatTomlStringArray(missing) + "\n\n" + config[location.firstTable:], true, nil +} + +type codexStatusLineLocation struct { + tuiStart int + tuiEnd int + targetStart int + firstTable int +} + +func findCodexStatusLine(config string) codexStatusLineLocation { + lines := strings.SplitAfter(config, "\n") + section := "" + location := codexStatusLineLocation{tuiStart: -1, tuiEnd: -1, targetStart: -1, firstTable: -1} + offset := 0 + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + if location.firstTable < 0 { + location.firstTable = offset + } + section = strings.TrimSpace(trimmed[1 : len(trimmed)-1]) + if section == "tui" { + location.tuiStart = offset + location.tuiEnd = offset + len(line) + } + } else if section == "tui" && location.tuiStart >= 0 { + location.tuiEnd = offset + len(line) + } + + key, value, hasValue := strings.Cut(strings.SplitN(trimmed, "#", 2)[0], "=") + if hasValue && (section == "tui" && strings.TrimSpace(key) == "status_line" || section == "" && strings.TrimSpace(key) == "tui.status_line") { + location.targetStart = offset + strings.Index(line, value) + } + offset += len(line) + } + return location +} + +func updateCodexStatusLine(config string, start int) (string, bool, error) { + for start < len(config) && isSpace(config[start]) { + start++ + } + if start >= len(config) || config[start] != '[' { + return config, false, fmt.Errorf("tui.status_line must be an array of strings") + } + end, err := tomlArrayEnd(config, start, len(config)) + if err != nil { + return config, false, err + } + items, err := parseTomlStringArray(config[start:end]) + if err != nil { + return config, false, err + } + if !appendMissingStatusLineItems(&items) { + return config, false, nil + } + return config[:start] + formatTomlStringArray(items) + config[end:], true, nil +} + +func appendMissingStatusLineItems(items *[]string) bool { + changed := false + for _, wanted := range codexStatusLineItems { + found := false + for _, item := range *items { + if item == wanted { + found = true + break + } + } + if !found { + *items = append(*items, wanted) + changed = true + } + } + return changed +} + +func formatTomlStringArray(items []string) string { + quoted := make([]string, 0, len(items)) + for _, item := range items { + quoted = append(quoted, strconv.Quote(item)) + } + return "[" + strings.Join(quoted, ", ") + "]" +} + +func parseTomlStringArray(value string) ([]string, error) { + if len(value) < 2 || value[0] != '[' || value[len(value)-1] != ']' { + return nil, fmt.Errorf("tui.status_line must be an array of strings") + } + body := value[1 : len(value)-1] + var items []string + for i := 0; i < len(body); { + i = skipTomlArrayTrivia(body, i) + if i == len(body) { + break + } + item, next, err := parseTomlString(body, i) + if err != nil { + return nil, err + } + items = append(items, item) + i = next + } + return items, nil +} + +func skipTomlArrayTrivia(body string, i int) int { + for i < len(body) { + if isSpace(body[i]) || body[i] == ',' { + i++ + continue + } + if body[i] == '#' { + for i < len(body) && body[i] != '\n' { + i++ + } + continue + } + break + } + return i +} + +func parseTomlString(body string, start int) (string, int, error) { + quote := body[start] + if quote != '"' && quote != '\'' { + return "", start, fmt.Errorf("tui.status_line contains a non-string value") + } + end := start + 1 + for end < len(body) { + if quote == '"' && body[end] == '\\' { + end += 2 + continue + } + if body[end] == quote { + end++ + break + } + end++ + } + if end > len(body) || body[end-1] != quote { + return "", start, fmt.Errorf("unterminated string in tui.status_line") + } + raw := body[start:end] + if quote == '\'' { + return raw[1 : len(raw)-1], end, nil + } + item, err := strconv.Unquote(raw) + if err != nil { + return "", start, fmt.Errorf("invalid string in tui.status_line: %w", err) + } + return item, end, nil +} + +func tomlArrayEnd(config string, start, limit int) (int, error) { + state := tomlArrayScanState{} + for i := start + 1; i < limit; i++ { + if state.consume(config[i]) { + return i + 1, nil + } + } + return 0, fmt.Errorf("unterminated tui.status_line array") +} + +type tomlArrayScanState struct { + quote byte + escaped bool + comment bool +} + +func (state *tomlArrayScanState) consume(char byte) bool { + if state.comment { + state.comment = char != '\n' + return false + } + if state.quote != 0 { + if state.quote == '"' && state.escaped { + state.escaped = false + } else if state.quote == '"' && char == '\\' { + state.escaped = true + } else if char == state.quote { + state.quote = 0 + } + return false + } + if char == '#' { + state.comment = true + } else if char == '"' || char == '\'' { + state.quote = char + } + return char == ']' +} + +func isSpace(value byte) bool { + return value == ' ' || value == '\t' || value == '\r' || value == '\n' +} diff --git a/cli/commands/install/codex_statusline_test.go b/cli/commands/install/codex_statusline_test.go new file mode 100644 index 00000000..7a2f7aac --- /dev/null +++ b/cli/commands/install/codex_statusline_test.go @@ -0,0 +1,117 @@ +package install + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/drn/dots/pkg/path" +) + +func TestEnsureCodexStatusLine_CreatesStatusLine(t *testing.T) { + got, changed, err := ensureCodexStatusLine("") + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected new status line to be added") + } + for _, item := range codexStatusLineItems { + if !strings.Contains(got, `"`+item+`"`) { + t.Errorf("status line does not include %q: %s", item, got) + } + } +} + +func TestEnsureCodexStatusLine_PreservesExistingSettingsAndItems(t *testing.T) { + config := "model = \"gpt-5\"\n\n[tui]\nshow_tooltips = false\nstatus_line = [\"git-branch\", \"weekly-limit\"]\n\n[projects.\"/repo\"]\ntrust_level = \"trusted\"\n" + got, changed, err := ensureCodexStatusLine(config) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected status line to be extended") + } + for _, want := range []string{ + `model = "gpt-5"`, + `show_tooltips = false`, + `"git-branch"`, + `"weekly-limit"`, + `[projects."/repo"]`, + `trust_level = "trusted"`, + } { + if !strings.Contains(got, want) { + t.Errorf("updated config lost %q:\n%s", want, got) + } + } + if strings.Count(got, `"weekly-limit"`) != 1 { + t.Errorf("weekly-limit duplicated in status line:\n%s", got) + } +} + +func TestEnsureCodexStatusLine_HandlesMultilineAndDottedKey(t *testing.T) { + config := "tui.status_line = [\n \"git-branch\", # keep this item\n]\n" + got, changed, err := ensureCodexStatusLine(config) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected status line to be extended") + } + if !strings.Contains(got, `"git-branch"`) || !strings.Contains(got, `"five-hour-limit"`) { + t.Errorf("updated dotted status line is missing expected items:\n%s", got) + } +} + +func TestEnsureCodexStatusLine_Idempotent(t *testing.T) { + first, changed, err := ensureCodexStatusLine("[tui]\nstatus_line = [\"git-branch\"]\n") + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected first call to add status line items") + } + second, changed, err := ensureCodexStatusLine(first) + if err != nil { + t.Fatal(err) + } + if changed { + t.Fatal("expected second call to be a no-op") + } + if second != first { + t.Fatal("second call changed the Codex config") + } +} + +func TestEnsureCodexStatusLine_RejectsUnexpectedValue(t *testing.T) { + config := "[tui]\nstatus_line = false\n" + got, changed, err := ensureCodexStatusLine(config) + if err == nil { + t.Fatal("expected invalid status_line value to fail") + } + if changed || got != config { + t.Fatal("invalid config should remain unchanged") + } +} + +func TestRegisterCodexStatusLine_UsesCodexHome(t *testing.T) { + home := t.TempDir() + configPath := filepath.Join(home, "config.toml") + if err := os.WriteFile(configPath, []byte("model = \"gpt-5\"\n"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("CODEX_HOME", home) + path.SetHome(t.TempDir()) + t.Cleanup(func() { path.SetHome("") }) + + registerCodexStatusLine() + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"five-hour-limit"`) { + t.Errorf("Codex config did not receive status-line items:\n%s", data) + } +} diff --git a/openspec/changes/archive/2026-10-02-add-codex-status-limits/proposal.md b/openspec/changes/archive/2026-10-02-add-codex-status-limits/proposal.md new file mode 100644 index 00000000..a0e245de --- /dev/null +++ b/openspec/changes/archive/2026-10-02-add-codex-status-limits/proposal.md @@ -0,0 +1,13 @@ +# Change: Display Codex usage limits in the status bar + +## Why +Codex exposes current 5-hour and weekly plan usage in its native TUI status line, but `dots install agents` does not configure those items. + +## What Changes +- Configure Codex's native TUI status line to display the model, context remaining, 5-hour usage, and weekly usage, matching the information shown by the Claude status line. +- Preserve unrelated Codex configuration and existing status-line items, and make installation idempotent. +- Codex retains its native visual rendering; its status line does not support the custom bars and colors used by the Claude hook. + +## Impact +- Affected specs: `agent-config-install` +- Affected code: `cli/commands/install/agents.go` and Codex config mutation helpers/tests diff --git a/openspec/changes/archive/2026-10-02-add-codex-status-limits/specs/agent-config-install/spec.md b/openspec/changes/archive/2026-10-02-add-codex-status-limits/specs/agent-config-install/spec.md new file mode 100644 index 00000000..3950b845 --- /dev/null +++ b/openspec/changes/archive/2026-10-02-add-codex-status-limits/specs/agent-config-install/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements +### Requirement: Codex Status Line Limit Configuration +The `agents` installer SHALL configure Codex's native TUI status line in `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`) to include model, context remaining, 5-hour usage, and weekly usage items, matching the information shown by the Claude status line. It SHALL preserve unrelated Codex configuration and existing status-line items. Codex SHALL render the items using its native status-line appearance. + +#### Scenario: Usage limits are configured +- **WHEN** the `agents` installer runs and the Codex status line does not include both usage-limit items +- **THEN** `tui.status_line` includes `model-with-reasoning`, `context-remaining`, `five-hour-limit`, and `weekly-limit` + +#### Scenario: Existing Codex configuration is preserved +- **WHEN** the `agents` installer updates the Codex status line +- **THEN** unrelated Codex configuration and existing status-line items remain present + +#### Scenario: Re-running does not duplicate items +- **WHEN** the `agents` installer runs when both usage-limit items are already configured +- **THEN** the Codex configuration is left unchanged + +#### Scenario: Custom Codex home is respected +- **WHEN** `CODEX_HOME` points to a custom Codex home directory +- **THEN** status-line items are written to that directory's `config.toml` diff --git a/openspec/changes/archive/2026-10-02-add-codex-status-limits/tasks.md b/openspec/changes/archive/2026-10-02-add-codex-status-limits/tasks.md new file mode 100644 index 00000000..a18aeca9 --- /dev/null +++ b/openspec/changes/archive/2026-10-02-add-codex-status-limits/tasks.md @@ -0,0 +1,5 @@ +## 1. Implementation +- [x] 1.1 Add idempotent management of the Codex `tui.status_line` items for model, context remaining, and usage limits during `dots install agents`. +- [x] 1.2 Preserve unrelated `~/.codex/config.toml` settings and existing status-line items. +- [x] 1.3 Add tests for insertion, preservation, and idempotency. +- [x] 1.4 Update the agent config installation specification. diff --git a/openspec/specs/agent-config-install/spec.md b/openspec/specs/agent-config-install/spec.md index a4bd818b..9079bbe1 100644 --- a/openspec/specs/agent-config-install/spec.md +++ b/openspec/specs/agent-config-install/spec.md @@ -4,7 +4,7 @@ The `agent-config-install` capability defines `dots install agents` — how the repository's reusable skills, custom agent types, and hooks are wired into Claude -Code and Codex by symlinking directories and mutating `~/.claude/settings.json`. +Code and Codex by symlinking directories and managing their settings. This spec documents the behavior that ships today. ## Requirements @@ -79,6 +79,30 @@ command differs from the existing one. - **WHEN** the `agents` installer runs and `settings.statusLine` already matches the configured command - **THEN** `settings.json` is left unchanged for the status line +### Requirement: Codex Status Line Limit Configuration +The `agents` installer SHALL configure Codex's native TUI status line in +`$CODEX_HOME/config.toml` (default `~/.codex/config.toml`) to include model, +context remaining, 5-hour usage, and weekly usage items, matching the information +shown by the Claude status line. It SHALL preserve unrelated Codex configuration +and existing status-line items. Codex SHALL render the items using its native +status-line appearance. + +#### Scenario: Usage limits and context are configured +- **WHEN** the `agents` installer runs and Codex's status line does not include all configured items +- **THEN** `tui.status_line` includes `model-with-reasoning`, `context-remaining`, `five-hour-limit`, and `weekly-limit` + +#### Scenario: Existing Codex configuration is preserved +- **WHEN** the `agents` installer updates the Codex status line +- **THEN** unrelated Codex configuration and existing status-line items remain present + +#### Scenario: Re-running does not duplicate items +- **WHEN** the `agents` installer runs when all configured items are already present +- **THEN** the Codex configuration is left unchanged + +#### Scenario: Custom Codex home is respected +- **WHEN** `CODEX_HOME` points to a custom Codex home directory +- **THEN** status-line items are written to that directory's `config.toml` + ### Requirement: Settings File Handling Settings mutation SHALL read `~/.claude/settings.json`, treat a missing file as @@ -171,4 +195,3 @@ every machine where `dots install agents` runs. - **WHEN** the `agents` installer runs again with `cleanupPeriodDays` already `1095` - **THEN** `settings.json` is left unchanged for that key - From f57902ef8fd099b927c03cdd60fc83f0d8266266 Mon Sep 17 00:00:00 2001 From: Darren Cheng Date: Thu, 1 Oct 2026 22:18:17 -0700 Subject: [PATCH 2/5] Harden Codex status line config updates --- cli/commands/install/codex_statusline.go | 78 ++++++++++++++++--- cli/commands/install/codex_statusline_test.go | 50 ++++++++++++ 2 files changed, 117 insertions(+), 11 deletions(-) diff --git a/cli/commands/install/codex_statusline.go b/cli/commands/install/codex_statusline.go index 2f9e9456..24a8934c 100644 --- a/cli/commands/install/codex_statusline.go +++ b/cli/commands/install/codex_statusline.go @@ -94,7 +94,7 @@ func findCodexStatusLine(config string) codexStatusLineLocation { location := codexStatusLineLocation{tuiStart: -1, tuiEnd: -1, targetStart: -1, firstTable: -1} offset := 0 for _, line := range lines { - trimmed := strings.TrimSpace(line) + trimmed := strings.TrimSpace(stripTomlComment(line)) if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { if location.firstTable < 0 { location.firstTable = offset @@ -108,8 +108,9 @@ func findCodexStatusLine(config string) codexStatusLineLocation { location.tuiEnd = offset + len(line) } - key, value, hasValue := strings.Cut(strings.SplitN(trimmed, "#", 2)[0], "=") + key, value, hasValue := strings.Cut(trimmed, "=") if hasValue && (section == "tui" && strings.TrimSpace(key) == "status_line" || section == "" && strings.TrimSpace(key) == "tui.status_line") { + value = strings.TrimSpace(value) location.targetStart = offset + strings.Index(line, value) } offset += len(line) @@ -121,21 +122,69 @@ func updateCodexStatusLine(config string, start int) (string, bool, error) { for start < len(config) && isSpace(config[start]) { start++ } + if isTomlNull(config, start) { + var items []string + appendMissingStatusLineItems(&items) + return config[:start] + formatTomlStringArray(items) + config[start+4:], true, nil + } + items, end, err := readCodexStatusLineArray(config, start) + if err != nil { + return config, false, err + } + if !appendMissingStatusLineItems(&items) { + return config, false, nil + } + return config[:start] + formatTomlStringArray(items) + config[end:], true, nil +} + +func readCodexStatusLineArray(config string, start int) ([]string, int, error) { if start >= len(config) || config[start] != '[' { - return config, false, fmt.Errorf("tui.status_line must be an array of strings") + return nil, start, fmt.Errorf("tui.status_line must be an array of strings") } end, err := tomlArrayEnd(config, start, len(config)) if err != nil { - return config, false, err + return nil, start, err } items, err := parseTomlStringArray(config[start:end]) if err != nil { - return config, false, err + return nil, start, err } - if !appendMissingStatusLineItems(&items) { - return config, false, nil + return items, end, nil +} + +func isTomlNull(config string, start int) bool { + if start+4 > len(config) || config[start:start+4] != "null" { + return false } - return config[:start] + formatTomlStringArray(items) + config[end:], true, nil + if start+4 == len(config) { + return true + } + next := config[start+4] + return isSpace(next) || next == '#' || next == '\n' || next == '\r' +} + +func stripTomlComment(line string) string { + var quote byte + escaped := false + for i := 0; i < len(line); i++ { + char := line[i] + if quote != 0 { + if quote == '"' && escaped { + escaped = false + } else if quote == '"' && char == '\\' { + escaped = true + } else if char == quote { + quote = 0 + } + continue + } + if char == '"' || char == '\'' { + quote = char + } else if char == '#' { + return line[:i] + } + } + return line } func appendMissingStatusLineItems(items *[]string) bool { @@ -171,7 +220,7 @@ func parseTomlStringArray(value string) ([]string, error) { body := value[1 : len(value)-1] var items []string for i := 0; i < len(body); { - i = skipTomlArrayTrivia(body, i) + i = skipTomlArrayWhitespace(body, i) if i == len(body) { break } @@ -181,13 +230,20 @@ func parseTomlStringArray(value string) ([]string, error) { } items = append(items, item) i = next + i = skipTomlArrayWhitespace(body, i) + if i < len(body) { + if body[i] != ',' { + return nil, fmt.Errorf("expected comma in tui.status_line") + } + i++ + } } return items, nil } -func skipTomlArrayTrivia(body string, i int) int { +func skipTomlArrayWhitespace(body string, i int) int { for i < len(body) { - if isSpace(body[i]) || body[i] == ',' { + if isSpace(body[i]) { i++ continue } diff --git a/cli/commands/install/codex_statusline_test.go b/cli/commands/install/codex_statusline_test.go index 7a2f7aac..7f10a727 100644 --- a/cli/commands/install/codex_statusline_test.go +++ b/cli/commands/install/codex_statusline_test.go @@ -17,6 +17,10 @@ func TestEnsureCodexStatusLine_CreatesStatusLine(t *testing.T) { if !changed { t.Fatal("expected new status line to be added") } + want := `["model-with-reasoning", "context-remaining", "five-hour-limit", "weekly-limit"]` + if !strings.Contains(got, want) { + t.Fatalf("status line array = %q, want it to contain %q", got, want) + } for _, item := range codexStatusLineItems { if !strings.Contains(got, `"`+item+`"`) { t.Errorf("status line does not include %q: %s", item, got) @@ -48,6 +52,10 @@ func TestEnsureCodexStatusLine_PreservesExistingSettingsAndItems(t *testing.T) { if strings.Count(got, `"weekly-limit"`) != 1 { t.Errorf("weekly-limit duplicated in status line:\n%s", got) } + wantArray := `status_line = ["git-branch", "weekly-limit", "model-with-reasoning", "context-remaining", "five-hour-limit"]` + if !strings.Contains(got, wantArray) { + t.Errorf("serialized status line = %q, want %q", got, wantArray) + } } func TestEnsureCodexStatusLine_HandlesMultilineAndDottedKey(t *testing.T) { @@ -64,6 +72,37 @@ func TestEnsureCodexStatusLine_HandlesMultilineAndDottedKey(t *testing.T) { } } +func TestEnsureCodexStatusLine_HandlesTableHeaderComment(t *testing.T) { + config := "[tui] # existing Codex settings\nshow_tooltips = false\n" + got, changed, err := ensureCodexStatusLine(config) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected status line to be added") + } + if strings.Count(got, "[tui]") != 1 || !strings.Contains(got, "# existing Codex settings") { + t.Fatalf("table header comment or table was not preserved:\n%s", got) + } + if !strings.Contains(got, `"weekly-limit"`) { + t.Fatalf("status line limits missing:\n%s", got) + } +} + +func TestEnsureCodexStatusLine_ReplacesNullStatusLine(t *testing.T) { + config := "[tui]\nstatus_line = null # Codex default\n" + got, changed, err := ensureCodexStatusLine(config) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected null status line to be configured") + } + if !strings.Contains(got, `status_line = ["model-with-reasoning", "context-remaining", "five-hour-limit", "weekly-limit"] # Codex default`) { + t.Fatalf("null status line was not replaced while preserving its comment:\n%s", got) + } +} + func TestEnsureCodexStatusLine_Idempotent(t *testing.T) { first, changed, err := ensureCodexStatusLine("[tui]\nstatus_line = [\"git-branch\"]\n") if err != nil { @@ -95,6 +134,17 @@ func TestEnsureCodexStatusLine_RejectsUnexpectedValue(t *testing.T) { } } +func TestEnsureCodexStatusLine_RejectsMissingArrayComma(t *testing.T) { + config := "[tui]\nstatus_line = [\"git-branch\" \"weekly-limit\"]\n" + got, changed, err := ensureCodexStatusLine(config) + if err == nil { + t.Fatal("expected invalid array syntax to fail") + } + if changed || got != config { + t.Fatal("invalid array syntax should remain unchanged") + } +} + func TestRegisterCodexStatusLine_UsesCodexHome(t *testing.T) { home := t.TempDir() configPath := filepath.Join(home, "config.toml") From 02d4a0f53aa0337278a920ffa2f35a662b39e871 Mon Sep 17 00:00:00 2001 From: Darren Cheng Date: Thu, 1 Oct 2026 22:19:41 -0700 Subject: [PATCH 3/5] Preserve comments in Codex status line config --- cli/commands/install/codex_statusline.go | 21 ++++++++++++++++++- cli/commands/install/codex_statusline_test.go | 3 +++ .../tasks.md | 4 ++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/cli/commands/install/codex_statusline.go b/cli/commands/install/codex_statusline.go index 24a8934c..f93193fd 100644 --- a/cli/commands/install/codex_statusline.go +++ b/cli/commands/install/codex_statusline.go @@ -134,7 +134,15 @@ func updateCodexStatusLine(config string, start int) (string, bool, error) { if !appendMissingStatusLineItems(&items) { return config, false, nil } - return config[:start] + formatTomlStringArray(items) + config[end:], true, nil + updatedArray := formatTomlStringArray(items) + for i, comment := range tomlArrayComments(config[start:end]) { + if i == 0 { + updatedArray += " " + comment + } else { + updatedArray += "\n" + comment + } + } + return config[:start] + updatedArray + config[end:], true, nil } func readCodexStatusLineArray(config string, start int) ([]string, int, error) { @@ -187,6 +195,17 @@ func stripTomlComment(line string) string { return line } +func tomlArrayComments(value string) []string { + var comments []string + for _, line := range strings.Split(value, "\n") { + withoutComment := stripTomlComment(line) + if len(withoutComment) < len(line) { + comments = append(comments, strings.TrimSpace(line[len(withoutComment):])) + } + } + return comments +} + func appendMissingStatusLineItems(items *[]string) bool { changed := false for _, wanted := range codexStatusLineItems { diff --git a/cli/commands/install/codex_statusline_test.go b/cli/commands/install/codex_statusline_test.go index 7f10a727..ccb165aa 100644 --- a/cli/commands/install/codex_statusline_test.go +++ b/cli/commands/install/codex_statusline_test.go @@ -70,6 +70,9 @@ func TestEnsureCodexStatusLine_HandlesMultilineAndDottedKey(t *testing.T) { if !strings.Contains(got, `"git-branch"`) || !strings.Contains(got, `"five-hour-limit"`) { t.Errorf("updated dotted status line is missing expected items:\n%s", got) } + if !strings.Contains(got, "# keep this item") { + t.Errorf("updated status line dropped an existing comment:\n%s", got) + } } func TestEnsureCodexStatusLine_HandlesTableHeaderComment(t *testing.T) { diff --git a/openspec/changes/archive/2026-10-02-add-codex-status-limits/tasks.md b/openspec/changes/archive/2026-10-02-add-codex-status-limits/tasks.md index a18aeca9..6ab996f1 100644 --- a/openspec/changes/archive/2026-10-02-add-codex-status-limits/tasks.md +++ b/openspec/changes/archive/2026-10-02-add-codex-status-limits/tasks.md @@ -1,5 +1,5 @@ ## 1. Implementation - [x] 1.1 Add idempotent management of the Codex `tui.status_line` items for model, context remaining, and usage limits during `dots install agents`. -- [x] 1.2 Preserve unrelated `~/.codex/config.toml` settings and existing status-line items. -- [x] 1.3 Add tests for insertion, preservation, and idempotency. +- [x] 1.2 Preserve unrelated Codex config settings and existing status-line items under `$CODEX_HOME` (default `~/.codex`). +- [x] 1.3 Add tests for insertion, config preservation, idempotency, Codex home selection, and TOML edge cases. - [x] 1.4 Update the agent config installation specification. From 5c4bacb2d5b4baa6867d823b385d0b5ac4203b0d Mon Sep 17 00:00:00 2001 From: Darren Cheng Date: Thu, 1 Oct 2026 22:20:06 -0700 Subject: [PATCH 4/5] Test Codex status line root config placement --- cli/commands/install/codex_statusline_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cli/commands/install/codex_statusline_test.go b/cli/commands/install/codex_statusline_test.go index ccb165aa..25ecc003 100644 --- a/cli/commands/install/codex_statusline_test.go +++ b/cli/commands/install/codex_statusline_test.go @@ -28,6 +28,25 @@ func TestEnsureCodexStatusLine_CreatesStatusLine(t *testing.T) { } } +func TestEnsureCodexStatusLine_AddsRootSettingBeforeOtherTables(t *testing.T) { + config := "model = \"gpt-5\"\n\n[projects.\"/repo\"]\ntrust_level = \"trusted\"\n" + got, changed, err := ensureCodexStatusLine(config) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected status line to be added") + } + statusLineIndex := strings.Index(got, "tui.status_line =") + projectIndex := strings.Index(got, "[projects.\"/repo\"]") + if statusLineIndex < 0 || projectIndex < 0 || statusLineIndex > projectIndex { + t.Fatalf("root status line setting must precede the project table:\n%s", got) + } + if !strings.Contains(got, "trust_level = \"trusted\"") { + t.Fatalf("existing project settings were not preserved:\n%s", got) + } +} + func TestEnsureCodexStatusLine_PreservesExistingSettingsAndItems(t *testing.T) { config := "model = \"gpt-5\"\n\n[tui]\nshow_tooltips = false\nstatus_line = [\"git-branch\", \"weekly-limit\"]\n\n[projects.\"/repo\"]\ntrust_level = \"trusted\"\n" got, changed, err := ensureCodexStatusLine(config) From c1d0369ddfa68f29e313b30f2fb1eb7a6f3e9f39 Mon Sep 17 00:00:00 2001 From: Darren Cheng Date: Thu, 1 Oct 2026 22:20:31 -0700 Subject: [PATCH 5/5] Assert Codex installer preserves existing config --- cli/commands/install/codex_statusline_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/commands/install/codex_statusline_test.go b/cli/commands/install/codex_statusline_test.go index 25ecc003..b7ed7656 100644 --- a/cli/commands/install/codex_statusline_test.go +++ b/cli/commands/install/codex_statusline_test.go @@ -186,4 +186,7 @@ func TestRegisterCodexStatusLine_UsesCodexHome(t *testing.T) { if !strings.Contains(string(data), `"five-hour-limit"`) { t.Errorf("Codex config did not receive status-line items:\n%s", data) } + if !strings.Contains(string(data), `model = "gpt-5"`) { + t.Errorf("Codex config lost its existing model setting:\n%s", data) + } }