project-manager-tui 0.1.0__tar.gz
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.
- project_manager_tui-0.1.0/PKG-INFO +7 -0
- project_manager_tui-0.1.0/pyproject.toml +19 -0
- project_manager_tui-0.1.0/setup.cfg +4 -0
- project_manager_tui-0.1.0/src/project_manager_tui/__init__.py +1 -0
- project_manager_tui-0.1.0/src/project_manager_tui/cli.py +21 -0
- project_manager_tui-0.1.0/src/project_manager_tui/config.py +56 -0
- project_manager_tui-0.1.0/src/project_manager_tui/core.py +418 -0
- project_manager_tui-0.1.0/src/project_manager_tui/tui.py +480 -0
- project_manager_tui-0.1.0/src/project_manager_tui.egg-info/PKG-INFO +7 -0
- project_manager_tui-0.1.0/src/project_manager_tui.egg-info/SOURCES.txt +12 -0
- project_manager_tui-0.1.0/src/project_manager_tui.egg-info/dependency_links.txt +1 -0
- project_manager_tui-0.1.0/src/project_manager_tui.egg-info/entry_points.txt +2 -0
- project_manager_tui-0.1.0/src/project_manager_tui.egg-info/requires.txt +4 -0
- project_manager_tui-0.1.0/src/project_manager_tui.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "project-manager-tui"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Rich-based TUI for managing projects/tasks with git worktrees"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"rich>=13.0",
|
|
12
|
+
"tomli>=2.0; python_version < '3.11'",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
pm-tui = "project_manager_tui.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Entry point for project-manager-tui."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from project_manager_tui.config import load_config
|
|
7
|
+
from project_manager_tui.tui import main as tui_main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
parser = argparse.ArgumentParser(description="Project management TUI")
|
|
12
|
+
parser.add_argument("--config", type=Path, default=None,
|
|
13
|
+
help="Path to config file (default: ~/.project-manager-tui.toml)")
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
|
|
16
|
+
config = load_config(args.config)
|
|
17
|
+
tui_main(config)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Configuration loading for project-manager-tui."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tomllib
|
|
10
|
+
except ModuleNotFoundError:
|
|
11
|
+
import tomli as tomllib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CONFIG_PATH = Path.home() / ".project-manager-tui.toml"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Config:
|
|
19
|
+
agent_worktree_script: Path
|
|
20
|
+
projects_dir: Path
|
|
21
|
+
projects_filename: str = "Projects.md"
|
|
22
|
+
editor: str = field(default_factory=lambda: os.environ.get("EDITOR", "vim"))
|
|
23
|
+
git_repo_dir: Path = field(default_factory=lambda: Path("."))
|
|
24
|
+
sync_interval: int = 300
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def projects_file(self) -> Path:
|
|
28
|
+
return self.projects_dir / self.projects_filename
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_config(config_path: Path | None = None) -> Config:
|
|
32
|
+
"""Load config from TOML file."""
|
|
33
|
+
path = config_path or CONFIG_PATH
|
|
34
|
+
if not path.exists():
|
|
35
|
+
print(f"Error: config file not found: {path}")
|
|
36
|
+
print(f"Create {CONFIG_PATH} with at least:")
|
|
37
|
+
print(' agent_worktree_script = "/path/to/agent-worktree.sh"')
|
|
38
|
+
print(' projects_dir = "/path/to/projects"')
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
|
|
41
|
+
with open(path, "rb") as f:
|
|
42
|
+
data = tomllib.load(f)
|
|
43
|
+
|
|
44
|
+
missing = [k for k in ("agent_worktree_script", "projects_dir") if k not in data]
|
|
45
|
+
if missing:
|
|
46
|
+
print(f"Error: missing required config keys: {', '.join(missing)}")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
return Config(
|
|
50
|
+
agent_worktree_script=Path(data["agent_worktree_script"]),
|
|
51
|
+
projects_dir=Path(data["projects_dir"]),
|
|
52
|
+
projects_filename=data.get("projects_filename", "Projects.md"),
|
|
53
|
+
editor=data.get("editor", os.environ.get("EDITOR", "vim")),
|
|
54
|
+
git_repo_dir=Path(data.get("git_repo_dir", ".")),
|
|
55
|
+
sync_interval=data.get("sync_interval", 300),
|
|
56
|
+
)
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Non-TUI logic: parsing, task operations, worktree commands."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from project_manager_tui.config import Config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_section_projects(projects_file, section_name):
|
|
12
|
+
"""Parse Projects.md and return list of slugs for a given section."""
|
|
13
|
+
if not projects_file.exists():
|
|
14
|
+
return []
|
|
15
|
+
|
|
16
|
+
content = projects_file.read_text()
|
|
17
|
+
section_match = re.search(
|
|
18
|
+
rf"## {section_name}\n(.*?)(?=\n## |\Z)", content, re.DOTALL
|
|
19
|
+
)
|
|
20
|
+
if not section_match:
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
section_content = section_match.group(1)
|
|
24
|
+
projects = []
|
|
25
|
+
for line in section_content.strip().split("\n"):
|
|
26
|
+
slug = line.strip()
|
|
27
|
+
if slug:
|
|
28
|
+
projects.append(slug)
|
|
29
|
+
return projects
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_all_projects_by_section(projects_file):
|
|
33
|
+
"""Get all projects grouped by section."""
|
|
34
|
+
sections = ["Active", "Future", "Archived"]
|
|
35
|
+
result = {}
|
|
36
|
+
for section in sections:
|
|
37
|
+
result[section] = parse_section_projects(projects_file, section)
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_worktree_list_output(config):
|
|
42
|
+
"""Run agent-worktree.sh list once and return the output."""
|
|
43
|
+
agent_worktree = config.agent_worktree_script
|
|
44
|
+
if not agent_worktree.exists():
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
[str(agent_worktree), "list"],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
)
|
|
53
|
+
if result.returncode != 0:
|
|
54
|
+
return ""
|
|
55
|
+
except Exception:
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
return result.stdout
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_worktree_statuses(worktree_output):
|
|
62
|
+
"""Parse worktree list output to get status per worktree."""
|
|
63
|
+
statuses = {}
|
|
64
|
+
for line in worktree_output.strip().split("\n"):
|
|
65
|
+
if not line:
|
|
66
|
+
continue
|
|
67
|
+
if line.startswith(" "):
|
|
68
|
+
rest = line.lstrip()
|
|
69
|
+
name_part = rest.split(":")[0].split()[0] if rest else ""
|
|
70
|
+
if name_part:
|
|
71
|
+
statuses[name_part] = ""
|
|
72
|
+
else:
|
|
73
|
+
parts = line.split()
|
|
74
|
+
if len(parts) >= 2:
|
|
75
|
+
status = parts[0]
|
|
76
|
+
name_part = parts[1].rstrip(":")
|
|
77
|
+
statuses[name_part] = status
|
|
78
|
+
return statuses
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_active_task_nums(config, project_slug, worktree_output=None):
|
|
82
|
+
"""Get set of active task numbers for a project from agent-worktree.sh list."""
|
|
83
|
+
if worktree_output is None:
|
|
84
|
+
worktree_output = get_worktree_list_output(config)
|
|
85
|
+
|
|
86
|
+
active_tasks = set()
|
|
87
|
+
for match in re.finditer(rf"\b{re.escape(project_slug)}-(\d+)\b", worktree_output):
|
|
88
|
+
active_tasks.add(int(match.group(1)))
|
|
89
|
+
|
|
90
|
+
return active_tasks
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_tasks(config, project_md_path, project_slug, worktree_output=None):
|
|
94
|
+
"""Parse Project.md and return dict of number -> task info."""
|
|
95
|
+
if not project_md_path.exists():
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
content = project_md_path.read_text()
|
|
99
|
+
active_tasks = get_active_task_nums(config, project_slug, worktree_output)
|
|
100
|
+
worktree_statuses = parse_worktree_statuses(worktree_output or "")
|
|
101
|
+
tasks = {}
|
|
102
|
+
|
|
103
|
+
for match in re.finditer(r"^(\d+)\.\s+\[([^\]])\]\s*(.*)$", content, re.MULTILINE):
|
|
104
|
+
num = int(match.group(1))
|
|
105
|
+
state_char = match.group(2).lower()
|
|
106
|
+
done = state_char == "x"
|
|
107
|
+
snoozed = state_char in ("s", "z")
|
|
108
|
+
verified = state_char == "v"
|
|
109
|
+
desc = match.group(3).strip()
|
|
110
|
+
in_worktree = num in active_tasks
|
|
111
|
+
|
|
112
|
+
worktree_name = f"{project_slug}-{num}"
|
|
113
|
+
worktree_status = worktree_statuses.get(worktree_name, "")
|
|
114
|
+
|
|
115
|
+
depends_on = []
|
|
116
|
+
dep_match = re.search(r"\s*>(\d+(?:,\d+)*)\s*$", desc)
|
|
117
|
+
if dep_match:
|
|
118
|
+
depends_on = [int(d) for d in dep_match.group(1).split(",")]
|
|
119
|
+
desc = desc[:dep_match.start()].strip()
|
|
120
|
+
|
|
121
|
+
line_num = content[:match.start()].count('\n') + 1
|
|
122
|
+
tasks[num] = {"desc": desc, "done": done, "snoozed": snoozed, "verified": verified,
|
|
123
|
+
"in_worktree": in_worktree, "worktree_status": worktree_status,
|
|
124
|
+
"depends_on": depends_on, "line_num": line_num, "state_char": state_char}
|
|
125
|
+
|
|
126
|
+
for num, task in tasks.items():
|
|
127
|
+
deps_met = all(tasks.get(d, {}).get("state_char", "") in ("v")
|
|
128
|
+
for d in task["depends_on"])
|
|
129
|
+
task["deps_met"] = deps_met
|
|
130
|
+
task["available"] = not task["done"] and not task["in_worktree"] and deps_met
|
|
131
|
+
|
|
132
|
+
return tasks
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def add_task_to_project(project_md_path):
|
|
136
|
+
"""Add a new empty task to the end of the task list in Project.md."""
|
|
137
|
+
if not project_md_path.exists():
|
|
138
|
+
return None, None
|
|
139
|
+
|
|
140
|
+
content = project_md_path.read_text()
|
|
141
|
+
|
|
142
|
+
existing_nums = []
|
|
143
|
+
for match in re.finditer(r"^(\d+)\.\s+\[", content, re.MULTILINE):
|
|
144
|
+
existing_nums.append(int(match.group(1)))
|
|
145
|
+
|
|
146
|
+
new_num = max(existing_nums) + 1 if existing_nums else 1
|
|
147
|
+
|
|
148
|
+
last_task_end = 0
|
|
149
|
+
for match in re.finditer(r"^(\d+)\.\s+\[[^\]]\].*$", content, re.MULTILINE):
|
|
150
|
+
last_task_end = match.end()
|
|
151
|
+
|
|
152
|
+
if last_task_end == 0:
|
|
153
|
+
tasks_match = re.search(r"^## Tasks\s*$", content, re.MULTILINE)
|
|
154
|
+
if tasks_match:
|
|
155
|
+
last_task_end = tasks_match.end()
|
|
156
|
+
else:
|
|
157
|
+
content = content.rstrip() + "\n\n## Tasks\n"
|
|
158
|
+
last_task_end = len(content)
|
|
159
|
+
|
|
160
|
+
new_task_line = f"\n{new_num}. [ ] "
|
|
161
|
+
new_content = content[:last_task_end] + new_task_line + content[last_task_end:]
|
|
162
|
+
|
|
163
|
+
project_md_path.write_text(new_content)
|
|
164
|
+
|
|
165
|
+
line_num = new_content[:last_task_end + 1].count('\n') + 1
|
|
166
|
+
|
|
167
|
+
return new_num, line_num
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def move_project_to_section(projects_file, project_slug, target_section):
|
|
171
|
+
"""Move a project from one section to another in Projects.md."""
|
|
172
|
+
content = projects_file.read_text()
|
|
173
|
+
|
|
174
|
+
content = re.sub(rf"^{re.escape(project_slug)}\s*\n?",
|
|
175
|
+
"", content, flags=re.MULTILINE)
|
|
176
|
+
|
|
177
|
+
section_match = re.search(rf"(## {target_section})(\n|$)", content)
|
|
178
|
+
if section_match:
|
|
179
|
+
insert_pos = section_match.end()
|
|
180
|
+
prefix = "" if section_match.group(2) == "\n" else "\n"
|
|
181
|
+
content = content[:insert_pos] + prefix + f"{project_slug}\n" + content[insert_pos:]
|
|
182
|
+
else:
|
|
183
|
+
content = content.rstrip() + f"\n\n## {target_section}\n{project_slug}\n"
|
|
184
|
+
|
|
185
|
+
projects_file.write_text(content)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def snooze_task(project_md_path, task_num):
|
|
189
|
+
"""Snooze a task by changing [ ] to [z] in Project.md."""
|
|
190
|
+
if not project_md_path.exists():
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
content = project_md_path.read_text()
|
|
194
|
+
new_content = re.sub(
|
|
195
|
+
rf"^({task_num}\.\s+)\[ \]",
|
|
196
|
+
r"\1[z]",
|
|
197
|
+
content,
|
|
198
|
+
flags=re.MULTILINE
|
|
199
|
+
)
|
|
200
|
+
if new_content != content:
|
|
201
|
+
project_md_path.write_text(new_content)
|
|
202
|
+
return True
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def unsnooze_task(project_md_path, task_num):
|
|
207
|
+
"""Unsnooze a task by changing [s] or [z] to [ ] in Project.md."""
|
|
208
|
+
if not project_md_path.exists():
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
content = project_md_path.read_text()
|
|
212
|
+
new_content = re.sub(
|
|
213
|
+
rf"^({task_num}\.\s+)\[[sSzZ]\]",
|
|
214
|
+
r"\1[ ]",
|
|
215
|
+
content,
|
|
216
|
+
flags=re.MULTILINE
|
|
217
|
+
)
|
|
218
|
+
if new_content != content:
|
|
219
|
+
project_md_path.write_text(new_content)
|
|
220
|
+
return True
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def mark_task_done(project_md_path, task_num):
|
|
225
|
+
"""Mark a task as done in Project.md by changing [ ] to [x]."""
|
|
226
|
+
if not project_md_path.exists():
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
content = project_md_path.read_text()
|
|
230
|
+
pattern = rf"^({task_num}\.\s+)\[ \]"
|
|
231
|
+
new_content, count = re.subn(pattern, r"\1[x]", content, flags=re.MULTILINE)
|
|
232
|
+
|
|
233
|
+
if count > 0:
|
|
234
|
+
project_md_path.write_text(new_content)
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def mark_task_verified(project_md_path, task_num):
|
|
240
|
+
"""Mark a task as verified in Project.md by changing any state to [v]."""
|
|
241
|
+
if not project_md_path.exists():
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
content = project_md_path.read_text()
|
|
245
|
+
pattern = rf"^({task_num}\.\s+)\[[^\]]\]"
|
|
246
|
+
new_content, count = re.subn(pattern, r"\1[v]", content, flags=re.MULTILINE)
|
|
247
|
+
|
|
248
|
+
if count > 0:
|
|
249
|
+
project_md_path.write_text(new_content)
|
|
250
|
+
return True
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def mark_task_released(project_md_path, task_num):
|
|
255
|
+
"""Mark a task as released in Project.md by changing state to [r]."""
|
|
256
|
+
if not project_md_path.exists():
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
content = project_md_path.read_text()
|
|
260
|
+
pattern = rf"^({task_num}\.\s+)\[[^\]]\]"
|
|
261
|
+
new_content, count = re.subn(pattern, r"\1[r]", content, flags=re.MULTILINE)
|
|
262
|
+
|
|
263
|
+
if count > 0:
|
|
264
|
+
project_md_path.write_text(new_content)
|
|
265
|
+
return True
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def delete_task(config, project_slug, task_num):
|
|
270
|
+
"""Delete a task - remove worktree, Task.md directory, and task line from Project.md."""
|
|
271
|
+
project_dir = config.projects_dir / project_slug
|
|
272
|
+
project_md = project_dir / "Project.md"
|
|
273
|
+
|
|
274
|
+
worktree_name = f"{project_slug}-{task_num}"
|
|
275
|
+
agent_worktree = config.agent_worktree_script
|
|
276
|
+
|
|
277
|
+
if agent_worktree.exists():
|
|
278
|
+
worktree_output = get_worktree_list_output(config)
|
|
279
|
+
if worktree_name in worktree_output:
|
|
280
|
+
result = subprocess.run([str(agent_worktree), "rm", worktree_name])
|
|
281
|
+
if result.returncode != 0:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
task_dir = project_dir / "tasks" / str(task_num)
|
|
285
|
+
if task_dir.exists():
|
|
286
|
+
shutil.rmtree(task_dir)
|
|
287
|
+
|
|
288
|
+
if project_md.exists():
|
|
289
|
+
content = project_md.read_text()
|
|
290
|
+
new_content = re.sub(
|
|
291
|
+
rf"^{task_num}\.\s+\[[^\]]\].*\n?",
|
|
292
|
+
"",
|
|
293
|
+
content,
|
|
294
|
+
flags=re.MULTILINE
|
|
295
|
+
)
|
|
296
|
+
project_md.write_text(new_content)
|
|
297
|
+
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def ensure_task_md(config, project_md_path, project_slug, task_num, task_desc):
|
|
302
|
+
"""Ensure Task.md exists for a task, creating it if needed. Returns the path."""
|
|
303
|
+
task_md = config.projects_dir / project_slug / "tasks" / str(task_num) / "Task.md"
|
|
304
|
+
if not task_md.exists():
|
|
305
|
+
task_md.parent.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
task_md.write_text(
|
|
307
|
+
f"# {task_desc}\n\n<!-- Read {project_md_path} for project context -->\n")
|
|
308
|
+
return task_md
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def activate_task(config, project_slug, task_num, task_desc):
|
|
312
|
+
"""Activate a task - create directory and worktree."""
|
|
313
|
+
project_dir = config.projects_dir / project_slug
|
|
314
|
+
project_md = project_dir / "Project.md"
|
|
315
|
+
|
|
316
|
+
task_md = ensure_task_md(config, project_md, project_slug, task_num, task_desc)
|
|
317
|
+
print(f"Created: {task_md.parent}")
|
|
318
|
+
|
|
319
|
+
worktree_name = f"{project_slug}-{task_num}"
|
|
320
|
+
agent_worktree = config.agent_worktree_script
|
|
321
|
+
|
|
322
|
+
if not agent_worktree.exists():
|
|
323
|
+
print(f"Warning: {agent_worktree} not found, skipping worktree creation")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
print(f"Running: agent-worktree.sh add {worktree_name}")
|
|
327
|
+
subprocess.run([str(agent_worktree), "add", worktree_name])
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def get_current_branch(config):
|
|
331
|
+
"""Get the current git branch name."""
|
|
332
|
+
try:
|
|
333
|
+
result = subprocess.run(
|
|
334
|
+
["git", "branch", "--show-current"],
|
|
335
|
+
capture_output=True,
|
|
336
|
+
text=True,
|
|
337
|
+
cwd=str(config.git_repo_dir)
|
|
338
|
+
)
|
|
339
|
+
if result.returncode == 0:
|
|
340
|
+
return result.stdout.strip()
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def get_all_active_tasks(config):
|
|
347
|
+
"""Get all tasks from active projects.
|
|
348
|
+
|
|
349
|
+
Returns list of dicts with: project_slug, task_num, desc, state, task_md_path
|
|
350
|
+
"""
|
|
351
|
+
active_projects = parse_section_projects(config.projects_file, "Active")
|
|
352
|
+
if not active_projects:
|
|
353
|
+
return []
|
|
354
|
+
|
|
355
|
+
worktree_output = get_worktree_list_output(config)
|
|
356
|
+
|
|
357
|
+
all_tasks = []
|
|
358
|
+
for project_slug in active_projects:
|
|
359
|
+
project_dir = config.projects_dir / project_slug
|
|
360
|
+
project_md = project_dir / "Project.md"
|
|
361
|
+
if not project_md.exists():
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
tasks = parse_tasks(config, project_md, project_slug, worktree_output)
|
|
365
|
+
for task_num, task_info in sorted(tasks.items()):
|
|
366
|
+
state = task_info.get("state_char", " ")
|
|
367
|
+
worktree_status = task_info.get("worktree_status", "")
|
|
368
|
+
task_md_path = project_dir / "tasks" / str(task_num) / "Task.md"
|
|
369
|
+
|
|
370
|
+
all_tasks.append({
|
|
371
|
+
"project_slug": project_slug,
|
|
372
|
+
"task_num": task_num,
|
|
373
|
+
"desc": task_info["desc"],
|
|
374
|
+
"state": state,
|
|
375
|
+
"worktree_status": worktree_status,
|
|
376
|
+
"task_md_path": task_md_path,
|
|
377
|
+
"project_md_path": project_md,
|
|
378
|
+
"line_num": task_info.get("line_num", 1),
|
|
379
|
+
"done": task_info["done"],
|
|
380
|
+
"snoozed": task_info.get("snoozed", False),
|
|
381
|
+
"verified": task_info.get("verified", False),
|
|
382
|
+
"in_worktree": task_info["in_worktree"],
|
|
383
|
+
"depends_on": task_info.get("depends_on", []),
|
|
384
|
+
"deps_met": task_info.get("deps_met", True),
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
return all_tasks
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def get_project_line_numbers(projects_file):
|
|
391
|
+
"""Get line numbers for each project in Projects.md."""
|
|
392
|
+
if not projects_file.exists():
|
|
393
|
+
return {}
|
|
394
|
+
|
|
395
|
+
content = projects_file.read_text()
|
|
396
|
+
line_nums = {}
|
|
397
|
+
for i, line in enumerate(content.split("\n"), start=1):
|
|
398
|
+
slug = line.strip()
|
|
399
|
+
if slug and not slug.startswith("#"):
|
|
400
|
+
line_nums[slug] = i
|
|
401
|
+
return line_nums
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def get_project_list(config, current_project=None):
|
|
405
|
+
"""Get flat list of projects with section info for display."""
|
|
406
|
+
projects_by_section = get_all_projects_by_section(config.projects_file)
|
|
407
|
+
line_nums = get_project_line_numbers(config.projects_file)
|
|
408
|
+
|
|
409
|
+
result = []
|
|
410
|
+
for section in ["Active", "Future", "Archived"]:
|
|
411
|
+
for slug in projects_by_section.get(section, []):
|
|
412
|
+
result.append({
|
|
413
|
+
"slug": slug,
|
|
414
|
+
"section": section,
|
|
415
|
+
"is_current": slug == current_project,
|
|
416
|
+
"line_num": line_nums.get(slug, 1),
|
|
417
|
+
})
|
|
418
|
+
return result
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Rich TUI display + main loop."""
|
|
2
|
+
|
|
3
|
+
import select
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import termios
|
|
7
|
+
import time
|
|
8
|
+
import tty
|
|
9
|
+
|
|
10
|
+
from rich.console import Console, Group
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
|
|
16
|
+
from project_manager_tui.config import Config
|
|
17
|
+
from project_manager_tui.core import (
|
|
18
|
+
activate_task,
|
|
19
|
+
add_task_to_project,
|
|
20
|
+
delete_task,
|
|
21
|
+
ensure_task_md,
|
|
22
|
+
get_all_active_tasks,
|
|
23
|
+
get_current_branch,
|
|
24
|
+
get_project_list,
|
|
25
|
+
get_worktree_list_output,
|
|
26
|
+
mark_task_released,
|
|
27
|
+
mark_task_verified,
|
|
28
|
+
move_project_to_section,
|
|
29
|
+
parse_tasks,
|
|
30
|
+
snooze_task,
|
|
31
|
+
unsnooze_task,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_key(timeout=None):
|
|
36
|
+
"""Read a single keypress from stdin with optional timeout."""
|
|
37
|
+
fd = sys.stdin.fileno()
|
|
38
|
+
old_settings = termios.tcgetattr(fd)
|
|
39
|
+
try:
|
|
40
|
+
tty.setraw(fd)
|
|
41
|
+
if timeout is not None:
|
|
42
|
+
ready, _, _ = select.select([sys.stdin], [], [], timeout)
|
|
43
|
+
if not ready:
|
|
44
|
+
return None
|
|
45
|
+
ch = sys.stdin.read(1)
|
|
46
|
+
finally:
|
|
47
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
48
|
+
return ch
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_display(tasks, selected_idx, show_help=False, show_verified=False, show_snoozed=False, show_blocked=False, show_working=False, current_branch=None):
|
|
52
|
+
"""Build the display renderable."""
|
|
53
|
+
table = Table(show_header=False, box=None, padding=(0, 0), pad_edge=False)
|
|
54
|
+
table.add_column(width=5, no_wrap=True)
|
|
55
|
+
table.add_column(width=24, no_wrap=True, overflow="ellipsis")
|
|
56
|
+
table.add_column(no_wrap=True, overflow="ellipsis")
|
|
57
|
+
|
|
58
|
+
for i, task in enumerate(tasks):
|
|
59
|
+
wt_status = task.get("worktree_status", "")
|
|
60
|
+
state_display = f"[{task['state']}]{wt_status}"
|
|
61
|
+
|
|
62
|
+
state = task.get("state", " ")
|
|
63
|
+
if task.get("verified"):
|
|
64
|
+
state_style = "cyan"
|
|
65
|
+
row_color = "cyan"
|
|
66
|
+
elif task["done"]:
|
|
67
|
+
state_style = "green"
|
|
68
|
+
row_color = "green"
|
|
69
|
+
elif task.get("snoozed"):
|
|
70
|
+
state_style = "magenta"
|
|
71
|
+
row_color = "magenta"
|
|
72
|
+
elif state == "r":
|
|
73
|
+
state_style = "yellow"
|
|
74
|
+
row_color = "yellow"
|
|
75
|
+
elif state == "t":
|
|
76
|
+
state_style = "blue"
|
|
77
|
+
row_color = "blue"
|
|
78
|
+
elif not task.get("deps_met", True):
|
|
79
|
+
state_style = "red"
|
|
80
|
+
row_color = "red"
|
|
81
|
+
else:
|
|
82
|
+
state_style = "white"
|
|
83
|
+
row_color = ""
|
|
84
|
+
|
|
85
|
+
if i == selected_idx:
|
|
86
|
+
select_style = "reverse"
|
|
87
|
+
state_style = f"{state_style} reverse"
|
|
88
|
+
name_style = f"{row_color} reverse" if row_color else "reverse"
|
|
89
|
+
else:
|
|
90
|
+
select_style = ""
|
|
91
|
+
name_style = row_color if row_color else ""
|
|
92
|
+
|
|
93
|
+
task_branch = f"{task['project_slug']}-{task['task_num']}"
|
|
94
|
+
current_marker = "*" if current_branch == task_branch else ""
|
|
95
|
+
|
|
96
|
+
desc = task["desc"]
|
|
97
|
+
table.add_row(
|
|
98
|
+
Text(state_display, style=state_style),
|
|
99
|
+
Text(f" {task_branch}{current_marker}", style=name_style),
|
|
100
|
+
Text(desc, style=select_style),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if show_help:
|
|
104
|
+
help_text = Panel(
|
|
105
|
+
"[bold]Task View:[/bold]\n"
|
|
106
|
+
" j/k Navigate\n"
|
|
107
|
+
" t Vim Task.md\n"
|
|
108
|
+
" p Vim Project.md\n"
|
|
109
|
+
" v View worktree (awt view)\n"
|
|
110
|
+
" s Switch worktree (awt switch)\n"
|
|
111
|
+
" z Snooze/unsnooze\n"
|
|
112
|
+
" c Complete (close)\n"
|
|
113
|
+
" r Release (mark [r])\n"
|
|
114
|
+
" d Delete task\n"
|
|
115
|
+
" V/Z/B/W Toggle verified/snoozed/blocked/working\n"
|
|
116
|
+
" P Project view\n"
|
|
117
|
+
"\n[bold]Project View:[/bold]\n"
|
|
118
|
+
" j/k Navigate\n"
|
|
119
|
+
" n New task\n"
|
|
120
|
+
" p Vim Project.md\n"
|
|
121
|
+
" v Vim Projects.md\n"
|
|
122
|
+
" a Archive project\n"
|
|
123
|
+
" T Task view\n"
|
|
124
|
+
"\n[bold]General:[/bold]\n"
|
|
125
|
+
" ? Toggle help\n"
|
|
126
|
+
" q Quit",
|
|
127
|
+
title="Help",
|
|
128
|
+
border_style="green",
|
|
129
|
+
)
|
|
130
|
+
return Group(table, help_text)
|
|
131
|
+
else:
|
|
132
|
+
status = "[dim]? for help, q to quit[/dim]"
|
|
133
|
+
if show_verified:
|
|
134
|
+
status += " [cyan](+verified)[/cyan]"
|
|
135
|
+
if show_snoozed:
|
|
136
|
+
status += " [magenta](+snoozed)[/magenta]"
|
|
137
|
+
if show_blocked:
|
|
138
|
+
status += " [red](+blocked)[/red]"
|
|
139
|
+
if show_working:
|
|
140
|
+
status += " [yellow](+working)[/yellow]"
|
|
141
|
+
return Group(table, Text.from_markup(status))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_project_display(projects, selected_idx):
|
|
145
|
+
"""Build the project selection display."""
|
|
146
|
+
table = Table(show_header=False, box=None, padding=(0, 0), pad_edge=False)
|
|
147
|
+
table.add_column(width=10)
|
|
148
|
+
table.add_column()
|
|
149
|
+
|
|
150
|
+
section_styles = {
|
|
151
|
+
"Active": "green",
|
|
152
|
+
"Future": "yellow",
|
|
153
|
+
"Archived": "dim",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
current_section = None
|
|
157
|
+
for i, proj in enumerate(projects):
|
|
158
|
+
if proj["section"] != current_section:
|
|
159
|
+
current_section = proj["section"]
|
|
160
|
+
section_display = current_section
|
|
161
|
+
else:
|
|
162
|
+
section_display = ""
|
|
163
|
+
|
|
164
|
+
section_style = section_styles.get(proj["section"], "white")
|
|
165
|
+
|
|
166
|
+
if i == selected_idx:
|
|
167
|
+
row_style = "reverse"
|
|
168
|
+
else:
|
|
169
|
+
row_style = ""
|
|
170
|
+
|
|
171
|
+
table.add_row(
|
|
172
|
+
Text(section_display, style=section_style),
|
|
173
|
+
proj["slug"],
|
|
174
|
+
style=row_style,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
status = "[dim]j/k navigate, n new task, p vim Project.md, v vim Projects.md, a archive, T task view, q quit[/dim]"
|
|
178
|
+
return Group(table, Text.from_markup(status))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main(config: Config):
|
|
182
|
+
console = Console()
|
|
183
|
+
|
|
184
|
+
tasks = get_all_active_tasks(config)
|
|
185
|
+
projects = get_project_list(config)
|
|
186
|
+
|
|
187
|
+
if not tasks and not projects:
|
|
188
|
+
console.print("[yellow]No tasks or projects found[/yellow]")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
view = ['task']
|
|
192
|
+
task_idx = [0]
|
|
193
|
+
proj_idx = [0]
|
|
194
|
+
show_help = False
|
|
195
|
+
show_verified = [False]
|
|
196
|
+
show_snoozed = [False]
|
|
197
|
+
show_blocked = [False]
|
|
198
|
+
show_working = [True]
|
|
199
|
+
current_branch = [get_current_branch(config)]
|
|
200
|
+
last_sync_time = [time.time()]
|
|
201
|
+
|
|
202
|
+
def visible_tasks():
|
|
203
|
+
result = tasks
|
|
204
|
+
if not show_verified[0]:
|
|
205
|
+
result = [t for t in result if not t.get("verified")]
|
|
206
|
+
if not show_snoozed[0]:
|
|
207
|
+
result = [t for t in result if not t.get("snoozed")]
|
|
208
|
+
if not show_blocked[0]:
|
|
209
|
+
result = [t for t in result if t.get("deps_met", True)]
|
|
210
|
+
if not show_working[0]:
|
|
211
|
+
result = [t for t in result if t.get("worktree_status") != "\u23f3"]
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
def current_display():
|
|
215
|
+
if view[0] == 'task':
|
|
216
|
+
return build_display(visible_tasks(), task_idx[0], show_help, show_verified[0], show_snoozed[0], show_blocked[0], show_working[0], current_branch[0])
|
|
217
|
+
else:
|
|
218
|
+
return build_project_display(projects, proj_idx[0])
|
|
219
|
+
|
|
220
|
+
with Live(current_display(), console=console, screen=True, auto_refresh=False) as live:
|
|
221
|
+
try:
|
|
222
|
+
while True:
|
|
223
|
+
key = get_key(timeout=5)
|
|
224
|
+
|
|
225
|
+
if key is None:
|
|
226
|
+
tasks = get_all_active_tasks(config)
|
|
227
|
+
current_branch[0] = get_current_branch(config)
|
|
228
|
+
task_idx[0] = min(task_idx[0], max(0, len(visible_tasks()) - 1))
|
|
229
|
+
if time.time() - last_sync_time[0] >= config.sync_interval:
|
|
230
|
+
subprocess.run(
|
|
231
|
+
[str(config.agent_worktree_script), "sync-master"],
|
|
232
|
+
capture_output=True)
|
|
233
|
+
last_sync_time[0] = time.time()
|
|
234
|
+
live.update(current_display(), refresh=True)
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
if key == "q":
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
elif key == "j" or key == "\x1b":
|
|
241
|
+
if key == "\x1b":
|
|
242
|
+
next1 = sys.stdin.read(1) if sys.stdin else ""
|
|
243
|
+
next2 = sys.stdin.read(1) if sys.stdin else ""
|
|
244
|
+
if next1 == "[" and next2 == "B":
|
|
245
|
+
if view[0] == 'task':
|
|
246
|
+
task_idx[0] = min(task_idx[0] + 1, max(0, len(visible_tasks()) - 1))
|
|
247
|
+
else:
|
|
248
|
+
proj_idx[0] = min(proj_idx[0] + 1, len(projects) - 1)
|
|
249
|
+
elif next1 == "[" and next2 == "A":
|
|
250
|
+
if view[0] == 'task':
|
|
251
|
+
task_idx[0] = max(task_idx[0] - 1, 0)
|
|
252
|
+
else:
|
|
253
|
+
proj_idx[0] = max(proj_idx[0] - 1, 0)
|
|
254
|
+
else:
|
|
255
|
+
if view[0] == 'task':
|
|
256
|
+
task_idx[0] = min(task_idx[0] + 1, max(0, len(visible_tasks()) - 1))
|
|
257
|
+
else:
|
|
258
|
+
proj_idx[0] = min(proj_idx[0] + 1, len(projects) - 1)
|
|
259
|
+
elif key == "k":
|
|
260
|
+
if view[0] == 'task':
|
|
261
|
+
task_idx[0] = max(task_idx[0] - 1, 0)
|
|
262
|
+
else:
|
|
263
|
+
proj_idx[0] = max(proj_idx[0] - 1, 0)
|
|
264
|
+
|
|
265
|
+
elif key == "T":
|
|
266
|
+
view[0] = 'task'
|
|
267
|
+
elif key == "P":
|
|
268
|
+
vis = visible_tasks()
|
|
269
|
+
if vis and task_idx[0] < len(vis):
|
|
270
|
+
current_project = vis[task_idx[0]]["project_slug"]
|
|
271
|
+
for i, p in enumerate(projects):
|
|
272
|
+
if p["slug"] == current_project:
|
|
273
|
+
proj_idx[0] = i
|
|
274
|
+
break
|
|
275
|
+
view[0] = 'project'
|
|
276
|
+
|
|
277
|
+
elif view[0] == 'task':
|
|
278
|
+
if key == "V":
|
|
279
|
+
show_verified[0] = not show_verified[0]
|
|
280
|
+
task_idx[0] = min(task_idx[0], max(0, len(visible_tasks()) - 1))
|
|
281
|
+
elif key == "Z":
|
|
282
|
+
show_snoozed[0] = not show_snoozed[0]
|
|
283
|
+
task_idx[0] = min(task_idx[0], max(0, len(visible_tasks()) - 1))
|
|
284
|
+
elif key == "B":
|
|
285
|
+
show_blocked[0] = not show_blocked[0]
|
|
286
|
+
task_idx[0] = min(task_idx[0], max(0, len(visible_tasks()) - 1))
|
|
287
|
+
elif key == "W":
|
|
288
|
+
show_working[0] = not show_working[0]
|
|
289
|
+
task_idx[0] = min(task_idx[0], max(0, len(visible_tasks()) - 1))
|
|
290
|
+
elif key == "s":
|
|
291
|
+
vis = visible_tasks()
|
|
292
|
+
if vis:
|
|
293
|
+
task = vis[task_idx[0]]
|
|
294
|
+
worktree_name = f"{task['project_slug']}-{task['task_num']}"
|
|
295
|
+
result = subprocess.run(
|
|
296
|
+
["tmux", "display-message", "-p", "#{window_index}"],
|
|
297
|
+
capture_output=True, text=True)
|
|
298
|
+
original_window = result.stdout.strip() if result.returncode == 0 else None
|
|
299
|
+
live.stop()
|
|
300
|
+
subprocess.run(
|
|
301
|
+
[str(config.agent_worktree_script), "switch", worktree_name])
|
|
302
|
+
current_branch[0] = get_current_branch(config)
|
|
303
|
+
if original_window:
|
|
304
|
+
subprocess.run(["tmux", "select-window", "-t", f":{original_window}"])
|
|
305
|
+
live.start()
|
|
306
|
+
elif key == "z":
|
|
307
|
+
vis = visible_tasks()
|
|
308
|
+
if vis:
|
|
309
|
+
task = vis[task_idx[0]]
|
|
310
|
+
if task.get("snoozed"):
|
|
311
|
+
unsnooze_task(task["project_md_path"], task["task_num"])
|
|
312
|
+
else:
|
|
313
|
+
snooze_task(task["project_md_path"], task["task_num"])
|
|
314
|
+
tasks = get_all_active_tasks(config)
|
|
315
|
+
task_idx[0] = min(task_idx[0], max(0, len(visible_tasks()) - 1))
|
|
316
|
+
elif key == "c":
|
|
317
|
+
vis = visible_tasks()
|
|
318
|
+
if vis:
|
|
319
|
+
task = vis[task_idx[0]]
|
|
320
|
+
confirm_text = Text.from_markup(
|
|
321
|
+
f"[bold green]Complete task?[/bold green]\n\n"
|
|
322
|
+
f" {task['project_slug']}-{task['task_num']}: {task['desc']}\n\n"
|
|
323
|
+
f"This will remove the worktree and mark task as done.\n\n"
|
|
324
|
+
f"[bold]Press 'y' to confirm, any other key to cancel[/bold]"
|
|
325
|
+
)
|
|
326
|
+
live.update(confirm_text, refresh=True)
|
|
327
|
+
confirm_key = get_key()
|
|
328
|
+
if confirm_key == "y":
|
|
329
|
+
worktree_name = f"{task['project_slug']}-{task['task_num']}"
|
|
330
|
+
live.stop()
|
|
331
|
+
success = True
|
|
332
|
+
agent_worktree = config.agent_worktree_script
|
|
333
|
+
if agent_worktree.exists() and task["in_worktree"]:
|
|
334
|
+
result = subprocess.run([str(agent_worktree), "rm", worktree_name])
|
|
335
|
+
if result.returncode != 0:
|
|
336
|
+
success = False
|
|
337
|
+
print("\nPress Enter to continue...")
|
|
338
|
+
input()
|
|
339
|
+
if success:
|
|
340
|
+
mark_task_verified(task["project_md_path"], task["task_num"])
|
|
341
|
+
tasks = get_all_active_tasks(config)
|
|
342
|
+
task_idx[0] = min(task_idx[0], max(0, len(visible_tasks()) - 1))
|
|
343
|
+
live.start()
|
|
344
|
+
elif key == "t":
|
|
345
|
+
vis = visible_tasks()
|
|
346
|
+
if vis:
|
|
347
|
+
task = vis[task_idx[0]]
|
|
348
|
+
task_md = ensure_task_md(
|
|
349
|
+
config, task["project_md_path"], task["project_slug"], task["task_num"], task["desc"])
|
|
350
|
+
live.stop()
|
|
351
|
+
subprocess.run([config.editor, str(task_md)])
|
|
352
|
+
tasks = get_all_active_tasks(config)
|
|
353
|
+
task_idx[0] = min(task_idx[0], max(0, len(visible_tasks()) - 1))
|
|
354
|
+
live.start()
|
|
355
|
+
elif key == "p":
|
|
356
|
+
vis = visible_tasks()
|
|
357
|
+
if vis:
|
|
358
|
+
task = vis[task_idx[0]]
|
|
359
|
+
project_md = task["project_md_path"]
|
|
360
|
+
line_num = task["line_num"]
|
|
361
|
+
live.stop()
|
|
362
|
+
subprocess.run([config.editor, f"+{line_num}", str(project_md)])
|
|
363
|
+
tasks = get_all_active_tasks(config)
|
|
364
|
+
task_idx[0] = min(task_idx[0], max(0, len(visible_tasks()) - 1))
|
|
365
|
+
live.start()
|
|
366
|
+
elif key == "v":
|
|
367
|
+
vis = visible_tasks()
|
|
368
|
+
if vis:
|
|
369
|
+
task = vis[task_idx[0]]
|
|
370
|
+
ensure_task_md(config, task["project_md_path"],
|
|
371
|
+
task["project_slug"], task["task_num"], task["desc"])
|
|
372
|
+
worktree_name = f"{task['project_slug']}-{task['task_num']}"
|
|
373
|
+
live.stop()
|
|
374
|
+
subprocess.run(
|
|
375
|
+
[str(config.agent_worktree_script), "view", worktree_name])
|
|
376
|
+
live.start()
|
|
377
|
+
elif key == "r":
|
|
378
|
+
vis = visible_tasks()
|
|
379
|
+
if vis:
|
|
380
|
+
task = vis[task_idx[0]]
|
|
381
|
+
confirm_text = Text.from_markup(
|
|
382
|
+
f"[bold yellow]Release task?[/bold yellow]\n\n"
|
|
383
|
+
f" {task['project_slug']}-{task['task_num']}: {task['desc']}\n\n"
|
|
384
|
+
f"[bold]Press 'y' to confirm, any other key to cancel[/bold]"
|
|
385
|
+
)
|
|
386
|
+
live.update(confirm_text, refresh=True)
|
|
387
|
+
confirm_key = get_key()
|
|
388
|
+
if confirm_key == "y":
|
|
389
|
+
mark_task_released(task["project_md_path"], task["task_num"])
|
|
390
|
+
tasks = get_all_active_tasks(config)
|
|
391
|
+
task_idx[0] = min(task_idx[0], max(0, len(visible_tasks()) - 1))
|
|
392
|
+
elif key == "d":
|
|
393
|
+
vis = visible_tasks()
|
|
394
|
+
if vis:
|
|
395
|
+
task = vis[task_idx[0]]
|
|
396
|
+
confirm_text = Text.from_markup(
|
|
397
|
+
f"[bold red]Delete task?[/bold red]\n\n"
|
|
398
|
+
f" {task['project_slug']}-{task['task_num']}: {task['desc']}\n\n"
|
|
399
|
+
f"This will remove the worktree, Task.md, and task line.\n\n"
|
|
400
|
+
f"[bold]Press 'y' to confirm, any other key to cancel[/bold]"
|
|
401
|
+
)
|
|
402
|
+
live.update(confirm_text, refresh=True)
|
|
403
|
+
confirm_key = get_key()
|
|
404
|
+
if confirm_key == "y":
|
|
405
|
+
live.stop()
|
|
406
|
+
success = delete_task(config, task["project_slug"], task["task_num"])
|
|
407
|
+
if not success:
|
|
408
|
+
print("\nDelete failed. Press Enter to continue...")
|
|
409
|
+
input()
|
|
410
|
+
tasks = get_all_active_tasks(config)
|
|
411
|
+
task_idx[0] = min(task_idx[0], max(0, len(visible_tasks()) - 1))
|
|
412
|
+
live.start()
|
|
413
|
+
elif key == "?":
|
|
414
|
+
show_help = not show_help
|
|
415
|
+
|
|
416
|
+
elif view[0] == 'project':
|
|
417
|
+
if key == "p":
|
|
418
|
+
if projects:
|
|
419
|
+
selected_project = projects[proj_idx[0]]
|
|
420
|
+
project_md = config.projects_dir / selected_project["slug"] / "Project.md"
|
|
421
|
+
if project_md.exists():
|
|
422
|
+
live.stop()
|
|
423
|
+
subprocess.run([config.editor, str(project_md)])
|
|
424
|
+
tasks = get_all_active_tasks(config)
|
|
425
|
+
live.start()
|
|
426
|
+
elif key == "v":
|
|
427
|
+
if projects:
|
|
428
|
+
selected_project = projects[proj_idx[0]]
|
|
429
|
+
line_num = selected_project.get("line_num", 1)
|
|
430
|
+
live.stop()
|
|
431
|
+
subprocess.run([config.editor, f"+{line_num}", str(config.projects_file)])
|
|
432
|
+
projects = get_project_list(config)
|
|
433
|
+
proj_idx[0] = min(proj_idx[0], max(0, len(projects) - 1))
|
|
434
|
+
live.start()
|
|
435
|
+
elif key == "a":
|
|
436
|
+
if projects:
|
|
437
|
+
selected_project = projects[proj_idx[0]]
|
|
438
|
+
move_project_to_section(config.projects_file,
|
|
439
|
+
selected_project["slug"], "Archived")
|
|
440
|
+
projects = get_project_list(config)
|
|
441
|
+
proj_idx[0] = min(proj_idx[0], max(0, len(projects) - 1))
|
|
442
|
+
elif key == "n":
|
|
443
|
+
if projects:
|
|
444
|
+
selected_project = projects[proj_idx[0]]
|
|
445
|
+
project_dir = config.projects_dir / selected_project["slug"]
|
|
446
|
+
project_md = project_dir / "Project.md"
|
|
447
|
+
|
|
448
|
+
live.stop()
|
|
449
|
+
|
|
450
|
+
if not project_md.exists():
|
|
451
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
title = selected_project["slug"].replace("-", " ").title()
|
|
453
|
+
project_md.write_text(f"# {title}\n\n\n\n## Tasks\n1. [ ] \n")
|
|
454
|
+
subprocess.run([config.editor, "+6", str(project_md)])
|
|
455
|
+
new_num = 1
|
|
456
|
+
else:
|
|
457
|
+
new_num, line_num = add_task_to_project(project_md)
|
|
458
|
+
if new_num and line_num:
|
|
459
|
+
subprocess.run([config.editor, f"+{line_num}", str(project_md)])
|
|
460
|
+
|
|
461
|
+
if new_num:
|
|
462
|
+
new_tasks = parse_tasks(config, project_md, selected_project["slug"], "")
|
|
463
|
+
task_desc = new_tasks.get(new_num, {}).get("desc", "")
|
|
464
|
+
if task_desc:
|
|
465
|
+
activate_task(config, selected_project["slug"], new_num, task_desc)
|
|
466
|
+
|
|
467
|
+
tasks = get_all_active_tasks(config)
|
|
468
|
+
|
|
469
|
+
view[0] = 'task'
|
|
470
|
+
for i, t in enumerate(visible_tasks()):
|
|
471
|
+
if t["project_slug"] == selected_project["slug"] and t["task_num"] == new_num:
|
|
472
|
+
task_idx[0] = i
|
|
473
|
+
break
|
|
474
|
+
|
|
475
|
+
live.start()
|
|
476
|
+
|
|
477
|
+
live.update(current_display(), refresh=True)
|
|
478
|
+
|
|
479
|
+
except KeyboardInterrupt:
|
|
480
|
+
pass
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/project_manager_tui/__init__.py
|
|
3
|
+
src/project_manager_tui/cli.py
|
|
4
|
+
src/project_manager_tui/config.py
|
|
5
|
+
src/project_manager_tui/core.py
|
|
6
|
+
src/project_manager_tui/tui.py
|
|
7
|
+
src/project_manager_tui.egg-info/PKG-INFO
|
|
8
|
+
src/project_manager_tui.egg-info/SOURCES.txt
|
|
9
|
+
src/project_manager_tui.egg-info/dependency_links.txt
|
|
10
|
+
src/project_manager_tui.egg-info/entry_points.txt
|
|
11
|
+
src/project_manager_tui.egg-info/requires.txt
|
|
12
|
+
src/project_manager_tui.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
project_manager_tui
|