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.
Files changed (77) hide show
  1. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/PKG-INFO +5 -2
  2. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/README.md +4 -1
  3. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/cli.py +27 -10
  4. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/db.py +5 -1
  5. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/session_detail.py +27 -5
  6. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/session_table.py +121 -20
  7. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/utils/display.py +61 -2
  8. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/pyproject.toml +1 -1
  9. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/test_db.py +14 -0
  10. context_forge_cli-0.4.15/tests/core/test_display.py +104 -0
  11. context_forge_cli-0.4.15/tests/test_tui_detail.py +117 -0
  12. context_forge_cli-0.4.15/tests/test_tui_table.py +128 -0
  13. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/uv.lock +1 -1
  14. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/.contextforge.toml +0 -0
  15. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/.github/workflows/publish.yml +0 -0
  16. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/.gitignore +0 -0
  17. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/.python-version +0 -0
  18. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/AGENTS.md +0 -0
  19. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/PLAN.md +0 -0
  20. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/__init__.py +0 -0
  21. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/__init__.py +0 -0
  22. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/altimate_code.py +0 -0
  23. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/antigravity.py +0 -0
  24. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/base.py +0 -0
  25. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/claude_code.py +0 -0
  26. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/claude_desktop.py +0 -0
  27. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/codex.py +0 -0
  28. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/cursor.py +0 -0
  29. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/datatzis.py +0 -0
  30. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/gemini.py +0 -0
  31. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/pi.py +0 -0
  32. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/adapters/registry.py +0 -0
  33. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/__init__.py +0 -0
  34. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/analytics.py +0 -0
  35. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/compactor.py +0 -0
  36. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/injector.py +0 -0
  37. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/scanner.py +0 -0
  38. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/summarizer.py +0 -0
  39. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/core/token_analyzer.py +0 -0
  40. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/mcp_server.py +0 -0
  41. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/models/__init__.py +0 -0
  42. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/models/config.py +0 -0
  43. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/models/session.py +0 -0
  44. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/__init__.py +0 -0
  45. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/app.py +0 -0
  46. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/clipboard.py +0 -0
  47. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/styles.tcss +0 -0
  48. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/__init__.py +0 -0
  49. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/stats_panel.py +0 -0
  50. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/status_bar.py +0 -0
  51. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/tokens_panel.py +0 -0
  52. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/tui/widgets/transfer_panel.py +0 -0
  53. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/utils/__init__.py +0 -0
  54. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/contextforge/utils/tokens.py +0 -0
  55. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/__init__.py +0 -0
  56. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/__init__.py +0 -0
  57. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/test_antigravity.py +0 -0
  58. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/test_claude_code.py +0 -0
  59. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/test_cursor.py +0 -0
  60. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/test_datatzis.py +0 -0
  61. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/adapters/test_pi.py +0 -0
  62. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/conftest.py +0 -0
  63. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/__init__.py +0 -0
  64. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/test_compactor.py +0 -0
  65. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/test_injector.py +0 -0
  66. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/test_summarizer.py +0 -0
  67. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/core/test_token_analyzer.py +0 -0
  68. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/fixtures/claude_session_sample.jsonl +0 -0
  69. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/tasks/22222222222222222222222222222222.json +0 -0
  70. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/tasks/33333333333333333333333333333333.json +0 -0
  71. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/11111111111111111111111111111111.json +0 -0
  72. {context_forge_cli-0.4.13 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/99999999999999999999999999999999.json +0 -0
  73. {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
  74. {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
  75. {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
  76. {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
  77. {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.13
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 console, err_console, sessions_table
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
- table = sessions_table(rows)
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
- console.print(f"[dim]{len(rows)} sessions[/dim]")
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') or '?'}\n"
174
- f"Status: {row.get('status')}\n\n"
175
- f"[bold]Summary:[/bold]\n{summary}",
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": session.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),
@@ -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
- tags = ", ".join(json.loads(tags_raw)) or "—"
175
+ tags_list = [str(t) for t in json.loads(tags_raw)]
168
176
  except Exception:
169
- tags = "—"
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, "")
@@ -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._display_rows = rows
216
- self._render_rows(rows)
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
- for row in rows:
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
- def _apply_filter(self) -> None:
246
- """Filter self._rows in memory and re-render."""
247
- text = self._filter_text.lower()
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
- filtered = [
251
- row for row in self._rows
252
- if (not tool or row.get("tool") == tool)
253
- and (
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(rows: list[dict]) -> 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,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "context-forge-cli"
3
- version = "0.4.13"
3
+ version = "0.4.15"
4
4
  description = "Session manager and context bridge for agentic CLI tools"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -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())
@@ -206,7 +206,7 @@ wheels = [
206
206
 
207
207
  [[package]]
208
208
  name = "context-forge-cli"
209
- version = "0.4.13"
209
+ version = "0.4.15"
210
210
  source = { editable = "." }
211
211
  dependencies = [
212
212
  { name = "anthropic" },