erk 0.4.5__py3-none-any.whl

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 (331) hide show
  1. erk/__init__.py +12 -0
  2. erk/__main__.py +6 -0
  3. erk/agent_docs/__init__.py +5 -0
  4. erk/agent_docs/models.py +123 -0
  5. erk/agent_docs/operations.py +666 -0
  6. erk/artifacts/__init__.py +5 -0
  7. erk/artifacts/artifact_health.py +623 -0
  8. erk/artifacts/detection.py +16 -0
  9. erk/artifacts/discovery.py +343 -0
  10. erk/artifacts/models.py +63 -0
  11. erk/artifacts/staleness.py +56 -0
  12. erk/artifacts/state.py +100 -0
  13. erk/artifacts/sync.py +624 -0
  14. erk/cli/__init__.py +0 -0
  15. erk/cli/activation.py +132 -0
  16. erk/cli/alias.py +53 -0
  17. erk/cli/cli.py +221 -0
  18. erk/cli/commands/__init__.py +0 -0
  19. erk/cli/commands/admin.py +153 -0
  20. erk/cli/commands/artifact/__init__.py +1 -0
  21. erk/cli/commands/artifact/check.py +260 -0
  22. erk/cli/commands/artifact/group.py +31 -0
  23. erk/cli/commands/artifact/list_cmd.py +89 -0
  24. erk/cli/commands/artifact/show.py +62 -0
  25. erk/cli/commands/artifact/sync_cmd.py +39 -0
  26. erk/cli/commands/branch/__init__.py +26 -0
  27. erk/cli/commands/branch/assign_cmd.py +152 -0
  28. erk/cli/commands/branch/checkout_cmd.py +357 -0
  29. erk/cli/commands/branch/create_cmd.py +161 -0
  30. erk/cli/commands/branch/list_cmd.py +82 -0
  31. erk/cli/commands/branch/unassign_cmd.py +197 -0
  32. erk/cli/commands/cc/__init__.py +15 -0
  33. erk/cli/commands/cc/jsonl_cmd.py +20 -0
  34. erk/cli/commands/cc/session/AGENTS.md +30 -0
  35. erk/cli/commands/cc/session/CLAUDE.md +1 -0
  36. erk/cli/commands/cc/session/__init__.py +15 -0
  37. erk/cli/commands/cc/session/list_cmd.py +167 -0
  38. erk/cli/commands/cc/session/show_cmd.py +175 -0
  39. erk/cli/commands/completion.py +89 -0
  40. erk/cli/commands/completions.py +165 -0
  41. erk/cli/commands/config.py +327 -0
  42. erk/cli/commands/docs/__init__.py +1 -0
  43. erk/cli/commands/docs/group.py +16 -0
  44. erk/cli/commands/docs/sync.py +121 -0
  45. erk/cli/commands/docs/validate.py +102 -0
  46. erk/cli/commands/doctor.py +243 -0
  47. erk/cli/commands/down.py +171 -0
  48. erk/cli/commands/exec/__init__.py +1 -0
  49. erk/cli/commands/exec/group.py +164 -0
  50. erk/cli/commands/exec/scripts/AGENTS.md +79 -0
  51. erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
  52. erk/cli/commands/exec/scripts/__init__.py +5 -0
  53. erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
  54. erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
  55. erk/cli/commands/exec/scripts/check_impl.py +152 -0
  56. erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
  57. erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
  58. erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
  59. erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
  60. erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
  61. erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
  62. erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
  63. erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
  64. erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
  65. erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
  66. erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
  67. erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
  68. erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
  69. erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
  70. erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
  71. erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
  72. erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
  73. erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
  74. erk/cli/commands/exec/scripts/impl_init.py +158 -0
  75. erk/cli/commands/exec/scripts/impl_signal.py +375 -0
  76. erk/cli/commands/exec/scripts/impl_verify.py +49 -0
  77. erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
  78. erk/cli/commands/exec/scripts/list_sessions.py +296 -0
  79. erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
  80. erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
  81. erk/cli/commands/exec/scripts/marker.py +163 -0
  82. erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
  83. erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
  84. erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
  85. erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
  86. erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
  87. erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
  88. erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
  89. erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
  90. erk/cli/commands/exec/scripts/quick_submit.py +32 -0
  91. erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
  92. erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
  93. erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
  94. erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
  95. erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
  96. erk/cli/commands/exec/scripts/slot_objective.py +102 -0
  97. erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
  98. erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
  99. erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
  100. erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
  101. erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
  102. erk/cli/commands/implement.py +695 -0
  103. erk/cli/commands/implement_shared.py +649 -0
  104. erk/cli/commands/info/__init__.py +14 -0
  105. erk/cli/commands/info/release_notes_cmd.py +128 -0
  106. erk/cli/commands/init.py +801 -0
  107. erk/cli/commands/land_cmd.py +690 -0
  108. erk/cli/commands/log_cmd.py +137 -0
  109. erk/cli/commands/md/__init__.py +5 -0
  110. erk/cli/commands/md/check.py +118 -0
  111. erk/cli/commands/md/group.py +14 -0
  112. erk/cli/commands/navigation_helpers.py +430 -0
  113. erk/cli/commands/objective/__init__.py +16 -0
  114. erk/cli/commands/objective/list_cmd.py +47 -0
  115. erk/cli/commands/objective_helpers.py +132 -0
  116. erk/cli/commands/plan/__init__.py +32 -0
  117. erk/cli/commands/plan/check_cmd.py +174 -0
  118. erk/cli/commands/plan/close_cmd.py +69 -0
  119. erk/cli/commands/plan/create_cmd.py +120 -0
  120. erk/cli/commands/plan/docs/__init__.py +18 -0
  121. erk/cli/commands/plan/docs/extract_cmd.py +53 -0
  122. erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
  123. erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
  124. erk/cli/commands/plan/extraction/__init__.py +16 -0
  125. erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
  126. erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
  127. erk/cli/commands/plan/get.py +71 -0
  128. erk/cli/commands/plan/list_cmd.py +754 -0
  129. erk/cli/commands/plan/log_cmd.py +440 -0
  130. erk/cli/commands/plan/start_cmd.py +459 -0
  131. erk/cli/commands/planner/__init__.py +40 -0
  132. erk/cli/commands/planner/configure_cmd.py +73 -0
  133. erk/cli/commands/planner/connect_cmd.py +96 -0
  134. erk/cli/commands/planner/create_cmd.py +148 -0
  135. erk/cli/commands/planner/list_cmd.py +51 -0
  136. erk/cli/commands/planner/register_cmd.py +105 -0
  137. erk/cli/commands/planner/set_default_cmd.py +23 -0
  138. erk/cli/commands/planner/unregister_cmd.py +43 -0
  139. erk/cli/commands/pr/__init__.py +23 -0
  140. erk/cli/commands/pr/check_cmd.py +112 -0
  141. erk/cli/commands/pr/checkout_cmd.py +165 -0
  142. erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
  143. erk/cli/commands/pr/parse_pr_reference.py +10 -0
  144. erk/cli/commands/pr/submit_cmd.py +360 -0
  145. erk/cli/commands/pr/sync_cmd.py +181 -0
  146. erk/cli/commands/prepare_cwd_recovery.py +60 -0
  147. erk/cli/commands/project/__init__.py +16 -0
  148. erk/cli/commands/project/init_cmd.py +91 -0
  149. erk/cli/commands/run/__init__.py +17 -0
  150. erk/cli/commands/run/list_cmd.py +189 -0
  151. erk/cli/commands/run/logs_cmd.py +54 -0
  152. erk/cli/commands/run/shared.py +19 -0
  153. erk/cli/commands/shell_integration.py +29 -0
  154. erk/cli/commands/slot/__init__.py +23 -0
  155. erk/cli/commands/slot/check_cmd.py +277 -0
  156. erk/cli/commands/slot/common.py +314 -0
  157. erk/cli/commands/slot/init_pool_cmd.py +157 -0
  158. erk/cli/commands/slot/list_cmd.py +228 -0
  159. erk/cli/commands/slot/repair_cmd.py +190 -0
  160. erk/cli/commands/stack/__init__.py +23 -0
  161. erk/cli/commands/stack/consolidate_cmd.py +470 -0
  162. erk/cli/commands/stack/list_cmd.py +79 -0
  163. erk/cli/commands/stack/move_cmd.py +309 -0
  164. erk/cli/commands/stack/split_old/README.md +64 -0
  165. erk/cli/commands/stack/split_old/__init__.py +5 -0
  166. erk/cli/commands/stack/split_old/command.py +233 -0
  167. erk/cli/commands/stack/split_old/display.py +116 -0
  168. erk/cli/commands/stack/split_old/plan.py +216 -0
  169. erk/cli/commands/status.py +58 -0
  170. erk/cli/commands/submit.py +768 -0
  171. erk/cli/commands/up.py +154 -0
  172. erk/cli/commands/upgrade.py +82 -0
  173. erk/cli/commands/wt/__init__.py +29 -0
  174. erk/cli/commands/wt/checkout_cmd.py +110 -0
  175. erk/cli/commands/wt/create_cmd.py +998 -0
  176. erk/cli/commands/wt/current_cmd.py +35 -0
  177. erk/cli/commands/wt/delete_cmd.py +573 -0
  178. erk/cli/commands/wt/list_cmd.py +332 -0
  179. erk/cli/commands/wt/rename_cmd.py +66 -0
  180. erk/cli/config.py +242 -0
  181. erk/cli/constants.py +29 -0
  182. erk/cli/core.py +65 -0
  183. erk/cli/debug.py +9 -0
  184. erk/cli/ensure-conversion-tasks.md +288 -0
  185. erk/cli/ensure.py +628 -0
  186. erk/cli/github_parsing.py +96 -0
  187. erk/cli/graphite.py +81 -0
  188. erk/cli/graphite_command.py +80 -0
  189. erk/cli/help_formatter.py +345 -0
  190. erk/cli/output.py +361 -0
  191. erk/cli/presets/dagster.toml +12 -0
  192. erk/cli/presets/generic.toml +12 -0
  193. erk/cli/prompt_hooks_templates/README.md +68 -0
  194. erk/cli/script_output.py +32 -0
  195. erk/cli/shell_integration/bash_wrapper.sh +32 -0
  196. erk/cli/shell_integration/fish_wrapper.fish +39 -0
  197. erk/cli/shell_integration/handler.py +338 -0
  198. erk/cli/shell_integration/zsh_wrapper.sh +32 -0
  199. erk/cli/shell_utils.py +171 -0
  200. erk/cli/subprocess_utils.py +92 -0
  201. erk/cli/uvx_detection.py +59 -0
  202. erk/core/__init__.py +0 -0
  203. erk/core/claude_executor.py +511 -0
  204. erk/core/claude_settings.py +317 -0
  205. erk/core/command_log.py +406 -0
  206. erk/core/commit_message_generator.py +234 -0
  207. erk/core/completion.py +10 -0
  208. erk/core/consolidation_utils.py +177 -0
  209. erk/core/context.py +570 -0
  210. erk/core/display/__init__.py +4 -0
  211. erk/core/display/abc.py +24 -0
  212. erk/core/display/real.py +30 -0
  213. erk/core/display_utils.py +526 -0
  214. erk/core/file_utils.py +87 -0
  215. erk/core/health_checks.py +1315 -0
  216. erk/core/health_checks_dogfooder/__init__.py +85 -0
  217. erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
  218. erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
  219. erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
  220. erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
  221. erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
  222. erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
  223. erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
  224. erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
  225. erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
  226. erk/core/implementation_queue/__init__.py +1 -0
  227. erk/core/implementation_queue/github/__init__.py +8 -0
  228. erk/core/implementation_queue/github/abc.py +7 -0
  229. erk/core/implementation_queue/github/noop.py +38 -0
  230. erk/core/implementation_queue/github/printing.py +43 -0
  231. erk/core/implementation_queue/github/real.py +119 -0
  232. erk/core/init_utils.py +227 -0
  233. erk/core/output_filter.py +338 -0
  234. erk/core/plan_store/__init__.py +6 -0
  235. erk/core/planner/__init__.py +1 -0
  236. erk/core/planner/registry_abc.py +8 -0
  237. erk/core/planner/registry_fake.py +129 -0
  238. erk/core/planner/registry_real.py +195 -0
  239. erk/core/planner/types.py +7 -0
  240. erk/core/pr_utils.py +30 -0
  241. erk/core/release_notes.py +263 -0
  242. erk/core/repo_discovery.py +126 -0
  243. erk/core/script_writer.py +41 -0
  244. erk/core/services/__init__.py +1 -0
  245. erk/core/services/plan_list_service.py +94 -0
  246. erk/core/shell.py +51 -0
  247. erk/core/user_feedback.py +11 -0
  248. erk/core/version_check.py +55 -0
  249. erk/core/workflow_display.py +75 -0
  250. erk/core/worktree_pool.py +190 -0
  251. erk/core/worktree_utils.py +300 -0
  252. erk/data/CHANGELOG.md +438 -0
  253. erk/data/__init__.py +1 -0
  254. erk/data/claude/agents/devrun.md +180 -0
  255. erk/data/claude/commands/erk/__init__.py +0 -0
  256. erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
  257. erk/data/claude/commands/erk/fix-conflicts.md +25 -0
  258. erk/data/claude/commands/erk/git-pr-push.md +345 -0
  259. erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
  260. erk/data/claude/commands/erk/land.md +193 -0
  261. erk/data/claude/commands/erk/objective-create.md +370 -0
  262. erk/data/claude/commands/erk/objective-list.md +34 -0
  263. erk/data/claude/commands/erk/objective-next-plan.md +220 -0
  264. erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
  265. erk/data/claude/commands/erk/plan-implement.md +202 -0
  266. erk/data/claude/commands/erk/plan-save.md +45 -0
  267. erk/data/claude/commands/erk/plan-submit.md +39 -0
  268. erk/data/claude/commands/erk/pr-address.md +367 -0
  269. erk/data/claude/commands/erk/pr-submit.md +58 -0
  270. erk/data/claude/skills/dignified-python/SKILL.md +48 -0
  271. erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
  272. erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
  273. erk/data/claude/skills/dignified-python/subprocess.md +99 -0
  274. erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
  275. erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
  276. erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
  277. erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
  278. erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
  279. erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
  280. erk/data/claude/skills/learned-docs/SKILL.md +362 -0
  281. erk/data/github/actions/setup-claude-erk/action.yml +11 -0
  282. erk/data/github/prompts/dignified-python-review.md +125 -0
  283. erk/data/github/workflows/dignified-python-review.yml +61 -0
  284. erk/data/github/workflows/erk-impl.yml +251 -0
  285. erk/hooks/__init__.py +1 -0
  286. erk/hooks/decorators.py +319 -0
  287. erk/status/__init__.py +8 -0
  288. erk/status/collectors/__init__.py +9 -0
  289. erk/status/collectors/base.py +52 -0
  290. erk/status/collectors/git.py +76 -0
  291. erk/status/collectors/github.py +81 -0
  292. erk/status/collectors/graphite.py +80 -0
  293. erk/status/collectors/impl.py +145 -0
  294. erk/status/models/__init__.py +4 -0
  295. erk/status/models/status_data.py +404 -0
  296. erk/status/orchestrator.py +169 -0
  297. erk/status/renderers/__init__.py +5 -0
  298. erk/status/renderers/simple.py +322 -0
  299. erk/tui/AGENTS.md +193 -0
  300. erk/tui/CLAUDE.md +1 -0
  301. erk/tui/__init__.py +1 -0
  302. erk/tui/app.py +1404 -0
  303. erk/tui/commands/__init__.py +1 -0
  304. erk/tui/commands/executor.py +66 -0
  305. erk/tui/commands/provider.py +165 -0
  306. erk/tui/commands/real_executor.py +63 -0
  307. erk/tui/commands/registry.py +121 -0
  308. erk/tui/commands/types.py +36 -0
  309. erk/tui/data/__init__.py +1 -0
  310. erk/tui/data/provider.py +492 -0
  311. erk/tui/data/types.py +104 -0
  312. erk/tui/filtering/__init__.py +1 -0
  313. erk/tui/filtering/logic.py +43 -0
  314. erk/tui/filtering/types.py +55 -0
  315. erk/tui/jsonl_viewer/__init__.py +1 -0
  316. erk/tui/jsonl_viewer/app.py +61 -0
  317. erk/tui/jsonl_viewer/models.py +208 -0
  318. erk/tui/jsonl_viewer/widgets.py +204 -0
  319. erk/tui/sorting/__init__.py +6 -0
  320. erk/tui/sorting/logic.py +55 -0
  321. erk/tui/sorting/types.py +68 -0
  322. erk/tui/styles/dash.tcss +95 -0
  323. erk/tui/widgets/__init__.py +1 -0
  324. erk/tui/widgets/command_output.py +112 -0
  325. erk/tui/widgets/plan_table.py +276 -0
  326. erk/tui/widgets/status_bar.py +116 -0
  327. erk-0.4.5.dist-info/METADATA +376 -0
  328. erk-0.4.5.dist-info/RECORD +331 -0
  329. erk-0.4.5.dist-info/WHEEL +4 -0
  330. erk-0.4.5.dist-info/entry_points.txt +2 -0
  331. erk-0.4.5.dist-info/licenses/LICENSE.md +3 -0
