context-forge-cli 0.4.13__tar.gz → 0.4.14__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.13 → context_forge_cli-0.4.14}/PKG-INFO +1 -1
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/cli.py +15 -7
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/core/db.py +5 -1
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/session_detail.py +27 -5
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/session_table.py +15 -1
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/utils/display.py +50 -1
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/pyproject.toml +1 -1
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/core/test_db.py +14 -0
- context_forge_cli-0.4.14/tests/core/test_display.py +81 -0
- context_forge_cli-0.4.14/tests/test_tui_detail.py +117 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/uv.lock +1 -1
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/.contextforge.toml +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/.github/workflows/publish.yml +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/.gitignore +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/.python-version +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/AGENTS.md +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/PLAN.md +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/README.md +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/altimate_code.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/antigravity.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/base.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/claude_code.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/claude_desktop.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/codex.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/cursor.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/datatzis.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/gemini.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/pi.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/registry.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/core/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/core/analytics.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/core/compactor.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/core/injector.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/core/scanner.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/core/summarizer.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/core/token_analyzer.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/mcp_server.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/models/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/models/config.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/models/session.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/app.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/clipboard.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/styles.tcss +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/stats_panel.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/status_bar.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/tokens_panel.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/transfer_panel.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/utils/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/utils/tokens.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/adapters/test_antigravity.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/adapters/test_claude_code.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/adapters/test_cursor.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/adapters/test_datatzis.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/adapters/test_pi.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/conftest.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/core/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/core/test_compactor.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/core/test_injector.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/core/test_summarizer.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/core/test_token_analyzer.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/fixtures/claude_session_sample.jsonl +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/fixtures/datatzis/my-repo/.datatzis/tasks/22222222222222222222222222222222.json +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/fixtures/datatzis/my-repo/.datatzis/tasks/33333333333333333333333333333333.json +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/11111111111111111111111111111111.json +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/99999999999999999999999999999999.json +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T10-30-00-000Z_a1b2c3d4-0000-4000-8000-000000000001/e5f61122-3333-4444-5555-666677778888/run-0/session.jsonl +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T10-30-00-000Z_a1b2c3d4-0000-4000-8000-000000000001/e5f61122-3333-4444-5555-666677778888/run-1/session.jsonl +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T10-30-00-000Z_a1b2c3d4-0000-4000-8000-000000000001.jsonl +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T11-20-00-000Z_c9d8e7f6-0000-4000-8000-000000000003.jsonl +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/test_mcp_server.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: context-forge-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.14
|
|
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
|
|
@@ -8,6 +8,7 @@ from typing import Annotated, Optional
|
|
|
8
8
|
|
|
9
9
|
import typer
|
|
10
10
|
from rich.console import Console
|
|
11
|
+
from rich.markup import escape
|
|
11
12
|
from rich.panel import Panel
|
|
12
13
|
|
|
13
14
|
from contextforge.core import db as db_module
|
|
@@ -16,7 +17,13 @@ from contextforge.core.injector import build_inject_command, execute_transfer
|
|
|
16
17
|
from contextforge.core.scanner import scan
|
|
17
18
|
from contextforge.core.summarizer import batch_summarize, summarize_session
|
|
18
19
|
from contextforge.models.config import ForgeConfig
|
|
19
|
-
from contextforge.utils.display import
|
|
20
|
+
from contextforge.utils.display import (
|
|
21
|
+
console,
|
|
22
|
+
display_safe,
|
|
23
|
+
err_console,
|
|
24
|
+
row_tags as display_row_tags,
|
|
25
|
+
sessions_table,
|
|
26
|
+
)
|
|
20
27
|
|
|
21
28
|
def _version_callback(value: bool) -> None:
|
|
22
29
|
if value:
|
|
@@ -167,12 +174,13 @@ def show(
|
|
|
167
174
|
|
|
168
175
|
summary = row.get("summary") or "[dim]No summary yet — run [bold]cf summarize[/bold][/dim]"
|
|
169
176
|
panel = Panel(
|
|
170
|
-
f"[bold]{row.get('title') or '(no title)'}[/bold]\n\n"
|
|
171
|
-
f"Tool: {row.get('tool')}\n"
|
|
172
|
-
f"CWD: {row.get('cwd') or '?'}\n"
|
|
173
|
-
f"Tokens: {row.get('token_count')
|
|
174
|
-
f"Status: {row.get('status')}\n\n"
|
|
175
|
-
f"[bold]
|
|
177
|
+
f"[bold]{display_safe(row.get('title') or '(no title)')}[/bold]\n\n"
|
|
178
|
+
f"Tool: {display_safe(row.get('tool'))}\n"
|
|
179
|
+
f"CWD: {display_safe(row.get('cwd') or '?')}\n"
|
|
180
|
+
f"Tokens: {display_safe(row.get('token_count'))}\n"
|
|
181
|
+
f"Status: {display_safe(row.get('status'))}\n\n"
|
|
182
|
+
f"[bold]Tags:[/bold] {display_safe(', '.join(display_row_tags(row)) or '—')}\n\n"
|
|
183
|
+
f"[bold]Summary:[/bold]\n{display_safe(summary) if row.get('summary') else summary}",
|
|
176
184
|
title=f"Session {session_id[:16]}",
|
|
177
185
|
)
|
|
178
186
|
console.print(panel)
|
|
@@ -84,6 +84,10 @@ def upsert_session(db: sqlite_utils.Database, session: Session) -> None:
|
|
|
84
84
|
if existing_updated >= new_updated:
|
|
85
85
|
return
|
|
86
86
|
|
|
87
|
+
# Cap the title: some adapters derive it from the first message, which can
|
|
88
|
+
# be arbitrarily large (an entire system prompt). Titles are display text.
|
|
89
|
+
title = session.title[:300] if session.title else None
|
|
90
|
+
|
|
87
91
|
first_msg = ""
|
|
88
92
|
if session.messages:
|
|
89
93
|
for m in session.messages:
|
|
@@ -95,7 +99,7 @@ def upsert_session(db: sqlite_utils.Database, session: Session) -> None:
|
|
|
95
99
|
{
|
|
96
100
|
"id": session.id,
|
|
97
101
|
"tool": session.tool,
|
|
98
|
-
"title":
|
|
102
|
+
"title": title,
|
|
99
103
|
"cwd": session.cwd,
|
|
100
104
|
"created_at": int(session.created_at.timestamp() * 1000),
|
|
101
105
|
"updated_at": int(session.updated_at.timestamp() * 1000),
|
{context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/session_detail.py
RENAMED
|
@@ -7,9 +7,12 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from textual.app import ComposeResult
|
|
9
9
|
from textual.containers import Vertical
|
|
10
|
+
from rich.markup import escape
|
|
10
11
|
from textual.widget import Widget
|
|
11
12
|
from textual.widgets import Markdown, Static, TextArea
|
|
12
13
|
|
|
14
|
+
from contextforge.utils.display import _clean_title, display_safe
|
|
15
|
+
|
|
13
16
|
TOOL_COLORS = {
|
|
14
17
|
"claude_code": "cyan",
|
|
15
18
|
"codex": "green",
|
|
@@ -18,6 +21,8 @@ TOOL_COLORS = {
|
|
|
18
21
|
"gemini": "blue",
|
|
19
22
|
"antigravity": "white",
|
|
20
23
|
"cursor": "bright_white",
|
|
24
|
+
"pi": "bright_cyan",
|
|
25
|
+
"datatzis": "orange1",
|
|
21
26
|
}
|
|
22
27
|
TOOL_LABELS = {
|
|
23
28
|
"claude_code": "◆ Claude Code",
|
|
@@ -27,6 +32,8 @@ TOOL_LABELS = {
|
|
|
27
32
|
"gemini": "✦ Gemini",
|
|
28
33
|
"antigravity": "◉ Antigravity",
|
|
29
34
|
"cursor": "▲ Cursor",
|
|
35
|
+
"pi": "∏ Pi",
|
|
36
|
+
"datatzis": "✎ Datatzis",
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
|
|
@@ -130,6 +137,7 @@ class SessionDetail(Widget):
|
|
|
130
137
|
yield Static("", id="detail-created")
|
|
131
138
|
yield Static("", id="detail-updated")
|
|
132
139
|
yield Static("", id="detail-status")
|
|
140
|
+
yield Static("", id="detail-parent")
|
|
133
141
|
yield Static("", id="detail-tags")
|
|
134
142
|
yield TextArea("", id="detail-id", read_only=True)
|
|
135
143
|
yield Static("✎ Summary", id="detail-summary-label")
|
|
@@ -164,9 +172,15 @@ class SessionDetail(Widget):
|
|
|
164
172
|
status = row.get("status") or "?"
|
|
165
173
|
tags_raw = row.get("tags") or "[]"
|
|
166
174
|
try:
|
|
167
|
-
|
|
175
|
+
tags_list = [str(t) for t in json.loads(tags_raw)]
|
|
168
176
|
except Exception:
|
|
169
|
-
|
|
177
|
+
tags_list = []
|
|
178
|
+
tags = ", ".join(tags_list) or "—"
|
|
179
|
+
|
|
180
|
+
parent = next(
|
|
181
|
+
(t.split(":", 1)[1] for t in tags_list if t.startswith(("parent:", "workspace:"))),
|
|
182
|
+
None,
|
|
183
|
+
)
|
|
170
184
|
|
|
171
185
|
created_str = _fmt_ts(row.get("created_at"))
|
|
172
186
|
updated_str = _fmt_ts(row.get("updated_at"))
|
|
@@ -189,14 +203,21 @@ class SessionDetail(Widget):
|
|
|
189
203
|
self.query_one("#detail-meta-block").display = True
|
|
190
204
|
self.query_one("#detail-summary-label").display = True
|
|
191
205
|
|
|
192
|
-
self.query_one("#detail-title", Static).update(f"[bold]{title}[/bold]")
|
|
206
|
+
self.query_one("#detail-title", Static).update(f"[bold]{display_safe(_clean_title(title, max_len=200))}[/bold]")
|
|
193
207
|
self.query_one("#detail-tool", Static).update(_meta_line("Tool", tool_markup))
|
|
194
208
|
self.query_one("#detail-cwd", TextArea).load_text("Project " + cwd)
|
|
195
209
|
self.query_one("#detail-tokens", Static).update(_meta_line("Tokens", tok_markup))
|
|
196
210
|
self.query_one("#detail-created", Static).update(_meta_line("Created", f"[dim]{created_str}[/dim]"))
|
|
197
211
|
self.query_one("#detail-updated", Static).update(_meta_line("Updated", f"[dim]{updated_str}[/dim]"))
|
|
198
|
-
self.query_one("#detail-status", Static).update(_meta_line("Status", f"[dim]{status}[/dim]"))
|
|
199
|
-
self.query_one("#detail-tags", Static).update(_meta_line("Tags", f"[dim]{tags}[/dim]"))
|
|
212
|
+
self.query_one("#detail-status", Static).update(_meta_line("Status", f"[dim]{display_safe(status)}[/dim]"))
|
|
213
|
+
self.query_one("#detail-tags", Static).update(_meta_line("Tags", f"[dim]{display_safe(tags)}[/dim]"))
|
|
214
|
+
parent_widget = self.query_one("#detail-parent", Static)
|
|
215
|
+
if parent:
|
|
216
|
+
parent_widget.update(_meta_line("Parent", f"[dim]↳ {display_safe(parent)}[/dim]"))
|
|
217
|
+
parent_widget.display = True
|
|
218
|
+
else:
|
|
219
|
+
parent_widget.update("")
|
|
220
|
+
parent_widget.display = False
|
|
200
221
|
self.query_one("#detail-id", TextArea).load_text("ID " + session_id)
|
|
201
222
|
|
|
202
223
|
md = self.query_one("#detail-summary-md", Markdown)
|
|
@@ -209,4 +230,5 @@ class SessionDetail(Widget):
|
|
|
209
230
|
self.query_one("#detail-meta-block").display = False
|
|
210
231
|
self.query_one("#detail-summary-label").display = False
|
|
211
232
|
self.query_one("#detail-title", Static).update("")
|
|
233
|
+
self.query_one("#detail-parent").display = False
|
|
212
234
|
self.app.call_later(self.query_one("#detail-summary-md", Markdown).update, "")
|
{context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/session_table.py
RENAMED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
from rich.markup import escape
|
|
7
8
|
from textual.app import ComposeResult
|
|
8
9
|
from textual.containers import Horizontal, Vertical
|
|
9
10
|
from textual.events import Key
|
|
@@ -21,6 +22,8 @@ TOOL_ICON = {
|
|
|
21
22
|
"gemini": "✦", # Google Gemini — four-pointed star
|
|
22
23
|
"antigravity": "◉", # Antigravity — circle
|
|
23
24
|
"cursor": "▲", # Cursor — triangle
|
|
25
|
+
"pi": "∏", # Pi — product symbol
|
|
26
|
+
"datatzis": "✎", # writing harness — pencil
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
TOOL_MARKUP = {
|
|
@@ -31,6 +34,8 @@ TOOL_MARKUP = {
|
|
|
31
34
|
"gemini": "[blue]✦ Gemini[/blue]",
|
|
32
35
|
"antigravity": "[white]◉ Agy[/white]",
|
|
33
36
|
"cursor": "[bright_white]▲ Cursor[/bright_white]",
|
|
37
|
+
"pi": "[bright_cyan]∏ Pi[/bright_cyan]",
|
|
38
|
+
"datatzis": "[orange1]✎ Datz[/orange1]",
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
TOOL_DISPLAY = {
|
|
@@ -41,6 +46,8 @@ TOOL_DISPLAY = {
|
|
|
41
46
|
"gemini": "✦ Gemini",
|
|
42
47
|
"antigravity": "◉ Agy",
|
|
43
48
|
"cursor": "▲ Cursor",
|
|
49
|
+
"pi": "∏ Pi",
|
|
50
|
+
"datatzis": "✎ Datz",
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
_FTOOL_MAP = {
|
|
@@ -51,6 +58,8 @@ _FTOOL_MAP = {
|
|
|
51
58
|
"ftool-alt": "altimate_code",
|
|
52
59
|
"ftool-agy": "antigravity",
|
|
53
60
|
"ftool-cur": "cursor",
|
|
61
|
+
"ftool-pi": "pi",
|
|
62
|
+
"ftool-datz": "datatzis",
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
|
|
@@ -169,6 +178,8 @@ class SessionTable(Widget):
|
|
|
169
178
|
yield Button("⚡ Alt", id="ftool-alt", classes="filter-tool-btn")
|
|
170
179
|
yield Button("◉ Agy", id="ftool-agy", classes="filter-tool-btn")
|
|
171
180
|
yield Button("▲ Cursor", id="ftool-cur", classes="filter-tool-btn")
|
|
181
|
+
yield Button("∏ Pi", id="ftool-pi", classes="filter-tool-btn")
|
|
182
|
+
yield Button("✎ Datz", id="ftool-datz", classes="filter-tool-btn")
|
|
172
183
|
yield DataTable(cursor_type="row", zebra_stripes=True, id="sessions-datatable")
|
|
173
184
|
|
|
174
185
|
def on_mount(self) -> None:
|
|
@@ -227,8 +238,11 @@ class SessionTable(Widget):
|
|
|
227
238
|
tool = row.get("tool", "")
|
|
228
239
|
tool_label = TOOL_MARKUP.get(tool, f"⚪ {tool}")
|
|
229
240
|
|
|
230
|
-
from contextforge.utils.display import _clean_title
|
|
241
|
+
from contextforge.utils.display import _clean_title, display_safe, is_subagent
|
|
231
242
|
title = _clean_title(row.get("title") or "", max_len=40) or "(no title)"
|
|
243
|
+
if is_subagent(row):
|
|
244
|
+
title = f"↳ {title}"
|
|
245
|
+
title = display_safe(title)
|
|
232
246
|
|
|
233
247
|
updated_ms = row.get("updated_at") or 0
|
|
234
248
|
try:
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"""Rich console helpers."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import json
|
|
4
5
|
import re
|
|
5
6
|
from datetime import datetime, timezone
|
|
6
7
|
|
|
7
8
|
from rich.console import Console
|
|
9
|
+
from rich.markup import escape
|
|
8
10
|
from rich.table import Table
|
|
9
11
|
|
|
10
12
|
console = Console()
|
|
@@ -14,6 +16,9 @@ TOOL_COLORS = {
|
|
|
14
16
|
"claude_code": "cyan",
|
|
15
17
|
"codex": "green",
|
|
16
18
|
"altimate_code": "magenta",
|
|
19
|
+
"gemini": "blue",
|
|
20
|
+
"pi": "bright_cyan",
|
|
21
|
+
"datatzis": "orange1",
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
TOOL_SHORT = {
|
|
@@ -27,6 +32,25 @@ def tool_color(tool: str) -> str:
|
|
|
27
32
|
return TOOL_COLORS.get(tool, "white")
|
|
28
33
|
|
|
29
34
|
|
|
35
|
+
_BRACKETS = str.maketrans({"[": "❰", "]": "❱"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def display_safe(text) -> str:
|
|
39
|
+
"""Sanitize untrusted display text for Rich/Textual markup rendering.
|
|
40
|
+
|
|
41
|
+
``rich.markup.escape`` alone is NOT sufficient: it only escapes bracket
|
|
42
|
+
spans it recognises as valid tags, leaving unmatched sequences such as
|
|
43
|
+
``[/ηλικίας/... ]`` intact — which Rich/Textual parsers still consume as
|
|
44
|
+
markup (MarkupError). Transliterating brackets to lookalikes first
|
|
45
|
+
removes the entire problem class while keeping the text readable.
|
|
46
|
+
"""
|
|
47
|
+
if text is None:
|
|
48
|
+
return ""
|
|
49
|
+
if not isinstance(text, str):
|
|
50
|
+
text = str(text)
|
|
51
|
+
return escape(text.translate(_BRACKETS))
|
|
52
|
+
|
|
53
|
+
|
|
30
54
|
def _clean_title(raw: str, max_len: int = 42) -> str:
|
|
31
55
|
"""Return a single-line, truncated title from raw session display text."""
|
|
32
56
|
# Take only the first non-empty line
|
|
@@ -38,6 +62,28 @@ def _clean_title(raw: str, max_len: int = 42) -> str:
|
|
|
38
62
|
return first_line
|
|
39
63
|
|
|
40
64
|
|
|
65
|
+
def row_tags(row: dict) -> list[str]:
|
|
66
|
+
"""Parse a DB row's JSON-encoded tags into a plain list."""
|
|
67
|
+
try:
|
|
68
|
+
tags = json.loads(row.get("tags") or "[]")
|
|
69
|
+
return [str(t) for t in tags] if isinstance(tags, list) else []
|
|
70
|
+
except Exception:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_subagent(row: dict) -> bool:
|
|
75
|
+
"""True when the row is a sub-agent (child) session."""
|
|
76
|
+
return "subagent" in row_tags(row)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parent_id_of(row: dict) -> str | None:
|
|
80
|
+
"""Session id of the parent, when the row is a sub-agent session."""
|
|
81
|
+
for tag in row_tags(row):
|
|
82
|
+
if tag.startswith("parent:") or tag.startswith("workspace:"):
|
|
83
|
+
return tag.split(":", 1)[1]
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
41
87
|
def _project_name(cwd: str) -> str:
|
|
42
88
|
"""Return just the last two path components of a CWD."""
|
|
43
89
|
if not cwd:
|
|
@@ -70,6 +116,9 @@ def sessions_table(rows: list[dict]) -> Table:
|
|
|
70
116
|
|
|
71
117
|
session_id = str(row.get("id", ""))[:13]
|
|
72
118
|
title = _clean_title(row.get("title") or "")
|
|
119
|
+
if is_subagent(row):
|
|
120
|
+
title = f"↳ {title}"
|
|
121
|
+
title = display_safe(title)
|
|
73
122
|
project = _project_name(row.get("cwd") or "")
|
|
74
123
|
|
|
75
124
|
raw_tokens = row.get("token_count")
|
|
@@ -90,7 +139,7 @@ def sessions_table(rows: list[dict]) -> Table:
|
|
|
90
139
|
updated = "?"
|
|
91
140
|
|
|
92
141
|
summary_raw = row.get("summary") or ""
|
|
93
|
-
summary = _clean_title(summary_raw, max_len=45)
|
|
142
|
+
summary = display_safe(_clean_title(summary_raw, max_len=45))
|
|
94
143
|
|
|
95
144
|
table.add_row(
|
|
96
145
|
session_id,
|
|
@@ -54,6 +54,20 @@ def test_get_sessions_filter_by_tool():
|
|
|
54
54
|
assert cc[0]["id"] == "s1"
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def test_title_capped_at_ingest():
|
|
58
|
+
"""Adapters may derive the title from an arbitrarily large first message
|
|
59
|
+
(a whole system prompt); storage caps it so display surfaces stay safe."""
|
|
60
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
61
|
+
db = get_db(Path(tmpdir) / "test.db")
|
|
62
|
+
session = make_session("big-title")
|
|
63
|
+
session = session.model_copy(update={"title": "Η" * 10_000})
|
|
64
|
+
upsert_session(db, session)
|
|
65
|
+
|
|
66
|
+
row = get_session(db, "big-title")
|
|
67
|
+
assert len(row["title"]) == 300
|
|
68
|
+
assert row["title"].startswith("ΗΗΗ")
|
|
69
|
+
|
|
70
|
+
|
|
57
71
|
def test_schema_idempotent():
|
|
58
72
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
59
73
|
path = Path(tmpdir) / "test.db"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Tests for utils/display.py — escaping, title cleaning, sub-agent helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.markup import escape as _escape # noqa: F401 (import sanity)
|
|
8
|
+
|
|
9
|
+
from contextforge.utils.display import (
|
|
10
|
+
_clean_title,
|
|
11
|
+
is_subagent,
|
|
12
|
+
parent_id_of,
|
|
13
|
+
row_tags,
|
|
14
|
+
sessions_table,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# The exact crash payload class from the codex session: bracket sequences that
|
|
18
|
+
# Rich would otherwise parse as markup ([/ηλικίας/...] closed a fictional tag).
|
|
19
|
+
HOSTILE_TITLE = (
|
|
20
|
+
"Είσαι [/ηλικίας/γεωγραφίας. [bold]ερέυνα[/bold] και άμεσες[/ηλικίας] εργασίες."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_clean_title_first_line_and_truncation() -> None:
|
|
25
|
+
multi = "first line\nsecond [bold] line\nthird"
|
|
26
|
+
out = _clean_title(multi, max_len=20)
|
|
27
|
+
assert out == "first line"
|
|
28
|
+
assert "[" not in out # brackets from later lines are cut, not interpreted
|
|
29
|
+
long = "x" * 100
|
|
30
|
+
assert _clean_title(long, max_len=10) == "x" * 9 + "…"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_row_tags_parses_and_survives_corruption() -> None:
|
|
34
|
+
assert row_tags({"tags": json.dumps(["subagent", "parent:a1"])}) == ["subagent", "parent:a1"]
|
|
35
|
+
assert row_tags({"tags": "not json at all"}) == []
|
|
36
|
+
assert row_tags({}) == []
|
|
37
|
+
assert row_tags({"tags": json.dumps("scalar")}) == []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_is_subagent_and_parent_id() -> None:
|
|
41
|
+
sub = {"tags": json.dumps(["subagent", "parent:01a11552-b234"])}
|
|
42
|
+
task = {"tags": json.dumps(["subagent", "workspace:111111111111"])}
|
|
43
|
+
main = {"tags": json.dumps([])}
|
|
44
|
+
assert is_subagent(sub) and is_subagent(task)
|
|
45
|
+
assert not is_subagent(main)
|
|
46
|
+
assert parent_id_of(sub) == "01a11552-b234"
|
|
47
|
+
assert parent_id_of(task) == "111111111111"
|
|
48
|
+
assert parent_id_of(main) is None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_sessions_table_escapes_hostile_titles() -> None:
|
|
52
|
+
"""Rendering a table with markup-like titles must not raise MarkupError."""
|
|
53
|
+
rows = [
|
|
54
|
+
{"id": "s1" * 8, "tool": "pi", "title": HOSTILE_TITLE, "cwd": "/proj",
|
|
55
|
+
"token_count": 10, "updated_at": "2026-01-15T12:30:00+00:00",
|
|
56
|
+
"tags": "[]", "summary": "Says [bold]hi[/bold] with [/weird/brackets."},
|
|
57
|
+
]
|
|
58
|
+
table = sessions_table(rows)
|
|
59
|
+
console = Console(record=True, width=200)
|
|
60
|
+
console.print(table) # must not raise MarkupError
|
|
61
|
+
exported = console.export_text()
|
|
62
|
+
assert "❰/ηλικίας" in exported # brackets transliterated, shown literally
|
|
63
|
+
assert "Says ❰bold❱hi❰/bold❱ with ❰/weird/brackets." in exported
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_sessions_table_marks_subagent_rows() -> None:
|
|
67
|
+
rows = [
|
|
68
|
+
{"id": "a" * 32, "tool": "pi", "title": "Child run", "cwd": "/proj",
|
|
69
|
+
"token_count": 5, "updated_at": "2026-01-15T12:31:00+00:00",
|
|
70
|
+
"tags": json.dumps(["subagent", "parent:01a11552"]), "summary": ""},
|
|
71
|
+
{"id": "b" * 32, "tool": "pi", "title": "Parent chat", "cwd": "/proj",
|
|
72
|
+
"token_count": 9, "updated_at": "2026-01-15T12:30:00+00:00",
|
|
73
|
+
"tags": "[]", "summary": ""},
|
|
74
|
+
]
|
|
75
|
+
table = sessions_table(rows)
|
|
76
|
+
console = Console(record=True, width=200)
|
|
77
|
+
console.print(table)
|
|
78
|
+
exported = console.export_text()
|
|
79
|
+
assert "↳ Child run" in exported
|
|
80
|
+
assert "Parent chat" in exported
|
|
81
|
+
assert "↳ Parent chat" not in exported # parent rows unmarked
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Regression tests for the TUI session-detail rendering.
|
|
2
|
+
|
|
3
|
+
Covers the MarkupError crash when a session title (untrusted content, e.g. a
|
|
4
|
+
codex session whose title is a whole raw JSON payload) contains Rich-style
|
|
5
|
+
bracket sequences. Uses Textual's pilot harness against a real DB.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import tempfile
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from textual.app import App, ComposeResult
|
|
15
|
+
from textual.widgets import Static
|
|
16
|
+
|
|
17
|
+
from contextforge.core.db import get_db, upsert_session
|
|
18
|
+
from contextforge.models.session import Message, Session
|
|
19
|
+
from contextforge.tui.widgets.session_detail import SessionDetail
|
|
20
|
+
|
|
21
|
+
HOSTILE_TITLE = (
|
|
22
|
+
"Είσαι ο βοηθός [/ηλικίας/γεωγραφίας. Περιλαμβάνει [bold]πρόθεση[/bold], "
|
|
23
|
+
"[dim]εκτίμηση[/] με εύρη, εύρος και συσπειρώσεις."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
SUBAGENT_ID = "0" * 32
|
|
27
|
+
PARENT_ID = "1" * 32
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _seed(db_path: Path) -> None:
|
|
31
|
+
now = datetime(2026, 1, 15, 12, 30, tzinfo=timezone.utc)
|
|
32
|
+
db = get_db(db_path)
|
|
33
|
+
upsert_session(
|
|
34
|
+
db,
|
|
35
|
+
Session(
|
|
36
|
+
id=PARENT_ID,
|
|
37
|
+
tool="codex",
|
|
38
|
+
title=HOSTILE_TITLE,
|
|
39
|
+
cwd="/home/mver/Documents/Github/datatzis",
|
|
40
|
+
created_at=now,
|
|
41
|
+
updated_at=now,
|
|
42
|
+
messages=[Message(role="user", content="hello")],
|
|
43
|
+
status="unknown",
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
upsert_session(
|
|
47
|
+
db,
|
|
48
|
+
Session(
|
|
49
|
+
id=SUBAGENT_ID,
|
|
50
|
+
tool="pi",
|
|
51
|
+
title="↳-safe plain child title",
|
|
52
|
+
cwd="/home/mver/Documents/Github/datatzis",
|
|
53
|
+
created_at=now,
|
|
54
|
+
updated_at=now,
|
|
55
|
+
messages=[Message(role="user", content="child")],
|
|
56
|
+
status="unknown",
|
|
57
|
+
tags=["subagent", f"parent:{PARENT_ID}"],
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _HarnessApp(App):
|
|
63
|
+
def __init__(self, db_path: Path) -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.db_path = db_path # SessionDetail.load passes this to get_db()
|
|
66
|
+
|
|
67
|
+
def compose(self) -> ComposeResult:
|
|
68
|
+
yield SessionDetail(id="session-detail")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _run(fn) -> None:
|
|
72
|
+
asyncio.run(fn())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_detail_load_hostile_title_does_not_crash():
|
|
76
|
+
"""The exact codex-session case: markup-like title must render escaped."""
|
|
77
|
+
|
|
78
|
+
async def scenario():
|
|
79
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
80
|
+
db_path = Path(tmpdir) / "test.db"
|
|
81
|
+
_seed(db_path)
|
|
82
|
+
app = _HarnessApp(db_path)
|
|
83
|
+
async with app.run_test() as pilot:
|
|
84
|
+
detail = app.query_one(SessionDetail)
|
|
85
|
+
detail.load(PARENT_ID)
|
|
86
|
+
await pilot.pause()
|
|
87
|
+
title_widget = detail.query_one("#detail-title", Static)
|
|
88
|
+
plain = title_widget.render().plain # visible text after sanitizing
|
|
89
|
+
# Brackets transliterated; markup engine has nothing to consume.
|
|
90
|
+
assert "❰/ηλικίας" in plain
|
|
91
|
+
assert "❰bold❱πρόθεση❰/bold❱" in plain
|
|
92
|
+
detail.load(SUBAGENT_ID)
|
|
93
|
+
await pilot.pause()
|
|
94
|
+
parent_widget = detail.query_one("#detail-parent", Static)
|
|
95
|
+
assert parent_widget.display is True
|
|
96
|
+
assert PARENT_ID in parent_widget.render().plain
|
|
97
|
+
|
|
98
|
+
_run(scenario)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_detail_parent_hidden_for_main_sessions():
|
|
102
|
+
async def scenario():
|
|
103
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
104
|
+
db_path = Path(tmpdir) / "test.db"
|
|
105
|
+
_seed(db_path)
|
|
106
|
+
app = _HarnessApp(db_path)
|
|
107
|
+
async with app.run_test() as pilot:
|
|
108
|
+
detail = app.query_one(SessionDetail)
|
|
109
|
+
detail.load(PARENT_ID) # main codex session — no subagent tags
|
|
110
|
+
await pilot.pause()
|
|
111
|
+
parent_widget = detail.query_one("#detail-parent", Static)
|
|
112
|
+
assert parent_widget.display is False
|
|
113
|
+
detail.clear()
|
|
114
|
+
await pilot.pause()
|
|
115
|
+
assert parent_widget.display is False
|
|
116
|
+
|
|
117
|
+
_run(scenario)
|
|
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.13 → context_forge_cli-0.4.14}/contextforge/adapters/altimate_code.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/adapters/claude_desktop.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
|
|
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.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/stats_panel.py
RENAMED
|
File without changes
|
{context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/status_bar.py
RENAMED
|
File without changes
|
{context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/contextforge/tui/widgets/tokens_panel.py
RENAMED
|
File without changes
|
{context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/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
|
|
File without changes
|
|
File without changes
|
{context_forge_cli-0.4.13 → context_forge_cli-0.4.14}/tests/fixtures/claude_session_sample.jsonl
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
|