aiwcli 0.9.8 → 0.10.0

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 (116) hide show
  1. package/bin/run.js +5 -2
  2. package/dist/lib/claude-settings-types.d.ts +2 -0
  3. package/dist/templates/CLAUDE.md +3 -3
  4. package/dist/templates/_shared/.claude/settings.json +4 -0
  5. package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  7. package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
  8. package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
  9. package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
  10. package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
  11. package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  12. package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  13. package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
  14. package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
  15. package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  16. package/dist/templates/_shared/hooks/archive_plan.py +87 -178
  17. package/dist/templates/_shared/hooks/context_monitor.py +104 -247
  18. package/dist/templates/_shared/hooks/file-suggestion.py +26 -23
  19. package/dist/templates/_shared/hooks/pre_compact.py +47 -32
  20. package/dist/templates/_shared/hooks/session_end.py +103 -60
  21. package/dist/templates/_shared/hooks/session_start.py +110 -81
  22. package/dist/templates/_shared/hooks/task_create_capture.py +26 -50
  23. package/dist/templates/_shared/hooks/task_update_capture.py +42 -115
  24. package/dist/templates/_shared/hooks/user_prompt_submit.py +61 -61
  25. package/dist/templates/_shared/lib/base/__init__.py +16 -0
  26. package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
  28. package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
  29. package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
  30. package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
  31. package/dist/templates/_shared/lib/base/hook_utils.py +199 -11
  32. package/dist/templates/_shared/lib/base/inference.py +121 -0
  33. package/dist/templates/_shared/lib/base/logger.py +291 -0
  34. package/dist/templates/_shared/lib/base/utils.py +42 -9
  35. package/dist/templates/_shared/lib/context/__init__.py +72 -80
  36. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
  38. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  39. package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
  40. package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
  41. package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
  42. package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
  43. package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
  44. package/dist/templates/_shared/lib/context/context_formatter.py +316 -0
  45. package/dist/templates/_shared/lib/context/context_selector.py +491 -0
  46. package/dist/templates/_shared/lib/context/context_store.py +636 -0
  47. package/dist/templates/_shared/lib/context/plan_manager.py +204 -0
  48. package/dist/templates/_shared/lib/context/task_tracker.py +188 -0
  49. package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
  50. package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
  51. package/dist/templates/_shared/lib/handoff/document_generator.py +14 -40
  52. package/dist/templates/_shared/lib/templates/README.md +5 -13
  53. package/dist/templates/_shared/lib/templates/__init__.py +2 -6
  54. package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
  56. package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
  57. package/dist/templates/_shared/lib/templates/plan_context.py +1 -38
  58. package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
  59. package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
  60. package/dist/templates/_shared/scripts/save_handoff.py +39 -19
  61. package/dist/templates/_shared/scripts/status_line.py +701 -0
  62. package/dist/templates/_shared/workflows/handoff.md +9 -3
  63. package/dist/templates/cc-native/.claude/settings.json +41 -8
  64. package/dist/templates/cc-native/CC-NATIVE-README.md +25 -28
  65. package/dist/templates/cc-native/MIGRATION.md +1 -1
  66. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +14 -39
  67. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +49 -21
  68. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  69. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  70. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
  71. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
  72. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
  73. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
  74. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +57 -55
  75. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +163 -131
  76. package/dist/templates/cc-native/_cc-native/hooks/plan_accepted.py +127 -0
  77. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +81 -0
  78. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +26 -25
  79. package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +6 -4
  80. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
  82. package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
  83. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  84. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  85. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  86. package/dist/templates/cc-native/_cc-native/lib/debug.py +37 -22
  87. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +34 -29
  88. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  89. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  90. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  91. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  92. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  93. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +26 -21
  94. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +12 -7
  95. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +12 -7
  96. package/dist/templates/cc-native/_cc-native/lib/state.py +31 -16
  97. package/dist/templates/cc-native/_cc-native/lib/utils.py +207 -40
  98. package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -2
  99. package/oclif.manifest.json +1 -1
  100. package/package.json +1 -1
  101. package/dist/templates/_shared/hooks/context_enforcer.py +0 -625
  102. package/dist/templates/_shared/hooks/task_create_atomicity.py +0 -177
  103. package/dist/templates/_shared/lib/context/auto_state.py +0 -167
  104. package/dist/templates/_shared/lib/context/cache.py +0 -444
  105. package/dist/templates/_shared/lib/context/context_extractor.py +0 -115
  106. package/dist/templates/_shared/lib/context/context_manager.py +0 -1057
  107. package/dist/templates/_shared/lib/context/discovery.py +0 -554
  108. package/dist/templates/_shared/lib/context/event_log.py +0 -316
  109. package/dist/templates/_shared/lib/context/plan_archive.py +0 -101
  110. package/dist/templates/_shared/lib/context/task_sync.py +0 -407
  111. package/dist/templates/_shared/lib/templates/persona_questions.py +0 -113
  112. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  113. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
  114. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
  115. package/dist/templates/cc-native/_cc-native/lib/async_archive.py +0 -68
  116. package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +0 -98
