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,322 @@
1
+ """Simple text-based status renderer."""
2
+
3
+ import click
4
+
5
+ from erk.status.models.status_data import StatusData
6
+ from erk_shared.output.output import user_output
7
+
8
+
9
+ class SimpleRenderer:
10
+ """Renders status information as simple formatted text."""
11
+
12
+ def render(self, status: StatusData) -> None:
13
+ """Render status data to console.
14
+
15
+ Args:
16
+ status: Status data to render
17
+ """
18
+ self._render_header(status)
19
+ self._render_plan(status)
20
+ self._render_stack(status)
21
+ self._render_pr_status(status)
22
+ self._render_git_status(status)
23
+ self._render_related_worktrees(status)
24
+
25
+ def _render_file_list(self, files: list[str], *, max_files: int = 3) -> None:
26
+ """Render a list of files with truncation.
27
+
28
+ Args:
29
+ files: List of file paths
30
+ max_files: Maximum number of files to display
31
+ """
32
+ for file in files[:max_files]:
33
+ user_output(f" {file}")
34
+
35
+ if len(files) > max_files:
36
+ remaining = len(files) - max_files
37
+ user_output(
38
+ click.style(
39
+ f" ... and {remaining} more",
40
+ fg="white",
41
+ dim=True,
42
+ )
43
+ )
44
+
45
+ def _render_header(self, status: StatusData) -> None:
46
+ """Render worktree header section.
47
+
48
+ Args:
49
+ status: Status data
50
+ """
51
+ wt = status.worktree_info
52
+
53
+ # Title
54
+ name_color = "green" if wt.is_root else "cyan"
55
+ user_output(click.style(f"Worktree: {wt.name}", fg=name_color, bold=True))
56
+
57
+ # Location
58
+ user_output(click.style(f"Location: {wt.path}", fg="white", dim=True))
59
+
60
+ # Branch
61
+ if wt.branch:
62
+ user_output(click.style(f"Branch: {wt.branch}", fg="yellow"))
63
+ else:
64
+ user_output(click.style("Branch: (detached HEAD)", fg="red", dim=True))
65
+
66
+ user_output()
67
+
68
+ def _truncate_plan_filename(self, filename: str) -> str:
69
+ """Truncate enriched plan filename to max 22 characters, stripping suffixes.
70
+
71
+ Args:
72
+ filename: Full filename (e.g., "very-long-plan-name-plan.md")
73
+
74
+ Returns:
75
+ Base name truncated to 22 chars, with "-plan.md" suffix removed
76
+ Format: "first-14-chars...last-5-chars" (exactly 22 chars when truncated)
77
+ """
78
+ max_length = 22
79
+
80
+ # Strip "-plan.md" suffix if present (9 chars)
81
+ suffix = "-plan.md"
82
+ if filename.endswith(suffix):
83
+ base_name = filename[: -len(suffix)]
84
+ else:
85
+ base_name = filename
86
+
87
+ # If short enough, return as-is
88
+ if len(base_name) <= max_length:
89
+ return base_name
90
+
91
+ # Truncate with ellipsis: first 14 chars + "..." + last 5 chars = 22 chars
92
+ return f"{base_name[:14]}...{base_name[-5:]}"
93
+
94
+ def _render_plan(self, status: StatusData) -> None:
95
+ """Render plan folder section if available.
96
+
97
+ Args:
98
+ status: Status data
99
+ """
100
+ if status.plan is None:
101
+ return
102
+
103
+ # Check if we have either .impl/ folder or enriched plan
104
+ has_plan_folder = status.plan.exists
105
+ has_enriched_plan = status.plan.enriched_plan_filename is not None
106
+
107
+ if not has_plan_folder and not has_enriched_plan:
108
+ return
109
+
110
+ # Build plan header
111
+ plan_header = "Plan:"
112
+
113
+ # Add enriched plan indicator if exists
114
+ if has_enriched_plan and status.plan.enriched_plan_filename is not None:
115
+ # Strip suffixes and truncate to max 22 chars for display
116
+ display_filename = self._truncate_plan_filename(status.plan.enriched_plan_filename)
117
+ plan_header += f" 🆕 {display_filename}"
118
+
119
+ user_output(click.style(plan_header, fg="bright_magenta", bold=True))
120
+
121
+ # Only show plan content details if .impl/ folder exists
122
+ if has_plan_folder:
123
+ if status.plan.first_lines:
124
+ for line in status.plan.first_lines:
125
+ user_output(f" {line}")
126
+
127
+ user_output(
128
+ click.style(
129
+ f" ({status.plan.line_count} lines in plan.md)",
130
+ fg="white",
131
+ dim=True,
132
+ )
133
+ )
134
+
135
+ # Show GitHub issue link if available (make clickable)
136
+ if status.plan.issue_number is not None and status.plan.issue_url:
137
+ id_text = f"#{status.plan.issue_number}"
138
+ colored_id = click.style(id_text, fg="cyan")
139
+ # Make ID clickable using OSC 8
140
+ clickable_id = f"\033]8;;{status.plan.issue_url}\033\\{colored_id}\033]8;;\033\\"
141
+ user_output(f" Issue: {clickable_id}")
142
+ user_output(
143
+ click.style(
144
+ f" {status.plan.issue_url}",
145
+ fg="white",
146
+ dim=True,
147
+ )
148
+ )
149
+
150
+ user_output()
151
+
152
+ def _render_stack(self, status: StatusData) -> None:
153
+ """Render worktree stack section if available.
154
+
155
+ Args:
156
+ status: Status data
157
+ """
158
+ if status.stack_position is None:
159
+ return
160
+
161
+ stack = status.stack_position
162
+
163
+ user_output(click.style("Stack Position:", fg="blue", bold=True))
164
+
165
+ # Show position in stack
166
+ if stack.is_trunk:
167
+ user_output(" This is a trunk branch")
168
+ else:
169
+ if stack.parent_branch:
170
+ parent = click.style(stack.parent_branch, fg="yellow")
171
+ user_output(f" Parent: {parent}")
172
+
173
+ if stack.children_branches:
174
+ children = ", ".join(click.style(c, fg="yellow") for c in stack.children_branches)
175
+ user_output(f" Children: {children}")
176
+
177
+ # Show stack visualization
178
+ if len(stack.stack) > 1:
179
+ user_output()
180
+ user_output(click.style(" Stack:", fg="white", dim=True))
181
+ for branch in reversed(stack.stack):
182
+ is_current = branch == stack.current_branch
183
+
184
+ if is_current:
185
+ marker = click.style("◉", fg="bright_green")
186
+ branch_text = click.style(branch, fg="bright_green", bold=True)
187
+ else:
188
+ marker = click.style("◯", fg="bright_black")
189
+ branch_text = branch
190
+
191
+ user_output(f" {marker} {branch_text}")
192
+
193
+ user_output()
194
+
195
+ def _render_pr_status(self, status: StatusData) -> None:
196
+ """Render PR status section if available.
197
+
198
+ Args:
199
+ status: Status data
200
+ """
201
+ if status.pr_status is None:
202
+ return
203
+
204
+ pr = status.pr_status
205
+
206
+ user_output(click.style("Pull Request:", fg="blue", bold=True))
207
+
208
+ # PR number (clickable) and state
209
+ # Make PR number clickable using OSC 8
210
+ pr_number_text = f"#{pr.number}"
211
+ colored_pr_number = click.style(pr_number_text, fg="cyan")
212
+ clickable_pr = f"\033]8;;{pr.url}\033\\{colored_pr_number}\033]8;;\033\\"
213
+
214
+ state_color = (
215
+ "green" if pr.state == "OPEN" else "red" if pr.state == "CLOSED" else "magenta"
216
+ )
217
+ state_text = click.style(pr.state, fg=state_color)
218
+ user_output(f" {clickable_pr} {state_text}")
219
+
220
+ # Draft status
221
+ if pr.is_draft:
222
+ user_output(click.style(" Draft PR", fg="yellow"))
223
+
224
+ # Checks status
225
+ if pr.checks_passing is not None:
226
+ if pr.checks_passing:
227
+ user_output(click.style(" Checks: passing", fg="green"))
228
+ else:
229
+ user_output(click.style(" Checks: failing", fg="red"))
230
+
231
+ # Ready to merge
232
+ if pr.ready_to_merge:
233
+ user_output(click.style(" ✓ Ready to merge", fg="green", bold=True))
234
+
235
+ user_output()
236
+
237
+ def _render_git_status(self, status: StatusData) -> None:
238
+ """Render git status section.
239
+
240
+ Args:
241
+ status: Status data
242
+ """
243
+ if status.git_status is None:
244
+ return
245
+
246
+ git = status.git_status
247
+
248
+ user_output(click.style("Git Status:", fg="blue", bold=True))
249
+
250
+ # Clean/dirty status
251
+ if git.clean:
252
+ user_output(click.style(" Working tree clean", fg="green"))
253
+ else:
254
+ user_output(click.style(" Working tree has changes:", fg="yellow"))
255
+
256
+ if git.staged_files:
257
+ user_output(click.style(" Staged:", fg="green"))
258
+ self._render_file_list(git.staged_files, max_files=3)
259
+
260
+ if git.modified_files:
261
+ user_output(click.style(" Modified:", fg="yellow"))
262
+ self._render_file_list(git.modified_files, max_files=3)
263
+
264
+ if git.untracked_files:
265
+ user_output(click.style(" Untracked:", fg="red"))
266
+ self._render_file_list(git.untracked_files, max_files=3)
267
+
268
+ # Ahead/behind
269
+ if git.ahead > 0 or git.behind > 0:
270
+ parts = []
271
+ if git.ahead > 0:
272
+ parts.append(click.style(f"{git.ahead} ahead", fg="green"))
273
+ if git.behind > 0:
274
+ parts.append(click.style(f"{git.behind} behind", fg="red"))
275
+
276
+ user_output(f" Branch: {', '.join(parts)}")
277
+
278
+ # Recent commits
279
+ if git.recent_commits:
280
+ user_output()
281
+ user_output(click.style(" Recent commits:", fg="white", dim=True))
282
+ for commit in git.recent_commits[:3]:
283
+ sha = click.style(commit.sha, fg="yellow")
284
+ message = commit.message[:60]
285
+ if len(commit.message) > 60:
286
+ message += "..."
287
+ user_output(f" {sha} {message}")
288
+
289
+ user_output()
290
+
291
+ def _render_related_worktrees(self, status: StatusData) -> None:
292
+ """Render related worktrees section.
293
+
294
+ Args:
295
+ status: Status data
296
+ """
297
+ if not status.related_worktrees:
298
+ return
299
+
300
+ user_output(click.style("Related Worktrees:", fg="blue", bold=True))
301
+
302
+ for wt in status.related_worktrees[:5]:
303
+ name_color = "green" if wt.is_root else "cyan"
304
+ name_part = click.style(wt.name, fg=name_color)
305
+
306
+ if wt.branch:
307
+ branch_part = click.style(f"[{wt.branch}]", fg="yellow", dim=True)
308
+ user_output(f" {name_part} {branch_part}")
309
+ else:
310
+ user_output(f" {name_part}")
311
+
312
+ if len(status.related_worktrees) > 5:
313
+ remaining = len(status.related_worktrees) - 5
314
+ user_output(
315
+ click.style(
316
+ f" ... and {remaining} more",
317
+ fg="white",
318
+ dim=True,
319
+ )
320
+ )
321
+
322
+ user_output()
erk/tui/AGENTS.md ADDED
@@ -0,0 +1,193 @@
1
+ # Textual Dash - Interactive TUI for erk
2
+
3
+ ## Purpose
4
+
5
+ Textual Dash provides an interactive terminal UI for the `erk dash` command, enabling keyboard-driven navigation through plan lists with quick actions. It's an alternative to the static table output and watch mode, optimized for users managing many plans.
6
+
7
+ **Invocation**: `erk dash -i` or `erk dash --interactive`
8
+
9
+ ## Architecture Overview
10
+
11
+ ```
12
+ src/erk/tui/
13
+ ├── AGENTS.md # This file
14
+ ├── TEXTUAL_QUIRKS.md # API quirks and workarounds (READ THIS)
15
+ ├── __init__.py
16
+ ├── app.py # ErkDashApp - main Textual application
17
+ ├── data/
18
+ │ ├── __init__.py
19
+ │ ├── provider.py # PlanDataProvider ABC + RealPlanDataProvider
20
+ │ └── types.py # PlanRowData, PlanFilters dataclasses
21
+ ├── widgets/
22
+ │ ├── __init__.py
23
+ │ ├── plan_table.py # PlanDataTable - DataTable subclass
24
+ │ └── status_bar.py # StatusBar - footer with stats and hints
25
+ └── styles/
26
+ └── dash.tcss # Textual CSS styles
27
+ ```
28
+
29
+ ## Key Components
30
+
31
+ ### ErkDashApp (`app.py`)
32
+
33
+ The main Textual `App` subclass. Responsibilities:
34
+
35
+ - Compose layout (Header, PlanDataTable, StatusBar)
36
+ - Handle keyboard bindings (q, r, Enter, o, p, i, ?, j/k)
37
+ - Manage auto-refresh timer and countdown
38
+ - Coordinate data loading via workers
39
+
40
+ **Key bindings**:
41
+ | Key | Action |
42
+ |-----|--------|
43
+ | `q` / `Esc` | Quit |
44
+ | `r` | Refresh data (resets countdown) |
45
+ | `Enter` / `o` | Open issue in browser |
46
+ | `p` | Open PR in browser |
47
+ | `i` | Show implement command |
48
+ | `?` | Show help overlay |
49
+ | `j` / `k` | Vim-style navigation |
50
+
51
+ ### PlanDataProvider (`data/provider.py`)
52
+
53
+ ABC defining data fetching interface. Enables testing with fakes.
54
+
55
+ - `PlanDataProvider` - Abstract base class
56
+ - `RealPlanDataProvider` - Production implementation wrapping `PlanListService`
57
+
58
+ The provider transforms `PlanListData` from the service layer into `PlanRowData` tuples optimized for table display.
59
+
60
+ ### PlanRowData (`data/types.py`)
61
+
62
+ Immutable dataclass containing:
63
+
64
+ - Raw data for actions (issue_number, issue_url, pr_number, pr_url)
65
+ - Pre-formatted display strings (title, pr_display, checks_display, etc.)
66
+
67
+ ### PlanDataTable (`widgets/plan_table.py`)
68
+
69
+ DataTable subclass with:
70
+
71
+ - Row selection mode (not cell selection)
72
+ - Column setup based on filter flags
73
+ - Cursor position preservation on refresh
74
+ - Left/right arrow disabled (row mode only)
75
+
76
+ ### StatusBar (`widgets/status_bar.py`)
77
+
78
+ Footer widget showing:
79
+
80
+ - Plan count
81
+ - Last update time with fetch duration
82
+ - Countdown to next refresh
83
+ - Key binding hints
84
+
85
+ ## Data Flow
86
+
87
+ ```
88
+ 1. erk dash -i
89
+ └── _run_interactive_mode()
90
+ └── Creates RealPlanDataProvider with ErkContext
91
+ └── Creates ErkDashApp(provider, filters, interval)
92
+ └── app.run()
93
+
94
+ 2. On mount:
95
+ └── run_worker(_load_data())
96
+ └── provider.fetch_plans(filters) # In executor thread
97
+ └── _update_table(rows, time, duration)
98
+ └── table.populate(rows)
99
+ └── status_bar.set_plan_count()
100
+ └── status_bar.set_last_update()
101
+
102
+ 3. Auto-refresh (every N seconds):
103
+ └── _tick_countdown() decrements counter
104
+ └── When 0: action_refresh()
105
+ └── Resets countdown
106
+ └── run_worker(_load_data())
107
+
108
+ 4. User actions:
109
+ └── Enter/o → action_open_issue() → click.launch(url)
110
+ └── p → action_open_pr() → click.launch(url)
111
+ └── r → action_refresh() → reload data
112
+ ```
113
+
114
+ ## Testing Strategy
115
+
116
+ Tests live in `tests/tui/`:
117
+
118
+ - `test_plan_table.py` - Unit tests for table widget and row conversion
119
+ - `test_app.py` - Textual pilot-based integration tests
120
+
121
+ **Fake infrastructure** in `tests/fakes/plan_data_provider.py`:
122
+
123
+ - `FakePlanDataProvider` - Returns canned data, tracks fetch count
124
+ - `make_plan_row()` - Helper to create test PlanRowData
125
+
126
+ **Testing pattern**:
127
+
128
+ ```python
129
+ @pytest.mark.asyncio
130
+ async def test_something(self) -> None:
131
+ provider = FakePlanDataProvider([make_plan_row(123, "Test")])
132
+ app = ErkDashApp(provider, PlanFilters.default(), refresh_interval=0)
133
+
134
+ async with app.run_test() as pilot:
135
+ await pilot.pause() # Wait for async load
136
+ # assertions...
137
+ ```
138
+
139
+ ## Important: Read TEXTUAL_QUIRKS.md
140
+
141
+ Before modifying this code, read `TEXTUAL_QUIRKS.md` which documents:
142
+
143
+ - DataTable `cursor_type` initialization pattern
144
+ - Naming conflicts to avoid (`_filters`)
145
+ - Cursor position preservation on `clear()`
146
+ - Enter key handling via `RowSelected` event
147
+ - `action_quit` override issues
148
+ - Footer vs custom StatusBar conflicts
149
+ - Async data loading patterns
150
+ - pytest-asyncio configuration
151
+
152
+ ## Design Decisions
153
+
154
+ ### Why Row Selection Mode?
155
+
156
+ Cell selection adds complexity (left/right navigation) without benefit for this use case. Users care about selecting a plan, not a specific column.
157
+
158
+ ### Why Separate PlanDataProvider?
159
+
160
+ 1. **Testability**: FakePlanDataProvider enables fast tests without API calls
161
+ 2. **Separation of concerns**: TUI code doesn't know about PlanListService internals
162
+ 3. **Future flexibility**: Could add caching, filtering, or alternative data sources
163
+
164
+ ### Why No Footer Widget?
165
+
166
+ Textual's built-in `Footer` shows BINDINGS but conflicts with custom status bars. Our `StatusBar` provides richer information (countdown, timing, messages) in a single line.
167
+
168
+ ### Why `-i` Implies `-a`?
169
+
170
+ Interactive mode benefits from seeing all columns. Users navigating with keyboard want full context without remembering to add flags.
171
+
172
+ ## Adding Features
173
+
174
+ ### New Key Binding
175
+
176
+ 1. Add to `BINDINGS` list in `ErkDashApp`
177
+ 2. Implement `action_*` method
178
+ 3. Update status bar hints in `StatusBar._update_display()`
179
+ 4. Update help screen in `HelpScreen.compose()`
180
+
181
+ ### New Column
182
+
183
+ 1. Add field to `PlanRowData` in `types.py`
184
+ 2. Update `RealPlanDataProvider._build_row_data()` to populate it
185
+ 3. Add column in `PlanDataTable._setup_columns()` (check filter flags)
186
+ 4. Add value in `PlanDataTable._row_to_values()`
187
+ 5. Update `make_plan_row()` helper in test fakes
188
+
189
+ ### New Status Bar Info
190
+
191
+ 1. Add field and setter to `StatusBar`
192
+ 2. Update `_update_display()` to include in output
193
+ 3. Call setter from `ErkDashApp` at appropriate time
erk/tui/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
erk/tui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Interactive TUI components for erk."""