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,112 @@
1
+ """Widget for displaying live subprocess output in the TUI."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Vertical
5
+ from textual.widgets import RichLog, Static
6
+
7
+
8
+ class CommandOutputPanel(Static):
9
+ """Bottom panel showing live subprocess output.
10
+
11
+ Displays streaming output from a subprocess with status indicator
12
+ and dismiss hint after completion.
13
+ """
14
+
15
+ DEFAULT_CSS = """
16
+ CommandOutputPanel {
17
+ height: auto;
18
+ max-height: 15;
19
+ background: $surface-darken-1;
20
+ border-top: solid $primary;
21
+ padding: 0 1;
22
+ }
23
+
24
+ CommandOutputPanel #output-header {
25
+ height: 1;
26
+ color: $primary;
27
+ text-style: bold;
28
+ }
29
+
30
+ CommandOutputPanel #output-log {
31
+ height: auto;
32
+ max-height: 10;
33
+ scrollbar-gutter: stable;
34
+ }
35
+
36
+ CommandOutputPanel #output-status {
37
+ height: 1;
38
+ color: $text-muted;
39
+ }
40
+
41
+ CommandOutputPanel #output-status.success {
42
+ color: #238636;
43
+ }
44
+
45
+ CommandOutputPanel #output-status.failure {
46
+ color: #da3633;
47
+ }
48
+ """
49
+
50
+ def __init__(self, title: str) -> None:
51
+ """Initialize output panel.
52
+
53
+ Args:
54
+ title: Title to show at the top of the panel
55
+ """
56
+ super().__init__()
57
+ self._title = title
58
+ self._completed = False
59
+ self._success = False
60
+ self._lines: list[str] = []
61
+
62
+ @property
63
+ def is_completed(self) -> bool:
64
+ """Check if the command has completed."""
65
+ return self._completed
66
+
67
+ @property
68
+ def succeeded(self) -> bool:
69
+ """Check if the command succeeded."""
70
+ return self._success
71
+
72
+ def compose(self) -> ComposeResult:
73
+ """Create panel content."""
74
+ with Vertical():
75
+ yield Static(f"[bold]{self._title}[/bold]", id="output-header")
76
+ yield RichLog(id="output-log", highlight=True, markup=True)
77
+ yield Static("Running...", id="output-status")
78
+
79
+ def append_line(self, line: str, is_stderr: bool = False) -> None:
80
+ """Add output line to the log.
81
+
82
+ Args:
83
+ line: The line of output to append
84
+ is_stderr: If True, style as error output (red)
85
+ """
86
+ self._lines.append(line)
87
+ log = self.query_one("#output-log", RichLog)
88
+ if is_stderr:
89
+ log.write(f"[red]{line}[/red]")
90
+ else:
91
+ log.write(line)
92
+
93
+ def get_output_text(self) -> str:
94
+ """Return all output lines joined with newlines."""
95
+ return "\n".join(self._lines)
96
+
97
+ def set_completed(self, success: bool) -> None:
98
+ """Mark command as complete and show dismiss hint.
99
+
100
+ Args:
101
+ success: Whether the command succeeded
102
+ """
103
+ self._completed = True
104
+ self._success = success
105
+
106
+ status = self.query_one("#output-status", Static)
107
+ if success:
108
+ status.update("✓ Complete - Press Esc to close, y to copy logs")
109
+ status.add_class("success")
110
+ else:
111
+ status.update("✗ Failed - Press Esc to close, y to copy logs")
112
+ status.add_class("failure")
@@ -0,0 +1,276 @@
1
+ """Plan table widget for TUI dashboard."""
2
+
3
+ from rich.text import Text
4
+ from textual.events import Click
5
+ from textual.message import Message
6
+ from textual.widgets import DataTable
7
+
8
+ from erk.tui.data.types import PlanFilters, PlanRowData
9
+
10
+
11
+ class PlanDataTable(DataTable):
12
+ """DataTable subclass for displaying plans.
13
+
14
+ Manages column configuration and row population from PlanRowData.
15
+ Uses row selection mode (not cell selection) for simpler navigation.
16
+ """
17
+
18
+ class LocalWtClicked(Message):
19
+ """Posted when user clicks local-wt column on a row with existing worktree."""
20
+
21
+ def __init__(self, row_index: int) -> None:
22
+ """Initialize the message.
23
+
24
+ Args:
25
+ row_index: Index of the clicked row
26
+ """
27
+ super().__init__()
28
+ self.row_index = row_index
29
+
30
+ class RunIdClicked(Message):
31
+ """Posted when user clicks run-id column on a row with a run URL."""
32
+
33
+ def __init__(self, row_index: int) -> None:
34
+ super().__init__()
35
+ self.row_index = row_index
36
+
37
+ class PrClicked(Message):
38
+ """Posted when user clicks pr column on a row with a PR URL."""
39
+
40
+ def __init__(self, row_index: int) -> None:
41
+ super().__init__()
42
+ self.row_index = row_index
43
+
44
+ class PlanClicked(Message):
45
+ """Posted when user clicks plan column on a row with an issue URL."""
46
+
47
+ def __init__(self, row_index: int) -> None:
48
+ super().__init__()
49
+ self.row_index = row_index
50
+
51
+ def __init__(self, plan_filters: PlanFilters) -> None:
52
+ """Initialize table with column configuration based on filters.
53
+
54
+ Args:
55
+ plan_filters: Filter options that determine which columns to show
56
+ """
57
+ super().__init__(cursor_type="row")
58
+ self._plan_filters = plan_filters
59
+ self._rows: list[PlanRowData] = []
60
+ self._plan_column_index: int = 0 # Always first column
61
+ self._pr_column_index: int | None = None
62
+ self._local_wt_column_index: int | None = None
63
+ self._run_id_column_index: int | None = None
64
+
65
+ @property
66
+ def local_wt_column_index(self) -> int | None:
67
+ """Get the column index for the local-wt column.
68
+
69
+ Returns:
70
+ Column index (0-based), or None if columns not yet set up.
71
+ The index varies based on show_prs flag:
72
+ - Without PRs: index 2 (plan, title, local-wt)
73
+ - With PRs: index 4 (plan, title, pr, chks, local-wt)
74
+ """
75
+ return self._local_wt_column_index
76
+
77
+ def action_cursor_left(self) -> None:
78
+ """Disable left arrow navigation (row mode only)."""
79
+ pass
80
+
81
+ def action_cursor_right(self) -> None:
82
+ """Disable right arrow navigation (row mode only)."""
83
+ pass
84
+
85
+ def on_mount(self) -> None:
86
+ """Configure columns when widget is mounted."""
87
+ self._setup_columns()
88
+
89
+ def _setup_columns(self) -> None:
90
+ """Add columns based on current filter settings.
91
+
92
+ Tracks the column index for local-wt to enable click detection.
93
+ """
94
+ col_index = 0
95
+ self.add_column("plan", key="plan")
96
+ col_index += 1
97
+ self.add_column("title", key="title")
98
+ col_index += 1
99
+ if self._plan_filters.show_prs:
100
+ self.add_column("pr", key="pr")
101
+ self._pr_column_index = col_index
102
+ col_index += 1
103
+ self.add_column("chks", key="chks")
104
+ col_index += 1
105
+ self._local_wt_column_index = col_index
106
+ self.add_column("local-wt", key="local_wt")
107
+ col_index += 1
108
+ self.add_column("local-impl", key="local_impl")
109
+ col_index += 1
110
+ if self._plan_filters.show_runs:
111
+ self.add_column("remote-impl", key="remote_impl")
112
+ col_index += 1
113
+ self.add_column("run-id", key="run_id")
114
+ self._run_id_column_index = col_index
115
+ col_index += 1
116
+ self.add_column("run-state", key="run_state")
117
+
118
+ def populate(self, rows: list[PlanRowData]) -> None:
119
+ """Populate table with plan data, preserving cursor position.
120
+
121
+ If the selected plan still exists, cursor stays on it.
122
+ If the selected plan disappeared, cursor stays at the same row index.
123
+
124
+ Args:
125
+ rows: List of PlanRowData to display
126
+ """
127
+ # Save current selection by issue number (row key)
128
+ selected_key: str | None = None
129
+ if self._rows and self.cursor_row is not None and 0 <= self.cursor_row < len(self._rows):
130
+ selected_key = str(self._rows[self.cursor_row].issue_number)
131
+
132
+ # Save cursor row index for fallback (move up if plan disappears)
133
+ saved_cursor_row = self.cursor_row
134
+
135
+ self._rows = rows
136
+ self.clear()
137
+
138
+ for row in rows:
139
+ values = self._row_to_values(row)
140
+ self.add_row(*values, key=str(row.issue_number))
141
+
142
+ # Restore cursor position
143
+ if rows:
144
+ # Try to restore by key (issue number) first
145
+ if selected_key is not None:
146
+ for idx, row in enumerate(rows):
147
+ if str(row.issue_number) == selected_key:
148
+ self.move_cursor(row=idx)
149
+ return
150
+
151
+ # Plan disappeared - stay at same row index, clamped to valid range
152
+ if saved_cursor_row is not None and saved_cursor_row >= 0:
153
+ target_row = min(saved_cursor_row, len(rows) - 1)
154
+ self.move_cursor(row=target_row)
155
+
156
+ def _row_to_values(self, row: PlanRowData) -> tuple[str | Text, ...]:
157
+ """Convert PlanRowData to table cell values.
158
+
159
+ Args:
160
+ row: Plan row data
161
+
162
+ Returns:
163
+ Tuple of cell values matching column order
164
+ """
165
+ # Format issue number - colorize if clickable
166
+ plan_cell: str | Text = f"#{row.issue_number}"
167
+ if row.issue_url:
168
+ plan_cell = Text(plan_cell, style="cyan underline")
169
+
170
+ # Format worktree
171
+ if row.exists_locally:
172
+ wt_cell = row.worktree_name
173
+ else:
174
+ wt_cell = "-"
175
+
176
+ # Build values list based on columns
177
+ values: list[str | Text] = [plan_cell, row.title]
178
+ if self._plan_filters.show_prs:
179
+ # Strip Rich markup and colorize if clickable
180
+ pr_display = _strip_rich_markup(row.pr_display)
181
+ if row.pr_url:
182
+ pr_display = Text(pr_display, style="cyan underline")
183
+ checks_display = _strip_rich_markup(row.checks_display)
184
+ values.extend([pr_display, checks_display])
185
+ values.extend([wt_cell, row.local_impl_display])
186
+ if self._plan_filters.show_runs:
187
+ remote_impl = _strip_rich_markup(row.remote_impl_display)
188
+ run_id = _strip_rich_markup(row.run_id_display)
189
+ if row.run_url:
190
+ run_id = Text(run_id, style="cyan underline")
191
+ run_state = _strip_rich_markup(row.run_state_display)
192
+ values.extend([remote_impl, run_id, run_state])
193
+
194
+ return tuple(values)
195
+
196
+ def get_selected_row_data(self) -> PlanRowData | None:
197
+ """Get the PlanRowData for the currently selected row.
198
+
199
+ Returns:
200
+ PlanRowData for selected row, or None if no selection
201
+ """
202
+ cursor_row = self.cursor_row
203
+ if cursor_row is None or cursor_row < 0 or cursor_row >= len(self._rows):
204
+ return None
205
+ return self._rows[cursor_row]
206
+
207
+ def on_click(self, event: Click) -> None:
208
+ """Detect clicks on specific columns and post appropriate messages.
209
+
210
+ Posts LocalWtClicked event if:
211
+ - Click is on the local-wt column
212
+ - The row has an existing local worktree (not '-')
213
+
214
+ Posts RunIdClicked event if:
215
+ - Click is on the run-id column
216
+ - The row has a run URL
217
+
218
+ Stops event propagation to prevent default row selection behavior when
219
+ a column-specific click is detected.
220
+
221
+ Args:
222
+ event: Click event from Textual
223
+ """
224
+ coord = self.hover_coordinate
225
+ if coord is None:
226
+ return
227
+
228
+ row_index = coord.row
229
+ col_index = coord.column
230
+
231
+ # Check plan column (issue number)
232
+ if col_index == self._plan_column_index:
233
+ if row_index < len(self._rows) and self._rows[row_index].issue_url:
234
+ self.post_message(self.PlanClicked(row_index))
235
+ event.prevent_default()
236
+ event.stop()
237
+ return
238
+
239
+ # Check PR column
240
+ if self._pr_column_index is not None and col_index == self._pr_column_index:
241
+ if row_index < len(self._rows) and self._rows[row_index].pr_url:
242
+ self.post_message(self.PrClicked(row_index))
243
+ event.prevent_default()
244
+ event.stop()
245
+ return
246
+
247
+ # Check local-wt column - post event if worktree exists
248
+ if self._local_wt_column_index is not None and col_index == self._local_wt_column_index:
249
+ if row_index < len(self._rows) and self._rows[row_index].exists_locally:
250
+ self.post_message(self.LocalWtClicked(row_index))
251
+ event.prevent_default()
252
+ event.stop()
253
+ return
254
+
255
+ # Check run-id column - post event if run URL exists
256
+ if self._run_id_column_index is not None and col_index == self._run_id_column_index:
257
+ if row_index < len(self._rows) and self._rows[row_index].run_url:
258
+ self.post_message(self.RunIdClicked(row_index))
259
+ event.prevent_default()
260
+ event.stop()
261
+ return
262
+
263
+
264
+ def _strip_rich_markup(text: str) -> str:
265
+ """Remove Rich markup tags from text.
266
+
267
+ Args:
268
+ text: Text potentially containing Rich markup like [link=...]...[/link]
269
+
270
+ Returns:
271
+ Plain text with markup removed
272
+ """
273
+ import re
274
+
275
+ # Remove [tag=value] and [/tag] patterns
276
+ return re.sub(r"\[/?[^\]]+\]", "", text)
@@ -0,0 +1,116 @@
1
+ """Status bar widget for TUI dashboard."""
2
+
3
+ from textual.widgets import Static
4
+
5
+
6
+ class StatusBar(Static):
7
+ """Footer status bar showing plan count, refresh status, and messages.
8
+
9
+ Displays:
10
+ - Plan count
11
+ - Last update time
12
+ - Time until next refresh
13
+ - Action messages (e.g., command to copy)
14
+ - Key bindings hint
15
+ """
16
+
17
+ DEFAULT_CSS = """
18
+ StatusBar {
19
+ dock: bottom;
20
+ height: 1;
21
+ background: $surface;
22
+ color: $text-muted;
23
+ padding: 0 1;
24
+ }
25
+ """
26
+
27
+ def __init__(self) -> None:
28
+ """Initialize status bar."""
29
+ super().__init__()
30
+ self._plan_count = 0
31
+ self._seconds_remaining = 0
32
+ self._last_update: str | None = None
33
+ self._fetch_duration: float | None = None
34
+ self._message: str | None = None
35
+ self._sort_mode: str | None = None
36
+
37
+ def set_plan_count(self, count: int) -> None:
38
+ """Update the plan count display.
39
+
40
+ Args:
41
+ count: Number of plans currently displayed
42
+ """
43
+ self._plan_count = count
44
+ self._update_display()
45
+
46
+ def set_refresh_countdown(self, seconds: int) -> None:
47
+ """Update the refresh countdown.
48
+
49
+ Args:
50
+ seconds: Seconds until next refresh
51
+ """
52
+ self._seconds_remaining = seconds
53
+ self._update_display()
54
+
55
+ def set_message(self, message: str | None) -> None:
56
+ """Set or clear a status message.
57
+
58
+ Args:
59
+ message: Message to display, or None to clear
60
+ """
61
+ self._message = message
62
+ self._update_display()
63
+
64
+ def set_last_update(self, time_str: str, duration_secs: float | None = None) -> None:
65
+ """Set the last update time.
66
+
67
+ Args:
68
+ time_str: Formatted time string (e.g., "14:30:45")
69
+ duration_secs: Duration of the fetch in seconds, or None
70
+ """
71
+ self._last_update = time_str
72
+ self._fetch_duration = duration_secs
73
+ self._update_display()
74
+
75
+ def set_sort_mode(self, mode: str) -> None:
76
+ """Set the current sort mode display.
77
+
78
+ Args:
79
+ mode: Sort mode label (e.g., "by issue#", "by recent activity")
80
+ """
81
+ self._sort_mode = mode
82
+ self._update_display()
83
+
84
+ def _update_display(self) -> None:
85
+ """Render the status bar content."""
86
+ parts: list[str] = []
87
+
88
+ # Plan count
89
+ if self._plan_count == 1:
90
+ parts.append("1 plan")
91
+ else:
92
+ parts.append(f"{self._plan_count} plans")
93
+
94
+ # Sort mode
95
+ if self._sort_mode:
96
+ parts.append(f"sorted {self._sort_mode}")
97
+
98
+ # Last update time with optional duration
99
+ if self._last_update:
100
+ update_str = f"updated: {self._last_update}"
101
+ if self._fetch_duration is not None:
102
+ update_str += f" ({self._fetch_duration:.1f}s)"
103
+ parts.append(update_str)
104
+
105
+ # Refresh countdown
106
+ if self._seconds_remaining > 0:
107
+ parts.append(f"next: {self._seconds_remaining}s")
108
+
109
+ # Message
110
+ if self._message:
111
+ parts.append(self._message)
112
+
113
+ # Key hints
114
+ parts.append("Enter:open p:PR /:filter s:sort r:refresh q:quit ?:help")
115
+
116
+ self.update(" │ ".join(parts))