codex-statusline 0.1.3__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/PKG-INFO +20 -5
  2. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/README.md +19 -4
  3. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/codex_statusline.egg-info/PKG-INFO +20 -5
  4. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/codex_statusline.egg-info/SOURCES.txt +2 -0
  5. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/pyproject.toml +1 -1
  6. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/src/__init__.py +1 -1
  7. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/src/cli.py +24 -45
  8. codex_statusline-0.2.2/src/configure.py +174 -0
  9. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/src/launcher.py +1 -1
  10. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/src/manage.py +78 -42
  11. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/src/render.py +7 -3
  12. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/src/update.py +5 -3
  13. codex_statusline-0.2.2/tests/test_configure.py +42 -0
  14. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/tests/test_management.py +15 -0
  15. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/tests/test_startup.py +2 -0
  16. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/tests/test_update.py +8 -6
  17. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/LICENSE +0 -0
  18. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/codex_statusline.egg-info/dependency_links.txt +0 -0
  19. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/codex_statusline.egg-info/entry_points.txt +0 -0
  20. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/codex_statusline.egg-info/top_level.txt +0 -0
  21. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/setup.cfg +0 -0
  22. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/src/__main__.py +0 -0
  23. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/src/data.py +0 -0
  24. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/src/entry.py +0 -0
  25. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/src/hook.py +0 -0
  26. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/tests/test_auto_shells.py +0 -0
  27. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/tests/test_data.py +0 -0
  28. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/tests/test_fish.py +0 -0
  29. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/tests/test_render.py +0 -0
  30. {codex_statusline-0.1.3 → codex_statusline-0.2.2}/tests/test_tmux.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-statusline
3
- Version: 0.1.3
3
+ Version: 0.2.2
4
4
  Summary: A tmux statusline for OpenAI Codex CLI: context usage, 5h and weekly quota, and session tokens at a glance. Local-only.
5
5
  Author: Zimo Xu
6
6
  License-Expression: MIT
@@ -59,7 +59,7 @@ Prefer to do it yourself:
59
59
 
60
60
  ```sh
61
61
  uv tool install codex-statusline # or: pipx install codex-statusline
62
- codex-statusline install # add --dry-run to only preview the diff
62
+ codex-statusline install # add --dry-run to see the plan first
63
63
  ```
64
64
 
65
65
  Then **open a new terminal** and run `codex`. The first time, Codex asks you to review a hook: confirm it is `codex-statusline binding`.
@@ -73,9 +73,9 @@ codex # same as always, now with a status bar
73
73
  codex resume --last
74
74
  codex exec 'task' # non-interactive: passed straight through, no bar
75
75
 
76
+ codex-statusline config # pick segments, theme and colors interactively
76
77
  codex-statusline update # upgrade, whichever way you installed it
77
78
  codex-statusline doctor # check versions and dependencies
78
- codex-statusline preview --width 80 # try it without launching Codex
79
79
  codex-statusline uninstall
80
80
  ```
81
81
 
@@ -83,7 +83,22 @@ Uninstall removes only what this tool added and still matches exactly. If you ed
83
83
 
84
84
  ## Configuration
85
85
 
86
- Optional. Create `~/.config/codex-statusline/config.toml`:
86
+ Run `codex-statusline config` to set everything up in an interactive screen with a live preview: toggle and reorder segments, switch theme, set the warning thresholds, then press `s` to save.
87
+
88
+ ```text
89
+ [x] ctx context used
90
+ [x] 5h 5-hour quota
91
+ [x] week weekly quota
92
+ [x] tokens session tokens
93
+ > Theme < auto >
94
+ Quota yellow at < 20% >
95
+ Quota red at < 5% >
96
+
97
+ Preview (sample numbers):
98
+ CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
99
+ ```
100
+
101
+ It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
87
102
 
88
103
  ```toml
89
104
  segments = ["ctx", "5h", "week", "tokens"] # pick and reorder
@@ -102,7 +117,7 @@ Invalid values are reported and fall back to defaults.
102
117
 
103
118
  ## FAQ
104
119
 
105
- **How do I scroll or copy?** It runs inside tmux: press `Ctrl-B` then `[` to scroll and copy. The mouse wheel may behave differently from bare Codex.
120
+ **How do I scroll or copy?** Exactly as in plain Codex: the mouse wheel scrolls the conversation, and text selection works the same way.
106
121
 
107
122
  **Windows?** Not supported (tmux).
108
123
 
@@ -38,7 +38,7 @@ Prefer to do it yourself:
38
38
 
39
39
  ```sh
40
40
  uv tool install codex-statusline # or: pipx install codex-statusline
41
- codex-statusline install # add --dry-run to only preview the diff
41
+ codex-statusline install # add --dry-run to see the plan first
42
42
  ```
43
43
 
44
44
  Then **open a new terminal** and run `codex`. The first time, Codex asks you to review a hook: confirm it is `codex-statusline binding`.
@@ -52,9 +52,9 @@ codex # same as always, now with a status bar
52
52
  codex resume --last
53
53
  codex exec 'task' # non-interactive: passed straight through, no bar
