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.
Files changed (37) hide show
  1. context_forge_cli-0.1.0.dist-info/METADATA +340 -0
  2. context_forge_cli-0.1.0.dist-info/RECORD +37 -0
  3. context_forge_cli-0.1.0.dist-info/WHEEL +4 -0
  4. context_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. contextforge/__init__.py +3 -0
  6. contextforge/adapters/__init__.py +3 -0
  7. contextforge/adapters/altimate_code.py +179 -0
  8. contextforge/adapters/base.py +44 -0
  9. contextforge/adapters/claude_code.py +393 -0
  10. contextforge/adapters/claude_desktop.py +306 -0
  11. contextforge/adapters/codex.py +220 -0
  12. contextforge/adapters/registry.py +31 -0
  13. contextforge/cli.py +523 -0
  14. contextforge/core/__init__.py +0 -0
  15. contextforge/core/analytics.py +226 -0
  16. contextforge/core/compactor.py +182 -0
  17. contextforge/core/db.py +181 -0
  18. contextforge/core/injector.py +94 -0
  19. contextforge/core/scanner.py +72 -0
  20. contextforge/core/summarizer.py +155 -0
  21. contextforge/core/token_analyzer.py +91 -0
  22. contextforge/models/__init__.py +4 -0
  23. contextforge/models/config.py +34 -0
  24. contextforge/models/session.py +37 -0
  25. contextforge/tui/__init__.py +0 -0
  26. contextforge/tui/app.py +204 -0
  27. contextforge/tui/styles.tcss +50 -0
  28. contextforge/tui/widgets/__init__.py +0 -0
  29. contextforge/tui/widgets/session_detail.py +191 -0
  30. contextforge/tui/widgets/session_table.py +277 -0
  31. contextforge/tui/widgets/stats_panel.py +340 -0
  32. contextforge/tui/widgets/status_bar.py +102 -0
  33. contextforge/tui/widgets/tokens_panel.py +134 -0
  34. contextforge/tui/widgets/transfer_panel.py +119 -0
  35. contextforge/utils/__init__.py +0 -0
  36. contextforge/utils/display.py +105 -0
  37. 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