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,191 @@
1
+ """SessionDetail widget — right panel showing summary and metadata for selected session."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Vertical
10
+ from textual.widget import Widget
11
+ from textual.widgets import Markdown, Static
12
+
13
+ TOOL_COLORS = {
14
+ "claude_code": "cyan",
15
+ "codex": "green",
16
+ "altimate_code": "magenta",
17
+ "claude_desktop": "yellow",
18
+ }
19
+ TOOL_LABELS = {
20
+ "claude_code": "Claude Code",
21
+ "codex": "Codex",
22
+ "altimate_code": "altimate",
23
+ "claude_desktop": "Desktop",
24
+ }
25
+
26
+
27
+ def _meta_line(label: str, value: str, color: str = "cyan") -> str:
28
+ return f"[bold {color}]{label:<10}[/bold {color}] {value}"
29
+
30
+
31
+ def _fmt_tokens(n: int | None) -> str:
32
+ if not n:
33
+ return "—"
34
+ if n >= 1_000_000:
35
+ return f"{n / 1_000_000:.1f}M"
36
+ if n >= 1_000:
37
+ return f"{n // 1_000}k"
38
+ return str(n)
39
+
40
+
41
+ def _token_color(n: int | None) -> str:
42
+ if not n:
43
+ return "dim"
44
+ if n >= 100_000:
45
+ return "bold red"
46
+ if n >= 20_000:
47
+ return "yellow"
48
+ return "dim"
49
+
50
+
51
+ def _fmt_ts(ms: int | None, fmt: str = "%Y-%m-%d %H:%M UTC") -> str:
52
+ if not ms:
53
+ return "?"
54
+ try:
55
+ dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
56
+ return dt.strftime(fmt)
57
+ except Exception:
58
+ return "?"
59
+
60
+
61
+ class SessionDetail(Widget):
62
+ """Shows title, metadata, and summary for the currently selected session."""
63
+
64
+ DEFAULT_CSS = """
65
+ SessionDetail {
66
+ width: 2fr;
67
+ height: 1fr;
68
+ padding: 1 2;
69
+ border: tall $primary;
70
+ border-title-color: $accent;
71
+ border-title-style: bold;
72
+ overflow-y: auto;
73
+ }
74
+ SessionDetail #detail-placeholder {
75
+ color: $text-disabled;
76
+ margin: 4 0;
77
+ text-align: center;
78
+ }
79
+ SessionDetail #detail-title {
80
+ text-style: bold;
81
+ color: $accent;
82
+ margin-bottom: 1;
83
+ }
84
+ SessionDetail #detail-meta-block {
85
+ margin-bottom: 1;
86
+ border-bottom: solid $primary-background-lighten-1;
87
+ padding-bottom: 1;
88
+ }
89
+ SessionDetail #detail-summary-label {
90
+ text-style: bold;
91
+ color: $text-muted;
92
+ margin-bottom: 1;
93
+ }
94
+ SessionDetail Markdown {
95
+ background: transparent;
96
+ color: $text;
97
+ }
98
+ """
99
+
100
+ _current_id: str | None = None
101
+
102
+ def compose(self) -> ComposeResult:
103
+ yield Static("← Select a session", id="detail-placeholder")
104
+ yield Static("", id="detail-title")
105
+ with Vertical(id="detail-meta-block"):
106
+ yield Static("", id="detail-tool")
107
+ yield Static("", id="detail-cwd")
108
+ yield Static("", id="detail-tokens")
109
+ yield Static("", id="detail-created")
110
+ yield Static("", id="detail-updated")
111
+ yield Static("", id="detail-status")
112
+ yield Static("", id="detail-tags")
113
+ yield Static("", id="detail-id")
114
+ yield Static("── Summary ──", id="detail-summary-label")
115
+ yield Markdown("", id="detail-summary-md")
116
+
117
+ def on_mount(self) -> None:
118
+ self.border_title = "Detail"
119
+ self.query_one("#detail-summary-label").display = False
120
+ self.query_one("#detail-meta-block").display = False
121
+ self.query_one("#detail-title").display = False
122
+
123
+ def load(self, session_id: str) -> None:
124
+ """Load and display the given session."""
125
+ if session_id == self._current_id:
126
+ return
127
+ self._current_id = session_id
128
+
129
+ db_path: Path | None = getattr(self.app, "db_path", None)
130
+ if db_path is None:
131
+ return
132
+
133
+ from contextforge.core.db import get_db, get_session
134
+ db = get_db(db_path)
135
+ row = get_session(db, session_id)
136
+ if row is None:
137
+ return
138
+
139
+ tool = row.get("tool", "?")
140
+ title = row.get("title") or "(no title)"
141
+ cwd = row.get("cwd") or "?"
142
+ tokens_int = row.get("token_count")
143
+ status = row.get("status") or "?"
144
+ tags_raw = row.get("tags") or "[]"
145
+ try:
146
+ tags = ", ".join(json.loads(tags_raw)) or "—"
147
+ except Exception:
148
+ tags = "—"
149
+
150
+ created_str = _fmt_ts(row.get("created_at"))
151
+ updated_str = _fmt_ts(row.get("updated_at"))
152
+
153
+ summary = row.get("summary") or "*No summary yet* — run: `cf summarize <id>`"
154
+
155
+ # Tool label with color
156
+ tool_color = TOOL_COLORS.get(tool, "white")
157
+ tool_display = TOOL_LABELS.get(tool, tool)
158
+ tool_markup = f"[{tool_color}]{tool_display}[/{tool_color}]"
159
+
160
+ # Token display with color
161
+ tok_color = _token_color(tokens_int)
162
+ tok_str = _fmt_tokens(tokens_int)
163
+ tok_markup = f"[{tok_color}]{tok_str}[/{tok_color}]"
164
+
165
+ # Show everything
166
+ self.query_one("#detail-placeholder").display = False
167
+ self.query_one("#detail-title").display = True
168
+ self.query_one("#detail-meta-block").display = True
169
+ self.query_one("#detail-summary-label").display = True
170
+
171
+ self.query_one("#detail-title", Static).update(f"[bold accent]{title}[/bold accent]")
172
+ self.query_one("#detail-tool", Static).update(_meta_line("Tool", tool_markup, tool_color))
173
+ self.query_one("#detail-cwd", Static).update(_meta_line("CWD", f"[dim]{cwd}[/dim]", "dim"))
174
+ self.query_one("#detail-tokens", Static).update(_meta_line("Tokens", tok_markup, "dim"))
175
+ self.query_one("#detail-created", Static).update(_meta_line("Created", f"[dim]{created_str}[/dim]", "dim"))
176
+ self.query_one("#detail-updated", Static).update(_meta_line("Updated", f"[dim]{updated_str}[/dim]", "dim"))
177
+ self.query_one("#detail-status", Static).update(_meta_line("Status", status, "dim"))
178
+ self.query_one("#detail-tags", Static).update(_meta_line("Tags", tags, "dim"))
179
+ self.query_one("#detail-id", Static).update(_meta_line("ID", f"[dim]{session_id}[/dim]", "dim"))
180
+
181
+ md = self.query_one("#detail-summary-md", Markdown)
182
+ self.app.call_later(md.update, summary)
183
+
184
+ def clear(self) -> None:
185
+ self._current_id = None
186
+ self.query_one("#detail-placeholder").display = True
187
+ self.query_one("#detail-title").display = False
188
+ self.query_one("#detail-meta-block").display = False
189
+ self.query_one("#detail-summary-label").display = False
190
+ self.query_one("#detail-title", Static).update("")
191
+ self.app.call_later(self.query_one("#detail-summary-md", Markdown).update, "")
@@ -0,0 +1,277 @@
1
+ """SessionTable widget — scrollable DataTable of all indexed sessions."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Horizontal, Vertical
9
+ from textual.events import Key
10
+ from textual.message import Message
11
+ from textual.reactive import reactive
12
+ from textual.widget import Widget
13
+ from textual.widgets import Button, DataTable, Input, Label
14
+
15
+ TOOL_EMOJI = {
16
+ "claude_code": "🔵",
17
+ "codex": "🟢",
18
+ "altimate_code": "🟣",
19
+ "claude_desktop": "🟡",
20
+ }
21
+
22
+ TOOL_MARKUP = {
23
+ "claude_code": "[cyan]🔵 CC[/cyan]",
24
+ "codex": "[green]🟢 Codex[/green]",
25
+ "altimate_code": "[magenta]🟣 Alt[/magenta]",
26
+ "claude_desktop": "[yellow]🟡 Desktop[/yellow]",
27
+ }
28
+
29
+ TOOL_DISPLAY = {
30
+ "claude_code": "CC",
31
+ "codex": "Codex",
32
+ "altimate_code": "Alt",
33
+ "claude_desktop": "Desktop",
34
+ }
35
+
36
+ _FTOOL_MAP = {
37
+ "ftool-all": None,
38
+ "ftool-cc": "claude_code",
39
+ "ftool-codex": "codex",
40
+ "ftool-alt": "altimate_code",
41
+ }
42
+
43
+
44
+ def _fmt_tokens(n: int | None) -> str:
45
+ if not n:
46
+ return ""
47
+ if n >= 1_000_000:
48
+ return f"{n / 1_000_000:.1f}M"
49
+ if n >= 1_000:
50
+ return f"{n // 1_000}k"
51
+ return str(n)
52
+
53
+
54
+ def _token_markup(n: int | None) -> str:
55
+ if not n:
56
+ return "[dim]—[/dim]"
57
+ raw = _fmt_tokens(n)
58
+ if n >= 100_000:
59
+ return f"[bold red]{raw}[/bold red]"
60
+ if n >= 20_000:
61
+ return f"[yellow]{raw}[/yellow]"
62
+ return f"[dim]{raw}[/dim]"
63
+
64
+
65
+ class SessionTable(Widget):
66
+ """Displays all sessions in a navigable DataTable with live filtering."""
67
+
68
+ DEFAULT_CSS = """
69
+ SessionTable {
70
+ width: 1fr;
71
+ height: 1fr;
72
+ border: tall $primary;
73
+ border-title-color: $accent;
74
+ border-title-style: bold;
75
+ }
76
+ SessionTable > Vertical {
77
+ height: 1fr;
78
+ }
79
+ SessionTable DataTable {
80
+ height: 1fr;
81
+ }
82
+ SessionTable #filter-bar {
83
+ height: 3;
84
+ background: $primary-background-darken-1;
85
+ padding: 0 1;
86
+ layout: horizontal;
87
+ align: left middle;
88
+ }
89
+ SessionTable #filter-label {
90
+ width: auto;
91
+ margin-right: 1;
92
+ color: $text-muted;
93
+ }
94
+ SessionTable #filter-input {
95
+ width: 28;
96
+ margin-right: 1;
97
+ }
98
+ SessionTable .filter-tool-btn {
99
+ min-width: 7;
100
+ height: 1;
101
+ margin-right: 1;
102
+ background: $surface;
103
+ border: none;
104
+ color: $text-muted;
105
+ }
106
+ SessionTable .filter-tool-btn.active {
107
+ background: $primary;
108
+ color: $text;
109
+ text-style: bold;
110
+ }
111
+ SessionTable .hidden {
112
+ display: none;
113
+ }
114
+ """
115
+
116
+ # ── Messages ──────────────────────────────────────────────────────────────
117
+
118
+ class RowSelected(Message):
119
+ """Emitted when the user highlights a session row."""
120
+ def __init__(self, session_id: str, tool: str) -> None:
121
+ super().__init__()
122
+ self.session_id = session_id
123
+ self.tool = tool
124
+
125
+ class FilterChanged(Message):
126
+ """Emitted when filter state changes."""
127
+ def __init__(self, text: str, tool: str | None, match_count: int) -> None:
128
+ super().__init__()
129
+ self.text = text
130
+ self.tool = tool
131
+ self.match_count = match_count
132
+
133
+ # ── State ─────────────────────────────────────────────────────────────────
134
+
135
+ def __init__(self, **kwargs) -> None:
136
+ super().__init__(**kwargs)
137
+ self._rows: list[dict] = []
138
+ self._display_rows: list[dict] = []
139
+ self._filter_text: str = ""
140
+ self._filter_tool: str | None = None
141
+ self._filter_visible: bool = False
142
+
143
+ # ── Compose ───────────────────────────────────────────────────────────────
144
+
145
+ def compose(self) -> ComposeResult:
146
+ with Vertical():
147
+ with Horizontal(id="filter-bar", classes="hidden"):
148
+ yield Label("Filter:", id="filter-label")
149
+ yield Input(placeholder="title or project…", id="filter-input")
150
+ yield Button("All", id="ftool-all", classes="filter-tool-btn active")
151
+ yield Button("CC", id="ftool-cc", classes="filter-tool-btn")
152
+ yield Button("Codex", id="ftool-codex", classes="filter-tool-btn")
153
+ yield Button("Alt", id="ftool-alt", classes="filter-tool-btn")
154
+ yield DataTable(cursor_type="row", zebra_stripes=True)
155
+
156
+ def on_mount(self) -> None:
157
+ self.border_title = "Sessions"
158
+ table = self.query_one(DataTable)
159
+ table.add_columns("Tool", "Title", "Updated", "Tokens", "ID")
160
+ self.reload()
161
+
162
+ # ── Public API ────────────────────────────────────────────────────────────
163
+
164
+ def toggle_filter(self) -> None:
165
+ """Show or hide the filter bar."""
166
+ bar = self.query_one("#filter-bar")
167
+ if self._filter_visible:
168
+ self._filter_visible = False
169
+ bar.add_class("hidden")
170
+ self._filter_text = ""
171
+ self._filter_tool = None
172
+ self.query_one("#filter-input", Input).value = ""
173
+ # Reset active button
174
+ for btn_id in _FTOOL_MAP:
175
+ btn = self.query_one(f"#{btn_id}", Button)
176
+ if btn_id == "ftool-all":
177
+ btn.add_class("active")
178
+ else:
179
+ btn.remove_class("active")
180
+ self.reload()
181
+ self.post_message(self.FilterChanged("", None, len(self._rows)))
182
+ else:
183
+ self._filter_visible = True
184
+ bar.remove_class("hidden")
185
+ self.query_one("#filter-input", Input).focus()
186
+
187
+ def reload(self, tool_filter: str | None = None) -> None:
188
+ """Re-read sessions from DB and refresh the table."""
189
+ db_path: Path | None = getattr(self.app, "db_path", None)
190
+ if db_path is None:
191
+ return
192
+
193
+ from contextforge.core.db import get_db, get_sessions
194
+ db = get_db(db_path)
195
+ rows = get_sessions(db, tool=tool_filter, limit=500)
196
+ self._rows = rows
197
+ self._display_rows = rows
198
+ self._render_rows(rows)
199
+
200
+ # ── Internal rendering ────────────────────────────────────────────────────
201
+
202
+ def _render_rows(self, rows: list[dict]) -> None:
203
+ """Populate the DataTable from a list of session dicts."""
204
+ self._display_rows = rows
205
+ table = self.query_one(DataTable)
206
+ table.clear()
207
+
208
+ for row in rows:
209
+ tool = row.get("tool", "")
210
+ tool_label = TOOL_MARKUP.get(tool, f"⚪ {tool}")
211
+
212
+ from contextforge.utils.display import _clean_title
213
+ title = _clean_title(row.get("title") or "", max_len=38) or "(no title)"
214
+
215
+ updated_ms = row.get("updated_at") or 0
216
+ try:
217
+ dt = datetime.fromtimestamp(updated_ms / 1000, tz=timezone.utc)
218
+ updated = dt.strftime("%m-%d %H:%M")
219
+ except Exception:
220
+ updated = "?"
221
+
222
+ tokens = _token_markup(row.get("token_count"))
223
+ session_id = str(row.get("id", ""))[:12]
224
+
225
+ table.add_row(tool_label, title, updated, tokens, session_id)
226
+
227
+ def _apply_filter(self) -> None:
228
+ """Filter self._rows in memory and re-render."""
229
+ text = self._filter_text.lower()
230
+ tool = self._filter_tool
231
+
232
+ filtered = [
233
+ row for row in self._rows
234
+ if (not tool or row.get("tool") == tool)
235
+ and (
236
+ not text
237
+ or text in (row.get("title") or "").lower()
238
+ or text in (row.get("cwd") or "").lower()
239
+ )
240
+ ]
241
+ self._render_rows(filtered)
242
+ self.post_message(self.FilterChanged(self._filter_text, tool, len(filtered)))
243
+
244
+ # ── Event handlers ────────────────────────────────────────────────────────
245
+
246
+ def on_input_changed(self, event: Input.Changed) -> None:
247
+ if event.input.id == "filter-input":
248
+ self._filter_text = event.value
249
+ self._apply_filter()
250
+
251
+ def on_key(self, event: Key) -> None:
252
+ if event.key == "escape" and self._filter_visible:
253
+ self.toggle_filter()
254
+ event.stop()
255
+
256
+ def on_button_pressed(self, event: Button.Pressed) -> None:
257
+ btn_id = event.button.id
258
+ if btn_id not in _FTOOL_MAP:
259
+ return
260
+ self._filter_tool = _FTOOL_MAP[btn_id]
261
+ for bid in _FTOOL_MAP:
262
+ btn = self.query_one(f"#{bid}", Button)
263
+ if bid == btn_id:
264
+ btn.add_class("active")
265
+ else:
266
+ btn.remove_class("active")
267
+ self._apply_filter()
268
+
269
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
270
+ idx = event.cursor_row
271
+ display = self._display_rows
272
+ if 0 <= idx < len(display):
273
+ row = display[idx]
274
+ self.post_message(self.RowSelected(
275
+ session_id=str(row["id"]),
276
+ tool=str(row.get("tool", "")),
277
+ ))