multi-forge 0.2.0__py3-none-any.whl

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 (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,1398 @@
1
+ """Status line command for Claude Code.
2
+
3
+ Invoked by Claude Code's statusLine setting. Reads JSON from stdin,
4
+ produces a formatted status line to stdout.
5
+
6
+ Layout (5 categories):
7
+ Where | Who | What | Metrics | State
8
+ path (branch) | breadcrumb | template [Model] ctx_bar | cost dur | +12/-3 | in:12K out:3K cache:8K | THINK | LOOP N/M | SC
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ import unicodedata
22
+ from pathlib import Path
23
+ from typing import Any, NamedTuple
24
+
25
+ import click
26
+
27
+ # Set up minimal logging for status line (stderr to avoid polluting stdout)
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # ANSI color codes
31
+ RED = "\033[31m"
32
+ RED_BOLD = "\033[31;1m"
33
+ LIGHT_RED = "\033[91m"
34
+ YELLOW = "\033[33m"
35
+ YELLOW_BOLD = "\033[33;1m"
36
+ GREEN = "\033[32m"
37
+ GREEN_BOLD = "\033[32;1m"
38
+ PURPLE = "\033[35m"
39
+ BLUE = "\033[94m"
40
+ BREADCRUMB_COLOR = "\033[38;5;139m" # dusty plum
41
+ TEMPLATE_COLOR = "\033[38;5;60m" # deep blue-gray
42
+ METRICS_COLOR = "\033[38;5;145m" # cool grey
43
+
44
+ # Context bar gradient (Gradient E: soft green → warm → hot)
45
+ CTX_LOW = "\033[38;5;115m" # soft green (<25%)
46
+ CTX_MED = "\033[38;5;150m" # light olive (25-49%)
47
+ CTX_HIGH = "\033[38;5;179m" # warm gold (50-74%)
48
+ CTX_WARN = "\033[38;5;173m" # burnt orange (75-89%)
49
+ CTX_CRIT = "\033[38;5;167m" # hot coral (90-100%)
50
+ BOLD = "\033[1m"
51
+
52
+ # Per-tier model colors (Option 4: navy family)
53
+ # 1M variants use a deeper shade of the same hue
54
+ TIER_HAIKU = "\033[38;5;67m" # steel blue
55
+ TIER_SONNET = "\033[38;5;69m" # cornflower
56
+ TIER_SONNET_DEEP = "\033[38;5;26m" # deeper cornflower (1M context)
57
+ TIER_OPUS = "\033[38;5;75m" # vivid blue
58
+ TIER_OPUS_DEEP = "\033[38;5;32m" # deeper vivid blue (1M context)
59
+ DARK_GRAY = "\033[90m"
60
+ DIM = "\033[2m"
61
+ RESET = "\033[0m"
62
+
63
+ # ASCII display characters
64
+ PROGRESS_FILLED = "#"
65
+ PROGRESS_EMPTY = "-"
66
+
67
+ # Separator
68
+ SEP = f"{DARK_GRAY}|{RESET}"
69
+
70
+ # ASCII status indicators
71
+ THINKING_INDICATOR = "THINK"
72
+ VERIFICATION_INDICATOR = "LOOP"
73
+ SIDECAR_INDICATOR = "SC"
74
+ TOKEN_INPUT_LABEL = "in:"
75
+ TOKEN_OUTPUT_LABEL = "out:"
76
+ TOKEN_CACHE_LABEL = "cache:"
77
+ LINE_ADD_COLOR = "\033[38;5;28m"
78
+ LINE_REMOVE_COLOR = "\033[38;5;124m"
79
+
80
+ # Trailing margin width (non-breaking spaces) to prevent merging with Claude Code's
81
+ # native status display when rendered adjacent to custom statusLine output
82
+ TRAILING_MARGIN = 3
83
+
84
+ # Reserve for Claude Code's native token display (e.g., " 97595 tokens") appended
85
+ # to line 1. ccstatusline defaults to subtracting 40; we use a tighter estimate.
86
+ NATIVE_DISPLAY_RESERVE = 15
87
+
88
+ # Fallback terminal width when /dev/tty and COLUMNS are both unavailable.
89
+ # Conservative: "too narrow = mild truncation" is better than "too wide = wrapping bug".
90
+ DEFAULT_TERM_WIDTH = 80
91
+
92
+ # Separator as it appears in hardened output (spaces → NBSPs)
93
+ _HARDENED_SEP = f"\u00a0{SEP}\u00a0"
94
+
95
+
96
+ def _get_terminal_width() -> int:
97
+ """Get terminal width, even when stdout is piped.
98
+
99
+ Claude Code always pipes to statusLine commands, so os.get_terminal_size()
100
+ on stdout fails. Instead, open /dev/tty (the controlling terminal) directly
101
+ to query the real width. Falls back to COLUMNS env var, then DEFAULT_TERM_WIDTH.
102
+ """
103
+ try:
104
+ fd = os.open("/dev/tty", os.O_RDONLY)
105
+ try:
106
+ return os.get_terminal_size(fd).columns
107
+ finally:
108
+ os.close(fd)
109
+ except (OSError, ValueError):
110
+ pass
111
+ return shutil.get_terminal_size(fallback=(DEFAULT_TERM_WIDTH, 24)).columns
112
+
113
+
114
+ def _status_timeout() -> float:
115
+ from forge.runtime_config import get_runtime_config
116
+
117
+ return get_runtime_config().status_timeout
118
+
119
+
120
+ def compact_model_name(model: str) -> str:
121
+ """Strip provider prefix and shorten model names for display.
122
+
123
+ Delegates to the model catalog for short_name overrides, with generic
124
+ rules (prefix stripping, -preview removal) for models not in the catalog.
125
+ """
126
+ from forge.core.models import get_compact_name
127
+
128
+ return get_compact_name(model)
129
+
130
+
131
+ class ProxyRuntimeTruth:
132
+ """Structured proxy runtime truth from GET / endpoint."""
133
+
134
+ def __init__(self, raw: dict[str, Any]):
135
+ self.raw = raw
136
+ self.is_proxy = raw.get("is_proxy", False)
137
+
138
+ # Proxy identity (B2.1)
139
+ proxy = raw.get("proxy", {})
140
+ self.proxy_id = proxy.get("proxy_id")
141
+ self.template = proxy.get("template") or raw.get("template", "unknown")
142
+ self.port = proxy.get("port")
143
+ self.base_url = proxy.get("base_url")
144
+
145
+ # Runtime truth
146
+ runtime = raw.get("runtime", {})
147
+ self.active_tier = runtime.get("active_tier")
148
+ self.active_context_window = runtime.get("active_context_window")
149
+ self.context_windows = runtime.get("context_windows", {})
150
+ self.tier_mappings = runtime.get("tier_mappings", {})
151
+
152
+ # Older proxy response shape (system boundary: proxy HTTP response)
153
+ self.tiers = raw.get("tiers", {})
154
+
155
+ def get_context_window_for_tier(self, tier: str) -> int | None:
156
+ """Get context window for a tier, preferring runtime truth."""
157
+ # Prefer runtime.context_windows (authoritative)
158
+ if tier in self.context_windows:
159
+ return self.context_windows[tier]
160
+ # Fallback: older proxy response shape (system boundary)
161
+ tier_info = self.tiers.get(tier, {})
162
+ return tier_info.get("context_window")
163
+
164
+ @property
165
+ def proxy_cost_usd(self) -> float:
166
+ """Total estimated proxy cost in USD from metrics snapshot."""
167
+ metrics = self.raw.get("metrics", {})
168
+ costs = metrics.get("costs", {})
169
+ return costs.get("total_usd", 0.0)
170
+
171
+
172
+ def detect_proxy() -> tuple[bool, ProxyRuntimeTruth | None, bool]:
173
+ """Detect if using a proxy and fetch its runtime truth.
174
+
175
+ Returns:
176
+ Tuple of (is_proxy, runtime_truth_or_none, is_authoritative).
177
+ - is_authoritative=True means live proxy GET / succeeded
178
+ - is_authoritative=False means we fell back to registry lookup
179
+ """
180
+ base_url = os.environ.get("ANTHROPIC_BASE_URL", "")
181
+ if not base_url:
182
+ return False, None, False
183
+
184
+ # Parse as URL — works for any host, not just localhost (CR-016)
185
+ from urllib.parse import urlparse
186
+
187
+ # Normalize scheme-less URLs (e.g., "localhost:8085" → "http://localhost:8085")
188
+ normalized = base_url if "://" in base_url else f"http://{base_url}"
189
+ parsed = urlparse(normalized)
190
+ if not parsed.hostname:
191
+ return False, None, False
192
+
193
+ # Try live proxy query first (authoritative)
194
+ # Use scheme://netloc/ to strip any path (proxy serves identity at /)
195
+ try:
196
+ import urllib.request
197
+
198
+ query_url = f"{parsed.scheme}://{parsed.netloc}/"
199
+ with urllib.request.urlopen(query_url, timeout=_status_timeout()) as response:
200
+ proxy_info = json.loads(response.read())
201
+
202
+ if proxy_info.get("is_proxy") is True:
203
+ return True, ProxyRuntimeTruth(proxy_info), True # authoritative
204
+ except Exception:
205
+ pass
206
+
207
+ # Fallback: reverse lookup from proxy registry (non-authoritative)
208
+ try:
209
+ from forge.proxy.proxies import ProxyRegistryStore
210
+
211
+ store = ProxyRegistryStore()
212
+ registry = store.read()
213
+
214
+ # Match by port when available, or by full netloc
215
+ target_port = parsed.port
216
+ for proxy_id, entry in registry.proxies.items():
217
+ entry_normalized = entry.base_url if "://" in (entry.base_url or "") else f"http://{entry.base_url or ''}"
218
+ entry_parsed = urlparse(entry_normalized)
219
+ match = (target_port is not None and entry_parsed.port == target_port) or (
220
+ target_port is None and parsed.netloc == entry_parsed.netloc
221
+ )
222
+ if match:
223
+ runtime_dict: dict[str, Any] = {}
224
+ try:
225
+ from forge.config.loader import load_proxy_instance_config
226
+ from forge.core.models import get_context_window_tokens
227
+
228
+ proxy_config = load_proxy_instance_config(proxy_id)
229
+ if proxy_config is not None:
230
+ tier_models = {
231
+ t: m
232
+ for t, m in [
233
+ ("haiku", proxy_config.tiers.haiku),
234
+ ("sonnet", proxy_config.tiers.sonnet),
235
+ ("opus", proxy_config.tiers.opus),
236
+ ]
237
+ if m
238
+ }
239
+ context_windows: dict[str, int] = {}
240
+ for tier, model in tier_models.items():
241
+ try:
242
+ context_windows[tier] = get_context_window_tokens(model)
243
+ except Exception:
244
+ pass
245
+ active_tier = proxy_config.default_tier or "sonnet"
246
+ active_cw = context_windows.get(active_tier) or context_windows.get("sonnet")
247
+ runtime_dict = {
248
+ "tier_mappings": tier_models,
249
+ "context_windows": context_windows,
250
+ "active_tier": active_tier,
251
+ "active_context_window": active_cw,
252
+ }
253
+ except Exception:
254
+ pass
255
+
256
+ fallback_info = {
257
+ "is_proxy": True,
258
+ "proxy": {
259
+ "proxy_id": proxy_id,
260
+ "template": entry.template,
261
+ "port": entry.port,
262
+ "base_url": entry.base_url,
263
+ },
264
+ "runtime": runtime_dict,
265
+ "tiers": {},
266
+ }
267
+ return (
268
+ True,
269
+ ProxyRuntimeTruth(fallback_info),
270
+ False,
271
+ ) # non-authoritative
272
+ except Exception:
273
+ pass
274
+
275
+ return False, None, False
276
+
277
+
278
+ def _tier_color(tier: str, runtime: ProxyRuntimeTruth | None) -> str:
279
+ """Pick color for a tier, using deep variant for extended context (>200K)."""
280
+ extended = False
281
+ if runtime:
282
+ ctx = runtime.get_context_window_for_tier(tier)
283
+ if ctx and ctx > 200_000:
284
+ extended = True
285
+
286
+ if tier == "opus":
287
+ return TIER_OPUS_DEEP if extended else TIER_OPUS
288
+ elif tier == "sonnet":
289
+ return TIER_SONNET_DEEP if extended else TIER_SONNET
290
+ return TIER_HAIKU
291
+
292
+
293
+ def get_tier_display(runtime: ProxyRuntimeTruth | None) -> str | None:
294
+ """Get tier display string showing all mappings.
295
+
296
+ Format: "O:model S:model H:model" with per-tier coloring.
297
+ """
298
+ if runtime is None:
299
+ return None
300
+
301
+ # Prefer runtime.tier_mappings (authoritative), fallback to legacy tiers
302
+ tier_mappings = runtime.tier_mappings
303
+ if not tier_mappings:
304
+ tier_mappings = {k: v.get("model", "") for k, v in runtime.tiers.items()}
305
+
306
+ if not tier_mappings:
307
+ return None
308
+
309
+ h_model = tier_mappings.get("haiku", "")
310
+ s_model = tier_mappings.get("sonnet", "")
311
+ o_model = tier_mappings.get("opus", "")
312
+
313
+ if not any([h_model, s_model, o_model]):
314
+ return None
315
+
316
+ h_name = compact_model_name(h_model)
317
+ s_name = compact_model_name(s_model)
318
+ o_name = compact_model_name(o_model)
319
+
320
+ oc = _tier_color("opus", runtime)
321
+ sc = _tier_color("sonnet", runtime)
322
+ hc = _tier_color("haiku", runtime)
323
+
324
+ return f"{oc}O:{o_name}{RESET} {sc}S:{s_name}{RESET} {hc}H:{h_name}{RESET}"
325
+
326
+
327
+ # Context window info is sourced from:
328
+ # 1. Proxy runtime truth (GET /) when using proxy - authoritative from core.models catalog
329
+ # 2. Claude Code's JSON input (context_window field) when not using proxy
330
+ # No hardcoded fallback tables - unknown models will show context from Claude Code's input
331
+
332
+
333
+ def get_tier_from_display_name(display_name: str) -> str:
334
+ """Map Claude Code's display name to tier."""
335
+ display_lower = display_name.lower()
336
+ if "opus" in display_lower:
337
+ return "opus"
338
+ elif "sonnet" in display_lower:
339
+ return "sonnet"
340
+ elif "haiku" in display_lower:
341
+ return "haiku"
342
+ return "sonnet"
343
+
344
+
345
+ class TranscriptStats(NamedTuple):
346
+ """Results from single-pass transcript scan."""
347
+
348
+ has_thinking: bool = False
349
+ user_count: int = 0
350
+ tool_count: int = 0
351
+ input_tokens: int = 0
352
+ output_tokens: int = 0
353
+ cached_tokens: int = 0
354
+
355
+
356
+ _EMPTY_STATS = TranscriptStats()
357
+
358
+ # Cache transcript stats by (path, mtime_ns, size) to skip re-scanning unchanged files (CR-017).
359
+ _transcript_cache: dict[str, tuple[int, int, TranscriptStats]] = {}
360
+
361
+
362
+ def _cached_scan_transcript(transcript_path: str) -> TranscriptStats:
363
+ """Scan transcript with file-identity caching.
364
+
365
+ Returns cached stats if the file's mtime_ns and size haven't changed.
366
+ """
367
+ if not transcript_path:
368
+ return _EMPTY_STATS
369
+
370
+ try:
371
+ st = Path(transcript_path).stat()
372
+ key = (st.st_mtime_ns, st.st_size)
373
+ except OSError:
374
+ return _EMPTY_STATS
375
+
376
+ cached = _transcript_cache.get(transcript_path)
377
+ if cached is not None and (cached[0], cached[1]) == key:
378
+ return cached[2]
379
+
380
+ stats = scan_transcript(transcript_path)
381
+ _transcript_cache[transcript_path] = (key[0], key[1], stats)
382
+ return stats
383
+
384
+
385
+ def _resolve_entry_role(entry: dict[str, Any]) -> str | None:
386
+ """Resolve entry role from either transcript format.
387
+
388
+ Old format: top-level "type" field ("user" | "assistant")
389
+ New format: "message.role" field ("user" | "assistant")
390
+ """
391
+ # Old format: entry.type
392
+ entry_type = entry.get("type")
393
+ if entry_type in ("user", "assistant"):
394
+ return entry_type
395
+ # New format: entry.message.role
396
+ return entry.get("message", {}).get("role")
397
+
398
+
399
+ def scan_transcript(transcript_path: str) -> TranscriptStats:
400
+ """Single-pass transcript scan for thinking, counts, and token metrics.
401
+
402
+ Supports both transcript formats:
403
+ - Old: top-level "type" field ("user" | "assistant")
404
+ - New: "message.role" field (requestId-based, newer Claude Code)
405
+
406
+ Extracts in one pass: thinking indicator, user turn count, tool call count,
407
+ and cumulative token usage (input/output/cached) from message.usage fields.
408
+ """
409
+ if not transcript_path:
410
+ return _EMPTY_STATS
411
+
412
+ path = Path(transcript_path)
413
+ if not path.is_file():
414
+ return _EMPTY_STATS
415
+
416
+ user_count = 0
417
+ tool_count = 0
418
+ input_tokens = 0
419
+ output_tokens = 0
420
+ cached_tokens = 0
421
+ last_assistant_content: list[Any] | None = None
422
+
423
+ try:
424
+ with path.open(encoding="utf-8") as f:
425
+ for line in f:
426
+ line = line.strip()
427
+ if not line:
428
+ continue
429
+ try:
430
+ entry = json.loads(line)
431
+ role = _resolve_entry_role(entry)
432
+
433
+ if role == "user":
434
+ # In new format, tool_result messages also have role=user;
435
+ # only count actual human turns (no tool_result content)
436
+ content = entry.get("message", {}).get("content", [])
437
+ is_tool_result = isinstance(content, list) and any(
438
+ isinstance(b, dict) and b.get("type") == "tool_result" for b in content
439
+ )
440
+ if not is_tool_result:
441
+ user_count += 1
442
+ elif role == "assistant":
443
+ content = entry.get("message", {}).get("content", [])
444
+ last_assistant_content = content
445
+ for block in content:
446
+ if isinstance(block, dict) and block.get("type") == "tool_use":
447
+ tool_count += 1
448
+
449
+ # Accumulate token usage from any entry with message.usage
450
+ usage = entry.get("message", {}).get("usage")
451
+ if usage:
452
+ input_tokens += usage.get("input_tokens", 0)
453
+ output_tokens += usage.get("output_tokens", 0)
454
+ cached_tokens += usage.get("cache_read_input_tokens", 0)
455
+ cached_tokens += usage.get("cache_creation_input_tokens", 0)
456
+ except json.JSONDecodeError:
457
+ continue
458
+ except Exception:
459
+ return _EMPTY_STATS
460
+
461
+ has_thinking = False
462
+ if last_assistant_content:
463
+ for block in last_assistant_content:
464
+ if isinstance(block, dict) and block.get("type") == "thinking":
465
+ has_thinking = True
466
+ break
467
+
468
+ return TranscriptStats(
469
+ has_thinking=has_thinking,
470
+ user_count=user_count,
471
+ tool_count=tool_count,
472
+ input_tokens=input_tokens,
473
+ output_tokens=output_tokens,
474
+ cached_tokens=cached_tokens,
475
+ )
476
+
477
+
478
+ def parse_context_from_json(data: dict[str, Any]) -> dict[str, Any] | None:
479
+ """Parse context usage from Claude Code's JSON input.
480
+
481
+ Uses the official context_window field from Claude Code's status line contract.
482
+
483
+ Expected format:
484
+ context_window:
485
+ context_window_size: 200000
486
+ current_usage:
487
+ input_tokens: 8500
488
+ cache_creation_input_tokens: 5000
489
+ cache_read_input_tokens: 2000
490
+ """
491
+ context_window_data = data.get("context_window")
492
+ if not context_window_data:
493
+ return None
494
+
495
+ # Claude Code sends context_window as int (just the size) or dict (size + usage).
496
+ # When it's an int there's no usage breakdown to display.
497
+ if isinstance(context_window_data, (int, float)):
498
+ return None
499
+
500
+ context_window_size = context_window_data.get("context_window_size", 0)
501
+ if not context_window_size or context_window_size <= 0:
502
+ return None
503
+
504
+ current_usage = context_window_data.get("current_usage") or {}
505
+
506
+ # Calculate current context from current_usage fields
507
+ input_tokens = current_usage.get("input_tokens", 0)
508
+ cache_creation = current_usage.get("cache_creation_input_tokens", 0)
509
+ cache_read = current_usage.get("cache_read_input_tokens", 0)
510
+ total_tokens = input_tokens + cache_creation + cache_read
511
+
512
+ used_percentage = context_window_data.get("used_percentage")
513
+ if used_percentage is None and total_tokens <= 0:
514
+ return None
515
+
516
+ if used_percentage is not None:
517
+ percent_used = min(100, int(used_percentage))
518
+ # Back-compute tokens from percentage so proxy override path stays consistent
519
+ if total_tokens <= 0:
520
+ total_tokens = int(context_window_size * used_percentage / 100)
521
+ else:
522
+ percent_used = min(100, int((total_tokens / context_window_size) * 100))
523
+
524
+ return {
525
+ "percent": percent_used,
526
+ "tokens": total_tokens,
527
+ "context_window": context_window_size,
528
+ }
529
+
530
+
531
+ def get_effective_context_window(
532
+ data: dict[str, Any], runtime: ProxyRuntimeTruth | None, context_info: dict[str, Any] | None
533
+ ) -> int | None:
534
+ """Resolve the best-known context window size for display."""
535
+ if runtime and runtime.active_context_window:
536
+ return runtime.active_context_window
537
+
538
+ if context_info:
539
+ context_window = context_info.get("context_window", 0)
540
+ if context_window > 0:
541
+ return context_window
542
+
543
+ context_window_data = data.get("context_window")
544
+ if isinstance(context_window_data, dict):
545
+ context_window_size = context_window_data.get("context_window_size", 0)
546
+ if context_window_size > 0:
547
+ return context_window_size
548
+ if isinstance(context_window_data, (int, float)) and context_window_data > 0:
549
+ return int(context_window_data)
550
+
551
+ return None
552
+
553
+
554
+ def format_model_label(display_name: str, context_window: int | None) -> str:
555
+ """Clean Claude's display name and append non-default context size when useful."""
556
+ base_name = re.sub(r"\s*\([^)]*context[^)]*\)", "", display_name).strip()
557
+ if context_window and context_window > 200_000:
558
+ return f"{base_name} ({format_context_size(context_window)})"
559
+ return base_name
560
+
561
+
562
+ def format_context_size(size: int) -> str:
563
+ """Format context window size for display (e.g., 2097152 -> "2M")."""
564
+ if size >= 1_000_000:
565
+ millions = size // 1_000_000
566
+ remainder = (size % 1_000_000) // 100_000
567
+ if remainder > 0:
568
+ return f"{millions}.{remainder}M"
569
+ return f"{millions}M"
570
+ elif size >= 1000:
571
+ return f"{size // 1000}K"
572
+ return str(size)
573
+
574
+
575
+ def get_context_display(context_info: dict[str, Any] | None) -> str:
576
+ """Generate context display with progress bar."""
577
+ if not context_info:
578
+ return f"{DARK_GRAY}---{RESET}"
579
+
580
+ percent = context_info.get("percent", 0)
581
+ warning = context_info.get("warning", "")
582
+ context_window = context_info.get("context_window", 0)
583
+
584
+ # 5-step gradient with wider bands at extremes (2/7, 1/7, 1/7, 1/7, 2/7).
585
+ # Auto-compact fires around 80% so the warning zone starts early at 57%.
586
+ if percent >= 72:
587
+ color = CTX_CRIT
588
+ alert = "!"
589
+ elif percent >= 57:
590
+ color = CTX_WARN
591
+ alert = ""
592
+ elif percent >= 43:
593
+ color = CTX_HIGH
594
+ alert = ""
595
+ elif percent >= 29:
596
+ color = CTX_MED
597
+ alert = ""
598
+ else:
599
+ color = CTX_LOW
600
+ alert = ""
601
+
602
+ segments = 8
603
+ filled = percent * segments // 100
604
+ empty = segments - filled
605
+ bar = PROGRESS_FILLED * filled + PROGRESS_EMPTY * empty
606
+
607
+ # Warning overrides
608
+ if warning == "auto-compact":
609
+ alert = "AC"
610
+ elif warning == "low":
611
+ alert = "!"
612
+
613
+ if context_window > 0:
614
+ size_str = format_context_size(context_window)
615
+ alert_str = f" {alert}" if alert else ""
616
+ return f"{color}{bar} {percent}%/{BOLD}{size_str}{alert_str}{RESET}"
617
+ else:
618
+ alert_str = f" {alert}" if alert else ""
619
+ return f"{color}{bar} {percent}%{BOLD}{alert_str}{RESET}"
620
+
621
+
622
+ def get_session_metrics(
623
+ cost_data: dict[str, Any],
624
+ is_proxy: bool,
625
+ proxy_cost_usd: float = 0.0,
626
+ ) -> str | None:
627
+ """Get session metrics (cost, duration). Returns bare string or None."""
628
+ if not cost_data and proxy_cost_usd <= 0:
629
+ return None
630
+
631
+ metrics: list[str] = []
632
+
633
+ if is_proxy and proxy_cost_usd > 0:
634
+ cost_color = METRICS_COLOR
635
+ if proxy_cost_usd < 0.01:
636
+ cost_str = f"~{int(proxy_cost_usd * 10000) / 100}c"
637
+ else:
638
+ cost_str = f"~${proxy_cost_usd:.2f}"
639
+ metrics.append(f"{cost_color}{cost_str}{RESET}")
640
+ elif not is_proxy:
641
+ cost_usd = (cost_data or {}).get("total_cost_usd", 0)
642
+ if cost_usd > 0:
643
+ cost_color = METRICS_COLOR
644
+
645
+ if cost_usd < 0.01:
646
+ cost_str = f"{int(cost_usd * 100)}c"
647
+ else:
648
+ cost_str = f"${cost_usd:.2f}"
649
+
650
+ metrics.append(f"{cost_color}{cost_str}{RESET}")
651
+
652
+ # Duration
653
+ duration_ms = cost_data.get("total_duration_ms", 0)
654
+ if duration_ms > 0:
655
+ minutes = duration_ms // 60000
656
+
657
+ if minutes >= 30:
658
+ duration_color = YELLOW
659
+ else:
660
+ duration_color = METRICS_COLOR
661
+
662
+ if duration_ms < 60000:
663
+ duration_str = f"{duration_ms // 1000}s"
664
+ else:
665
+ duration_str = f"{minutes}m"
666
+
667
+ metrics.append(f"{duration_color}{duration_str}{RESET}")
668
+
669
+ return " ".join(metrics) if metrics else None
670
+
671
+
672
+ def get_git_branch(current_dir: str) -> str | None:
673
+ """Get git branch name for directory."""
674
+ if not current_dir:
675
+ return None
676
+
677
+ try:
678
+ # Try symbolic-ref first (for normal branches)
679
+ timeout = _status_timeout()
680
+ result = subprocess.run(
681
+ ["git", "-C", current_dir, "symbolic-ref", "--short", "HEAD"],
682
+ capture_output=True,
683
+ text=True,
684
+ timeout=timeout,
685
+ )
686
+ if result.returncode == 0:
687
+ return result.stdout.strip()
688
+
689
+ # Fall back to rev-parse for detached HEAD
690
+ result = subprocess.run(
691
+ ["git", "-C", current_dir, "rev-parse", "--short", "HEAD"],
692
+ capture_output=True,
693
+ text=True,
694
+ timeout=timeout,
695
+ )
696
+ if result.returncode == 0:
697
+ return result.stdout.strip()
698
+ except Exception:
699
+ pass
700
+
701
+ return None
702
+
703
+
704
+ def get_compact_path(current_dir: str) -> str:
705
+ """Create compact path: project/.../dir."""
706
+ if not current_dir:
707
+ return ""
708
+
709
+ home = str(Path.home())
710
+ workspace_path = os.path.join(home, "workspace")
711
+
712
+ if current_dir.startswith(workspace_path + "/"):
713
+ rel_path = current_dir[len(workspace_path) + 1 :]
714
+ parts = rel_path.split("/")
715
+ num_parts = len(parts)
716
+
717
+ if num_parts == 1:
718
+ return parts[0]
719
+ elif num_parts == 2:
720
+ return f"{parts[0]}/{parts[-1]}"
721
+ else:
722
+ return f"{parts[0]}/.../{parts[-1]}"
723
+ else:
724
+ # Outside workspace, use ~ substitution
725
+ if current_dir.startswith(home):
726
+ return "~" + current_dir[len(home) :]
727
+ return current_dir
728
+
729
+
730
+ # --- Formatting helpers ---
731
+
732
+ # Breadcrumb separator
733
+ BREADCRUMB_SEP = " > "
734
+ BREADCRUMB_ELISION = "..."
735
+
736
+ # Terminal states where verification loop has ended (no indicator needed).
737
+ # "error" is intentionally excluded — a broken verifier is actionable info.
738
+ _VERIFICATION_TERMINAL = {
739
+ "passed",
740
+ "max_iterations",
741
+ "max_minutes",
742
+ "bypassed",
743
+ "warned",
744
+ }
745
+
746
+ # ANSI escape sequence regex for stripping/preserving color codes
747
+ _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
748
+
749
+
750
+ def _char_width(c: str) -> int:
751
+ """Return terminal display width of a single character.
752
+
753
+ Handles emoji (2 cols), variation selectors and combining marks (0 cols),
754
+ and East Asian wide/fullwidth characters (2 cols).
755
+ """
756
+ cp = ord(c)
757
+ # Zero-width: variation selectors, ZWJ, ZWNJ
758
+ if cp in (0xFE0E, 0xFE0F, 0x200D, 0x200C):
759
+ return 0
760
+ cat = unicodedata.category(c)
761
+ if cat.startswith("M"): # Combining marks
762
+ return 0
763
+ # Supplementary characters (most emoji live here)
764
+ if cp >= 0x10000:
765
+ return 2
766
+ eaw = unicodedata.east_asian_width(c)
767
+ if eaw in ("W", "F"):
768
+ return 2
769
+ return 1
770
+
771
+
772
+ def _visible_width(text: str) -> int:
773
+ """Return terminal display width of text, stripping ANSI and counting Unicode correctly.
774
+
775
+ Key difference from len(): emoji like 🧠 count as 2 columns,
776
+ and variation selectors (U+FE0F) after BMP characters add 1 extra column
777
+ (BMP char goes from 1-col text to 2-col emoji presentation).
778
+ """
779
+ stripped = _ANSI_RE.sub("", text)
780
+ width = 0
781
+ prev_cp = 0
782
+ for c in stripped:
783
+ cp = ord(c)
784
+ # VS16 after a narrow BMP char → upgrade previous char to emoji width
785
+ if cp == 0xFE0F and 0 < prev_cp < 0x10000:
786
+ eaw = unicodedata.east_asian_width(chr(prev_cp))
787
+ if eaw not in ("W", "F"):
788
+ width += 1 # was counted as 1, should be 2
789
+ prev_cp = cp
790
+ continue
791
+ w = _char_width(c)
792
+ width += w
793
+ if w > 0:
794
+ prev_cp = cp
795
+ return width
796
+
797
+
798
+ def format_tokens(count: int) -> str:
799
+ """Format token count compactly: 1.2M / 12.5K / 42."""
800
+ if count >= 1_000_000:
801
+ return f"{count / 1_000_000:.1f}M"
802
+ if count >= 1000:
803
+ return f"{count / 1000:.1f}K"
804
+ return str(count)
805
+
806
+
807
+ def format_breadcrumb(manifest: dict[str, Any], is_authoritative: bool) -> str | None:
808
+ """Format session lineage as breadcrumb: origin > ... > parent > current.
809
+
810
+ Rules (max 3 crumbs):
811
+ - No lineage → session_name
812
+ - 1 ancestor -> parent > current
813
+ - 2 ancestors -> origin > parent > current
814
+ - 3+ ancestors -> origin > ... > parent > current
815
+
816
+ lineage field is [parent, grandparent, ...] (nearest first).
817
+ """
818
+ session_name = manifest.get("name", "")
819
+ if not session_name:
820
+ return None
821
+
822
+ derivation = manifest.get("confirmed", {}).get("derivation") or {}
823
+ lineage: list[str] = derivation.get("lineage", [])
824
+ suffix = "" if is_authoritative else "(~)"
825
+
826
+ if not lineage:
827
+ return f"{session_name}{suffix}"
828
+
829
+ # Reverse: [parent, grandparent, origin] → [origin, grandparent, parent]
830
+ ancestors = list(reversed(lineage))
831
+
832
+ if len(ancestors) == 1:
833
+ breadcrumb = f"{ancestors[0]}{BREADCRUMB_SEP}{session_name}"
834
+ elif len(ancestors) == 2:
835
+ breadcrumb = BREADCRUMB_SEP.join(ancestors) + f"{BREADCRUMB_SEP}{session_name}"
836
+ else:
837
+ # 3+ ancestors: origin > ... > parent > current
838
+ breadcrumb = (
839
+ f"{ancestors[0]}{BREADCRUMB_SEP}{BREADCRUMB_ELISION}{BREADCRUMB_SEP}"
840
+ f"{ancestors[-1]}{BREADCRUMB_SEP}{session_name}"
841
+ )
842
+
843
+ return f"{breadcrumb}{suffix}"
844
+
845
+
846
+ def format_verification(manifest: dict[str, Any]) -> str | None:
847
+ """Format verification status: LOOP N/M when active, None otherwise."""
848
+ confirmed_verif = manifest.get("confirmed", {}).get("verification") or {}
849
+ iterations = confirmed_verif.get("iterations", 0)
850
+ if iterations == 0:
851
+ return None
852
+
853
+ last_result = confirmed_verif.get("last_result")
854
+ if last_result in _VERIFICATION_TERMINAL:
855
+ return None
856
+
857
+ max_iterations = manifest.get("intent", {}).get("verification", {}).get("max_iterations", 50)
858
+ return f"{VERIFICATION_INDICATOR} {iterations}/{max_iterations}"
859
+
860
+
861
+ def format_sidecar(manifest: dict[str, Any]) -> str | None:
862
+ """Return ASCII indicator when session uses sidecar mode."""
863
+ if manifest.get("confirmed", {}).get("is_sandboxed", False):
864
+ return SIDECAR_INDICATOR
865
+ return None
866
+
867
+
868
+ def format_native_sandbox() -> str | None:
869
+ """Return indicator if Claude Code native sandbox is active.
870
+
871
+ TODO: Claude Code does not currently expose a discoverable
872
+ env var for sandbox state (Seatbelt/bubblewrap). Wire this in when
873
+ the detection mechanism is confirmed. Candidates: CLAUDE_SANDBOX,
874
+ CLAUDE_CODE_SANDBOX_MODE, or presence of sandbox-runtime process.
875
+ """
876
+ return None
877
+
878
+
879
+ def format_rate_limits(rate_limits: Any, is_proxy: bool) -> str | None:
880
+ """Format rate limit usage from Claude Code's rate_limits field.
881
+
882
+ Only shows the shortest window (5h) since that's the one users hit.
883
+ Skips display in proxy mode (proxy has its own rate limits).
884
+
885
+ Color thresholds: green < 50%, yellow 50-80%, red > 80%.
886
+ """
887
+ if is_proxy or not rate_limits:
888
+ return None
889
+
890
+ # rate_limits is a list of window objects
891
+ if not isinstance(rate_limits, list):
892
+ logger.debug("rate_limits unexpected type: %s", type(rate_limits).__name__)
893
+ return None
894
+
895
+ # Find the shortest window (5h preferred)
896
+ window = None
897
+ for entry in rate_limits:
898
+ if not isinstance(entry, dict):
899
+ continue
900
+ window_type = entry.get("type", "")
901
+ if "5" in str(window_type) or "hour" in str(window_type).lower():
902
+ window = entry
903
+ break
904
+ # Fall back to first entry if no 5h window found
905
+ if window is None and rate_limits:
906
+ first = rate_limits[0]
907
+ if isinstance(first, dict):
908
+ window = first
909
+
910
+ if window is None:
911
+ return None
912
+
913
+ used_pct = window.get("used_percentage")
914
+ if used_pct is None:
915
+ return None
916
+
917
+ try:
918
+ used_pct_float = float(used_pct)
919
+ except (TypeError, ValueError):
920
+ logger.debug("rate_limits used_percentage unexpected value: %r", used_pct)
921
+ return None
922
+
923
+ pct = int(used_pct_float)
924
+ if used_pct_float > 80:
925
+ color = RED_BOLD
926
+ elif used_pct_float >= 50:
927
+ color = YELLOW
928
+ else:
929
+ color = GREEN
930
+
931
+ return f"{DIM}RL:{RESET}{color}{pct}%{RESET}"
932
+
933
+
934
+ def format_token_breakdown(input_tokens: int, output_tokens: int, cached_tokens: int) -> str | None:
935
+ """Format cumulative token breakdown: in:12K out:3.2K cache:8K."""
936
+ if input_tokens == 0 and output_tokens == 0 and cached_tokens == 0:
937
+ return None
938
+ parts: list[str] = []
939
+ if input_tokens > 0:
940
+ parts.append(f"{DIM}{TOKEN_INPUT_LABEL}{RESET}{METRICS_COLOR}{format_tokens(input_tokens)}{RESET}")
941
+ if output_tokens > 0:
942
+ parts.append(f"{DIM}{TOKEN_OUTPUT_LABEL}{RESET}{METRICS_COLOR}{format_tokens(output_tokens)}{RESET}")
943
+ if cached_tokens > 0:
944
+ parts.append(f"{DIM}{TOKEN_CACHE_LABEL}{RESET}{METRICS_COLOR}{format_tokens(cached_tokens)}{RESET}")
945
+ return " ".join(parts) if parts else None
946
+
947
+
948
+ def _parse_numstat(output: str) -> tuple[int, int]:
949
+ """Parse `git diff --numstat` output into (added, removed) totals."""
950
+ added = 0
951
+ removed = 0
952
+
953
+ for line in output.splitlines():
954
+ parts = line.split("\t", 2)
955
+ if len(parts) < 3:
956
+ continue
957
+ add_str, remove_str = parts[0], parts[1]
958
+ if add_str.isdigit():
959
+ added += int(add_str)
960
+ if remove_str.isdigit():
961
+ removed += int(remove_str)
962
+
963
+ return added, removed
964
+
965
+
966
+ # Cache git numstat results with a short TTL to avoid two subprocess calls per refresh
967
+ _numstat_cache: dict[str, tuple[float, tuple[int, int]]] = {}
968
+ _NUMSTAT_TTL_SECS = 5.0
969
+
970
+
971
+ def _git_numstat(current_dir: str) -> tuple[int, int]:
972
+ """Run git diff --numstat (staged + unstaged) with TTL cache."""
973
+ now = time.monotonic()
974
+ cached = _numstat_cache.get(current_dir)
975
+ if cached is not None and (now - cached[0]) < _NUMSTAT_TTL_SECS:
976
+ return cached[1]
977
+
978
+ try:
979
+ timeout = _status_timeout()
980
+ unstaged = subprocess.run(
981
+ ["git", "-C", current_dir, "diff", "--numstat"],
982
+ capture_output=True,
983
+ text=True,
984
+ timeout=timeout,
985
+ )
986
+ staged = subprocess.run(
987
+ ["git", "-C", current_dir, "diff", "--cached", "--numstat"],
988
+ capture_output=True,
989
+ text=True,
990
+ timeout=timeout,
991
+ )
992
+ if unstaged.returncode != 0 or staged.returncode != 0:
993
+ result = (0, 0)
994
+ else:
995
+ unstaged_added, unstaged_removed = _parse_numstat(unstaged.stdout)
996
+ staged_added, staged_removed = _parse_numstat(staged.stdout)
997
+ result = (unstaged_added + staged_added, unstaged_removed + staged_removed)
998
+ except Exception:
999
+ result = (0, 0)
1000
+
1001
+ _numstat_cache[current_dir] = (now, result)
1002
+ return result
1003
+
1004
+
1005
+ def get_line_change_values(cost_data: dict[str, Any], current_dir: str = "") -> tuple[int, int]:
1006
+ """Prefer Claude totals, then fall back to cached git diff counts."""
1007
+ if cost_data:
1008
+ lines_added = int(cost_data.get("total_lines_added", 0) or 0)
1009
+ lines_removed = int(cost_data.get("total_lines_removed", 0) or 0)
1010
+ if lines_added > 0 or lines_removed > 0:
1011
+ return lines_added, lines_removed
1012
+
1013
+ if not current_dir:
1014
+ return 0, 0
1015
+
1016
+ return _git_numstat(current_dir)
1017
+
1018
+
1019
+ def format_line_changes(cost_data: dict[str, Any], current_dir: str = "") -> str | None:
1020
+ """Format direct line counts as +added/-removed with conventional colors."""
1021
+ lines_added, lines_removed = get_line_change_values(cost_data, current_dir)
1022
+ if lines_added == 0 and lines_removed == 0:
1023
+ return None
1024
+
1025
+ parts: list[str] = []
1026
+ if lines_added > 0:
1027
+ parts.append(f"{LINE_ADD_COLOR}+{lines_added}{RESET}")
1028
+ if lines_removed > 0:
1029
+ parts.append(f"{LINE_REMOVE_COLOR}-{lines_removed}{RESET}")
1030
+
1031
+ return f"{DARK_GRAY}/{RESET}".join(parts) if len(parts) == 2 else parts[0]
1032
+
1033
+
1034
+ def get_token_breakdown_values(data: dict[str, Any], stats: TranscriptStats) -> tuple[int, int, int]:
1035
+ """Prefer token totals from Claude Code input, with transcript fallback."""
1036
+ context_window_data = data.get("context_window")
1037
+ if not isinstance(context_window_data, dict):
1038
+ return stats.input_tokens, stats.output_tokens, stats.cached_tokens
1039
+
1040
+ input_tokens = context_window_data.get("total_input_tokens")
1041
+ output_tokens = context_window_data.get("total_output_tokens")
1042
+
1043
+ # Prefer aggregate key; fall back to sum of breakdown keys to avoid double-counting
1044
+ total_cached = context_window_data.get("total_cached_tokens")
1045
+ if total_cached is not None:
1046
+ cached_tokens: int | None = int(total_cached)
1047
+ else:
1048
+ read = context_window_data.get("total_cache_read_input_tokens")
1049
+ creation = context_window_data.get("total_cache_creation_input_tokens")
1050
+ if read is not None or creation is not None:
1051
+ cached_tokens = int(read or 0) + int(creation or 0)
1052
+ else:
1053
+ cached_tokens = None
1054
+
1055
+ return (
1056
+ int(input_tokens) if input_tokens is not None else stats.input_tokens,
1057
+ int(output_tokens) if output_tokens is not None else stats.output_tokens,
1058
+ cached_tokens if cached_tokens is not None else stats.cached_tokens,
1059
+ )
1060
+
1061
+
1062
+ def truncate_ansi(text: str, max_width: int) -> str:
1063
+ """Truncate text to max_width visible columns, preserving ANSI codes.
1064
+
1065
+ Uses _char_width() for correct emoji/Unicode column counting.
1066
+ Appends '...' when limit reached.
1067
+ """
1068
+ if max_width <= 3:
1069
+ return "..."
1070
+
1071
+ visible_len = 0
1072
+ result: list[str] = []
1073
+ in_ansi = False
1074
+ prev_cp = 0
1075
+
1076
+ for char in text:
1077
+ if char == "\033":
1078
+ in_ansi = True
1079
+ result.append(char)
1080
+ elif in_ansi:
1081
+ result.append(char)
1082
+ if char == "m":
1083
+ in_ansi = False
1084
+ else:
1085
+ cp = ord(char)
1086
+ # VS16 after BMP char upgrades it to emoji width
1087
+ if cp == 0xFE0F and 0 < prev_cp < 0x10000:
1088
+ eaw = unicodedata.east_asian_width(chr(prev_cp))
1089
+ if eaw not in ("W", "F"):
1090
+ visible_len += 1
1091
+ result.append(char)
1092
+ prev_cp = cp
1093
+ continue
1094
+
1095
+ w = _char_width(char)
1096
+ if visible_len + w <= max_width - 3:
1097
+ result.append(char)
1098
+ visible_len += w
1099
+ if w > 0:
1100
+ prev_cp = cp
1101
+ else:
1102
+ result.append("...")
1103
+ break
1104
+ else:
1105
+ return text
1106
+
1107
+ return "".join(result)
1108
+
1109
+
1110
+ def _wrap_output(output: str, available: int) -> str:
1111
+ """Wrap at a separator boundary instead of truncating with '...'.
1112
+
1113
+ Splits at the last | separator that fits within `available` visible columns.
1114
+ Line 2 gets an ANSI reset prefix. Falls back to truncate_ansi() when
1115
+ there are no separators or the first segment alone exceeds the width.
1116
+ """
1117
+ segments = output.split(_HARDENED_SEP)
1118
+ if len(segments) <= 1:
1119
+ return truncate_ansi(output, available)
1120
+
1121
+ sep_visible_width = _visible_width(_HARDENED_SEP)
1122
+
1123
+ line1_parts = [segments[0]]
1124
+ line1_visible = _visible_width(segments[0])
1125
+ split_idx = 1
1126
+
1127
+ for i in range(1, len(segments)):
1128
+ seg_visible = _visible_width(segments[i])
1129
+ new_width = line1_visible + sep_visible_width + seg_visible
1130
+ if new_width <= available:
1131
+ line1_parts.append(segments[i])
1132
+ line1_visible = new_width
1133
+ split_idx = i + 1
1134
+ else:
1135
+ break
1136
+
1137
+ if split_idx >= len(segments):
1138
+ return output
1139
+
1140
+ if not line1_parts or line1_visible == 0:
1141
+ return truncate_ansi(output, available)
1142
+
1143
+ line1 = _HARDENED_SEP.join(line1_parts)
1144
+ remaining = segments[split_idx:]
1145
+ line2 = "\x1b[0m" + _HARDENED_SEP.join(remaining)
1146
+
1147
+ line2_visible = _visible_width(line2)
1148
+ if line2_visible > available:
1149
+ line2 = truncate_ansi(line2, available)
1150
+
1151
+ return line1 + "\n" + line2
1152
+
1153
+
1154
+ def render_categories(
1155
+ where: list[str],
1156
+ who: list[str],
1157
+ what: list[str],
1158
+ metrics: list[str],
1159
+ state: list[str],
1160
+ ) -> str:
1161
+ """Join category segments into final status line string.
1162
+
1163
+ Where parts are concatenated directly (path + branch).
1164
+ All other segments are flattened with SEP between each — no visual
1165
+ distinction between within-category and between-category separators.
1166
+ """
1167
+ parts: list[str] = []
1168
+
1169
+ if where:
1170
+ parts.append("".join(where))
1171
+
1172
+ for category in (who, what, metrics, state):
1173
+ for segment in category:
1174
+ parts.append(f" {SEP} {segment}")
1175
+
1176
+ return "".join(parts)
1177
+
1178
+
1179
+ def discover_session() -> tuple[dict[str, Any] | None, bool]:
1180
+ """Discover session state via FORGE_SESSION env var only.
1181
+
1182
+ No CWD fallback: if FORGE_SESSION is not set, returns (None, False).
1183
+ This prevents false positives when running native ``claude`` in a
1184
+ directory that happens to have Forge sessions.
1185
+
1186
+ Returns:
1187
+ Tuple of (manifest_dict, is_authoritative).
1188
+ - is_authoritative=True means FORGE_SESSION env var + index lookup succeeded
1189
+ - (None, False) means no Forge session context
1190
+ """
1191
+ session_name = os.environ.get("FORGE_SESSION")
1192
+ if not session_name:
1193
+ return None, False
1194
+
1195
+ forge_root = os.environ.get("FORGE_FORGE_ROOT")
1196
+
1197
+ try:
1198
+ # Lazy import to avoid slowing down status line startup
1199
+ from forge.session.index import IndexStore
1200
+ from forge.session.store import get_manifest_path
1201
+
1202
+ index = IndexStore()
1203
+ entry = index.get_session(session_name, forge_root=forge_root)
1204
+ if entry:
1205
+ manifest_path = get_manifest_path(entry.forge_root or entry.worktree_path, session_name)
1206
+ if manifest_path.is_file():
1207
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
1208
+ return manifest, True # authoritative
1209
+ except Exception as e:
1210
+ logger.debug(f"Index lookup failed for FORGE_SESSION={session_name}: {e}")
1211
+
1212
+ return None, False
1213
+
1214
+
1215
+ @click.command(name="status-line", hidden=True)
1216
+ def status_line() -> None:
1217
+ """Generate status line for Claude Code.
1218
+
1219
+ Reads JSON from stdin (Claude Code's status line contract),
1220
+ outputs formatted status line to stdout.
1221
+
1222
+ This command is invoked by Claude Code's statusLine setting.
1223
+
1224
+ Exempt from automatic debug logging (runs every poll cycle).
1225
+ Enable via FORGE_DEBUG=1 or config.yaml log_level: debug.
1226
+ Logs to $FORGE_HOME/logs/cli/status-line.<PID>.log.
1227
+ """
1228
+ # Status-line configures its own logging (exempt from main.py auto-config,
1229
+ # same pattern as hooks/_group.py).
1230
+ from forge.core.logging import configure_debug_logging
1231
+
1232
+ configure_debug_logging(component="status-line", subdirectory="cli")
1233
+
1234
+ try:
1235
+ json_data = sys.stdin.read()
1236
+ if not json_data.strip():
1237
+ click.echo(f"{RED}[Error: No input]{RESET}", color=True)
1238
+ return
1239
+
1240
+ data = json.loads(json_data)
1241
+ except json.JSONDecodeError:
1242
+ click.echo(f"{RED}[Error: Invalid JSON]{RESET}", color=True)
1243
+ return
1244
+
1245
+ logger.debug("env: FORGE_HOME=%s", os.environ.get("FORGE_HOME", "<unset>"))
1246
+ logger.debug("env: ANTHROPIC_BASE_URL=%s", os.environ.get("ANTHROPIC_BASE_URL", "<unset>"))
1247
+ logger.debug("env: FORGE_SESSION=%s", os.environ.get("FORGE_SESSION", "<unset>"))
1248
+ logger.debug("input keys: %s", list(data.keys()))
1249
+ logger.debug("workspace.current_dir: %s", data.get("workspace", {}).get("current_dir", "<missing>"))
1250
+
1251
+ is_proxy, runtime, is_proxy_authoritative = detect_proxy()
1252
+
1253
+ logger.debug("proxy: is_proxy=%s, authoritative=%s", is_proxy, is_proxy_authoritative)
1254
+ if runtime:
1255
+ logger.debug("proxy: template=%s, tier_mappings=%s", runtime.template, runtime.tier_mappings)
1256
+ else:
1257
+ logger.debug("proxy: runtime=None")
1258
+
1259
+ workspace = data.get("workspace", {})
1260
+ current_dir = workspace.get("current_dir", "")
1261
+ model_data = data.get("model", {})
1262
+ raw_model_name = model_data.get("display_name", "Claude")
1263
+ transcript_path = data.get("transcript_path", "")
1264
+ cost_data = data.get("cost", {})
1265
+
1266
+ # Discover session early (needed for Who + State categories)
1267
+ session_manifest, is_session_authoritative = discover_session()
1268
+
1269
+ session_name = session_manifest.get("name") if session_manifest else None
1270
+ logger.debug("session: name=%s, authoritative=%s", session_name, is_session_authoritative)
1271
+
1272
+ # === CATEGORY: Where ===
1273
+ where: list[str] = []
1274
+ where.append(f"{GREEN_BOLD}{get_compact_path(current_dir)}{RESET}")
1275
+ git_branch = get_git_branch(current_dir)
1276
+ if git_branch:
1277
+ where.append(f" ({YELLOW_BOLD}{git_branch}{RESET})")
1278
+
1279
+ # === CATEGORY: Who ===
1280
+ who: list[str] = []
1281
+ if session_manifest:
1282
+ breadcrumb = format_breadcrumb(session_manifest, is_session_authoritative)
1283
+ if breadcrumb:
1284
+ who.append(f"{BREADCRUMB_COLOR}{breadcrumb}{RESET}")
1285
+
1286
+ # === CATEGORY: What ===
1287
+ what: list[str] = []
1288
+
1289
+ # Context info (may be overridden by proxy runtime truth)
1290
+ logger.debug(
1291
+ "context_window raw: %s (type=%s)", data.get("context_window"), type(data.get("context_window")).__name__
1292
+ )
1293
+ context_info = parse_context_from_json(data)
1294
+ if is_proxy and runtime and runtime.active_context_window:
1295
+ if context_info:
1296
+ tokens = context_info.get("tokens", 0)
1297
+ accurate_window = runtime.active_context_window
1298
+ context_info["context_window"] = accurate_window
1299
+ context_info["percent"] = min(100, int((tokens / accurate_window) * 100))
1300
+
1301
+ effective_context_window = get_effective_context_window(data, runtime, context_info)
1302
+ model_name = format_model_label(raw_model_name, effective_context_window)
1303
+
1304
+ tier_display = get_tier_display(runtime) if is_proxy else None
1305
+ if tier_display:
1306
+ model_segment = f"[{tier_display}] {get_context_display(context_info)}"
1307
+ else:
1308
+ detected_tier = get_tier_from_display_name(raw_model_name)
1309
+ model_color = _tier_color(detected_tier, runtime)
1310
+ model_segment = f"{model_color}[{model_name}]{RESET} {get_context_display(context_info)}"
1311
+
1312
+ if is_proxy and runtime and runtime.template and runtime.template != "unknown":
1313
+ suffix = "" if is_proxy_authoritative else "(~)"
1314
+ what.append(f"{TEMPLATE_COLOR}{runtime.template}{suffix}{RESET} {model_segment}")
1315
+ else:
1316
+ what.append(model_segment)
1317
+
1318
+ # === CATEGORY: Metrics ===
1319
+ metrics_cat: list[str] = []
1320
+
1321
+ _proxy_cost = runtime.proxy_cost_usd if runtime else 0.0
1322
+ session_metrics = get_session_metrics(cost_data, is_proxy, proxy_cost_usd=_proxy_cost)
1323
+ if session_metrics:
1324
+ metrics_cat.append(session_metrics)
1325
+
1326
+ # Rate limit usage (direct Anthropic sessions only, config-gated)
1327
+ from forge.runtime_config import get_runtime_config
1328
+
1329
+ if get_runtime_config().show_rate_limits:
1330
+ rate_limits_data = data.get("rate_limits")
1331
+ logger.debug("rate_limits: %s", rate_limits_data)
1332
+ rate_limit_display = format_rate_limits(rate_limits_data, is_proxy)
1333
+ if rate_limit_display:
1334
+ metrics_cat.append(rate_limit_display)
1335
+
1336
+ # Transcript stats (mtime-cached to avoid re-scanning unchanged files)
1337
+ stats = _cached_scan_transcript(transcript_path)
1338
+
1339
+ line_display = format_line_changes(cost_data, current_dir)
1340
+ if line_display:
1341
+ metrics_cat.append(line_display)
1342
+
1343
+ input_tokens, output_tokens, cached_tokens = get_token_breakdown_values(data, stats)
1344
+ token_display = format_token_breakdown(input_tokens, output_tokens, cached_tokens)
1345
+ if token_display:
1346
+ metrics_cat.append(token_display)
1347
+
1348
+ # === CATEGORY: State ===
1349
+ state: list[str] = []
1350
+
1351
+ if stats.has_thinking:
1352
+ state.append(f"{BLUE}{THINKING_INDICATOR}{RESET}")
1353
+
1354
+ if session_manifest:
1355
+ verif = format_verification(session_manifest)
1356
+ if verif:
1357
+ state.append(verif)
1358
+
1359
+ sidecar = format_sidecar(session_manifest)
1360
+ if sidecar:
1361
+ state.append(sidecar)
1362
+
1363
+ # === RENDER ===
1364
+ output = render_categories(where, who, what, metrics_cat, state)
1365
+
1366
+ # Output hardening (from ccstatusline)
1367
+ # ANSI reset prefix: override Claude Code's dim default styling
1368
+ output = "\x1b[0m" + output
1369
+ # Non-breaking spaces: prevent VSCode terminal from trimming
1370
+ output = output.replace(" ", "\u00a0")
1371
+
1372
+ # Wrap or truncate to prevent terminal line wrapping (which causes Forge output
1373
+ # to overlap Claude Code's native status on the next terminal row). Prefers
1374
+ # wrapping at a | separator boundary (preserves all info on two lines) over
1375
+ # truncation with '...' (loses info). Always on by default; set
1376
+ # FORGE_STATUS_TRUNCATE=0 to disable.
1377
+ if os.environ.get("FORGE_STATUS_TRUNCATE") != "0":
1378
+ term_width = _get_terminal_width()
1379
+ available = term_width - TRAILING_MARGIN - NATIVE_DISPLAY_RESERVE
1380
+ if available > 3:
1381
+ display_width = _visible_width(output)
1382
+ if display_width + TRAILING_MARGIN + NATIVE_DISPLAY_RESERVE > term_width:
1383
+ output = _wrap_output(output, available)
1384
+
1385
+ # Trailing margin on each line: RESET prevents color bleed, NBSP padding
1386
+ # prevents visual merging with Claude Code's native token display.
1387
+ margin = RESET + "\u00a0" * TRAILING_MARGIN
1388
+ output = "\n".join(line + margin for line in output.split("\n"))
1389
+
1390
+ logger.debug(
1391
+ "output line_count=%d, visible_width=%d, term_width=%d",
1392
+ output.count("\n") + 1,
1393
+ _visible_width(output.split("\n")[0]),
1394
+ _get_terminal_width(),
1395
+ )
1396
+
1397
+ # Force color=True since Claude Code pipes output (not a TTY)
1398
+ click.echo(output, color=True)