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.
- erk/__init__.py +12 -0
- erk/__main__.py +6 -0
- erk/agent_docs/__init__.py +5 -0
- erk/agent_docs/models.py +123 -0
- erk/agent_docs/operations.py +666 -0
- erk/artifacts/__init__.py +5 -0
- erk/artifacts/artifact_health.py +623 -0
- erk/artifacts/detection.py +16 -0
- erk/artifacts/discovery.py +343 -0
- erk/artifacts/models.py +63 -0
- erk/artifacts/staleness.py +56 -0
- erk/artifacts/state.py +100 -0
- erk/artifacts/sync.py +624 -0
- erk/cli/__init__.py +0 -0
- erk/cli/activation.py +132 -0
- erk/cli/alias.py +53 -0
- erk/cli/cli.py +221 -0
- erk/cli/commands/__init__.py +0 -0
- erk/cli/commands/admin.py +153 -0
- erk/cli/commands/artifact/__init__.py +1 -0
- erk/cli/commands/artifact/check.py +260 -0
- erk/cli/commands/artifact/group.py +31 -0
- erk/cli/commands/artifact/list_cmd.py +89 -0
- erk/cli/commands/artifact/show.py +62 -0
- erk/cli/commands/artifact/sync_cmd.py +39 -0
- erk/cli/commands/branch/__init__.py +26 -0
- erk/cli/commands/branch/assign_cmd.py +152 -0
- erk/cli/commands/branch/checkout_cmd.py +357 -0
- erk/cli/commands/branch/create_cmd.py +161 -0
- erk/cli/commands/branch/list_cmd.py +82 -0
- erk/cli/commands/branch/unassign_cmd.py +197 -0
- erk/cli/commands/cc/__init__.py +15 -0
- erk/cli/commands/cc/jsonl_cmd.py +20 -0
- erk/cli/commands/cc/session/AGENTS.md +30 -0
- erk/cli/commands/cc/session/CLAUDE.md +1 -0
- erk/cli/commands/cc/session/__init__.py +15 -0
- erk/cli/commands/cc/session/list_cmd.py +167 -0
- erk/cli/commands/cc/session/show_cmd.py +175 -0
- erk/cli/commands/completion.py +89 -0
- erk/cli/commands/completions.py +165 -0
- erk/cli/commands/config.py +327 -0
- erk/cli/commands/docs/__init__.py +1 -0
- erk/cli/commands/docs/group.py +16 -0
- erk/cli/commands/docs/sync.py +121 -0
- erk/cli/commands/docs/validate.py +102 -0
- erk/cli/commands/doctor.py +243 -0
- erk/cli/commands/down.py +171 -0
- erk/cli/commands/exec/__init__.py +1 -0
- erk/cli/commands/exec/group.py +164 -0
- erk/cli/commands/exec/scripts/AGENTS.md +79 -0
- erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
- erk/cli/commands/exec/scripts/__init__.py +5 -0
- erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
- erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
- erk/cli/commands/exec/scripts/check_impl.py +152 -0
- erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
- erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
- erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
- erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
- erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
- erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
- erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
- erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
- erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
- erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
- erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
- erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
- erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
- erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
- erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
- erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
- erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
- erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
- erk/cli/commands/exec/scripts/impl_init.py +158 -0
- erk/cli/commands/exec/scripts/impl_signal.py +375 -0
- erk/cli/commands/exec/scripts/impl_verify.py +49 -0
- erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
- erk/cli/commands/exec/scripts/list_sessions.py +296 -0
- erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
- erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
- erk/cli/commands/exec/scripts/marker.py +163 -0
- erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
- erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
- erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
- erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
- erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
- erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
- erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
- erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
- erk/cli/commands/exec/scripts/quick_submit.py +32 -0
- erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
- erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
- erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
- erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
- erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
- erk/cli/commands/exec/scripts/slot_objective.py +102 -0
- erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
- erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
- erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
- erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
- erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
- erk/cli/commands/implement.py +695 -0
- erk/cli/commands/implement_shared.py +649 -0
- erk/cli/commands/info/__init__.py +14 -0
- erk/cli/commands/info/release_notes_cmd.py +128 -0
- erk/cli/commands/init.py +801 -0
- erk/cli/commands/land_cmd.py +690 -0
- erk/cli/commands/log_cmd.py +137 -0
- erk/cli/commands/md/__init__.py +5 -0
- erk/cli/commands/md/check.py +118 -0
- erk/cli/commands/md/group.py +14 -0
- erk/cli/commands/navigation_helpers.py +430 -0
- erk/cli/commands/objective/__init__.py +16 -0
- erk/cli/commands/objective/list_cmd.py +47 -0
- erk/cli/commands/objective_helpers.py +132 -0
- erk/cli/commands/plan/__init__.py +32 -0
- erk/cli/commands/plan/check_cmd.py +174 -0
- erk/cli/commands/plan/close_cmd.py +69 -0
- erk/cli/commands/plan/create_cmd.py +120 -0
- erk/cli/commands/plan/docs/__init__.py +18 -0
- erk/cli/commands/plan/docs/extract_cmd.py +53 -0
- erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
- erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
- erk/cli/commands/plan/extraction/__init__.py +16 -0
- erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
- erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
- erk/cli/commands/plan/get.py +71 -0
- erk/cli/commands/plan/list_cmd.py +754 -0
- erk/cli/commands/plan/log_cmd.py +440 -0
- erk/cli/commands/plan/start_cmd.py +459 -0
- erk/cli/commands/planner/__init__.py +40 -0
- erk/cli/commands/planner/configure_cmd.py +73 -0
- erk/cli/commands/planner/connect_cmd.py +96 -0
- erk/cli/commands/planner/create_cmd.py +148 -0
- erk/cli/commands/planner/list_cmd.py +51 -0
- erk/cli/commands/planner/register_cmd.py +105 -0
- erk/cli/commands/planner/set_default_cmd.py +23 -0
- erk/cli/commands/planner/unregister_cmd.py +43 -0
- erk/cli/commands/pr/__init__.py +23 -0
- erk/cli/commands/pr/check_cmd.py +112 -0
- erk/cli/commands/pr/checkout_cmd.py +165 -0
- erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
- erk/cli/commands/pr/parse_pr_reference.py +10 -0
- erk/cli/commands/pr/submit_cmd.py +360 -0
- erk/cli/commands/pr/sync_cmd.py +181 -0
- erk/cli/commands/prepare_cwd_recovery.py +60 -0
- erk/cli/commands/project/__init__.py +16 -0
- erk/cli/commands/project/init_cmd.py +91 -0
- erk/cli/commands/run/__init__.py +17 -0
- erk/cli/commands/run/list_cmd.py +189 -0
- erk/cli/commands/run/logs_cmd.py +54 -0
- erk/cli/commands/run/shared.py +19 -0
- erk/cli/commands/shell_integration.py +29 -0
- erk/cli/commands/slot/__init__.py +23 -0
- erk/cli/commands/slot/check_cmd.py +277 -0
- erk/cli/commands/slot/common.py +314 -0
- erk/cli/commands/slot/init_pool_cmd.py +157 -0
- erk/cli/commands/slot/list_cmd.py +228 -0
- erk/cli/commands/slot/repair_cmd.py +190 -0
- erk/cli/commands/stack/__init__.py +23 -0
- erk/cli/commands/stack/consolidate_cmd.py +470 -0
- erk/cli/commands/stack/list_cmd.py +79 -0
- erk/cli/commands/stack/move_cmd.py +309 -0
- erk/cli/commands/stack/split_old/README.md +64 -0
- erk/cli/commands/stack/split_old/__init__.py +5 -0
- erk/cli/commands/stack/split_old/command.py +233 -0
- erk/cli/commands/stack/split_old/display.py +116 -0
- erk/cli/commands/stack/split_old/plan.py +216 -0
- erk/cli/commands/status.py +58 -0
- erk/cli/commands/submit.py +768 -0
- erk/cli/commands/up.py +154 -0
- erk/cli/commands/upgrade.py +82 -0
- erk/cli/commands/wt/__init__.py +29 -0
- erk/cli/commands/wt/checkout_cmd.py +110 -0
- erk/cli/commands/wt/create_cmd.py +998 -0
- erk/cli/commands/wt/current_cmd.py +35 -0
- erk/cli/commands/wt/delete_cmd.py +573 -0
- erk/cli/commands/wt/list_cmd.py +332 -0
- erk/cli/commands/wt/rename_cmd.py +66 -0
- erk/cli/config.py +242 -0
- erk/cli/constants.py +29 -0
- erk/cli/core.py +65 -0
- erk/cli/debug.py +9 -0
- erk/cli/ensure-conversion-tasks.md +288 -0
- erk/cli/ensure.py +628 -0
- erk/cli/github_parsing.py +96 -0
- erk/cli/graphite.py +81 -0
- erk/cli/graphite_command.py +80 -0
- erk/cli/help_formatter.py +345 -0
- erk/cli/output.py +361 -0
- erk/cli/presets/dagster.toml +12 -0
- erk/cli/presets/generic.toml +12 -0
- erk/cli/prompt_hooks_templates/README.md +68 -0
- erk/cli/script_output.py +32 -0
- erk/cli/shell_integration/bash_wrapper.sh +32 -0
- erk/cli/shell_integration/fish_wrapper.fish +39 -0
- erk/cli/shell_integration/handler.py +338 -0
- erk/cli/shell_integration/zsh_wrapper.sh +32 -0
- erk/cli/shell_utils.py +171 -0
- erk/cli/subprocess_utils.py +92 -0
- erk/cli/uvx_detection.py +59 -0
- erk/core/__init__.py +0 -0
- erk/core/claude_executor.py +511 -0
- erk/core/claude_settings.py +317 -0
- erk/core/command_log.py +406 -0
- erk/core/commit_message_generator.py +234 -0
- erk/core/completion.py +10 -0
- erk/core/consolidation_utils.py +177 -0
- erk/core/context.py +570 -0
- erk/core/display/__init__.py +4 -0
- erk/core/display/abc.py +24 -0
- erk/core/display/real.py +30 -0
- erk/core/display_utils.py +526 -0
- erk/core/file_utils.py +87 -0
- erk/core/health_checks.py +1315 -0
- erk/core/health_checks_dogfooder/__init__.py +85 -0
- erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
- erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
- erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
- erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
- erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
- erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
- erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
- erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
- erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
- erk/core/implementation_queue/__init__.py +1 -0
- erk/core/implementation_queue/github/__init__.py +8 -0
- erk/core/implementation_queue/github/abc.py +7 -0
- erk/core/implementation_queue/github/noop.py +38 -0
- erk/core/implementation_queue/github/printing.py +43 -0
- erk/core/implementation_queue/github/real.py +119 -0
- erk/core/init_utils.py +227 -0
- erk/core/output_filter.py +338 -0
- erk/core/plan_store/__init__.py +6 -0
- erk/core/planner/__init__.py +1 -0
- erk/core/planner/registry_abc.py +8 -0
- erk/core/planner/registry_fake.py +129 -0
- erk/core/planner/registry_real.py +195 -0
- erk/core/planner/types.py +7 -0
- erk/core/pr_utils.py +30 -0
- erk/core/release_notes.py +263 -0
- erk/core/repo_discovery.py +126 -0
- erk/core/script_writer.py +41 -0
- erk/core/services/__init__.py +1 -0
- erk/core/services/plan_list_service.py +94 -0
- erk/core/shell.py +51 -0
- erk/core/user_feedback.py +11 -0
- erk/core/version_check.py +55 -0
- erk/core/workflow_display.py +75 -0
- erk/core/worktree_pool.py +190 -0
- erk/core/worktree_utils.py +300 -0
- erk/data/CHANGELOG.md +438 -0
- erk/data/__init__.py +1 -0
- erk/data/claude/agents/devrun.md +180 -0
- erk/data/claude/commands/erk/__init__.py +0 -0
- erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
- erk/data/claude/commands/erk/fix-conflicts.md +25 -0
- erk/data/claude/commands/erk/git-pr-push.md +345 -0
- erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
- erk/data/claude/commands/erk/land.md +193 -0
- erk/data/claude/commands/erk/objective-create.md +370 -0
- erk/data/claude/commands/erk/objective-list.md +34 -0
- erk/data/claude/commands/erk/objective-next-plan.md +220 -0
- erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
- erk/data/claude/commands/erk/plan-implement.md +202 -0
- erk/data/claude/commands/erk/plan-save.md +45 -0
- erk/data/claude/commands/erk/plan-submit.md +39 -0
- erk/data/claude/commands/erk/pr-address.md +367 -0
- erk/data/claude/commands/erk/pr-submit.md +58 -0
- erk/data/claude/skills/dignified-python/SKILL.md +48 -0
- erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
- erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
- erk/data/claude/skills/dignified-python/subprocess.md +99 -0
- erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
- erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
- erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
- erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
- erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
- erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
- erk/data/claude/skills/learned-docs/SKILL.md +362 -0
- erk/data/github/actions/setup-claude-erk/action.yml +11 -0
- erk/data/github/prompts/dignified-python-review.md +125 -0
- erk/data/github/workflows/dignified-python-review.yml +61 -0
- erk/data/github/workflows/erk-impl.yml +251 -0
- erk/hooks/__init__.py +1 -0
- erk/hooks/decorators.py +319 -0
- erk/status/__init__.py +8 -0
- erk/status/collectors/__init__.py +9 -0
- erk/status/collectors/base.py +52 -0
- erk/status/collectors/git.py +76 -0
- erk/status/collectors/github.py +81 -0
- erk/status/collectors/graphite.py +80 -0
- erk/status/collectors/impl.py +145 -0
- erk/status/models/__init__.py +4 -0
- erk/status/models/status_data.py +404 -0
- erk/status/orchestrator.py +169 -0
- erk/status/renderers/__init__.py +5 -0
- erk/status/renderers/simple.py +322 -0
- erk/tui/AGENTS.md +193 -0
- erk/tui/CLAUDE.md +1 -0
- erk/tui/__init__.py +1 -0
- erk/tui/app.py +1404 -0
- erk/tui/commands/__init__.py +1 -0
- erk/tui/commands/executor.py +66 -0
- erk/tui/commands/provider.py +165 -0
- erk/tui/commands/real_executor.py +63 -0
- erk/tui/commands/registry.py +121 -0
- erk/tui/commands/types.py +36 -0
- erk/tui/data/__init__.py +1 -0
- erk/tui/data/provider.py +492 -0
- erk/tui/data/types.py +104 -0
- erk/tui/filtering/__init__.py +1 -0
- erk/tui/filtering/logic.py +43 -0
- erk/tui/filtering/types.py +55 -0
- erk/tui/jsonl_viewer/__init__.py +1 -0
- erk/tui/jsonl_viewer/app.py +61 -0
- erk/tui/jsonl_viewer/models.py +208 -0
- erk/tui/jsonl_viewer/widgets.py +204 -0
- erk/tui/sorting/__init__.py +6 -0
- erk/tui/sorting/logic.py +55 -0
- erk/tui/sorting/types.py +68 -0
- erk/tui/styles/dash.tcss +95 -0
- erk/tui/widgets/__init__.py +1 -0
- erk/tui/widgets/command_output.py +112 -0
- erk/tui/widgets/plan_table.py +276 -0
- erk/tui/widgets/status_bar.py +116 -0
- erk-0.4.5.dist-info/METADATA +376 -0
- erk-0.4.5.dist-info/RECORD +331 -0
- erk-0.4.5.dist-info/WHEEL +4 -0
- erk-0.4.5.dist-info/entry_points.txt +2 -0
- erk-0.4.5.dist-info/licenses/LICENSE.md +3 -0
erk/tui/app.py
ADDED
|
@@ -0,0 +1,1404 @@
|
|
|
1
|
+
"""Main Textual application for erk dash interactive mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from rich.markup import escape as escape_markup
|
|
14
|
+
from textual import on, work
|
|
15
|
+
from textual.app import App, ComposeResult, SystemCommand
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import Container, Vertical
|
|
18
|
+
from textual.events import Click
|
|
19
|
+
from textual.screen import ModalScreen, Screen
|
|
20
|
+
from textual.widgets import Header, Input, Label, Static
|
|
21
|
+
|
|
22
|
+
from erk.tui.commands.executor import CommandExecutor
|
|
23
|
+
from erk.tui.commands.provider import MainListCommandProvider, PlanCommandProvider
|
|
24
|
+
from erk.tui.commands.real_executor import RealCommandExecutor
|
|
25
|
+
from erk.tui.data.provider import PlanDataProvider
|
|
26
|
+
from erk.tui.data.types import PlanFilters, PlanRowData
|
|
27
|
+
from erk.tui.filtering.logic import filter_plans
|
|
28
|
+
from erk.tui.filtering.types import FilterMode, FilterState
|
|
29
|
+
from erk.tui.sorting.logic import sort_plans
|
|
30
|
+
from erk.tui.sorting.types import BranchActivity, SortKey, SortState
|
|
31
|
+
from erk.tui.widgets.command_output import CommandOutputPanel
|
|
32
|
+
from erk.tui.widgets.plan_table import PlanDataTable
|
|
33
|
+
from erk.tui.widgets.status_bar import StatusBar
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from erk_shared.gateway.browser.abc import BrowserLauncher
|
|
37
|
+
from erk_shared.gateway.clipboard.abc import Clipboard
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ClickableLink(Static):
|
|
41
|
+
"""A clickable link widget that opens a URL in the browser."""
|
|
42
|
+
|
|
43
|
+
DEFAULT_CSS = """
|
|
44
|
+
ClickableLink {
|
|
45
|
+
color: $primary;
|
|
46
|
+
text-style: underline;
|
|
47
|
+
}
|
|
48
|
+
ClickableLink:hover {
|
|
49
|
+
color: $primary-lighten-2;
|
|
50
|
+
}
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, text: str, url: str, **kwargs) -> None:
|
|
54
|
+
"""Initialize clickable link.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
text: Display text for the link
|
|
58
|
+
url: URL to open when clicked
|
|
59
|
+
**kwargs: Additional widget arguments
|
|
60
|
+
"""
|
|
61
|
+
super().__init__(escape_markup(text), **kwargs)
|
|
62
|
+
self._url = url
|
|
63
|
+
|
|
64
|
+
def on_click(self, event: Click) -> None:
|
|
65
|
+
"""Open URL in browser when clicked."""
|
|
66
|
+
event.stop()
|
|
67
|
+
# Access browser through the app's provider (ErkDashApp)
|
|
68
|
+
# Use getattr to avoid circular import isinstance issues
|
|
69
|
+
app: Any = self.app
|
|
70
|
+
provider = getattr(app, "_provider", None)
|
|
71
|
+
if provider is not None:
|
|
72
|
+
browser = getattr(provider, "browser", None)
|
|
73
|
+
if browser is not None:
|
|
74
|
+
browser.launch(self._url)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CopyableLabel(Static):
|
|
78
|
+
"""A label that copies text to clipboard when clicked, styled with orange/accent color."""
|
|
79
|
+
|
|
80
|
+
DEFAULT_CSS = """
|
|
81
|
+
CopyableLabel {
|
|
82
|
+
color: $accent;
|
|
83
|
+
}
|
|
84
|
+
CopyableLabel:hover {
|
|
85
|
+
color: $accent-lighten-1;
|
|
86
|
+
text-style: bold;
|
|
87
|
+
}
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, label: str, text_to_copy: str, **kwargs) -> None:
|
|
91
|
+
"""Initialize copyable label.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
label: Display text for the label (e.g., "[1]" or "erk pr co 2022")
|
|
95
|
+
text_to_copy: Text to copy to clipboard when clicked
|
|
96
|
+
**kwargs: Additional widget arguments
|
|
97
|
+
"""
|
|
98
|
+
super().__init__(label, **kwargs)
|
|
99
|
+
self._text_to_copy = text_to_copy
|
|
100
|
+
self._original_label = label
|
|
101
|
+
|
|
102
|
+
def on_click(self, event: Click) -> None:
|
|
103
|
+
"""Copy text to clipboard when clicked."""
|
|
104
|
+
event.stop()
|
|
105
|
+
success = self._copy_to_clipboard()
|
|
106
|
+
if success:
|
|
107
|
+
self.update("Copied!")
|
|
108
|
+
self.set_timer(1.5, lambda: self.update(self._original_label))
|
|
109
|
+
|
|
110
|
+
def _copy_to_clipboard(self) -> bool:
|
|
111
|
+
"""Copy text to clipboard, finding the clipboard interface.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
True if copy succeeded, False otherwise.
|
|
115
|
+
"""
|
|
116
|
+
# Access clipboard through the app's provider (ErkDashApp)
|
|
117
|
+
# Use getattr to avoid circular import isinstance issues
|
|
118
|
+
app: Any = self.app
|
|
119
|
+
provider = getattr(app, "_provider", None)
|
|
120
|
+
if provider is not None:
|
|
121
|
+
clipboard = getattr(provider, "clipboard", None)
|
|
122
|
+
if clipboard is not None:
|
|
123
|
+
return clipboard.copy(self._text_to_copy)
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class HelpScreen(ModalScreen):
|
|
128
|
+
"""Modal screen showing keyboard shortcuts."""
|
|
129
|
+
|
|
130
|
+
BINDINGS = [
|
|
131
|
+
Binding("escape", "dismiss", "Close"),
|
|
132
|
+
Binding("q", "dismiss", "Close"),
|
|
133
|
+
Binding("?", "dismiss", "Close"),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
DEFAULT_CSS = """
|
|
137
|
+
HelpScreen {
|
|
138
|
+
align: center middle;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#help-dialog {
|
|
142
|
+
width: 60;
|
|
143
|
+
height: auto;
|
|
144
|
+
max-height: 80%;
|
|
145
|
+
background: $surface;
|
|
146
|
+
border: solid $primary;
|
|
147
|
+
padding: 1 2;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#help-title {
|
|
151
|
+
text-style: bold;
|
|
152
|
+
text-align: center;
|
|
153
|
+
margin-bottom: 1;
|
|
154
|
+
width: 100%;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.help-section {
|
|
158
|
+
margin-top: 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.help-section-title {
|
|
162
|
+
text-style: bold;
|
|
163
|
+
color: $primary;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.help-binding {
|
|
167
|
+
margin-left: 2;
|
|
168
|
+
}
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def compose(self) -> ComposeResult:
|
|
172
|
+
"""Create help dialog content."""
|
|
173
|
+
with Vertical(id="help-dialog"):
|
|
174
|
+
yield Label("erk dash - Keyboard Shortcuts", id="help-title")
|
|
175
|
+
|
|
176
|
+
with Vertical(classes="help-section"):
|
|
177
|
+
yield Label("Navigation", classes="help-section-title")
|
|
178
|
+
yield Label("↑/k Move cursor up", classes="help-binding")
|
|
179
|
+
yield Label("↓/j Move cursor down", classes="help-binding")
|
|
180
|
+
yield Label("Home Jump to first row", classes="help-binding")
|
|
181
|
+
yield Label("End Jump to last row", classes="help-binding")
|
|
182
|
+
|
|
183
|
+
with Vertical(classes="help-section"):
|
|
184
|
+
yield Label("Actions", classes="help-section-title")
|
|
185
|
+
yield Label("Enter View plan details", classes="help-binding")
|
|
186
|
+
yield Label("Ctrl+P Commands (opens detail modal)", classes="help-binding")
|
|
187
|
+
yield Label("o Open PR (or issue if no PR)", classes="help-binding")
|
|
188
|
+
yield Label("p Open PR in browser", classes="help-binding")
|
|
189
|
+
yield Label("i Show implement command", classes="help-binding")
|
|
190
|
+
|
|
191
|
+
with Vertical(classes="help-section"):
|
|
192
|
+
yield Label("Filter & Sort", classes="help-section-title")
|
|
193
|
+
yield Label("/ Start filter mode", classes="help-binding")
|
|
194
|
+
yield Label("Esc Clear filter / exit filter", classes="help-binding")
|
|
195
|
+
yield Label("Enter Return focus to table", classes="help-binding")
|
|
196
|
+
yield Label("s Toggle sort mode", classes="help-binding")
|
|
197
|
+
|
|
198
|
+
with Vertical(classes="help-section"):
|
|
199
|
+
yield Label("General", classes="help-section-title")
|
|
200
|
+
yield Label("r Refresh data", classes="help-binding")
|
|
201
|
+
yield Label("? Show this help", classes="help-binding")
|
|
202
|
+
yield Label("q/Esc Quit", classes="help-binding")
|
|
203
|
+
|
|
204
|
+
yield Label("")
|
|
205
|
+
yield Label("Press any key to close", id="help-footer")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class PlanDetailScreen(ModalScreen):
|
|
209
|
+
"""Modal screen showing detailed plan information as an Action Hub."""
|
|
210
|
+
|
|
211
|
+
COMMANDS = {PlanCommandProvider} # Register command provider for palette
|
|
212
|
+
|
|
213
|
+
BINDINGS = [
|
|
214
|
+
# Navigation
|
|
215
|
+
Binding("escape", "dismiss", "Close"),
|
|
216
|
+
Binding("q", "dismiss", "Close"),
|
|
217
|
+
Binding("space", "dismiss", "Close"),
|
|
218
|
+
# Links section
|
|
219
|
+
Binding("o", "open_browser", "Open"),
|
|
220
|
+
Binding("i", "open_issue", "Issue"),
|
|
221
|
+
Binding("p", "open_pr", "PR"),
|
|
222
|
+
Binding("r", "open_run", "Run"),
|
|
223
|
+
# Copy section
|
|
224
|
+
Binding("c", "copy_checkout", "Checkout"),
|
|
225
|
+
Binding("e", "copy_pr_checkout", "PR Checkout"),
|
|
226
|
+
Binding("y", "copy_output_logs", "Copy Logs"),
|
|
227
|
+
Binding("1", "copy_implement", "Implement"),
|
|
228
|
+
Binding("2", "copy_implement_dangerous", "Dangerous"),
|
|
229
|
+
Binding("3", "copy_implement_yolo", "Yolo"),
|
|
230
|
+
Binding("4", "copy_submit", "Submit"),
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
DEFAULT_CSS = """
|
|
234
|
+
PlanDetailScreen {
|
|
235
|
+
align: center middle;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#detail-dialog {
|
|
239
|
+
width: 80%;
|
|
240
|
+
max-width: 120;
|
|
241
|
+
height: auto;
|
|
242
|
+
max-height: 90%;
|
|
243
|
+
background: $surface;
|
|
244
|
+
border: solid $primary;
|
|
245
|
+
padding: 1 2;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#detail-header {
|
|
249
|
+
width: 100%;
|
|
250
|
+
height: auto;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#detail-plan-link {
|
|
254
|
+
text-style: bold;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#detail-title {
|
|
258
|
+
color: $text;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.status-badge {
|
|
262
|
+
margin-left: 1;
|
|
263
|
+
padding: 0 1;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.badge-open {
|
|
267
|
+
background: #238636;
|
|
268
|
+
color: white;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.badge-closed {
|
|
272
|
+
background: #8957e5;
|
|
273
|
+
color: white;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.badge-merged {
|
|
277
|
+
background: #8957e5;
|
|
278
|
+
color: white;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.badge-pr {
|
|
282
|
+
background: $primary;
|
|
283
|
+
color: white;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.badge-success {
|
|
287
|
+
background: #238636;
|
|
288
|
+
color: white;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.badge-failure {
|
|
292
|
+
background: #da3633;
|
|
293
|
+
color: white;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.badge-pending {
|
|
297
|
+
background: #9e6a03;
|
|
298
|
+
color: white;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.badge-local {
|
|
302
|
+
background: #58a6ff;
|
|
303
|
+
color: black;
|
|
304
|
+
text-style: bold;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.badge-dim {
|
|
308
|
+
background: $surface-lighten-1;
|
|
309
|
+
color: $text-muted;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#detail-divider {
|
|
313
|
+
height: 1;
|
|
314
|
+
background: $primary-darken-2;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.info-row {
|
|
318
|
+
layout: horizontal;
|
|
319
|
+
height: 1;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.info-label {
|
|
323
|
+
color: $text-muted;
|
|
324
|
+
width: 12;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.info-value {
|
|
328
|
+
color: $text;
|
|
329
|
+
min-width: 20;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.copyable-row {
|
|
333
|
+
layout: horizontal;
|
|
334
|
+
height: 1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.copyable-text {
|
|
338
|
+
color: $text;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#detail-footer {
|
|
342
|
+
text-align: center;
|
|
343
|
+
margin-top: 1;
|
|
344
|
+
color: $text-muted;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.log-entry {
|
|
348
|
+
color: $text-muted;
|
|
349
|
+
margin-left: 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.log-section {
|
|
353
|
+
margin-top: 1;
|
|
354
|
+
max-height: 6;
|
|
355
|
+
overflow-y: auto;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.log-header {
|
|
359
|
+
color: $text-muted;
|
|
360
|
+
text-style: italic;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.section-header {
|
|
364
|
+
color: $text-muted;
|
|
365
|
+
text-style: bold italic;
|
|
366
|
+
margin-top: 1;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.command-row {
|
|
370
|
+
layout: horizontal;
|
|
371
|
+
height: 1;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.command-key {
|
|
375
|
+
color: $accent;
|
|
376
|
+
width: 4;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.command-text {
|
|
380
|
+
color: $text;
|
|
381
|
+
}
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
def __init__(
|
|
385
|
+
self,
|
|
386
|
+
row: PlanRowData,
|
|
387
|
+
clipboard: Clipboard | None = None,
|
|
388
|
+
browser: BrowserLauncher | None = None,
|
|
389
|
+
executor: CommandExecutor | None = None,
|
|
390
|
+
repo_root: Path | None = None,
|
|
391
|
+
auto_open_palette: bool = False,
|
|
392
|
+
) -> None:
|
|
393
|
+
"""Initialize with plan row data.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
row: PlanRowData containing all plan information
|
|
397
|
+
clipboard: Optional clipboard interface for copy operations
|
|
398
|
+
browser: Optional browser launcher interface for opening URLs
|
|
399
|
+
executor: Optional command executor for palette commands
|
|
400
|
+
repo_root: Path to repository root for running commands
|
|
401
|
+
auto_open_palette: If True, open command palette on mount
|
|
402
|
+
"""
|
|
403
|
+
super().__init__()
|
|
404
|
+
self._row = row
|
|
405
|
+
self._clipboard = clipboard
|
|
406
|
+
self._browser = browser
|
|
407
|
+
self._executor = executor
|
|
408
|
+
self._repo_root = repo_root
|
|
409
|
+
self._output_panel: CommandOutputPanel | None = None
|
|
410
|
+
self._command_running = False
|
|
411
|
+
self._auto_open_palette = auto_open_palette
|
|
412
|
+
|
|
413
|
+
def on_mount(self) -> None:
|
|
414
|
+
"""Handle mount event - optionally open command palette."""
|
|
415
|
+
if self._auto_open_palette:
|
|
416
|
+
# Use call_after_refresh to ensure screen is fully active
|
|
417
|
+
# before opening command palette
|
|
418
|
+
self.call_after_refresh(self.app.action_command_palette)
|
|
419
|
+
|
|
420
|
+
def _get_pr_state_badge(self) -> tuple[str, str]:
|
|
421
|
+
"""Get PR state display text and CSS class."""
|
|
422
|
+
state = self._row.pr_state
|
|
423
|
+
if state == "MERGED":
|
|
424
|
+
return ("MERGED", "badge-merged")
|
|
425
|
+
elif state == "CLOSED":
|
|
426
|
+
return ("CLOSED", "badge-closed")
|
|
427
|
+
elif state == "OPEN":
|
|
428
|
+
return ("OPEN", "badge-open")
|
|
429
|
+
return ("PR", "badge-pr")
|
|
430
|
+
|
|
431
|
+
def _get_run_badge(self) -> tuple[str, str]:
|
|
432
|
+
"""Get workflow run display text and CSS class."""
|
|
433
|
+
if not self._row.run_status:
|
|
434
|
+
return ("No runs", "badge-dim")
|
|
435
|
+
|
|
436
|
+
conclusion = self._row.run_conclusion
|
|
437
|
+
if conclusion == "success":
|
|
438
|
+
return ("✓ Passed", "badge-success")
|
|
439
|
+
elif conclusion == "failure":
|
|
440
|
+
return ("✗ Failed", "badge-failure")
|
|
441
|
+
elif conclusion == "cancelled":
|
|
442
|
+
return ("Cancelled", "badge-dim")
|
|
443
|
+
elif self._row.run_status == "in_progress":
|
|
444
|
+
return ("Running...", "badge-pending")
|
|
445
|
+
elif self._row.run_status == "queued":
|
|
446
|
+
return ("Queued", "badge-pending")
|
|
447
|
+
return (self._row.run_status, "badge-dim")
|
|
448
|
+
|
|
449
|
+
def action_open_browser(self) -> None:
|
|
450
|
+
"""Open the plan (PR if available, otherwise issue) in browser."""
|
|
451
|
+
if self._browser is None:
|
|
452
|
+
return
|
|
453
|
+
if self._row.pr_url:
|
|
454
|
+
self._browser.launch(self._row.pr_url)
|
|
455
|
+
elif self._row.issue_url:
|
|
456
|
+
self._browser.launch(self._row.issue_url)
|
|
457
|
+
|
|
458
|
+
def action_open_issue(self) -> None:
|
|
459
|
+
"""Open the issue in browser."""
|
|
460
|
+
if self._browser is None:
|
|
461
|
+
return
|
|
462
|
+
if self._row.issue_url:
|
|
463
|
+
self._browser.launch(self._row.issue_url)
|
|
464
|
+
|
|
465
|
+
def action_open_pr(self) -> None:
|
|
466
|
+
"""Open the PR in browser."""
|
|
467
|
+
if self._browser is None:
|
|
468
|
+
return
|
|
469
|
+
if self._row.pr_url:
|
|
470
|
+
self._browser.launch(self._row.pr_url)
|
|
471
|
+
|
|
472
|
+
def action_open_run(self) -> None:
|
|
473
|
+
"""Open the workflow run in browser."""
|
|
474
|
+
if self._browser is None:
|
|
475
|
+
return
|
|
476
|
+
if self._row.run_url:
|
|
477
|
+
self._browser.launch(self._row.run_url)
|
|
478
|
+
|
|
479
|
+
def _copy_and_notify(self, text: str) -> None:
|
|
480
|
+
"""Copy text to clipboard and show notification.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
text: Text to copy to clipboard
|
|
484
|
+
"""
|
|
485
|
+
if self._clipboard is not None:
|
|
486
|
+
self._clipboard.copy(text)
|
|
487
|
+
# Show brief notification via app's notify method
|
|
488
|
+
self.notify(f"Copied: {text}", timeout=2)
|
|
489
|
+
|
|
490
|
+
def action_copy_checkout(self) -> None:
|
|
491
|
+
"""Copy local checkout command to clipboard."""
|
|
492
|
+
if self._row.exists_locally:
|
|
493
|
+
cmd = f"erk co {self._row.worktree_name}"
|
|
494
|
+
self._copy_and_notify(cmd)
|
|
495
|
+
|
|
496
|
+
def action_copy_pr_checkout(self) -> None:
|
|
497
|
+
"""Copy PR checkout command to clipboard."""
|
|
498
|
+
if self._row.pr_number is not None:
|
|
499
|
+
cmd = f"erk pr co {self._row.pr_number}"
|
|
500
|
+
self._copy_and_notify(cmd)
|
|
501
|
+
|
|
502
|
+
def action_copy_implement(self) -> None:
|
|
503
|
+
"""Copy basic implement command to clipboard."""
|
|
504
|
+
cmd = f"erk implement {self._row.issue_number}"
|
|
505
|
+
self._copy_and_notify(cmd)
|
|
506
|
+
|
|
507
|
+
def action_copy_implement_dangerous(self) -> None:
|
|
508
|
+
"""Copy implement --dangerous command to clipboard."""
|
|
509
|
+
cmd = f"erk implement {self._row.issue_number} --dangerous"
|
|
510
|
+
self._copy_and_notify(cmd)
|
|
511
|
+
|
|
512
|
+
def action_copy_implement_yolo(self) -> None:
|
|
513
|
+
"""Copy implement --yolo command to clipboard."""
|
|
514
|
+
cmd = f"erk implement {self._row.issue_number} --yolo"
|
|
515
|
+
self._copy_and_notify(cmd)
|
|
516
|
+
|
|
517
|
+
def action_copy_submit(self) -> None:
|
|
518
|
+
"""Copy submit command to clipboard."""
|
|
519
|
+
cmd = f"erk plan submit {self._row.issue_number}"
|
|
520
|
+
self._copy_and_notify(cmd)
|
|
521
|
+
|
|
522
|
+
def action_copy_output_logs(self) -> None:
|
|
523
|
+
"""Copy command output logs to clipboard."""
|
|
524
|
+
if self._output_panel is None:
|
|
525
|
+
return
|
|
526
|
+
if not self._output_panel.is_completed:
|
|
527
|
+
return
|
|
528
|
+
self._copy_and_notify(self._output_panel.get_output_text())
|
|
529
|
+
|
|
530
|
+
async def action_dismiss(self, result: object = None) -> None:
|
|
531
|
+
"""Dismiss the modal, blocking while command is running.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
result: Optional result to pass to dismiss (unused, for API compat)
|
|
535
|
+
"""
|
|
536
|
+
# Block while command is running
|
|
537
|
+
if self._command_running:
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
# If panel exists and completed, refresh data if successful
|
|
541
|
+
if self._output_panel is not None:
|
|
542
|
+
if self._output_panel.is_completed:
|
|
543
|
+
if self._executor and self._output_panel.succeeded:
|
|
544
|
+
self._executor.refresh_data()
|
|
545
|
+
await self._flush_next_callbacks()
|
|
546
|
+
self.dismiss(result)
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
# Normal dismiss
|
|
550
|
+
await self._flush_next_callbacks()
|
|
551
|
+
self.dismiss(result)
|
|
552
|
+
|
|
553
|
+
def run_streaming_command(
|
|
554
|
+
self,
|
|
555
|
+
command: list[str],
|
|
556
|
+
cwd: Path,
|
|
557
|
+
title: str,
|
|
558
|
+
) -> None:
|
|
559
|
+
"""Run command with live output in bottom panel.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
command: Command to run as list of arguments
|
|
563
|
+
cwd: Working directory for the command
|
|
564
|
+
title: Title to display in the output panel
|
|
565
|
+
"""
|
|
566
|
+
# Create and mount output panel
|
|
567
|
+
self._output_panel = CommandOutputPanel(title)
|
|
568
|
+
dialog = self.query_one("#detail-dialog")
|
|
569
|
+
dialog.mount(self._output_panel)
|
|
570
|
+
self._command_running = True
|
|
571
|
+
|
|
572
|
+
# Run subprocess in worker thread
|
|
573
|
+
self._stream_subprocess(command, cwd)
|
|
574
|
+
|
|
575
|
+
@work(thread=True)
|
|
576
|
+
def _stream_subprocess(self, command: list[str], cwd: Path) -> None:
|
|
577
|
+
"""Worker: stream subprocess output to panel.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
command: Command to run
|
|
581
|
+
cwd: Working directory
|
|
582
|
+
"""
|
|
583
|
+
# Capture panel reference at start (won't be None since run_streaming_command sets it)
|
|
584
|
+
panel = self._output_panel
|
|
585
|
+
if panel is None:
|
|
586
|
+
self._command_running = False
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
process = subprocess.Popen(
|
|
590
|
+
command,
|
|
591
|
+
cwd=cwd,
|
|
592
|
+
stdout=subprocess.PIPE,
|
|
593
|
+
stderr=subprocess.STDOUT,
|
|
594
|
+
text=True,
|
|
595
|
+
bufsize=1,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
if process.stdout is not None:
|
|
599
|
+
for line in process.stdout:
|
|
600
|
+
self.app.call_from_thread(
|
|
601
|
+
panel.append_line,
|
|
602
|
+
line.rstrip(),
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
return_code = process.wait()
|
|
606
|
+
success = return_code == 0
|
|
607
|
+
self.app.call_from_thread(panel.set_completed, success)
|
|
608
|
+
self._command_running = False
|
|
609
|
+
|
|
610
|
+
def execute_command(self, command_id: str) -> None:
|
|
611
|
+
"""Execute a command from the palette.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
command_id: The ID of the command to execute
|
|
615
|
+
"""
|
|
616
|
+
if self._executor is None:
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
row = self._row
|
|
620
|
+
executor = self._executor
|
|
621
|
+
|
|
622
|
+
if command_id == "open_browser":
|
|
623
|
+
url = row.pr_url or row.issue_url
|
|
624
|
+
if url:
|
|
625
|
+
executor.open_url(url)
|
|
626
|
+
executor.notify(f"Opened {url}")
|
|
627
|
+
|
|
628
|
+
elif command_id == "open_issue":
|
|
629
|
+
if row.issue_url:
|
|
630
|
+
executor.open_url(row.issue_url)
|
|
631
|
+
executor.notify(f"Opened issue #{row.issue_number}")
|
|
632
|
+
|
|
633
|
+
elif command_id == "open_pr":
|
|
634
|
+
if row.pr_url:
|
|
635
|
+
executor.open_url(row.pr_url)
|
|
636
|
+
executor.notify(f"Opened PR #{row.pr_number}")
|
|
637
|
+
|
|
638
|
+
elif command_id == "open_run":
|
|
639
|
+
if row.run_url:
|
|
640
|
+
executor.open_url(row.run_url)
|
|
641
|
+
executor.notify(f"Opened run {row.run_id_display}")
|
|
642
|
+
|
|
643
|
+
elif command_id == "copy_checkout":
|
|
644
|
+
cmd = f"erk co {row.worktree_name}"
|
|
645
|
+
executor.copy_to_clipboard(cmd)
|
|
646
|
+
executor.notify(f"Copied: {cmd}")
|
|
647
|
+
|
|
648
|
+
elif command_id == "copy_pr_checkout":
|
|
649
|
+
cmd = f"erk pr co {row.pr_number}"
|
|
650
|
+
executor.copy_to_clipboard(cmd)
|
|
651
|
+
executor.notify(f"Copied: {cmd}")
|
|
652
|
+
|
|
653
|
+
elif command_id == "copy_implement":
|
|
654
|
+
cmd = f"erk implement {row.issue_number}"
|
|
655
|
+
executor.copy_to_clipboard(cmd)
|
|
656
|
+
executor.notify(f"Copied: {cmd}")
|
|
657
|
+
|
|
658
|
+
elif command_id == "copy_implement_dangerous":
|
|
659
|
+
cmd = f"erk implement {row.issue_number} --dangerous"
|
|
660
|
+
executor.copy_to_clipboard(cmd)
|
|
661
|
+
executor.notify(f"Copied: {cmd}")
|
|
662
|
+
|
|
663
|
+
elif command_id == "copy_implement_yolo":
|
|
664
|
+
cmd = f"erk implement {row.issue_number} --yolo"
|
|
665
|
+
executor.copy_to_clipboard(cmd)
|
|
666
|
+
executor.notify(f"Copied: {cmd}")
|
|
667
|
+
|
|
668
|
+
elif command_id == "copy_submit":
|
|
669
|
+
cmd = f"erk plan submit {row.issue_number}"
|
|
670
|
+
executor.copy_to_clipboard(cmd)
|
|
671
|
+
executor.notify(f"Copied: {cmd}")
|
|
672
|
+
|
|
673
|
+
elif command_id == "close_plan":
|
|
674
|
+
if row.issue_url:
|
|
675
|
+
closed_prs = executor.close_plan(row.issue_number, row.issue_url)
|
|
676
|
+
if closed_prs:
|
|
677
|
+
pr_list = ", ".join(f"#{pr}" for pr in closed_prs)
|
|
678
|
+
executor.notify(f"Closed plan #{row.issue_number} and PRs: {pr_list}")
|
|
679
|
+
else:
|
|
680
|
+
executor.notify(f"Closed plan #{row.issue_number}")
|
|
681
|
+
executor.refresh_data()
|
|
682
|
+
# Close modal after closing plan (only when running in app context)
|
|
683
|
+
if self.is_attached:
|
|
684
|
+
self.dismiss()
|
|
685
|
+
|
|
686
|
+
elif command_id == "submit_to_queue":
|
|
687
|
+
if row.issue_url and self._repo_root is not None:
|
|
688
|
+
# Use streaming output for submit command
|
|
689
|
+
self.run_streaming_command(
|
|
690
|
+
["erk", "plan", "submit", str(row.issue_number)],
|
|
691
|
+
cwd=self._repo_root,
|
|
692
|
+
title=f"Submitting Plan #{row.issue_number}",
|
|
693
|
+
)
|
|
694
|
+
# Don't dismiss - user must press Esc after completion
|
|
695
|
+
|
|
696
|
+
def compose(self) -> ComposeResult:
|
|
697
|
+
"""Create detail dialog content as an Action Hub."""
|
|
698
|
+
with Vertical(id="detail-dialog"):
|
|
699
|
+
# Header: Plan number + title
|
|
700
|
+
with Vertical(id="detail-header"):
|
|
701
|
+
plan_text = f"Plan #{self._row.issue_number}"
|
|
702
|
+
yield Label(plan_text, id="detail-plan-link")
|
|
703
|
+
yield Label(self._row.full_title, id="detail-title", markup=False)
|
|
704
|
+
|
|
705
|
+
# Divider
|
|
706
|
+
yield Label("", id="detail-divider")
|
|
707
|
+
|
|
708
|
+
# ISSUE/PR INFO SECTION
|
|
709
|
+
# Issue Info - clickable issue number
|
|
710
|
+
with Container(classes="info-row"):
|
|
711
|
+
yield Label("Issue", classes="info-label")
|
|
712
|
+
if self._row.issue_url:
|
|
713
|
+
yield ClickableLink(
|
|
714
|
+
f"#{self._row.issue_number}", self._row.issue_url, classes="info-value"
|
|
715
|
+
)
|
|
716
|
+
else:
|
|
717
|
+
yield Label(f"#{self._row.issue_number}", classes="info-value", markup=False)
|
|
718
|
+
|
|
719
|
+
# PR Info (if exists) - clickable PR number with state badge inline
|
|
720
|
+
if self._row.pr_number:
|
|
721
|
+
with Container(classes="info-row"):
|
|
722
|
+
yield Label("PR", classes="info-label")
|
|
723
|
+
if self._row.pr_url:
|
|
724
|
+
yield ClickableLink(
|
|
725
|
+
f"#{self._row.pr_number}", self._row.pr_url, classes="info-value"
|
|
726
|
+
)
|
|
727
|
+
else:
|
|
728
|
+
yield Label(f"#{self._row.pr_number}", classes="info-value", markup=False)
|
|
729
|
+
# PR state badge inline
|
|
730
|
+
pr_text, pr_class = self._get_pr_state_badge()
|
|
731
|
+
yield Label(pr_text, classes=f"status-badge {pr_class}")
|
|
732
|
+
|
|
733
|
+
# PR title if different from issue title
|
|
734
|
+
if self._row.pr_title and self._row.pr_title != self._row.full_title:
|
|
735
|
+
with Container(classes="info-row"):
|
|
736
|
+
yield Label("PR Title", classes="info-label")
|
|
737
|
+
yield Label(self._row.pr_title, classes="info-value", markup=False)
|
|
738
|
+
|
|
739
|
+
# Checks status
|
|
740
|
+
if self._row.checks_display and self._row.checks_display != "-":
|
|
741
|
+
with Container(classes="info-row"):
|
|
742
|
+
yield Label("Checks", classes="info-label")
|
|
743
|
+
yield Label(self._row.checks_display, classes="info-value", markup=False)
|
|
744
|
+
|
|
745
|
+
# REMOTE RUN INFO SECTION (separate from worktree/local info)
|
|
746
|
+
if self._row.run_id:
|
|
747
|
+
with Container(classes="info-row"):
|
|
748
|
+
yield Label("Run", classes="info-label")
|
|
749
|
+
if self._row.run_url:
|
|
750
|
+
yield ClickableLink(
|
|
751
|
+
self._row.run_id, self._row.run_url, classes="info-value"
|
|
752
|
+
)
|
|
753
|
+
else:
|
|
754
|
+
yield Label(self._row.run_id, classes="info-value", markup=False)
|
|
755
|
+
# Run status badge inline
|
|
756
|
+
run_text, run_class = self._get_run_badge()
|
|
757
|
+
yield Label(run_text, classes=f"status-badge {run_class}")
|
|
758
|
+
|
|
759
|
+
if self._row.remote_impl_display and self._row.remote_impl_display != "-":
|
|
760
|
+
with Container(classes="info-row"):
|
|
761
|
+
yield Label("Last remote impl", classes="info-label")
|
|
762
|
+
yield Label(
|
|
763
|
+
self._row.remote_impl_display, classes="info-value", markup=False
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
# COMMANDS SECTION (copy to clipboard)
|
|
767
|
+
# All items below use uniform orange labels that copy when clicked
|
|
768
|
+
yield Label("COMMANDS (copy)", classes="section-header")
|
|
769
|
+
|
|
770
|
+
# PR checkout command (if PR exists)
|
|
771
|
+
if self._row.pr_number is not None:
|
|
772
|
+
pr_checkout_cmd = f"erk pr co {self._row.pr_number}"
|
|
773
|
+
with Container(classes="command-row"):
|
|
774
|
+
yield CopyableLabel(pr_checkout_cmd, pr_checkout_cmd)
|
|
775
|
+
|
|
776
|
+
# Implement commands
|
|
777
|
+
implement_cmd = f"erk implement {self._row.issue_number}"
|
|
778
|
+
with Container(classes="command-row"):
|
|
779
|
+
yield Label("[1]", classes="command-key")
|
|
780
|
+
yield CopyableLabel(implement_cmd, implement_cmd)
|
|
781
|
+
|
|
782
|
+
dangerous_cmd = f"erk implement {self._row.issue_number} --dangerous"
|
|
783
|
+
with Container(classes="command-row"):
|
|
784
|
+
yield Label("[2]", classes="command-key")
|
|
785
|
+
yield CopyableLabel(dangerous_cmd, dangerous_cmd)
|
|
786
|
+
|
|
787
|
+
yolo_cmd = f"erk implement {self._row.issue_number} --yolo"
|
|
788
|
+
with Container(classes="command-row"):
|
|
789
|
+
yield Label("[3]", classes="command-key")
|
|
790
|
+
yield CopyableLabel(yolo_cmd, yolo_cmd)
|
|
791
|
+
|
|
792
|
+
# Submit command
|
|
793
|
+
submit_cmd = f"erk plan submit {self._row.issue_number}"
|
|
794
|
+
with Container(classes="command-row"):
|
|
795
|
+
yield Label("[4]", classes="command-key")
|
|
796
|
+
yield CopyableLabel(submit_cmd, submit_cmd)
|
|
797
|
+
|
|
798
|
+
# Log entries (if any) - clickable timestamps
|
|
799
|
+
if self._row.log_entries:
|
|
800
|
+
with Vertical(classes="log-section"):
|
|
801
|
+
yield Label("Recent activity", classes="log-header")
|
|
802
|
+
for event_name, timestamp, comment_url in self._row.log_entries[:5]:
|
|
803
|
+
log_text = f"{timestamp} {event_name}"
|
|
804
|
+
if comment_url:
|
|
805
|
+
yield ClickableLink(log_text, comment_url, classes="log-entry")
|
|
806
|
+
else:
|
|
807
|
+
yield Label(log_text, classes="log-entry", markup=False)
|
|
808
|
+
|
|
809
|
+
yield Label("Ctrl+P: commands Esc: close", id="detail-footer")
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
class ErkDashApp(App):
|
|
813
|
+
"""Interactive TUI for erk dash command.
|
|
814
|
+
|
|
815
|
+
Displays plans in a navigable table with quick actions.
|
|
816
|
+
"""
|
|
817
|
+
|
|
818
|
+
CSS_PATH = Path(__file__).parent / "styles" / "dash.tcss"
|
|
819
|
+
COMMANDS = {MainListCommandProvider}
|
|
820
|
+
|
|
821
|
+
BINDINGS = [
|
|
822
|
+
Binding("q", "exit_app", "Quit"),
|
|
823
|
+
Binding("escape", "exit_app", "Quit"),
|
|
824
|
+
Binding("r", "refresh", "Refresh"),
|
|
825
|
+
Binding("?", "help", "Help"),
|
|
826
|
+
Binding("j", "cursor_down", "Down", show=False),
|
|
827
|
+
Binding("k", "cursor_up", "Up", show=False),
|
|
828
|
+
Binding("enter", "show_detail", "Detail"),
|
|
829
|
+
Binding("space", "show_detail", "Detail", show=False),
|
|
830
|
+
Binding("o", "open_row", "Open", show=False),
|
|
831
|
+
Binding("p", "open_pr", "Open PR"),
|
|
832
|
+
# NOTE: 'c' binding removed - close_plan now accessible via command palette
|
|
833
|
+
# in the plan detail modal (Enter → Ctrl+P → "Close Plan")
|
|
834
|
+
Binding("i", "show_implement", "Implement"),
|
|
835
|
+
Binding("slash", "start_filter", "Filter", key_display="/"),
|
|
836
|
+
Binding("s", "toggle_sort", "Sort"),
|
|
837
|
+
Binding("ctrl+p", "command_palette", "Commands"),
|
|
838
|
+
]
|
|
839
|
+
|
|
840
|
+
def get_system_commands(self, screen: Screen) -> Iterator[SystemCommand]:
|
|
841
|
+
"""Return system commands, hiding them when plan commands are available.
|
|
842
|
+
|
|
843
|
+
Hides Keys, Quit, Screenshot, Theme from command palette when on
|
|
844
|
+
PlanDetailScreen or when main list has a selected row, so only
|
|
845
|
+
plan-specific commands appear.
|
|
846
|
+
"""
|
|
847
|
+
if isinstance(screen, PlanDetailScreen):
|
|
848
|
+
return iter(())
|
|
849
|
+
# Hide system commands on main list when a row is selected
|
|
850
|
+
if self._get_selected_row() is not None:
|
|
851
|
+
return iter(())
|
|
852
|
+
yield from super().get_system_commands(screen)
|
|
853
|
+
|
|
854
|
+
def __init__(
|
|
855
|
+
self,
|
|
856
|
+
provider: PlanDataProvider,
|
|
857
|
+
filters: PlanFilters,
|
|
858
|
+
refresh_interval: float = 15.0,
|
|
859
|
+
initial_sort: SortState | None = None,
|
|
860
|
+
) -> None:
|
|
861
|
+
"""Initialize the dashboard app.
|
|
862
|
+
|
|
863
|
+
Args:
|
|
864
|
+
provider: Data provider for fetching plan data
|
|
865
|
+
filters: Filter options for the plan list
|
|
866
|
+
refresh_interval: Seconds between auto-refresh (0 to disable)
|
|
867
|
+
initial_sort: Initial sort state (defaults to by issue number)
|
|
868
|
+
"""
|
|
869
|
+
super().__init__()
|
|
870
|
+
self._provider = provider
|
|
871
|
+
self._plan_filters = filters
|
|
872
|
+
self._refresh_interval = refresh_interval
|
|
873
|
+
self._table: PlanDataTable | None = None
|
|
874
|
+
self._status_bar: StatusBar | None = None
|
|
875
|
+
self._filter_input: Input | None = None
|
|
876
|
+
self._all_rows: list[PlanRowData] = [] # Unfiltered data
|
|
877
|
+
self._rows: list[PlanRowData] = [] # Currently displayed (possibly filtered)
|
|
878
|
+
self._refresh_task: asyncio.Task | None = None
|
|
879
|
+
self._loading = True
|
|
880
|
+
self._filter_state = FilterState.initial()
|
|
881
|
+
self._sort_state = initial_sort if initial_sort is not None else SortState.initial()
|
|
882
|
+
self._activity_by_issue: dict[int, BranchActivity] = {}
|
|
883
|
+
self._activity_loading = False
|
|
884
|
+
|
|
885
|
+
def compose(self) -> ComposeResult:
|
|
886
|
+
"""Create the application layout."""
|
|
887
|
+
yield Header(show_clock=True)
|
|
888
|
+
with Container(id="main-container"):
|
|
889
|
+
yield Label("Loading plans...", id="loading-message")
|
|
890
|
+
yield PlanDataTable(self._plan_filters)
|
|
891
|
+
yield Input(id="filter-input", placeholder="Filter...", disabled=True)
|
|
892
|
+
yield StatusBar()
|
|
893
|
+
|
|
894
|
+
def on_mount(self) -> None:
|
|
895
|
+
"""Initialize app after mounting."""
|
|
896
|
+
self._table = self.query_one(PlanDataTable)
|
|
897
|
+
self._status_bar = self.query_one(StatusBar)
|
|
898
|
+
self._filter_input = self.query_one("#filter-input", Input)
|
|
899
|
+
self._loading_label = self.query_one("#loading-message", Label)
|
|
900
|
+
|
|
901
|
+
# Hide table until loaded
|
|
902
|
+
self._table.display = False
|
|
903
|
+
|
|
904
|
+
# Start data loading
|
|
905
|
+
self.run_worker(self._load_data(), exclusive=True)
|
|
906
|
+
|
|
907
|
+
# Start refresh timer if interval > 0
|
|
908
|
+
if self._refresh_interval > 0:
|
|
909
|
+
self._start_refresh_timer()
|
|
910
|
+
|
|
911
|
+
async def _load_data(self) -> None:
|
|
912
|
+
"""Load plan data in background thread."""
|
|
913
|
+
# Track fetch timing
|
|
914
|
+
start_time = time.monotonic()
|
|
915
|
+
|
|
916
|
+
# Run sync fetch in executor to avoid blocking
|
|
917
|
+
loop = asyncio.get_running_loop()
|
|
918
|
+
rows = await loop.run_in_executor(None, self._provider.fetch_plans, self._plan_filters)
|
|
919
|
+
|
|
920
|
+
# If sorting by activity, also fetch activity data
|
|
921
|
+
if self._sort_state.key == SortKey.BRANCH_ACTIVITY:
|
|
922
|
+
activity = await loop.run_in_executor(None, self._provider.fetch_branch_activity, rows)
|
|
923
|
+
self._activity_by_issue = activity
|
|
924
|
+
|
|
925
|
+
# Calculate duration
|
|
926
|
+
duration = time.monotonic() - start_time
|
|
927
|
+
update_time = datetime.now().strftime("%H:%M:%S")
|
|
928
|
+
|
|
929
|
+
# Update UI directly since we're in async context
|
|
930
|
+
self._update_table(rows, update_time, duration)
|
|
931
|
+
|
|
932
|
+
def _update_table(
|
|
933
|
+
self,
|
|
934
|
+
rows: list[PlanRowData],
|
|
935
|
+
update_time: str | None = None,
|
|
936
|
+
duration: float | None = None,
|
|
937
|
+
) -> None:
|
|
938
|
+
"""Update table with new data.
|
|
939
|
+
|
|
940
|
+
Args:
|
|
941
|
+
rows: Plan data to display
|
|
942
|
+
update_time: Formatted time of this update
|
|
943
|
+
duration: Duration of the fetch in seconds
|
|
944
|
+
"""
|
|
945
|
+
self._all_rows = rows
|
|
946
|
+
self._loading = False
|
|
947
|
+
|
|
948
|
+
# Apply filter and sort
|
|
949
|
+
self._rows = self._apply_filter_and_sort(rows)
|
|
950
|
+
|
|
951
|
+
if self._table is not None:
|
|
952
|
+
self._loading_label.display = False
|
|
953
|
+
self._table.display = True
|
|
954
|
+
self._table.populate(self._rows)
|
|
955
|
+
|
|
956
|
+
if self._status_bar is not None:
|
|
957
|
+
self._status_bar.set_plan_count(len(self._rows))
|
|
958
|
+
self._status_bar.set_sort_mode(self._sort_state.display_label)
|
|
959
|
+
if update_time is not None:
|
|
960
|
+
self._status_bar.set_last_update(update_time, duration)
|
|
961
|
+
|
|
962
|
+
def _apply_filter_and_sort(self, rows: list[PlanRowData]) -> list[PlanRowData]:
|
|
963
|
+
"""Apply current filter and sort to rows.
|
|
964
|
+
|
|
965
|
+
Args:
|
|
966
|
+
rows: Raw rows to process
|
|
967
|
+
|
|
968
|
+
Returns:
|
|
969
|
+
Filtered and sorted rows
|
|
970
|
+
"""
|
|
971
|
+
# Apply filter first
|
|
972
|
+
if self._filter_state.mode == FilterMode.ACTIVE and self._filter_state.query:
|
|
973
|
+
filtered = filter_plans(rows, self._filter_state.query)
|
|
974
|
+
else:
|
|
975
|
+
filtered = rows
|
|
976
|
+
|
|
977
|
+
# Apply sort
|
|
978
|
+
return sort_plans(
|
|
979
|
+
filtered,
|
|
980
|
+
self._sort_state.key,
|
|
981
|
+
self._activity_by_issue if self._sort_state.key == SortKey.BRANCH_ACTIVITY else None,
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
def _start_refresh_timer(self) -> None:
|
|
985
|
+
"""Start the auto-refresh countdown timer."""
|
|
986
|
+
self._seconds_remaining = int(self._refresh_interval)
|
|
987
|
+
self.set_interval(1.0, self._tick_countdown)
|
|
988
|
+
|
|
989
|
+
def _tick_countdown(self) -> None:
|
|
990
|
+
"""Handle countdown timer tick."""
|
|
991
|
+
if self._status_bar is not None:
|
|
992
|
+
self._status_bar.set_refresh_countdown(self._seconds_remaining)
|
|
993
|
+
|
|
994
|
+
self._seconds_remaining -= 1
|
|
995
|
+
if self._seconds_remaining <= 0:
|
|
996
|
+
self.action_refresh()
|
|
997
|
+
self._seconds_remaining = int(self._refresh_interval)
|
|
998
|
+
|
|
999
|
+
def action_exit_app(self) -> None:
|
|
1000
|
+
"""Quit the application or handle progressive escape from filter mode."""
|
|
1001
|
+
if self._filter_state.mode == FilterMode.ACTIVE:
|
|
1002
|
+
self._filter_state = self._filter_state.handle_escape()
|
|
1003
|
+
if self._filter_state.mode == FilterMode.INACTIVE:
|
|
1004
|
+
# Fully exited filter mode
|
|
1005
|
+
self._exit_filter_mode()
|
|
1006
|
+
else:
|
|
1007
|
+
# Just cleared text, stay in filter mode
|
|
1008
|
+
if self._filter_input is not None:
|
|
1009
|
+
self._filter_input.value = ""
|
|
1010
|
+
# Reset to show all rows
|
|
1011
|
+
self._apply_filter()
|
|
1012
|
+
return
|
|
1013
|
+
self.exit()
|
|
1014
|
+
|
|
1015
|
+
def action_refresh(self) -> None:
|
|
1016
|
+
"""Refresh plan data and reset countdown timer."""
|
|
1017
|
+
# Reset countdown timer
|
|
1018
|
+
if self._refresh_interval > 0:
|
|
1019
|
+
self._seconds_remaining = int(self._refresh_interval)
|
|
1020
|
+
self.run_worker(self._load_data(), exclusive=True)
|
|
1021
|
+
|
|
1022
|
+
def action_help(self) -> None:
|
|
1023
|
+
"""Show help screen."""
|
|
1024
|
+
self.push_screen(HelpScreen())
|
|
1025
|
+
|
|
1026
|
+
def action_toggle_sort(self) -> None:
|
|
1027
|
+
"""Toggle between sort modes."""
|
|
1028
|
+
self._sort_state = self._sort_state.toggle()
|
|
1029
|
+
|
|
1030
|
+
# If switching to activity sort, load activity data in background
|
|
1031
|
+
if self._sort_state.key == SortKey.BRANCH_ACTIVITY and not self._activity_by_issue:
|
|
1032
|
+
self._load_activity_and_resort()
|
|
1033
|
+
else:
|
|
1034
|
+
# Re-sort with current data
|
|
1035
|
+
self._rows = self._apply_filter_and_sort(self._all_rows)
|
|
1036
|
+
if self._table is not None:
|
|
1037
|
+
self._table.populate(self._rows)
|
|
1038
|
+
|
|
1039
|
+
# Update status bar
|
|
1040
|
+
if self._status_bar is not None:
|
|
1041
|
+
self._status_bar.set_sort_mode(self._sort_state.display_label)
|
|
1042
|
+
|
|
1043
|
+
@work(thread=True)
|
|
1044
|
+
def _load_activity_and_resort(self) -> None:
|
|
1045
|
+
"""Load branch activity in background, then resort."""
|
|
1046
|
+
self._activity_loading = True
|
|
1047
|
+
|
|
1048
|
+
# Fetch activity data
|
|
1049
|
+
activity = self._provider.fetch_branch_activity(self._all_rows)
|
|
1050
|
+
|
|
1051
|
+
# Update on main thread
|
|
1052
|
+
self.app.call_from_thread(self._on_activity_loaded, activity)
|
|
1053
|
+
|
|
1054
|
+
def _on_activity_loaded(self, activity: dict[int, BranchActivity]) -> None:
|
|
1055
|
+
"""Handle activity data loaded - resort the table."""
|
|
1056
|
+
self._activity_by_issue = activity
|
|
1057
|
+
self._activity_loading = False
|
|
1058
|
+
|
|
1059
|
+
# Re-sort with new activity data
|
|
1060
|
+
self._rows = self._apply_filter_and_sort(self._all_rows)
|
|
1061
|
+
if self._table is not None:
|
|
1062
|
+
self._table.populate(self._rows)
|
|
1063
|
+
|
|
1064
|
+
def action_show_detail(self) -> None:
|
|
1065
|
+
"""Show plan detail modal for selected row."""
|
|
1066
|
+
row = self._get_selected_row()
|
|
1067
|
+
if row is None:
|
|
1068
|
+
return
|
|
1069
|
+
|
|
1070
|
+
# Create executor with injected dependencies
|
|
1071
|
+
executor = RealCommandExecutor(
|
|
1072
|
+
browser_launch=self._provider.browser.launch,
|
|
1073
|
+
clipboard_copy=self._provider.clipboard.copy,
|
|
1074
|
+
close_plan_fn=self._provider.close_plan,
|
|
1075
|
+
notify_fn=self.notify,
|
|
1076
|
+
refresh_fn=self.action_refresh,
|
|
1077
|
+
submit_to_queue_fn=self._provider.submit_to_queue,
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
self.push_screen(
|
|
1081
|
+
PlanDetailScreen(
|
|
1082
|
+
row,
|
|
1083
|
+
clipboard=self._provider.clipboard,
|
|
1084
|
+
browser=self._provider.browser,
|
|
1085
|
+
executor=executor,
|
|
1086
|
+
repo_root=self._provider.repo_root,
|
|
1087
|
+
)
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
def action_cursor_down(self) -> None:
|
|
1091
|
+
"""Move cursor down (vim j key)."""
|
|
1092
|
+
if self._table is not None:
|
|
1093
|
+
self._table.action_cursor_down()
|
|
1094
|
+
|
|
1095
|
+
def action_cursor_up(self) -> None:
|
|
1096
|
+
"""Move cursor up (vim k key)."""
|
|
1097
|
+
if self._table is not None:
|
|
1098
|
+
self._table.action_cursor_up()
|
|
1099
|
+
|
|
1100
|
+
def action_start_filter(self) -> None:
|
|
1101
|
+
"""Activate filter mode and focus the input."""
|
|
1102
|
+
if self._filter_input is None:
|
|
1103
|
+
return
|
|
1104
|
+
self._filter_state = self._filter_state.activate()
|
|
1105
|
+
self._filter_input.disabled = False
|
|
1106
|
+
self._filter_input.add_class("visible")
|
|
1107
|
+
self._filter_input.focus()
|
|
1108
|
+
|
|
1109
|
+
def _apply_filter(self) -> None:
|
|
1110
|
+
"""Apply current filter query to the table."""
|
|
1111
|
+
self._rows = self._apply_filter_and_sort(self._all_rows)
|
|
1112
|
+
|
|
1113
|
+
if self._table is not None:
|
|
1114
|
+
self._table.populate(self._rows)
|
|
1115
|
+
|
|
1116
|
+
if self._status_bar is not None:
|
|
1117
|
+
self._status_bar.set_plan_count(len(self._rows))
|
|
1118
|
+
|
|
1119
|
+
def _exit_filter_mode(self) -> None:
|
|
1120
|
+
"""Exit filter mode, restore all rows, and focus table."""
|
|
1121
|
+
if self._filter_input is not None:
|
|
1122
|
+
self._filter_input.value = ""
|
|
1123
|
+
self._filter_input.remove_class("visible")
|
|
1124
|
+
self._filter_input.disabled = True
|
|
1125
|
+
|
|
1126
|
+
self._filter_state = FilterState.initial()
|
|
1127
|
+
self._rows = self._apply_filter_and_sort(self._all_rows)
|
|
1128
|
+
|
|
1129
|
+
if self._table is not None:
|
|
1130
|
+
self._table.populate(self._rows)
|
|
1131
|
+
self._table.focus()
|
|
1132
|
+
|
|
1133
|
+
if self._status_bar is not None:
|
|
1134
|
+
self._status_bar.set_plan_count(len(self._rows))
|
|
1135
|
+
|
|
1136
|
+
def action_open_row(self) -> None:
|
|
1137
|
+
"""Open selected row - PR if available, otherwise issue."""
|
|
1138
|
+
row = self._get_selected_row()
|
|
1139
|
+
if row is None:
|
|
1140
|
+
return
|
|
1141
|
+
|
|
1142
|
+
if row.pr_url:
|
|
1143
|
+
self._provider.browser.launch(row.pr_url)
|
|
1144
|
+
if self._status_bar is not None:
|
|
1145
|
+
self._status_bar.set_message(f"Opened PR #{row.pr_number}")
|
|
1146
|
+
elif row.issue_url:
|
|
1147
|
+
self._provider.browser.launch(row.issue_url)
|
|
1148
|
+
if self._status_bar is not None:
|
|
1149
|
+
self._status_bar.set_message(f"Opened issue #{row.issue_number}")
|
|
1150
|
+
|
|
1151
|
+
def action_open_pr(self) -> None:
|
|
1152
|
+
"""Open selected PR in browser."""
|
|
1153
|
+
row = self._get_selected_row()
|
|
1154
|
+
if row is None:
|
|
1155
|
+
return
|
|
1156
|
+
|
|
1157
|
+
if row.pr_url:
|
|
1158
|
+
self._provider.browser.launch(row.pr_url)
|
|
1159
|
+
if self._status_bar is not None:
|
|
1160
|
+
self._status_bar.set_message(f"Opened PR #{row.pr_number}")
|
|
1161
|
+
else:
|
|
1162
|
+
if self._status_bar is not None:
|
|
1163
|
+
self._status_bar.set_message("No PR linked to this plan")
|
|
1164
|
+
|
|
1165
|
+
def action_show_implement(self) -> None:
|
|
1166
|
+
"""Show implement command in status bar."""
|
|
1167
|
+
row = self._get_selected_row()
|
|
1168
|
+
if row is None:
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
cmd = f"erk implement {row.issue_number}"
|
|
1172
|
+
if self._status_bar is not None:
|
|
1173
|
+
self._status_bar.set_message(f"Copy: {cmd}")
|
|
1174
|
+
|
|
1175
|
+
def action_copy_checkout(self) -> None:
|
|
1176
|
+
"""Copy checkout command for selected row."""
|
|
1177
|
+
row = self._get_selected_row()
|
|
1178
|
+
if row is None:
|
|
1179
|
+
return
|
|
1180
|
+
self._copy_checkout_command(row)
|
|
1181
|
+
|
|
1182
|
+
def action_close_plan(self) -> None:
|
|
1183
|
+
"""Close the selected plan and its linked PRs."""
|
|
1184
|
+
row = self._get_selected_row()
|
|
1185
|
+
if row is None:
|
|
1186
|
+
return
|
|
1187
|
+
|
|
1188
|
+
if row.issue_url is None:
|
|
1189
|
+
if self._status_bar is not None:
|
|
1190
|
+
self._status_bar.set_message("Cannot close plan: no issue URL")
|
|
1191
|
+
return
|
|
1192
|
+
|
|
1193
|
+
# Perform the close operation
|
|
1194
|
+
closed_prs = self._provider.close_plan(row.issue_number, row.issue_url)
|
|
1195
|
+
|
|
1196
|
+
# Show status message
|
|
1197
|
+
if self._status_bar is not None:
|
|
1198
|
+
if closed_prs:
|
|
1199
|
+
pr_list = ", ".join(f"#{pr}" for pr in closed_prs)
|
|
1200
|
+
self._status_bar.set_message(f"Closed plan #{row.issue_number} and PRs: {pr_list}")
|
|
1201
|
+
else:
|
|
1202
|
+
self._status_bar.set_message(f"Closed plan #{row.issue_number}")
|
|
1203
|
+
|
|
1204
|
+
# Refresh data to remove the closed plan from the list
|
|
1205
|
+
self.action_refresh()
|
|
1206
|
+
|
|
1207
|
+
def _copy_checkout_command(self, row: PlanRowData) -> None:
|
|
1208
|
+
"""Copy appropriate checkout command based on row state.
|
|
1209
|
+
|
|
1210
|
+
If worktree exists locally, copies 'erk co {worktree_name}'.
|
|
1211
|
+
If only PR available, copies 'erk pr co {pr_number}'.
|
|
1212
|
+
Shows status message with result.
|
|
1213
|
+
|
|
1214
|
+
Args:
|
|
1215
|
+
row: The plan row data to generate command from
|
|
1216
|
+
"""
|
|
1217
|
+
# Determine which command to use
|
|
1218
|
+
if row.exists_locally:
|
|
1219
|
+
# Local worktree exists - use branch checkout
|
|
1220
|
+
cmd = f"erk co {row.worktree_name}"
|
|
1221
|
+
elif row.pr_number is not None:
|
|
1222
|
+
# No local worktree but PR exists - use PR checkout
|
|
1223
|
+
cmd = f"erk pr co {row.pr_number}"
|
|
1224
|
+
else:
|
|
1225
|
+
# Neither available
|
|
1226
|
+
if self._status_bar is not None:
|
|
1227
|
+
self._status_bar.set_message("No worktree or PR available for checkout")
|
|
1228
|
+
return
|
|
1229
|
+
|
|
1230
|
+
# Copy to clipboard
|
|
1231
|
+
success = self._provider.clipboard.copy(cmd)
|
|
1232
|
+
|
|
1233
|
+
# Show status message
|
|
1234
|
+
if self._status_bar is not None:
|
|
1235
|
+
if success:
|
|
1236
|
+
self._status_bar.set_message(f"Copied: {cmd}")
|
|
1237
|
+
else:
|
|
1238
|
+
self._status_bar.set_message(f"Clipboard unavailable. Copy manually: {cmd}")
|
|
1239
|
+
|
|
1240
|
+
def _get_selected_row(self) -> PlanRowData | None:
|
|
1241
|
+
"""Get currently selected row data."""
|
|
1242
|
+
if self._table is None:
|
|
1243
|
+
return None
|
|
1244
|
+
return self._table.get_selected_row_data()
|
|
1245
|
+
|
|
1246
|
+
def execute_palette_command(self, command_id: str) -> None:
|
|
1247
|
+
"""Execute a command from the palette on the selected row.
|
|
1248
|
+
|
|
1249
|
+
Args:
|
|
1250
|
+
command_id: The ID of the command to execute
|
|
1251
|
+
"""
|
|
1252
|
+
row = self._get_selected_row()
|
|
1253
|
+
if row is None:
|
|
1254
|
+
return
|
|
1255
|
+
|
|
1256
|
+
if command_id == "open_browser":
|
|
1257
|
+
url = row.pr_url or row.issue_url
|
|
1258
|
+
if url:
|
|
1259
|
+
self._provider.browser.launch(url)
|
|
1260
|
+
self.notify(f"Opened {url}")
|
|
1261
|
+
|
|
1262
|
+
elif command_id == "open_issue":
|
|
1263
|
+
if row.issue_url:
|
|
1264
|
+
self._provider.browser.launch(row.issue_url)
|
|
1265
|
+
self.notify(f"Opened issue #{row.issue_number}")
|
|
1266
|
+
|
|
1267
|
+
elif command_id == "open_pr":
|
|
1268
|
+
if row.pr_url:
|
|
1269
|
+
self._provider.browser.launch(row.pr_url)
|
|
1270
|
+
self.notify(f"Opened PR #{row.pr_number}")
|
|
1271
|
+
|
|
1272
|
+
elif command_id == "open_run":
|
|
1273
|
+
if row.run_url:
|
|
1274
|
+
self._provider.browser.launch(row.run_url)
|
|
1275
|
+
self.notify(f"Opened run {row.run_id_display}")
|
|
1276
|
+
|
|
1277
|
+
elif command_id == "copy_checkout":
|
|
1278
|
+
cmd = f"erk co {row.worktree_name}"
|
|
1279
|
+
self._provider.clipboard.copy(cmd)
|
|
1280
|
+
self.notify(f"Copied: {cmd}")
|
|
1281
|
+
|
|
1282
|
+
elif command_id == "copy_pr_checkout":
|
|
1283
|
+
cmd = f"erk pr co {row.pr_number}"
|
|
1284
|
+
self._provider.clipboard.copy(cmd)
|
|
1285
|
+
self.notify(f"Copied: {cmd}")
|
|
1286
|
+
|
|
1287
|
+
elif command_id == "copy_implement":
|
|
1288
|
+
cmd = f"erk implement {row.issue_number}"
|
|
1289
|
+
self._provider.clipboard.copy(cmd)
|
|
1290
|
+
self.notify(f"Copied: {cmd}")
|
|
1291
|
+
|
|
1292
|
+
elif command_id == "copy_implement_dangerous":
|
|
1293
|
+
cmd = f"erk implement {row.issue_number} --dangerous"
|
|
1294
|
+
self._provider.clipboard.copy(cmd)
|
|
1295
|
+
self.notify(f"Copied: {cmd}")
|
|
1296
|
+
|
|
1297
|
+
elif command_id == "copy_implement_yolo":
|
|
1298
|
+
cmd = f"erk implement {row.issue_number} --yolo"
|
|
1299
|
+
self._provider.clipboard.copy(cmd)
|
|
1300
|
+
self.notify(f"Copied: {cmd}")
|
|
1301
|
+
|
|
1302
|
+
elif command_id == "copy_submit":
|
|
1303
|
+
cmd = f"erk plan submit {row.issue_number}"
|
|
1304
|
+
self._provider.clipboard.copy(cmd)
|
|
1305
|
+
self.notify(f"Copied: {cmd}")
|
|
1306
|
+
|
|
1307
|
+
elif command_id == "close_plan":
|
|
1308
|
+
if row.issue_url:
|
|
1309
|
+
closed_prs = self._provider.close_plan(row.issue_number, row.issue_url)
|
|
1310
|
+
if closed_prs:
|
|
1311
|
+
pr_list = ", ".join(f"#{pr}" for pr in closed_prs)
|
|
1312
|
+
self.notify(f"Closed plan #{row.issue_number} and PRs: {pr_list}")
|
|
1313
|
+
else:
|
|
1314
|
+
self.notify(f"Closed plan #{row.issue_number}")
|
|
1315
|
+
self.action_refresh()
|
|
1316
|
+
|
|
1317
|
+
elif command_id == "submit_to_queue":
|
|
1318
|
+
if row.issue_url:
|
|
1319
|
+
# Open detail modal to show streaming output
|
|
1320
|
+
executor = RealCommandExecutor(
|
|
1321
|
+
browser_launch=self._provider.browser.launch,
|
|
1322
|
+
clipboard_copy=self._provider.clipboard.copy,
|
|
1323
|
+
close_plan_fn=self._provider.close_plan,
|
|
1324
|
+
notify_fn=self.notify,
|
|
1325
|
+
refresh_fn=self.action_refresh,
|
|
1326
|
+
submit_to_queue_fn=self._provider.submit_to_queue,
|
|
1327
|
+
)
|
|
1328
|
+
detail_screen = PlanDetailScreen(
|
|
1329
|
+
row,
|
|
1330
|
+
clipboard=self._provider.clipboard,
|
|
1331
|
+
browser=self._provider.browser,
|
|
1332
|
+
executor=executor,
|
|
1333
|
+
repo_root=self._provider.repo_root,
|
|
1334
|
+
)
|
|
1335
|
+
self.push_screen(detail_screen)
|
|
1336
|
+
# Trigger the streaming command after screen is mounted
|
|
1337
|
+
detail_screen.call_after_refresh(
|
|
1338
|
+
lambda: detail_screen.run_streaming_command(
|
|
1339
|
+
["erk", "plan", "submit", str(row.issue_number)],
|
|
1340
|
+
cwd=self._provider.repo_root,
|
|
1341
|
+
title=f"Submitting Plan #{row.issue_number}",
|
|
1342
|
+
)
|
|
1343
|
+
)
|
|
1344
|
+
|
|
1345
|
+
@on(PlanDataTable.RowSelected)
|
|
1346
|
+
def on_row_selected(self, event: PlanDataTable.RowSelected) -> None:
|
|
1347
|
+
"""Handle Enter/double-click on row - show plan details."""
|
|
1348
|
+
self.action_show_detail()
|
|
1349
|
+
|
|
1350
|
+
@on(Input.Changed, "#filter-input")
|
|
1351
|
+
def on_filter_changed(self, event: Input.Changed) -> None:
|
|
1352
|
+
"""Handle filter input text changes."""
|
|
1353
|
+
self._filter_state = self._filter_state.with_query(event.value)
|
|
1354
|
+
self._apply_filter()
|
|
1355
|
+
|
|
1356
|
+
@on(Input.Submitted, "#filter-input")
|
|
1357
|
+
def on_filter_submitted(self, event: Input.Submitted) -> None:
|
|
1358
|
+
"""Handle Enter in filter input - return focus to table."""
|
|
1359
|
+
if self._table is not None:
|
|
1360
|
+
self._table.focus()
|
|
1361
|
+
|
|
1362
|
+
@on(PlanDataTable.PlanClicked)
|
|
1363
|
+
def on_plan_clicked(self, event: PlanDataTable.PlanClicked) -> None:
|
|
1364
|
+
"""Handle click on plan cell - open issue in browser."""
|
|
1365
|
+
if event.row_index < len(self._rows):
|
|
1366
|
+
row = self._rows[event.row_index]
|
|
1367
|
+
if row.issue_url:
|
|
1368
|
+
self._provider.browser.launch(row.issue_url)
|
|
1369
|
+
if self._status_bar is not None:
|
|
1370
|
+
self._status_bar.set_message(f"Opened issue #{row.issue_number}")
|
|
1371
|
+
|
|
1372
|
+
@on(PlanDataTable.PrClicked)
|
|
1373
|
+
def on_pr_clicked(self, event: PlanDataTable.PrClicked) -> None:
|
|
1374
|
+
"""Handle click on pr cell - open PR in browser."""
|
|
1375
|
+
if event.row_index < len(self._rows):
|
|
1376
|
+
row = self._rows[event.row_index]
|
|
1377
|
+
if row.pr_url:
|
|
1378
|
+
self._provider.browser.launch(row.pr_url)
|
|
1379
|
+
if self._status_bar is not None:
|
|
1380
|
+
self._status_bar.set_message(f"Opened PR #{row.pr_number}")
|
|
1381
|
+
|
|
1382
|
+
@on(PlanDataTable.LocalWtClicked)
|
|
1383
|
+
def on_local_wt_clicked(self, event: PlanDataTable.LocalWtClicked) -> None:
|
|
1384
|
+
"""Handle click on local-wt cell - copy worktree name to clipboard."""
|
|
1385
|
+
if event.row_index < len(self._rows):
|
|
1386
|
+
row = self._rows[event.row_index]
|
|
1387
|
+
if row.worktree_name:
|
|
1388
|
+
success = self._provider.clipboard.copy(row.worktree_name)
|
|
1389
|
+
if success:
|
|
1390
|
+
self.notify(f"Copied: {row.worktree_name}", timeout=2)
|
|
1391
|
+
else:
|
|
1392
|
+
self.notify("Clipboard unavailable", severity="error", timeout=2)
|
|
1393
|
+
|
|
1394
|
+
@on(PlanDataTable.RunIdClicked)
|
|
1395
|
+
def on_run_id_clicked(self, event: PlanDataTable.RunIdClicked) -> None:
|
|
1396
|
+
"""Handle click on run-id cell - open run in browser."""
|
|
1397
|
+
if event.row_index < len(self._rows):
|
|
1398
|
+
row = self._rows[event.row_index]
|
|
1399
|
+
if row.run_url:
|
|
1400
|
+
self._provider.browser.launch(row.run_url)
|
|
1401
|
+
if self._status_bar is not None:
|
|
1402
|
+
# Extract run ID from URL to avoid Rich markup in status bar
|
|
1403
|
+
run_id = row.run_url.rsplit("/", 1)[-1]
|
|
1404
|
+
self._status_bar.set_message(f"Opened run {run_id}")
|