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,61 @@
1
+ """Main Textual application for JSONL viewer."""
2
+
3
+ from pathlib import Path
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.widgets import Footer, Header
8
+
9
+ from erk.tui.jsonl_viewer.models import parse_jsonl_file
10
+ from erk.tui.jsonl_viewer.widgets import CustomListView, JsonlEntryItem
11
+
12
+
13
+ class JsonlViewerApp(App):
14
+ """Interactive TUI for viewing JSONL files."""
15
+
16
+ BINDINGS = [
17
+ Binding("q", "quit", "Quit"),
18
+ Binding("escape", "quit", "Quit"),
19
+ Binding("j", "cursor_down", "Down", show=False),
20
+ Binding("k", "cursor_up", "Up", show=False),
21
+ ]
22
+
23
+ DEFAULT_CSS = """
24
+ JsonlViewerApp {
25
+ background: $surface;
26
+ }
27
+
28
+ ListView {
29
+ height: 1fr;
30
+ }
31
+
32
+ ListView > ListItem.--highlight {
33
+ background: $primary-darken-2;
34
+ }
35
+ """
36
+
37
+ def __init__(self, jsonl_path: Path) -> None:
38
+ """Initialize with path to JSONL file.
39
+
40
+ Args:
41
+ jsonl_path: Path to the JSONL file to view
42
+ """
43
+ super().__init__()
44
+ self._jsonl_path = jsonl_path
45
+ self._entries = parse_jsonl_file(jsonl_path)
46
+
47
+ def compose(self) -> ComposeResult:
48
+ """Create application layout."""
49
+ yield Header(show_clock=False)
50
+ yield CustomListView(*[JsonlEntryItem(entry) for entry in self._entries])
51
+ yield Footer()
52
+
53
+ def action_cursor_down(self) -> None:
54
+ """Move cursor down (vim j key)."""
55
+ list_view = self.query_one(CustomListView)
56
+ list_view.action_cursor_down()
57
+
58
+ def action_cursor_up(self) -> None:
59
+ """Move cursor up (vim k key)."""
60
+ list_view = self.query_one(CustomListView)
61
+ list_view.action_cursor_up()
@@ -0,0 +1,208 @@
1
+ """Data models for JSONL viewer."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class JsonlEntry:
10
+ """Represents a single entry from a JSONL file."""
11
+
12
+ line_number: int
13
+ entry_type: str
14
+ role: str | None
15
+ tool_name: str | None
16
+ raw_json: str
17
+ parsed: dict
18
+
19
+
20
+ def extract_tool_name(entry: dict) -> str | None:
21
+ """Extract tool name from tool_use content blocks.
22
+
23
+ Args:
24
+ entry: Parsed JSON entry
25
+
26
+ Returns:
27
+ Tool name if found, None otherwise
28
+ """
29
+ message = entry.get("message")
30
+ if not isinstance(message, dict):
31
+ return None
32
+
33
+ content = message.get("content")
34
+ if not isinstance(content, list):
35
+ return None
36
+
37
+ # Find first tool_use block
38
+ for block in content:
39
+ if isinstance(block, dict) and block.get("type") == "tool_use":
40
+ name = block.get("name")
41
+ if isinstance(name, str):
42
+ return name
43
+
44
+ return None
45
+
46
+
47
+ def format_summary(entry: JsonlEntry) -> str:
48
+ """Format entry summary for display.
49
+
50
+ Format: [line#] type | tool_name?
51
+
52
+ Args:
53
+ entry: JSONL entry to format
54
+
55
+ Returns:
56
+ Formatted summary string
57
+ """
58
+ line_str = f"[{entry.line_number:>4}]"
59
+
60
+ parts = [line_str, entry.entry_type]
61
+ if entry.tool_name:
62
+ parts.append(entry.tool_name)
63
+
64
+ return " | ".join(parts)
65
+
66
+
67
+ def _interpret_escape_sequences(text: str) -> str:
68
+ """Convert literal escape sequences to actual characters.
69
+
70
+ Converts \\n, \\t, \\r to their actual character equivalents.
71
+
72
+ Args:
73
+ text: Text with literal escape sequences
74
+
75
+ Returns:
76
+ Text with actual newlines, tabs, carriage returns
77
+ """
78
+ return text.replace("\\n", "\n").replace("\\t", "\t").replace("\\r", "\r")
79
+
80
+
81
+ def _format_as_yaml_like(value: object, indent: int = 0) -> str:
82
+ """Format a value in YAML-like style for readability.
83
+
84
+ Args:
85
+ value: Value to format (dict, list, str, int, float, bool, None)
86
+ indent: Current indentation level
87
+
88
+ Returns:
89
+ Formatted string representation
90
+ """
91
+ prefix = " " * indent
92
+
93
+ if value is None:
94
+ return "null"
95
+
96
+ if isinstance(value, bool):
97
+ return "true" if value else "false"
98
+
99
+ if isinstance(value, (int, float)):
100
+ return str(value)
101
+
102
+ if isinstance(value, str):
103
+ # Interpret escape sequences - no additional formatting for strings
104
+ return _interpret_escape_sequences(value)
105
+
106
+ if isinstance(value, list):
107
+ if not value:
108
+ return "[]"
109
+ lines = []
110
+ for item in value:
111
+ formatted_item = _format_as_yaml_like(item, indent + 1)
112
+ if "\n" in formatted_item:
113
+ # Multi-line item
114
+ first_line, *rest = formatted_item.split("\n")
115
+ lines.append(f"{prefix}- {first_line}")
116
+ lines.extend(rest)
117
+ else:
118
+ lines.append(f"{prefix}- {formatted_item}")
119
+ return "\n".join(lines)
120
+
121
+ if isinstance(value, dict):
122
+ if not value:
123
+ return "{}"
124
+ lines = []
125
+ for k, v in value.items():
126
+ formatted_v = _format_as_yaml_like(v, indent + 1)
127
+ if "\n" in formatted_v:
128
+ # Multi-line value
129
+ first_line, *rest = formatted_v.split("\n")
130
+ lines.append(f"{prefix}{k}: {first_line}")
131
+ lines.extend(rest)
132
+ else:
133
+ lines.append(f"{prefix}{k}: {formatted_v}")
134
+ return "\n".join(lines)
135
+
136
+ # Fallback for unknown types
137
+ return str(value)
138
+
139
+
140
+ def format_entry_detail(entry: JsonlEntry, formatted: bool = True) -> str:
141
+ """Format entry detail for display.
142
+
143
+ Args:
144
+ entry: JSONL entry to format
145
+ formatted: If True, use YAML-like formatting. If False, use raw JSON.
146
+
147
+ Returns:
148
+ Formatted string representation
149
+ """
150
+ if not formatted:
151
+ return entry.raw_json
152
+
153
+ return _format_as_yaml_like(entry.parsed)
154
+
155
+
156
+ def parse_jsonl_file(path: Path) -> list[JsonlEntry]:
157
+ """Parse JSONL file into list of entries.
158
+
159
+ Skips empty lines and malformed JSON.
160
+
161
+ Args:
162
+ path: Path to JSONL file
163
+
164
+ Returns:
165
+ List of parsed entries
166
+ """
167
+ entries: list[JsonlEntry] = []
168
+ content = path.read_text(encoding="utf-8")
169
+
170
+ for line_number, line in enumerate(content.splitlines(), start=1):
171
+ stripped = line.strip()
172
+ if not stripped:
173
+ continue
174
+
175
+ try:
176
+ parsed = json.loads(stripped)
177
+ except json.JSONDecodeError:
178
+ continue
179
+
180
+ if not isinstance(parsed, dict):
181
+ continue
182
+
183
+ entry_type = parsed.get("type", "unknown")
184
+ if not isinstance(entry_type, str):
185
+ entry_type = "unknown"
186
+
187
+ # Extract role from message if present
188
+ role: str | None = None
189
+ message = parsed.get("message")
190
+ if isinstance(message, dict):
191
+ msg_role = message.get("role")
192
+ if isinstance(msg_role, str):
193
+ role = msg_role
194
+
195
+ tool_name = extract_tool_name(parsed)
196
+
197
+ entries.append(
198
+ JsonlEntry(
199
+ line_number=line_number,
200
+ entry_type=entry_type,
201
+ role=role,
202
+ tool_name=tool_name,
203
+ raw_json=stripped,
204
+ parsed=parsed,
205
+ )
206
+ )
207
+
208
+ return entries
@@ -0,0 +1,204 @@
1
+ """Widgets for JSONL viewer."""
2
+
3
+ import json
4
+
5
+ from rich.markup import escape as escape_markup
6
+ from textual.app import ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import Vertical
9
+ from textual.widgets import Label, ListItem, ListView, Static
10
+
11
+ from erk.tui.jsonl_viewer.models import JsonlEntry, format_entry_detail, format_summary
12
+
13
+
14
+ class JsonlEntryItem(ListItem):
15
+ """Expandable JSONL entry with summary and JSON detail."""
16
+
17
+ DEFAULT_CSS = """
18
+ JsonlEntryItem {
19
+ height: auto;
20
+ padding: 0 1;
21
+ }
22
+
23
+ JsonlEntryItem .entry-summary {
24
+ height: 1;
25
+ }
26
+
27
+ JsonlEntryItem .entry-summary-user {
28
+ color: #58a6ff;
29
+ }
30
+
31
+ JsonlEntryItem .entry-summary-assistant {
32
+ color: #7ee787;
33
+ }
34
+
35
+ JsonlEntryItem .entry-summary-tool-result {
36
+ color: #ffa657;
37
+ }
38
+
39
+ JsonlEntryItem .entry-summary-other {
40
+ color: $text-muted;
41
+ }
42
+
43
+ JsonlEntryItem .json-detail {
44
+ display: none;
45
+ padding: 1;
46
+ background: $surface-darken-1;
47
+ overflow-x: auto;
48
+ }
49
+
50
+ JsonlEntryItem.expanded .json-detail {
51
+ display: block;
52
+ }
53
+
54
+ JsonlEntryItem.selected {
55
+ background: $accent;
56
+ }
57
+
58
+ JsonlEntryItem.selected .entry-summary {
59
+ color: $text;
60
+ }
61
+ """
62
+
63
+ def __init__(self, entry: JsonlEntry) -> None:
64
+ """Initialize with JSONL entry.
65
+
66
+ Args:
67
+ entry: The JSONL entry to display
68
+ """
69
+ super().__init__()
70
+ self._entry = entry
71
+ self._expanded = False
72
+
73
+ def compose(self) -> ComposeResult:
74
+ """Create widget content."""
75
+ summary = format_summary(self._entry)
76
+
77
+ # Determine style class based on entry type
78
+ entry_type = self._entry.entry_type
79
+ if entry_type == "user":
80
+ style_class = "entry-summary entry-summary-user"
81
+ elif entry_type == "assistant":
82
+ style_class = "entry-summary entry-summary-assistant"
83
+ elif entry_type == "tool_result":
84
+ style_class = "entry-summary entry-summary-tool-result"
85
+ else:
86
+ style_class = "entry-summary entry-summary-other"
87
+
88
+ yield Label(escape_markup(summary), classes=style_class)
89
+
90
+ # Pretty-printed JSON detail (hidden by default)
91
+ # Use markup=False to avoid Rich interpreting brackets as markup tags
92
+ pretty_json = json.dumps(self._entry.parsed, indent=2)
93
+ with Vertical(classes="json-detail"):
94
+ yield Static(pretty_json, markup=False)
95
+
96
+ def toggle_expand(self) -> None:
97
+ """Toggle expand/collapse state."""
98
+ self._expanded = not self._expanded
99
+ if self._expanded:
100
+ self.add_class("expanded")
101
+ else:
102
+ self.remove_class("expanded")
103
+ # Ensure the widget is updated
104
+ self.refresh()
105
+
106
+ def set_expanded(self, expanded: bool) -> None:
107
+ """Set expand state explicitly.
108
+
109
+ Args:
110
+ expanded: Whether to expand (True) or collapse (False)
111
+ """
112
+ self._expanded = expanded
113
+ if expanded:
114
+ self.add_class("expanded")
115
+ else:
116
+ self.remove_class("expanded")
117
+ self.refresh()
118
+
119
+ def is_expanded(self) -> bool:
120
+ """Return current expanded state."""
121
+ return self._expanded
122
+
123
+ def update_format(self, formatted: bool) -> None:
124
+ """Update the JSON detail display format.
125
+
126
+ Args:
127
+ formatted: If True, use YAML-like format. If False, use raw JSON.
128
+ """
129
+ detail_container = self.query_one(".json-detail", Vertical)
130
+ static = detail_container.query_one(Static)
131
+ content = format_entry_detail(self._entry, formatted=formatted)
132
+ # No escape_markup needed - Static widget has markup=False
133
+ static.update(content)
134
+
135
+
136
+ class CustomListView(ListView):
137
+ """Custom ListView with expand/collapse keybinding."""
138
+
139
+ BINDINGS = [
140
+ Binding("enter", "toggle_expand", "Expand/Collapse"),
141
+ Binding("f", "toggle_format", "Format"),
142
+ ]
143
+
144
+ def __init__(self, *children: ListItem) -> None:
145
+ """Initialize with format and expand mode state.
146
+
147
+ Args:
148
+ children: List items to include in the view
149
+ """
150
+ super().__init__(*children)
151
+ self._formatted_mode = True
152
+ self._expand_mode = False
153
+ self._expanded_item: JsonlEntryItem | None = None
154
+
155
+ def action_toggle_expand(self) -> None:
156
+ """Toggle expand/collapse for selected entry."""
157
+ highlighted = self.highlighted_child
158
+ if isinstance(highlighted, JsonlEntryItem):
159
+ highlighted.toggle_expand()
160
+ # Track expand mode
161
+ self._expand_mode = highlighted.is_expanded()
162
+ self._expanded_item = highlighted if self._expand_mode else None
163
+
164
+ def action_toggle_format(self) -> None:
165
+ """Toggle format mode between formatted and raw JSON."""
166
+ self._formatted_mode = not self._formatted_mode
167
+ # Update all items with new format
168
+ for child in self.children:
169
+ if isinstance(child, JsonlEntryItem):
170
+ child.update_format(self._formatted_mode)
171
+
172
+ def watch_index(self, old_index: int | None, new_index: int | None) -> None:
173
+ """Handle index changes for sticky expand mode and selection styling.
174
+
175
+ Args:
176
+ old_index: Previous highlighted index (None if no previous selection)
177
+ new_index: New highlighted index (None if no selection)
178
+ """
179
+ # Update selection styling
180
+ if old_index is not None and old_index >= 0 and old_index < len(self.children):
181
+ old_child = self.children[old_index]
182
+ if isinstance(old_child, JsonlEntryItem):
183
+ old_child.remove_class("selected")
184
+
185
+ if new_index is not None and new_index >= 0 and new_index < len(self.children):
186
+ new_child = self.children[new_index]
187
+ if isinstance(new_child, JsonlEntryItem):
188
+ new_child.add_class("selected")
189
+
190
+ # Sticky expand mode: maintain expand state when navigating
191
+ if self._expand_mode and self._expanded_item is not None:
192
+ # Collapse previous expanded item
193
+ self._expanded_item.set_expanded(False)
194
+ # Expand new item
195
+ new_child.set_expanded(True)
196
+ self._expanded_item = new_child
197
+
198
+ def _on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None:
199
+ """Disable mouse clicks for item selection.
200
+
201
+ We want keyboard-only navigation for this viewer.
202
+ """
203
+ event.prevent_default()
204
+ event.stop()
@@ -0,0 +1,6 @@
1
+ """Sorting module for TUI dashboard."""
2
+
3
+ from erk.tui.sorting.logic import sort_plans
4
+ from erk.tui.sorting.types import BranchActivity, SortKey, SortState
5
+
6
+ __all__ = ["BranchActivity", "SortKey", "SortState", "sort_plans"]
@@ -0,0 +1,55 @@
1
+ """Pure sorting logic for TUI dashboard."""
2
+
3
+ from datetime import datetime
4
+
5
+ from erk.tui.data.types import PlanRowData
6
+ from erk.tui.sorting.types import BranchActivity, SortKey
7
+
8
+
9
+ def sort_plans(
10
+ plans: list[PlanRowData],
11
+ sort_key: SortKey,
12
+ activity_by_issue: dict[int, BranchActivity] | None = None,
13
+ ) -> list[PlanRowData]:
14
+ """Sort plans by the given key.
15
+
16
+ Args:
17
+ plans: List of plans to sort
18
+ sort_key: Which field to sort by
19
+ activity_by_issue: Mapping of issue number to branch activity data.
20
+ Required when sort_key is BRANCH_ACTIVITY.
21
+
22
+ Returns:
23
+ Sorted list of plans. Original list is not modified.
24
+ """
25
+ if sort_key == SortKey.ISSUE_NUMBER:
26
+ # Sort by issue number descending (newest first)
27
+ return sorted(plans, key=lambda p: p.issue_number, reverse=True)
28
+
29
+ if sort_key == SortKey.BRANCH_ACTIVITY:
30
+ # Sort by most recent commit on branch
31
+ # Plans with recent activity first, no activity at end
32
+ activity_map = activity_by_issue or {}
33
+
34
+ def get_activity_key(plan: PlanRowData) -> tuple[bool, datetime]:
35
+ """Return sort key tuple: (has_activity, timestamp).
36
+
37
+ Returns tuple where:
38
+ - has_activity: True if there's branch activity (so it sorts first)
39
+ - timestamp: The activity timestamp (or min datetime for no activity)
40
+ """
41
+ activity = activity_map.get(plan.issue_number)
42
+ if activity is None or activity.last_commit_at is None:
43
+ # No activity - sort to end with very old date
44
+ return (False, datetime.min)
45
+ return (True, activity.last_commit_at)
46
+
47
+ # Sort: has_activity=True first, then by timestamp descending (newest first)
48
+ return sorted(
49
+ plans,
50
+ key=get_activity_key,
51
+ reverse=True,
52
+ )
53
+
54
+ # Default fallback: return as-is
55
+ return list(plans)
@@ -0,0 +1,68 @@
1
+ """Sort state types for TUI dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from enum import Enum, auto
8
+
9
+
10
+ class SortKey(Enum):
11
+ """Available sort keys for plan list."""
12
+
13
+ ISSUE_NUMBER = auto() # Default: sort by issue number (descending)
14
+ BRANCH_ACTIVITY = auto() # Sort by most recent commit on branch
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class BranchActivity:
19
+ """Branch activity data for a plan.
20
+
21
+ Represents the most recent commit on the branch (not in trunk),
22
+ indicating how recently the branch was worked on.
23
+
24
+ Attributes:
25
+ last_commit_at: Timestamp of most recent commit on branch, None if no commits
26
+ last_commit_author: Author of most recent commit, None if no commits
27
+ """
28
+
29
+ last_commit_at: datetime | None
30
+ last_commit_author: str | None
31
+
32
+ @staticmethod
33
+ def empty() -> BranchActivity:
34
+ """Create empty activity (no commits on branch)."""
35
+ return BranchActivity(last_commit_at=None, last_commit_author=None)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SortState:
40
+ """State for sort mode.
41
+
42
+ Attributes:
43
+ key: Current sort key
44
+ """
45
+
46
+ key: SortKey
47
+
48
+ @staticmethod
49
+ def initial() -> SortState:
50
+ """Create initial state with default sort (by issue number)."""
51
+ return SortState(key=SortKey.ISSUE_NUMBER)
52
+
53
+ def toggle(self) -> SortState:
54
+ """Toggle between sort keys.
55
+
56
+ Returns:
57
+ New state with next sort key
58
+ """
59
+ if self.key == SortKey.ISSUE_NUMBER:
60
+ return SortState(key=SortKey.BRANCH_ACTIVITY)
61
+ return SortState(key=SortKey.ISSUE_NUMBER)
62
+
63
+ @property
64
+ def display_label(self) -> str:
65
+ """Get display label for current sort mode."""
66
+ if self.key == SortKey.ISSUE_NUMBER:
67
+ return "by issue#"
68
+ return "by recent activity"
@@ -0,0 +1,95 @@
1
+ /* Styles for erk dash interactive TUI */
2
+
3
+ Screen {
4
+ background: $surface;
5
+ }
6
+
7
+ /* Plan table styling */
8
+ PlanDataTable {
9
+ height: 1fr;
10
+ margin: 1 2;
11
+ }
12
+
13
+ PlanDataTable > .datatable--header {
14
+ background: $primary;
15
+ color: $text;
16
+ text-style: bold;
17
+ }
18
+
19
+ PlanDataTable > .datatable--cursor {
20
+ background: $accent;
21
+ color: $text;
22
+ }
23
+
24
+ PlanDataTable > .datatable--hover {
25
+ background: $surface-lighten-1;
26
+ }
27
+
28
+ /* Status bar styling */
29
+ StatusBar {
30
+ dock: bottom;
31
+ height: 1;
32
+ background: $primary-background;
33
+ color: $text-muted;
34
+ padding: 0 1;
35
+ }
36
+
37
+ /* Help overlay styling */
38
+ #help-container {
39
+ align: center middle;
40
+ }
41
+
42
+ #help-panel {
43
+ width: 60;
44
+ height: auto;
45
+ max-height: 80%;
46
+ background: $surface;
47
+ border: solid $primary;
48
+ padding: 1 2;
49
+ }
50
+
51
+ #help-title {
52
+ text-style: bold;
53
+ text-align: center;
54
+ margin-bottom: 1;
55
+ }
56
+
57
+ #help-content {
58
+ height: auto;
59
+ }
60
+
61
+ /* Loading indicator */
62
+ #loading {
63
+ align: center middle;
64
+ height: 100%;
65
+ width: 100%;
66
+ }
67
+
68
+ #loading-message {
69
+ text-align: center;
70
+ color: $text-muted;
71
+ }
72
+
73
+ /* Header styling */
74
+ #header {
75
+ dock: top;
76
+ height: 1;
77
+ background: $primary;
78
+ color: $text;
79
+ text-style: bold;
80
+ content-align: center middle;
81
+ }
82
+
83
+ /* Filter input styling - positioned above status bar */
84
+ #filter-input {
85
+ dock: bottom;
86
+ height: 3;
87
+ display: none;
88
+ background: $primary;
89
+ border: solid $accent;
90
+ padding: 0 1;
91
+ }
92
+
93
+ #filter-input.visible {
94
+ display: block;
95
+ }
@@ -0,0 +1 @@
1
+ """TUI widgets for erk dashboard."""