54
54
 
55
+ codex-statusline config # pick segments, theme and colors interactively
55
56
  codex-statusline update # upgrade, whichever way you installed it
56
57
  codex-statusline doctor # check versions and dependencies
57
- codex-statusline preview --width 80 # try it without launching Codex
58
58
  codex-statusline uninstall
59
59
  ```
60
60
 
@@ -62,7 +62,22 @@ Uninstall removes only what this tool added and still matches exactly. If you ed
62
62
 
63
63
  ## Configuration
64
64
 
65
- Optional. Create `~/.config/codex-statusline/config.toml`:
65
+ Run `codex-statusline config` to set everything up in an interactive screen with a live preview: toggle and reorder segments, switch theme, set the warning thresholds, then press `s` to save.
66
+
67
+ ```text
68
+ [x] ctx context used
69
+ [x] 5h 5-hour quota
70
+ [x] week weekly quota
71
+ [x] tokens session tokens
72
+ > Theme < auto >
73
+ Quota yellow at < 20% >
74
+ Quota red at < 5% >
75
+
76
+ Preview (sample numbers):
77
+ CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
78
+ ```
79
+
80
+ It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
66
81
 
67
82
  ```toml
68
83
  segments = ["ctx", "5h", "week", "tokens"] # pick and reorder
@@ -81,7 +96,7 @@ Invalid values are reported and fall back to defaults.
81
96
 
82
97
  ## FAQ
83
98
 
84
- **How do I scroll or copy?** It runs inside tmux: press `Ctrl-B` then `[` to scroll and copy. The mouse wheel may behave differently from bare Codex.
99
+ **How do I scroll or copy?** Exactly as in plain Codex: the mouse wheel scrolls the conversation, and text selection works the same way.
85
100
 
86
101
  **Windows?** Not supported (tmux).
87
102
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-statusline
3
- Version: 0.1.3
3
+ Version: 0.2.2
4
4
  Summary: A tmux statusline for OpenAI Codex CLI: context usage, 5h and weekly quota, and session tokens at a glance. Local-only.
5
5
  Author: Zimo Xu
6
6
  License-Expression: MIT
@@ -59,7 +59,7 @@ Prefer to do it yourself:
59
59
 
60
60
  ```sh
61
61
  uv tool install codex-statusline # or: pipx install codex-statusline
62
- codex-statusline install # add --dry-run to only preview the diff
62
+ codex-statusline install # add --dry-run to see the plan first
63
63
  ```
64
64
 
65
65
  Then **open a new terminal** and run `codex`. The first time, Codex asks you to review a hook: confirm it is `codex-statusline binding`.
@@ -73,9 +73,9 @@ codex # same as always, now with a status bar
73
73
  codex resume --last
74
74
  codex exec 'task' # non-interactive: passed straight through, no bar
75
75
 
76
+ codex-statusline config # pick segments, theme and colors interactively
76
77
  codex-statusline update # upgrade, whichever way you installed it
77
78
  codex-statusline doctor # check versions and dependencies
78
- codex-statusline preview --width 80 # try it without launching Codex
79
79
  codex-statusline uninstall
80
80
  ```
81
81
 
@@ -83,7 +83,22 @@ Uninstall removes only what this tool added and still matches exactly. If you ed
83
83
 
84
84
  ## Configuration
85
85
 
86
- Optional. Create `~/.config/codex-statusline/config.toml`:
86
+ Run `codex-statusline config` to set everything up in an interactive screen with a live preview: toggle and reorder segments, switch theme, set the warning thresholds, then press `s` to save.
87
+
88
+ ```text
89
+ [x] ctx context used
90
+ [x] 5h 5-hour quota
91
+ [x] week weekly quota
92
+ [x] tokens session tokens
93
+ > Theme < auto >
94
+ Quota yellow at < 20% >
95
+ Quota red at < 5% >
96
+
97
+ Preview (sample numbers):
98
+ CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
99
+ ```
100
+
101
+ It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
87
102
 
88
103
  ```toml
89
104
  segments = ["ctx", "5h", "week", "tokens"] # pick and reorder
@@ -102,7 +117,7 @@ Invalid values are reported and fall back to defaults.
102
117
 
103
118
  ## FAQ
104
119
 
105
- **How do I scroll or copy?** It runs inside tmux: press `Ctrl-B` then `[` to scroll and copy. The mouse wheel may behave differently from bare Codex.
120
+ **How do I scroll or copy?** Exactly as in plain Codex: the mouse wheel scrolls the conversation, and text selection works the same way.
106
121
 
107
122
  **Windows?** Not supported (tmux).
108
123
 
@@ -9,6 +9,7 @@ codex_statusline.egg-info/top_level.txt
9
9
  src/__init__.py
10
10
  src/__main__.py
11
11
  src/cli.py
12
+ src/configure.py
12
13
  src/data.py
13
14
  src/entry.py
14
15
  src/hook.py
@@ -17,6 +18,7 @@ src/manage.py
17
18
  src/render.py
18
19
  src/update.py
19
20
  tests/test_auto_shells.py