@@ -0,0 +1,492 @@
1
+ """Data provider for TUI plan table."""
2
+
3
+ import subprocess
4
+ from abc import ABC, abstractmethod
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from erk.core.context import ErkContext
9
+ from erk.core.display_utils import (
10
+ format_relative_time,
11
+ format_workflow_outcome,
12
+ format_workflow_run_id,
13
+ get_workflow_run_state,
14
+ )
15
+ from erk.core.pr_utils import select_display_pr
16
+ from erk.core.repo_discovery import NoRepoSentinel, RepoContext, ensure_erk_metadata_dir
17
+ from erk.tui.data.types import PlanFilters, PlanRowData
18
+ from erk.tui.sorting.types import BranchActivity
19
+ from erk_shared.gateway.browser.abc import BrowserLauncher
20
+ from erk_shared.gateway.clipboard.abc import Clipboard
21
+ from erk_shared.github.emoji import format_checks_cell, get_pr_status_emoji
22
+ from erk_shared.github.issues import IssueInfo
23
+ from erk_shared.github.metadata.plan_header import (
24
+ extract_plan_header_local_impl_at,
25
+ extract_plan_header_remote_impl_at,
26
+ extract_plan_header_worktree_name,
27
+ )
28
+ from erk_shared.github.parsing import github_repo_location_from_url
29
+ from erk_shared.github.types import GitHubRepoId, GitHubRepoLocation, PullRequestInfo, WorkflowRun
30
+ from erk_shared.naming import extract_leading_issue_number
31
+ from erk_shared.plan_store.types import Plan, PlanState
32
+
33
+
34
+ class PlanDataProvider(ABC):
35
+ """Abstract base class for plan data providers.
36
+
37
+ Defines the interface for fetching plan data for TUI display.
38
+ """
39
+
40
+ @property
41
+ @abstractmethod
42
+ def repo_root(self) -> Path:
43
+ """Get the repository root path.
44
+
45
+ Returns:
46
+ Path to the repository root directory
47
+ """
48
+ ...
49
+
50
+ @property
51
+ @abstractmethod
52
+ def clipboard(self) -> Clipboard:
53
+ """Get the clipboard interface for copy operations.
54
+
55
+ Returns:
56
+ Clipboard interface for copying to system clipboard
57
+ """
58
+ ...
59
+
60
+ @property
61
+ @abstractmethod
62
+ def browser(self) -> BrowserLauncher:
63
+ """Get the browser launcher interface for opening URLs.
64
+
65
+ Returns:
66
+ BrowserLauncher interface for opening URLs in browser
67
+ """
68
+ ...
69
+
70
+ @abstractmethod
71
+ def fetch_plans(self, filters: PlanFilters) -> list[PlanRowData]:
72
+ """Fetch plans matching the given filters.
73
+
74
+ Args:
75
+ filters: Filter options for the query
76
+
77
+ Returns:
78
+ List of PlanRowData objects for display
79
+ """
80
+ ...
81
+
82
+ @abstractmethod
83
+ def close_plan(self, issue_number: int, issue_url: str) -> list[int]:
84
+ """Close a plan and its linked PRs.
85
+
86
+ Args:
87
+ issue_number: The issue number to close
88
+ issue_url: The issue URL for PR linkage lookup
89
+
90
+ Returns:
91
+ List of PR numbers that were also closed
92
+ """
93
+ ...
94
+
95
+ @abstractmethod
96
+ def submit_to_queue(self, issue_number: int, issue_url: str) -> None:
97
+ """Submit a plan to the implementation queue.
98
+
99
+ Args:
100
+ issue_number: The issue number to submit
101
+ issue_url: The issue URL for repository context
102
+ """
103
+ ...
104
+
105
+ @abstractmethod
106
+ def fetch_branch_activity(self, rows: list[PlanRowData]) -> dict[int, BranchActivity]:
107
+ """Fetch branch activity for plans that exist locally.
108
+
109
+ Examines commits on each local branch (not in trunk) to determine
110
+ the most recent activity.
111
+
112
+ Args:
113
+ rows: List of plan rows to fetch activity for
114
+
115
+ Returns:
116
+ Mapping of issue_number to BranchActivity for plans with local worktrees.
117
+ Plans without local worktrees are not included in the result.
118
+ """
119
+ ...
120
+
121
+
122
+ class RealPlanDataProvider(PlanDataProvider):
123
+ """Production implementation that wraps PlanListService.
124
+
125
+ Transforms PlanListData into PlanRowData for TUI display.
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ ctx: ErkContext,
131
+ location: GitHubRepoLocation,
132
+ clipboard: Clipboard,
133
+ browser: BrowserLauncher,
134
+ ) -> None:
135
+ """Initialize with context and repository info.
136
+
137
+ Args:
138
+ ctx: ErkContext with all dependencies
139
+ location: GitHub repository location (local root + repo identity)
140
+ clipboard: Clipboard interface for copy operations
141
+ browser: BrowserLauncher interface for opening URLs
142
+ """
143
+ self._ctx = ctx
144
+ self._location = location
145
+ self._clipboard = clipboard
146
+ self._browser = browser
147
+
148
+ @property
149
+ def repo_root(self) -> Path:
150
+ """Get the repository root path."""
151
+ return self._location.root
152
+
153
+ @property
154
+ def clipboard(self) -> Clipboard:
155
+ """Get the clipboard interface for copy operations."""
156
+ return self._clipboard
157
+
158
+ @property
159
+ def browser(self) -> BrowserLauncher:
160
+ """Get the browser launcher interface for opening URLs."""
161
+ return self._browser
162
+
163
+ def fetch_plans(self, filters: PlanFilters) -> list[PlanRowData]:
164
+ """Fetch plans and transform to TUI row format.
165
+
166
+ Args:
167
+ filters: Filter options for the query
168
+
169
+ Returns:
170
+ List of PlanRowData objects for display
171
+ """
172
+ # Determine if we need workflow runs
173
+ needs_workflow_runs = filters.show_runs or filters.run_state is not None
174
+
175
+ # Fetch data via PlanListService
176
+ # Note: PR linkages are always fetched via unified GraphQL query (no performance penalty)
177
+ plan_data = self._ctx.plan_list_service.get_plan_list_data(
178
+ location=self._location,
179
+ labels=list(filters.labels),
180
+ state=filters.state,
181
+ limit=filters.limit,
182
+ skip_workflow_runs=not needs_workflow_runs,
183
+ creator=filters.creator,
184
+ )
185
+
186
+ # Build local worktree mapping
187
+ worktree_by_issue = self._build_worktree_mapping()
188
+
189
+ # Transform to PlanRowData
190
+ rows: list[PlanRowData] = []
191
+ use_graphite = self._ctx.global_config.use_graphite if self._ctx.global_config else False
192
+
193
+ for issue in plan_data.issues:
194
+ plan = _issue_to_plan(issue)
195
+
196
+ # Get workflow run for filtering
197
+ workflow_run = plan_data.workflow_runs.get(issue.number)
198
+
199
+ # Apply run_state filter
200
+ if filters.run_state is not None:
201
+ if workflow_run is None:
202
+ continue
203
+ if get_workflow_run_state(workflow_run) != filters.run_state:
204
+ continue
205
+
206
+ # Build row data
207
+ row = self._build_row_data(
208
+ plan=plan,
209
+ issue_number=issue.number,
210
+ pr_linkages=plan_data.pr_linkages,
211
+ workflow_run=workflow_run,
212
+ worktree_by_issue=worktree_by_issue,
213
+ use_graphite=use_graphite,
214
+ )
215
+ rows.append(row)
216
+
217
+ return rows
218
+
219
+ def close_plan(self, issue_number: int, issue_url: str) -> list[int]:
220
+ """Close a plan and its linked PRs.
221
+
222
+ Args:
223
+ issue_number: The issue number to close
224
+ issue_url: The issue URL for PR linkage lookup
225
+
226
+ Returns:
227
+ List of PR numbers that were also closed
228
+ """
229
+ # Close linked PRs first
230
+ closed_prs = self._close_linked_prs(issue_number, issue_url)
231
+
232
+ # Close the plan (issue)
233
+ self._ctx.plan_store.close_plan(self._location.root, str(issue_number))
234
+
235
+ return closed_prs
236
+
237
+ def _close_linked_prs(self, issue_number: int, issue_url: str) -> list[int]:
238
+ """Close all OPEN PRs linked to an issue.
239
+
240
+ Args:
241
+ issue_number: The issue number
242
+ issue_url: The issue URL for location lookup
243
+
244
+ Returns:
245
+ List of PR numbers that were closed
246
+ """
247
+ location = github_repo_location_from_url(self._location.root, issue_url)
248
+ if location is None:
249
+ return []
250
+ pr_linkages = self._ctx.github.get_prs_linked_to_issues(location, [issue_number])
251
+ linked_prs = pr_linkages.get(issue_number, [])
252
+
253
+ closed_prs: list[int] = []
254
+ for pr in linked_prs:
255
+ if pr.state == "OPEN":
256
+ self._ctx.github.close_pr(self._location.root, pr.number)
257
+ closed_prs.append(pr.number)
258
+
259
+ return closed_prs
260
+
261
+ def submit_to_queue(self, issue_number: int, issue_url: str) -> None:
262
+ """Submit a plan to the implementation queue.
263
+
264
+ Runs 'erk plan submit' as a subprocess to handle the complex workflow
265
+ of creating branches, PRs, and triggering GitHub Actions.
266
+
267
+ Args:
268
+ issue_number: The issue number to submit
269
+ issue_url: The issue URL (unused, kept for interface consistency)
270
+ """
271
+ # Run erk plan submit command from the repository root
272
+ subprocess.run(
273
+ ["erk", "plan", "submit", str(issue_number)],
274
+ cwd=self._location.root,
275
+ check=True,
276
+ capture_output=True,
277
+ )
278
+
279
+ def fetch_branch_activity(self, rows: list[PlanRowData]) -> dict[int, BranchActivity]:
280
+ """Fetch branch activity for plans that exist locally.
281
+
282
+ Args:
283
+ rows: List of plan rows to fetch activity for
284
+
285
+ Returns:
286
+ Mapping of issue_number to BranchActivity for plans with local worktrees.
287
+ """
288
+ result: dict[int, BranchActivity] = {}
289
+
290
+ # Get trunk branch
291
+ trunk = self._ctx.git.detect_trunk_branch(self._location.root)
292
+
293
+ for row in rows:
294
+ # Only fetch for plans with local branches
295
+ if not row.exists_locally or row.worktree_branch is None:
296
+ continue
297
+
298
+ # Get commits on branch not in trunk
299
+ commits = self._ctx.git.get_branch_commits_with_authors(
300
+ self._location.root,
301
+ row.worktree_branch,
302
+ trunk,
303
+ limit=1, # Only need most recent
304
+ )
305
+
306
+ if commits:
307
+ # Parse ISO timestamp from git
308
+ timestamp_str = commits[0]["timestamp"]
309
+ commit_at = datetime.fromisoformat(timestamp_str)
310
+ result[row.issue_number] = BranchActivity(
311
+ last_commit_at=commit_at,
312
+ last_commit_author=commits[0]["author"],
313
+ )
314
+ else:
315
+ result[row.issue_number] = BranchActivity.empty()
316
+
317
+ return result
318
+
319
+ def _build_worktree_mapping(self) -> dict[int, tuple[str, str | None]]:
320
+ """Build mapping of issue number to (worktree name, branch).
321
+
322
+ Uses PXXXX prefix matching on worktree names to associate worktrees
323
+ with issues. Branch names follow pattern: P{issue_number}-{slug}-{timestamp}
324
+
325
+ Returns:
326
+ Mapping of issue number to tuple of (worktree_name, branch_name)
327
+ """
328
+ _ensure_erk_metadata_dir_from_context(self._ctx.repo)
329
+ worktree_by_issue: dict[int, tuple[str, str | None]] = {}
330
+ worktrees = self._ctx.git.list_worktrees(self._location.root)
331
+ for worktree in worktrees:
332
+ issue_number = extract_leading_issue_number(worktree.path.name)
333
+ if issue_number is not None:
334
+ if issue_number not in worktree_by_issue:
335
+ worktree_by_issue[issue_number] = (
336
+ worktree.path.name,
337
+ worktree.branch,
338
+ )
339
+ return worktree_by_issue
340
+
341
+ def _build_row_data(
342
+ self,
343
+ *,
344
+ plan: Plan,
345
+ issue_number: int,
346
+ pr_linkages: dict[int, list[PullRequestInfo]],
347
+ workflow_run: WorkflowRun | None,
348
+ worktree_by_issue: dict[int, tuple[str, str | None]],
349
+ use_graphite: bool,
350
+ ) -> PlanRowData:
351
+ """Build a single PlanRowData from plan and related data."""
352
+ # Truncate title for display
353
+ title = plan.title
354
+ if len(title) > 50:
355
+ title = title[:47] + "..."
356
+
357
+ # Store full title
358
+ full_title = plan.title
359
+
360
+ # Worktree info
361
+ worktree_name = ""
362
+ worktree_branch: str | None = None
363
+ exists_locally = False
364
+
365
+ if issue_number in worktree_by_issue:
366
+ worktree_name, worktree_branch = worktree_by_issue[issue_number]
367
+ exists_locally = True
368
+
369
+ # Extract from issue body
370
+ local_impl_str: str | None = None
371
+ remote_impl_str: str | None = None
372
+ if plan.body:
373
+ extracted = extract_plan_header_worktree_name(plan.body)
374
+ if extracted and not worktree_name:
375
+ worktree_name = extracted
376
+ local_impl_str = extract_plan_header_local_impl_at(plan.body)
377
+ remote_impl_str = extract_plan_header_remote_impl_at(plan.body)
378
+
379
+ # Parse ISO 8601 timestamps for storage
380
+ last_local_impl_at: datetime | None = None
381
+ last_remote_impl_at: datetime | None = None
382
+ if local_impl_str:
383
+ last_local_impl_at = datetime.fromisoformat(local_impl_str.replace("Z", "+00:00"))
384
+ if remote_impl_str:
385
+ last_remote_impl_at = datetime.fromisoformat(remote_impl_str.replace("Z", "+00:00"))
386
+
387
+ # Format time displays
388
+ local_impl = format_relative_time(local_impl_str)
389
+ local_impl_display = local_impl if local_impl else "-"
390
+ remote_impl = format_relative_time(remote_impl_str)
391
+ remote_impl_display = remote_impl if remote_impl else "-"
392
+
393
+ # PR info
394
+ pr_number: int | None = None
395
+ pr_url: str | None = None
396
+ pr_title: str | None = None
397
+ pr_state: str | None = None
398
+ pr_display = "-"
399
+ checks_display = "-"
400
+
401
+ if issue_number in pr_linkages:
402
+ issue_prs = pr_linkages[issue_number]
403
+ selected_pr = select_display_pr(issue_prs)
404
+ if selected_pr is not None:
405
+ pr_number = selected_pr.number
406
+ pr_title = selected_pr.title
407
+ pr_state = selected_pr.state
408
+ graphite_url = self._ctx.graphite.get_graphite_url(
409
+ GitHubRepoId(selected_pr.owner, selected_pr.repo), selected_pr.number
410
+ )
411
+ pr_url = graphite_url if use_graphite and graphite_url else selected_pr.url
412
+ emoji = get_pr_status_emoji(selected_pr)
413
+ if selected_pr.will_close_target:
414
+ emoji += "🔗"
415
+ pr_display = f"#{selected_pr.number} {emoji}"
416
+ checks_display = format_checks_cell(selected_pr)
417
+
418
+ # Workflow run info
419
+ run_id: str | None = None
420
+ run_status: str | None = None
421
+ run_conclusion: str | None = None
422
+ run_id_display = "-"
423
+ run_state_display = "-"
424
+ run_url: str | None = None
425
+
426
+ if workflow_run is not None:
427
+ run_id = str(workflow_run.run_id)
428
+ run_status = workflow_run.status
429
+ run_conclusion = workflow_run.conclusion
430
+ if plan.url:
431
+ parts = plan.url.split("/")
432
+ if len(parts) >= 5:
433
+ owner = parts[-4]
434
+ repo_name = parts[-3]
435
+ run_url = (
436
+ f"https://github.com/{owner}/{repo_name}/actions/runs/{workflow_run.run_id}"
437
+ )
438
+ run_id_display = format_workflow_run_id(workflow_run, run_url)
439
+ run_state_display = format_workflow_outcome(workflow_run)
440
+
441
+ # Log entries (empty for now - will be fetched on demand in the modal)
442
+ log_entries: tuple[tuple[str, str, str], ...] = ()
443
+
444
+ return PlanRowData(
445
+ issue_number=issue_number,
446
+ issue_url=plan.url,
447
+ title=title,
448
+ pr_number=pr_number,
449
+ pr_url=pr_url,
450
+ pr_display=pr_display,
451
+ checks_display=checks_display,
452
+ worktree_name=worktree_name,
453
+ exists_locally=exists_locally,
454
+ local_impl_display=local_impl_display,
455
+ remote_impl_display=remote_impl_display,
456
+ run_id_display=run_id_display,
457
+ run_state_display=run_state_display,
458
+ run_url=run_url,
459
+ full_title=full_title,
460
+ pr_title=pr_title,
461
+ pr_state=pr_state,
462
+ worktree_branch=worktree_branch,
463
+ last_local_impl_at=last_local_impl_at,
464
+ last_remote_impl_at=last_remote_impl_at,
465
+ run_id=run_id,
466
+ run_status=run_status,
467
+ run_conclusion=run_conclusion,
468
+ log_entries=log_entries,
469
+ )
470
+
471
+
472
+ def _issue_to_plan(issue: IssueInfo) -> Plan:
473
+ """Convert IssueInfo to Plan format."""
474
+ state = PlanState.OPEN if issue.state == "OPEN" else PlanState.CLOSED
475
+ return Plan(
476
+ plan_identifier=str(issue.number),
477
+ title=issue.title,
478
+ body=issue.body,
479
+ state=state,
480
+ url=issue.url,
481
+ labels=issue.labels,
482
+ assignees=issue.assignees,
483
+ created_at=issue.created_at,
484
+ updated_at=issue.updated_at,
485
+ metadata={"number": issue.number},
486
+ )
487
+
488
+
489
+ def _ensure_erk_metadata_dir_from_context(repo: RepoContext | NoRepoSentinel) -> None:
490
+ """Ensure erk metadata directory exists, handling sentinel case."""
491
+ if isinstance(repo, RepoContext):
492
+ ensure_erk_metadata_dir(repo)
erk/tui/data/types.py ADDED
@@ -0,0 +1,104 @@
1
+ """Data types for TUI components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class PlanRowData:
11
+ """Row data for displaying a plan in the TUI table.
12
+
13
+ Contains pre-formatted display strings and raw data needed for actions.
14
+ Immutable to ensure table state consistency.
15
+
16
+ Attributes:
17
+ issue_number: GitHub issue number (e.g., 123)
18
+ issue_url: Full URL to the GitHub issue
19
+ title: Plan title (truncated for display)
20
+ pr_number: PR number if linked, None otherwise
21
+ pr_url: URL to PR (GitHub or Graphite), None if no PR
22
+ pr_display: Formatted PR cell content (e.g., "#123 👀")
23
+ checks_display: Formatted checks cell (e.g., "✓" or "✗")
24
+ worktree_name: Name of local worktree, empty string if none
25
+ exists_locally: Whether worktree exists on local machine
26
+ local_impl_display: Relative time since last local impl (e.g., "2h ago")
27
+ remote_impl_display: Relative time since last remote impl
28
+ run_id_display: Formatted workflow run ID
29
+ run_state_display: Formatted workflow run state
30
+ run_url: URL to the GitHub Actions run page
31
+ full_title: Complete untruncated plan title
32
+ pr_title: PR title if linked
33
+ pr_state: PR state (e.g., "OPEN", "MERGED", "CLOSED")
34
+ worktree_branch: Branch name in the worktree (if exists locally)
35
+ last_local_impl_at: Raw timestamp for local impl
36
+ last_remote_impl_at: Raw timestamp for remote impl
37
+ run_id: Raw workflow run ID (for display and URL construction)
38
+ run_status: Workflow run status (e.g., "completed", "in_progress")
39
+ run_conclusion: Workflow run conclusion (e.g., "success", "failure", "cancelled")
40
+ log_entries: List of (event_name, timestamp, comment_url) for plan log
41
+ """
42
+
43
+ issue_number: int
44
+ issue_url: str | None
45
+ title: str
46
+ pr_number: int | None
47
+ pr_url: str | None
48
+ pr_display: str
49
+ checks_display: str
50
+ worktree_name: str
51
+ exists_locally: bool
52
+ local_impl_display: str
53
+ remote_impl_display: str
54
+ run_id_display: str
55
+ run_state_display: str
56
+ run_url: str | None
57
+ full_title: str
58
+ pr_title: str | None
59
+ pr_state: str | None
60
+ worktree_branch: str | None
61
+ last_local_impl_at: datetime | None
62
+ last_remote_impl_at: datetime | None
63
+ run_id: str | None
64
+ run_status: str | None
65
+ run_conclusion: str | None
66
+ log_entries: tuple[tuple[str, str, str], ...]
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class PlanFilters:
71
+ """Filter options for plan list queries.
72
+
73
+ Matches options from the existing CLI command for consistency.
74
+
75
+ Attributes:
76
+ labels: Labels to filter by (default: ["erk-plan"])
77
+ state: Filter by state ("open", "closed", or None for all)
78
+ run_state: Filter by workflow run state (e.g., "in_progress")
79
+ limit: Maximum number of results (None for no limit)
80
+ show_prs: Whether to include PR data
81
+ show_runs: Whether to include workflow run data
82
+ creator: Filter by creator username (None for all users)
83
+ """
84
+
85
+ labels: tuple[str, ...]
86
+ state: str | None
87
+ run_state: str | None
88
+ limit: int | None
89
+ show_prs: bool
90
+ show_runs: bool
91
+ creator: str | None = None
92
+
93
+ @staticmethod
94
+ def default() -> PlanFilters:
95
+ """Create default filters (open erk-plan issues)."""
96
+ return PlanFilters(
97
+ labels=("erk-plan",),
98
+ state=None,
99
+ run_state=None,
100
+ limit=None,
101
+ show_prs=False,
102
+ show_runs=False,
103
+ creator=None,
104
+ )
@@ -0,0 +1 @@
1
+ """Filtering module for TUI dashboard."""
@@ -0,0 +1,43 @@
1
+ """Pure filtering logic for TUI dashboard."""
2
+
3
+ from erk.tui.data.types import PlanRowData
4
+
5
+
6
+ def filter_plans(plans: list[PlanRowData], query: str) -> list[PlanRowData]:
7
+ """Filter plans by query matching title, issue number, or PR number.
8
+
9
+ Case-insensitive substring matching against:
10
+ - Plan title
11
+ - Issue number (as string)
12
+ - PR number (as string, if present)
13
+
14
+ Args:
15
+ plans: List of plans to filter
16
+ query: Search query string
17
+
18
+ Returns:
19
+ Filtered list of plans matching the query.
20
+ Returns all plans if query is empty.
21
+ """
22
+ if not query:
23
+ return plans
24
+
25
+ query_lower = query.lower()
26
+ result: list[PlanRowData] = []
27
+
28
+ for plan in plans:
29
+ # Check title (case-insensitive)
30
+ if query_lower in plan.title.lower():
31
+ result.append(plan)
32
+ continue
33
+
34
+ # Check issue number
35
+ if query_lower in str(plan.issue_number):
36
+ result.append(plan)
37
+ continue
38
+
39
+ # Check PR number if present
40
+ if plan.pr_number is not None and query_lower in str(plan.pr_number):
41
+ result.append(plan)
42
+
43
+ return result
@@ -0,0 +1,55 @@
1
+ """Filter state types for TUI dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum, auto
7
+
8
+
9
+ class FilterMode(Enum):
10
+ """Filter mode state."""
11
+
12
+ INACTIVE = auto()
13
+ ACTIVE = auto()
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class FilterState:
18
+ """State for filter mode with progressive escape behavior.
19
+
20
+ Attributes:
21
+ mode: Current filter mode (INACTIVE or ACTIVE)
22
+ query: Current filter query text
23
+ """
24
+
25
+ mode: FilterMode
26
+ query: str = ""
27
+
28
+ @staticmethod
29
+ def initial() -> FilterState:
30
+ """Create initial inactive state."""
31
+ return FilterState(mode=FilterMode.INACTIVE, query="")
32
+
33
+ def activate(self) -> FilterState:
34
+ """Activate filter mode."""
35
+ return FilterState(mode=FilterMode.ACTIVE, query=self.query)
36
+
37
+ def with_query(self, query: str) -> FilterState:
38
+ """Update query text."""
39
+ return FilterState(mode=self.mode, query=query)
40
+
41
+ def handle_escape(self) -> FilterState:
42
+ """Handle escape key with progressive behavior.
43
+
44
+ Progressive escape:
45
+ - If text exists, clear it first (stay in active mode)
46
+ - If text is empty, deactivate filter mode
47
+
48
+ Returns:
49
+ New state after escape handling
50
+ """
51
+ if self.query:
52
+ # Clear text first, stay in active mode
53
+ return FilterState(mode=FilterMode.ACTIVE, query="")
54
+ # Text already empty, deactivate
55
+ return FilterState(mode=FilterMode.INACTIVE, query="")
@@ -0,0 +1 @@
1
+ """JSONL viewer TUI for Claude Code session files."""