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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: project-manager-tui
3
+ Version: 0.1.0
4
+ Summary: Rich-based TUI for managing projects/tasks with git worktrees
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: rich>=13.0
7
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: project-manager-tui
3
+ Version: 0.1.0
4
+ Summary: Rich-based TUI for managing projects/tasks with git worktrees
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: rich>=13.0
7
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
@@ -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,2 @@
1
+ [console_scripts]
2
+ pm-tui = project_manager_tui.cli:main
@@ -0,0 +1,4 @@
1
+ rich>=13.0
2
+
3
+ [:python_version < "3.11"]
4
+ tomli>=2.0
@@ -0,0 +1 @@
1
+ project_manager_tui