21
+ tests/test_configure.py
20
22
  tests/test_data.py
21
23
  tests/test_fish.py
22
24
  tests/test_management.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-statusline"
3
- version = "0.1.3"
3
+ version = "0.2.2"
4
4
  description = "A tmux statusline for OpenAI Codex CLI: context usage, 5h and weekly quota, and session tokens at a glance. Local-only."
5
5
  requires-python = ">=3.11"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """A local-only tmux status area for the official Codex CLI."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.2.2"
@@ -3,7 +3,6 @@ import json
3
3
  import os
4
4
  import shutil
5
5
  import sys
6
- import time
7
6
  from pathlib import Path
8
7
 
9
8
  from . import __version__
@@ -26,38 +25,44 @@ def main():
26
25
  record()
27
26
  return 0
28
27
  parser = argparse.ArgumentParser(
29
- description="Third-party local tmux statusline for the official Codex CLI."
28
+ prog="codex-statusline",
29
+ description="A status bar for Codex CLI: context, 5h/weekly quota, and tokens. "
30
+ "Run `codex` as usual once installed.",
30
31
  )
31
32
  parser.add_argument("--version", action="version", version=__version__)
32
- sub = parser.add_subparsers(dest="action", required=True)
33
+ sub = parser.add_subparsers(dest="action", required=True, metavar="<command>", title="commands")
34
+ commands = {
35
+ "install": "hook the status bar into `codex` for your shells",
36
+ "uninstall": "remove everything install added",
37
+ "config": "choose what the bar shows, interactively",
38
+ "update": "upgrade to the latest release",
39
+ "doctor": "check Codex, tmux, and Python versions",
40
+ }
41
+ parsers = {
42
+ name: sub.add_parser(name, help=text, description=text) for name, text in commands.items()
43
+ }
33
44
  for name in ("install", "uninstall"):
34
- s = sub.add_parser(name)
45
+ s = parsers[name]
35
46
  s.add_argument(
36
47
  "--shell",
37
48
  choices=["auto", "bash", "zsh", "fish", "both", "all"],
38
49
  default="auto",
39
- help="default: all supported shells found on PATH",
50
+ help="default: every supported shell found on PATH",
40
51
  )
41
- s.add_argument("--home", default=str(Path.home()), help="target home (for isolated tests)")
42
- s.add_argument(
43
- "--yes",
44
- action="store_true",
45
- help="approve printed diff without interactive prompt",
46
- )
47
- s.add_argument("--dry-run", action="store_true")
48
- sub.add_parser("doctor")
49
- sub.add_parser("update", help="upgrade codex-statusline the way it was installed")
50
- p = sub.add_parser("preview")
51
- p.add_argument("--width", type=int, default=120)
52
- p.add_argument("--theme", choices=["auto", "dark", "light"])
53
- p.add_argument("--ascii", action="store_true")
54
- p.add_argument("--tmux", action="store_true", help="emit tmux style syntax, not ANSI")
52
+ s.add_argument("--home", default=str(Path.home()), help=argparse.SUPPRESS) # tests
53
+ s.add_argument("--yes", action="store_true", help="apply without asking")
54
+ s.add_argument("--diff", action="store_true", help="show the exact file changes")
55
+ s.add_argument("--dry-run", action="store_true", help="show the plan, change nothing")
55
56
  ns = parser.parse_args(args)
56
57
  try:
57
58
  if ns.action in ("install", "uninstall"):
58
59
  from .manage import installation
59
60
 
60
61
  return installation(ns, ns.action == "uninstall")
62
+ if ns.action == "config":
63
+ from .configure import configure
64
+
65
+ return configure()
61
66
  if ns.action == "update":
62
67
  from .update import update
63
68
 
@@ -75,32 +80,6 @@ def main():
75
80
  }
76
81
  print(json.dumps(data, indent=2))
77
82
  return 0 if data["tmux"] and version(path) >= (0, 159, 0) else 1
78
- if ns.action == "preview":
79
- from .render import load_config, render
80
-
81
- cfg = load_config()
82
- now = time.time()
83
- state = {
84
- "context_used": 35,
85
- "tokens": 1_234_567,
86
- "quotas": {
87
- "5h": {"remaining": 78, "reset_at": now + 7200, "observed_at": now},
88
- "weekly": {"remaining": 39, "reset_at": now + 172800, "observed_at": now},
89
- },
90
- }
91
- print(
92
- render(
93
- state,
94
- ns.width,
95
- theme=ns.theme or cfg["theme"],
96
- ascii_only=ns.ascii or cfg["ascii"],
97
- tmux=ns.tmux,
98
- segments=cfg["segments"],
99
- warn_at=cfg["warn_at"],
100
- crit_at=cfg["crit_at"],
101
- )
102
- )
103
- return 0
104
83
  except (OSError, ValueError, RuntimeError, KeyError) as error:
105
84
  print(f"codex-statusline: {error}", file=sys.stderr)
106
85
  return 1
@@ -0,0 +1,174 @@
1
+ """`codex-statusline config`: pick segments, theme and thresholds with a live preview."""
2
+
3
+ import curses
4
+ import time
5
+
6
+ from .render import DEFAULT_SEGMENTS, THEMES, config_path, load_config, render
7
+
8
+ SEGMENT_NAMES = {
9
+ "ctx": "context used",
10
+ "5h": "5-hour quota",
11
+ "week": "weekly quota",
12
+ "tokens": "session tokens",
13
+ }
14
+ SETTINGS = ["theme", "ascii", "warn_at", "crit_at", "update_check"]
15
+ LABELS = {
16
+ "theme": "Theme",
17
+ "ascii": "ASCII only",
18
+ "warn_at": "Quota yellow at",
19
+ "crit_at": "Quota red at",
20
+ "update_check": "Update notice",
21
+ }
22
+ HELP = "↑↓ move space toggle ←→ change J/K reorder s save q quit"
23
+
24
+
25
+ def model_from(cfg: dict) -> dict:
26
+ enabled = [name for name in cfg["segments"] if name in SEGMENT_NAMES]
27
+ order = enabled + [name for name in DEFAULT_SEGMENTS if name not in enabled]
28
+ return {**{key: cfg[key] for key in SETTINGS}, "order": order, "enabled": set(enabled)}
29
+
30
+
31
+ def to_config(model: dict) -> dict:
32
+ segments = [name for name in model["order"] if name in model["enabled"]]
33
+ return {"segments": segments, **{key: model[key] for key in SETTINGS}}
34
+
35
+
36
+ def dump_toml(cfg: dict) -> str:
37
+ def value(v):
38
+ if isinstance(v, bool):
39
+ return "true" if v else "false"
40
+ if isinstance(v, list):
41
+ return "[" + ", ".join(f'"{x}"' for x in v) + "]"
42
+ return f'"{v}"' if isinstance(v, str) else str(v)
43
+
44
+ return "".join(f"{key} = {value(v)}\n" for key, v in cfg.items())
45
+
46
+
47
+ def row_count(model: dict) -> int:
48
+ return len(model["order"]) + len(SETTINGS)
49
+
50
+
51
+ def apply(model: dict, row: int, key: str) -> int:
52
+ """Apply one key press to the row under the cursor; returns the new cursor row."""
53
+ rows = row_count(model)
54
+ if key in ("up", "k"):
55
+ return (row - 1) % rows
56
+ if key in ("down", "j"):
57
+ return (row + 1) % rows
58
+ order = model["order"]
59
+ if row < len(order):
60
+ name = order[row]
61
+ if key == "space":
62
+ # Keep at least one segment on, or the bar would be empty.
63
+ if name not in model["enabled"]:
64
+ model["enabled"].add(name)
65
+ elif len(model["enabled"]) > 1:
66
+ model["enabled"].discard(name)
67
+ elif key in ("K", "J"):
68
+ target = row - 1 if key == "K" else row + 1
69
+ if 0 <= target < len(order):
70
+ order[row], order[target] = order[target], order[row]
71
+ return target
72
+ return row
73
+ setting = SETTINGS[row - len(order)]
74
+ current = model[setting]
75
+ step = {"left": -1, "right": 1}.get(key, 0)
76
+ if isinstance(current, bool):
77
+ if key in ("space", "left", "right"):
78
+ model[setting] = not current
79
+ elif setting == "theme" and (step or key == "space"):
80
+ names = list(THEMES)
81
+ model[setting] = names[(names.index(current) + (step or 1)) % len(names)]
82
+ elif step:
83
+ model[setting] = max(0, min(100, current + 5 * step))
84
+ return row
85
+
86
+
87
+ def rows_text(model: dict) -> list[str]:
88
+ lines = []
89
+ for name in model["order"]:
90
+ mark = "x" if name in model["enabled"] else " "
91
+ lines.append(f"[{mark}] {name:<7} {SEGMENT_NAMES[name]}")
92
+ for setting in SETTINGS:
93
+ value = model[setting]
94
+ if isinstance(value, bool):
95
+ shown = "[x]" if value else "[ ]"
96
+ elif setting == "theme":
97
+ shown = f"< {value} >"
98
+ else:
99
+ shown = f"< {value}% >"
100
+ lines.append(f"{LABELS[setting]:<16} {shown}")
101
+ return lines
102
+
103
+
104
+ def sample_state() -> dict:
105
+ now = time.time()
106
+ return {
107
+ "context_used": 35,
108
+ "tokens": 1_234_567,
109
+ "quotas": {
110
+ "5h": {"remaining": 18, "reset_at": now + 7200, "observed_at": now},
111
+ "weekly": {"remaining": 62, "reset_at": now + 172800, "observed_at": now},
112
+ },
113
+ }
114
+
115
+
116
+ KEYS = {
117
+ curses.KEY_UP: "up",
118
+ curses.KEY_DOWN: "down",
119
+ curses.KEY_LEFT: "left",
120
+ curses.KEY_RIGHT: "right",
121
+ ord(" "): "space",
122
+ }
123
+
124
+
125
+ def _ui(screen, model: dict) -> bool:
126
+ curses.curs_set(0)
127
+ row, state = 0, sample_state()
128
+ while True:
129
+ screen.erase()
130
+ height, width = screen.getmaxyx()
131
+ cfg = to_config(model)
132
+ preview = render(
133
+ state,
134
+ max(1, width - 4),
135
+ tmux=False,
136
+ ascii_only=cfg["ascii"],
137
+ segments=cfg["segments"],
138
+ warn_at=cfg["warn_at"],
139
+ crit_at=cfg["crit_at"],
140
+ )
141
+ lines = ["codex-statusline config", HELP, ""]
142
+ lines += [("> " if i == row else " ") + text for i, text in enumerate(rows_text(model))]
143
+ lines += ["", "Preview (sample numbers):", " " + preview, "", f"Saves to {config_path()}"]
144
+ for y, text in enumerate(lines[: height - 1]):
145
+ highlight = curses.A_REVERSE if y - 3 == row else curses.A_NORMAL
146
+ try:
147
+ screen.addnstr(y, 0, text, width - 1, highlight)
148
+ except curses.error:
149
+ pass # tiny terminal: draw what fits
150
+ key = screen.getch()
151
+ name = KEYS.get(key, chr(key) if 0 <= key < 256 else "")
152
+ if name in ("q", "\x1b"):
153
+ return False
154
+ if name in ("s", "\n"):
155
+ return True
156
+ row = apply(model, row, name)
157
+
158
+
159
+ def configure() -> int:
160
+ model = model_from(load_config())
161
+ try:
162
+ save = curses.wrapper(_ui, model)
163
+ except curses.error as error:
164
+ print(f"codex-statusline: needs an interactive terminal ({error}).")
165
+ print(f"Edit {config_path()} instead.")
166
+ return 1
167
+ if not save:
168
+ print("No changes saved.")
169
+ return 0
170
+ path = config_path()
171
+ path.parent.mkdir(parents=True, exist_ok=True)
172
+ path.write_text(dump_toml(to_config(model)))
173
+ print(f"Saved {path}. Takes effect the next time you start codex.")
174
+ return 0
@@ -215,7 +215,7 @@ def launch(args):
215
215
  'set -g status-left ""\n'
216
216
  "set -g allow-rename off\n"
217
217
  "set -g set-titles off\n"
218
- "set -g mouse off\n"
218
+ "set -g mouse on\n"
219
219
  "set -g history-limit 10000\n"
220
220
  "set -g remain-on-exit on\n"
221
221
  "set -g exit-empty on\n"
@@ -16,6 +16,7 @@ from .launcher import ENTRY
16
16
 
17
17
  BEGIN = "# >>> codex-statusline >>>"
18
18
  END = "# <<< codex-statusline <<<"
19
+ STATUS_MESSAGE = "codex-statusline binding"
19
20
 
20
21
 
21
22
  def detected_shell():
@@ -96,7 +97,7 @@ def hook_group():
96
97
  "type": "command",
97
98
  "command": shlex.join([*base_command(), "hook"]),
98
99
  "timeout": 2,
99
- "statusMessage": "codex-statusline binding",
100
+ "statusMessage": STATUS_MESSAGE,
100
101
  }
101
102
  ]
102
103
  }
@@ -167,14 +168,45 @@ def modify_hook(raw, group, add):
167
168
  return json.dumps(obj, ensure_ascii=False, indent=2) + "\n"
168
169
 
169
170
 
171
+ def summary(changed, home):
172
+ """One line per file: what is added or removed, in words rather than a diff."""
173
+ lines = ["codex-statusline will:"]
174
+ for p, old, new in changed:
175
+ what = "the SessionStart hook" if p.name == "hooks.json" else "the codex shell function"
176
+ if p.name == "hooks.json": # JSON is re-serialized, so compare our marker instead
177
+ had, has = (STATUS_MESSAGE in t for t in (old, new))
178
+ sign, verb = (
179
+ ("+", "add") if has and not had else ("-", "remove") if had else ("~", "update")
180
+ )
181
+ elif old in new:
182
+ sign, verb = "+", "add"
183
+ elif new in old:
184
+ sign, verb = "-", "remove"
185
+ else:
186
+ sign, verb = "~", "update"
187
+ lines.append(f" {sign} {tilde(p, home):<44} {verb} {what}")
188
+ return "\n".join(lines)
189
+
190
+
191
+ def tilde(p, home):
192
+ try:
193
+ return "~/" + str(Path(p).relative_to(home))
194
+ except ValueError:
195
+ return str(p)
196
+
197
+
170
198
  def installation(args, uninstall=False):
171
199
  chome, shells, manifest_path = paths(args.home, args.shell)
172
- changes = []
173
200
  previous = json.loads(text(manifest_path) or "{}")
174
201
  if uninstall and not previous:
175
202
  print("Nothing installed by codex-statusline.")
176
203
  return 0
177
- if uninstall:
204
+ pending = {} # path -> new text; several steps may edit the same file
205
+
206
+ def current(p):
207
+ return pending.get(p, text(p))
208
+
209
+ def remove_previous():
178
210
  # Manifest defines owned paths; never use current shell choice to guess.
179
211
  expected_home, allowed_shells, _ = paths(args.home, "all")
180
212
  if previous.get("hooks_path") != str(expected_home / "hooks.json") or any(
@@ -183,19 +215,20 @@ def installation(args, uninstall=False):
183
215
  or not i["block"].endswith(END + "\n")
184
216
  for i in previous.get("shells", [])
185
217
  ):
186
- raise ValueError("Manifest paths/blocks do not match this home; refusing uninstall.")
187
- group = previous["hook_group"]
188
- hp = Path(previous["hooks_path"])
189
- old = text(hp)
190
- new = modify_hook(old, group, False)
191
- changes.append((hp, old, new))
218
+ raise ValueError("Manifest paths/blocks do not match this home; refusing to remove it.")
219
+ php = Path(previous["hooks_path"])
220
+ pending[php] = modify_hook(current(php), previous["hook_group"], False)
192
221
  for item in previous["shells"]:
193
222
  p = Path(item["path"])
194
- old = text(p)
195
- owned = item["block"]
196
- if owned not in old and BEGIN in old:
223
+ old = current(p)
224
+ if item["block"] not in old and BEGIN in old:
197
225
  raise ValueError(f"Owned shell block was edited; remove it manually: {p}")
198
- changes.append((p, old, old.replace(owned, "", 1)))
226
+ pending[p] = old.replace(item["block"], "", 1)
227
+
228
+ migrating = False
229
+ if uninstall:
230
+ group, hp = previous["hook_group"], Path(previous["hooks_path"])
231
+ remove_previous()
199
232
  else:
200
233
  cfg = tomllib.loads(text(chome / "config.toml"))
201
234
  hook_config = cfg.get("hooks", {})
@@ -206,40 +239,37 @@ def installation(args, uninstall=False):
206
239
  )
207
240
  group = hook_group()
208
241
  hp = chome / "hooks.json"
209
- old = text(hp)
210
- changes.append((hp, old, modify_hook(old, group, True)))
242
+ # An older install from another path (e.g. a git checkout before uv/pipx):
243
+ # swap it out in the same reviewed diff instead of asking for an uninstall first.
244
+ migrating = bool(previous) and (
245
+ previous.get("hook_group") != group or previous.get("hooks_path") != str(hp)
246
+ )
247
+ if migrating:
248
+ print("Replacing a previous codex-statusline installation from another path.")
249
+ remove_previous()
250
+ pending[hp] = modify_hook(current(hp), group, True)
211
251
  for p in shells:
212
252
  block = shell_block("fish" if p.suffix == ".fish" else "zsh")
213
- old = text(p)
253
+ old = current(p)
214
254
  if BEGIN in old and block not in old:
215
255
  raise ValueError(
216
256
  f"A different/edited codex-statusline block exists in {p}; "
217
257
  "uninstall that version first."
218
258
  )
219
- changes.append((p, old, old if block in old else old + block))
220
- if previous and (
221
- previous.get("hook_group") != group or previous.get("hooks_path") != str(hp)
222
- ):
223
- raise ValueError(
224
- "Existing installation uses another project/hook path. Uninstall it first."
225
- )
259
+ pending[p] = old if block in old else old + block
226
260
  allowed = paths(args.home, "all")[1]
227
261
  if any(Path(x["path"]) not in allowed for x in previous.get("shells", [])):
228
262
  raise ValueError("Existing manifest contains unexpected shell paths.")
263
+ changes = [(p, text(p), new) for p, new in pending.items()]
229
264
  changed = [(p, old, new) for p, old, new in changes if old != new]
230
- for p, old, new in changed:
231
- print(
232
- "".join(
233
- difflib.unified_diff(
234
- old.splitlines(True),
235
- new.splitlines(True),
236
- fromfile=str(p),
237
- tofile=str(p),
238
- n=0,
239
- )
240
- ),
241
- end="",
242
- )
265
+ if changed:
266
+ print(summary(changed, Path(args.home).expanduser()))
267
+ if changed and (getattr(args, "diff", False) or args.dry_run):
268
+ for p, old, new in changed:
269
+ diff = difflib.unified_diff(
270
+ old.splitlines(True), new.splitlines(True), str(p), str(p), n=0
271
+ )
272
+ print("".join(diff), end="")
243
273
  if args.dry_run:
244
274
  print("Dry run: no files written.")
245
275
  return 0
@@ -252,7 +282,12 @@ def installation(args, uninstall=False):
252
282
  print("Already installed; no configuration changes.")
253
283
  return 0
254
284
  if not args.yes:
255
- if not sys.stdin.isatty() or input("Apply these changes? [y/N] ").strip().lower() != "y":
285
+ hint = "" if getattr(args, "diff", False) else " (--diff shows the exact changes)"
286
+ if not sys.stdin.isatty() or input(f"Apply?{hint} [Y/n] ").strip().lower() not in (
287
+ "",
288
+ "y",
289
+ "yes",
290
+ ):
256
291
  print("No files changed.")
257
292
  return 0
258
293
  # Refuse symlinks for edited files. User must explicitly manage those targets.
@@ -284,7 +319,8 @@ def installation(args, uninstall=False):
284
319
  if uninstall:
285
320
  manifest_path.unlink(missing_ok=True)
286
321
  else:
287
- owned = {item["path"]: item for item in previous.get("shells", [])}
322
+ kept = [] if migrating else previous.get("shells", [])
323
+ owned = {item["path"]: item for item in kept}
288
324
  for p in shells:
289
325
  owned[str(p)] = {
290
326
  "path": str(p),
@@ -306,10 +342,10 @@ def installation(args, uninstall=False):
306
342
  p.unlink(missing_ok=True)
307
343
  raise
308
344
  print(
309
- "Uninstalled. Open a fresh terminal (existing shell functions remain until then)."
345
+ "Uninstalled. Open a new terminal to finish."
310
346
  if uninstall
311
- else "Installed. Open a fresh terminal, run codex, and review/trust "
312
- "the codex-statusline hook in the official UI."
347
+ else "Installed. Open a new terminal and run codex; trust the "
348
+ f"'{STATUS_MESSAGE}' hook when Codex asks."
313
349
  )
314
- print(f"Configuration change backup: {backup}")
350
+ print(f"Backup: {tilde(backup, Path(args.home).expanduser())}")
315
351
  return 0
@@ -35,11 +35,15 @@ _CONFIG_DEFAULTS = {
35
35
  }
36
36
 
37
37
 
38
+ def config_path() -> Path:
39
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
40
+ return Path(base) / "codex-statusline" / "config.toml"
41
+
42
+
38
43
  def load_config(path: str | os.PathLike | None = None) -> dict[str, Any]:
39
44
  """Read $XDG_CONFIG_HOME/codex-statusline/config.toml; invalid values fall back to defaults."""
40
45
  if path is None:
41
- base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
42
- path = Path(base) / "codex-statusline" / "config.toml"
46
+ path = config_path()
43
47
  config = dict(_CONFIG_DEFAULTS)
44
48
  try:
45
49
  raw = tomllib.loads(Path(path).read_text())
@@ -274,7 +278,7 @@ def render(
274
278
  hint = None
275
279
  if isinstance(update, str) and re.fullmatch(r"\d+(\.\d+)*", update):
276
280
  arrow = "^" if ascii_only else "↑"
277
- hint = (f"{arrow} {update} run: codex-statusline update", f"{arrow}{update}", "warning")
281
+ hint = (f"{arrow} update available: codex-statusline update", f"{arrow} update", "warning")
278
282
  head, rest = parts[0], parts[1:]
279
283
  compact_head = (head[1], head[2])
280
284
  # Widest first: all detail, compact head, all compact, then drop segments.
@@ -38,11 +38,13 @@ def newer_version(timeout: float = 2) -> str | None:
38
38
 
39
39
 
40
40
  def upgrade_command(prefix: str = sys.prefix, package: Path = PACKAGE) -> list[str]:
41
- # uv and pipx each leave a marker file in the tool's environment.
41
+ # uv and pipx each leave a marker file in the tool's environment. Install the latest
42
+ # release from PyPI explicitly: `upgrade` keeps the original source, so a tool first
43
+ # installed from a local checkout would otherwise never leave that version.
42
44
  if (Path(prefix) / "uv-receipt.toml").exists():
43
- return ["uv", "tool", "upgrade", "codex-statusline"]
45
+ return ["uv", "tool", "install", "codex-statusline@latest"]
44
46
  if (Path(prefix) / "pipx_metadata.json").exists():
45
- return ["pipx", "upgrade", "codex-statusline"]
47
+ return ["pipx", "install", "--force", "codex-statusline"]
46
48
  if (package.parent / ".git").exists():
47
49
  return ["git", "-C", str(package.parent), "pull", "--ff-only"]
48
50
  return [sys.executable, "-m", "pip", "install", "--upgrade", "codex-statusline"]
@@ -0,0 +1,42 @@
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ from src.configure import apply, dump_toml, model_from, rows_text, to_config
7
+ from src.render import load_config
8
+
9
+
10
+ class ConfigureTests(unittest.TestCase):
11
+ def setUp(self):
12
+ with patch.dict("os.environ", {}, clear=True):
13
+ self.model = model_from(load_config("/nonexistent/config.toml"))
14
+
15
+ def test_toggle_reorder_and_adjust(self):
16
+ m = self.model
17
+ row = apply(m, 3, "space") # turn tokens off
18
+ row = apply(m, 1, "J") # move 5h below week
19
+ self.assertEqual(row, 2)
20
+ apply(m, 4, "right") # theme auto -> dark
21
+ apply(m, 6, "left") # warn_at 20 -> 15
22
+ apply(m, 7, "left") # crit_at 5 -> 0
23
+ apply(m, 7, "left") # clamps at 0
24
+ apply(m, 8, "space") # update notice off
25
+ cfg = to_config(m)
26
+ self.assertEqual(cfg["segments"], ["ctx", "week", "5h"])
27
+ self.assertEqual((cfg["theme"], cfg["warn_at"], cfg["crit_at"]), ("dark", 15, 0))
28
+ self.assertFalse(cfg["update_check"])
29
+ self.assertIn("[ ] tokens", "\n".join(rows_text(m)))
30
+
31
+ def test_last_segment_cannot_be_turned_off(self):
32
+ for row in range(4):
33
+ apply(self.model, row, "space")
34
+ self.assertEqual(to_config(self.model)["segments"], ["tokens"])
35
+
36
+ def test_saved_file_loads_back(self):
37
+ apply(self.model, 0, "J")
38
+ with tempfile.TemporaryDirectory() as tmp:
39
+ path = Path(tmp) / "config.toml"
40
+ path.write_text(dump_toml(to_config(self.model)))
41
+ with patch.dict("os.environ", {}, clear=True):
42
+ self.assertEqual(load_config(path), to_config(self.model))
@@ -53,6 +53,21 @@ class InstallTests(unittest.TestCase):
53
53
  str(ENTRY), manage.hook_group()["hooks"][0]["command"]
54
54
  ) # git-clone mode unchanged
55
55
 
56
+ def test_install_from_new_path_replaces_previous_install(self):
57
+ (self.root / ".zshrc").write_text("# mine\n")
58
+ manage.installation(self.args) # e.g. an old git-checkout install
59
+ tool = ["/home/u/.local/bin/codex-statusline"]
60
+ with patch.object(manage, "base_command", return_value=tool):
61
+ manage.installation(self.args) # now from uv/pipx
62
+ rc = (self.root / ".zshrc").read_text()
63
+ self.assertEqual(rc.count(manage.BEGIN), 1)
64
+ self.assertIn(tool[0], rc)
65
+ hooks = json.loads((self.root / ".codex/hooks.json").read_text())
66
+ commands = [h["hooks"][0]["command"] for h in hooks["hooks"]["SessionStart"]]
67
+ self.assertEqual(commands, [tool[0] + " hook"])
68
+ manage.installation(self.args, True)
69
+ self.assertEqual((self.root / ".zshrc").read_text(), "# mine\n")
70
+
56
71
  def test_install_repeat_uninstall_preserves_later_changes(self):
57
72
  original = "# original rc\nexport USER_SETTING=yes\n"
58
73
  (self.root / ".zshrc").write_text(original)
@@ -116,6 +116,8 @@ class StartupTests(unittest.TestCase):
116
116
  )
117
117
  conf = (root / "tmux.conf").read_text()
118
118
  self.assertIn("CTX USED", conf)
119
+ # Codex asks tmux for #{mouse}; off makes the wheel recall old prompts.
120
+ self.assertIn("set -g mouse on", conf)
119
121
  self.assertNotIn("binding pending", conf)
120
122
  # error.json is written before the fallback status is applied.
121
123
  # Faster Linux startup can observe that intermediate state.
@@ -27,11 +27,13 @@ class UpdateTests(unittest.TestCase):
27
27
  with tempfile.TemporaryDirectory() as tmp:
28
28
  (Path(tmp) / "uv-receipt.toml").touch()
29
29
  self.assertEqual(
30
- upgrade_command(tmp, pkg), ["uv", "tool", "upgrade", "codex-statusline"]
30
+ upgrade_command(tmp, pkg), ["uv", "tool", "install", "codex-statusline@latest"]
31
31
  )
32
32
  with tempfile.TemporaryDirectory() as tmp:
33
33
  (Path(tmp) / "pipx_metadata.json").touch()
34
- self.assertEqual(upgrade_command(tmp, pkg), ["pipx", "upgrade", "codex-statusline"])
34
+ self.assertEqual(
35
+ upgrade_command(tmp, pkg), ["pipx", "install", "--force", "codex-statusline"]
36
+ )
35
37
  self.assertEqual(upgrade_command("/nowhere", pkg)[1:3], ["-m", "pip"])
36
38
  repo = Path(__file__).resolve().parent.parent
37
39
  if (repo / ".git").exists():
@@ -40,7 +42,7 @@ class UpdateTests(unittest.TestCase):
40
42
  def test_update_hint_is_dropped_first_when_narrow(self):
41
43
  state = {"context_used": 10, "now": 1000, "update": "0.2.0", "quotas": {}}
42
44
  wide = render(state, 240, tmux=False)
43
- self.assertIn("↑ 0.2.0 run: codex-statusline update", wide)
44
- self.assertIn("^0.2.0", render(state, 240, tmux=False, ascii_only=True).replace(" ", ""))
45
- self.assertNotIn("0.2.0", render(state, 40, tmux=False))
46
- self.assertNotIn("0.2.0", render(dict(state, update="#(evil)"), 240, tmux=False))
45
+ self.assertIn("↑ update available: codex-statusline update", wide)
46
+ self.assertIn("^ update available", render(state, 240, tmux=False, ascii_only=True))
47
+ self.assertNotIn("update", render(state, 40, tmux=False))
48
+ self.assertNotIn("update", render(dict(state, update="#(evil)"), 240, tmux=False))