context-forge-cli 0.4.14__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.14 → context_forge_cli-0.4.15}/PKG-INFO +5 -2
  2. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/README.md +4 -1
  3. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/cli.py +12 -3
  4. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/tui/widgets/session_table.py +107 -20
  5. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/utils/display.py +11 -1
  6. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/pyproject.toml +1 -1
  7. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/core/test_display.py +24 -1
  8. context_forge_cli-0.4.15/tests/test_tui_table.py +128 -0
  9. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/uv.lock +1 -1
  10. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/.contextforge.toml +0 -0
  11. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/.github/workflows/publish.yml +0 -0
  12. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/.gitignore +0 -0
  13. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/.python-version +0 -0
  14. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/AGENTS.md +0 -0
  15. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/PLAN.md +0 -0
  16. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/__init__.py +0 -0
  17. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/__init__.py +0 -0
  18. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/altimate_code.py +0 -0
  19. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/antigravity.py +0 -0
  20. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/base.py +0 -0
  21. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/claude_code.py +0 -0
  22. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/claude_desktop.py +0 -0
  23. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/codex.py +0 -0
  24. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/cursor.py +0 -0
  25. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/datatzis.py +0 -0
  26. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/gemini.py +0 -0
  27. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/pi.py +0 -0
  28. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/adapters/registry.py +0 -0
  29. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/core/__init__.py +0 -0
  30. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/core/analytics.py +0 -0
  31. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/core/compactor.py +0 -0
  32. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/core/db.py +0 -0
  33. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/core/injector.py +0 -0
  34. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/core/scanner.py +0 -0
  35. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/core/summarizer.py +0 -0
  36. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/core/token_analyzer.py +0 -0
  37. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/mcp_server.py +0 -0
  38. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/models/__init__.py +0 -0
  39. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/models/config.py +0 -0
  40. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/models/session.py +0 -0
  41. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/tui/__init__.py +0 -0
  42. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/tui/app.py +0 -0
  43. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/tui/clipboard.py +0 -0
  44. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/tui/styles.tcss +0 -0
  45. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/tui/widgets/__init__.py +0 -0
  46. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/tui/widgets/session_detail.py +0 -0
  47. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/tui/widgets/stats_panel.py +0 -0
  48. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/tui/widgets/status_bar.py +0 -0
  49. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/tui/widgets/tokens_panel.py +0 -0
  50. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/tui/widgets/transfer_panel.py +0 -0
  51. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/utils/__init__.py +0 -0
  52. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/contextforge/utils/tokens.py +0 -0
  53. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/__init__.py +0 -0
  54. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/adapters/__init__.py +0 -0
  55. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/adapters/test_antigravity.py +0 -0
  56. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/adapters/test_claude_code.py +0 -0
  57. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/adapters/test_cursor.py +0 -0
  58. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/adapters/test_datatzis.py +0 -0
  59. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/adapters/test_pi.py +0 -0
  60. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/conftest.py +0 -0
  61. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/core/__init__.py +0 -0
  62. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/core/test_compactor.py +0 -0
  63. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/core/test_db.py +0 -0
  64. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/core/test_injector.py +0 -0
  65. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/core/test_summarizer.py +0 -0
  66. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/core/test_token_analyzer.py +0 -0
  67. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/fixtures/claude_session_sample.jsonl +0 -0
  68. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/tasks/22222222222222222222222222222222.json +0 -0
  69. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/tasks/33333333333333333333333333333333.json +0 -0
  70. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/11111111111111111111111111111111.json +0 -0
  71. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/99999999999999999999999999999999.json +0 -0
  72. {context_forge_cli-0.4.14 → 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
  73. {context_forge_cli-0.4.14 → 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
  74. {context_forge_cli-0.4.14 → 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
  75. {context_forge_cli-0.4.14 → 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
  76. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/test_mcp_server.py +0 -0
  77. {context_forge_cli-0.4.14 → context_forge_cli-0.4.15}/tests/test_tui_detail.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: context-forge-cli
3
- Version: 0.4.14
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`).
@@ -21,6 +21,7 @@ from contextforge.utils.display import (
21
21
  console,
22
22
  display_safe,
23
23
  err_console,
24
+ is_subagent as display_is_subagent,
24
25
  row_tags as display_row_tags,
25
26
  sessions_table,
26
27
  )
@@ -130,9 +131,10 @@ def scan_cmd(
130
131
  def list_sessions(
131
132
  tool: Annotated[Optional[str], typer.Option("--tool", "-t")] = None,
132
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,
133
135
  fmt: Annotated[str, typer.Option("--format", "-f")] = "table",
134
136
  ):
135
- """List sessions."""
137
+ """List sessions (parent sessions only, unless --all)."""
136
138
  cfg = _get_config()
137
139
  database = _get_db(cfg)
138
140
  rows = db_module.get_sessions(database, tool=tool, limit=limit)
@@ -145,9 +147,16 @@ def list_sessions(
145
147
  console.print("[dim]No sessions found. Run [bold]cf scan[/bold] first.[/dim]")
146
148
  return
147
149
 
148
- 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)
149
152
  console.print(table)
150
- 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
+ )
151
160
 
152
161
 
153
162
  # ---------------------------------------------------------------------------
@@ -163,6 +163,8 @@ class SessionTable(Widget):
163
163
  self._filter_text: str = ""
164
164
  self._filter_tool: str | None = None
165
165
  self._filter_visible: bool = False
166
+ self._expanded_parents: set[str] = set()
167
+ self._preserve_cursor_id: str | None = None
166
168
 
167
169
  # ── Compose ───────────────────────────────────────────────────────────────
168
170
 
@@ -183,7 +185,7 @@ class SessionTable(Widget):
183
185
  yield DataTable(cursor_type="row", zebra_stripes=True, id="sessions-datatable")
184
186
 
185
187
  def on_mount(self) -> None:
186
- self.border_title = "◈ Sessions"
188
+ self.border_title = "◈ Sessions — select a parent to expand sub-agents"
187
189
  table = self.query_one(DataTable)
188
190
  table.add_columns("Tool", "Title", "Updated", "Tokens", "ID")
189
191
  self.reload()
@@ -223,8 +225,82 @@ class SessionTable(Widget):
223
225
  db = get_db(db_path)
224
226
  rows = get_sessions(db, tool=tool_filter, limit=500)
225
227
  self._rows = rows
226
- self._display_rows = rows
227
- 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()
228
304
 
229
305
  # ── Internal rendering ────────────────────────────────────────────────────
230
306
 
@@ -232,16 +308,22 @@ class SessionTable(Widget):
232
308
  """Populate the DataTable from a list of session dicts."""
233
309
  self._display_rows = rows
234
310
  table = self.query_one(DataTable)
311
+ restore_id = self._preserve_cursor_id
312
+ restore_row_index: int | None = None
235
313
  table.clear()
236
314
 
237
- for row in rows:
315
+ from contextforge.utils.display import _clean_title, display_safe, is_subagent
316
+
317
+ for idx, row in enumerate(rows):
238
318
  tool = row.get("tool", "")
319
+ is_child = bool(row.get("_is_child"))
239
320
  tool_label = TOOL_MARKUP.get(tool, f"⚪ {tool}")
240
321
 
241
- from contextforge.utils.display import _clean_title, display_safe, is_subagent
242
322
  title = _clean_title(row.get("title") or "", max_len=40) or "(no title)"
243
323
  if is_subagent(row):
244
324
  title = f"↳ {title}"
325
+ if is_child:
326
+ title = f" {title}"
245
327
  title = display_safe(title)
246
328
 
247
329
  updated_ms = row.get("updated_at") or 0
@@ -255,23 +337,17 @@ class SessionTable(Widget):
255
337
  session_id = str(row.get("id", ""))[:12]
256
338
 
257
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
258
342
 
259
- def _apply_filter(self) -> None:
260
- """Filter self._rows in memory and re-render."""
261
- text = self._filter_text.lower()
262
- 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
263
346
 
264
- filtered = [
265
- row for row in self._rows
266
- if (not tool or row.get("tool") == tool)
267
- and (
268
- not text
269
- or text in (row.get("title") or "").lower()
270
- or text in (row.get("cwd") or "").lower()
271
- )
272
- ]
273
- self._render_rows(filtered)
274
- 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()
275
351
 
276
352
  # ── Event handlers ────────────────────────────────────────────────────────
277
353
 
@@ -298,6 +374,17 @@ class SessionTable(Widget):
298
374
  btn.remove_class("active")
299
375
  self._apply_filter()
300
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
+
301
388
  def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
302
389
  idx = event.cursor_row
303
390
  display = self._display_rows
@@ -94,7 +94,17 @@ def _project_name(cwd: str) -> str:
94
94
  return parts[-1]
95
95
 
96
96
 
97
- 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)]
98
108
  table = Table(
99
109
  show_header=True,
100
110
  header_style="bold",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "context-forge-cli"
3
- version = "0.4.14"
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" }
@@ -63,6 +63,29 @@ def test_sessions_table_escapes_hostile_titles() -> None:
63
63
  assert "Says ❰bold❱hi❰/bold❱ with ❰/weird/brackets." in exported
64
64
 
65
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
+
66
89
  def test_sessions_table_marks_subagent_rows() -> None:
67
90
  rows = [
68
91
  {"id": "a" * 32, "tool": "pi", "title": "Child run", "cwd": "/proj",
@@ -72,7 +95,7 @@ def test_sessions_table_marks_subagent_rows() -> None:
72
95
  "token_count": 9, "updated_at": "2026-01-15T12:30:00+00:00",
73
96
  "tags": "[]", "summary": ""},
74
97
  ]
75
- table = sessions_table(rows)
98
+ table = sessions_table(rows, include_subagents=True)
76
99
  console = Console(record=True, width=200)
77
100
  console.print(table)
78
101
  exported = console.export_text()
@@ -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.14"
209
+ version = "0.4.15"
210
210
  source = { editable = "." }
211
211
  dependencies = [
212
212
  { name = "anthropic" },