tasktree-manager 2.12.1__tar.gz → 2.13.0__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 (73) hide show
  1. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/CHANGELOG.md +6 -0
  2. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/PKG-INFO +1 -1
  3. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/README.md +1 -0
  4. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/docs/configuration.md +12 -0
  5. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/docs/user-guide.md +17 -0
  6. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/pyproject.toml +1 -1
  7. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/_version.py +2 -2
  8. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/app.py +50 -0
  9. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/claude_hooks.py +49 -6
  10. tasktree_manager-2.13.0/tasktree_manager/services/claude_sessions.py +236 -0
  11. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/config.py +9 -0
  12. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/status_panel.py +67 -0
  13. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_app.py +62 -0
  14. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_claude_hooks.py +72 -0
  15. tasktree_manager-2.13.0/tests/test_claude_sessions.py +248 -0
  16. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_config.py +31 -0
  17. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_status_panel.py +96 -0
  18. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/SKILL.md +0 -0
  19. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/assets/README.md +0 -0
  20. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
  21. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
  22. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
  23. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
  24. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
  25. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
  26. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/references/styling.md +0 -0
  27. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
  28. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.github/templates/CHANGELOG.md.j2 +0 -0
  29. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.github/workflows/ci.yml +0 -0
  30. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.github/workflows/release.yml +0 -0
  31. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/.gitignore +0 -0
  32. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/CONTRIBUTING.md +0 -0
  33. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/LICENSE +0 -0
  34. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/OPENSPEC.md +0 -0
  35. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/ROADMAP.md +0 -0
  36. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/docs/claude-code-integration-spec.md +0 -0
  37. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/docs/installation.md +0 -0
  38. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/docs/troubleshooting.md +0 -0
  39. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/mise.toml +0 -0
  40. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/scripts/bump_version.py +0 -0
  41. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/__init__.py +0 -0
  42. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/cli.py +0 -0
  43. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/commands.py +0 -0
  44. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/__init__.py +0 -0
  45. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/agent_sessions.py +0 -0
  46. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/forge.py +0 -0
  47. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/git_ops.py +0 -0
  48. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/models.py +0 -0
  49. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/services/task_manager.py +0 -0
  50. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/themes.py +0 -0
  51. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/__init__.py +0 -0
  52. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/app_header.py +0 -0
  53. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/create_modal.py +0 -0
  54. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/messages_panel.py +0 -0
  55. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/setup_modal.py +0 -0
  56. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/task_list.py +0 -0
  57. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tasktree_manager/widgets/worktree_list.py +0 -0
  58. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/__init__.py +0 -0
  59. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/conftest.py +0 -0
  60. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_agent_sessions.py +0 -0
  61. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_cli.py +0 -0
  62. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_commands.py +0 -0
  63. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_forge.py +0 -0
  64. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_git_ops.py +0 -0
  65. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_integration_git.py +0 -0
  66. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_medium_fixes.py +0 -0
  67. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_memory.py +0 -0
  68. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_messages_panel.py +0 -0
  69. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_modals.py +0 -0
  70. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_safety_fixes.py +0 -0
  71. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_setup_modal.py +0 -0
  72. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_task_manager.py +0 -0
  73. {tasktree_manager-2.12.1 → tasktree_manager-2.13.0}/tests/test_worktree_badges.py +0 -0
@@ -15,6 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
  - Git hooks support
16
16
  - Custom task metadata
17
17
 
18
+ ## [2.13.0] - 2026-09-11
19
+
20
+ ### Added
21
+ - **tui**: Show Claude session recap in the Info panel
22
+
23
+
18
24
  ## [2.12.1] - 2026-09-03
19
25
 
20
26
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tasktree-manager
3
- Version: 2.12.1
3
+ Version: 2.13.0
4
4
  Summary: TUI application for managing worktree-based tasks
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -20,6 +20,7 @@
20
20
  - 🔧 **Flexible Configuration** - TOML config file, custom keybindings, environment variables
21
21
  - 🤖 **Headless CLI** - Create, list, inspect, finish, and delete tasks from scripts and AI agents without opening the TUI
22
22
  - 🛰️ **Agent Dashboard** - Live Claude Code session state per worktree (from `claude agents`), plus one-key background-agent dispatch — the multi-repo agent view Claude Code lacks natively
