context-forge-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- context_forge_cli-0.1.0.dist-info/METADATA +340 -0
- context_forge_cli-0.1.0.dist-info/RECORD +37 -0
- context_forge_cli-0.1.0.dist-info/WHEEL +4 -0
- context_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- contextforge/__init__.py +3 -0
- contextforge/adapters/__init__.py +3 -0
- contextforge/adapters/altimate_code.py +179 -0
- contextforge/adapters/base.py +44 -0
- contextforge/adapters/claude_code.py +393 -0
- contextforge/adapters/claude_desktop.py +306 -0
- contextforge/adapters/codex.py +220 -0
- contextforge/adapters/registry.py +31 -0
- contextforge/cli.py +523 -0
- contextforge/core/__init__.py +0 -0
- contextforge/core/analytics.py +226 -0
- contextforge/core/compactor.py +182 -0
- contextforge/core/db.py +181 -0
- contextforge/core/injector.py +94 -0
- contextforge/core/scanner.py +72 -0
- contextforge/core/summarizer.py +155 -0
- contextforge/core/token_analyzer.py +91 -0
- contextforge/models/__init__.py +4 -0
- contextforge/models/config.py +34 -0
- contextforge/models/session.py +37 -0
- contextforge/tui/__init__.py +0 -0
- contextforge/tui/app.py +204 -0
- contextforge/tui/styles.tcss +50 -0
- contextforge/tui/widgets/__init__.py +0 -0
- contextforge/tui/widgets/session_detail.py +191 -0
- contextforge/tui/widgets/session_table.py +277 -0
- contextforge/tui/widgets/stats_panel.py +340 -0
- contextforge/tui/widgets/status_bar.py +102 -0
- contextforge/tui/widgets/tokens_panel.py +134 -0
- contextforge/tui/widgets/transfer_panel.py +119 -0
- contextforge/utils/__init__.py +0 -0
- contextforge/utils/display.py +105 -0
- contextforge/utils/tokens.py +17 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""StatsPanel — analytics modal with time-window charts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.reactive import reactive
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Static
|
|
12
|
+
|
|
13
|
+
# ── Unicode rendering helpers ─────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
_BLOCKS = " ▏▎▍▌▋▊▉█" # 9 levels: 0/8 → 8/8 of a cell
|
|
16
|
+
_SPARK = "▁▂▃▄▅▆▇█" # 8 vertical height levels
|
|
17
|
+
|
|
18
|
+
TOOL_COLORS = {
|
|
19
|
+
"claude_code": "cyan",
|
|
20
|
+
"codex": "green",
|
|
21
|
+
"altimate_code": "magenta",
|
|
22
|
+
"claude_desktop": "yellow",
|
|
23
|
+
}
|
|
24
|
+
TOOL_LABELS = {
|
|
25
|
+
"claude_code": "Claude Code",
|
|
26
|
+
"codex": "Codex",
|
|
27
|
+
"altimate_code": "altimate",
|
|
28
|
+
"claude_desktop": "Desktop",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
WINDOW_LABELS = {
|
|
32
|
+
"7d": "7 days",
|
|
33
|
+
"30d": "30 days",
|
|
34
|
+
"6m": "6 months",
|
|
35
|
+
"1y": "1 year",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _unicode_bar(fraction: float, width: int = 32) -> str:
|
|
40
|
+
if fraction <= 0:
|
|
41
|
+
return " " * width
|
|
42
|
+
total_eighths = round(fraction * width * 8)
|
|
43
|
+
full_cells = min(total_eighths // 8, width)
|
|
44
|
+
remainder = total_eighths % 8
|
|
45
|
+
bar = "█" * full_cells
|
|
46
|
+
if full_cells < width and remainder > 0:
|
|
47
|
+
bar += _BLOCKS[remainder]
|
|
48
|
+
return bar.ljust(width)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _render_bar_line(
|
|
52
|
+
label: str,
|
|
53
|
+
value: int,
|
|
54
|
+
max_value: int,
|
|
55
|
+
color: str = "cyan",
|
|
56
|
+
label_width: int = 14,
|
|
57
|
+
bar_width: int = 32,
|
|
58
|
+
) -> str:
|
|
59
|
+
fraction = value / max_value if max_value else 0
|
|
60
|
+
bar = _unicode_bar(fraction, bar_width)
|
|
61
|
+
val_str = _fmt_tokens(value) if value >= 1000 else str(value)
|
|
62
|
+
return f"[dim]{label:<{label_width}}[/dim][{color}]{bar}[/{color}] [bold]{val_str}[/bold]"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fmt_tokens(n: int) -> str:
|
|
66
|
+
if n >= 1_000_000:
|
|
67
|
+
return f"{n / 1_000_000:.1f}M"
|
|
68
|
+
if n >= 1_000:
|
|
69
|
+
return f"{n // 1_000}k"
|
|
70
|
+
return str(n)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _spark_char(value: int, max_value: int) -> str:
|
|
74
|
+
if not max_value or not value:
|
|
75
|
+
return " "
|
|
76
|
+
idx = max(0, min(7, int(value / max_value * 8) - 1))
|
|
77
|
+
return _SPARK[idx]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _render_sparkline(buckets: list, width: int = 52) -> str:
|
|
81
|
+
"""Return a two-line Rich markup string: bar row + sparse date labels."""
|
|
82
|
+
if not buckets:
|
|
83
|
+
return "[dim](no data)[/dim]"
|
|
84
|
+
|
|
85
|
+
max_count = max(b.count for b in buckets) if buckets else 0
|
|
86
|
+
|
|
87
|
+
# Downsample if too many buckets
|
|
88
|
+
if len(buckets) > width:
|
|
89
|
+
step = len(buckets) / width
|
|
90
|
+
sampled = []
|
|
91
|
+
for i in range(width):
|
|
92
|
+
idx = min(int(i * step), len(buckets) - 1)
|
|
93
|
+
sampled.append(buckets[idx])
|
|
94
|
+
buckets = sampled
|
|
95
|
+
|
|
96
|
+
# Compute quartiles for coloring
|
|
97
|
+
counts = sorted(b.count for b in buckets if b.count > 0)
|
|
98
|
+
q1 = counts[len(counts) // 4] if counts else 1
|
|
99
|
+
q3 = counts[3 * len(counts) // 4] if counts else 1
|
|
100
|
+
|
|
101
|
+
bar_chars: list[str] = []
|
|
102
|
+
for b in buckets:
|
|
103
|
+
ch = _spark_char(b.count, max_count)
|
|
104
|
+
if b.count == 0:
|
|
105
|
+
bar_chars.append(f"[dim]{ch}[/dim]")
|
|
106
|
+
elif b.count >= q3:
|
|
107
|
+
bar_chars.append(f"[yellow]{ch}[/yellow]")
|
|
108
|
+
elif b.count >= q1:
|
|
109
|
+
bar_chars.append(f"[green]{ch}[/green]")
|
|
110
|
+
else:
|
|
111
|
+
bar_chars.append(f"[dim]{ch}[/dim]")
|
|
112
|
+
|
|
113
|
+
spark_line = "".join(bar_chars)
|
|
114
|
+
|
|
115
|
+
# Build sparse date label row (first, middle, last)
|
|
116
|
+
n = len(buckets)
|
|
117
|
+
label_line = [" "] * n
|
|
118
|
+
if n > 0:
|
|
119
|
+
first_lbl = buckets[0].label[-5:] # last 5 chars e.g. "04-12" or "W15"
|
|
120
|
+
last_lbl = buckets[-1].label[-5:]
|
|
121
|
+
mid_lbl = buckets[n // 2].label[-5:]
|
|
122
|
+
|
|
123
|
+
# Place labels without overlap
|
|
124
|
+
label_line[0] = first_lbl[0]
|
|
125
|
+
for i, ch in enumerate(first_lbl):
|
|
126
|
+
if i < n:
|
|
127
|
+
label_line[i] = ch
|
|
128
|
+
|
|
129
|
+
mid_start = n // 2
|
|
130
|
+
for i, ch in enumerate(mid_lbl):
|
|
131
|
+
pos = mid_start + i
|
|
132
|
+
if pos < n:
|
|
133
|
+
label_line[pos] = ch
|
|
134
|
+
|
|
135
|
+
last_start = max(n - len(last_lbl), 0)
|
|
136
|
+
for i, ch in enumerate(last_lbl):
|
|
137
|
+
pos = last_start + i
|
|
138
|
+
if pos < n:
|
|
139
|
+
label_line[pos] = ch
|
|
140
|
+
|
|
141
|
+
date_line = f"[dim]{''.join(label_line)}[/dim]"
|
|
142
|
+
return spark_line + "\n" + date_line
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── Modal screen ──────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class StatsPanel(ModalScreen):
|
|
149
|
+
"""Analytics overview modal. Open with 'a', close with ESC/q."""
|
|
150
|
+
|
|
151
|
+
BINDINGS = [
|
|
152
|
+
Binding("escape,q", "dismiss(None)", "Close"),
|
|
153
|
+
Binding("w", "set_window_7d", "7d"),
|
|
154
|
+
Binding("m", "set_window_30d", "30d"),
|
|
155
|
+
Binding("h", "set_window_6m", "6m"),
|
|
156
|
+
Binding("y", "set_window_1y", "1y"),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
DEFAULT_CSS = """
|
|
160
|
+
StatsPanel {
|
|
161
|
+
align: center middle;
|
|
162
|
+
}
|
|
163
|
+
StatsPanel > Vertical {
|
|
164
|
+
width: 82%;
|
|
165
|
+
max-width: 110;
|
|
166
|
+
height: 88%;
|
|
167
|
+
border: thick $primary;
|
|
168
|
+
background: $surface;
|
|
169
|
+
padding: 1 2;
|
|
170
|
+
overflow-y: auto;
|
|
171
|
+
}
|
|
172
|
+
StatsPanel #stats-title {
|
|
173
|
+
text-style: bold;
|
|
174
|
+
color: $accent;
|
|
175
|
+
margin-bottom: 1;
|
|
176
|
+
}
|
|
177
|
+
StatsPanel #stats-overview {
|
|
178
|
+
color: $text-muted;
|
|
179
|
+
margin-bottom: 1;
|
|
180
|
+
border-bottom: solid $primary-background-lighten-1;
|
|
181
|
+
padding-bottom: 1;
|
|
182
|
+
}
|
|
183
|
+
StatsPanel .chart-title {
|
|
184
|
+
text-style: bold;
|
|
185
|
+
color: $text;
|
|
186
|
+
margin-top: 1;
|
|
187
|
+
}
|
|
188
|
+
StatsPanel #bar-sessions {
|
|
189
|
+
margin-bottom: 1;
|
|
190
|
+
}
|
|
191
|
+
StatsPanel #bar-tokens {
|
|
192
|
+
margin-bottom: 1;
|
|
193
|
+
}
|
|
194
|
+
StatsPanel #sparkline {
|
|
195
|
+
margin-bottom: 1;
|
|
196
|
+
}
|
|
197
|
+
StatsPanel #projects {
|
|
198
|
+
margin-bottom: 1;
|
|
199
|
+
}
|
|
200
|
+
StatsPanel #stats-footer {
|
|
201
|
+
color: $text-disabled;
|
|
202
|
+
text-align: center;
|
|
203
|
+
margin-top: 1;
|
|
204
|
+
border-top: solid $primary-background-lighten-1;
|
|
205
|
+
padding-top: 1;
|
|
206
|
+
}
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
window: reactive[str] = reactive("30d")
|
|
210
|
+
|
|
211
|
+
def __init__(self) -> None:
|
|
212
|
+
super().__init__()
|
|
213
|
+
|
|
214
|
+
def compose(self) -> ComposeResult:
|
|
215
|
+
with Vertical():
|
|
216
|
+
yield Static("", id="stats-title")
|
|
217
|
+
yield Static("", id="stats-overview")
|
|
218
|
+
yield Static("Sessions by tool", classes="chart-title")
|
|
219
|
+
yield Static("", id="bar-sessions")
|
|
220
|
+
yield Static("Token usage by tool", classes="chart-title")
|
|
221
|
+
yield Static("", id="bar-tokens")
|
|
222
|
+
yield Static("Activity over time", classes="chart-title")
|
|
223
|
+
yield Static("", id="sparkline")
|
|
224
|
+
yield Static("Top projects", classes="chart-title")
|
|
225
|
+
yield Static("", id="projects")
|
|
226
|
+
yield Static(
|
|
227
|
+
"ESC/q — close │ W=7d M=30d H=6m Y=1y",
|
|
228
|
+
id="stats-footer",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def on_mount(self) -> None:
|
|
232
|
+
self._refresh_data()
|
|
233
|
+
|
|
234
|
+
def watch_window(self, new_window: str) -> None:
|
|
235
|
+
self._refresh_data()
|
|
236
|
+
|
|
237
|
+
# ── Window actions ────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
def action_set_window_7d(self) -> None:
|
|
240
|
+
self.window = "7d"
|
|
241
|
+
|
|
242
|
+
def action_set_window_30d(self) -> None:
|
|
243
|
+
self.window = "30d"
|
|
244
|
+
|
|
245
|
+
def action_set_window_6m(self) -> None:
|
|
246
|
+
self.window = "6m"
|
|
247
|
+
|
|
248
|
+
def action_set_window_1y(self) -> None:
|
|
249
|
+
self.window = "1y"
|
|
250
|
+
|
|
251
|
+
# ── Data loading ──────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
def _refresh_data(self) -> None:
|
|
254
|
+
db_path: Path | None = getattr(self.app, "db_path", None)
|
|
255
|
+
if db_path is None:
|
|
256
|
+
self.query_one("#stats-overview", Static).update("[red]No database path.[/red]")
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
from contextforge.core import analytics
|
|
260
|
+
from contextforge.core.db import get_db
|
|
261
|
+
|
|
262
|
+
db = get_db(db_path)
|
|
263
|
+
overview = analytics.get_overview(db, self.window)
|
|
264
|
+
activity = analytics.get_activity_over_time(db, self.window)
|
|
265
|
+
projects = analytics.get_top_projects(db, self.window)
|
|
266
|
+
|
|
267
|
+
self._render_all(overview, activity, projects)
|
|
268
|
+
|
|
269
|
+
def _render_all(self, overview, activity: list, projects: list) -> None:
|
|
270
|
+
w = self.window
|
|
271
|
+
label = WINDOW_LABELS.get(w, w)
|
|
272
|
+
|
|
273
|
+
# ── Title ──────────────────────────────────────────────────────────
|
|
274
|
+
window_indicators = " ".join(
|
|
275
|
+
f"[bold cyan]▶ {k}[/bold cyan]" if k == w else f"[dim]{k.upper()[0]}[/dim]"
|
|
276
|
+
for k in ("7d", "30d", "6m", "1y")
|
|
277
|
+
)
|
|
278
|
+
self.query_one("#stats-title", Static).update(
|
|
279
|
+
f"[bold]Analytics[/bold] {window_indicators} [dim]— last {label}[/dim]"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# ── Overview ───────────────────────────────────────────────────────
|
|
283
|
+
total_tok_str = _fmt_tokens(overview.total_tokens)
|
|
284
|
+
date_str = ""
|
|
285
|
+
if overview.date_range:
|
|
286
|
+
earliest, latest = overview.date_range
|
|
287
|
+
date_str = (
|
|
288
|
+
f" │ {earliest.strftime('%b %d')} → {latest.strftime('%b %d, %Y')}"
|
|
289
|
+
)
|
|
290
|
+
tools_str = ", ".join(
|
|
291
|
+
TOOL_LABELS.get(t, t) for t in sorted(overview.active_tools)
|
|
292
|
+
) or "—"
|
|
293
|
+
self.query_one("#stats-overview", Static).update(
|
|
294
|
+
f"[bold]{overview.total_sessions:,}[/bold] sessions │ "
|
|
295
|
+
f"[bold]{total_tok_str}[/bold] tokens │ "
|
|
296
|
+
f"Tools: [dim]{tools_str}[/dim]"
|
|
297
|
+
f"{date_str}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# ── Sessions bar chart ─────────────────────────────────────────────
|
|
301
|
+
tool_order = ["claude_code", "codex", "altimate_code", "claude_desktop"]
|
|
302
|
+
max_sess = max(overview.session_count_by_tool.values(), default=1)
|
|
303
|
+
sess_lines: list[str] = []
|
|
304
|
+
for tool in tool_order:
|
|
305
|
+
cnt = overview.session_count_by_tool.get(tool, 0)
|
|
306
|
+
if cnt == 0:
|
|
307
|
+
continue
|
|
308
|
+
color = TOOL_COLORS.get(tool, "white")
|
|
309
|
+
label_str = TOOL_LABELS.get(tool, tool)
|
|
310
|
+
sess_lines.append(_render_bar_line(label_str, cnt, max_sess, color))
|
|
311
|
+
self.query_one("#bar-sessions", Static).update(
|
|
312
|
+
"\n".join(sess_lines) if sess_lines else "[dim](no data)[/dim]"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# ── Token bar chart ────────────────────────────────────────────────
|
|
316
|
+
max_tok = max(overview.token_sum_by_tool.values(), default=1)
|
|
317
|
+
tok_lines: list[str] = []
|
|
318
|
+
for tool in tool_order:
|
|
319
|
+
tok = overview.token_sum_by_tool.get(tool, 0)
|
|
320
|
+
if tok == 0:
|
|
321
|
+
continue
|
|
322
|
+
color = TOOL_COLORS.get(tool, "white")
|
|
323
|
+
label_str = TOOL_LABELS.get(tool, tool)
|
|
324
|
+
tok_lines.append(_render_bar_line(label_str, tok, max_tok, color))
|
|
325
|
+
self.query_one("#bar-tokens", Static).update(
|
|
326
|
+
"\n".join(tok_lines) if tok_lines else "[dim](no data)[/dim]"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# ── Sparkline ──────────────────────────────────────────────────────
|
|
330
|
+
self.query_one("#sparkline", Static).update(_render_sparkline(activity))
|
|
331
|
+
|
|
332
|
+
# ── Top projects ───────────────────────────────────────────────────
|
|
333
|
+
if projects:
|
|
334
|
+
proj_lines = [
|
|
335
|
+
f"[dim]{i + 1}.[/dim] [bold]{p.project}[/bold] [dim]({p.count})[/dim]"
|
|
336
|
+
for i, p in enumerate(projects)
|
|
337
|
+
]
|
|
338
|
+
self.query_one("#projects", Static).update("\n".join(proj_lines))
|
|
339
|
+
else:
|
|
340
|
+
self.query_one("#projects", Static).update("[dim](no data)[/dim]")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""StatusBar widget — bottom bar showing scan time and session counts by tool."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual.widget import Widget
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _fmt_tokens(n: int) -> str:
|
|
12
|
+
if n >= 1_000_000:
|
|
13
|
+
return f"{n / 1_000_000:.1f}M tok"
|
|
14
|
+
if n >= 1_000:
|
|
15
|
+
return f"{n // 1_000}k tok"
|
|
16
|
+
return f"{n} tok"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StatusBar(Widget):
|
|
20
|
+
"""Shows last scan time, per-tool session counts, and filter state."""
|
|
21
|
+
|
|
22
|
+
DEFAULT_CSS = """
|
|
23
|
+
StatusBar {
|
|
24
|
+
height: 1;
|
|
25
|
+
dock: bottom;
|
|
26
|
+
background: $primary-background-darken-2;
|
|
27
|
+
color: $text-muted;
|
|
28
|
+
padding: 0 1;
|
|
29
|
+
layout: horizontal;
|
|
30
|
+
}
|
|
31
|
+
StatusBar #status-counts {
|
|
32
|
+
width: auto;
|
|
33
|
+
margin-right: 2;
|
|
34
|
+
}
|
|
35
|
+
StatusBar #status-filter {
|
|
36
|
+
width: 1fr;
|
|
37
|
+
}
|
|
38
|
+
StatusBar #status-scan-time {
|
|
39
|
+
width: auto;
|
|
40
|
+
}
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def compose(self):
|
|
44
|
+
yield Static("", id="status-counts")
|
|
45
|
+
yield Static("", id="status-filter")
|
|
46
|
+
yield Static("", id="status-scan-time")
|
|
47
|
+
|
|
48
|
+
def refresh_stats(self) -> None:
|
|
49
|
+
"""Re-read DB and update the status bar."""
|
|
50
|
+
db_path: Path | None = getattr(self.app, "db_path", None)
|
|
51
|
+
if db_path is None:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
from contextforge.core.db import get_db, get_sessions
|
|
55
|
+
db = get_db(db_path)
|
|
56
|
+
rows = get_sessions(db, limit=10_000)
|
|
57
|
+
|
|
58
|
+
counts: dict[str, int] = {}
|
|
59
|
+
total_tokens = 0
|
|
60
|
+
for row in rows:
|
|
61
|
+
tool = row.get("tool", "unknown")
|
|
62
|
+
counts[tool] = counts.get(tool, 0) + 1
|
|
63
|
+
total_tokens += row.get("token_count") or 0
|
|
64
|
+
|
|
65
|
+
tool_parts = []
|
|
66
|
+
labels = {
|
|
67
|
+
"claude_code": "CC",
|
|
68
|
+
"codex": "Codex",
|
|
69
|
+
"altimate_code": "Alt",
|
|
70
|
+
"claude_desktop": "Desktop",
|
|
71
|
+
}
|
|
72
|
+
for tool, label in labels.items():
|
|
73
|
+
n = counts.get(tool, 0)
|
|
74
|
+
if n:
|
|
75
|
+
tool_parts.append(f"{label}:{n}")
|
|
76
|
+
|
|
77
|
+
tok_str = _fmt_tokens(total_tokens)
|
|
78
|
+
self.query_one("#status-counts").update(
|
|
79
|
+
f"Sessions — {' │ '.join(tool_parts)} │ Total:{len(rows)} │ {tok_str}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
now = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
83
|
+
self.query_one("#status-scan-time").update(f"Updated {now}")
|
|
84
|
+
|
|
85
|
+
def set_filter_indicator(
|
|
86
|
+
self, text: str, tool: str | None, match_count: int
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Show or clear the active filter indicator."""
|
|
89
|
+
if not text and tool is None:
|
|
90
|
+
self.query_one("#status-filter", Static).update("")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
parts: list[str] = []
|
|
94
|
+
if text:
|
|
95
|
+
parts.append(f'"{text}"')
|
|
96
|
+
if tool:
|
|
97
|
+
short = {"claude_code": "CC", "codex": "Codex", "altimate_code": "Alt", "claude_desktop": "Desktop"}.get(tool, tool)
|
|
98
|
+
parts.append(short)
|
|
99
|
+
|
|
100
|
+
self.query_one("#status-filter", Static).update(
|
|
101
|
+
f"[yellow]Filter: {' + '.join(parts)} ({match_count} matches)[/yellow]"
|
|
102
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""TokensPanel — modal screen showing per-turn token analysis for a session."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import DataTable, Footer, Static
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TokensPanel(ModalScreen):
|
|
14
|
+
"""Full-screen modal showing token breakdown for a session."""
|
|
15
|
+
|
|
16
|
+
BINDINGS = [
|
|
17
|
+
Binding("escape,q", "dismiss(None)", "Close"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
DEFAULT_CSS = """
|
|
21
|
+
TokensPanel {
|
|
22
|
+
align: center middle;
|
|
23
|
+
}
|
|
24
|
+
TokensPanel > Vertical {
|
|
25
|
+
width: 90%;
|
|
26
|
+
height: 85%;
|
|
27
|
+
border: thick $primary;
|
|
28
|
+
background: $surface;
|
|
29
|
+
padding: 0 1;
|
|
30
|
+
}
|
|
31
|
+
TokensPanel #tokens-header {
|
|
32
|
+
height: auto;
|
|
33
|
+
padding: 1 1 0 1;
|
|
34
|
+
color: $text;
|
|
35
|
+
}
|
|
36
|
+
TokensPanel #tokens-stats {
|
|
37
|
+
height: auto;
|
|
38
|
+
padding: 0 1 1 1;
|
|
39
|
+
color: $text-muted;
|
|
40
|
+
border-bottom: solid $primary-background-lighten-1;
|
|
41
|
+
margin-bottom: 1;
|
|
42
|
+
}
|
|
43
|
+
TokensPanel DataTable {
|
|
44
|
+
height: 1fr;
|
|
45
|
+
}
|
|
46
|
+
TokensPanel #tokens-footer {
|
|
47
|
+
height: 1;
|
|
48
|
+
color: $text-disabled;
|
|
49
|
+
text-align: center;
|
|
50
|
+
padding: 0 1;
|
|
51
|
+
}
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
_BAR_MAX = 24
|
|
55
|
+
|
|
56
|
+
def __init__(self, session_id: str, db_path: Path) -> None:
|
|
57
|
+
super().__init__()
|
|
58
|
+
self._session_id = session_id
|
|
59
|
+
self._db_path = db_path
|
|
60
|
+
|
|
61
|
+
def compose(self) -> ComposeResult:
|
|
62
|
+
with Vertical():
|
|
63
|
+
yield Static("", id="tokens-header")
|
|
64
|
+
yield Static("", id="tokens-stats")
|
|
65
|
+
yield DataTable(id="tokens-table", show_cursor=True, zebra_stripes=True)
|
|
66
|
+
yield Static("ESC / q — close", id="tokens-footer")
|
|
67
|
+
|
|
68
|
+
def on_mount(self) -> None:
|
|
69
|
+
self._load()
|
|
70
|
+
|
|
71
|
+
def _load(self) -> None:
|
|
72
|
+
from contextforge.core.db import get_db
|
|
73
|
+
from contextforge.core.token_analyzer import analyze_tokens
|
|
74
|
+
|
|
75
|
+
db = get_db(self._db_path)
|
|
76
|
+
report = analyze_tokens(db, self._session_id)
|
|
77
|
+
|
|
78
|
+
header = self.query_one("#tokens-header", Static)
|
|
79
|
+
stats = self.query_one("#tokens-stats", Static)
|
|
80
|
+
table = self.query_one("#tokens-table", DataTable)
|
|
81
|
+
|
|
82
|
+
if report is None or not report.turns:
|
|
83
|
+
header.update("[bold red]No token data available.[/bold red]")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# ── Header ──────────────────────────────────────────────────────────
|
|
87
|
+
header.update(
|
|
88
|
+
f"[bold accent]Token Analysis[/bold accent] "
|
|
89
|
+
f"[dim]{report.title[:60]}[/dim] "
|
|
90
|
+
f"[dim]{report.tool}[/dim]"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# ── Stats bar ───────────────────────────────────────────────────────
|
|
94
|
+
max_t = report.max_turn
|
|
95
|
+
stats.update(
|
|
96
|
+
f"Total [bold]{report.total:,}[/bold] tokens │ "
|
|
97
|
+
f"[cyan]User {report.user_total:,}[/cyan] (avg {report.avg_user:,.0f}) │ "
|
|
98
|
+
f"[green]Asst {report.assistant_total:,}[/green] (avg {report.avg_assistant:,.0f}) │ "
|
|
99
|
+
f"{report.turn_count} turns"
|
|
100
|
+
+ (f" │ Heaviest: turn #{max_t.turn} "
|
|
101
|
+
f"[bold]{max_t.tokens:,}[/bold] tok [{max_t.role}]"
|
|
102
|
+
if max_t else "")
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# ── Table ────────────────────────────────────────────────────────────
|
|
106
|
+
table.add_columns("#", "Role", "Tokens", "Cumul.", "Bar", "Preview")
|
|
107
|
+
|
|
108
|
+
max_tokens = max(t.tokens for t in report.turns)
|
|
109
|
+
|
|
110
|
+
for t in report.turns:
|
|
111
|
+
bar_len = max(1, int(t.tokens / max_tokens * self._BAR_MAX))
|
|
112
|
+
|
|
113
|
+
if t.role == "user":
|
|
114
|
+
role_str = "[cyan]user[/cyan]"
|
|
115
|
+
bar_str = f"[cyan]{'█' * bar_len}[/cyan]"
|
|
116
|
+
elif t.role == "assistant":
|
|
117
|
+
role_str = "[green]asst[/green]"
|
|
118
|
+
bar_str = f"[green]{'█' * bar_len}[/green]"
|
|
119
|
+
else:
|
|
120
|
+
role_str = f"[dim]{t.role}[/dim]"
|
|
121
|
+
bar_str = f"[dim]{'█' * bar_len}[/dim]"
|
|
122
|
+
|
|
123
|
+
table.add_row(
|
|
124
|
+
str(t.turn),
|
|
125
|
+
role_str,
|
|
126
|
+
f"{t.tokens:,}",
|
|
127
|
+
f"{t.cumulative:,}",
|
|
128
|
+
bar_str,
|
|
129
|
+
t.content_preview,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Highlight the heaviest row
|
|
133
|
+
if max_t:
|
|
134
|
+
table.move_cursor(row=max_t.turn - 1)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""TransferPanel — modal screen for configuring and launching a context transfer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
from textual.message import Message
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Label, RadioButton, RadioSet, Static
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
TOOLS = [
|
|
12
|
+
("claude_code", "Claude Code"),
|
|
13
|
+
("codex", "Codex"),
|
|
14
|
+
("altimate_code", "altimate-code"),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
STRATEGIES = [
|
|
18
|
+
("summary_only", "summary_only — max token efficiency"),
|
|
19
|
+
("key_messages", "key_messages — scored important messages"),
|
|
20
|
+
("full_recent", "full_recent — last N messages verbatim"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TransferPanel(ModalScreen):
|
|
25
|
+
"""Modal overlay for selecting target tool, strategy, and launching transfer."""
|
|
26
|
+
|
|
27
|
+
DEFAULT_CSS = """
|
|
28
|
+
TransferPanel {
|
|
29
|
+
align: center middle;
|
|
30
|
+
}
|
|
31
|
+
TransferPanel > Vertical {
|
|
32
|
+
width: 60;
|
|
33
|
+
height: auto;
|
|
34
|
+
max-height: 40;
|
|
35
|
+
border: thick $primary;
|
|
36
|
+
background: $surface;
|
|
37
|
+
padding: 1 2;
|
|
38
|
+
}
|
|
39
|
+
TransferPanel #panel-title {
|
|
40
|
+
text-style: bold;
|
|
41
|
+
color: $accent;
|
|
42
|
+
margin-bottom: 1;
|
|
43
|
+
}
|
|
44
|
+
TransferPanel .section-label {
|
|
45
|
+
text-style: bold;
|
|
46
|
+
margin-top: 1;
|
|
47
|
+
color: $text-muted;
|
|
48
|
+
}
|
|
49
|
+
TransferPanel #btn-row {
|
|
50
|
+
margin-top: 2;
|
|
51
|
+
height: auto;
|
|
52
|
+
layout: horizontal;
|
|
53
|
+
align: right middle;
|
|
54
|
+
}
|
|
55
|
+
TransferPanel Button {
|
|
56
|
+
margin-left: 1;
|
|
57
|
+
}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
BINDINGS = [
|
|
61
|
+
Binding("escape", "dismiss(None)", "Cancel"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
class Confirmed(Message):
|
|
65
|
+
"""Emitted when the user clicks Execute."""
|
|
66
|
+
def __init__(self, session_id: str, target_tool: str, strategy: str) -> None:
|
|
67
|
+
super().__init__()
|
|
68
|
+
self.session_id = session_id
|
|
69
|
+
self.target_tool = target_tool
|
|
70
|
+
self.strategy = strategy
|
|
71
|
+
|
|
72
|
+
def __init__(self, session_id: str, session_title: str = "") -> None:
|
|
73
|
+
super().__init__()
|
|
74
|
+
self._session_id = session_id
|
|
75
|
+
self._session_title = session_title or session_id[:16]
|
|
76
|
+
|
|
77
|
+
def compose(self) -> ComposeResult:
|
|
78
|
+
from textual.containers import Vertical, Horizontal
|
|
79
|
+
|
|
80
|
+
with Vertical():
|
|
81
|
+
yield Static(f"Transfer: {self._session_title}", id="panel-title")
|
|
82
|
+
|
|
83
|
+
yield Static("Target tool:", classes="section-label")
|
|
84
|
+
with RadioSet(id="tool-select"):
|
|
85
|
+
for tool_id, tool_name in TOOLS:
|
|
86
|
+
yield RadioButton(tool_name, value=(tool_id == "codex"), id=f"tool-{tool_id}")
|
|
87
|
+
|
|
88
|
+
yield Static("Compaction strategy:", classes="section-label")
|
|
89
|
+
with RadioSet(id="strategy-select"):
|
|
90
|
+
for strat_id, strat_label in STRATEGIES:
|
|
91
|
+
yield RadioButton(strat_label, value=(strat_id == "summary_only"), id=f"strat-{strat_id}")
|
|
92
|
+
|
|
93
|
+
with Horizontal(id="btn-row"):
|
|
94
|
+
yield Button("Cancel", variant="default", id="btn-cancel")
|
|
95
|
+
yield Button("Preview command", variant="primary", id="btn-preview")
|
|
96
|
+
yield Button("Execute", variant="error", id="btn-execute")
|
|
97
|
+
|
|
98
|
+
def _selected_tool(self) -> str:
|
|
99
|
+
radio_set = self.query_one("#tool-select", RadioSet)
|
|
100
|
+
pressed = radio_set.pressed_button
|
|
101
|
+
if pressed is None:
|
|
102
|
+
return TOOLS[0][0]
|
|
103
|
+
return pressed.id.removeprefix("tool-") if pressed.id else TOOLS[0][0]
|
|
104
|
+
|
|
105
|
+
def _selected_strategy(self) -> str:
|
|
106
|
+
radio_set = self.query_one("#strategy-select", RadioSet)
|
|
107
|
+
pressed = radio_set.pressed_button
|
|
108
|
+
if pressed is None:
|
|
109
|
+
return "summary_only"
|
|
110
|
+
return pressed.id.removeprefix("strat-") if pressed.id else "summary_only"
|
|
111
|
+
|
|
112
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
113
|
+
if event.button.id == "btn-cancel":
|
|
114
|
+
self.dismiss(None)
|
|
115
|
+
elif event.button.id in ("btn-preview", "btn-execute"):
|
|
116
|
+
tool = self._selected_tool()
|
|
117
|
+
strategy = self._selected_strategy()
|
|
118
|
+
execute = event.button.id == "btn-execute"
|
|
119
|
+
self.dismiss({"tool": tool, "strategy": strategy, "execute": execute})
|
|
File without changes
|