context-forge-cli 0.4.5__tar.gz → 0.4.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/PKG-INFO +1 -1
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/stats_panel.py +169 -93
- context_forge_cli-0.4.7/contextforge/tui/widgets/tokens_panel.py +372 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/utils/tokens.py +2 -2
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/pyproject.toml +1 -1
- context_forge_cli-0.4.5/contextforge/tui/widgets/tokens_panel.py +0 -293
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/.contextforge.toml +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/.github/workflows/publish.yml +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/.gitignore +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/.python-version +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/AGENTS.md +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/PLAN.md +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/README.md +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/altimate_code.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/base.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/claude_code.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/claude_desktop.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/codex.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/gemini.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/registry.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/cli.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/analytics.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/compactor.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/db.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/injector.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/scanner.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/summarizer.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/token_analyzer.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/mcp_server.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/models/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/models/config.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/models/session.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/app.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/styles.tcss +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/session_detail.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/session_table.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/status_bar.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/transfer_panel.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/utils/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/utils/display.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/adapters/test_claude_code.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/conftest.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/test_compactor.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/test_db.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/test_injector.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/test_summarizer.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/test_token_analyzer.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/fixtures/claude_session_sample.jsonl +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/test_mcp_server.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: context-forge-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.7
|
|
4
4
|
Summary: Session manager and context bridge for agentic CLI tools
|
|
5
5
|
Project-URL: Homepage, https://github.com/emmver/contextforge
|
|
6
6
|
Project-URL: Repository, https://github.com/emmver/contextforge
|
|
@@ -12,7 +12,7 @@ from textual.widgets import Static
|
|
|
12
12
|
|
|
13
13
|
# ── Unicode rendering helpers ─────────────────────────────────────────────────
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
_EIGHTHS = " ▏▎▍▌▋▊▉█" # 9 levels for smooth bars
|
|
16
16
|
_SPARK = "▁▂▃▄▅▆▇█" # 8 vertical height levels
|
|
17
17
|
|
|
18
18
|
TOOL_COLORS = {
|
|
@@ -30,24 +30,27 @@ TOOL_LABELS = {
|
|
|
30
30
|
"gemini": "✦ Gemini",
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
_TOOL_ORDER = ["claude_code", "codex", "gemini", "altimate_code", "claude_desktop"]
|
|
34
|
+
|
|
33
35
|
WINDOW_LABELS = {
|
|
34
|
-
"7d":
|
|
36
|
+
"7d": "7 days",
|
|
35
37
|
"30d": "30 days",
|
|
36
|
-
"6m":
|
|
37
|
-
"1y":
|
|
38
|
+
"6m": "6 months",
|
|
39
|
+
"1y": "1 year",
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
|
|
41
|
-
def _unicode_bar(fraction: float, width: int =
|
|
43
|
+
def _unicode_bar(fraction: float, width: int = 30) -> str:
|
|
42
44
|
if fraction <= 0:
|
|
43
|
-
return "
|
|
45
|
+
return "░" * width
|
|
46
|
+
fraction = min(fraction, 1.0)
|
|
44
47
|
total_eighths = round(fraction * width * 8)
|
|
45
48
|
full_cells = min(total_eighths // 8, width)
|
|
46
49
|
remainder = total_eighths % 8
|
|
47
50
|
bar = "█" * full_cells
|
|
48
51
|
if full_cells < width and remainder > 0:
|
|
49
|
-
bar +=
|
|
50
|
-
return bar.ljust(width)
|
|
52
|
+
bar += _EIGHTHS[remainder]
|
|
53
|
+
return bar.ljust(width, "░")
|
|
51
54
|
|
|
52
55
|
|
|
53
56
|
def _render_bar_line(
|
|
@@ -55,13 +58,17 @@ def _render_bar_line(
|
|
|
55
58
|
value: int,
|
|
56
59
|
max_value: int,
|
|
57
60
|
color: str = "cyan",
|
|
58
|
-
label_width: int =
|
|
59
|
-
bar_width: int =
|
|
61
|
+
label_width: int = 16,
|
|
62
|
+
bar_width: int = 30,
|
|
60
63
|
) -> str:
|
|
61
64
|
fraction = value / max_value if max_value else 0
|
|
62
65
|
bar = _unicode_bar(fraction, bar_width)
|
|
63
66
|
val_str = _fmt_tokens(value) if value >= 1000 else str(value)
|
|
64
|
-
return
|
|
67
|
+
return (
|
|
68
|
+
f" [dim]{label:<{label_width}}[/dim]"
|
|
69
|
+
f"[{color}]{bar}[/{color}]"
|
|
70
|
+
f" [bold]{val_str}[/bold]"
|
|
71
|
+
)
|
|
65
72
|
|
|
66
73
|
|
|
67
74
|
def _fmt_tokens(n: int) -> str:
|
|
@@ -112,38 +119,35 @@ def _render_sparkline(buckets: list, width: int = 52) -> str:
|
|
|
112
119
|
else:
|
|
113
120
|
bar_chars.append(f"[dim]{ch}[/dim]")
|
|
114
121
|
|
|
115
|
-
spark_line = "".join(bar_chars)
|
|
122
|
+
spark_line = " " + "".join(bar_chars)
|
|
116
123
|
|
|
117
124
|
# Build sparse date label row (first, middle, last)
|
|
118
125
|
n = len(buckets)
|
|
119
126
|
label_line = [" "] * n
|
|
120
127
|
if n > 0:
|
|
121
|
-
first_lbl = buckets[0].label[-5:]
|
|
122
|
-
last_lbl
|
|
123
|
-
mid_lbl
|
|
124
|
-
|
|
125
|
-
# Place labels without overlap
|
|
126
|
-
label_line[0] = first_lbl[0]
|
|
128
|
+
first_lbl = buckets[0].label[-5:]
|
|
129
|
+
last_lbl = buckets[-1].label[-5:]
|
|
130
|
+
mid_lbl = buckets[n // 2].label[-5:]
|
|
127
131
|
for i, ch in enumerate(first_lbl):
|
|
128
132
|
if i < n:
|
|
129
133
|
label_line[i] = ch
|
|
130
|
-
|
|
131
134
|
mid_start = n // 2
|
|
132
135
|
for i, ch in enumerate(mid_lbl):
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
label_line[pos] = ch
|
|
136
|
-
|
|
136
|
+
if mid_start + i < n:
|
|
137
|
+
label_line[mid_start + i] = ch
|
|
137
138
|
last_start = max(n - len(last_lbl), 0)
|
|
138
139
|
for i, ch in enumerate(last_lbl):
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
label_line[pos] = ch
|
|
140
|
+
if last_start + i < n:
|
|
141
|
+
label_line[last_start + i] = ch
|
|
142
142
|
|
|
143
|
-
date_line =
|
|
143
|
+
date_line = " [dim]" + "".join(label_line) + "[/dim]"
|
|
144
144
|
return spark_line + "\n" + date_line
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
def _section(title: str, icon: str = "▤") -> str:
|
|
148
|
+
return f"[bold]{icon} {title}[/bold]"
|
|
149
|
+
|
|
150
|
+
|
|
147
151
|
# ── Modal screen ──────────────────────────────────────────────────────────────
|
|
148
152
|
|
|
149
153
|
|
|
@@ -152,10 +156,10 @@ class StatsPanel(ModalScreen):
|
|
|
152
156
|
|
|
153
157
|
BINDINGS = [
|
|
154
158
|
Binding("escape,q", "dismiss(None)", "Close"),
|
|
155
|
-
Binding("w", "set_window_7d",
|
|
159
|
+
Binding("w", "set_window_7d", "7d"),
|
|
156
160
|
Binding("m", "set_window_30d", "30d"),
|
|
157
|
-
Binding("h", "set_window_6m",
|
|
158
|
-
Binding("y", "set_window_1y",
|
|
161
|
+
Binding("h", "set_window_6m", "6m"),
|
|
162
|
+
Binding("y", "set_window_1y", "1y"),
|
|
159
163
|
]
|
|
160
164
|
|
|
161
165
|
DEFAULT_CSS = """
|
|
@@ -163,10 +167,10 @@ class StatsPanel(ModalScreen):
|
|
|
163
167
|
align: center middle;
|
|
164
168
|
}
|
|
165
169
|
StatsPanel > Vertical {
|
|
166
|
-
width:
|
|
167
|
-
max-width:
|
|
168
|
-
height:
|
|
169
|
-
border: thick $
|
|
170
|
+
width: 84%;
|
|
171
|
+
max-width: 114;
|
|
172
|
+
height: 90%;
|
|
173
|
+
border: thick $accent;
|
|
170
174
|
background: $surface;
|
|
171
175
|
padding: 1 2;
|
|
172
176
|
overflow-y: auto;
|
|
@@ -175,17 +179,22 @@ class StatsPanel(ModalScreen):
|
|
|
175
179
|
text-style: bold;
|
|
176
180
|
color: $accent;
|
|
177
181
|
margin-bottom: 1;
|
|
182
|
+
padding-bottom: 1;
|
|
183
|
+
border-bottom: solid $primary-background-lighten-1;
|
|
178
184
|
}
|
|
179
185
|
StatsPanel #stats-overview {
|
|
180
|
-
color: $text-muted;
|
|
181
186
|
margin-bottom: 1;
|
|
187
|
+
}
|
|
188
|
+
StatsPanel #stats-insights {
|
|
189
|
+
margin-bottom: 1;
|
|
190
|
+
padding: 0 0 1 0;
|
|
182
191
|
border-bottom: solid $primary-background-lighten-1;
|
|
183
|
-
padding-bottom: 1;
|
|
184
192
|
}
|
|
185
|
-
StatsPanel .chart-
|
|
193
|
+
StatsPanel .chart-section {
|
|
186
194
|
text-style: bold;
|
|
187
|
-
color: $
|
|
195
|
+
color: $accent;
|
|
188
196
|
margin-top: 1;
|
|
197
|
+
margin-bottom: 0;
|
|
189
198
|
}
|
|
190
199
|
StatsPanel #bar-sessions {
|
|
191
200
|
margin-bottom: 1;
|
|
@@ -217,16 +226,17 @@ class StatsPanel(ModalScreen):
|
|
|
217
226
|
with Vertical():
|
|
218
227
|
yield Static("", id="stats-title")
|
|
219
228
|
yield Static("", id="stats-overview")
|
|
220
|
-
yield Static("
|
|
229
|
+
yield Static("", id="stats-insights")
|
|
230
|
+
yield Static(_section("Sessions by tool", "▤"), classes="chart-section")
|
|
221
231
|
yield Static("", id="bar-sessions")
|
|
222
|
-
yield Static("Token usage by tool", classes="chart-
|
|
232
|
+
yield Static(_section("Token usage by tool", "◈"), classes="chart-section")
|
|
223
233
|
yield Static("", id="bar-tokens")
|
|
224
|
-
yield Static("Activity over time", classes="chart-
|
|
234
|
+
yield Static(_section("Activity over time", "⌁"), classes="chart-section")
|
|
225
235
|
yield Static("", id="sparkline")
|
|
226
|
-
yield Static("Top projects", classes="chart-
|
|
236
|
+
yield Static(_section("Top projects", "⌂"), classes="chart-section")
|
|
227
237
|
yield Static("", id="projects")
|
|
228
238
|
yield Static(
|
|
229
|
-
"ESC/q
|
|
239
|
+
" [dim]ESC/q[/dim] close [dim]W[/dim] 7d [dim]M[/dim] 30d [dim]H[/dim] 6m [dim]Y[/dim] 1y",
|
|
230
240
|
id="stats-footer",
|
|
231
241
|
)
|
|
232
242
|
|
|
@@ -238,17 +248,10 @@ class StatsPanel(ModalScreen):
|
|
|
238
248
|
|
|
239
249
|
# ── Window actions ────────────────────────────────────────────────────────
|
|
240
250
|
|
|
241
|
-
def action_set_window_7d(self)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def
|
|
245
|
-
self.window = "30d"
|
|
246
|
-
|
|
247
|
-
def action_set_window_6m(self) -> None:
|
|
248
|
-
self.window = "6m"
|
|
249
|
-
|
|
250
|
-
def action_set_window_1y(self) -> None:
|
|
251
|
-
self.window = "1y"
|
|
251
|
+
def action_set_window_7d(self) -> None: self.window = "7d"
|
|
252
|
+
def action_set_window_30d(self) -> None: self.window = "30d"
|
|
253
|
+
def action_set_window_6m(self) -> None: self.window = "6m"
|
|
254
|
+
def action_set_window_1y(self) -> None: self.window = "1y"
|
|
252
255
|
|
|
253
256
|
# ── Data loading ──────────────────────────────────────────────────────────
|
|
254
257
|
|
|
@@ -262,81 +265,154 @@ class StatsPanel(ModalScreen):
|
|
|
262
265
|
from contextforge.core.db import get_db
|
|
263
266
|
|
|
264
267
|
db = get_db(db_path)
|
|
265
|
-
overview
|
|
266
|
-
activity
|
|
267
|
-
projects
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
268
|
+
overview = analytics.get_overview(db, self.window)
|
|
269
|
+
activity = analytics.get_activity_over_time(db, self.window)
|
|
270
|
+
projects = analytics.get_top_projects(db, self.window)
|
|
271
|
+
|
|
272
|
+
# Extra: heavy session count (>100k tokens)
|
|
273
|
+
from contextforge.core import analytics as _a
|
|
274
|
+
start_ms = _a._window_start_ms(self.window)
|
|
275
|
+
try:
|
|
276
|
+
heavy_count = db.execute(
|
|
277
|
+
"SELECT COUNT(*) FROM sessions WHERE updated_at >= ? AND token_count > 100000",
|
|
278
|
+
[start_ms],
|
|
279
|
+
).fetchone()[0]
|
|
280
|
+
except Exception:
|
|
281
|
+
heavy_count = 0
|
|
282
|
+
|
|
283
|
+
self._render_all(overview, activity, projects, heavy_count)
|
|
284
|
+
|
|
285
|
+
def _render_all(self, overview, activity: list, projects: list, heavy_count: int) -> None:
|
|
286
|
+
w = self.window
|
|
273
287
|
label = WINDOW_LABELS.get(w, w)
|
|
274
288
|
|
|
275
|
-
# ── Title
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
289
|
+
# ── Title / window selector ───────────────────────────────────────
|
|
290
|
+
windows = ["7d", "30d", "6m", "1y"]
|
|
291
|
+
keys = ["W", "M", "H", "Y"]
|
|
292
|
+
pills = " ".join(
|
|
293
|
+
f"[bold reverse] {k}={ww} [/bold reverse]" if ww == w
|
|
294
|
+
else f"[dim] {k}={ww} [/dim]"
|
|
295
|
+
for ww, k in zip(windows, keys)
|
|
279
296
|
)
|
|
280
297
|
self.query_one("#stats-title", Static).update(
|
|
281
|
-
f"[bold]Analytics[/bold]
|
|
298
|
+
f"[bold]▤ Analytics[/bold] {pills} [dim]last {label}[/dim]"
|
|
282
299
|
)
|
|
283
300
|
|
|
284
|
-
# ── Overview
|
|
301
|
+
# ── Overview card ─────────────────────────────────────────────────
|
|
285
302
|
total_tok_str = _fmt_tokens(overview.total_tokens)
|
|
303
|
+
avg_tok = overview.total_tokens // overview.total_sessions if overview.total_sessions else 0
|
|
304
|
+
avg_str = _fmt_tokens(avg_tok) if avg_tok else "—"
|
|
305
|
+
|
|
286
306
|
date_str = ""
|
|
287
307
|
if overview.date_range:
|
|
288
308
|
earliest, latest = overview.date_range
|
|
289
|
-
date_str = (
|
|
290
|
-
|
|
291
|
-
)
|
|
292
|
-
tools_str = ", ".join(
|
|
293
|
-
TOOL_LABELS.get(t, t) for t in sorted(overview.active_tools)
|
|
294
|
-
) or "—"
|
|
309
|
+
date_str = f" [dim]{earliest.strftime('%b %d')} → {latest.strftime('%b %d, %Y')}[/dim]"
|
|
310
|
+
|
|
295
311
|
self.query_one("#stats-overview", Static).update(
|
|
296
|
-
f"[bold]{overview.total_sessions:,}[/bold] sessions
|
|
297
|
-
f"[bold]{total_tok_str}[/bold] tokens
|
|
298
|
-
f"
|
|
312
|
+
f" [bold]{overview.total_sessions:,}[/bold] sessions "
|
|
313
|
+
f"[bold]{total_tok_str}[/bold] tokens "
|
|
314
|
+
f"[dim]avg {avg_str}/session[/dim]"
|
|
299
315
|
f"{date_str}"
|
|
300
316
|
)
|
|
301
317
|
|
|
302
|
-
# ──
|
|
303
|
-
|
|
318
|
+
# ── Insights ──────────────────────────────────────────────────────
|
|
319
|
+
insight_lines: list[str] = []
|
|
320
|
+
|
|
321
|
+
# Dominant tool
|
|
322
|
+
if overview.session_count_by_tool:
|
|
323
|
+
dominant = max(overview.session_count_by_tool, key=lambda t: overview.session_count_by_tool[t])
|
|
324
|
+
dom_pct = int(
|
|
325
|
+
overview.session_count_by_tool[dominant] / overview.total_sessions * 100
|
|
326
|
+
) if overview.total_sessions else 0
|
|
327
|
+
dom_label = TOOL_LABELS.get(dominant, dominant)
|
|
328
|
+
dom_color = TOOL_COLORS.get(dominant, "white")
|
|
329
|
+
insight_lines.append(
|
|
330
|
+
f" [dim]Dominant tool [/dim][{dom_color}]{dom_label}[/{dom_color}]"
|
|
331
|
+
f" [dim]{dom_pct}% of sessions[/dim]"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Heavy sessions
|
|
335
|
+
if heavy_count:
|
|
336
|
+
heavy_pct = int(heavy_count / overview.total_sessions * 100) if overview.total_sessions else 0
|
|
337
|
+
insight_lines.append(
|
|
338
|
+
f" [dim]Context pressure[/dim] "
|
|
339
|
+
f"[yellow]{heavy_count}[/yellow] [dim]sessions >100k tokens ({heavy_pct}%)[/dim]"
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
insight_lines.append(
|
|
343
|
+
f" [dim]Context pressure[/dim] [green]none[/green] [dim](no sessions >100k)[/dim]"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Activity trend (compare first vs last third of buckets)
|
|
347
|
+
trend_str = ""
|
|
348
|
+
if len(activity) >= 6:
|
|
349
|
+
third = max(len(activity) // 3, 1)
|
|
350
|
+
first_avg = sum(b.count for b in activity[:third]) / third
|
|
351
|
+
last_avg = sum(b.count for b in activity[-third:]) / third
|
|
352
|
+
if first_avg > 0:
|
|
353
|
+
ratio = last_avg / first_avg
|
|
354
|
+
if ratio >= 1.2:
|
|
355
|
+
trend_str = f"[green]↗ growing[/green] [dim](+{int((ratio-1)*100)}%)[/dim]"
|
|
356
|
+
elif ratio <= 0.8:
|
|
357
|
+
trend_str = f"[red]↘ declining[/red] [dim]({int((ratio-1)*100)}%)[/dim]"
|
|
358
|
+
else:
|
|
359
|
+
trend_str = "[dim]→ stable[/dim]"
|
|
360
|
+
else:
|
|
361
|
+
trend_str = "[dim]→ stable[/dim]" if last_avg > 0 else "[dim]— no prior activity[/dim]"
|
|
362
|
+
insight_lines.append(f" [dim]Trend [/dim]{trend_str}")
|
|
363
|
+
|
|
364
|
+
# Peak activity bucket
|
|
365
|
+
if activity:
|
|
366
|
+
peak = max(activity, key=lambda b: b.count)
|
|
367
|
+
if peak.count > 0:
|
|
368
|
+
insight_lines.append(
|
|
369
|
+
f" [dim]Peak period [/dim][bold]{peak.label}[/bold]"
|
|
370
|
+
f" [dim]{peak.count} session{'s' if peak.count != 1 else ''}[/dim]"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
self.query_one("#stats-insights", Static).update("\n".join(insight_lines))
|
|
374
|
+
|
|
375
|
+
# ── Sessions bar chart ────────────────────────────────────────────
|
|
304
376
|
max_sess = max(overview.session_count_by_tool.values(), default=1)
|
|
305
377
|
sess_lines: list[str] = []
|
|
306
|
-
for tool in
|
|
378
|
+
for tool in _TOOL_ORDER:
|
|
307
379
|
cnt = overview.session_count_by_tool.get(tool, 0)
|
|
308
380
|
if cnt == 0:
|
|
309
381
|
continue
|
|
310
|
-
color
|
|
382
|
+
color = TOOL_COLORS.get(tool, "white")
|
|
311
383
|
label_str = TOOL_LABELS.get(tool, tool)
|
|
312
384
|
sess_lines.append(_render_bar_line(label_str, cnt, max_sess, color))
|
|
313
385
|
self.query_one("#bar-sessions", Static).update(
|
|
314
|
-
"\n".join(sess_lines) if sess_lines else "[dim](no data)[/dim]"
|
|
386
|
+
"\n".join(sess_lines) if sess_lines else "[dim] (no data)[/dim]"
|
|
315
387
|
)
|
|
316
388
|
|
|
317
|
-
# ── Token bar chart
|
|
389
|
+
# ── Token bar chart ───────────────────────────────────────────────
|
|
318
390
|
max_tok = max(overview.token_sum_by_tool.values(), default=1)
|
|
319
391
|
tok_lines: list[str] = []
|
|
320
|
-
for tool in
|
|
392
|
+
for tool in _TOOL_ORDER:
|
|
321
393
|
tok = overview.token_sum_by_tool.get(tool, 0)
|
|
322
394
|
if tok == 0:
|
|
323
395
|
continue
|
|
324
|
-
color
|
|
396
|
+
color = TOOL_COLORS.get(tool, "white")
|
|
325
397
|
label_str = TOOL_LABELS.get(tool, tool)
|
|
326
398
|
tok_lines.append(_render_bar_line(label_str, tok, max_tok, color))
|
|
327
399
|
self.query_one("#bar-tokens", Static).update(
|
|
328
|
-
"\n".join(tok_lines) if tok_lines else "[dim](no data)[/dim]"
|
|
400
|
+
"\n".join(tok_lines) if tok_lines else "[dim] (no data)[/dim]"
|
|
329
401
|
)
|
|
330
402
|
|
|
331
|
-
# ── Sparkline
|
|
403
|
+
# ── Sparkline ─────────────────────────────────────────────────────
|
|
332
404
|
self.query_one("#sparkline", Static).update(_render_sparkline(activity))
|
|
333
405
|
|
|
334
|
-
# ── Top projects
|
|
406
|
+
# ── Top projects ──────────────────────────────────────────────────
|
|
335
407
|
if projects:
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
408
|
+
max_proj = max(p.count for p in projects)
|
|
409
|
+
proj_lines: list[str] = []
|
|
410
|
+
for i, p in enumerate(projects):
|
|
411
|
+
bar = _unicode_bar(p.count / max_proj, 20)
|
|
412
|
+
proj_lines.append(
|
|
413
|
+
f" [dim]{i + 1:>2}.[/dim] [bold]{p.project}[/bold]"
|
|
414
|
+
f" [cyan]{bar}[/cyan] [dim]{p.count}[/dim]"
|
|
415
|
+
)
|
|
340
416
|
self.query_one("#projects", Static).update("\n".join(proj_lines))
|
|
341
417
|
else:
|
|
342
|
-
self.query_one("#projects", Static).update("[dim](no data)[/dim]")
|
|
418
|
+
self.query_one("#projects", Static).update("[dim] (no data)[/dim]")
|