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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
forge/cli/status_line.py
ADDED
|
@@ -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)
|