@@ -0,0 +1,701 @@
1
+ #!/usr/bin/env python3
2
+ """Status line for Claude Code sessions.
3
+
4
+ Renders context window usage and git status with ANSI colors.
5
+ Optionally persists context_window data to the session's state.json.
6
+
7
+ Ported from PAI statusline.ts — context and git sections only.
8
+
9
+ Usage: echo '{"session_id":"...","model":{"display_name":"Opus"},...}' | python status_line.py
10
+ """
11
+ import json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import sys
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any, Dict, Optional, Tuple
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Path setup (matches save_handoff.py pattern)
22
+ # ---------------------------------------------------------------------------
23
+ SCRIPT_DIR = Path(__file__).resolve().parent
24
+ SHARED_ROOT = SCRIPT_DIR.parent # _shared/
25
+ sys.path.insert(0, str(SHARED_ROOT))
26
+
27
+ from lib.base.atomic_write import atomic_write
28
+ from lib.base.hook_utils import CONTEXT_BASELINE_TOKENS
29
+
30
+ # Cache file for session_id → context_id mapping
31
+ OUTPUT_DIR = Path(".") / "_output"
32
+ STATUSLINE_CACHE = OUTPUT_DIR / ".statusline-cache.json"
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # NO_COLOR support (https://no-color.org)
36
+ # ---------------------------------------------------------------------------
37
+ NO_COLOR = bool(os.environ.get("NO_COLOR"))
38
+
39
+ RESET = "" if NO_COLOR else "\x1b[0m"
40
+
41
+ # Structural
42
+ SLATE_300 = "" if NO_COLOR else "\x1b[38;2;203;213;225m"
43
+ SLATE_400 = "" if NO_COLOR else "\x1b[38;2;148;163;184m"
44
+ SLATE_500 = "" if NO_COLOR else "\x1b[38;2;100;116;139m"
45
+ SLATE_600 = "" if NO_COLOR else "\x1b[38;2;71;85;105m"
46
+
47
+ # Semantic
48
+ EMERALD = "" if NO_COLOR else "\x1b[38;2;74;222;128m"
49
+ ROSE = "" if NO_COLOR else "\x1b[38;2;251;113;133m"
50
+ AMBER = "" if NO_COLOR else "\x1b[38;2;251;191;36m"
51
+
52
+ # Context colors
53
+ CTX_PRIMARY = "" if NO_COLOR else "\x1b[38;2;129;140;248m"
54
+ CTX_SECONDARY = "" if NO_COLOR else "\x1b[38;2;165;180;252m"
55
+ CTX_ACCENT = "" if NO_COLOR else "\x1b[38;2;139;92;246m"
56
+ CTX_BUCKET_EMPTY = "" if NO_COLOR else "\x1b[38;2;75;82;95m"
57
+
58
+ # Git colors
59
+ GIT_PRIMARY = "" if NO_COLOR else "\x1b[38;2;56;189;248m"
60
+ GIT_VALUE = "" if NO_COLOR else "\x1b[38;2;186;230;253m"
61
+ GIT_DIR = "" if NO_COLOR else "\x1b[38;2;147;197;253m"
62
+ GIT_CLEAN = "" if NO_COLOR else "\x1b[38;2;125;211;252m"
63
+ GIT_MODIFIED = "" if NO_COLOR else "\x1b[38;2;96;165;250m"
64
+ GIT_ADDED = "" if NO_COLOR else "\x1b[38;2;59;130;246m"
65
+ GIT_STASH = "" if NO_COLOR else "\x1b[38;2;165;180;252m"
66
+ GIT_AGE_FRESH = "" if NO_COLOR else "\x1b[38;2;125;211;252m"
67
+ GIT_AGE_RECENT = "" if NO_COLOR else "\x1b[38;2;96;165;250m"
68
+ GIT_AGE_STALE = "" if NO_COLOR else "\x1b[38;2;59;130;246m"
69
+ GIT_AGE_OLD = "" if NO_COLOR else "\x1b[38;2;99;102;241m"
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Display modes
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def get_terminal_width() -> int:
76
+ """Detect terminal width with fallbacks."""
77
+ # Try COLUMNS env var first
78
+ cols_env = os.environ.get("COLUMNS")
79
+ if cols_env:
80
+ try:
81
+ cols = int(cols_env)
82
+ if cols > 0:
83
+ return cols
84
+ except ValueError:
85
+ pass
86
+
87
+ # Try os.get_terminal_size
88
+ try:
89
+ return os.get_terminal_size().columns
90
+ except (OSError, ValueError):
91
+ pass
92
+
93
+ return 80
94
+
95
+
96
+ def get_display_mode(width: int) -> str:
97
+ """Map terminal width to display mode."""
98
+ if width < 35:
99
+ return "nano"
100
+ if width < 55:
101
+ return "micro"
102
+ if width < 80:
103
+ return "mini"
104
+ return "normal"
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Color helpers
109
+ # ---------------------------------------------------------------------------
110
+
111
+ def get_bucket_color(pos: int, max_pos: int) -> str:
112
+ """Get gradient color for context bar bucket at position."""
113
+ if NO_COLOR:
114
+ return ""
115
+ pct = (pos * 100) // max_pos
116
+
117
+ if pct <= 33:
118
+ r = 74 + ((250 - 74) * pct) // 33
119
+ g = 222 + ((204 - 222) * pct) // 33
120
+ b = 128 + ((21 - 128) * pct) // 33
121
+ elif pct <= 66:
122
+ t = pct - 33
123
+ r = 250 + ((251 - 250) * t) // 33
124
+ g = 204 + ((146 - 204) * t) // 33
125
+ b = 21 + ((60 - 21) * t) // 33
126
+ else:
127
+ t = pct - 66
128
+ r = 251 + ((239 - 251) * t) // 34
129
+ g = 146 + ((68 - 146) * t) // 34
130
+ b = 60 + ((68 - 60) * t) // 34
131
+
132
+ return f"\x1b[38;2;{r};{g};{b}m"
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Context bar rendering
137
+ # ---------------------------------------------------------------------------
138
+
139
+ def render_context_bar(width: int, pct: int) -> Tuple[str, str]:
140
+ """Render the context usage bar with gradient colors.
141
+
142
+ Returns (bar_string, last_filled_color).
143
+ """
144
+ pct = max(0, min(100, pct))
145
+ filled = (pct * width) // 100
146
+ last_color = EMERALD
147
+ parts = []
148
+
149
+ for i in range(1, width + 1):
150
+ if i <= filled:
151
+ color = get_bucket_color(i, width)
152
+ last_color = color
153
+ parts.append(f"{color}\u26C1{RESET}")
154
+ else:
155
+ parts.append(f"{CTX_BUCKET_EMPTY}\u26C1{RESET}")
156
+ if width > 8:
157
+ parts.append(" ")
158
+
159
+ return "".join(parts).rstrip(), last_color
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Separator
164
+ # ---------------------------------------------------------------------------
165
+
166
+ SEPARATOR = f"{SLATE_600}" + "\u2500" * 72 + f"{RESET}"
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Context section
171
+ # ---------------------------------------------------------------------------
172
+
173
+ def shorten_model(name: str) -> str:
174
+ """Shorten common model display names."""
175
+ replacements = [
176
+ ("claude-opus-4-6", "opus-4.6"),
177
+ ("claude-opus-4-5", "opus-4.5"),
178
+ ("claude-sonnet-4", "sonnet-4"),
179
+ ("claude-3-5-sonnet", "sonnet-3.5"),
180
+ ("claude-3-5-haiku", "haiku-3.5"),
181
+ ("claude-", ""),
182
+ ]
183
+ result = name
184
+ for old, new in replacements:
185
+ result = result.replace(old, new)
186
+ return result
187
+
188
+
189
+ def render_context(
190
+ mode: str,
191
+ context_pct: int,
192
+ context_k: int,
193
+ max_k: int,
194
+ time_display: str,
195
+ model_name: str,
196
+ ) -> None:
197
+ """Render the context usage section."""
198
+ if context_pct <= 33:
199
+ pct_color = EMERALD
200
+ elif context_pct <= 66:
201
+ pct_color = AMBER
202
+ else:
203
+ pct_color = ROSE
204
+
205
+ short_model = shorten_model(model_name)
206
+
207
+ if mode == "nano":
208
+ bar, _ = render_context_bar(5, context_pct)
209
+ print(
210
+ f"{CTX_PRIMARY}\u25C9{RESET} {CTX_ACCENT}{short_model}{RESET} "
211
+ f"{bar} {pct_color}{context_pct}%{RESET} "
212
+ f"{CTX_ACCENT}\u23F1{RESET} {SLATE_300}{time_display}{RESET}"
213
+ )
214
+ elif mode == "micro":
215
+ bar, _ = render_context_bar(6, context_pct)
216
+ print(
217
+ f"{CTX_PRIMARY}\u25C9{RESET} {CTX_ACCENT}{short_model}{RESET} "
218
+ f"{SLATE_600}\u2502{RESET} "
219
+ f"{bar} {pct_color}{context_pct}%{RESET} {SLATE_500}({context_k}k){RESET} "
220
+ f"{CTX_ACCENT}\u23F1{RESET} {SLATE_300}{time_display}{RESET}"
221
+ )
222
+ elif mode == "mini":
223
+ bar, _ = render_context_bar(8, context_pct)
224
+ print(
225
+ f"{CTX_PRIMARY}\u25C9{RESET} {CTX_ACCENT}{short_model}{RESET} "
226
+ f"{SLATE_600}\u2502{RESET} "
227
+ f"{CTX_SECONDARY}CTX:{RESET} {bar} "
228
+ f"{pct_color}{context_pct}%{RESET} {SLATE_500}({context_k}k/{max_k}k){RESET} "
229
+ f"{CTX_ACCENT}\u23F1{RESET} {SLATE_300}{time_display}{RESET}"
230
+ )
231
+ else: # normal
232
+ bar, last_color = render_context_bar(16, context_pct)
233
+ print(
234
+ f"{CTX_PRIMARY}\u25C9{RESET} {CTX_SECONDARY}Model:{RESET} {CTX_ACCENT}{short_model}{RESET} "
235
+ f"{SLATE_600}\u2502{RESET} "
236
+ f"{CTX_SECONDARY}Context:{RESET} {bar} "
237
+ f"{last_color}{context_pct}%{RESET} {SLATE_500}({context_k}k/{max_k}k){RESET} "
238
+ f"{SLATE_600}\u2502{RESET} "
239
+ f"{CTX_ACCENT}\u23F1{RESET} {SLATE_300}{time_display}{RESET}"
240
+ )
241
+
242
+ print(SEPARATOR)
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Git status
247
+ # ---------------------------------------------------------------------------
248
+
249
+ def _run_git(args: list, cwd: str, timeout: int = 2) -> Optional[str]:
250
+ """Run a git command and return stdout, or None on failure."""
251
+ try:
252
+ kwargs: Dict[str, Any] = {
253
+ "capture_output": True,
254
+ "text": True,
255
+ "timeout": timeout,
256
+ "cwd": cwd,
257
+ }
258
+ if sys.platform == "win32":
259
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
260
+ result = subprocess.run(["git"] + args, **kwargs)
261
+ if result.returncode == 0:
262
+ return result.stdout.strip()
263
+ except Exception:
264
+ pass
265
+ return None
266
+
267
+
268
+ def get_git_status(cwd: str) -> Optional[Dict[str, Any]]:
269
+ """Gather git repository status."""
270
+ # Check if git repo
271
+ if _run_git(["rev-parse", "--git-dir"], cwd) is None:
272
+ return None
273
+
274
+ status: Dict[str, Any] = {
275
+ "branch": "detached",
276
+ "modified": 0,
277
+ "staged": 0,
278
+ "untracked": 0,
279
+ "stash_count": 0,
280
+ "ahead": 0,
281
+ "behind": 0,
282
+ "age_display": "",
283
+ "age_color": GIT_AGE_FRESH,
284
+ }
285
+
286
+ # Branch
287
+ branch = _run_git(["branch", "--show-current"], cwd)
288
+ if branch:
289
+ status["branch"] = branch
290
+
291
+ # Modified files
292
+ diff = _run_git(["diff", "--name-only"], cwd)
293
+ if diff:
294
+ status["modified"] = len([l for l in diff.splitlines() if l])
295
+
296
+ # Staged files
297
+ staged = _run_git(["diff", "--cached", "--name-only"], cwd)
298
+ if staged:
299
+ status["staged"] = len([l for l in staged.splitlines() if l])
300
+
301
+ # Untracked files
302
+ untracked = _run_git(["ls-files", "--others", "--exclude-standard"], cwd)
303
+ if untracked:
304
+ status["untracked"] = len([l for l in untracked.splitlines() if l])
305
+
306
+ # Stash count
307
+ stash = _run_git(["stash", "list"], cwd)
308
+ if stash:
309
+ status["stash_count"] = len([l for l in stash.splitlines() if l])
310
+
311
+ # Ahead/behind
312
+ ab = _run_git(["rev-list", "--left-right", "--count", "HEAD...@{u}"], cwd)
313
+ if ab:
314
+ parts = ab.split()
315
+ if len(parts) >= 2:
316
+ status["ahead"] = int(parts[0] or 0)
317
+ status["behind"] = int(parts[1] or 0)
318
+
319
+ # Commit age
320
+ log = _run_git(["log", "-1", "--format=%ct"], cwd)
321
+ if log:
322
+ try:
323
+ import time
324
+ last_epoch = int(log)
325
+ now_epoch = int(time.time())
326
+ age_sec = now_epoch - last_epoch
327
+ age_min = age_sec // 60
328
+ age_hrs = age_sec // 3600
329
+ age_days = age_sec // 86400
330
+
331
+ if age_min < 1:
332
+ status["age_display"] = "now"
333
+ status["age_color"] = GIT_AGE_FRESH
334
+ elif age_hrs < 1:
335
+ status["age_display"] = f"{age_min}m"
336
+ status["age_color"] = GIT_AGE_FRESH
337
+ elif age_hrs < 24:
338
+ status["age_display"] = f"{age_hrs}h"
339
+ status["age_color"] = GIT_AGE_RECENT
340
+ elif age_days < 7:
341
+ status["age_display"] = f"{age_days}d"
342
+ status["age_color"] = GIT_AGE_STALE
343
+ else:
344
+ status["age_display"] = f"{age_days}d"
345
+ status["age_color"] = GIT_AGE_OLD
346
+ except (ValueError, TypeError):
347
+ pass
348
+
349
+ return status
350
+
351
+
352
+ def render_git(mode: str, git: Dict[str, Any], dir_name: str) -> None:
353
+ """Render the git status section."""
354
+ total_changed = git["modified"] + git["staged"]
355
+ status_icon = "*" if (total_changed > 0 or git["untracked"] > 0) else "\u2713"
356
+
357
+ if mode == "nano":
358
+ line = f"{GIT_PRIMARY}\u25C8{RESET} {GIT_DIR}{dir_name}{RESET} {GIT_VALUE}{git['branch']}{RESET} "
359
+ if status_icon == "\u2713":
360
+ line += f"{GIT_CLEAN}\u2713{RESET}"
361
+ else:
362
+ line += f"{GIT_MODIFIED}*{total_changed}{RESET}"
363
+ print(line)
364
+
365
+ elif mode == "micro":
366
+ line = f"{GIT_PRIMARY}\u25C8{RESET} {GIT_DIR}{dir_name}{RESET} {GIT_VALUE}{git['branch']}{RESET}"
367
+ if git["age_display"]:
368
+ line += f" {git['age_color']}{git['age_display']}{RESET}"
369
+ line += " "
370
+ if status_icon == "\u2713":
371
+ line += f"{GIT_CLEAN}{status_icon}{RESET}"
372
+ else:
373
+ line += f"{GIT_MODIFIED}{status_icon}{total_changed}{RESET}"
374
+ print(line)
375
+
376
+ elif mode == "mini":
377
+ line = (
378
+ f"{GIT_PRIMARY}\u25C8{RESET} {GIT_DIR}{dir_name}{RESET} "
379
+ f"{SLATE_600}\u2502{RESET} {GIT_VALUE}{git['branch']}{RESET}"
380
+ )
381
+ if git["age_display"]:
382
+ line += f" {SLATE_600}\u2502{RESET} {git['age_color']}{git['age_display']}{RESET}"
383
+ line += f" {SLATE_600}\u2502{RESET} "
384
+ if status_icon == "\u2713":
385
+ line += f"{GIT_CLEAN}{status_icon}{RESET}"
386
+ else:
387
+ line += f"{GIT_MODIFIED}{status_icon}{total_changed}{RESET}"
388
+ if git["untracked"] > 0:
389
+ line += f" {GIT_ADDED}+{git['untracked']}{RESET}"
390
+ print(line)
391
+
392
+ else: # normal
393
+ line = (
394
+ f"{GIT_PRIMARY}\u25C8{RESET} {GIT_PRIMARY}PWD:{RESET} {GIT_DIR}{dir_name}{RESET} "
395
+ f"{SLATE_600}\u2502{RESET} "
396
+ f"{GIT_PRIMARY}Branch:{RESET} {GIT_VALUE}{git['branch']}{RESET}"
397
+ )
398
+ if git["age_display"]:
399
+ line += f" {SLATE_600}\u2502{RESET} {GIT_PRIMARY}Age:{RESET} {git['age_color']}{git['age_display']}{RESET}"
400
+ if git["stash_count"] > 0:
401
+ line += f" {SLATE_600}\u2502{RESET} {GIT_PRIMARY}Stash:{RESET} {GIT_STASH}{git['stash_count']}{RESET}"
402
+
403
+ if total_changed > 0 or git["untracked"] > 0:
404
+ line += f" {SLATE_600}\u2502{RESET} "
405
+ if total_changed > 0:
406
+ line += f"{GIT_PRIMARY}Mod:{RESET} {GIT_MODIFIED}{total_changed}{RESET}"
407
+ if git["untracked"] > 0:
408
+ if total_changed > 0:
409
+ line += " "
410
+ line += f"{GIT_PRIMARY}New:{RESET} {GIT_ADDED}{git['untracked']}{RESET}"
411
+ else:
412
+ line += f" {SLATE_600}\u2502{RESET} {GIT_CLEAN}\u2713 clean{RESET}"
413
+
414
+ if git["ahead"] > 0 or git["behind"] > 0:
415
+ line += f" {SLATE_600}\u2502{RESET} {GIT_PRIMARY}Sync:{RESET} "
416
+ if git["ahead"] > 0:
417
+ line += f"{GIT_CLEAN}\u2191{git['ahead']}{RESET}"
418
+ if git["behind"] > 0:
419
+ line += f"{GIT_STASH}\u2193{git['behind']}{RESET}"
420
+ print(line)
421
+
422
+
423
+ # ---------------------------------------------------------------------------
424
+ # Context manager line (line 3)
425
+ # ---------------------------------------------------------------------------
426
+
427
+ def render_context_manager(
428
+ mode: str,
429
+ context_id: str,
430
+ context_state=None,
431
+ ) -> None:
432
+ """Render the context manager line (line 3) showing context ID, mode, and plan."""
433
+ # Strip YYMMDD-HHMM- timestamp prefix from context ID for display
434
+ display_id = re.sub(r"^\d{6}-\d{4}-", "", context_id)
435
+ if not display_id:
436
+ display_id = context_id # fallback if regex strips everything
437
+
438
+ # Truncate display_id per mode
439
+ max_id_len = {"nano": 14, "micro": 18, "mini": 22, "normal": 30}.get(mode, 30)
440
+ truncated_id = display_id[:max_id_len]
441
+ if len(display_id) > max_id_len:
442
+ truncated_id += "\u2026"
443
+
444
+ # Read state fields (ContextState object from context_store)
445
+ state_mode = getattr(context_state, "mode", "idle") if context_state else "idle"
446
+ state_plan_path = getattr(context_state, "plan_path", None) if context_state else None
447
+
448
+ # Detect plan mode heuristic: if state is idle but a recent plan file exists
449
+ # in ~/.claude/plans/, we're likely in active planning (transient, not persisted)
450
+ active_plan_file = _find_active_plan_file()
451
+ is_planning = state_mode == "idle" and active_plan_file is not None
452
+
453
+ # Build mode badge
454
+ mode_badge = ""
455
+ if is_planning:
456
+ label = "Plan" if mode == "nano" else "Planning"
457
+ mode_badge = f" {SLATE_600}\u2502{RESET} {CTX_SECONDARY}Mode:{RESET} {AMBER}{label}{RESET}"
458
+ elif state_mode == "has_plan":
459
+ label = "Ready" if mode == "nano" else "Plan Ready"
460
+ mode_badge = f" {SLATE_600}\u2502{RESET} {CTX_SECONDARY}Mode:{RESET} {EMERALD}{label}{RESET}"
461
+ elif state_mode == "active":
462
+ label = "Active" if mode == "nano" else "Active"
463
+ mode_badge = f" {SLATE_600}\u2502{RESET} {CTX_SECONDARY}Mode:{RESET} {CTX_ACCENT}{label}{RESET}"
464
+
465
+ # Resolve plan file path for display
466
+ plan_file_path = None
467
+ if is_planning:
468
+ plan_file_path = active_plan_file
469
+ elif state_plan_path:
470
+ plan_file_path = state_plan_path
471
+ elif state_mode in ("has_plan", "active"):
472
+ # Fallback: check context's plans/ folder
473
+ try:
474
+ from lib.context.plan_manager import find_latest_plan
475
+ plan_file_path = find_latest_plan(context_id)
476
+ except Exception:
477
+ pass
478
+
479
+ # Build plan name (mini/normal only)
480
+ plan_part = ""
481
+ if mode in ("mini", "normal") and plan_file_path:
482
+ plan_stem = re.sub(r"^\d{4}-\d{2}-\d{2}-", "", Path(plan_file_path).stem)
483
+ max_plan_len = 20 if mode == "mini" else 30
484
+ truncated_plan = plan_stem[:max_plan_len]
485
+ if len(plan_stem) > max_plan_len:
486
+ truncated_plan += "\u2026"
487
+ plan_part = f" {SLATE_600}\u2502{RESET} {CTX_SECONDARY}Plan:{RESET} {SLATE_300}{truncated_plan}{RESET}"
488
+
489
+ if mode == "nano":
490
+ print(
491
+ f"{CTX_ACCENT}\u25C6{RESET} {SLATE_400}{truncated_id}{RESET}"
492
+ f"{mode_badge}"
493
+ )
494
+ elif mode == "micro":
495
+ print(
496
+ f"{CTX_ACCENT}\u25C6{RESET} {SLATE_400}{truncated_id}{RESET}"
497
+ f"{mode_badge}"
498
+ )
499
+ elif mode == "mini":
500
+ print(
501
+ f"{CTX_ACCENT}\u25C6{RESET} {SLATE_400}{truncated_id}{RESET}"
502
+ f"{mode_badge}{plan_part}"
503
+ )
504
+ else: # normal
505
+ print(
506
+ f"{CTX_ACCENT}\u25C6{RESET} {CTX_SECONDARY}Context:{RESET} {SLATE_300}{truncated_id}{RESET}"
507
+ f"{mode_badge}{plan_part}"
508
+ )
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Context persistence
513
+ # ---------------------------------------------------------------------------
514
+
515
+ def _load_cache() -> Dict[str, Any]:
516
+ """Load the statusline cache file."""
517
+ try:
518
+ if STATUSLINE_CACHE.exists():
519
+ return json.loads(STATUSLINE_CACHE.read_text(encoding="utf-8"))
520
+ except Exception:
521
+ pass
522
+ return {}
523
+
524
+
525
+ def _save_cache(cache: Dict[str, Any]) -> None:
526
+ """Save the statusline cache file."""
527
+ try:
528
+ STATUSLINE_CACHE.parent.mkdir(parents=True, exist_ok=True)
529
+ STATUSLINE_CACHE.write_text(
530
+ json.dumps(cache, indent=2, ensure_ascii=False), encoding="utf-8"
531
+ )
532
+ except Exception:
533
+ pass
534
+
535
+
536
+ def _resolve_context_id(session_id: str) -> Optional[str]:
537
+ """Resolve session_id to context_id, using cache when possible."""
538
+ if not session_id or session_id == "unknown":
539
+ return None
540
+
541
+ # Check cache first
542
+ cache = _load_cache()
543
+ cached_entry = cache.get("sessions", {}).get(session_id)
544
+ if cached_entry and cached_entry.get("context_id") is not None:
545
+ return cached_entry["context_id"]
546
+
547
+ # Cache miss — look up via context manager
548
+ try:
549
+ from lib.context.context_store import get_context_by_session_id
550
+ context = get_context_by_session_id(session_id)
551
+ if context:
552
+ # Update cache
553
+ if "sessions" not in cache:
554
+ cache["sessions"] = {}
555
+ cache["sessions"][session_id] = {"context_id": context.id}
556
+ _save_cache(cache)
557
+ return context.id
558
+ except Exception:
559
+ pass
560
+
561
+ # Mark as no-context in cache to avoid repeated lookups
562
+ if "sessions" not in cache:
563
+ cache["sessions"] = {}
564
+ cache["sessions"][session_id] = {"context_id": None}
565
+ _save_cache(cache)
566
+ return None
567
+
568
+
569
+ def _load_context_state(context_id: str):
570
+ """Load context state from state.json (with context.json fallback)."""
571
+ try:
572
+ from lib.context.context_store import load_state
573
+ return load_state(context_id)
574
+ except Exception:
575
+ return None
576
+
577
+
578
+ def _find_active_plan_file() -> Optional[str]:
579
+ """Find most recent plan file in ~/.claude/plans/."""
580
+ try:
581
+ plans_dir = Path.home() / ".claude" / "plans"
582
+ if not plans_dir.exists():
583
+ return None
584
+ plan_files = list(plans_dir.glob("*.md"))
585
+ if not plan_files:
586
+ return None
587
+ plan_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
588
+ return str(plan_files[0])
589
+ except Exception:
590
+ return None
591
+
592
+
593
+ def _write_context_window(context_id: str, context_window_data: Dict[str, Any]) -> None:
594
+ """Write context_window data to state.json last_session."""
595
+ try:
596
+ from lib.context.context_store import get_context as get_ctx, save_state
597
+ state = get_ctx(context_id)
598
+ if state:
599
+ if state.last_session is None:
600
+ state.last_session = {}
601
+ state.last_session["context_remaining_pct"] = context_window_data.get("remaining_percentage")
602
+ save_state(state)
603
+ except Exception:
604
+ pass
605
+
606
+
607
+ # ---------------------------------------------------------------------------
608
+ # Main
609
+ # ---------------------------------------------------------------------------
610
+
611
+ def main() -> None:
612
+ """Read stdin JSON, render status line, optionally persist context data."""
613
+ # Force UTF-8 stdout on Windows to support Unicode symbols
614
+ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
615
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
616
+
617
+ # Read JSON from stdin
618
+ try:
619
+ input_data = json.loads(sys.stdin.read())
620
+ except Exception:
621
+ input_data = {}
622
+
623
+ # Terminal width and mode
624
+ term_width = get_terminal_width()
625
+ mode = get_display_mode(term_width)
626
+
627
+ # Extract input fields
628
+ session_id = input_data.get("session_id", "")
629
+ model_name = (input_data.get("model") or {}).get("display_name", "unknown")
630
+ cost = input_data.get("cost") or {}
631
+ duration_ms = cost.get("total_duration_ms", 0)
632
+ workspace = input_data.get("workspace") or {}
633
+ current_dir = workspace.get("project_dir", os.getcwd())
634
+ dir_name = os.path.basename(current_dir)
635
+
636
+ # Context window data
637
+ ctx_win = input_data.get("context_window") or {}
638
+ usage = ctx_win.get("current_usage") or {}
639
+ cache_read = usage.get("cache_read_input_tokens", 0)
640
+ input_tokens = usage.get("input_tokens", 0)
641
+ cache_creation = usage.get("cache_creation_input_tokens", 0)
642
+ output_tokens = usage.get("output_tokens", 0)
643
+ context_max = ctx_win.get("context_window_size", 200000)
644
+
645
+ # Calculate context percentage
646
+ # Use used_percentage if available (pre-calculated), else raw tokens + baseline
647
+ used_pct = ctx_win.get("used_percentage")
648
+ if used_pct is not None:
649
+ context_pct = int(used_pct)
650
+ total_input = cache_read + input_tokens + cache_creation
651
+ context_used = total_input + output_tokens + CONTEXT_BASELINE_TOKENS
652
+ else:
653
+ total_input = cache_read + input_tokens + cache_creation
654
+ context_used = total_input + output_tokens + CONTEXT_BASELINE_TOKENS
655
+ context_pct = (context_used * 100) // context_max if context_max > 0 else 0
656
+
657
+ context_k = context_used // 1000
658
+ max_k = context_max // 1000
659
+
660
+ # Format duration
661
+ duration_sec = duration_ms // 1000
662
+ if duration_sec >= 3600:
663
+ time_display = f"{duration_sec // 3600}h{(duration_sec % 3600) // 60}m"
664
+ elif duration_sec >= 60:
665
+ time_display = f"{duration_sec // 60}m{duration_sec % 60}s"
666
+ else:
667
+ time_display = f"{duration_sec}s"
668
+
669
+ # Resolve context ID for display and persistence
670
+ context_id = _resolve_context_id(session_id)
671
+
672
+ # Render context section
673
+ render_context(mode, context_pct, context_k, max_k, time_display, model_name)
674
+
675
+ # Render git section
676
+ git = get_git_status(current_dir)
677
+ if git:
678
+ render_git(mode, git, dir_name)
679
+
680
+ # Render context manager line (line 3) with separator
681
+ if context_id:
682
+ print(SEPARATOR)
683
+ context_state = _load_context_state(context_id)
684
+ render_context_manager(mode, context_id, context_state)
685
+
686
+ # Persist context_window to state.json
687
+ if context_id:
688
+ _write_context_window(context_id, {
689
+ "used_percentage": context_pct,
690
+ "remaining_percentage": 100 - context_pct,
691
+ "context_window_size": context_max,
692
+ "tokens_used": context_used,
693
+ "total_input_tokens": total_input,
694
+ "total_output_tokens": output_tokens,
695
+ "model": model_name,
696
+ "last_updated": datetime.now().isoformat(timespec="seconds"),
697
+ })
698
+
699
+
700
+ if __name__ == "__main__":
701
+ main()