23
+ - ✻ **Session Recap** - The Info panel shows each task's latest Claude Code session: title, last turn duration, and Claude's own idle recap of what was done and what comes next — read straight from the transcript, no CLI call
23
24
  - 🔀 **Forge-Aware Safety** - MR/PR and CI state via `glab`/`gh`; squash- and rebase-merged branches count as merged instead of blocking deletion forever
24
25
  - 📦 **Finish Flow with Archives** - Ending a task saves its combined diff (committed-but-unmerged + uncommitted) to an archive before deleting
25
26
 
@@ -180,9 +180,21 @@ claude_memory_dir = "~/.claude/tasktree-memory"
180
180
  # worktree of a repo survives worktree deletion and is shared with future
181
181
  # worktrees of that repo and with sessions in the main checkout. The settings
182
182
  # file is added to the repo's .git/info/exclude so worktrees stay clean.
183
+ # The directory name follows the current Claude CLI rule (every character
184
+ # outside [A-Za-z0-9] becomes "-"); memory saved by older tasktree versions
185
+ # under the previous name (which kept "_") is moved once, automatically.
183
186
  # Default: true
184
187
  claude_repo_memory = true
185
188
 
189
+ # Claude session recap in the Info panel. When a task is highlighted, the
190
+ # bottom of the panel shows its latest Claude Code session: title, last turn
191
+ # duration ("Baked for 45s · done 5:27 PM"), and the idle recap Claude writes
192
+ # a few minutes after a turn ("※ recap: ..."), or the last prompt when no
193
+ # recap exists yet. Read from the session transcript under ~/.claude/projects
194
+ # (honours CLAUDE_CONFIG_DIR); no CLI call.
195
+ # Default: true
196
+ claude_recap = true
197
+
186
198
  # Path to glab executable (GitLab CLI, used for MR/CI status)
187
199
  # Default: "glab" (searches PATH)
188
200
  glab_path = "glab"
@@ -219,6 +219,23 @@ Shows detailed git status for the selected worktree:
219
219
  - **Sync status**: Ahead/behind counts
220
220
  - **File changes**: Modified, staged, and untracked files with git status codes
221
221
 
222
+ While the task list is focused, the panel shows the task summary instead (every
223
+ repo with its changes or `clean`), followed by a **Claude session recap** when the
224
+ task has a Claude Code session:
225
+
226
+ ```
227
+ ─ claude ─────────────────
228
+ access-m2
229
+ ✻ Baked for 45s · done 5:27 PM · busy
230
+ ※ recap: Fulfilling ACCESS2-7180, read-only M2 access: gitops RBAC and
231
+ terraform edits sit unstaged. Next, decide whether to hand-create the group.
232
+ ```
233
+
234
+ - **Title**: the session name (`/rename`), else Claude's own title, else the session slug
235
+ - **Turn line**: duration of the last turn and when it finished; `busy`/`idle` mirrors the task's Claude status indicator
236
+ - **Recap**: the summary Claude writes a few minutes after a turn when the session idles. Until one exists, the dim `last prompt:` line shows what was asked last
237
+ - Nothing is shown for tasks without a session. The block refreshes as you move the cursor and every 5 seconds while a transcript changes. Disable with `claude_recap = false` under `[tools]` in `config.toml`
238
+
222
239
  **Git status codes:**
223
240
  - `M` - Modified
224
241
  - `A` - Added (staged)
@@ -71,7 +71,7 @@ version-file = "tasktree_manager/_version.py"
71
71
  # Semantic Release Configuration
72
72
  [tool.semantic_release]
73
73
  version_toml = ["pyproject.toml:tool.semantic_release.version"]
74
- version = "2.12.1"
74
+ version = "2.13.0"
75
75
  tag_format = "v{version}"
76
76
  branch = "main"
77
77
  commit_message = "chore(release): {version} [skip ci]"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.12.1'
