codex-statusline 0.1.2__tar.gz → 0.2.1__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.
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/PKG-INFO +19 -3
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/README.md +18 -2
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/PKG-INFO +19 -3
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/SOURCES.txt +2 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/pyproject.toml +1 -1
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/__init__.py +1 -1
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/cli.py +7 -1
- codex_statusline-0.2.1/src/configure.py +174 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/data.py +18 -5
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/launcher.py +9 -5
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/manage.py +78 -42
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/render.py +6 -2
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/update.py +5 -3
- codex_statusline-0.2.1/tests/test_configure.py +42 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_data.py +4 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_management.py +15 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_startup.py +27 -3
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_update.py +4 -2
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/LICENSE +0 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/dependency_links.txt +0 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/entry_points.txt +0 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/top_level.txt +0 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/setup.cfg +0 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/__main__.py +0 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/entry.py +0 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/hook.py +0 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_auto_shells.py +0 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_fish.py +0 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_render.py +0 -0
- {codex_statusline-0.1.2 → codex_statusline-0.2.1}/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
|
+
Version: 0.2.1
|
|
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
|
|
@@ -73,6 +73,7 @@ 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
79
|
codex-statusline preview --width 80 # try it without launching Codex
|
|
@@ -83,7 +84,22 @@ Uninstall removes only what this tool added and still matches exactly. If you ed
|
|
|
83
84
|
|
|
84
85
|
## Configuration
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
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.
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
[x] ctx context used
|
|
91
|
+
[x] 5h 5-hour quota
|
|
92
|
+
[x] week weekly quota
|
|
93
|
+
[x] tokens session tokens
|
|
94
|
+
> Theme < auto >
|
|
95
|
+
Quota yellow at < 20% >
|
|
96
|
+
Quota red at < 5% >
|
|
97
|
+
|
|
98
|
+
Preview (sample numbers):
|
|
99
|
+
CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
|
|
87
103
|
|
|
88
104
|
```toml
|
|
89
105
|
segments = ["ctx", "5h", "week", "tokens"] # pick and reorder
|
|
@@ -102,7 +118,7 @@ Invalid values are reported and fall back to defaults.
|
|
|
102
118
|
|
|
103
119
|
## FAQ
|
|
104
120
|
|
|
105
|
-
**How do I scroll or copy?**
|
|
121
|
+
**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
122
|
|
|
107
123
|
**Windows?** Not supported (tmux).
|
|
108
124
|
|
|
@@ -52,6 +52,7 @@ 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
58
|
codex-statusline preview --width 80 # try it without launching Codex
|
|
@@ -62,7 +63,22 @@ Uninstall removes only what this tool added and still matches exactly. If you ed
|
|
|
62
63
|
|
|
63
64
|
## Configuration
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
[x] ctx context used
|
|
70
|
+
[x] 5h 5-hour quota
|
|
71
|
+
[x] week weekly quota
|
|
72
|
+
[x] tokens session tokens
|
|
73
|
+
> Theme < auto >
|
|
74
|
+
Quota yellow at < 20% >
|
|
75
|
+
Quota red at < 5% >
|
|
76
|
+
|
|
77
|
+
Preview (sample numbers):
|
|
78
|
+
CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
|
|
66
82
|
|
|
67
83
|
```toml
|
|
68
84
|
segments = ["ctx", "5h", "week", "tokens"] # pick and reorder
|
|
@@ -81,7 +97,7 @@ Invalid values are reported and fall back to defaults.
|
|
|
81
97
|
|
|
82
98
|
## FAQ
|
|
83
99
|
|
|
84
|
-
**How do I scroll or copy?**
|
|
100
|
+
**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
101
|
|
|
86
102
|
**Windows?** Not supported (tmux).
|
|
87
103
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-statusline
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
@@ -73,6 +73,7 @@ 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
79
|
codex-statusline preview --width 80 # try it without launching Codex
|
|
@@ -83,7 +84,22 @@ Uninstall removes only what this tool added and still matches exactly. If you ed
|
|
|
83
84
|
|
|
84
85
|
## Configuration
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
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.
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
[x] ctx context used
|
|
91
|
+
[x] 5h 5-hour quota
|
|
92
|
+
[x] week weekly quota
|
|
93
|
+
[x] tokens session tokens
|
|
94
|
+
> Theme < auto >
|
|
95
|
+
Quota yellow at < 20% >
|
|
96
|
+
Quota red at < 5% >
|
|
97
|
+
|
|
98
|
+
Preview (sample numbers):
|
|
99
|
+
CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
|
|
87
103
|
|
|
88
104
|
```toml
|
|
89
105
|
segments = ["ctx", "5h", "week", "tokens"] # pick and reorder
|
|
@@ -102,7 +118,7 @@ Invalid values are reported and fall back to defaults.
|
|
|
102
118
|
|
|
103
119
|
## FAQ
|
|
104
120
|
|
|
105
|
-
**How do I scroll or copy?**
|
|
121
|
+
**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
122
|
|
|
107
123
|
**Windows?** Not supported (tmux).
|
|
108
124
|
|
|
@@ -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
|
|
@@ -42,10 +42,12 @@ def main():
|
|
|
42
42
|
s.add_argument(
|
|
43
43
|
"--yes",
|
|
44
44
|
action="store_true",
|
|
45
|
-
help="
|
|
45
|
+
help="apply without asking",
|
|
46
46
|
)
|
|
47
|
+
s.add_argument("--diff", action="store_true", help="show the exact file changes")
|
|
47
48
|
s.add_argument("--dry-run", action="store_true")
|
|
48
49
|
sub.add_parser("doctor")
|
|
50
|
+
sub.add_parser("config", help="choose segments, theme and thresholds interactively")
|
|
49
51
|
sub.add_parser("update", help="upgrade codex-statusline the way it was installed")
|
|
50
52
|
p = sub.add_parser("preview")
|
|
51
53
|
p.add_argument("--width", type=int, default=120)
|
|
@@ -58,6 +60,10 @@ def main():
|
|
|
58
60
|
from .manage import installation
|
|
59
61
|
|
|
60
62
|
return installation(ns, ns.action == "uninstall")
|
|
63
|
+
if ns.action == "config":
|
|
64
|
+
from .configure import configure
|
|
65
|
+
|
|
66
|
+
return configure()
|
|
61
67
|
if ns.action == "update":
|
|
62
68
|
from .update import update
|
|
63
69
|
|
|
@@ -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
|
|
@@ -284,8 +284,15 @@ def merge_quotas(*snapshots: dict[str, Any]) -> dict[str, Any]:
|
|
|
284
284
|
return merged
|
|
285
285
|
|
|
286
286
|
|
|
287
|
-
def
|
|
288
|
-
""
|
|
287
|
+
def sessions_dir() -> Path:
|
|
288
|
+
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "sessions"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def newest_quotas(sessions: Path, readers: dict[str, LogReader], limit: int = 10) -> dict[str, Any]:
|
|
292
|
+
"""Freshest quotas from local session logs, newest first, stopping once both windows are seen.
|
|
293
|
+
|
|
294
|
+
Sessions opened without a single turn carry no quota, so look past them.
|
|
295
|
+
"""
|
|
289
296
|
found = []
|
|
290
297
|
try:
|
|
291
298
|
for path in sessions.glob("*/*/*/*.jsonl"):
|
|
@@ -295,7 +302,13 @@ def newest_quotas(sessions: Path, readers: dict[str, LogReader], limit: int = 3)
|
|
|
295
302
|
continue
|
|
296
303
|
except OSError:
|
|
297
304
|
return {}
|
|
298
|
-
|
|
299
|
-
|
|
305
|
+
merged: dict[str, Any] = {}
|
|
306
|
+
scanned = []
|
|
307
|
+
for _, path in sorted(found, reverse=True)[:limit]:
|
|
308
|
+
scanned.append(path)
|
|
309
|
+
merged = merge_quotas(merged, readers.setdefault(path, LogReader()).update(path)["quotas"])
|
|
310
|
+
if {"5h", "weekly"} <= merged.keys():
|
|
311
|
+
break
|
|
312
|
+
for stale in set(readers) - set(scanned):
|
|
300
313
|
del readers[stale]
|
|
301
|
-
return
|
|
314
|
+
return merged
|
|
@@ -158,6 +158,7 @@ def branch(cwd):
|
|
|
158
158
|
|
|
159
159
|
def placeholder(width=80):
|
|
160
160
|
"""Use the normal layout before telemetry arrives or while it is unavailable."""
|
|
161
|
+
from .data import newest_quotas, sessions_dir
|
|
161
162
|
from .render import load_config, render
|
|
162
163
|
|
|
163
164
|
try:
|
|
@@ -165,7 +166,11 @@ def placeholder(width=80):
|
|
|
165
166
|
except (TypeError, ValueError):
|
|
166
167
|
width = 80
|
|
167
168
|
cfg = load_config()
|
|
168
|
-
|
|
169
|
+
# Quota is account-wide, so even the very first frame can show it.
|
|
170
|
+
state = {"quotas": newest_quotas(sessions_dir(), {})}
|
|
171
|
+
return render(
|
|
172
|
+
state, width, theme=cfg["theme"], ascii_only=cfg["ascii"], segments=cfg["segments"]
|
|
173
|
+
)
|
|
169
174
|
|
|
170
175
|
|
|
171
176
|
def launch(args):
|
|
@@ -210,7 +215,7 @@ def launch(args):
|
|
|
210
215
|
'set -g status-left ""\n'
|
|
211
216
|
"set -g allow-rename off\n"
|
|
212
217
|
"set -g set-titles off\n"
|
|
213
|
-
"set -g mouse
|
|
218
|
+
"set -g mouse on\n"
|
|
214
219
|
"set -g history-limit 10000\n"
|
|
215
220
|
"set -g remain-on-exit on\n"
|
|
216
221
|
"set -g exit-empty on\n"
|
|
@@ -236,7 +241,7 @@ def launch(args):
|
|
|
236
241
|
)
|
|
237
242
|
|
|
238
243
|
def monitor():
|
|
239
|
-
from .data import LogReader, merge_quotas, newest_quotas, parse_title
|
|
244
|
+
from .data import LogReader, merge_quotas, newest_quotas, parse_title, sessions_dir
|
|
240
245
|
from .render import load_config, render
|
|
241
246
|
|
|
242
247
|
cfg = load_config()
|
|
@@ -248,8 +253,7 @@ def launch(args):
|
|
|
248
253
|
threading.Thread(
|
|
249
254
|
target=lambda: latest.update(version=newer_version()), daemon=True
|
|
250
255
|
).start()
|
|
251
|
-
|
|
252
|
-
sessions, readers, others, scan_at = codex_home / "sessions", {}, {}, 0.0
|
|
256
|
+
sessions, readers, others, scan_at = sessions_dir(), {}, {}, 0.0
|
|
253
257
|
last_branch, branch_at = None, 0
|
|
254
258
|
last_line = None
|
|
255
259
|
while not stop.is_set():
|
|
@@ -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":
|
|
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
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
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 =
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
print(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
345
|
+
"Uninstalled. Open a new terminal to finish."
|
|
310
346
|
if uninstall
|
|
311
|
-
else "Installed. Open a
|
|
312
|
-
"
|
|
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"
|
|
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
|
-
|
|
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())
|
|
@@ -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", "
|
|
45
|
+
return ["uv", "tool", "install", "codex-statusline@latest"]
|
|
44
46
|
if (Path(prefix) / "pipx_metadata.json").exists():
|
|
45
|
-
return ["pipx", "
|
|
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))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import os
|
|
2
3
|
import tempfile
|
|
3
4
|
import unittest
|
|
4
5
|
from pathlib import Path
|
|
@@ -137,6 +138,9 @@ class LogReaderTests(unittest.TestCase):
|
|
|
137
138
|
day.mkdir(parents=True)
|
|
138
139
|
(day / "old.jsonl").write_bytes(line(token_event(1000, 80, 10)))
|
|
139
140
|
(day / "new.jsonl").write_bytes(line(token_event(2000, 5, 20)))
|
|
141
|
+
(day / "empty.jsonl").write_bytes(b"") # a session opened without any turn
|
|
142
|
+
for name, mtime in (("old", 100), ("new", 200), ("empty", 300)):
|
|
143
|
+
os.utime(day / f"{name}.jsonl", (mtime, mtime))
|
|
140
144
|
others = newest_quotas(Path(tmp), {})
|
|
141
145
|
self.assertEqual(others["5h"]["remaining"], 95.0)
|
|
142
146
|
self.assertEqual(others["weekly"]["observed_at"], 2000.0)
|
|
@@ -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)
|
|
@@ -18,14 +18,36 @@ from src.render import visible_width
|
|
|
18
18
|
|
|
19
19
|
class PlaceholderTests(unittest.TestCase):
|
|
20
20
|
def test_no_fabricated_values(self):
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
with tempfile.TemporaryDirectory() as home: # no local sessions at all
|
|
22
|
+
for theme in ("dark", "light"):
|
|
23
|
+
env = {"CODEX_STATUSLINE_THEME": theme, "CODEX_HOME": home}
|
|
24
|
+
with patch.dict(os.environ, env):
|
|
25
|
+
result = placeholder(160)
|
|
24
26
|
for text in ("CTX USED", "5h", "week", "--"):
|
|
25
27
|
self.assertIn(text, result)
|
|
26
28
|
for text in ("status unknown", "binding pending", "0%", "100%"):
|
|
27
29
|
self.assertNotIn(text, result)
|
|
28
30
|
|
|
31
|
+
def test_first_frame_shows_quota_from_local_sessions(self):
|
|
32
|
+
with tempfile.TemporaryDirectory() as home:
|
|
33
|
+
day = Path(home) / "sessions" / "2026" / "09" / "30"
|
|
34
|
+
day.mkdir(parents=True)
|
|
35
|
+
now = time.time()
|
|
36
|
+
limits = {
|
|
37
|
+
"primary": {"used_percent": 30, "window_minutes": 300, "resets_at": now + 3600},
|
|
38
|
+
"secondary": {"used_percent": 60, "window_minutes": 10080, "resets_at": now + 9e4},
|
|
39
|
+
}
|
|
40
|
+
event = {
|
|
41
|
+
"type": "event_msg",
|
|
42
|
+
"timestamp": now,
|
|
43
|
+
"payload": {"type": "token_count", "info": {"rate_limits": limits}},
|
|
44
|
+
}
|
|
45
|
+
(day / "r.jsonl").write_text(json.dumps(event) + "\n")
|
|
46
|
+
with patch.dict(os.environ, {"CODEX_HOME": home}):
|
|
47
|
+
result = placeholder(160)
|
|
48
|
+
self.assertIn("70%", result)
|
|
49
|
+
self.assertIn("40%", result)
|
|
50
|
+
|
|
29
51
|
def test_placeholder_follows_config_file(self):
|
|
30
52
|
with tempfile.TemporaryDirectory() as tmp:
|
|
31
53
|
conf = Path(tmp) / "codex-statusline" / "config.toml"
|
|
@@ -94,6 +116,8 @@ class StartupTests(unittest.TestCase):
|
|
|
94
116
|
)
|
|
95
117
|
conf = (root / "tmux.conf").read_text()
|
|
96
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)
|
|
97
121
|
self.assertNotIn("binding pending", conf)
|
|
98
122
|
# error.json is written before the fallback status is applied.
|
|
99
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", "
|
|
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(
|
|
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():
|
|
File without changes
|
{codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|