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.
Files changed (58) hide show
  1. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/PKG-INFO +1 -1
  2. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/stats_panel.py +169 -93
  3. context_forge_cli-0.4.7/contextforge/tui/widgets/tokens_panel.py +372 -0
  4. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/utils/tokens.py +2 -2
  5. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/pyproject.toml +1 -1
  6. context_forge_cli-0.4.5/contextforge/tui/widgets/tokens_panel.py +0 -293
  7. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/.contextforge.toml +0 -0
  8. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/.github/workflows/publish.yml +0 -0
  9. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/.gitignore +0 -0
  10. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/.python-version +0 -0
  11. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/AGENTS.md +0 -0
  12. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/PLAN.md +0 -0
  13. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/README.md +0 -0
  14. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/__init__.py +0 -0
  15. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/__init__.py +0 -0
  16. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/altimate_code.py +0 -0
  17. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/base.py +0 -0
  18. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/claude_code.py +0 -0
  19. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/claude_desktop.py +0 -0
  20. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/codex.py +0 -0
  21. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/gemini.py +0 -0
  22. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/adapters/registry.py +0 -0
  23. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/cli.py +0 -0
  24. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/__init__.py +0 -0
  25. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/analytics.py +0 -0
  26. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/compactor.py +0 -0
  27. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/db.py +0 -0
  28. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/injector.py +0 -0
  29. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/scanner.py +0 -0
  30. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/summarizer.py +0 -0
  31. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/core/token_analyzer.py +0 -0
  32. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/mcp_server.py +0 -0
  33. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/models/__init__.py +0 -0
  34. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/models/config.py +0 -0
  35. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/models/session.py +0 -0
  36. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/__init__.py +0 -0
  37. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/app.py +0 -0
  38. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/styles.tcss +0 -0
  39. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/__init__.py +0 -0
  40. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/session_detail.py +0 -0
  41. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/session_table.py +0 -0
  42. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/status_bar.py +0 -0
  43. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/tui/widgets/transfer_panel.py +0 -0
  44. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/utils/__init__.py +0 -0
  45. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/contextforge/utils/display.py +0 -0
  46. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/__init__.py +0 -0
  47. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/adapters/__init__.py +0 -0
  48. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/adapters/test_claude_code.py +0 -0
  49. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/conftest.py +0 -0
  50. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/__init__.py +0 -0
  51. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/test_compactor.py +0 -0
  52. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/test_db.py +0 -0
  53. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/test_injector.py +0 -0
  54. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/test_summarizer.py +0 -0
  55. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/core/test_token_analyzer.py +0 -0
  56. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/fixtures/claude_session_sample.jsonl +0 -0
  57. {context_forge_cli-0.4.5 → context_forge_cli-0.4.7}/tests/test_mcp_server.py +0 -0
  58. {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.5
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
- _BLOCKS = " ▏▎▍▌▋▊▉█" # 9 levels: 0/8 → 8/8 of a cell
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": "7 days",
36
+ "7d": "7 days",
35
37
  "30d": "30 days",
36
- "6m": "6 months",
37
- "1y": "1 year",
38
+ "6m": "6 months",
39
+ "1y": "1 year",
38
40
  }
39
41
 
40
42
 
41
- def _unicode_bar(fraction: float, width: int = 32) -> str:
43
+ def _unicode_bar(fraction: float, width: int = 30) -> str:
42
44
  if fraction <= 0:
43
- return " " * width
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 += _BLOCKS[remainder]
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 = 14,
59
- bar_width: int = 32,
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 f"[dim]{label:<{label_width}}[/dim][{color}]{bar}[/{color}] [bold]{val_str}[/bold]"
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:] # last 5 chars e.g. "04-12" or "W15"
122
- last_lbl = buckets[-1].label[-5:]
123
- mid_lbl = buckets[n // 2].label[-5:]
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
- pos = mid_start + i
134
- if pos < n:
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
- pos = last_start + i
140
- if pos < n:
141
- label_line[pos] = ch
140
+ if last_start + i < n:
141
+ label_line[last_start + i] = ch
142
142
 
143
- date_line = f"[dim]{''.join(label_line)}[/dim]"
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", "7d"),
159
+ Binding("w", "set_window_7d", "7d"),
156
160
  Binding("m", "set_window_30d", "30d"),
157
- Binding("h", "set_window_6m", "6m"),
158
- Binding("y", "set_window_1y", "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: 82%;
167
- max-width: 110;
168
- height: 88%;
169
- border: thick $primary;
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-title {
193
+ StatsPanel .chart-section {
186
194
  text-style: bold;
187
- color: $text;
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("Sessions by tool", classes="chart-title")
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-title")
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-title")
234
+ yield Static(_section("Activity over time", "⌁"), classes="chart-section")
225
235
  yield Static("", id="sparkline")
226
- yield Static("Top projects", classes="chart-title")
236
+ yield Static(_section("Top projects", "⌂"), classes="chart-section")
227
237
  yield Static("", id="projects")
228
238
  yield Static(
229
- "ESC/q — close │ W=7d M=30d H=6m Y=1y",
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) -> None:
242
- self.window = "7d"
243
-
244
- def action_set_window_30d(self) -> None:
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 = analytics.get_overview(db, self.window)
266
- activity = analytics.get_activity_over_time(db, self.window)
267
- projects = analytics.get_top_projects(db, self.window)
268
-
269
- self._render_all(overview, activity, projects)
270
-
271
- def _render_all(self, overview, activity: list, projects: list) -> None:
272
- w = self.window
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
- window_indicators = " ".join(
277
- f"[bold cyan]▶ {k}[/bold cyan]" if k == w else f"[dim]{k.upper()[0]}[/dim]"
278
- for k in ("7d", "30d", "6m", "1y")
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] {window_indicators} [dim]— last {label}[/dim]"
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
- f" │ {earliest.strftime('%b %d')} → {latest.strftime('%b %d, %Y')}"
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"Tools: [dim]{tools_str}[/dim]"
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
- # ── Sessions bar chart ─────────────────────────────────────────────
303
- tool_order = ["claude_code", "codex", "altimate_code", "claude_desktop"]
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 tool_order:
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 = TOOL_COLORS.get(tool, "white")
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 tool_order:
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 = TOOL_COLORS.get(tool, "white")
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
- proj_lines = [
337
- f"[dim]{i + 1}.[/dim] [bold]{p.project}[/bold] [dim]({p.count})[/dim]"
338
- for i, p in enumerate(projects)
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]")