22
- __version_tuple__ = version_tuple = (2, 12, 1)
21
+ __version__ = version = '2.13.0'
22
+ __version_tuple__ = version_tuple = (2, 13, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -20,6 +20,7 @@ from . import __version__
20
20
  from .commands import TaskTreeCommands
21
21
  from .services.agent_sessions import list_agent_sessions, map_sessions_to_worktrees
22
22
  from .services.claude_hooks import ensure_claude_hooks, has_claude_session
23
+ from .services.claude_sessions import ClaudeRecap, read_claude_recap, transcript_key
23
24
  from .services.config import Config, ConfigError
24
25
  from .services.forge import Forge, ForgeStatus, get_forge_status
25
26
  from .services.git_ops import GitOps
@@ -245,6 +246,9 @@ class TaskTreeApp(App):
245
246
  self._preserved_worktree_name: str | None = None
246
247
  # Claude session statuses: task_name -> status string
247
248
  self._claude_statuses: dict[str, str] = {}
249
+ # Transcript identity (path, mtime, size) behind the recap shown in
250
+ # the Info panel; None until the highlighted task has been scanned
251
+ self._recap_key: tuple[Path, int, int] | None = None
248
252
  # Agent session states from `claude agents --json`: worktree path -> state
249
253
  self._agent_sessions: dict[str, str] = {}
250
254
  # Forge MR/CI statuses: worktree path -> ForgeStatus
@@ -648,6 +652,50 @@ class TaskTreeApp(App):
648
652
  if statuses != self._claude_statuses:
649
653
  self._claude_statuses = statuses
650
654
  task_list.refresh_claude_indicators(statuses)
655
+ if self.current_task:
656
+ try:
657
+ status_panel = self.query_one("#status-display", StatusPanel)
658
+ except Exception:
659
+ return
660
+ status_panel.set_claude_live_status(statuses.get(self.current_task.name))
661
+
662
+ # Same tick keeps the recap fresh: a stat per poll, a read only on change
663
+ if self.config.claude_recap and self.current_task and task_list.has_focus:
664
+ self._update_claude_recap(self.current_task, self._recap_key)
665
+
666
+ @work(thread=True, exclusive=True, group="claude_recap_update")
667
+ def _update_claude_recap(self, task: Task, known_key: tuple[Path, int, int] | None) -> None:
668
+ """Read the task's latest Claude transcript tail when it changed.
669
+
670
+ ``known_key`` is passed in from the main thread so the worker never
671
+ reads mutable app state.
672
+ """
673
+ key = transcript_key(task.path)
674
+ if key == known_key:
675
+ return
676
+ recap = read_claude_recap(key[0]) if key else None
677
+ if get_current_worker().is_cancelled:
678
+ return
679
+ self.call_from_thread(self._apply_claude_recap, task, key, recap)
680
+
681
+ def _apply_claude_recap(
682
+ self, task: Task, key: tuple[Path, int, int] | None, recap: ClaudeRecap | None
683
+ ) -> None:
684
+ """Push a fetched recap to the Info panel (main thread)."""
685
+ if not (self.current_task and self.current_task.name == task.name):
686
+ return
687
+ self._recap_key = key
688
+ try:
689
+ status_panel = self.query_one("#status-display", StatusPanel)
690
+ except Exception:
691
+ return
692
+ status_panel.set_claude_recap(task.name, recap, self._claude_statuses.get(task.name))
693
+
694
+ def _request_claude_recap(self, task: Task) -> None:
695
+ """Start a fresh recap read for a newly shown task."""
696
+ self._recap_key = None
697
+ if self.config.claude_recap:
698
+ self._update_claude_recap(task, None)
651
699
 
652
700
  def _poll_agent_sessions_tick(self) -> None:
653
701
  """Dispatch an agent-session poll worker (never subprocess on UI thread)."""
@@ -936,6 +984,7 @@ class TaskTreeApp(App):
936
984
  self._set_info_title(self.current_task.name)
937
985
  status_panel.set_loading(True)
938
986
  self._update_task_summary(self.current_task)
987
+ self._request_claude_recap(self.current_task)
939
988
  elif isinstance(event.widget, WorktreeList):
940
989
  # Worktree list focused → show worktree details
941
990
  if self.current_worktree:
@@ -983,6 +1032,7 @@ class TaskTreeApp(App):
983
1032
  self._set_info_title(event.task.name)
984
1033
  status_panel.set_loading(True)
985
1034
  self._update_task_summary(event.task)
1035
+ self._request_claude_recap(event.task)
986
1036
  else:
987
1037
  worktree_list.clear_worktrees()
988
1038
  status_panel.clear_status()
@@ -1,7 +1,9 @@
1
- """Claude Code hook configuration for status monitoring."""
1
+ """Claude Code integration: config/project-dir resolution, session discovery,
2
+ and the hook/settings writers for status monitoring and shared memory."""
2
3
 
3
4
  import json
4
5
  import os
6
+ import re
5
7
  from datetime import datetime
6
8
  from pathlib import Path
7
9
 
@@ -22,20 +24,37 @@ def claude_config_dir() -> Path:
22
24
  def _encode_project_path(folder: Path) -> str:
23
25
  """Encode a path the way Claude CLI names ~/.claude/projects/ entries.
24
26
 
25
- The encoding replaces "/" and "." with "-".
27
+ Current CLIs replace every character outside [A-Za-z0-9] with "-".
26
28
  """
29
+ return re.sub(r"[^A-Za-z0-9]", "-", str(folder))
30
+
31
+
32
+ def _encode_project_path_legacy(folder: Path) -> str:
33
+ """Older CLIs replaced only "/" and "." — dirs they created keep "_" etc."""
27
34
  return str(folder).replace("/", "-").replace(".", "-")
28
35
 
29
36
 
37
+ def project_dir_candidates(folder: Path) -> list[Path]:
38
+ """Project dirs that may hold transcripts for a folder, current rule first.
39
+
40
+ A dir created by an older CLI for a path with "_" or spaces has a
41
+ different name than the current rule produces; checking both keeps
42
+ session resume and recaps working for it.
43
+ """
44
+ base = claude_config_dir() / "projects"
45
+ names = dict.fromkeys((_encode_project_path(folder), _encode_project_path_legacy(folder)))
46
+ return [base / name for name in names]
47
+
48
+
30
49
  def has_claude_session(folder: Path) -> bool:
31
50
  """Return True if Claude Code has a recorded session for the given folder.
32
51
 
33
52
  Claude CLI stores transcripts at <config dir>/projects/<encoded-path>/*.jsonl.
34
53
  """
35
- project_dir = claude_config_dir() / "projects" / _encode_project_path(folder)
36
- if not project_dir.is_dir():
37
- return False
38
- return any(project_dir.glob("*.jsonl"))
54
+ return any(
55
+ project_dir.is_dir() and any(project_dir.glob("*.jsonl"))
56
+ for project_dir in project_dir_candidates(folder)
57
+ )
39
58
 
40
59
 
41
60
  def repo_memory_dir(repo_path: Path) -> Path:
@@ -49,6 +68,29 @@ def repo_memory_dir(repo_path: Path) -> Path:
49
68
  return claude_config_dir() / "projects" / _encode_project_path(repo_path) / "memory"
50
69
 
51
70
 
71
+ def migrate_legacy_memory_dir(repo_path: Path) -> bool:
72
+ """Move memory saved under an old-rule project dir to the current one.
73
+
74
+ Worktree settings written before the encoder matched the current CLI
75
+ pointed repos with "_" (etc.) at a differently named dir; the CLI never
76
+ reads it for the main checkout. A one-time rename keeps that memory in
77
+ the shared pool. Skipped when the current dir already exists (nothing
78
+ can be merged safely). Returns True when a move happened.
79
+ """
80
+ candidates = project_dir_candidates(repo_path)
81
+ if len(candidates) < 2:
82
+ return False
83
+ current, legacy = candidates[0] / "memory", candidates[1] / "memory"
84
+ if current.exists() or not legacy.is_dir():
85
+ return False
86
+ try:
87
+ current.parent.mkdir(parents=True, exist_ok=True)
88
+ legacy.rename(current)
89
+ except OSError:
90
+ return False
91
+ return True
92
+
93
+
52
94
  def _make_hook(status: str, status_file: str) -> dict:
53
95
  """Create a single hook entry that writes status to the given file path."""
54
96
  safe_path = status_file.replace("'", "'\\''")
@@ -199,6 +241,7 @@ def ensure_worktree_claude_settings(
199
241
 
200
242
  # Merge, preserving user-defined hook groups (same policy as task hooks)
201
243
  existing["hooks"] = _merge_hooks(existing.get("hooks"), _build_hooks_config(str(status_file)))
244
+ migrate_legacy_memory_dir(repo_path)
202
245
  existing["autoMemoryDirectory"] = str(repo_memory_dir(repo_path))
203
246
 
204
247
  settings_file.write_text(json.dumps(existing, indent=2) + "\n")
@@ -0,0 +1,236 @@
1
+ """Read the recap of a task's latest Claude Code session from its transcript.
2
+
3
+ Claude Code appends to ``<config>/projects/<encoded cwd>/<session>.jsonl`` as
4
+ a session runs. The tail of that file carries what the terminal shows after
5
+ a turn: a ``system/turn_duration`` record ("Baked for 45s · done 5:27 PM")
6
+ and, when the session idles long enough, a ``system/away_summary`` record
7
+ (the "※ recap:" line). Title latches (``custom-title``, ``ai-title``,
8
+ ``agent-name``) and ``last-prompt`` are rewritten near the end on every turn.
9
+
10
+ Only the tail is read, newest line first, so a multi-megabyte transcript
11
+ costs one bounded read regardless of its length.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, tzinfo
19
+ from pathlib import Path
20
+
21
+ from .claude_hooks import project_dir_candidates
22
+
23
+ RECAP_SUFFIX = "(disable recaps in /config)"
24
+ TAIL_WINDOW = 1 << 20
25
+ TAIL_WINDOW_RETRY = 4 << 20
26
+ # Turns that span a laptop sleep report days of "duration"; anything above
27
+ # an hour is noise rather than work time
28
+ DURATION_CAP_MS = 3_600_000
29
+ LAST_PROMPT_MAX = 200
30
+
31
+ # Cheap substring gate so multi-hundred-KB tool-result lines are never parsed
32
+ _NEEDLES = (
33
+ b'"turn_duration"',
34
+ b'"away_summary"',
35
+ b'"custom-title"',
36
+ b'"ai-title"',
37
+ b'"agent-name"',
38
+ b'"last-prompt"',
39
+ )
40
+ _TITLE_KEYS = {
41
+ "custom-title": ("custom_title", "customTitle"),
42
+ "ai-title": ("ai_title", "aiTitle"),
43
+ "agent-name": ("agent_name", "agentName"),
44
+ "last-prompt": ("last_prompt", "lastPrompt"),
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ClaudeRecap:
50
+ """What the latest Claude session in a folder last did."""
51
+
52
+ title: str
53
+ duration_ms: int | None
54
+ finished_at: datetime | None # tz-aware UTC
55
+ summary: str | None # away_summary content, marketing suffix stripped
56
+ last_prompt: str | None
57
+
58
+
59
+ def transcript_key(folder: Path) -> tuple[Path, int, int] | None:
60
+ """Return ``(path, mtime_ns, size)`` of the newest transcript for a folder.
61
+
62
+ One ``scandir`` per candidate project dir and no file reads, so callers
63
+ can poll it to detect changes cheaply.
64
+ """
65
+ newest: tuple[int, int, Path] | None = None
66
+ for project_dir in project_dir_candidates(folder):
67
+ try:
68
+ entries = list(os.scandir(project_dir))
69
+ except OSError:
70
+ continue
71
+ for entry in entries:
72
+ try:
73
+ if not entry.name.endswith(".jsonl") or not entry.is_file():
74
+ continue
75
+ stat = entry.stat()
76
+ except OSError:
77
+ # Claude prunes old transcripts at startup; one can vanish
78
+ # between the scandir and the stat
79
+ continue
80
+ candidate = (stat.st_mtime_ns, stat.st_size, Path(entry.path))
81
+ if newest is None or candidate[0] > newest[0]:
82
+ newest = candidate
83
+ if newest is None:
84
+ return None
85
+ mtime_ns, size, path = newest
86
+ return path, mtime_ns, size
87
+
88
+
89
+ def read_claude_recap(path: Path) -> ClaudeRecap | None:
90
+ """Read the recap from one transcript (see ``transcript_key`` to pick it)."""
91
+ try:
92
+ lines, truncated = _tail_lines(path, TAIL_WINDOW)
93
+ found = _scan(lines)
94
+ if "duration_ms" not in found and truncated:
95
+ lines, _ = _tail_lines(path, TAIL_WINDOW_RETRY)
96
+ found = _scan(lines)
97
+ except OSError:
98
+ return None
99
+
100
+ summary = found.get("summary")
101
+ last_prompt = found.get("last_prompt")
102
+ duration_ms = found.get("duration_ms")
103
+ if summary is None and last_prompt is None and duration_ms is None:
104
+ return None
105
+
106
+ title = (
107
+ found.get("custom_title")
108
+ or found.get("ai_title")
109
+ or found.get("agent_name")
110
+ or found.get("slug")
111
+ or path.stem[:8]
112
+ )
113
+ return ClaudeRecap(
114
+ title=title,
115
+ duration_ms=duration_ms,
116
+ finished_at=_parse_timestamp(found.get("finished_at")),
117
+ summary=summary,
118
+ last_prompt=last_prompt,
119
+ )
120
+
121
+
122
+ def format_duration(ms: int) -> str:
123
+ """Render a turn duration the way Claude's terminal does: 45s, 3m 12s."""
124
+ if ms > DURATION_CAP_MS:
125
+ return ">1h"
126
+ seconds = max(0, ms // 1000)
127
+ if seconds < 60:
128
+ return f"{seconds}s"
129
+ return f"{seconds // 60}m {seconds % 60}s"
130
+
131
+
132
+ def format_clock(dt: datetime, tz: tzinfo | None = None) -> str:
133
+ """Render a wall-clock time as 5:27 PM in ``tz`` (local time by default)."""
134
+ # %-I is not portable (Windows); strip the pad by hand instead
135
+ return dt.astimezone(tz).strftime("%I:%M %p").lstrip("0")
136
+
137
+
138
+ def strip_recap_suffix(text: str) -> str:
139
+ """Drop the "(disable recaps in /config)" hint Claude appends to recaps."""
140
+ return text.strip().removesuffix(RECAP_SUFFIX).rstrip()
141
+
142
+
143
+ def _tail_lines(path: Path, window: int) -> tuple[list[bytes], bool]:
144
+ """Return the last ``window`` bytes as whole lines, newest first.
145
+
146
+ The second element says whether the file extends beyond the window
147
+ (so a caller can retry with a bigger one).
148
+ """
149
+ with path.open("rb") as fh:
150
+ fh.seek(0, os.SEEK_END)
151
+ offset = max(0, fh.tell() - window)
152
+ fh.seek(offset)
153
+ data = fh.read()
154
+ lines = data.split(b"\n")
155
+ if offset > 0:
156
+ # The first chunk is a partial line cut by the seek
157
+ lines = lines[1:]
158
+ # The final line may be one Claude is still writing; it fails to parse
159
+ # and is skipped like any other bad line
160
+ return [line for line in reversed(lines) if line.strip()], offset > 0
161
+
162
+
163
+ def _scan(lines: list[bytes]) -> dict:
164
+ """Collect recap fields from transcript lines ordered newest first."""
165
+ found: dict = {}
166
+ turn_seen = False
167
+ for raw in lines:
168
+ if not any(needle in raw for needle in _NEEDLES):
169
+ continue
170
+ try:
171
+ record = json.loads(raw)
172
+ except ValueError:
173
+ continue
174
+ if not isinstance(record, dict):
175
+ continue
176
+
177
+ rtype = record.get("type")
178
+ if rtype == "system":
179
+ subtype = record.get("subtype")
180
+ if subtype == "turn_duration":
181
+ if "duration_ms" not in found:
182
+ found["duration_ms"] = _as_int(record.get("durationMs"))
183
+ found["finished_at"] = _as_text(record.get("timestamp"))
184
+ turn_seen = True
185
+ elif subtype == "away_summary" and not turn_seen and "summary" not in found:
186
+ # A recap older than the latest turn describes a state that
187
+ # has since moved on; only accept one newer than the turn
188
+ content = _as_text(record.get("content"))
189
+ if content:
190
+ found["summary"] = strip_recap_suffix(content)
191
+ elif rtype in _TITLE_KEYS:
192
+ field, source = _TITLE_KEYS[rtype]
193
+ value = _as_text(record.get(source))
194
+ if value and field not in found:
195
+ found[field] = _shorten_prompt(value) if field == "last_prompt" else value
196
+
197
+ slug = _as_text(record.get("slug"))
198
+ if slug and "slug" not in found:
199
+ found["slug"] = slug
200
+
201
+ if all(key in found for key in ("duration_ms", "last_prompt", "custom_title")):
202
+ break
203
+ return found
204
+
205
+
206
+ def _as_int(value) -> int | None:
207
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
208
+
209
+
210
+ def _as_text(value) -> str | None:
211
+ """Return transcript text made safe for the terminal, or None if empty.
212
+
213
+ Transcript strings are model- or user-authored: control characters
214
+ (ESC and friends) would reach the terminal verbatim through Rich, so
215
+ they are dropped and whitespace is folded to single spaces.
216
+ """
217
+ if not isinstance(value, str):
218
+ return None
219
+ text = re.sub(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]", "", value)
220
+ text = re.sub(r"\s+", " ", text).strip()
221
+ return text or None
222
+
223
+
224
+ def _shorten_prompt(text: str) -> str:
225
+ if len(text) > LAST_PROMPT_MAX:
226
+ text = text[: LAST_PROMPT_MAX - 1].rstrip() + "…"
227
+ return text
228
+
229
+
230
+ def _parse_timestamp(value: str | None) -> datetime | None:
231
+ if not value:
232
+ return None
233
+ try:
234
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
235
+ except ValueError:
236
+ return None
@@ -124,6 +124,7 @@ class Config:
124
124
  claude_path: str = "claude"
125
125
  claude_memory_dir: str = "~/.claude/tasktree-memory"
126
126
  claude_repo_memory: bool = True
127
+ claude_recap: bool = True
127
128
  glab_path: str = "glab"
128
129
  gh_path: str = "gh"
129
130
 
@@ -197,6 +198,7 @@ class Config:
197
198
  claude_path = os.path.expanduser(str(tools_config.get("claude_path", "claude")))
198
199
  claude_memory_dir = tools_config.get("claude_memory_dir", "~/.claude/tasktree-memory")
199
200
  claude_repo_memory = bool(tools_config.get("claude_repo_memory", True))
201
+ claude_recap = bool(tools_config.get("claude_recap", True))
200
202
  glab_path = os.path.expanduser(str(tools_config.get("glab_path", "glab")))
201
203
  gh_path = os.path.expanduser(str(tools_config.get("gh_path", "gh")))
202
204
 
@@ -267,6 +269,7 @@ class Config:
267
269
  claude_path=claude_path,
268
270
  claude_memory_dir=claude_memory_dir,
269
271
  claude_repo_memory=claude_repo_memory,
272
+ claude_recap=claude_recap,
270
273
  glab_path=glab_path,
271
274
  gh_path=gh_path,
272
275
  forge_enabled=forge_enabled,
@@ -416,6 +419,12 @@ claude_memory_dir = "{self._toml_escape(self.claude_memory_dir)}"
416
419
  # same repo and with the main checkout. Set to false to disable.
417
420
  claude_repo_memory = {str(self.claude_repo_memory).lower()}
418
421
 
422
+ # Show a recap of the task's latest Claude Code session under the git
423
+ # summary in the Info panel: session title, last turn duration, and the
424
+ # idle recap (or the last prompt when no recap exists yet). Read from the
425
+ # session transcript; no CLI call. Set to false to disable.
426
+ claude_recap = {str(self.claude_recap).lower()}
427
+
419
428
  # Path to glab executable (GitLab CLI, used for MR/CI status)
420
429
  glab_path = "{self._toml_escape(self.glab_path)}"
421
430
 
@@ -3,8 +3,12 @@
3
3
  from rich.text import Text
4
4
  from textual.widgets import Static
5
5
 
6
+ from ..services.claude_sessions import ClaudeRecap, format_clock, format_duration
6
7
  from ..services.models import GitStatus, Task, Worktree
7
8
 
9
+ # .claude_status values worth a suffix on the recap line
10
+ _LIVE_LABELS = {"running": ("busy", "yellow"), "waiting": ("idle", "green")}
11
+
8
12
 
9
13
  def _style_for_code(status_code: str) -> str:
10
14
  """Pick a display style for a git XY status code."""
@@ -29,6 +33,37 @@ class StatusPanel(Static):
29
33
  self._current_task: Task | None = None
30
34
  self._task_statuses: dict[str, GitStatus] = {}
31
35
  self._mode: str = "worktree" # "worktree" or "task"
36
+ self._loading: bool = False
37
+ # Latest Claude session recap and the task it belongs to
38
+ self._claude_recap: ClaudeRecap | None = None
39
+ self._recap_task_name: str | None = None
40
+ self._claude_live: str | None = None
41
+
42
+ def set_claude_recap(
43
+ self, task_name: str, recap: ClaudeRecap | None, live: str | None = None
44
+ ) -> None:
45
+ """Store the Claude recap for a task; re-render if that task is shown.
46
+
47
+ A recap that lands while the git summary is still loading is kept
48
+ and drawn with the summary, so it never replaces "Loading...".
49
+ """
50
+ self._claude_recap = recap
51
+ self._recap_task_name = task_name
52
+ self._claude_live = live
53
+ self._rerender_task_view()
54
+
55
+ def set_claude_live_status(self, live: str | None) -> None:
56
+ """Update the busy/idle suffix on the recap line."""
57
+ if live != self._claude_live:
58
+ self._claude_live = live
59
+ self._rerender_task_view()
60
+
61
+ def _rerender_task_view(self) -> None:
62
+ """Redraw the task summary if the shown task owns the stored recap."""
63
+ if self._mode != "task" or self._loading or self._current_task is None:
64
+ return
65
+ if self._current_task.name == self._recap_task_name:
66
+ self._update_display()
32
67
 
33
68
  def update_status(self, worktree: Worktree | None, status: GitStatus | None) -> None:
34
69
  """Update the status display for a worktree."""
@@ -53,6 +88,7 @@ class StatusPanel(Static):
53
88
  statuses: Optional dict of worktree_name -> GitStatus with file details
54
89
  """
55
90
  self._mode = "task"
91
+ self._loading = False
56
92
  self._current_task = task
57
93
  self._task_statuses = statuses or {}
58
94
  self._update_display()
@@ -89,8 +125,36 @@ class StatusPanel(Static):
89
125
 
90
126
  text.append("\n")
91
127
 
128
+ if self._claude_recap is not None and self._recap_task_name == task.name:
129
+ self._append_claude_recap(text, self._claude_recap)
130
+
92
131
  self.update(text)
93
132
 
133
+ def _append_claude_recap(self, text: Text, recap: ClaudeRecap) -> None:
134
+ """Append the Claude session block: title, turn line, recap or prompt."""
135
+ text.append("─ claude " + "─" * 17 + "\n", style="dim")
136
+ text.append(f"{recap.title}\n", style="bold")
137
+
138
+ turn_parts = []
139
+ if recap.duration_ms is not None:
140
+ turn_parts.append(f"Baked for {format_duration(recap.duration_ms)}")
141
+ if recap.finished_at is not None:
142
+ turn_parts.append(f"done {format_clock(recap.finished_at)}")
143
+ live = _LIVE_LABELS.get(self._claude_live or "")
144
+ if turn_parts or live:
145
+ text.append("✻ ", style="magenta")
146
+ text.append(" · ".join(turn_parts), style="dim")
147
+ if live:
148
+ label, style = live
149
+ text.append(f"{' · ' if turn_parts else ''}{label}", style=style)
150
+ text.append("\n")
151
+
152
+ if recap.summary:
153
+ text.append("※ recap: ", style="cyan")
154
+ text.append(f"{recap.summary}\n")
155
+ elif recap.last_prompt:
156
+ text.append(f"last prompt: {recap.last_prompt}\n", style="dim")
157
+
94
158
  def _render_worktree_status(self) -> None:
95
159
  """Render the worktree-specific status view."""
96
160
  if not self._worktree_name or self._status is None:
@@ -149,6 +213,8 @@ class StatusPanel(Static):
149
213
  self._status = None
150
214
  self._current_task = None
151
215
  self._mode = "worktree"
216
+ self._claude_recap = None
217
+ self._recap_task_name = None
152
218
  self.update(Text("No worktree selected", style="dim"))
153
219
 
154
220
  def set_loading(self, loading: bool = True) -> None:
@@ -157,6 +223,7 @@ class StatusPanel(Static):
157
223
  Args:
158
224
  loading: If True, show loading indicator. If False, restore display.
159
225
  """
226
+ self._loading = loading
160
227
  if loading:
161
228
  self.update(Text("Loading...", style="dim italic"))
162
229
  else: