context-forge-cli 0.4.5__tar.gz → 0.4.6__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.6}/PKG-INFO +1 -1
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/widgets/stats_panel.py +169 -93
- context_forge_cli-0.4.6/contextforge/tui/widgets/tokens_panel.py +372 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/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.6}/.contextforge.toml +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/.github/workflows/publish.yml +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/.gitignore +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/.python-version +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/AGENTS.md +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/PLAN.md +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/README.md +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/adapters/altimate_code.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/adapters/base.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/adapters/claude_code.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/adapters/claude_desktop.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/adapters/codex.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/adapters/gemini.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/adapters/registry.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/cli.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/core/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/core/analytics.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/core/compactor.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/core/db.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/core/injector.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/core/scanner.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/core/summarizer.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/core/token_analyzer.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/mcp_server.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/models/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/models/config.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/models/session.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/app.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/styles.tcss +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/widgets/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/widgets/session_detail.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/widgets/session_table.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/widgets/status_bar.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/widgets/transfer_panel.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/utils/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/utils/display.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/utils/tokens.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/adapters/test_claude_code.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/conftest.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/core/__init__.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/core/test_compactor.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/core/test_db.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/core/test_injector.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/core/test_summarizer.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/core/test_token_analyzer.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/fixtures/claude_session_sample.jsonl +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/test_mcp_server.py +0 -0
- {context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/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.6
|
|
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]")
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""TokensPanel — modal screen showing per-turn token analysis for a session."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import ScrollableContainer, Vertical
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import DataTable, Static
|
|
13
|
+
|
|
14
|
+
from contextforge.models.session import Message
|
|
15
|
+
|
|
16
|
+
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
_CONTEXT_WINDOW = 200_000 # reference context window (Claude Sonnet)
|
|
19
|
+
_EIGHTHS = " ▏▎▍▌▋▊▉█"
|
|
20
|
+
|
|
21
|
+
_TOOL_LABELS = {
|
|
22
|
+
"claude_code": "◆ Claude Code",
|
|
23
|
+
"codex": "⬡ Codex",
|
|
24
|
+
"altimate_code": "⚡ Altimate",
|
|
25
|
+
"claude_desktop": "◇ Claude Desktop",
|
|
26
|
+
"gemini": "✦ Gemini",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _frac_bar(fraction: float, width: int = 22) -> str:
|
|
31
|
+
"""Smooth fractional progress bar using Unicode block elements."""
|
|
32
|
+
fraction = max(0.0, min(1.0, fraction))
|
|
33
|
+
total_eighths = round(fraction * width * 8)
|
|
34
|
+
full = min(total_eighths // 8, width)
|
|
35
|
+
remainder = total_eighths % 8
|
|
36
|
+
bar = "█" * full
|
|
37
|
+
if full < width and remainder:
|
|
38
|
+
bar += _EIGHTHS[remainder]
|
|
39
|
+
return bar.ljust(width, "░")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fmt(n: int) -> str:
|
|
43
|
+
if n >= 1_000_000:
|
|
44
|
+
return f"{n / 1_000_000:.1f}M"
|
|
45
|
+
if n >= 1_000:
|
|
46
|
+
return f"{n // 1_000}k"
|
|
47
|
+
return str(n)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── TurnDetailPanel ───────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TurnDetailPanel(ModalScreen):
|
|
54
|
+
"""Modal showing the full content of a single turn."""
|
|
55
|
+
|
|
56
|
+
BINDINGS = [
|
|
57
|
+
Binding("escape,q", "dismiss(None)", "Close"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
DEFAULT_CSS = """
|
|
61
|
+
TurnDetailPanel {
|
|
62
|
+
align: center middle;
|
|
63
|
+
}
|
|
64
|
+
TurnDetailPanel > Vertical {
|
|
65
|
+
width: 92%;
|
|
66
|
+
height: 88%;
|
|
67
|
+
border: thick $accent;
|
|
68
|
+
background: $surface;
|
|
69
|
+
padding: 0 1;
|
|
70
|
+
}
|
|
71
|
+
TurnDetailPanel #turn-header {
|
|
72
|
+
height: auto;
|
|
73
|
+
padding: 1 1 0 1;
|
|
74
|
+
border-bottom: solid $primary-background-lighten-1;
|
|
75
|
+
margin-bottom: 1;
|
|
76
|
+
}
|
|
77
|
+
TurnDetailPanel ScrollableContainer {
|
|
78
|
+
height: 1fr;
|
|
79
|
+
padding: 0 1;
|
|
80
|
+
}
|
|
81
|
+
TurnDetailPanel #turn-footer {
|
|
82
|
+
height: 1;
|
|
83
|
+
color: $text-disabled;
|
|
84
|
+
text-align: center;
|
|
85
|
+
}
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, turn_num: int, msg: Message) -> None:
|
|
89
|
+
super().__init__()
|
|
90
|
+
self._turn_num = turn_num
|
|
91
|
+
self._msg = msg
|
|
92
|
+
|
|
93
|
+
def compose(self) -> ComposeResult:
|
|
94
|
+
with Vertical():
|
|
95
|
+
yield Static("", id="turn-header")
|
|
96
|
+
with ScrollableContainer():
|
|
97
|
+
yield Static("", id="turn-body")
|
|
98
|
+
yield Static("[dim]ESC / q — close[/dim]", id="turn-footer")
|
|
99
|
+
|
|
100
|
+
def on_mount(self) -> None:
|
|
101
|
+
msg = self._msg
|
|
102
|
+
role_color = "cyan" if msg.role == "user" else "green"
|
|
103
|
+
role_icon = "👤" if msg.role == "user" else "🤖"
|
|
104
|
+
|
|
105
|
+
# Context pressure for this single turn
|
|
106
|
+
pct = int((msg.token_count or 0) / _CONTEXT_WINDOW * 100)
|
|
107
|
+
pressure_color = "red" if pct >= 80 else ("yellow" if pct >= 40 else "green")
|
|
108
|
+
|
|
109
|
+
self.query_one("#turn-header", Static).update(
|
|
110
|
+
f"[bold]Turn #{self._turn_num}[/bold] "
|
|
111
|
+
f"[{role_color}]{role_icon} {msg.role}[/{role_color}] "
|
|
112
|
+
f"[bold]{(msg.token_count or 0):,}[/bold] [dim]tokens[/dim] "
|
|
113
|
+
f"[{pressure_color}]{pct}% of context[/{pressure_color}]"
|
|
114
|
+
+ (f" [yellow]⚙ {len(msg.tool_calls)} call{'s' if len(msg.tool_calls) != 1 else ''}[/yellow]"
|
|
115
|
+
if msg.tool_calls else "")
|
|
116
|
+
+ (f" [magenta]⇥ {len(msg.tool_results)} result{'s' if len(msg.tool_results) != 1 else ''}[/magenta]"
|
|
117
|
+
if msg.tool_results else "")
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
body = Text()
|
|
121
|
+
|
|
122
|
+
if msg.content:
|
|
123
|
+
body.append("── Content ──\n", style="bold dim")
|
|
124
|
+
body.append(msg.content + "\n")
|
|
125
|
+
|
|
126
|
+
if msg.tool_calls:
|
|
127
|
+
body.append("\n── Tool Calls ──\n", style="bold dim")
|
|
128
|
+
for i, tc in enumerate(msg.tool_calls, 1):
|
|
129
|
+
name = tc.get("name", "?")
|
|
130
|
+
raw_input = tc.get("input", "")
|
|
131
|
+
try:
|
|
132
|
+
formatted = json.dumps(json.loads(raw_input), indent=2)
|
|
133
|
+
except (json.JSONDecodeError, TypeError):
|
|
134
|
+
formatted = raw_input if isinstance(raw_input, str) else str(raw_input)
|
|
135
|
+
body.append(f"⚙ {i}. {name}\n", style="bold yellow")
|
|
136
|
+
body.append(formatted + "\n", style="dim")
|
|
137
|
+
|
|
138
|
+
if msg.tool_results:
|
|
139
|
+
body.append("\n── Tool Results ──\n", style="bold dim")
|
|
140
|
+
for i, tr in enumerate(msg.tool_results, 1):
|
|
141
|
+
output = tr.get("output", "")
|
|
142
|
+
if not isinstance(output, str):
|
|
143
|
+
output = str(output)
|
|
144
|
+
body.append(f"⇥ Result {i}\n", style="bold green")
|
|
145
|
+
body.append(output + "\n", style="dim")
|
|
146
|
+
|
|
147
|
+
self.query_one("#turn-body", Static).update(body)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ── TokensPanel ───────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TokensPanel(ModalScreen):
|
|
154
|
+
"""Full-screen modal showing token breakdown for a session."""
|
|
155
|
+
|
|
156
|
+
BINDINGS = [
|
|
157
|
+
Binding("escape,q", "dismiss(None)", "Close"),
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
DEFAULT_CSS = """
|
|
161
|
+
TokensPanel {
|
|
162
|
+
align: center middle;
|
|
163
|
+
}
|
|
164
|
+
TokensPanel > Vertical {
|
|
165
|
+
width: 92%;
|
|
166
|
+
height: 88%;
|
|
167
|
+
border: thick $accent;
|
|
168
|
+
background: $surface;
|
|
169
|
+
padding: 0 1;
|
|
170
|
+
}
|
|
171
|
+
TokensPanel #tokens-header {
|
|
172
|
+
height: auto;
|
|
173
|
+
padding: 1 1 0 1;
|
|
174
|
+
color: $text;
|
|
175
|
+
border-bottom: solid $primary-background-lighten-1;
|
|
176
|
+
margin-bottom: 1;
|
|
177
|
+
}
|
|
178
|
+
TokensPanel #tokens-summary {
|
|
179
|
+
height: auto;
|
|
180
|
+
padding: 0 1;
|
|
181
|
+
color: $text-muted;
|
|
182
|
+
margin-bottom: 0;
|
|
183
|
+
}
|
|
184
|
+
TokensPanel #tokens-insights {
|
|
185
|
+
height: auto;
|
|
186
|
+
padding: 0 1 1 1;
|
|
187
|
+
margin-bottom: 1;
|
|
188
|
+
border-bottom: solid $primary-background-lighten-1;
|
|
189
|
+
}
|
|
190
|
+
TokensPanel DataTable {
|
|
191
|
+
height: 1fr;
|
|
192
|
+
}
|
|
193
|
+
TokensPanel #tokens-footer {
|
|
194
|
+
height: 1;
|
|
195
|
+
color: $text-disabled;
|
|
196
|
+
text-align: center;
|
|
197
|
+
padding: 0 1;
|
|
198
|
+
}
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
_BAR_WIDTH = 20
|
|
202
|
+
|
|
203
|
+
def __init__(self, session_id: str, db_path: Path) -> None:
|
|
204
|
+
super().__init__()
|
|
205
|
+
self._session_id = session_id
|
|
206
|
+
self._db_path = db_path
|
|
207
|
+
self._messages: list[Message] = []
|
|
208
|
+
|
|
209
|
+
def compose(self) -> ComposeResult:
|
|
210
|
+
with Vertical():
|
|
211
|
+
yield Static("", id="tokens-header")
|
|
212
|
+
yield Static("", id="tokens-summary")
|
|
213
|
+
yield Static("", id="tokens-insights")
|
|
214
|
+
yield DataTable(id="tokens-table", show_cursor=True, zebra_stripes=True, cursor_type="row")
|
|
215
|
+
yield Static("[dim]ESC/q[/dim] close [dim]Enter[/dim] view turn", id="tokens-footer")
|
|
216
|
+
|
|
217
|
+
def on_mount(self) -> None:
|
|
218
|
+
self._load()
|
|
219
|
+
|
|
220
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
221
|
+
row_idx = event.cursor_row
|
|
222
|
+
if 0 <= row_idx < len(self._messages):
|
|
223
|
+
self.app.push_screen(TurnDetailPanel(row_idx + 1, self._messages[row_idx]))
|
|
224
|
+
|
|
225
|
+
def _load(self) -> None:
|
|
226
|
+
from contextforge.adapters.registry import get_adapter
|
|
227
|
+
from contextforge.core.db import get_db, get_session
|
|
228
|
+
from contextforge.core.token_analyzer import analyze_tokens
|
|
229
|
+
|
|
230
|
+
db = get_db(self._db_path)
|
|
231
|
+
report = analyze_tokens(db, self._session_id)
|
|
232
|
+
|
|
233
|
+
row = get_session(db, self._session_id)
|
|
234
|
+
if row is not None:
|
|
235
|
+
try:
|
|
236
|
+
adapter = get_adapter(row["tool"])
|
|
237
|
+
self._messages = adapter.load_messages(self._session_id)
|
|
238
|
+
except Exception:
|
|
239
|
+
self._messages = []
|
|
240
|
+
|
|
241
|
+
header = self.query_one("#tokens-header", Static)
|
|
242
|
+
summary = self.query_one("#tokens-summary", Static)
|
|
243
|
+
insights = self.query_one("#tokens-insights", Static)
|
|
244
|
+
table = self.query_one("#tokens-table", DataTable)
|
|
245
|
+
|
|
246
|
+
if report is None or not report.turns:
|
|
247
|
+
header.update("[bold red]No token data available.[/bold red]")
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
# ── Header ────────────────────────────────────────────────────────
|
|
251
|
+
tool_label = _TOOL_LABELS.get(report.tool, report.tool)
|
|
252
|
+
header.update(
|
|
253
|
+
f"[bold]# Token Analysis[/bold] "
|
|
254
|
+
f"[dim]{report.title[:55]}[/dim] "
|
|
255
|
+
f"[dim]{tool_label}[/dim]"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# ── Summary ───────────────────────────────────────────────────────
|
|
259
|
+
max_t = report.max_turn
|
|
260
|
+
line1 = (
|
|
261
|
+
f" [bold]{report.total:,}[/bold] total "
|
|
262
|
+
f"[cyan]{report.user_total:,}[/cyan] user (avg [cyan]{report.avg_user:,.0f}[/cyan]) "
|
|
263
|
+
f"[green]{report.assistant_total:,}[/green] asst (avg [green]{report.avg_assistant:,.0f}[/green]) "
|
|
264
|
+
f"[dim]{report.turn_count} turns[/dim]"
|
|
265
|
+
)
|
|
266
|
+
line2_parts = []
|
|
267
|
+
if report.has_tool_data:
|
|
268
|
+
line2_parts.append(
|
|
269
|
+
f" [yellow]⚙ calls {report.tool_call_total:,}[/yellow] "
|
|
270
|
+
f"[magenta]⇥ results {report.tool_result_total:,}[/magenta]"
|
|
271
|
+
)
|
|
272
|
+
if max_t:
|
|
273
|
+
line2_parts.append(
|
|
274
|
+
f" [dim]heaviest: turn [/dim][bold]#{max_t.turn}[/bold]"
|
|
275
|
+
f"[dim] ({max_t.tokens:,} tok, {max_t.role})[/dim]"
|
|
276
|
+
)
|
|
277
|
+
summary.update(line1 + ("\n" + "".join(line2_parts) if line2_parts else ""))
|
|
278
|
+
|
|
279
|
+
# ── Insights ──────────────────────────────────────────────────────
|
|
280
|
+
insight_lines: list[str] = []
|
|
281
|
+
|
|
282
|
+
# Context pressure gauge
|
|
283
|
+
ctx_pct = report.total / _CONTEXT_WINDOW
|
|
284
|
+
ctx_bar = _frac_bar(ctx_pct, 22)
|
|
285
|
+
ctx_val = int(ctx_pct * 100)
|
|
286
|
+
ctx_col = "red" if ctx_val >= 80 else ("yellow" if ctx_val >= 40 else "green")
|
|
287
|
+
insight_lines.append(
|
|
288
|
+
f" [dim]Context [/dim][{ctx_col}]{ctx_bar}[/{ctx_col}]"
|
|
289
|
+
f" [{ctx_col}]{ctx_val}%[/{ctx_col}] [dim]of ~200k window[/dim]"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# User / Assistant split bar
|
|
293
|
+
if report.total > 0:
|
|
294
|
+
u_frac = report.user_total / report.total
|
|
295
|
+
a_frac = 1.0 - u_frac
|
|
296
|
+
u_chars = round(u_frac * 22)
|
|
297
|
+
a_chars = 22 - u_chars
|
|
298
|
+
split_bar = f"[cyan]{'█' * u_chars}[/cyan][green]{'█' * a_chars}[/green]"
|
|
299
|
+
insight_lines.append(
|
|
300
|
+
f" [dim]U/A split [/dim]{split_bar}"
|
|
301
|
+
f" [cyan]{int(u_frac * 100)}% user[/cyan] "
|
|
302
|
+
f"[green]{int(a_frac * 100)}% asst[/green]"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Tool overhead
|
|
306
|
+
if report.has_tool_data and report.total > 0:
|
|
307
|
+
tool_total = report.tool_call_total + report.tool_result_total
|
|
308
|
+
t_frac = tool_total / report.total
|
|
309
|
+
t_bar = _frac_bar(t_frac, 22)
|
|
310
|
+
t_col = "yellow" if t_frac >= 0.5 else "dim"
|
|
311
|
+
insight_lines.append(
|
|
312
|
+
f" [dim]Tool load [/dim][{t_col}]{t_bar}[/{t_col}]"
|
|
313
|
+
f" [{t_col}]{int(t_frac * 100)}%[/{t_col}] [dim]of tokens in tool calls/results[/dim]"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Conversation shape: are later turns heavier than earlier?
|
|
317
|
+
if len(report.turns) >= 4:
|
|
318
|
+
half = len(report.turns) // 2
|
|
319
|
+
first_avg = sum(t.tokens for t in report.turns[:half]) / half
|
|
320
|
+
last_avg = sum(t.tokens for t in report.turns[half:]) / half
|
|
321
|
+
if first_avg > 0:
|
|
322
|
+
ratio = last_avg / first_avg
|
|
323
|
+
if ratio >= 1.3:
|
|
324
|
+
shape = "[yellow]↗ growing[/yellow] [dim](context is building up)[/dim]"
|
|
325
|
+
elif ratio <= 0.7:
|
|
326
|
+
shape = "[green]↘ tapering[/green] [dim](turns getting shorter)[/dim]"
|
|
327
|
+
else:
|
|
328
|
+
shape = "[dim]→ uniform (turns are evenly sized)[/dim]"
|
|
329
|
+
insight_lines.append(f" [dim]Shape [/dim]{shape}")
|
|
330
|
+
|
|
331
|
+
insights.update("\n".join(insight_lines))
|
|
332
|
+
|
|
333
|
+
# ── Table ─────────────────────────────────────────────────────────
|
|
334
|
+
if report.has_tool_data:
|
|
335
|
+
table.add_columns("#", "Role", "Tokens", "Text", "Calls", "Results", "Cumul.", "Bar", "Preview")
|
|
336
|
+
else:
|
|
337
|
+
table.add_columns("#", "Role", "Tokens", "Cumul.", "Bar", "Preview")
|
|
338
|
+
|
|
339
|
+
max_tokens = max(t.tokens for t in report.turns)
|
|
340
|
+
|
|
341
|
+
for t in report.turns:
|
|
342
|
+
bar_str = _frac_bar(t.tokens / max_tokens if max_tokens else 0, self._BAR_WIDTH)
|
|
343
|
+
|
|
344
|
+
if t.role == "user":
|
|
345
|
+
role_str = "[cyan]👤 user[/cyan]"
|
|
346
|
+
bar_str = f"[cyan]{bar_str}[/cyan]"
|
|
347
|
+
elif t.role == "assistant":
|
|
348
|
+
role_str = "[green]🤖 asst[/green]"
|
|
349
|
+
bar_str = f"[green]{bar_str}[/green]"
|
|
350
|
+
else:
|
|
351
|
+
role_str = f"[dim]{t.role}[/dim]"
|
|
352
|
+
bar_str = f"[dim]{bar_str}[/dim]"
|
|
353
|
+
|
|
354
|
+
tok_str = f"[bold]{t.tokens:,}[/bold]" if max_tokens and t.tokens == max_tokens else f"{t.tokens:,}"
|
|
355
|
+
|
|
356
|
+
if report.has_tool_data:
|
|
357
|
+
calls_str = f"[yellow]{t.tool_call_tokens:,}[/yellow]" if t.tool_call_tokens else "[dim]—[/dim]"
|
|
358
|
+
results_str = f"[magenta]{t.tool_result_tokens:,}[/magenta]" if t.tool_result_tokens else "[dim]—[/dim]"
|
|
359
|
+
table.add_row(
|
|
360
|
+
str(t.turn), role_str, tok_str,
|
|
361
|
+
f"{t.text_tokens:,}", calls_str, results_str,
|
|
362
|
+
f"{t.cumulative:,}", bar_str, t.content_preview,
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
table.add_row(
|
|
366
|
+
str(t.turn), role_str, tok_str,
|
|
367
|
+
f"{t.cumulative:,}", bar_str, t.content_preview,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if max_t:
|
|
371
|
+
table.move_cursor(row=max_t.turn - 1)
|
|
372
|
+
table.focus()
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
"""TokensPanel — modal screen showing per-turn token analysis for a session."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
from rich.text import Text
|
|
8
|
-
from textual.app import ComposeResult
|
|
9
|
-
from textual.binding import Binding
|
|
10
|
-
from textual.containers import ScrollableContainer, Vertical
|
|
11
|
-
from textual.screen import ModalScreen
|
|
12
|
-
from textual.widgets import DataTable, Static
|
|
13
|
-
|
|
14
|
-
from contextforge.models.session import Message
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class TurnDetailPanel(ModalScreen):
|
|
18
|
-
"""Modal showing the full content of a single turn."""
|
|
19
|
-
|
|
20
|
-
BINDINGS = [
|
|
21
|
-
Binding("escape,q", "dismiss(None)", "Close"),
|
|
22
|
-
]
|
|
23
|
-
|
|
24
|
-
DEFAULT_CSS = """
|
|
25
|
-
TurnDetailPanel {
|
|
26
|
-
align: center middle;
|
|
27
|
-
}
|
|
28
|
-
TurnDetailPanel > Vertical {
|
|
29
|
-
width: 92%;
|
|
30
|
-
height: 88%;
|
|
31
|
-
border: thick $accent;
|
|
32
|
-
background: $surface;
|
|
33
|
-
padding: 0 1;
|
|
34
|
-
}
|
|
35
|
-
TurnDetailPanel #turn-header {
|
|
36
|
-
height: auto;
|
|
37
|
-
padding: 1 1 0 1;
|
|
38
|
-
border-bottom: solid $primary-background-lighten-1;
|
|
39
|
-
margin-bottom: 1;
|
|
40
|
-
}
|
|
41
|
-
TurnDetailPanel ScrollableContainer {
|
|
42
|
-
height: 1fr;
|
|
43
|
-
padding: 0 1;
|
|
44
|
-
}
|
|
45
|
-
TurnDetailPanel .section-label {
|
|
46
|
-
color: $text-muted;
|
|
47
|
-
text-style: bold;
|
|
48
|
-
margin-top: 1;
|
|
49
|
-
}
|
|
50
|
-
TurnDetailPanel .content-block {
|
|
51
|
-
color: $text;
|
|
52
|
-
margin-bottom: 1;
|
|
53
|
-
}
|
|
54
|
-
TurnDetailPanel .tool-name {
|
|
55
|
-
color: $warning;
|
|
56
|
-
text-style: bold;
|
|
57
|
-
}
|
|
58
|
-
TurnDetailPanel .tool-input {
|
|
59
|
-
color: $text-muted;
|
|
60
|
-
margin-left: 2;
|
|
61
|
-
}
|
|
62
|
-
TurnDetailPanel .tool-output {
|
|
63
|
-
color: $success;
|
|
64
|
-
margin-left: 2;
|
|
65
|
-
}
|
|
66
|
-
TurnDetailPanel #turn-footer {
|
|
67
|
-
height: 1;
|
|
68
|
-
color: $text-disabled;
|
|
69
|
-
text-align: center;
|
|
70
|
-
}
|
|
71
|
-
"""
|
|
72
|
-
|
|
73
|
-
def __init__(self, turn_num: int, msg: Message) -> None:
|
|
74
|
-
super().__init__()
|
|
75
|
-
self._turn_num = turn_num
|
|
76
|
-
self._msg = msg
|
|
77
|
-
|
|
78
|
-
def compose(self) -> ComposeResult:
|
|
79
|
-
with Vertical():
|
|
80
|
-
yield Static("", id="turn-header")
|
|
81
|
-
with ScrollableContainer():
|
|
82
|
-
yield Static("", id="turn-body")
|
|
83
|
-
yield Static("ESC / q — close", id="turn-footer")
|
|
84
|
-
|
|
85
|
-
def on_mount(self) -> None:
|
|
86
|
-
msg = self._msg
|
|
87
|
-
role_color = "cyan" if msg.role == "user" else "green"
|
|
88
|
-
|
|
89
|
-
self.query_one("#turn-header", Static).update(
|
|
90
|
-
f"[bold]Turn #{self._turn_num}[/bold] "
|
|
91
|
-
f"[{role_color}]{msg.role}[/{role_color}] "
|
|
92
|
-
f"[dim]{(msg.token_count or 0):,} tokens[/dim]"
|
|
93
|
-
+ (f" [yellow]{len(msg.tool_calls)} call(s)[/yellow]" if msg.tool_calls else "")
|
|
94
|
-
+ (f" [magenta]{len(msg.tool_results)} result(s)[/magenta]" if msg.tool_results else "")
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
body = Text()
|
|
98
|
-
|
|
99
|
-
# ── Text content ────────────────────────────────────────────────────
|
|
100
|
-
if msg.content:
|
|
101
|
-
body.append("── Content ──\n", style="bold dim")
|
|
102
|
-
body.append(msg.content + "\n")
|
|
103
|
-
|
|
104
|
-
# ── Tool calls ──────────────────────────────────────────────────────
|
|
105
|
-
if msg.tool_calls:
|
|
106
|
-
body.append("\n── Tool Calls ──\n", style="bold dim")
|
|
107
|
-
for i, tc in enumerate(msg.tool_calls, 1):
|
|
108
|
-
name = tc.get("name", "?")
|
|
109
|
-
raw_input = tc.get("input", "")
|
|
110
|
-
try:
|
|
111
|
-
formatted = json.dumps(json.loads(raw_input), indent=2)
|
|
112
|
-
except (json.JSONDecodeError, TypeError):
|
|
113
|
-
formatted = raw_input if isinstance(raw_input, str) else str(raw_input)
|
|
114
|
-
body.append(f"{i}. {name}\n", style="bold yellow")
|
|
115
|
-
body.append(formatted + "\n", style="dim")
|
|
116
|
-
|
|
117
|
-
# ── Tool results ────────────────────────────────────────────────────
|
|
118
|
-
if msg.tool_results:
|
|
119
|
-
body.append("\n── Tool Results ──\n", style="bold dim")
|
|
120
|
-
for i, tr in enumerate(msg.tool_results, 1):
|
|
121
|
-
output = tr.get("output", "")
|
|
122
|
-
if not isinstance(output, str):
|
|
123
|
-
output = str(output)
|
|
124
|
-
body.append(f"Result {i}\n", style="bold green")
|
|
125
|
-
body.append(output + "\n", style="dim")
|
|
126
|
-
|
|
127
|
-
self.query_one("#turn-body", Static).update(body)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
class TokensPanel(ModalScreen):
|
|
131
|
-
"""Full-screen modal showing token breakdown for a session."""
|
|
132
|
-
|
|
133
|
-
BINDINGS = [
|
|
134
|
-
Binding("escape,q", "dismiss(None)", "Close"),
|
|
135
|
-
]
|
|
136
|
-
|
|
137
|
-
DEFAULT_CSS = """
|
|
138
|
-
TokensPanel {
|
|
139
|
-
align: center middle;
|
|
140
|
-
}
|
|
141
|
-
TokensPanel > Vertical {
|
|
142
|
-
width: 90%;
|
|
143
|
-
height: 85%;
|
|
144
|
-
border: thick $primary;
|
|
145
|
-
background: $surface;
|
|
146
|
-
padding: 0 1;
|
|
147
|
-
}
|
|
148
|
-
TokensPanel #tokens-header {
|
|
149
|
-
height: auto;
|
|
150
|
-
padding: 1 1 0 1;
|
|
151
|
-
color: $text;
|
|
152
|
-
}
|
|
153
|
-
TokensPanel #tokens-stats {
|
|
154
|
-
height: auto;
|
|
155
|
-
padding: 0 1 1 1;
|
|
156
|
-
color: $text-muted;
|
|
157
|
-
border-bottom: solid $primary-background-lighten-1;
|
|
158
|
-
margin-bottom: 1;
|
|
159
|
-
}
|
|
160
|
-
TokensPanel DataTable {
|
|
161
|
-
height: 1fr;
|
|
162
|
-
}
|
|
163
|
-
TokensPanel #tokens-footer {
|
|
164
|
-
height: 1;
|
|
165
|
-
color: $text-disabled;
|
|
166
|
-
text-align: center;
|
|
167
|
-
padding: 0 1;
|
|
168
|
-
}
|
|
169
|
-
"""
|
|
170
|
-
|
|
171
|
-
_BAR_MAX = 24
|
|
172
|
-
|
|
173
|
-
def __init__(self, session_id: str, db_path: Path) -> None:
|
|
174
|
-
super().__init__()
|
|
175
|
-
self._session_id = session_id
|
|
176
|
-
self._db_path = db_path
|
|
177
|
-
self._messages: list[Message] = []
|
|
178
|
-
|
|
179
|
-
def compose(self) -> ComposeResult:
|
|
180
|
-
with Vertical():
|
|
181
|
-
yield Static("", id="tokens-header")
|
|
182
|
-
yield Static("", id="tokens-stats")
|
|
183
|
-
yield DataTable(id="tokens-table", show_cursor=True, zebra_stripes=True, cursor_type="row")
|
|
184
|
-
yield Static("ESC / q — close │ Enter — view turn", id="tokens-footer")
|
|
185
|
-
|
|
186
|
-
def on_mount(self) -> None:
|
|
187
|
-
self._load()
|
|
188
|
-
|
|
189
|
-
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
190
|
-
row_idx = event.cursor_row
|
|
191
|
-
if 0 <= row_idx < len(self._messages):
|
|
192
|
-
self.app.push_screen(TurnDetailPanel(row_idx + 1, self._messages[row_idx]))
|
|
193
|
-
|
|
194
|
-
def _load(self) -> None:
|
|
195
|
-
from contextforge.adapters.registry import get_adapter
|
|
196
|
-
from contextforge.core.db import get_db, get_session
|
|
197
|
-
from contextforge.core.token_analyzer import analyze_tokens
|
|
198
|
-
|
|
199
|
-
db = get_db(self._db_path)
|
|
200
|
-
report = analyze_tokens(db, self._session_id)
|
|
201
|
-
|
|
202
|
-
# Load messages so TurnDetailPanel can show full content
|
|
203
|
-
row = get_session(db, self._session_id)
|
|
204
|
-
if row is not None:
|
|
205
|
-
try:
|
|
206
|
-
adapter = get_adapter(row["tool"])
|
|
207
|
-
self._messages = adapter.load_messages(self._session_id)
|
|
208
|
-
except Exception:
|
|
209
|
-
self._messages = []
|
|
210
|
-
|
|
211
|
-
header = self.query_one("#tokens-header", Static)
|
|
212
|
-
stats = self.query_one("#tokens-stats", Static)
|
|
213
|
-
table = self.query_one("#tokens-table", DataTable)
|
|
214
|
-
|
|
215
|
-
if report is None or not report.turns:
|
|
216
|
-
header.update("[bold red]No token data available.[/bold red]")
|
|
217
|
-
return
|
|
218
|
-
|
|
219
|
-
# ── Header ──────────────────────────────────────────────────────────
|
|
220
|
-
header.update(
|
|
221
|
-
f"[bold accent]Token Analysis[/bold accent] "
|
|
222
|
-
f"[dim]{report.title[:60]}[/dim] "
|
|
223
|
-
f"[dim]{report.tool}[/dim]"
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
-
# ── Stats bar ───────────────────────────────────────────────────────
|
|
227
|
-
max_t = report.max_turn
|
|
228
|
-
tool_summary = ""
|
|
229
|
-
if report.has_tool_data:
|
|
230
|
-
tool_summary = (
|
|
231
|
-
f" │ [yellow]Calls {report.tool_call_total:,}[/yellow]"
|
|
232
|
-
f" [magenta]Results {report.tool_result_total:,}[/magenta]"
|
|
233
|
-
)
|
|
234
|
-
stats.update(
|
|
235
|
-
f"Total [bold]{report.total:,}[/bold] tokens │ "
|
|
236
|
-
f"[cyan]User {report.user_total:,}[/cyan] (avg {report.avg_user:,.0f}) │ "
|
|
237
|
-
f"[green]Asst {report.assistant_total:,}[/green] (avg {report.avg_assistant:,.0f}) │ "
|
|
238
|
-
f"{report.turn_count} turns"
|
|
239
|
-
+ tool_summary
|
|
240
|
-
+ (f" │ Heaviest: turn #{max_t.turn} "
|
|
241
|
-
f"[bold]{max_t.tokens:,}[/bold] tok [{max_t.role}]"
|
|
242
|
-
if max_t else "")
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
# ── Table ────────────────────────────────────────────────────────────
|
|
246
|
-
if report.has_tool_data:
|
|
247
|
-
table.add_columns("#", "Role", "Tokens", "Text", "Calls", "Results", "Cumul.", "Bar", "Preview")
|
|
248
|
-
else:
|
|
249
|
-
table.add_columns("#", "Role", "Tokens", "Cumul.", "Bar", "Preview")
|
|
250
|
-
|
|
251
|
-
max_tokens = max(t.tokens for t in report.turns)
|
|
252
|
-
|
|
253
|
-
for t in report.turns:
|
|
254
|
-
bar_len = max(1, int(t.tokens / max_tokens * self._BAR_MAX))
|
|
255
|
-
|
|
256
|
-
if t.role == "user":
|
|
257
|
-
role_str = "[cyan]user[/cyan]"
|
|
258
|
-
bar_str = f"[cyan]{'█' * bar_len}[/cyan]"
|
|
259
|
-
elif t.role == "assistant":
|
|
260
|
-
role_str = "[green]asst[/green]"
|
|
261
|
-
bar_str = f"[green]{'█' * bar_len}[/green]"
|
|
262
|
-
else:
|
|
263
|
-
role_str = f"[dim]{t.role}[/dim]"
|
|
264
|
-
bar_str = f"[dim]{'█' * bar_len}[/dim]"
|
|
265
|
-
|
|
266
|
-
if report.has_tool_data:
|
|
267
|
-
calls_str = f"[yellow]{t.tool_call_tokens:,}[/yellow]" if t.tool_call_tokens else "[dim]—[/dim]"
|
|
268
|
-
results_str = f"[magenta]{t.tool_result_tokens:,}[/magenta]" if t.tool_result_tokens else "[dim]—[/dim]"
|
|
269
|
-
table.add_row(
|
|
270
|
-
str(t.turn),
|
|
271
|
-
role_str,
|
|
272
|
-
f"{t.tokens:,}",
|
|
273
|
-
f"{t.text_tokens:,}",
|
|
274
|
-
calls_str,
|
|
275
|
-
results_str,
|
|
276
|
-
f"{t.cumulative:,}",
|
|
277
|
-
bar_str,
|
|
278
|
-
t.content_preview,
|
|
279
|
-
)
|
|
280
|
-
else:
|
|
281
|
-
table.add_row(
|
|
282
|
-
str(t.turn),
|
|
283
|
-
role_str,
|
|
284
|
-
f"{t.tokens:,}",
|
|
285
|
-
f"{t.cumulative:,}",
|
|
286
|
-
bar_str,
|
|
287
|
-
t.content_preview,
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
# Highlight the heaviest row and ensure the table has focus
|
|
291
|
-
if max_t:
|
|
292
|
-
table.move_cursor(row=max_t.turn - 1)
|
|
293
|
-
table.focus()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/widgets/session_detail.py
RENAMED
|
File without changes
|
{context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/widgets/session_table.py
RENAMED
|
File without changes
|
|
File without changes
|
{context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/contextforge/tui/widgets/transfer_panel.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{context_forge_cli-0.4.5 → context_forge_cli-0.4.6}/tests/fixtures/claude_session_sample.jsonl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|