context-forge-cli 0.4.13__tar.gz → 0.4.15__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.15}/PKG-INFO +5 -2
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/README.md +4 -1
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/cli.py +27 -10
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/db.py +5 -1
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/session_detail.py +27 -5
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/session_table.py +121 -20
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/utils/display.py +61 -2
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/pyproject.toml +1 -1
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/test_db.py +14 -0
- context_forge_cli-0.4.15/tests/core/test_display.py +104 -0
- context_forge_cli-0.4.15/tests/test_tui_detail.py +117 -0
- context_forge_cli-0.4.15/tests/test_tui_table.py +128 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/uv.lock +1 -1
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/.contextforge.toml +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/.github/workflows/publish.yml +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/.gitignore +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/.python-version +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/AGENTS.md +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/PLAN.md +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/altimate_code.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/antigravity.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/base.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/claude_code.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/claude_desktop.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/codex.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/cursor.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/datatzis.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/gemini.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/pi.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/registry.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/analytics.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/compactor.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/injector.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/scanner.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/summarizer.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/token_analyzer.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/mcp_server.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/models/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/models/config.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/models/session.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/app.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/clipboard.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/styles.tcss +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/stats_panel.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/status_bar.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/tokens_panel.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/transfer_panel.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/utils/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/utils/tokens.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/test_antigravity.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/test_claude_code.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/test_cursor.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/test_datatzis.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/test_pi.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/conftest.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/__init__.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/test_compactor.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/test_injector.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/test_summarizer.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/test_token_analyzer.py +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/fixtures/claude_session_sample.jsonl +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/tasks/22222222222222222222222222222222.json +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/tasks/33333333333333333333333333333333.json +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/11111111111111111111111111111111.json +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/99999999999999999999999999999999.json +0 -0
- {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/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.15}/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.15}/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.15}/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.15}/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.15
|
|
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
|
|
@@ -180,7 +180,10 @@ Notes:
|
|
|
180
180
|
- **Sub-agent tracking** — sessions spawned by sub-agents are tracked and cross-linked: Pi
|
|
181
181
|
(`pi-subagents`) child transcripts get `subagent` + `parent:<session-id>` tags naming the
|
|
182
182
|
parent session; the datatzis harness's orchestrator tasks (one per user prompt) get
|
|
183
|
-
`subagent` + `workspace:<session-id>` tags.
|
|
183
|
+
`subagent` + `workspace:<session-id>` tags. Surfaces are parent-first: `cf ls` shows only
|
|
184
|
+
main sessions unless `--all` is passed, and in the TUI dashboard sub-agents stay hidden
|
|
185
|
+
until you select their parent row, which nests them underneath as an indented `↳` list —
|
|
186
|
+
selecting the parent again collapses them.
|
|
184
187
|
- **datatzis discovery** — per-repo storage: ContextForge scans repository roots for
|
|
185
188
|
`*/.datatzis/workspaces/` (up to two levels deep). Configure roots via the
|
|
186
189
|
`DATATZIS_SEARCH_ROOTS` environment variable (colon-separated; default `~/Documents/Github`).
|
|
@@ -151,7 +151,10 @@ Notes:
|
|
|
151
151
|
- **Sub-agent tracking** — sessions spawned by sub-agents are tracked and cross-linked: Pi
|
|
152
152
|
(`pi-subagents`) child transcripts get `subagent` + `parent:<session-id>` tags naming the
|
|
153
153
|
parent session; the datatzis harness's orchestrator tasks (one per user prompt) get
|
|
154
|
-
`subagent` + `workspace:<session-id>` tags.
|
|
154
|
+
`subagent` + `workspace:<session-id>` tags. Surfaces are parent-first: `cf ls` shows only
|
|
155
|
+
main sessions unless `--all` is passed, and in the TUI dashboard sub-agents stay hidden
|
|
156
|
+
until you select their parent row, which nests them underneath as an indented `↳` list —
|
|
157
|
+
selecting the parent again collapses them.
|
|
155
158
|
- **datatzis discovery** — per-repo storage: ContextForge scans repository roots for
|
|
156
159
|
`*/.datatzis/workspaces/` (up to two levels deep). Configure roots via the
|
|
157
160
|
`DATATZIS_SEARCH_ROOTS` environment variable (colon-separated; default `~/Documents/Github`).
|
|
@@ -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,14 @@ 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
|
+
is_subagent as display_is_subagent,
|
|
25
|
+
row_tags as display_row_tags,
|
|
26
|
+
sessions_table,
|
|
27
|
+
)
|
|
20
28
|
|
|
21
29
|
def _version_callback(value: bool) -> None:
|
|
22
30
|
if value:
|
|
@@ -123,9 +131,10 @@ def scan_cmd(
|
|
|
123
131
|
def list_sessions(
|
|
124
132
|
tool: Annotated[Optional[str], typer.Option("--tool", "-t")] = None,
|
|
125
133
|
limit: Annotated[int, typer.Option("--limit", "-n")] = 50,
|
|
134
|
+
all_sessions: Annotated[bool, typer.Option("--all", "-a", help="Include sub-agent (child) sessions")] = False,
|
|
126
135
|
fmt: Annotated[str, typer.Option("--format", "-f")] = "table",
|
|
127
136
|
):
|
|
128
|
-
"""List sessions."""
|
|
137
|
+
"""List sessions (parent sessions only, unless --all)."""
|
|
129
138
|
cfg = _get_config()
|
|
130
139
|
database = _get_db(cfg)
|
|
131
140
|
rows = db_module.get_sessions(database, tool=tool, limit=limit)
|
|
@@ -138,9 +147,16 @@ def list_sessions(
|
|
|
138
147
|
console.print("[dim]No sessions found. Run [bold]cf scan[/bold] first.[/dim]")
|
|
139
148
|
return
|
|
140
149
|
|
|
141
|
-
|
|
150
|
+
hidden = sum(1 for r in rows if display_is_subagent(r))
|
|
151
|
+
table = sessions_table(rows, include_subagents=all_sessions)
|
|
142
152
|
console.print(table)
|
|
143
|
-
|
|
153
|
+
if all_sessions or not hidden:
|
|
154
|
+
console.print(f"[dim]{len(rows)} sessions[/dim]")
|
|
155
|
+
else:
|
|
156
|
+
console.print(
|
|
157
|
+
f"[dim]{len(rows) - hidden} sessions "
|
|
158
|
+
f"(+{hidden} sub-agent hidden — use --all to show)[/dim]"
|
|
159
|
+
)
|
|
144
160
|
|
|
145
161
|
|
|
146
162
|
# ---------------------------------------------------------------------------
|
|
@@ -167,12 +183,13 @@ def show(
|
|
|
167
183
|
|
|
168
184
|
summary = row.get("summary") or "[dim]No summary yet — run [bold]cf summarize[/bold][/dim]"
|
|
169
185
|
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]
|
|
186
|
+
f"[bold]{display_safe(row.get('title') or '(no title)')}[/bold]\n\n"
|
|
187
|
+
f"Tool: {display_safe(row.get('tool'))}\n"
|
|
188
|
+
f"CWD: {display_safe(row.get('cwd') or '?')}\n"
|
|
189
|
+
f"Tokens: {display_safe(row.get('token_count'))}\n"
|
|
190
|
+
f"Status: {display_safe(row.get('status'))}\n\n"
|
|
191
|
+
f"[bold]Tags:[/bold] {display_safe(', '.join(display_row_tags(row)) or '—')}\n\n"
|
|
192
|
+
f"[bold]Summary:[/bold]\n{display_safe(summary) if row.get('summary') else summary}",
|
|
176
193
|
title=f"Session {session_id[:16]}",
|
|
177
194
|
)
|
|
178
195
|
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.15}/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.15}/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
|
|
|
@@ -154,6 +163,8 @@ class SessionTable(Widget):
|
|
|
154
163
|
self._filter_text: str = ""
|
|
155
164
|
self._filter_tool: str | None = None
|
|
156
165
|
self._filter_visible: bool = False
|
|
166
|
+
self._expanded_parents: set[str] = set()
|
|
167
|
+
self._preserve_cursor_id: str | None = None
|
|
157
168
|
|
|
158
169
|
# ── Compose ───────────────────────────────────────────────────────────────
|
|
159
170
|
|
|
@@ -169,10 +180,12 @@ class SessionTable(Widget):
|
|
|
169
180
|
yield Button("⚡ Alt", id="ftool-alt", classes="filter-tool-btn")
|
|
170
181
|
yield Button("◉ Agy", id="ftool-agy", classes="filter-tool-btn")
|
|
171
182
|
yield Button("▲ Cursor", id="ftool-cur", classes="filter-tool-btn")
|
|
183
|
+
yield Button("∏ Pi", id="ftool-pi", classes="filter-tool-btn")
|
|
184
|
+
yield Button("✎ Datz", id="ftool-datz", classes="filter-tool-btn")
|
|
172
185
|
yield DataTable(cursor_type="row", zebra_stripes=True, id="sessions-datatable")
|
|
173
186
|
|
|
174
187
|
def on_mount(self) -> None:
|
|
175
|
-
self.border_title = "◈ Sessions"
|
|
188
|
+
self.border_title = "◈ Sessions — select a parent to expand sub-agents"
|
|
176
189
|
table = self.query_one(DataTable)
|
|
177
190
|
table.add_columns("Tool", "Title", "Updated", "Tokens", "ID")
|
|
178
191
|
self.reload()
|
|
@@ -212,8 +225,82 @@ class SessionTable(Widget):
|
|
|
212
225
|
db = get_db(db_path)
|
|
213
226
|
rows = get_sessions(db, tool=tool_filter, limit=500)
|
|
214
227
|
self._rows = rows
|
|
215
|
-
self.
|
|
216
|
-
|
|
228
|
+
self._rerender()
|
|
229
|
+
|
|
230
|
+
# ── Hierarchy: parent sessions first, sub-agents under their parent ───────
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _children_by_parent(rows: list[dict]) -> dict[str, list[dict]]:
|
|
234
|
+
from contextforge.utils.display import parent_id_of
|
|
235
|
+
|
|
236
|
+
children: dict[str, list[dict]] = {}
|
|
237
|
+
for row in rows:
|
|
238
|
+
parent = parent_id_of(row)
|
|
239
|
+
if parent:
|
|
240
|
+
children.setdefault(parent, []).append(row)
|
|
241
|
+
return children
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def _mains(rows: list[dict]) -> list[dict]:
|
|
245
|
+
from contextforge.utils.display import is_subagent
|
|
246
|
+
return [row for row in rows if not is_subagent(row)]
|
|
247
|
+
|
|
248
|
+
def _build_display_rows(self, mains: list[dict]) -> list[dict]:
|
|
249
|
+
"""Main sessions in order, with children of expanded parents nested."""
|
|
250
|
+
children = self._children_by_parent(self._rows)
|
|
251
|
+
display: list[dict] = []
|
|
252
|
+
for main in mains:
|
|
253
|
+
display.append(main)
|
|
254
|
+
if main["id"] in self._expanded_parents:
|
|
255
|
+
for child in children.get(main["id"], []):
|
|
256
|
+
row = dict(child)
|
|
257
|
+
row["_is_child"] = True
|
|
258
|
+
display.append(row)
|
|
259
|
+
return display
|
|
260
|
+
|
|
261
|
+
def _rerender(self) -> None:
|
|
262
|
+
"""Recompute the display list (filter + expansion) and render."""
|
|
263
|
+
self._apply_filter_and_render()
|
|
264
|
+
|
|
265
|
+
def _apply_filter_and_render(self) -> None:
|
|
266
|
+
text = self._filter_text.lower()
|
|
267
|
+
tool = self._filter_tool
|
|
268
|
+
|
|
269
|
+
def _row_matches(row: dict) -> bool:
|
|
270
|
+
if tool and row.get("tool") != tool:
|
|
271
|
+
return False
|
|
272
|
+
if not text:
|
|
273
|
+
return True
|
|
274
|
+
return (
|
|
275
|
+
text in (row.get("title") or "").lower()
|
|
276
|
+
or text in (row.get("cwd") or "").lower()
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
children = self._children_by_parent(self._rows)
|
|
280
|
+
|
|
281
|
+
filtered: list[dict] = []
|
|
282
|
+
for main in self._mains(self._rows):
|
|
283
|
+
if _row_matches(main):
|
|
284
|
+
filtered.append(main)
|
|
285
|
+
continue
|
|
286
|
+
# A child's match keeps its parent (and its siblings) visible
|
|
287
|
+
for child in children.get(main["id"], []):
|
|
288
|
+
if _row_matches(child):
|
|
289
|
+
filtered.append(main)
|
|
290
|
+
break
|
|
291
|
+
self._render_rows(self._build_display_rows(filtered))
|
|
292
|
+
self.post_message(self.FilterChanged(self._filter_text, tool, len(self._display_rows)))
|
|
293
|
+
|
|
294
|
+
def toggle_expansion(self, parent_id: str | None) -> None:
|
|
295
|
+
"""Expand (or collapse) a parent session's sub-agent rows in place."""
|
|
296
|
+
if not parent_id:
|
|
297
|
+
return
|
|
298
|
+
if parent_id in self._expanded_parents:
|
|
299
|
+
self._expanded_parents.discard(parent_id)
|
|
300
|
+
else:
|
|
301
|
+
self._expanded_parents.add(parent_id)
|
|
302
|
+
self._preserve_cursor_id = parent_id
|
|
303
|
+
self._rerender()
|
|
217
304
|
|
|
218
305
|
# ── Internal rendering ────────────────────────────────────────────────────
|
|
219
306
|
|
|
@@ -221,14 +308,23 @@ class SessionTable(Widget):
|
|
|
221
308
|
"""Populate the DataTable from a list of session dicts."""
|
|
222
309
|
self._display_rows = rows
|
|
223
310
|
table = self.query_one(DataTable)
|
|
311
|
+
restore_id = self._preserve_cursor_id
|
|
312
|
+
restore_row_index: int | None = None
|
|
224
313
|
table.clear()
|
|
225
314
|
|
|
226
|
-
|
|
315
|
+
from contextforge.utils.display import _clean_title, display_safe, is_subagent
|
|
316
|
+
|
|
317
|
+
for idx, row in enumerate(rows):
|
|
227
318
|
tool = row.get("tool", "")
|
|
319
|
+
is_child = bool(row.get("_is_child"))
|
|
228
320
|
tool_label = TOOL_MARKUP.get(tool, f"⚪ {tool}")
|
|
229
321
|
|
|
230
|
-
from contextforge.utils.display import _clean_title
|
|
231
322
|
title = _clean_title(row.get("title") or "", max_len=40) or "(no title)"
|
|
323
|
+
if is_subagent(row):
|
|
324
|
+
title = f"↳ {title}"
|
|
325
|
+
if is_child:
|
|
326
|
+
title = f" {title}"
|
|
327
|
+
title = display_safe(title)
|
|
232
328
|
|
|
233
329
|
updated_ms = row.get("updated_at") or 0
|
|
234
330
|
try:
|
|
@@ -241,23 +337,17 @@ class SessionTable(Widget):
|
|
|
241
337
|
session_id = str(row.get("id", ""))[:12]
|
|
242
338
|
|
|
243
339
|
table.add_row(tool_label, title, updated, tokens, session_id)
|
|
340
|
+
if restore_id is not None and str(row.get("id", "")) == restore_id:
|
|
341
|
+
restore_row_index = idx
|
|
244
342
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
tool = self._filter_tool
|
|
343
|
+
if restore_row_index is not None:
|
|
344
|
+
table.move_cursor(row=restore_row_index)
|
|
345
|
+
self._preserve_cursor_id = None
|
|
249
346
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
not text
|
|
255
|
-
or text in (row.get("title") or "").lower()
|
|
256
|
-
or text in (row.get("cwd") or "").lower()
|
|
257
|
-
)
|
|
258
|
-
]
|
|
259
|
-
self._render_rows(filtered)
|
|
260
|
-
self.post_message(self.FilterChanged(self._filter_text, tool, len(filtered)))
|
|
347
|
+
def _apply_filter(self) -> None:
|
|
348
|
+
"""Filter in memory: parent rows match their own fields or any of
|
|
349
|
+
their children's; then re-render with the current expansion state."""
|
|
350
|
+
self._apply_filter_and_render()
|
|
261
351
|
|
|
262
352
|
# ── Event handlers ────────────────────────────────────────────────────────
|
|
263
353
|
|
|
@@ -284,6 +374,17 @@ class SessionTable(Widget):
|
|
|
284
374
|
btn.remove_class("active")
|
|
285
375
|
self._apply_filter()
|
|
286
376
|
|
|
377
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
378
|
+
"""Enter/click on a parent row expands its sub-agent rows beneath it."""
|
|
379
|
+
idx = event.cursor_row
|
|
380
|
+
display = self._display_rows
|
|
381
|
+
if 0 <= idx < len(display):
|
|
382
|
+
row = display[idx]
|
|
383
|
+
from contextforge.utils.display import is_subagent
|
|
384
|
+
if not is_subagent(row):
|
|
385
|
+
self.toggle_expansion(str(row["id"]))
|
|
386
|
+
event.stop()
|
|
387
|
+
|
|
287
388
|
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
288
389
|
idx = event.cursor_row
|
|
289
390
|
display = self._display_rows
|
|
@@ -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:
|
|
@@ -48,7 +94,17 @@ def _project_name(cwd: str) -> str:
|
|
|
48
94
|
return parts[-1]
|
|
49
95
|
|
|
50
96
|
|
|
51
|
-
def sessions_table(
|
|
97
|
+
def sessions_table(
|
|
98
|
+
rows: list[dict],
|
|
99
|
+
include_subagents: bool = False,
|
|
100
|
+
) -> Table:
|
|
101
|
+
"""Build a table of sessions.
|
|
102
|
+
|
|
103
|
+
Sub-agent (child) sessions are hidden by default — surfaces show the
|
|
104
|
+
parent-first view and may opt in with ``include_subagents=True``; rows
|
|
105
|
+
then carry a ``↳`` prefix."""
|
|
106
|
+
if not include_subagents:
|
|
107
|
+
rows = [r for r in rows if not is_subagent(r)]
|
|
52
108
|
table = Table(
|
|
53
109
|
show_header=True,
|
|
54
110
|
header_style="bold",
|
|
@@ -70,6 +126,9 @@ def sessions_table(rows: list[dict]) -> Table:
|
|
|
70
126
|
|
|
71
127
|
session_id = str(row.get("id", ""))[:13]
|
|
72
128
|
title = _clean_title(row.get("title") or "")
|
|
129
|
+
if is_subagent(row):
|
|
130
|
+
title = f"↳ {title}"
|
|
131
|
+
title = display_safe(title)
|
|
73
132
|
project = _project_name(row.get("cwd") or "")
|
|
74
133
|
|
|
75
134
|
raw_tokens = row.get("token_count")
|
|
@@ -90,7 +149,7 @@ def sessions_table(rows: list[dict]) -> Table:
|
|
|
90
149
|
updated = "?"
|
|
91
150
|
|
|
92
151
|
summary_raw = row.get("summary") or ""
|
|
93
|
-
summary = _clean_title(summary_raw, max_len=45)
|
|
152
|
+
summary = display_safe(_clean_title(summary_raw, max_len=45))
|
|
94
153
|
|
|
95
154
|
table.add_row(
|
|
96
155
|
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,104 @@
|
|
|
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_hides_subagents_by_default() -> None:
|
|
67
|
+
"""Parent-first: `cf ls` shows main sessions unless --all is given."""
|
|
68
|
+
now = "2026-01-15T12:31:00+00:00"
|
|
69
|
+
rows = [
|
|
70
|
+
{"id": "b" * 32, "tool": "pi", "title": "Parent chat", "cwd": "/proj",
|
|
71
|
+
"token_count": 9, "updated_at": now, "tags": "[]", "summary": ""},
|
|
72
|
+
{"id": "a" * 32, "tool": "pi", "title": "Child run", "cwd": "/proj",
|
|
73
|
+
"token_count": 5, "updated_at": now,
|
|
74
|
+
"tags": json.dumps(["subagent", "parent:" + "b" * 32]), "summary": ""},
|
|
75
|
+
]
|
|
76
|
+
console = Console(record=True, width=200)
|
|
77
|
+
console.print(sessions_table(rows))
|
|
78
|
+
exported = console.export_text()
|
|
79
|
+
assert "Parent chat" in exported
|
|
80
|
+
assert "Child run" not in exported
|
|
81
|
+
|
|
82
|
+
console = Console(record=True, width=200)
|
|
83
|
+
console.print(sessions_table(rows, include_subagents=True))
|
|
84
|
+
exported = console.export_text()
|
|
85
|
+
assert "↳ Child run" in exported
|
|
86
|
+
assert "Parent chat" in exported
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_sessions_table_marks_subagent_rows() -> None:
|
|
90
|
+
rows = [
|
|
91
|
+
{"id": "a" * 32, "tool": "pi", "title": "Child run", "cwd": "/proj",
|
|
92
|
+
"token_count": 5, "updated_at": "2026-01-15T12:31:00+00:00",
|
|
93
|
+
"tags": json.dumps(["subagent", "parent:01a11552"]), "summary": ""},
|
|
94
|
+
{"id": "b" * 32, "tool": "pi", "title": "Parent chat", "cwd": "/proj",
|
|
95
|
+
"token_count": 9, "updated_at": "2026-01-15T12:30:00+00:00",
|
|
96
|
+
"tags": "[]", "summary": ""},
|
|
97
|
+
]
|
|
98
|
+
table = sessions_table(rows, include_subagents=True)
|
|
99
|
+
console = Console(record=True, width=200)
|
|
100
|
+
console.print(table)
|
|
101
|
+
exported = console.export_text()
|
|
102
|
+
assert "↳ Child run" in exported
|
|
103
|
+
assert "Parent chat" in exported
|
|
104
|
+
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)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Regression tests for the SessionTable hierarchy: parent sessions first,
|
|
2
|
+
sub-agent rows nested under their expanded parent."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from textual.app import App, ComposeResult
|
|
12
|
+
from textual.widgets import DataTable
|
|
13
|
+
|
|
14
|
+
from contextforge.core.db import get_db, upsert_session
|
|
15
|
+
from contextforge.models.session import Message, Session
|
|
16
|
+
from contextforge.tui.widgets.session_table import SessionTable
|
|
17
|
+
|
|
18
|
+
PARENT_ID = "1" * 32
|
|
19
|
+
CHILD_ID = "2" * 32
|
|
20
|
+
OTHER_ID = "3" * 32
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _seed(db_path: Path) -> None:
|
|
24
|
+
now = datetime(2026, 1, 15, 12, 30, tzinfo=timezone.utc)
|
|
25
|
+
db = get_db(db_path)
|
|
26
|
+
upsert_session(db, Session(
|
|
27
|
+
id=PARENT_ID, tool="pi", title="Parent chat", cwd="/proj",
|
|
28
|
+
created_at=now, updated_at=now,
|
|
29
|
+
messages=[Message(role="user", content="parent")],
|
|
30
|
+
))
|
|
31
|
+
upsert_session(db, Session(
|
|
32
|
+
id=CHILD_ID, tool="pi", title="Child worker run", cwd="/proj",
|
|
33
|
+
created_at=now, updated_at=now,
|
|
34
|
+
messages=[Message(role="user", content="child")],
|
|
35
|
+
tags=["subagent", f"parent:{PARENT_ID}"],
|
|
36
|
+
))
|
|
37
|
+
upsert_session(db, Session(
|
|
38
|
+
id=OTHER_ID, tool="codex", title="Another parent chat", cwd="/proj2",
|
|
39
|
+
created_at=now, updated_at=now,
|
|
40
|
+
messages=[Message(role="user", content="other")],
|
|
41
|
+
))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _HarnessApp(App):
|
|
45
|
+
def __init__(self, db_path: Path) -> None:
|
|
46
|
+
super().__init__()
|
|
47
|
+
self.db_path = db_path
|
|
48
|
+
|
|
49
|
+
def compose(self) -> ComposeResult:
|
|
50
|
+
yield SessionTable()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_session_table_parents_first_and_expandable():
|
|
54
|
+
async def scenario():
|
|
55
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
56
|
+
db_path = Path(tmpdir) / "test.db"
|
|
57
|
+
_seed(db_path)
|
|
58
|
+
app = _HarnessApp(db_path)
|
|
59
|
+
async with app.run_test() as pilot:
|
|
60
|
+
table = app.query_one(SessionTable)
|
|
61
|
+
await pilot.pause()
|
|
62
|
+
dt = table.query_one(DataTable)
|
|
63
|
+
|
|
64
|
+
# At first glance: parent sessions only, hidden children
|
|
65
|
+
assert len(table._display_rows) == 2
|
|
66
|
+
assert [r["id"][:1] for r in table._display_rows] == ["1", "3"]
|
|
67
|
+
|
|
68
|
+
# Selecting (entering) the parent expands its children in place
|
|
69
|
+
table.toggle_expansion(PARENT_ID)
|
|
70
|
+
await pilot.pause()
|
|
71
|
+
assert len(table._display_rows) == 3
|
|
72
|
+
assert table._display_rows[0]["id"] == PARENT_ID
|
|
73
|
+
assert table._display_rows[1]["id"] == CHILD_ID
|
|
74
|
+
assert table._display_rows[1]["_is_child"] is True
|
|
75
|
+
assert table._display_rows[2]["id"] == OTHER_ID
|
|
76
|
+
|
|
77
|
+
# Toggling again collapses
|
|
78
|
+
table.toggle_expansion(PARENT_ID)
|
|
79
|
+
await pilot.pause()
|
|
80
|
+
assert len(table._display_rows) == 2
|
|
81
|
+
|
|
82
|
+
asyncio.run(scenario())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_session_table_child_match_surfaces_parent():
|
|
86
|
+
async def scenario():
|
|
87
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
88
|
+
db_path = Path(tmpdir) / "test.db"
|
|
89
|
+
_seed(db_path)
|
|
90
|
+
app = _HarnessApp(db_path)
|
|
91
|
+
async with app.run_test() as pilot:
|
|
92
|
+
table = app.query_one(SessionTable)
|
|
93
|
+
await pilot.pause()
|
|
94
|
+
|
|
95
|
+
# Text filter matching only the CHILD surfaces its parent
|
|
96
|
+
table._filter_text = "worker"
|
|
97
|
+
table._apply_filter()
|
|
98
|
+
await pilot.pause()
|
|
99
|
+
ids = [r["id"] for r in table._display_rows]
|
|
100
|
+
assert ids == [PARENT_ID]
|
|
101
|
+
|
|
102
|
+
asyncio.run(scenario())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_session_table_expansion_survives_filter_toggle():
|
|
106
|
+
async def scenario():
|
|
107
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
108
|
+
db_path = Path(tmpdir) / "test.db"
|
|
109
|
+
_seed(db_path)
|
|
110
|
+
app = _HarnessApp(db_path)
|
|
111
|
+
async with app.run_test() as pilot:
|
|
112
|
+
table = app.query_one(SessionTable)
|
|
113
|
+
await pilot.pause()
|
|
114
|
+
table.toggle_expansion(PARENT_ID)
|
|
115
|
+
await pilot.pause()
|
|
116
|
+
|
|
117
|
+
table._filter_tool = "codex"
|
|
118
|
+
table._apply_filter()
|
|
119
|
+
await pilot.pause()
|
|
120
|
+
assert len(table._display_rows) == 1 # only the codex parent
|
|
121
|
+
|
|
122
|
+
table._filter_tool = None
|
|
123
|
+
table._apply_filter()
|
|
124
|
+
await pilot.pause()
|
|
125
|
+
# Expansion state persists across filter changes
|
|
126
|
+
assert len(table._display_rows) == 3
|
|
127
|
+
|
|
128
|
+
asyncio.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
|
{context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/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.15}/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.15}/contextforge/tui/widgets/stats_panel.py
RENAMED
|
File without changes
|
{context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/status_bar.py
RENAMED
|
File without changes
|
{context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/tokens_panel.py
RENAMED
|
File without changes
|
{context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/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.15}/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
|